Skip to content

feat(mac): add Touch ID biometric login#555

Open
andrey-dru-me1 wants to merge 2 commits into
tetherto:mainfrom
andrey-dru-me1:feature/add-mac-touch-id-login
Open

feat(mac): add Touch ID biometric login#555
andrey-dru-me1 wants to merge 2 commits into
tetherto:mainfrom
andrey-dru-me1:feature/add-mac-touch-id-login

Conversation

@andrey-dru-me1

Copy link
Copy Markdown

Closes #502

Password encrypted via safeStorage into macOS Keychain. Biometric prompt gates decryption — renderer cannot decrypt without passing Touch ID challenge, nor bypass the gate through localStorage alone.

Requirements

  • macOS users can enable Touch ID login in App Preferences
  • On login page, when Touch ID is enabled, the system biometric dialog auto-prompts
  • If biometric fails/cancels, user falls back to manual password entry
  • Renderer never holds or can derive the plaintext password from localStorage

Changes

  • Main process (electron/main.cjs): Added 4 IPC handlers:
    • biometric:isAvailable — checks systemPreferences.canPromptTouchID()
    • biometric:promptTouchID — standalone Touch ID prompt
    • biometric:encryptString — encrypts a string via safeStorage (macOS Keychain), returns base64
    • biometric:unlockWithPassword — atomic Touch ID prompt followed by safeStorage decryption; renderer sends the encrypted blob, main process returns decrypted password only if biometric scan succeeds
  • Preload (electron/preload.cjs): Exposed 4 new electronAPI methods to renderer
  • Types (src/types/electron.d.ts): Extended ElectronAPI interface with biometric methods
  • Login page (CardUnlockPearPass): Auto-triggers Touch ID on mount and window focus, with "Unlock with Touch ID" fallback link. Uses refs to avoid stale closures in async handlers. Migrated deprecated prop names (onChangeTextonChange, placeholderTextplaceholder, errorMessage/varianterror)
  • Settings (AppPreferencesContent): Touch ID toggle with password-verification dialog. User enters password to confirm; password is never stored — only encrypted via safeStorage and persisted to localStorage as a ciphertext blob the renderer can't decrypt
  • localStorage constants: Added BIOMETRIC_LOGIN_ENABLED and BIOMETRIC_ENCRYPTED_PASSWORD keys
  • Build config: Added build-assets/entitlements.mac.plist with Keychain access group and hardened runtime allowances; wired entitlements into electron-builder.mac.json
  • .gitignore: Updated to exclude local cert profiles

Testing Notes

  • Local dev on macOS 26 (Tahoe) with Touch ID hardware
  • Verified biometric dialog appears on app launch with Touch ID enabled
  • Verified fallback to manual password entry on Touch ID cancel/failure
  • Verified password-encryption dialog in Settings — password persisted as encrypted blob, not plaintext
  • Manual E2E flow: enable → quit → relaunch → Touch ID prompt → auto-unlock → vault loads

Things reviewers should pay attention to

  • Thread safety: biometricInFlightRef guards against concurrent biometric prompts
  • Stale closures: t, navigate, currentPage stored in refs to avoid stale captures in the tryBiometricLogin callback
  • Focus edge cases: Auto-trigger on mount if window already focused (document.hasFocus()), plus focus event listener for dock-icon clicks
  • Error taxonomy: main.cjs logs distinguish "user cancelled" vs "device unavailable" vs "decryption failed" for diagnostics
  • Deprecated prop migration: CardUnlockPearPass now uses modern kit props (onChange, placeholder, error) — verify no regressions on the password field

Dependencies

  • None (no new npm packages)
  • Requires macOS 10.12.2+ for systemPreferences.promptTouchID()
  • Non-macOS builds: biometric IPC handlers return false / throw early; UI toggle is hidden

Password encrypted via safeStorage into macOS Keychain. Biometric
prompt gates decryption — renderer cannot decrypt without passing
Touch ID challenge, nor bypass the gate through localStorage alone.
@andrey-dru-me1

Copy link
Copy Markdown
Author
image image

@andrey-dru-me1

Copy link
Copy Markdown
Author

@torkinos @hakaari @giorgikh93 Can anyone take a look please? I'd really appreciate to have this feature at the release

@giorgikh93

Copy link
Copy Markdown
Contributor

@andrey-dru-me1 Thanks for this PR, the UX work is solid, especially the focus/cancel handling in the unlock card and the test coverage. But one thing should be fixed before merging: the Touch ID check does not really protect the master password. It only looks like it does.
What happens:
When the user turns on Touch ID, the app:

encrypts the master password with safeStorage,
saves the encrypted text in localStorage (a file on disk),
on unlock, shows the Touch ID prompt, then decrypts.
The problem: the Touch ID prompt and the decryption are two separate steps. The fingerprint is not used to decrypt anything. safeStorage uses one key stored in the macOS keychain. The only thing protecting that key is the normal keychain rule (it trusts the PearPass app). So Touch ID protects this code path not the saved password.

What I tested
On macOS, I read two things and got the password back without any fingerprint:

the encrypted text from ~/Library/Application Support/PearPass/Local Storage/leveldb/
the key from the keychain
With these two, I decrypted the master password directly (standard Chromium v10 decryption).

To be exact about how hard this is:

No prompt at all: any code running inside the PearPass process (for example a bad npm dependency) can read the key silently. Note: this PR keeps disable-library-validation in the entitlements, which makes it easier to load untrusted libraries into the process - exactly this case.
One prompt, but still no Touch ID: a different program triggers a keychain window that asks for the login password / "Allow". A user click or the login password is enough. This is what stealer malware usually does.

