feat(security): biometric-guarded session token (Keychain/Keystore) - #2568
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).
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').
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.
…-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.
…ing onto dev The epoch guard (getClearEpoch + the sliding-refresh drop), the awaited clearAuthToken on 401/404, and the prompt/logOut message keys predate this branch on the mobile-release lineage; dev has not received them yet, so the rebase silently resolved those regions to dev's older state while the branch's code and tests depend on them.
|
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:
Comment |
Code-analysis diffPainscore total: 6739.16 → 6760.38 (+21.22) 🆕 New findings (44)
…and 24 more. ✅ Resolved (40)
…and 20 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
kushagrasarathe
left a comment
There was a problem hiding this comment.
Approving — READY. Reviewed against: code quality, DRY, bug risk, security, funds/trust.
What it does: Adds biometric-guarded session-token infrastructure (Keychain/Keystore), shipped dormant behind OPEN_GATED (default off). A default production build is byte-identical to current behavior with zero on-device biometric-binding risk until the flag + a binary release deliberately enable it.
Supersedes #2489 — all CHANGES_REQUESTED addressed:
- The blocking
await authReady()inonramp-quote.tsis present;apiFetchandgetSessionTokenForSocketalso await it, so every authenticated caller (incl. the charges WebSocket) parks while locked — no un-gated caller remains. - The two accepted residuals (migration-window plaintext, legacy plain-mode fail-open) are moot while
OPEN_GATED=false—guardedModeEnabled()short-circuits every guarded path.
Security — clean:
- Token never logged / sent to Sentry; error paths carry the plugin's message, never the secret.
- Gate fails closed: the "can't tell" branch in
detectSessionModereturnsguarded(stays locked), never falls open; lock decision derives from a non-secret presence marker, and the JWT is unreadable without a biometric. - Access control
BIOMETRY_CURRENT_SET(invalidates on re-enrollment → clean logout); iOSkSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, Android per-op auth key +setInvalidatedByBiometricEnrollment(true). - Lifecycle (store/read/clear) consistent;
clearAuthTokenwipes memory + Keychain + Preferences + cookie jar + localStorage;clearEpochguard prevents a sliding-refresh token resurrecting a cleared session.
Funds/trust: Cannot hurt funds — money movement stays passkey-gated at the transaction layer independently of this flag; the guarded path is dead code until an env flag + binary release enable it.
Minor (disclosed & accepted, non-blocking): OTA-toggling OPEN_GATED off after migration cleanup orphans the Keychain-only token → user looks signed-out (must re-login). Not a funds/security risk; just ensure the release note lands if/when the flag is flipped.
On-device iOS+Android matrix + residual sign-off correctly gate flipping the flag on, not this merge.
…conflict Points src/content at dev's current commit (6ad00061928298ea34cf0a76f5a91ef9d1dc2b42) so the feat->dev merge is a trivial (same-value) resolution. No dev history merged in, so the branch's verified-signatures rule only sees this one commit.
Continuation of #2489, retargeted at
dev. Same feature, same reviewed content — see #2489 for kushagrasarathe's adversarial review (design approved; the one blocking code item, theauthReady()gate inonramp-quote.ts, and the follow-up test fixes are all included here).Why a new PR: #2489's branch was based on the
mobile-releaselineage. Repository rules forbid force-pushing the branch, and mergingdevinto it would drag the whole release-branch ancestry intodevon merge (breaking future backmerges). This branch is the same seven commits transplanted ontodev, plus one commit restoring the mobile-release-lineage piecesdevhasn't received yet (clearEpochsliding-refresh guard, awaitedclearAuthTokenon 401/404, lock-screen message keys).The feature is already merged directly into
mobile-release(87f14c4) per our release-branch flow; this PR lands it indev.Original description follows.
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.