diff --git a/app/lib/app.ts b/app/lib/app.ts index 8a1406bf5f..be38d26bcb 100644 --- a/app/lib/app.ts +++ b/app/lib/app.ts @@ -1,4 +1,4 @@ -import { app, ipcMain, Menu, Tray, shell, screen, globalShortcut, MenuItemConstructorOptions, WebContents } from 'electron' +import { app, ipcMain, Menu, Tray, shell, screen, globalShortcut, MenuItemConstructorOptions, WebContents, safeStorage } from 'electron' import promiseIpc from 'electron-promise-ipc' import * as remote from '@electron/remote/main' import { exec } from 'mz/child_process' @@ -36,6 +36,25 @@ export class Application { this.broadcastExcept('host:config-change', event.sender, config) }) + // safeStorage IPC handlers for vault Touch ID support + ipcMain.handle('app:safe-storage-available', () => { + return safeStorage.isEncryptionAvailable() + }) + + ipcMain.handle('app:safe-storage-encrypt', (_event, plainText: string) => { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('Encryption is not available') + } + return safeStorage.encryptString(plainText) + }) + + ipcMain.handle('app:safe-storage-decrypt', (_event, encrypted: Buffer) => { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('Encryption is not available') + } + return safeStorage.decryptString(Buffer.from(encrypted as unknown as ArrayBuffer)) + }) + ipcMain.on('app:register-global-hotkey', (_event, specs) => { globalShortcut.unregisterAll() for (const spec of specs) { diff --git a/app/yarn.lock b/app/yarn.lock index ecf511976f..645708e3fc 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -4393,8 +4393,7 @@ strict-uri-encode@^2.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4412,6 +4411,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^2.0.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -4494,8 +4502,7 @@ stringify-package@^1.0.0, stringify-package@^1.0.1: resolved "https://registry.yarnpkg.com/stringify-package/-/stringify-package-1.0.1.tgz#e5aa3643e7f74d0f28628b72f3dad5cecfc3ba85" integrity sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg== -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - name strip-ansi-cjs +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4523,6 +4530,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -5035,8 +5049,7 @@ worker-farm@^1.6.0, worker-farm@^1.7.0: dependencies: errno "~0.1.7" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -5054,6 +5067,15 @@ wrap-ansi@^5.1.0: string-width "^3.0.0" strip-ansi "^5.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" diff --git a/locale/app.pot b/locale/app.pot index 651c43ebd7..ada916fcad 100644 --- a/locale/app.pot +++ b/locale/app.pot @@ -2410,10 +2410,62 @@ msgstr "" msgid "Vault is locked" msgstr "" +#: locale/tmp-html/tabby-core/src/components/unlockVaultModal.component.html:23 +msgid "Touch ID has expired. Please enter your passphrase." +msgstr "" + +#: locale/tmp-html/tabby-core/src/components/unlockVaultModal.component.html:33 +msgid "Unlock with Touch ID" +msgstr "" + +#: locale/tmp-html/tabby-core/src/components/unlockVaultModal.component.html:40 +msgid "or enter passphrase" +msgstr "" + +#: tabby-core/src/components/unlockVaultModal.component.ts:53 +msgid "Unlock Tabby Vault" +msgstr "" + +#: tabby-core/src/components/unlockVaultModal.component.ts:62 +msgid "Could not retrieve passphrase" +msgstr "" + +#: tabby-core/src/components/unlockVaultModal.component.ts:66 +msgid "Touch ID failed" +msgstr "" + #: locale/tmp-html/tabby-settings/src/components/vaultSettingsTab.component.html:3 msgid "Vault is not configured" msgstr "" +#: locale/tmp-html/tabby-settings/src/components/vaultSettingsTab.component.html:49 +msgid "Use Touch ID to unlock" +msgstr "" + +#: locale/tmp-html/tabby-settings/src/components/vaultSettingsTab.component.html:50 +msgid "Unlock the vault using Touch ID instead of entering the passphrase" +msgstr "" + +#: locale/tmp-html/tabby-settings/src/components/vaultSettingsTab.component.html:55 +msgid "Touch ID expires after" +msgstr "" + +#: locale/tmp-html/tabby-settings/src/components/vaultSettingsTab.component.html:56 +msgid "After this period, you will need to enter the passphrase again" +msgstr "" + +#: locale/tmp-html/tabby-settings/src/components/vaultSettingsTab.component.html:66 +msgid "Expire on restart" +msgstr "" + +#: locale/tmp-html/tabby-settings/src/components/vaultSettingsTab.component.html:67 +msgid "Require passphrase after computer restart" +msgstr "" + +#: tabby-settings/src/components/vaultSettingsTab.component.ts:68 +msgid "Enable Touch ID for Vault" +msgstr "" + #: tabby-core/src/services/fileProviders.service.ts:40 msgid "Vault master passphrase needs to be set to allow storing secrets" msgstr "" diff --git a/tabby-core/src/api/platform.ts b/tabby-core/src/api/platform.ts index 4088e22aeb..4c8472f978 100644 --- a/tabby-core/src/api/platform.ts +++ b/tabby-core/src/api/platform.ts @@ -269,6 +269,50 @@ export abstract class PlatformService { abstract showMessageBox (options: MessageBoxOptions): Promise abstract pickDirectory (): Promise abstract quit (): void + + // Biometric authentication (Touch ID on macOS) + async isBiometricAuthAvailable (): Promise { + return false + } + + async promptBiometricAuth (_reason: string): Promise { + throw new Error('Biometric authentication not available') + } + + // Secure storage for vault passphrase (uses macOS Keychain via safeStorage) + async isSecureStorageAvailable (): Promise { + return false + } + + async secureStorePassphrase (_passphrase: string): Promise { + throw new Error('Secure storage not available') + } + + async secureRetrievePassphrase (): Promise { + return null + } + + async secureDeletePassphrase (): Promise { + // No-op by default + } + + getSecureStorageTimestamp (): number|null { + return null + } + + // Touch ID settings (stored separately from encrypted config) + getTouchIdSettings (): { enabled: boolean, expireDays: number, expireOnRestart: boolean } { + return { enabled: false, expireDays: 1, expireOnRestart: false } + } + + async setTouchIdSettings (_enabled: boolean, _expireDays: number, _expireOnRestart?: boolean): Promise { + // No-op by default + } + + // Check if Touch ID should be considered expired (including restart check) + isTouchIdExpired (): boolean { + return true + } } export class HTMLFileUpload extends FileUpload { diff --git a/tabby-core/src/components/unlockVaultModal.component.pug b/tabby-core/src/components/unlockVaultModal.component.pug index 4943110edc..df2788b9cc 100644 --- a/tabby-core/src/components/unlockVaultModal.component.pug +++ b/tabby-core/src/components/unlockVaultModal.component.pug @@ -19,10 +19,31 @@ (click)='rememberFor = x', ) {{getRememberForDisplay(x)}} + // Touch ID expired warning + .alert.alert-warning.mb-3(*ngIf='touchIdExpired') + i.fas.fa-clock.me-2 + span(translate) Touch ID has expired. Please enter your passphrase. + + // Touch ID error message + .alert.alert-danger.mb-3(*ngIf='touchIdError') + i.fas.fa-exclamation-triangle.me-2 + span {{touchIdError}} + + // Touch ID button (show when available, enabled, and not expired) + .d-flex.justify-content-center.mb-3(*ngIf='touchIdAvailable && touchIdEnabled && !touchIdExpired') + button.btn.btn-lg.btn-primary((click)='unlockWithTouchId()') + i.fas.fa-fingerprint.me-2 + span(translate) Unlock with Touch ID + + // Divider when Touch ID is available + .d-flex.align-items-center.mb-3(*ngIf='touchIdAvailable && touchIdEnabled && !touchIdExpired') + hr.flex-grow-1.me-2 + span.text-muted(translate) or enter passphrase + hr.flex-grow-1.ms-2 + .input-group input.form-control.form-control-lg( type='password', - autofocus, [(ngModel)]='passphrase', #input, placeholder='Master passphrase', diff --git a/tabby-core/src/components/unlockVaultModal.component.ts b/tabby-core/src/components/unlockVaultModal.component.ts index 0367e29411..072965ffe3 100644 --- a/tabby-core/src/components/unlockVaultModal.component.ts +++ b/tabby-core/src/components/unlockVaultModal.component.ts @@ -1,5 +1,7 @@ import { Component, ViewChild, ElementRef } from '@angular/core' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { PlatformService } from '../api/platform' +import { TranslateService } from '@ngx-translate/core' /** @hidden */ @Component({ @@ -11,22 +13,73 @@ export class UnlockVaultModalComponent { rememberOptions = [1, 5, 15, 60, 1440, 10080] @ViewChild('input') input: ElementRef + touchIdAvailable = false + touchIdEnabled = false + touchIdExpired = false + touchIdError = '' + constructor ( private modalInstance: NgbActiveModal, + private platform: PlatformService, + private translate: TranslateService, ) { } - ngOnInit (): void { + async ngOnInit (): Promise { this.rememberFor = parseInt(window.localStorage.vaultRememberPassphraseFor ?? 0) + + // Check Touch ID availability and status + const biometricAvailable = await (this.platform.isBiometricAuthAvailable() as any) + const secureStorageAvailable = await (this.platform.isSecureStorageAvailable() as any) + this.touchIdAvailable = biometricAvailable && secureStorageAvailable + + const touchIdSettings = this.platform.getTouchIdSettings() + this.touchIdEnabled = touchIdSettings.enabled + + if (this.touchIdAvailable && this.touchIdEnabled) { + // Check if Touch ID has expired (time-based or restart-based) + this.touchIdExpired = this.platform.isTouchIdExpired() + + // Auto-trigger Touch ID if available and not expired + if (!this.touchIdExpired) { + await this.unlockWithTouchId() + } + } + setTimeout(() => { - this.input.nativeElement.focus() + this.input.nativeElement?.focus() }) } + async unlockWithTouchId (): Promise { + this.touchIdError = '' + try { + await this.platform.promptBiometricAuth(this.translate.instant('Unlock Tabby Vault')) + const passphrase = await this.platform.secureRetrievePassphrase() + if (passphrase) { + this.modalInstance.close({ + passphrase, + rememberFor: this.rememberFor, + usedTouchId: true, + }) + } else { + this.touchIdError = this.translate.instant('Could not retrieve passphrase') + // Hide Touch ID button since the stored passphrase seems invalid + this.touchIdEnabled = false + } + } catch (e: any) { + // User cancelled or Touch ID failed + this.touchIdError = e.message || this.translate.instant('Touch ID failed') + } + } + ok (): void { window.localStorage.vaultRememberPassphraseFor = this.rememberFor this.modalInstance.close({ passphrase: this.passphrase, rememberFor: this.rememberFor, + usedTouchId: false, + // Update Touch ID storage when enabled (both when expired and to refresh timestamp) + updateTouchId: this.touchIdEnabled, }) } diff --git a/tabby-core/src/services/vault.service.ts b/tabby-core/src/services/vault.service.ts index d9d678002a..fc0ffa3bcc 100644 --- a/tabby-core/src/services/vault.service.ts +++ b/tabby-core/src/services/vault.service.ts @@ -114,6 +114,7 @@ export class VaultService { private zone: NgZone, private notifications: NotificationsService, private ngbModal: NgbModal, + private platform: PlatformService, ) { this.getPassphrase = serializeFunction(this.getPassphrase.bind(this)) } @@ -178,12 +179,26 @@ export class VaultService { async getPassphrase (): Promise { if (!_rememberedPassphrase) { const modal = this.ngbModal.open(UnlockVaultModalComponent) - const { passphrase, rememberFor } = await modal.result + const result = await modal.result + if (!result) { + throw new Error('Vault unlock cancelled') + } + const { passphrase, rememberFor, updateTouchId } = result setTimeout(() => { _rememberedPassphrase = null // avoid multiple consequent prompts }, Math.max(1000, rememberFor * 60000)) _rememberedPassphrase = passphrase + + // Update Touch ID storage if needed (e.g., after expiration) + if (updateTouchId) { + try { + await this.platform.secureStorePassphrase(passphrase) + } catch (e) { + // Silently fail, Touch ID update is optional + console.error('Failed to update Touch ID storage:', e) + } + } } return _rememberedPassphrase! diff --git a/tabby-electron/src/services/electron.service.ts b/tabby-electron/src/services/electron.service.ts index 24e88a614c..e3526f61bd 100644 --- a/tabby-electron/src/services/electron.service.ts +++ b/tabby-electron/src/services/electron.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core' -import { App, IpcRenderer, Shell, Dialog, Clipboard, GlobalShortcut, Screen, AutoUpdater, TouchBar, BrowserWindow, Menu, MenuItem, PowerSaveBlocker, NativeTheme } from 'electron' +import { App, IpcRenderer, Shell, Dialog, Clipboard, GlobalShortcut, Screen, AutoUpdater, TouchBar, BrowserWindow, Menu, MenuItem, PowerSaveBlocker, NativeTheme, SystemPreferences } from 'electron' import * as remote from '@electron/remote' export interface MessageBoxResponse { @@ -24,6 +24,7 @@ export class ElectronService { BrowserWindow: typeof BrowserWindow Menu: typeof Menu MenuItem: typeof MenuItem + systemPreferences: SystemPreferences /** @hidden */ private constructor () { @@ -44,5 +45,6 @@ export class ElectronService { this.Menu = remote.Menu this.MenuItem = remote.MenuItem this.nativeTheme = remote.nativeTheme + this.systemPreferences = remote.systemPreferences } } diff --git a/tabby-electron/src/services/platform.service.ts b/tabby-electron/src/services/platform.service.ts index a25227d4d0..555272789f 100644 --- a/tabby-electron/src/services/platform.service.ts +++ b/tabby-electron/src/services/platform.service.ts @@ -322,6 +322,167 @@ export class ElectronPlatformService extends PlatformService { return 'light' } } + + // Touch ID / Biometric methods (macOS only) + private touchIdCache: { + encrypted: number[], + timestamp: number, + enabled?: boolean, + expireDays?: number, + expireOnRestart?: boolean, + bootTime?: number, + }|null = null + + private get touchIdStoragePath (): string { + return path.join(path.dirname(this.configPath), 'vault-touchid.json') + } + + private getBootTime (): number { + // Calculate boot time from current time minus uptime + return Date.now() - os.uptime() * 1000 + } + + private loadTouchIdCache (): void { + if (!this.touchIdCache) { + if (fsSync.existsSync(this.touchIdStoragePath)) { + try { + const content = fsSync.readFileSync(this.touchIdStoragePath, 'utf8') + this.touchIdCache = JSON.parse(content) + } catch { + this.touchIdCache = null + } + } + } + } + + private async saveTouchIdCache (): Promise { + if (this.touchIdCache) { + await fs.writeFile(this.touchIdStoragePath, JSON.stringify(this.touchIdCache), 'utf8') + try { + await fs.chmod(this.touchIdStoragePath, 0o600) + } catch { + // Ignore permission-setting errors to avoid breaking functionality on unsupported platforms + } + } + } + + async isBiometricAuthAvailable (): Promise { + if (this.hostApp.platform !== Platform.macOS) { + return false + } + try { + return this.electron.systemPreferences.canPromptTouchID() + } catch { + return false + } + } + + async promptBiometricAuth (reason: string): Promise { + if (this.hostApp.platform !== Platform.macOS) { + throw new Error('Biometric authentication is only available on macOS') + } + return this.electron.systemPreferences.promptTouchID(reason) + } + + async isSecureStorageAvailable (): Promise { + // safeStorage is available via main process IPC + if (this.hostApp.platform !== Platform.macOS) { + return false + } + return this.electron.ipcRenderer.invoke('app:safe-storage-available') + } + + async secureStorePassphrase (passphrase: string): Promise { + const encrypted: Buffer = await this.electron.ipcRenderer.invoke('app:safe-storage-encrypt', passphrase) + this.loadTouchIdCache() + if (!this.touchIdCache) { + this.touchIdCache = { encrypted: [], timestamp: 0 } + } + this.touchIdCache.encrypted = Array.from(encrypted) + this.touchIdCache.timestamp = Date.now() + this.touchIdCache.bootTime = this.getBootTime() + await this.saveTouchIdCache() + } + + async secureRetrievePassphrase (): Promise { + try { + this.loadTouchIdCache() + if (!this.touchIdCache?.encrypted.length) { + return null + } + const encrypted = Buffer.from(this.touchIdCache.encrypted) + const decrypted: string = await this.electron.ipcRenderer.invoke('app:safe-storage-decrypt', encrypted) + return decrypted + } catch { + return null + } + } + + async secureDeletePassphrase (): Promise { + this.loadTouchIdCache() + if (!this.touchIdCache) { + return + } + + this.touchIdCache.encrypted = [] + this.touchIdCache.timestamp = 0 + this.touchIdCache.bootTime = undefined + await this.saveTouchIdCache() + } + + getSecureStorageTimestamp (): number|null { + this.loadTouchIdCache() + return this.touchIdCache?.timestamp ?? null + } + + getTouchIdSettings (): { enabled: boolean, expireDays: number, expireOnRestart: boolean } { + this.loadTouchIdCache() + return { + enabled: this.touchIdCache?.enabled ?? false, + expireDays: this.touchIdCache?.expireDays ?? 1, + expireOnRestart: this.touchIdCache?.expireOnRestart ?? false, + } + } + + async setTouchIdSettings (enabled: boolean, expireDays: number, expireOnRestart?: boolean): Promise { + this.loadTouchIdCache() + if (!this.touchIdCache) { + this.touchIdCache = { encrypted: [], timestamp: 0 } + } + this.touchIdCache.enabled = enabled + this.touchIdCache.expireDays = expireDays + this.touchIdCache.expireOnRestart = expireOnRestart ?? false + await this.saveTouchIdCache() + } + + isTouchIdExpired (): boolean { + this.loadTouchIdCache() + if (!this.touchIdCache?.enabled) { + return true + } + + // Check restart-based expiration + if (this.touchIdCache.expireOnRestart) { + const storedBootTime = this.touchIdCache.bootTime + const currentBootTime = this.getBootTime() + // Allow 5 second tolerance for boot time comparison + if (!storedBootTime || Math.abs(currentBootTime - storedBootTime) > 5000) { + return true + } + } + + // Check time-based expiration + const timestamp = this.touchIdCache.timestamp + const expireDays = this.touchIdCache.expireDays ?? 1 + if (expireDays > 0 && timestamp) { + const expireMs = expireDays * 24 * 60 * 60 * 1000 + if (Date.now() - timestamp > expireMs) { + return true + } + } + + return false + } } class ElectronFileUpload extends FileUpload { diff --git a/tabby-settings/src/components/vaultSettingsTab.component.pug b/tabby-settings/src/components/vaultSettingsTab.component.pug index cce4d9ff1f..b12a046f63 100644 --- a/tabby-settings/src/components/vaultSettingsTab.component.pug +++ b/tabby-settings/src/components/vaultSettingsTab.component.pug @@ -61,6 +61,57 @@ div(*ngIf='vault.isEnabled()') span(translate) Delete h3.mt-5(translate) Options + + // Touch ID option (macOS only) + .form-line(*ngIf='touchIdAvailable') + .header + .title + i.fas.fa-fingerprint.me-2 + span(translate) Use Touch ID to unlock + .description(translate) Unlock the vault using Touch ID instead of entering the passphrase + toggle( + [ngModel]='touchIdEnabled', + (ngModelChange)='toggleTouchId($event)', + ) + + // Touch ID expire days (show only when Touch ID is enabled) + .form-line(*ngIf='touchIdAvailable && touchIdEnabled') + .header + .title(translate) Touch ID expires after + .description(translate) After this period, you will need to enter the passphrase again + .d-flex.align-items-center.gap-2 + select.form-control( + style='width: auto', + [ngModel]='touchIdExpireSelection', + (ngModelChange)='setTouchIdExpireDays($event)', + ) + option( + *ngFor='let opt of touchIdExpireOptions', + [ngValue]='opt.value', + ) {{opt.label | translate}} + option([ngValue]='-1', translate) Custom + // Custom days input (show when custom is selected) + .input-group(*ngIf='touchIdExpireSelection === -1', style='width: 120px') + input.form-control( + type='number', + min='1', + max='30', + step='1', + [ngModel]='customExpireDays', + (ngModelChange)='setCustomExpireDays($event)', + ) + span.input-group-text(translate) days + + // Expire on restart (show only when Touch ID is enabled) + .form-line(*ngIf='touchIdAvailable && touchIdEnabled') + .header + .title(translate) Expire on restart + .description(translate) Require passphrase after computer restart + toggle( + [ngModel]='touchIdExpireOnRestart', + (ngModelChange)='setTouchIdExpireOnRestart($event)', + ) + .form-line .header .title(translate) Encrypt config file diff --git a/tabby-settings/src/components/vaultSettingsTab.component.ts b/tabby-settings/src/components/vaultSettingsTab.component.ts index 1bb2c51ff8..d18f4dc50b 100644 --- a/tabby-settings/src/components/vaultSettingsTab.component.ts +++ b/tabby-settings/src/components/vaultSettingsTab.component.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { Component, HostBinding } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { BaseComponent, VaultService, VaultSecret, Vault, PlatformService, ConfigService, VAULT_SECRET_TYPE_FILE, PromptModalComponent, VaultFileSecret, TranslateService } from 'tabby-core' +import { BaseComponent, VaultService, VaultSecret, Vault, PlatformService, ConfigService, VAULT_SECRET_TYPE_FILE, PromptModalComponent, VaultFileSecret, TranslateService, NotificationsService } from 'tabby-core' import { SetVaultPassphraseModalComponent } from './setVaultPassphraseModal.component' import { ShowSecretModalComponent } from './showSecretModal.component' @@ -15,6 +15,20 @@ export class VaultSettingsTabComponent extends BaseComponent { vaultContents: Vault|null = null VAULT_SECRET_TYPE_FILE = VAULT_SECRET_TYPE_FILE + // Touch ID support + touchIdAvailable = false + touchIdEnabled = false + touchIdExpireOptions = [ + { value: 1, label: '1 day' }, + { value: 7, label: '7 days' }, + { value: 30, label: '30 days' }, + ] + + private touchIdExpirePresetValues = [1, 7, 30] + + customExpireDays = 1 + customExpireSelected = false + @HostBinding('class.content-box') true constructor ( @@ -23,11 +37,119 @@ export class VaultSettingsTabComponent extends BaseComponent { private platform: PlatformService, private ngbModal: NgbModal, private translate: TranslateService, + private notifications: NotificationsService, ) { super() if (vault.isOpen()) { this.loadVault() } + this.checkTouchIdAvailability() + } + + async checkTouchIdAvailability (): Promise { + const biometricAvailable = await (this.platform.isBiometricAuthAvailable() as any) + const secureStorageAvailable = await (this.platform.isSecureStorageAvailable() as any) + this.touchIdAvailable = biometricAvailable && secureStorageAvailable + + let expireDays = this.platform.getTouchIdSettings().expireDays + // Migration: ensure at least 1 day if previously set to 0 (for security) + if (expireDays <= 0) { + expireDays = 1 + await this.platform.setTouchIdSettings(true, 1, this.platform.getTouchIdSettings().expireOnRestart) + } + + this.touchIdEnabled = this.platform.getTouchIdSettings().enabled + + if (!this.touchIdExpirePresetValues.includes(expireDays)) { + this.customExpireDays = expireDays + } + } + + + get touchIdExpireDays (): number { + return this.platform.getTouchIdSettings().expireDays + } + + get touchIdExpireSelection (): number { + if (this.customExpireSelected) { + return -1 + } + const expireDays = this.touchIdExpireDays + if (expireDays > 0 && !this.touchIdExpirePresetValues.includes(expireDays)) { + return -1 + } + return expireDays + } + + get touchIdExpireOnRestart (): boolean { + return this.platform.getTouchIdSettings().expireOnRestart + } + + async enableTouchId (): Promise { + try { + // Prompt for Touch ID to confirm + await this.platform.promptBiometricAuth(this.translate.instant('Enable Touch ID for Vault')) + + // Get the current passphrase and store it securely + const passphrase = await this.vault.getPassphrase() + await this.platform.secureStorePassphrase(passphrase) + + // Update settings in separate file (not affected by vault encryption) + await this.platform.setTouchIdSettings(true, this.touchIdExpireDays) + this.touchIdEnabled = true + } catch (e: any) { + // User cancelled or Touch ID failed + console.error('Failed to enable Touch ID:', e) + this.notifications.error(this.translate.instant('Failed to enable Touch ID'), e.message || e.toString()) + + // Force toggle back + this.touchIdEnabled = true + setTimeout(() => this.touchIdEnabled = false) + } + } + + async disableTouchId (): Promise { + const settings = this.platform.getTouchIdSettings() + await this.platform.setTouchIdSettings(false, settings.expireDays, settings.expireOnRestart) + await this.platform.secureDeletePassphrase() + this.touchIdEnabled = false + } + + async toggleTouchId (enabled: boolean): Promise { + if (enabled) { + await this.enableTouchId() + } else { + await this.disableTouchId() + } + } + + async setTouchIdExpireDays (days: number): Promise { + if (days === -1) { + this.customExpireSelected = true + await this.platform.setTouchIdSettings(this.touchIdEnabled, this.customExpireDays, this.touchIdExpireOnRestart) + return + } + this.customExpireSelected = false + await this.platform.setTouchIdSettings(this.touchIdEnabled, days, this.touchIdExpireOnRestart) + } + + async setTouchIdExpireOnRestart (value: boolean): Promise { + await this.platform.setTouchIdSettings(this.touchIdEnabled, this.touchIdExpireDays, value) + } + + async setCustomExpireDays (days: number|null|undefined): Promise { + if (days === null || days === undefined) { + return + } + const validatedDays = Math.max(1, Math.min(30, Math.floor(days))) + if (days !== validatedDays) { + // Force the UI to reflect the validated value + this.customExpireDays = 0 + setTimeout(() => this.customExpireDays = validatedDays) + } else { + this.customExpireDays = validatedDays + } + await this.platform.setTouchIdSettings(this.touchIdEnabled, validatedDays, this.touchIdExpireOnRestart) } async loadVault (): Promise { @@ -56,6 +178,10 @@ export class VaultSettingsTabComponent extends BaseComponent { cancelId: 1, }, )).response === 0) { + // Also disable Touch ID when vault is disabled + if (this.touchIdEnabled) { + await this.disableTouchId() + } await this.vault.setEnabled(false) } } @@ -70,7 +196,11 @@ export class VaultSettingsTabComponent extends BaseComponent { const modal = this.ngbModal.open(SetVaultPassphraseModalComponent) const newPassphrase = await modal.result.catch(() => null) if (newPassphrase) { - this.vault.save(this.vaultContents, newPassphrase) + await this.vault.save(this.vaultContents, newPassphrase) + // Update Touch ID storage if enabled + if (this.touchIdEnabled) { + await this.platform.secureStorePassphrase(newPassphrase) + } } }