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
16 changes: 16 additions & 0 deletions .github/readme/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ npm run smoke:visual

`smoke:visual` additionally captures the runtime smoke views into a temporary directory reported by the script. Passing static checks alone does not prove packaged Windows/Linux behavior.

The Electron smoke also exercises the real sandboxed preload and main-process account handlers with an isolated in-memory database. It verifies encrypted batch import, TOTP generation, invalid-secret rejection, database rollback after a later insert fails, and account cleanup. It does not access the system clipboard or the user's vault.

### Account input and import boundaries

- Creation, secret updates and backup payloads share canonical Base32 validation. Spaces, hyphens, case and valid padding are normalized; invalid characters, padding and trailing bits are rejected before storage.
- A legacy unreadable account remains visible with an unavailable-code state. Its failure does not prevent healthy accounts from generating or copying codes.
- Google Authenticator migration QR input is bounded to 128 KiB and 100 accounts. Truncated protobuf data, overflowing lengths, unknown algorithms/digit counts and non-TOTP accounts are rejected before the confirmation preview.
- The named `importAccounts` bridge validates both sides of IPC, checks the renderer origin and PIN lock, and rechecks the lock after encryption. Every selected account is inserted in one SQLite transaction; any failure leaves the batch unsaved.
- These changes do not migrate the database or delete existing accounts. A malformed legacy secret must be replaced from the original provider or the affected account removed.

### Lock lifecycle and backup recovery

`LockService` owns a five-minute inactivity deadline and a session generation. Main checks the deadline every second, incorporates OS idle time, and locks on system lock or suspend. The renderer reports activity through a narrow validated method and subscribes to validated status events. Sensitive views are unmounted while locked and remounted after PIN verification; pending operations recheck their captured generation before delivering codes or writing data.

Backup payload version 2 includes groups and restores all groups/accounts in one transaction after encryption succeeds. Version 1 remains supported; missing legacy group metadata produces an explicit ungrouped-account notice. Failed encryption, expired sessions or database errors abort the restore instead of returning partial success. The AES-GCM envelope and PBKDF2 settings are unchanged, and version 2 payloads require Authly 0.3.11 or newer to import.

## Packaging

Windows NSIS installer:
Expand Down
2 changes: 2 additions & 0 deletions .github/readme/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ try {
onUpdateState: () => () => {},
copyTotp: async () => {},
lockApp: async () => {},
recordActivity: async () => {},
onLockStatus: () => () => {},
};
},
{ theme, locked },
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog

## 0.3.11

- Validate and normalize Base32 account keys before storage; isolate unreadable legacy accounts so healthy codes remain available.
- Reject malformed migration QR data and unsupported HOTP accounts; save selected QR accounts atomically.
- Reload accounts after PIN unlock and clear sensitive views when the application locks.
- Enforce inactivity locking in main, lock on system lock/suspend, and revoke pending sensitive operations across lock sessions.
- Restore grouped accounts and backups atomically; continue reading legacy backups and report missing group metadata without skipping accounts.
- Add regression coverage and real Electron account/lock/unlock smoke checks.
16 changes: 16 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Security

To report a potential vulnerability, use [GitHub private vulnerability reporting](https://github.com/ardakrt/authly/security/advisories/new) when available. Otherwise, open an issue requesting a private contact without including account secrets, OTP codes, PINs, passwords or backup contents.

## Boundaries

- Electron main owns database access, OS-backed secret encryption and TOTP generation. The sandboxed renderer receives safe metadata and display codes through named IPC methods; stored secrets are not returned by account-list or import responses.
- Account writes validate Base32 keys. QR migration parsing bounds input and rejects malformed or unsupported data. Selected QR accounts are encrypted before one atomic database transaction, with PIN lock checks before and after asynchronous encryption.
- Corrupt or unreadable legacy accounts remain in the vault. Code generation isolates their failure so healthy accounts remain usable. Unavailable codes cannot be copied from the interface.
- The application PIN gates access; it is not an independent encryption key for the vault. An unlocked OS session or compromised operating system is outside this protection boundary.
- Main enforces the inactivity timeout (five minutes by default), checks OS idle time, and locks on system lock/suspend. Renderer activity cannot unlock an expired session. A lock invalidates captured sessions so pending code delivery, clipboard writes, account writes and backup operations cannot continue under a later unlock.
- The renderer subscribes to validated lock-state events and unmounts account and form state while locked. It loads accounts again on unlock and fails closed if the initial lock status cannot be checked.
- New encrypted backups use a version 2 payload containing groups and accounts; the encryption envelope and KDF remain unchanged. Restore is transactional across both tables. Version 1 backups remain readable; accounts whose old backups lack group metadata are restored ungrouped with an explicit notice. No account is silently skipped on an import failure.
- TOTP is generated offline. Update operations contact GitHub, and the stylesheet currently references Google Fonts. See the README for platform-specific storage and backup details.

Never attach a real vault, unencrypted backup, raw QR content, secret key or generated code to an issue or log. Automated tests and runtime smoke use synthetic fixtures and isolated storage.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "authapp",
"version": "0.3.10",
"version": "0.3.11",
"private": true,
"description": "Cross-platform, offline desktop authenticator for Windows and Linux",
"desktopName": "Authly",
Expand Down
10 changes: 10 additions & 0 deletions src/main/database/repositories/AccountRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ export class AccountRepository {
);
}

