feat(security): biometric-guarded session token (Keychain/Keystore)#2489
feat(security): biometric-guarded session token (Keychain/Keystore)#2489innolope-dev wants to merge 7 commits into
Conversation
…Keychain/Keystore The app lock shipped in #2461 gated rendering only: the JWT sat in plain Preferences, deleting the stored credential id opened the gate, and the /users/me poller kept refreshing behind the lock. This makes it a real control (closes #2472). Three session modes, detected once per launch in auth-token.ts: - guarded: the token lives under AccessControl.BIOMETRY_CURRENT_SET (Keychain SecAccessControl / BiometricPrompt-bound Keystore key) via @capgo/capacitor-native-biometric, pinned 8.6.0. The unlock ceremony IS the token read: one OS prompt releases the credential into memory. authReady() parks every API caller while locked, so a locked app never emits an unauthenticated request that would 401 and tear the session down. On lock/background (same 5-min timeout) the in-memory token is dropped, the user query is disabled and the poller skips its tick. The lock decision derives from a non-secret presence marker — stripping local prefs yields a signed-out app, never an open session (fail closed). - plain: byte-for-byte the previous behavior, kept for older binaries running OTA'd JS (plugin feature-detected) and devices without enrolled biometrics. After the legacy gate opens, the session migrates to guarded storage; the plain copy is deleted only once the next cold start's guarded read proves the round-trip. - none: nothing to protect. Biometric re-enrollment invalidates the guarded item by design; both platforms surface it as not-found, which lands as a clean session-expired logout to /setup, never a stuck lock. Sliding-refresh tokens persist only inside the Android post-auth validity window (Keystore writes prompt outside it — iOS writes are always silent); otherwise they stay memory-only and the server re-mints later. Also routes services/card.ts through authReady()+getAuthHeaders — it read the jwt-token web cookie directly, which never worked on native (flagged in the #2463 re-review).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
auth-token.ts is reachable from Server Component pages (charges.ts → [...recipient]/page.tsx), so app-lock-state.ts must stay hook-free — useSyncExternalStore in that module failed the production build on Vercel. Move the useAppLocked hook into its own client file.
… any Extends the existing global Window.Capacitor declaration — the only eslint error this branch added on top of the known-red baseline.
Match main's #2493: drop the 'locked / could not confirm' framing for a plain log-in ask ('Welcome back!' + 'Please log in to access the app.', button 'Log in').
kushagrasarathe
left a comment
There was a problem hiding this comment.
Reviewed adversarially (external contributor, security control to a release branch) — requesting changes on one code item; the design itself is sound.
This is the real fix for the cosmetic app-lock (#2461 / issue #2472), and it genuinely closes those gaps: the JWT is now stored via NativeBiometric.setCredentials({ accessControl: BIOMETRY_CURRENT_SET }) (Keychain/Keystore, biometric-gated) rather than plain Preferences; the gate fails closed (auth state derives from the presence of the guarded token, so deleting a pref signs the user out, never opens the app); and the session is paused while locked (no /users/me polling with a live token). @capgo/capacitor-native-biometric@8.6.0 clears the 14-day supply-chain floor and its integrity matches npm. No backdoor.
Requested change (code):
onramp-quote.ts:39skips the gate — addawait authReady()before it fires so the one un-gated caller can't run mid-lock.
Merge-gate conditions (not code, but required before this rolls to the store):
- Run the on-device iOS + Android matrix — CI cannot prove the native biometric binding actually holds; this must be verified on hardware.
- Explicit sign-off on the two accepted residuals: (a) the migration-window plaintext copy kept until the first successful guarded unlock, and (b) the legacy plain-mode fail-open for older binaries / no enrolled biometric (those sessions have no guarded token to protect, so it's by-design — but call it out and accept it consciously).
Once authReady() lands and the device matrix + residual sign-off are done, this is good to ship.
Opening the app and viewing the balance is not treated as a critical vulnerability — matching the web app, where the same read-only view is ungated. Money movement stays passkey-gated at the transaction layer, independently of this flag. - add OPEN_GATED flag (NEXT_PUBLIC_APP_OPEN_GATED, default false) - AppLockGate renders children straight through when the flag is off - guarded-storage use in auth-token gated via guardedModeEnabled(), so the JWT stays in plain Preferences and the session remains readable without a biometric; the guarded infrastructure stays intact for when it is enabled - onramp-quote: await authReady() before building auth headers so the one un-gated caller parks instead of firing unauthenticated mid-lock - app-lock copy: 'Log in' -> 'Unlock' (en/es-419/pt-BR) — the ceremony is a biometric unlock of an existing session, not a login Adds tests covering guarded mode staying dormant when the flag is off.
|
Pushed Product change — app-open is no longer gated by default. We decided that opening the app and seeing the balance isn't a critical vulnerability (the web app doesn't gate it either); money movement stays passkey-gated at the transaction layer. Rather than drop the work, it's behind a new
Requested code change: Copy nit: Added tests covering guarded mode staying dormant when the flag is off; The device-matrix + residual sign-off gates only apply when |
|
Re-reviewed What's addressed:
Blocker — CI is red (
Two things to flag for the merge decision (not blockers, but be aware):
Once the test is green, I'm good to approve. |
…-through The app-open lock is now dormant behind OPEN_GATED (default off), so the guarded-mode lock tests failed — the effect returns early and never locks. Mock the flag on for those cases and add a case asserting a guarded session opens straight through when the flag is off.
Mirror production, where OPEN_GATED is off when NEXT_PUBLIC_APP_OPEN_GATED is unset. The gate-exercising cases enable it explicitly.
Closes #2472 — the follow-up to #2461, which shipped the app lock as a privacy screen only.
What changes
Three session modes on native, detected once per launch (
src/utils/auth-token.ts):@capgo/capacitor-native-biometric(pinned 8.6.0, clears the 14-day dependency floor) withAccessControl.BIOMETRY_CURRENT_SET: KeychainSecAccessControlon iOS, aBiometricPrompt.CryptoObject-bound Keystore key on Android. The unlock ceremony IS the token read — one OS biometric prompt both proves presence and releases the credential. No separate WebAuthn assertion.Fail closed: the lock decision derives from a non-secret presence marker (
guarded-token-present), never from the user query. Deleting the marker or any local pref yields a signed-out app — never an open session — because the JWT itself is unreadable without a biometric.Session paused while locked:
authReady()parks every API caller (a locked app never emits an unauthenticated request that would 401 →clearAuthToken()); the[USER]query is disabled (which also killsrefetchOnWindowFocuson resume); the auto-refresh poller skips its tick; a sliding-refresh token landing after suspension is dropped. On unlock, react-query's stale refetch doubles as the post-unlock refresh.Re-enrollment = session expired, never a stuck lock:
BIOMETRY_CURRENT_SETinvalidates the item when biometrics change (by design). Both platforms surface this as not-found → clean logout to /setup.card.ts fix (from the #2463 re-review):
services/card.tsread thejwt-tokenweb cookie directly, which never worked on native — now routed throughauthReady()+getAuthHeaders().Android write-prompt nuance
Keystore writes prompt outside a post-auth window (per-op auth keys). Handled with
authValidityDuration: 60: the unlock read opens a 60s window that silently covers the re-mint/users/meships right after unlock; writes outside the window are skipped (memory-only, server re-mints later). iOS Keychain writes never prompt. Consequence: on Android, login and one-time migration each show one extra 'Protect Credentials' prompt.Deliberate trade-offs
skipBackendCall): there's no token in memory to authenticate the revocation POST. tokenVersion isn't bumped — acceptable since the attacker can't extract the guarded JWT.BIOMETRY_CURRENT_SET/BIOMETRY_ANYon iOS andAUTH_BIOMETRIC_STRONGkeys on Android. After an OS biometric lockout the escape hatch is Log out → fresh login.Testing
setAuthTokenno-op, silent-window write policy, migration round-trip lifecycle, plugin error-code mapping, lock registry, and a D7 regression test (gate locks without waiting for the user query — the permanent-white-screen trap).Plugin gate verified by code-read
iOS:
SecAccessControlCreateWithFlags(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet)+SecItemCopyMatchingwithLAContext. Android: Keystore AES-GCM withsetUserAuthenticationRequired(true),setUserAuthenticationValidityDurationSeconds(-1)(per-op),setInvalidatedByBiometricEnrollment(true), reads/writes throughBiometricPrompt.CryptoObjectciphers. The ungatedgetCredentials()/verifyIdentity()paths are never used.