Skip to content
Open
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
21 changes: 20 additions & 1 deletion app/lib/app.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
34 changes: 28 additions & 6 deletions app/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==
Expand All @@ -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"
Expand Down Expand Up @@ -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==
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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==
Expand All @@ -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"
Expand Down
52 changes: 52 additions & 0 deletions locale/app.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
44 changes: 44 additions & 0 deletions tabby-core/src/api/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,50 @@ export abstract class PlatformService {
abstract showMessageBox (options: MessageBoxOptions): Promise<MessageBoxResult>
abstract pickDirectory (): Promise<string | null>
abstract quit (): void

// Biometric authentication (Touch ID on macOS)
async isBiometricAuthAvailable (): Promise<boolean> {
return false
}

async promptBiometricAuth (_reason: string): Promise<void> {
throw new Error('Biometric authentication not available')
}

// Secure storage for vault passphrase (uses macOS Keychain via safeStorage)
async isSecureStorageAvailable (): Promise<boolean> {
return false
}

async secureStorePassphrase (_passphrase: string): Promise<void> {
throw new Error('Secure storage not available')
}

async secureRetrievePassphrase (): Promise<string|null> {
return null
}

async secureDeletePassphrase (): Promise<void> {
// 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<void> {
// No-op by default
}

// Check if Touch ID should be considered expired (including restart check)
isTouchIdExpired (): boolean {
return true
}
}

export class HTMLFileUpload extends FileUpload {
Expand Down
23 changes: 22 additions & 1 deletion tabby-core/src/components/unlockVaultModal.component.pug
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
57 changes: 55 additions & 2 deletions tabby-core/src/components/unlockVaultModal.component.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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<void> {
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<void> {
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,
})
}

Expand Down
17 changes: 16 additions & 1 deletion tabby-core/src/services/vault.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -178,12 +179,26 @@ export class VaultService {
async getPassphrase (): Promise<string> {
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!
Expand Down
4 changes: 3 additions & 1 deletion tabby-electron/src/services/electron.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -24,6 +24,7 @@ export class ElectronService {
BrowserWindow: typeof BrowserWindow
Menu: typeof Menu
MenuItem: typeof MenuItem
systemPreferences: SystemPreferences

/** @hidden */
private constructor () {
Expand All @@ -44,5 +45,6 @@ export class ElectronService {
this.Menu = remote.Menu
this.MenuItem = remote.MenuItem
this.nativeTheme = remote.nativeTheme
this.systemPreferences = remote.systemPreferences
}
}
Loading
Loading