createMany(records: AccountRecord[]): void {
this.database.transaction(() => {
for (const record of records) this.create(record);
})();
}

transaction<T>(operation: () => T): T {
return this.database.transaction(operation)();
}

update(record: AccountRecord): void {
const result = this.database
.prepare(
Expand Down
24 changes: 21 additions & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { join } from 'node:path';
import { app, BrowserWindow, protocol } from 'electron';
import { app, BrowserWindow, protocol, powerMonitor } from 'electron';
import { LocalDatabase } from './database/LocalDatabase';
import { AccountRepository } from './database/repositories/AccountRepository';
import { GroupRepository } from './database/repositories/GroupRepository';
import { SettingsRepository } from './database/repositories/SettingsRepository';
import { registerAppHandlers, removeAppHandlers } from './ipc/registerAppHandlers';
import { ElectronSafeStorageVault } from './security/ElectronSafeStorageVault';
Expand All @@ -14,7 +15,7 @@ import { LockService } from './services/LockService';
import { UpdateService } from './services/UpdateService';
import { createMainWindow } from './window/createMainWindow';
import { TrayService } from './window/TrayService';
import { UPDATE_STATE_EVENT } from '@shared/ipc/channels';
import { UPDATE_STATE_EVENT, LOCK_STATUS_EVENT } from '@shared/ipc/channels';

protocol.registerSchemesAsPrivileged([
{
Expand Down Expand Up @@ -44,6 +45,7 @@ if (!hasSingleInstanceLock) app.quit();
const developmentUrl = process.env['ELECTRON_RENDERER_URL'];
let localDatabase: LocalDatabase | undefined;
let trayService: TrayService | undefined;
let lockTimer: ReturnType<typeof setInterval> | undefined;

app.whenReady().then(async () => {
if (!developmentUrl) registerLocalProtocol(join(__dirname, '../renderer'));
Expand All @@ -60,10 +62,25 @@ app.whenReady().then(async () => {
new AccountRepository(localDatabase.connection),
vault,
totpService,
new GroupRepository(localDatabase.connection),
);
const settingsService = new SettingsService(new SettingsRepository(localDatabase.connection));
const backupService = new BackupService(accountService);
const lockService = new LockService(new SettingsRepository(localDatabase.connection));
const lockService = new LockService(
new SettingsRepository(localDatabase.connection),
Date.now,
// Isolated smoke has no physical user input; OS-idle behavior is covered by service tests.
() => (isSmokeTest ? 0 : powerMonitor.getSystemIdleTime()),
);
lockService.onStatusChange((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(LOCK_STATUS_EVENT, status);
}
});
lockTimer = setInterval(() => lockService.checkAutoLock(), 1000);
lockTimer.unref();
powerMonitor.on('lock-screen', () => lockService.lockApp());
powerMonitor.on('suspend', () => lockService.lockApp());
const updateService = new UpdateService({
beforeInstall: () => trayService?.setQuitting(true),
});
Expand Down Expand Up @@ -103,6 +120,7 @@ app.on('second-instance', () => {
});

app.on('window-all-closed', () => {
if (lockTimer) clearInterval(lockTimer);
removeAppHandlers();
trayService?.destroy();
localDatabase?.close();
Expand Down
59 changes: 44 additions & 15 deletions src/main/ipc/registerAppHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { IPC_CHANNELS } from '@shared/ipc/channels';
import {
accountDtoSchema,
createAccountRequestSchema,
importAccountsRequestSchema,
deleteAccountRequestSchema,
listAccountsRequestSchema,
updateAccountRequestSchema,
Expand Down Expand Up @@ -39,6 +40,7 @@ import {
} from '@shared/schemas/settings';
import {
lockStatusSchema,
lockActivityRequestSchema,
removePinRequestSchema,
setPinRequestSchema,
verifyPinRequestSchema,
Expand Down Expand Up @@ -92,21 +94,34 @@ export function registerAppHandlers(
ipcMain.handle(IPC_CHANNELS.createAccount, async (event, rawRequest: unknown) => {
try {
assertTrustedSender(event, developmentUrl);
lockService.assertNotLocked();
const assertAccess = lockService.captureSession();
return accountDtoSchema.parse(
await accountService.create(createAccountRequestSchema.parse(rawRequest)),
await accountService.create(createAccountRequestSchema.parse(rawRequest), assertAccess),
);
} catch {
throw new Error('Hesap güvenli biçimde kaydedilemedi.');
}
});

ipcMain.handle(IPC_CHANNELS.importAccounts, async (event, rawRequest: unknown) => {
try {
assertTrustedSender(event, developmentUrl);
const assertAccess = lockService.captureSession();
const request = importAccountsRequestSchema.parse(rawRequest);
return accountDtoSchema
.array()
.parse(await accountService.importAccounts(request, assertAccess));
} catch {
throw new Error('Hesaplar içe aktarılamadı. Hiçbir hesap kaydedilmedi.');
}
});

ipcMain.handle(IPC_CHANNELS.updateAccount, async (event, rawRequest: unknown) => {
try {
assertTrustedSender(event, developmentUrl);
lockService.assertNotLocked();
const assertAccess = lockService.captureSession();
return accountDtoSchema.parse(
await accountService.update(updateAccountRequestSchema.parse(rawRequest)),
await accountService.update(updateAccountRequestSchema.parse(rawRequest), assertAccess),
);
} catch {
throw new Error('Hesap güvenli biçimde güncellenemedi.');
Expand All @@ -126,9 +141,11 @@ export function registerAppHandlers(
ipcMain.handle(IPC_CHANNELS.getTotpCodes, async (event, rawRequest: unknown) => {
try {
assertTrustedSender(event, developmentUrl);
lockService.assertNotLocked();
const assertAccess = lockService.captureSession();
getTotpCodesRequestSchema.parse(rawRequest);
return totpCodeSchema.array().parse(await accountService.getTotpCodes());
const codes = await accountService.getTotpCodes();
assertAccess();
return totpCodeSchema.array().parse(codes);
} catch {
throw new Error('Kodlar güvenli biçimde üretilemedi.');
}
Expand All @@ -147,12 +164,10 @@ export function registerAppHandlers(
ipcMain.handle(IPC_CHANNELS.copyTotp, async (event, rawRequest: unknown) => {
try {
assertTrustedSender(event, developmentUrl);
lockService.assertNotLocked();
const assertAccess = lockService.captureSession();
const { accountId } = copyTotpRequestSchema.parse(rawRequest);
const value = (await accountService.getTotpCodes()).find(
(item) => item.accountId === accountId,
);
if (!value) throw new Error('Account not found.');
const value = await accountService.getTotpCode(accountId);
assertAccess();
clipboard.writeText(value.code);
setTimeout(() => {
if (clipboard.readText() === value.code) clipboard.clear();
Expand All @@ -165,9 +180,11 @@ export function registerAppHandlers(
ipcMain.handle(IPC_CHANNELS.exportBackup, async (event, rawRequest: unknown) => {
try {
assertTrustedSender(event, developmentUrl);
lockService.assertNotLocked();
const assertAccess = lockService.captureSession();
const { password } = exportBackupRequestSchema.parse(rawRequest);
return exportBackupResultSchema.parse(await backupService.exportBackup(password));
const result = await backupService.exportBackup(password, assertAccess);
assertAccess();
return exportBackupResultSchema.parse(result);
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Yedek dışa aktarılamadı.', {
cause: err,
Expand All @@ -178,9 +195,11 @@ export function registerAppHandlers(
ipcMain.handle(IPC_CHANNELS.importBackup, async (event, rawRequest: unknown) => {
try {
assertTrustedSender(event, developmentUrl);
lockService.assertNotLocked();
const assertAccess = lockService.captureSession();
const { password } = importBackupRequestSchema.parse(rawRequest);
return importBackupResultSchema.parse(await backupService.importBackup(password));
const result = await backupService.importBackup(password, assertAccess);
assertAccess();
return importBackupResultSchema.parse(result);
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Yedek içe aktarılamadı.', {
cause: err,
Expand Down Expand Up @@ -256,6 +275,16 @@ export function registerAppHandlers(
}
});

ipcMain.handle(IPC_CHANNELS.recordActivity, (event, rawRequest: unknown) => {
try {
assertTrustedSender(event, developmentUrl);
lockActivityRequestSchema.parse(rawRequest);
lockService.recordActivity();
} catch {
throw new Error('Kilit oturumu yenilenemedi.');
}
});

ipcMain.handle(IPC_CHANNELS.checkUpdate, async (event, rawRequest: unknown) => {
try {
assertTrustedSender(event, developmentUrl);
Expand Down
Loading
Loading