diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f38bae5..7ae5b77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,15 +2,19 @@ name: CI on: push: - branches: ['**'] + branches: [master] pull_request: permissions: contents: read jobs: - verify: - runs-on: windows-latest + verify-platform: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -18,4 +22,17 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npm run verify + - name: Verify on Windows + if: runner.os == 'Windows' + run: npm run verify + - name: Verify on Linux + if: runner.os == 'Linux' + env: + ELECTRON_DISABLE_SANDBOX: '1' + run: xvfb-run --auto-servernum npm run verify + + verify: + needs: verify-platform + runs-on: ubuntu-latest + steps: + - run: echo "Windows and Linux verification passed." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b989d5..1a3afc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Release on: push: @@ -6,11 +6,19 @@ on: - 'v*' permissions: - contents: write + contents: read jobs: - release: - runs-on: windows-latest + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + platform: windows + - os: ubuntu-latest + platform: linux + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -22,34 +30,61 @@ jobs: - name: Install dependencies run: npm ci - - name: Verify project - run: npm run format:check && npm run lint && npm run test && npm run build + - name: Verify project on Windows + if: runner.os == 'Windows' + run: npm run verify - - name: Build Windows Executable + - name: Verify project on Linux + if: runner.os == 'Linux' + env: + ELECTRON_DISABLE_SANDBOX: '1' + run: xvfb-run --auto-servernum npm run verify + + - name: Build Windows installer + if: runner.os == 'Windows' run: npx electron-builder --win nsis --x64 --publish never - - name: Generate SHA-256 checksum - shell: pwsh - run: | - $installer = Get-ChildItem -LiteralPath dist -Filter '*.exe' | Select-Object -First 1 - if (-not $installer) { throw 'Windows installer was not created.' } - $hash = (Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash $($installer.Name)" | Set-Content -LiteralPath dist/SHA256SUMS.txt -Encoding ascii + - name: Build Linux packages + if: runner.os == 'Linux' + run: npx electron-builder --linux AppImage deb --x64 --publish never + + - name: Upload packaged artifacts + uses: actions/upload-artifact@v4 + with: + name: authly-${{ matrix.platform }} + path: | + dist/*.exe + dist/*.exe.blockmap + dist/*.AppImage + dist/*.AppImage.blockmap + dist/*.deb + dist/latest.yml + dist/latest-linux.yml + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: authly-* + path: dist + merge-multiple: true + + - name: Generate SHA-256 checksums + working-directory: dist + run: sha256sum * > SHA256SUMS.txt - - name: Create GitHub Release and upload verified artifacts - shell: pwsh + - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} run: | - $installer = Get-ChildItem -LiteralPath dist -Filter '*.exe' | Select-Object -First 1 - $blockmap = Get-ChildItem -LiteralPath dist -Filter '*.exe.blockmap' | Select-Object -First 1 - $metadata = Get-Item -LiteralPath dist/latest.yml - if (-not $blockmap) { throw 'Windows update blockmap was not created.' } - gh release create "${{ github.ref_name }}" ` - $installer.FullName ` - $blockmap.FullName ` - $metadata.FullName ` - dist/SHA256SUMS.txt ` - --verify-tag ` - --title "${{ github.ref_name }}" ` + gh release create "${{ github.ref_name }}" \ + dist/* \ + --repo "${{ github.repository }}" \ + --verify-tag \ + --title "${{ github.ref_name }}" \ --generate-notes diff --git a/README.md b/README.md index f6326e9..06713ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Authly -Authly is a Windows-first, offline desktop authenticator built with Electron, React, TypeScript, Vite, SQLite, and DPAPI OS-level encryption. It provides a secure, lightweight alternative to cloud-connected authenticator apps, keeping all multi-factor authentication (2FA/TOTP) secrets strictly local on your device. +Authly is a cross-platform, offline desktop authenticator for Windows and Linux, built with Electron, React, TypeScript, Vite, and SQLite. It provides a secure, lightweight alternative to cloud-connected authenticator apps, keeping all multi-factor authentication (2FA/TOTP) secrets strictly local on your device. ## Features @@ -8,7 +8,7 @@ Authly is a Windows-first, offline desktop authenticator built with Electron, Re - **Account Management**: Add accounts manually via secret keys, otpauth:// URIs, or scanning QR images. - **Master PIN Security**: Protect app startup and sensitive account data with an encrypted Master PIN lock. - **Encrypted Backup & Recovery**: Export and import full account backups protected with PBKDF2 + AES-256-GCM encryption. -- **System Tray & Hotkeys**: Minimize to Windows system tray, quick clipboard copy with auto-clipboard clear after 15 seconds. +- **System Tray & Hotkeys**: Minimize to the system tray, quick clipboard copy with auto-clipboard clear after 15 seconds. - **Theme & Appearance**: System, dark, and light visual modes built with custom UI tokens and clean accessibility. - **GitHub Release Update Check**: In-app one-click update checking against official GitHub releases. - **Isolated Renderer Security**: Strict IPC sandboxing, custom authapp:// protocol, zero raw Node/filesystem access in renderer. @@ -18,13 +18,14 @@ Authly is a Windows-first, offline desktop authenticator built with Electron, Re Authly is designed with a local-first security architecture: - **Offline Operating Mode**: Secrets and database files never leave your computer. -- **OS-Level Safe Storage**: TOTP secrets are encrypted using Windows Data Protection API (DPAPI). +- **OS-Level Safe Storage**: TOTP secrets are encrypted using Windows DPAPI or a Linux desktop keyring (GNOME Keyring/KWallet). - **Auto-Clipboard Clean**: Copied 2FA codes are automatically wiped from system clipboard after 15 seconds. - **Zero Analytics**: No telemetry, tracking, or cloud backend services. ## Requirements -- Windows 10/11 (x64) +- Windows 10/11 (x64), or a modern x64 Linux distribution +- Linux: an installed and unlocked GNOME Keyring or KWallet service - Node.js 20.19 or newer - npm 11 or newer @@ -48,13 +49,13 @@ npm run dev Run full quality gates (formatting, linting, tests, strict TypeScript build, and visual/runtime smoke testing): -```powershell +```bash npm run verify ``` Run individual quality checks: -```powershell +```bash npm run typecheck npm run lint npm test @@ -66,15 +67,36 @@ npm run smoke Package the standalone Windows NSIS installer: -```powershell +```bash npm run package:win ``` -Installers and packaged outputs are generated in the `dist/` directory. +Package the Linux AppImage and Debian package: + +```bash +npm run package:linux +``` + +Installers and packaged outputs are generated in the `dist/` directory. The AppImage runs on most modern Linux distributions; the `.deb` package targets Debian, Ubuntu, and their derivatives. + +To run the AppImage: + +```bash +chmod +x Authly-*.AppImage +./Authly-*.AppImage +``` + +To install the Debian package: + +```bash +sudo apt install ./Authly-*.deb +``` ## Local Data -Runtime data (SQLite database, encrypted Vault secrets, application settings) is stored locally in Windows `%APPDATA%\authapp` and is excluded from Git. Do not commit personal backups, local database files, or master PIN hashes. +Runtime data (SQLite database, encrypted Vault secrets, application settings) is stored in Electron's per-user application-data directory (`%APPDATA%\authapp` on Windows and usually `~/.config/authapp` on Linux) and is excluded from Git. Do not commit personal backups, local database files, or master PIN hashes. + +On Linux, Authly deliberately refuses to store new secrets when Electron falls back to its insecure `basic_text` backend. Start and unlock GNOME Keyring or KWallet before using the app. ## License diff --git a/package.json b/package.json index 7a6cf80..86b3b76 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "authapp", "version": "0.3.9", "private": true, - "description": "Windows-first, offline desktop authenticator", + "description": "Cross-platform, offline desktop authenticator for Windows and Linux", + "desktopName": "Authly", "author": "Arda Kartal ", "repository": { "type": "git", @@ -27,7 +28,9 @@ "smoke": "node scripts/smoke.mjs", "smoke:visual": "node scripts/smoke.mjs --visual", "verify": "npm run format:check && npm run lint && npm run test && npm run build && npm run smoke", - "package:win": "npm run verify && electron-builder --win nsis --x64" + "package:win": "npm run verify && electron-builder --win nsis --x64", + "package:linux": "npm run verify && electron-builder --linux AppImage deb --x64", + "package:linux:fast": "npm run build && electron-builder --linux AppImage deb --x64" }, "dependencies": { "@fontsource/ibm-plex-sans": "5.3.0", @@ -104,6 +107,31 @@ } ] }, + "linux": { + "icon": "resources/icon.png", + "category": "Utility;Security", + "executableName": "authly", + "syncDesktopName": true, + "artifactName": "Authly-${version}-${arch}.${ext}", + "target": [ + { + "target": "AppImage", + "arch": [ + "x64" + ] + }, + { + "target": "deb", + "arch": [ + "x64" + ] + } + ] + }, + "deb": { + "packageCategory": "utils", + "priority": "optional" + }, "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, diff --git a/src/main/index.ts b/src/main/index.ts index 5ef0a29..dae6492 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -33,6 +33,7 @@ app.commandLine.appendSwitch('js-flags', '--max-old-space-size=128'); const isSmokeTest = process.env['AUTHAPP_SMOKE_TEST'] === '1'; if (isSmokeTest) { + if (process.platform === 'linux') app.commandLine.appendSwitch('password-store', 'basic'); const smokeUserData = join(app.getPath('temp'), `authapp-smoke-userdata-${process.pid}`); app.setPath('userData', smokeUserData); } @@ -48,7 +49,7 @@ app.whenReady().then(async () => { if (!developmentUrl) registerLocalProtocol(join(__dirname, '../renderer')); const databasePath = isSmokeTest ? ':memory:' : join(app.getPath('userData'), 'authapp.db'); localDatabase = new LocalDatabase(databasePath); - const vault = new ElectronSafeStorageVault(); + const vault = new ElectronSafeStorageVault(isSmokeTest); if (isSmokeTest) { const encrypted = await vault.encryptSecret('runtime-smoke-value'); const decrypted = await vault.decryptSecret(encrypted); diff --git a/src/main/security/ElectronSafeStorageVault.ts b/src/main/security/ElectronSafeStorageVault.ts index bef358a..ad1c6ed 100644 --- a/src/main/security/ElectronSafeStorageVault.ts +++ b/src/main/security/ElectronSafeStorageVault.ts @@ -2,17 +2,15 @@ import { safeStorage } from 'electron'; import type { VaultDecryptResult, VaultService } from './VaultService'; export class ElectronSafeStorageVault implements VaultService { + constructor(private readonly allowInsecureLinuxBackend = false) {} + async encryptSecret(secret: string): Promise { - if (!(await safeStorage.isAsyncEncryptionAvailable())) { - throw new Error('Secure OS encryption is unavailable.'); - } + await this.assertSecureStorageAvailable(); return safeStorage.encryptStringAsync(secret); } async decryptSecret(encryptedSecret: Buffer): Promise { - if (!(await safeStorage.isAsyncEncryptionAvailable())) { - throw new Error('Secure OS encryption is unavailable.'); - } + await this.assertSecureStorageAvailable(); const result = await safeStorage.decryptStringAsync(encryptedSecret); return { secret: result.result, @@ -21,4 +19,20 @@ export class ElectronSafeStorageVault implements VaultService { : {}), }; } + + private async assertSecureStorageAvailable(): Promise { + if (!(await safeStorage.isAsyncEncryptionAvailable())) { + throw new Error('Secure OS encryption is unavailable.'); + } + + if ( + process.platform === 'linux' && + !this.allowInsecureLinuxBackend && + safeStorage.getSelectedStorageBackend() === 'basic_text' + ) { + throw new Error( + 'Secure OS keyring is unavailable. Install and unlock GNOME Keyring or KWallet.', + ); + } + } } diff --git a/src/renderer/src/utils/qrDecoder.ts b/src/renderer/src/utils/qrDecoder.ts index 7d3cbbc..7bbf315 100644 --- a/src/renderer/src/utils/qrDecoder.ts +++ b/src/renderer/src/utils/qrDecoder.ts @@ -1,4 +1,4 @@ -import jsQR from 'jsQR'; +import jsQR from 'jsqr'; import type { CreateAccountRequest } from '@shared/schemas/account'; import { parseOtpMigrationUri } from './otpMigrationParser';