diff --git a/.github/readme/DEVELOPMENT.md b/.github/readme/DEVELOPMENT.md index 699c5d2..dc30855 100644 --- a/.github/readme/DEVELOPMENT.md +++ b/.github/readme/DEVELOPMENT.md @@ -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: diff --git a/.github/readme/capture.mjs b/.github/readme/capture.mjs index 0860d6b..b3837cb 100644 --- a/.github/readme/capture.mjs +++ b/.github/readme/capture.mjs @@ -81,6 +81,8 @@ try { onUpdateState: () => () => {}, copyTotp: async () => {}, lockApp: async () => {}, + recordActivity: async () => {}, + onLockStatus: () => () => {}, }; }, { theme, locked }, diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..48861f0 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b096851 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/package-lock.json b/package-lock.json index e4d5919..b725823 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "authapp", - "version": "0.3.10", + "version": "0.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "authapp", - "version": "0.3.10", + "version": "0.3.11", "hasInstallScript": true, "dependencies": { "@fontsource/ibm-plex-sans": "5.3.0", diff --git a/package.json b/package.json index ce135c7..b616cd4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/database/repositories/AccountRepository.ts b/src/main/database/repositories/AccountRepository.ts index 68e525b..b3ea2b5 100644 --- a/src/main/database/repositories/AccountRepository.ts +++ b/src/main/database/repositories/AccountRepository.ts @@ -83,6 +83,16 @@ export class AccountRepository { ); } + createMany(records: AccountRecord[]): void { + this.database.transaction(() => { + for (const record of records) this.create(record); + })(); + } + + transaction(operation: () => T): T { + return this.database.transaction(operation)(); + } + update(record: AccountRecord): void { const result = this.database .prepare( diff --git a/src/main/index.ts b/src/main/index.ts index dae6492..c443cd7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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'; @@ -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([ { @@ -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 | undefined; app.whenReady().then(async () => { if (!developmentUrl) registerLocalProtocol(join(__dirname, '../renderer')); @@ -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), }); @@ -103,6 +120,7 @@ app.on('second-instance', () => { }); app.on('window-all-closed', () => { + if (lockTimer) clearInterval(lockTimer); removeAppHandlers(); trayService?.destroy(); localDatabase?.close(); diff --git a/src/main/ipc/registerAppHandlers.ts b/src/main/ipc/registerAppHandlers.ts index 1bd8d10..9ab7a17 100644 --- a/src/main/ipc/registerAppHandlers.ts +++ b/src/main/ipc/registerAppHandlers.ts @@ -11,6 +11,7 @@ import { IPC_CHANNELS } from '@shared/ipc/channels'; import { accountDtoSchema, createAccountRequestSchema, + importAccountsRequestSchema, deleteAccountRequestSchema, listAccountsRequestSchema, updateAccountRequestSchema, @@ -39,6 +40,7 @@ import { } from '@shared/schemas/settings'; import { lockStatusSchema, + lockActivityRequestSchema, removePinRequestSchema, setPinRequestSchema, verifyPinRequestSchema, @@ -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.'); @@ -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.'); } @@ -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(); @@ -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, @@ -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, @@ -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); diff --git a/src/main/services/AccountService.ts b/src/main/services/AccountService.ts index 39793b7..e233a45 100644 --- a/src/main/services/AccountService.ts +++ b/src/main/services/AccountService.ts @@ -2,13 +2,21 @@ import { randomUUID } from 'node:crypto'; import type { AccountDto, CreateAccountRequest, + ImportAccountsRequest, UpdateAccountRequest, } from '@shared/schemas/account'; -import { accountDtoSchema } from '@shared/schemas/account'; +import { + accountDtoSchema, + createAccountRequestSchema, + importAccountsRequestSchema, + updateAccountRequestSchema, +} from '@shared/schemas/account'; import { AccountRepository, type AccountRecord } from '../database/repositories/AccountRepository'; import type { VaultService } from '../security/VaultService'; import type { TotpCode } from '@shared/schemas/totp'; import type { TotpService } from './TotpService'; +import { GroupRepository, type GroupRecord } from '../database/repositories/GroupRepository'; +import { backupPayloadSchema, type BackupPayload } from '@shared/schemas/backup'; function toDto(record: AccountRecord): AccountDto { return accountDtoSchema.parse({ @@ -31,6 +39,7 @@ export class AccountService { private readonly repository: AccountRepository, private readonly vault: VaultService, private readonly totpService?: TotpService, + private readonly groupRepository?: GroupRepository, ) {} list(): AccountDto[] { @@ -39,23 +48,16 @@ export class AccountService { async getTotpCodes(): Promise { if (!this.totpService) throw new Error('TOTP service unavailable.'); - return Promise.all( - this.repository.list().map(async (account) => { - const decrypted = await this.vault.decryptSecret(account.encryptedSecret); - if (decrypted.reEncrypted) { - this.repository.updateEncryptedSecret(account.id, decrypted.reEncrypted); - } - return { - accountId: account.id, - ...this.totpService!.generate( - decrypted.secret, - account.algorithm, - account.digits, - account.period, - ), - }; - }), + const results = await Promise.allSettled( + this.repository.list().map((account) => this.getTotpCode(account.id)), ); + return results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])); + } + + async getTotpCode(id: string): Promise { + if (!this.totpService) throw new Error('TOTP service unavailable.'); + const { secret, algorithm, digits, period } = await this.getTotpSecret(id); + return { accountId: id, ...this.totpService.generate(secret, algorithm, digits, period) }; } async getTotpSecret(id: string): Promise<{ @@ -78,7 +80,11 @@ export class AccountService { }; } - async create(input: CreateAccountRequest): Promise { + async create( + input: CreateAccountRequest, + assertCanWrite: () => void = () => {}, + ): Promise { + input = createAccountRequestSchema.parse(input); const now = Date.now(); const record: AccountRecord = { id: randomUUID(), @@ -94,11 +100,105 @@ export class AccountService { createdAt: now, updatedAt: now, }; + const dto = toDto(record); + assertCanWrite(); this.repository.create(record); - return toDto(record); + return dto; + } + + async importAccounts( + input: ImportAccountsRequest, + assertCanWrite: () => void = () => {}, + ): Promise { + const { accounts } = importAccountsRequestSchema.parse(input); + const now = Date.now(); + const sortOrder = this.repository.list().length; + // Finish every asynchronous encryption before opening the synchronous SQLite transaction. + const records = await Promise.all( + accounts.map(async ({ secret, ...metadata }, index): Promise => ({ + ...metadata, + id: randomUUID(), + encryptedSecret: await this.vault.encryptSecret(secret), + sortOrder: sortOrder + index, + createdAt: now, + updatedAt: now, + })), + ); + const dtos = records.map(toDto); + assertCanWrite(); + this.repository.createMany(records); + return dtos; + } + + listGroups(): GroupRecord[] { + return this.groupRepository?.list() ?? []; + } + + async restoreBackup( + input: BackupPayload, + assertCanWrite: () => void = () => {}, + ): Promise<{ importedCount: number; ungroupedCount: number }> { + const payload = backupPayloadSchema.parse(input); + const existingGroups = this.listGroups(); + const groupsToCreate: GroupRecord[] = []; + const groupMap = new Map(); + if (payload.version === 2) { + for (const group of payload.groups) { + if (groupMap.has(group.id)) throw new Error('Yedekte yinelenen grup bilgisi var.'); + const existing = [...existingGroups, ...groupsToCreate].find( + (item) => item.name === group.name, + ); + const restored = existing ?? { + ...group, + id: randomUUID(), + sortOrder: existingGroups.length + groupsToCreate.length, + }; + groupMap.set(group.id, restored.id); + if (!existing) groupsToCreate.push(restored); + } + } + if (groupsToCreate.length && !this.groupRepository) + throw new Error('Grup deposu kullanılamıyor.'); + let ungroupedCount = 0; + const sortOrder = this.repository.list().length; + const now = Date.now(); + const records: AccountRecord[] = []; + for (const [index, account] of payload.accounts.entries()) { + const { secret, ...metadata } = account; + let groupId = account.groupId; + if (groupId) { + const mapped = groupMap.get(groupId); + if (payload.version === 2 && !mapped) throw new Error('Yedekte eksik grup bilgisi var.'); + if (payload.version === 1 && !existingGroups.some((item) => item.id === groupId)) { + groupId = null; + ungroupedCount++; + } else if (mapped) groupId = mapped; + } + records.push({ + ...metadata, + groupId, + id: randomUUID(), + encryptedSecret: await this.vault.encryptSecret(secret), + sortOrder: sortOrder + index, + createdAt: now, + updatedAt: now, + }); + assertCanWrite(); + } + records.forEach(toDto); + assertCanWrite(); + this.repository.transaction(() => { + for (const group of groupsToCreate) this.groupRepository!.create(group); + for (const record of records) this.repository.create(record); + }); + return { importedCount: records.length, ungroupedCount }; } - async update(input: UpdateAccountRequest): Promise { + async update( + input: UpdateAccountRequest, + assertCanWrite: () => void = () => {}, + ): Promise { + input = updateAccountRequestSchema.parse(input); const current = this.repository.getById(input.id); if (!current) throw new Error('Account not found.'); const updated: AccountRecord = { @@ -115,8 +215,10 @@ export class AccountService { : current.encryptedSecret, updatedAt: Date.now(), }; + const dto = toDto(updated); + assertCanWrite(); this.repository.update(updated); - return toDto(updated); + return dto; } delete(id: string): void { diff --git a/src/main/services/BackupService.ts b/src/main/services/BackupService.ts index 7641cb0..cc2a25c 100644 --- a/src/main/services/BackupService.ts +++ b/src/main/services/BackupService.ts @@ -14,18 +14,24 @@ import { export class BackupService { constructor(private readonly accountService: AccountService) {} - async exportBackup(password: string): Promise { + async exportBackup( + password: string, + assertAccess: () => void = () => {}, + ): Promise { + assertAccess(); const saveDialogResult = await dialog.showSaveDialog({ title: 'Şifreli Yedek Kaydet', defaultPath: `authapp-backup-${new Date().toISOString().slice(0, 10)}.authapp`, filters: [{ name: 'Authapp Backup (*.authapp)', extensions: ['authapp'] }], }); + assertAccess(); if (saveDialogResult.canceled || !saveDialogResult.filePath) { return { success: false, filePath: null, exportedCount: 0 }; } const accounts = this.accountService.list(); + const groups = this.accountService.listGroups(); const exportItems = await Promise.all( accounts.map(async (account) => { const { secret } = await this.accountService.getTotpSecret(account.id); @@ -43,7 +49,8 @@ export class BackupService { ); const payload = backupPayloadSchema.parse({ - version: 1, + version: 2, + groups, exportedAt: Date.now(), accounts: exportItems, }); @@ -76,7 +83,12 @@ export class BackupService { encryptedData: encrypted.toString('hex'), }); - fs.writeFileSync(saveDialogResult.filePath, JSON.stringify(envelope, null, 2), 'utf8'); + assertAccess(); + const fileContent = JSON.stringify(envelope, null, 2); + if (Buffer.byteLength(fileContent, 'utf8') > MAX_BACKUP_FILE_BYTES) { + throw new Error('Yedek dosyası izin verilen boyut sınırını aşıyor.'); + } + fs.writeFileSync(saveDialogResult.filePath, fileContent, 'utf8'); return { success: true, @@ -85,15 +97,20 @@ export class BackupService { }; } - async importBackup(password: string): Promise { + async importBackup( + password: string, + assertAccess: () => void = () => {}, + ): Promise { + assertAccess(); const openDialogResult = await dialog.showOpenDialog({ title: 'Yedek Dosyası Seç', filters: [{ name: 'Authapp Backup (*.authapp, *.json)', extensions: ['authapp', 'json'] }], properties: ['openFile'], }); + assertAccess(); if (openDialogResult.canceled || !openDialogResult.filePaths[0]) { - return { success: false, importedCount: 0, skippedCount: 0 }; + return { success: false, importedCount: 0, skippedCount: 0, ungroupedCount: 0 }; } const filePath = openDialogResult.filePaths[0]; @@ -141,31 +158,12 @@ export class BackupService { const payload = backupPayloadSchema.parse(payloadRaw); - let importedCount = 0; - let skippedCount = 0; - - for (const item of payload.accounts) { - try { - await this.accountService.create({ - issuer: item.issuer, - accountName: item.accountName, - secret: item.secret, - algorithm: item.algorithm, - digits: item.digits, - period: item.period, - favorite: item.favorite, - groupId: item.groupId, - }); - importedCount++; - } catch { - skippedCount++; - } - } + const restored = await this.accountService.restoreBackup(payload, assertAccess); return { success: true, - importedCount, - skippedCount, + ...restored, + skippedCount: 0, }; } } diff --git a/src/main/services/LockService.ts b/src/main/services/LockService.ts index 509b061..b9ea44e 100644 --- a/src/main/services/LockService.ts +++ b/src/main/services/LockService.ts @@ -11,19 +11,31 @@ const MAX_LOCKOUT_MS = 15 * 60_000; export class LockService { private isLocked: boolean; + private lastActivity: number; + private epoch = 0; + private readonly listeners = new Set<(status: LockStatus) => void>(); constructor( private readonly repository: SettingsRepository, private readonly now: () => number = Date.now, + private readonly systemIdleSeconds: () => number = () => 0, ) { const hasPin = Boolean(this.repository.get('pinHash')); this.isLocked = hasPin; + this.lastActivity = this.now(); } getStatus(): LockStatus { + this.checkAutoLock(); + return this.snapshot(); + } + + private snapshot(): LockStatus { const pinHash = this.repository.get('pinHash'); const timeoutRaw = this.repository.get('autoLockTimeout') ?? '5'; - const autoLockTimeout = parseInt(timeoutRaw, 10) || 5; + const parsedTimeout = Number(timeoutRaw); + const autoLockTimeout = + Number.isInteger(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5; return lockStatusSchema.parse({ isPinSet: Boolean(pinHash), @@ -32,6 +44,42 @@ export class LockService { }); } + onStatusChange(listener: (status: LockStatus) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + recordActivity(): void { + this.assertNotLocked(); + this.lastActivity = this.now(); + } + + checkAutoLock(): void { + const status = this.snapshot(); + if ( + status.isPinSet && + !status.isLocked && + status.autoLockTimeout > 0 && + Math.max(this.now() - this.lastActivity, this.systemIdleSeconds() * 1000) >= + status.autoLockTimeout * 60_000 + ) + this.lockApp(); + } + + captureSession(): () => void { + this.assertNotLocked(); + const epoch = this.epoch; + return () => { + this.assertNotLocked(); + if (this.epoch !== epoch) throw new Error('Kilit oturumu değişti. İşlemi yeniden başlatın.'); + }; + } + + private emitStatus(): void { + const status = this.snapshot(); + for (const listener of this.listeners) listener(status); + } + assertNotLocked(): void { if (this.getStatus().isLocked) { throw new Error('Uygulama kilitli. Lütfen PIN girin.'); @@ -82,6 +130,8 @@ export class LockService { this.clearFailedAttempts(); if (iterations < CURRENT_PIN_ITERATIONS) this.storePin(pin); this.isLocked = false; + this.lastActivity = this.now(); + this.emitStatus(); return true; } @@ -97,6 +147,9 @@ export class LockService { this.storePin(pin); this.clearFailedAttempts(); this.isLocked = false; + this.lastActivity = this.now(); + this.epoch++; + this.emitStatus(); } removePin(currentPin: string): void { @@ -106,11 +159,15 @@ export class LockService { this.repository.delete('pinIterations'); this.clearFailedAttempts(); this.isLocked = false; + this.epoch++; + this.emitStatus(); } lockApp(): void { - if (this.repository.get('pinHash')) { + if (this.repository.get('pinHash') && !this.isLocked) { this.isLocked = true; + this.epoch++; + this.emitStatus(); } } diff --git a/src/main/services/TotpService.ts b/src/main/services/TotpService.ts index 6efcea2..70700f5 100644 --- a/src/main/services/TotpService.ts +++ b/src/main/services/TotpService.ts @@ -1,6 +1,6 @@ import * as OTPAuth from 'otpauth'; import type { CreateAccountRequest } from '@shared/schemas/account'; -import { createAccountRequestSchema } from '@shared/schemas/account'; +import { base32SecretSchema, createAccountRequestSchema } from '@shared/schemas/account'; export interface TotpValue { code: string; @@ -16,7 +16,7 @@ export class TotpService { period: number, timestamp = Date.now(), ): TotpValue { - const normalized = secret.replace(/[\s-]/g, '').toUpperCase(); + const normalized = base32SecretSchema.parse(secret); const otpSecret = OTPAuth.Secret.fromBase32(normalized); return { code: OTPAuth.TOTP.generate({ secret: otpSecret, algorithm, digits, period, timestamp }), diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 61a8cec..4e46c67 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -81,6 +81,58 @@ export async function createMainWindow(developmentUrl?: string): Promise { + const api = window.authapp; + const fixture = { + issuer: 'Smoke fixture', accountName: 'fixture', secret: 'JBSWY3DPEHPK3PXP', + algorithm: 'SHA1', digits: 6, period: 30, favorite: false, groupId: null, + }; + const imported = await api.importAccounts({ + accounts: [fixture, { ...fixture, accountName: 'second fixture' }], + }); + if (imported.length !== 2 || imported.some((item) => 'secret' in item || 'encryptedSecret' in item)) { + throw new Error('Account import contract failed.'); + } + const codes = await api.getTotpCodes(); + if (codes.length !== 2 || codes.some((item) => !/^\\d{6}$/.test(item.code))) { + throw new Error('Account generation failed.'); + } + let invalidRejected = false; + try { await api.createAccount({ ...fixture, secret: 'INVALID!' }); } + catch { invalidRejected = true; } + if (!invalidRejected) throw new Error('Invalid secret was accepted.'); + let rollbackRejected = false; + try { + await api.importAccounts({ accounts: [fixture, { + ...fixture, groupId: 'a4ff59c6-2df2-4136-86ba-78bd57a617d8', + }] }); + } catch { rollbackRejected = true; } + if (!rollbackRejected || (await api.listAccounts()).length !== 2) { + throw new Error('Atomic account import failed.'); + } + await api.setPin({ pin: '1357' }); + await api.lockApp(); + if (!(await api.getLockStatus()).isLocked) throw new Error('Runtime lock failed.'); + let lockedReadRejected = false; + try { await api.getTotpCodes(); } catch { lockedReadRejected = true; } + if (!lockedReadRejected) throw new Error('Locked account read was accepted.'); + await api.verifyPin({ pin: '1357' }); + if ((await api.getLockStatus()).isLocked) throw new Error('PIN smoke remained locked.'); + const readyDeadline = Date.now() + 3000; + while (!document.querySelector('.account-card') && Date.now() < readyDeadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + if (!document.querySelector('.account-card')) throw new Error('Account view did not reload after unlock.'); + await api.removePin({ currentPin: '1357' }); + for (const account of imported) await api.deleteAccount({ id: account.id }); + const single = await api.createAccount(fixture); + await api.deleteAccount({ id: single.id }); + return (await api.listAccounts()).length === 0; + })() + `); + if (accountFlowReady !== true) throw new Error('Account flow smoke failed.'); + const screenshotDirectory = process.env['AUTHAPP_SMOKE_SCREENSHOT_DIR']; if (screenshotDirectory) { await mkdir(screenshotDirectory, { recursive: true }); diff --git a/src/preload/index.ts b/src/preload/index.ts index 0c44ffd..775f515 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,13 +5,15 @@ import { updateStateSchema, } from '@shared/schemas/update'; import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; -import { IPC_CHANNELS, UPDATE_STATE_EVENT } from '@shared/ipc/channels'; +import { IPC_CHANNELS, UPDATE_STATE_EVENT, LOCK_STATUS_EVENT } from '@shared/ipc/channels'; import { accountDtoSchema, createAccountRequestSchema, + importAccountsRequestSchema, deleteAccountRequestSchema, updateAccountRequestSchema, type CreateAccountRequest, + type ImportAccountsRequest, type DeleteAccountRequest, type UpdateAccountRequest, } from '@shared/schemas/account'; @@ -38,6 +40,7 @@ import { } from '@shared/schemas/settings'; import { lockStatusSchema, + lockActivityRequestSchema, removePinRequestSchema, setPinRequestSchema, verifyPinRequestSchema, @@ -58,6 +61,15 @@ const api: AuthappApi = Object.freeze({ createAccountRequestSchema.parse(request), ), ), + importAccounts: async (request: ImportAccountsRequest) => + accountDtoSchema + .array() + .parse( + await ipcRenderer.invoke( + IPC_CHANNELS.importAccounts, + importAccountsRequestSchema.parse(request), + ), + ), updateAccount: async (request: UpdateAccountRequest) => accountDtoSchema.parse( await ipcRenderer.invoke( @@ -115,6 +127,16 @@ const api: AuthappApi = Object.freeze({ lockApp: async () => { await ipcRenderer.invoke(IPC_CHANNELS.lockApp, {}); }, + recordActivity: async () => { + await ipcRenderer.invoke(IPC_CHANNELS.recordActivity, lockActivityRequestSchema.parse({})); + }, + onLockStatus: (listener: Parameters[0]) => { + const handler = (_event: IpcRendererEvent, rawStatus: unknown) => { + listener(lockStatusSchema.parse(rawStatus)); + }; + ipcRenderer.on(LOCK_STATUS_EVENT, handler); + return () => ipcRenderer.removeListener(LOCK_STATUS_EVENT, handler); + }, checkUpdate: async () => updateInfoSchema.parse(await ipcRenderer.invoke(IPC_CHANNELS.checkUpdate, {})), getUpdateState: async () => diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 6ca64e8..4a74c24 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -1,4 +1,5 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { LockStatus } from '@shared/schemas/lock'; import { Navigate, Route, Routes } from 'react-router-dom'; import { AppHeader } from '../components/AppHeader'; import { SearchDialog } from '../components/SearchDialog'; @@ -11,29 +12,53 @@ import { SettingsPage } from '../pages/SettingsPage'; export function App(): React.JSX.Element { const [searchOpen, setSearchOpen] = useState(false); + const [unlocked, setUnlocked] = useState(false); + const handleLockStatus = useCallback((status: LockStatus) => { + setUnlocked(!status.isLocked); + if (status.isLocked) setSearchOpen(false); + }, []); const openSearch = useCallback(() => setSearchOpen(true), []); const closeSearch = useCallback(() => setSearchOpen(false), []); useKeyboardShortcuts(openSearch); + useEffect(() => { + if (!unlocked) return; + let lastActivity = 0; + const recordActivity = () => { + if (Date.now() - lastActivity < 1000) return; + lastActivity = Date.now(); + void window.authapp.recordActivity().catch(() => {}); + }; + const events = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'focus'] as const; + for (const event of events) window.addEventListener(event, recordActivity, true); + return () => { + for (const event of events) window.removeEventListener(event, recordActivity, true); + }; + }, [unlocked]); + return (
- + ); } diff --git a/src/renderer/src/components/LockScreenOverlay.tsx b/src/renderer/src/components/LockScreenOverlay.tsx index f7cf130..5b05995 100644 --- a/src/renderer/src/components/LockScreenOverlay.tsx +++ b/src/renderer/src/components/LockScreenOverlay.tsx @@ -5,8 +5,10 @@ import { useLanguage } from '../hooks/useLanguage'; export function LockScreenOverlay({ onUnlock, + onStatusChange, }: { onUnlock?: () => void; + onStatusChange?: (status: LockStatus) => void; }): React.JSX.Element | null { const [status, setStatus] = useState(null); const [pin, setPin] = useState(''); @@ -18,20 +20,38 @@ export function LockScreenOverlay({ try { const next = await window.authapp.getLockStatus(); setStatus(next); + onStatusChange?.(next); } catch { - // ignore status check error + setError(t('lockStatusUnavailable')); } - }, []); + }, [onStatusChange, t]); useEffect(() => { let isMounted = true; - void window.authapp.getLockStatus().then((next) => { - if (isMounted) setStatus(next); + let receivedEvent = false; + const unsubscribe = window.authapp.onLockStatus((next) => { + receivedEvent = true; + if (isMounted) { + setStatus(next); + onStatusChange?.(next); + } }); + void window.authapp + .getLockStatus() + .then((next) => { + if (isMounted && !receivedEvent) { + setStatus(next); + onStatusChange?.(next); + } + }) + .catch(() => { + if (isMounted) setError(t('lockStatusUnavailable')); + }); return () => { isMounted = false; + unsubscribe(); }; - }, []); + }, [onStatusChange, t]); const handleVerify = useCallback( async (pinToTest: string) => { @@ -88,7 +108,20 @@ export function LockScreenOverlay({ return () => window.removeEventListener('keydown', onKeyDown); }, [status?.isLocked, pin, addDigit, backspace, handleVerify]); - if (!status?.isLocked) return null; + if (!status) + return ( +
+
+

{error || t('lockStatusLoading')}

+ {error && ( + + )} +
+
+ ); + if (!status.isLocked) return null; return (
diff --git a/src/renderer/src/pages/AddAccountPage.tsx b/src/renderer/src/pages/AddAccountPage.tsx index abc5efe..8505cca 100644 --- a/src/renderer/src/pages/AddAccountPage.tsx +++ b/src/renderer/src/pages/AddAccountPage.tsx @@ -13,6 +13,7 @@ import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import type { CreateAccountRequest } from '@shared/schemas/account'; +import { createAccountRequestSchema } from '@shared/schemas/account'; import { PageHeading } from '../components/PageHeading'; import { decodeQrFromImageFile, processScannedQrContent } from '../utils/qrDecoder'; import { useLanguage } from '../hooks/useLanguage'; @@ -167,15 +168,16 @@ export function AddAccountPage(): React.JSX.Element { setError('Lütfen bir hesap adı girin.'); return; } - if (!form.secret.trim()) { - setError('Lütfen geçerli bir kurulum anahtarı girin.'); + const parsed = createAccountRequestSchema.safeParse(form); + if (!parsed.success) { + setError(t('invalidAccountInput')); return; } setSaving(true); setError(''); try { - await window.authapp.createAccount(form); + await window.authapp.createAccount(parsed.data); navigate('/'); } catch { setError('Hesap kaydedilemedi. Kurulum anahtarını kontrol edin.'); @@ -194,12 +196,10 @@ export function AddAccountPage(): React.JSX.Element { setSaving(true); setError(''); try { - for (const account of selectedList) { - await window.authapp.createAccount(account); - } + await window.authapp.importAccounts({ accounts: selectedList }); navigate('/'); } catch { - setError('Hesaplar içe aktarılırken bir hata oluştu.'); + setError(t('accountImportFailed')); setSaving(false); } } diff --git a/src/renderer/src/pages/HomePage.tsx b/src/renderer/src/pages/HomePage.tsx index ef23316..b40752a 100644 --- a/src/renderer/src/pages/HomePage.tsx +++ b/src/renderer/src/pages/HomePage.tsx @@ -91,6 +91,7 @@ export function HomePage(): React.JSX.Element { ); } setError(true); + setCodes([]); } finally { setLoading(false); } @@ -126,7 +127,12 @@ export function HomePage(): React.JSX.Element { .then((nextCodes) => { if (isMounted) setCodes(nextCodes); }) - .catch(() => {}); + .catch(() => { + if (isMounted) { + setCodes([]); + setError(true); + } + }); } } lastSec = currentSec; @@ -207,7 +213,12 @@ export function HomePage(): React.JSX.Element { }; async function handleCopy(accountId: string): Promise { - await window.authapp.copyTotp(accountId); + try { + await window.authapp.copyTotp(accountId); + } catch { + setError(true); + return; + } setCopiedAccountId(accountId); window.setTimeout(() => { setCopiedAccountId((prev) => (prev === accountId ? null : prev)); @@ -304,10 +315,11 @@ export function HomePage(): React.JSX.Element {
- {value ? value.code.replace(/(.{3})/g, '$1 ').trim() : '------'} + {value ? value.code.replace(/(.{3})/g, '$1 ').trim() : t('codeUnavailable')}