Why this still matters
It's a regression. Today the master password is never saved. Login uses Argon2id to make a key, and only a salt + an encrypted vault key are stored. So nobody can get the master password, even with the login password and full disk access. This PR saves the master password on disk and makes it reachable by in-process code or a simple click/login password. The fingerprint prompt does not stop this.

Suggested fix
Make the key depend on Touch ID at the OS level, and stop using safeStorage (it has no option for this). Two ways:

Biometric keychain item: SecAccessControlCreateWithFlags(kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .biometryCurrentSet, …), or
Secure Enclave key: created with .biometryCurrentSet | .privateKeyUsage.
Both need a small native module. After this change, the key cannot be read without a real fingerprint - not by another program, not with the login password and not even by PearPass itself. Most of your code (IPC handlers, the toggle, the unlock screen) stays the same, and main.cjs becomes simpler. It is also better to store the derived vault key instead of the real password.

Thanks again for your work

@andrey-dru-me1

Copy link
Copy Markdown
Author

@giorgikh93 thanks for the detailed feedback, I really appreciate that. I'll come back with some fixes soon

andrey-dru-me1 added a commit to andrey-dru-me1/pearpass-app-desktop that referenced this pull request Jun 21, 2026
Replace the insecure approach of keeping an encrypted password in
localStorage with native keychain storage of verification credentials
(ciphertext, nonce, salt, hashedPassword).

- Add native macOS keychain module (Objective-C) for credential storage
- Add storeBiometricCredentials / retrieveBiometricCredentials /
  deleteBiometricCredentials electronAPI methods
- Extract EnableTouchIdDialog from AppPreferencesContent into its own
  component using ModalContext
- Re-store biometric credentials on master password change
- Rewrite CardUnlockPearPass biometric login to use native retrieval
- Remove safeStorage encryptString/unlockWithPassword codepath
- Add module augmentation for getMasterEncryption and runActionScan

Closes tetherto#555
andrey-dru-me1 added a commit to andrey-dru-me1/pearpass-app-desktop that referenced this pull request Jun 21, 2026
Replace the insecure approach of keeping an encrypted password in
localStorage with native keychain storage of verification credentials
(ciphertext, nonce, salt, hashedPassword).

- Add native macOS keychain module (Objective-C) for credential storage
- Add storeBiometricCredentials / retrieveBiometricCredentials /
  deleteBiometricCredentials electronAPI methods
- Extract EnableTouchIdDialog from AppPreferencesContent into its own
  component using ModalContext
- Re-store biometric credentials on master password change
- Rewrite CardUnlockPearPass biometric login to use native retrieval
- Remove safeStorage encryptString/unlockWithPassword codepath
- Add module augmentation for getMasterEncryption and runActionScan

Closes tetherto#555
@andrey-dru-me1
andrey-dru-me1 force-pushed the feature/add-mac-touch-id-login branch from e052fce to 48ac308 Compare June 21, 2026 16:13
Replace the insecure approach of keeping an encrypted password in
localStorage with native keychain storage of verification credentials
(ciphertext, nonce, salt, hashedPassword).

- Add native macOS keychain module (Objective-C) for credential storage
- Add storeBiometricCredentials / retrieveBiometricCredentials /
  deleteBiometricCredentials electronAPI methods
- Extract EnableTouchIdDialog from AppPreferencesContent into its own
  component using ModalContext
- Re-store biometric credentials on master password change
- Rewrite CardUnlockPearPass biometric login to use native retrieval
- Remove safeStorage encryptString/unlockWithPassword codepath
- Add module augmentation for getMasterEncryption and runActionScan

Closes tetherto#502
@andrey-dru-me1
andrey-dru-me1 force-pushed the feature/add-mac-touch-id-login branch from 48ac308 to 5fa9127 Compare June 21, 2026 16:14
@andrey-dru-me1

Copy link
Copy Markdown
Author

I rewrote the storage layer. Instead of encrypting with safeStorage and dumping the blob into localStorage, there's now a native N-API addon (electron/native-biometric/) that hits the keychain with kSecAccessControlBiometryCurrentSet. The OS gate is real — without a matching fingerprint the keychain refuses to hand over the data. Not via another process, not with the login password, and not from inside PearPass itself.

What gets stored is the Argon2id-derived vault credentials (ciphertext, nonce, salt, hashedPassword), never the raw master password. Same stuff the vault already uses internally. So even if someone managed to pull both the keychain item and the leveldb store, they'd get derived material, not the actual password.

Other things:

  • BIOMETRIC_ENCRYPTED_PASSWORD is gone from localStorage. Only BIOMETRIC_LOGIN_ENABLED remains as a boolean flag.
  • Removed disable-library-validation from entitlements — that was a dev leftover.
  • Pulled the password verification dialog out of AppPreferencesContent into its own EnableTouchIdDialog component using ModalContext.
  • Master password changes now re-store credentials automatically (in MasterPasswordContent).
  • Main process is simpler: three handlers — storeCredentials, retrieveCredentials, deleteCredentials. The retrieve step is a single transaction — LAContext evaluates, and if it passes, the same context does the keychain read via kSecUseAuthenticationContext.

Also hit a bug during testing: if biometric creds were stale, initWithCredentials would fail and leave a locked file descriptor, which then blocked manual password login too (the "File descriptor could not be locked" error). Fixed that upstream in pearpass-lib-vault-core#71, plus CardUnlockPearPass now calls encryptionClose() on biometric failure to be safe.

PTAL.

@andrey-dru-me1

Copy link
Copy Markdown
Author

@giorgikh93 can you review the changes made please?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: Touch ID unlock support on macOS

2 participants