Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,37 @@ 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
with:
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."
89 changes: 62 additions & 27 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
name: Release
name: Release

on:
push:
tags:
- '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

Expand All @@ -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
40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# 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

- **RFC 6238 TOTP Generation**: Offline 6-digit dynamic passcode generator with real-time countdown progress.
- **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.
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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

Expand Down
32 changes: 30 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ardakartaskisisel@gmail.com>",
"repository": {
"type": "git",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down
26 changes: 20 additions & 6 deletions src/main/security/ElectronSafeStorageVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer> {
if (!(await safeStorage.isAsyncEncryptionAvailable())) {
throw new Error('Secure OS encryption is unavailable.');
}
await this.assertSecureStorageAvailable();
return safeStorage.encryptStringAsync(secret);
}

async decryptSecret(encryptedSecret: Buffer): Promise<VaultDecryptResult> {
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,
Expand All @@ -21,4 +19,20 @@ export class ElectronSafeStorageVault implements VaultService {
: {}),
};
}

private async assertSecureStorageAvailable(): Promise<void> {
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.',
);
}
}
}
2 changes: 1 addition & 1 deletion src/renderer/src/utils/qrDecoder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import jsQR from 'jsQR';
import jsQR from 'jsqr';
import type { CreateAccountRequest } from '@shared/schemas/account';
import { parseOtpMigrationUri } from './otpMigrationParser';

Expand Down
Loading