Skip to content

feat(security): biometric-guarded session token (Keychain/Keystore)#2489

Open
innolope-dev wants to merge 7 commits into
mobile-releasefrom
feat/guarded-session-token
Open

feat(security): biometric-guarded session token (Keychain/Keystore)#2489
innolope-dev wants to merge 7 commits into
mobile-releasefrom
feat/guarded-session-token

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

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):

  • guarded — the JWT is stored via @capgo/capacitor-native-biometric (pinned 8.6.0, clears the 14-day dependency floor) with AccessControl.BIOMETRY_CURRENT_SET: Keychain SecAccessControl on iOS, a BiometricPrompt.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.
  • plain — byte-for-byte the previous behavior, kept for older binaries running OTA'd JS (plugin is feature-detected) and for devices without enrolled biometrics. After the legacy gate opens, the session migrates to guarded storage; the plain copy is deleted only after the next cold start's guarded read proves the round-trip.
  • none — signed out, nothing to protect.

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 kills refetchOnWindowFocus on 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_SET invalidates 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.ts read the jwt-token web cookie directly, which never worked on native — now routed through authReady() + 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/me ships 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

  • Lock-screen Log out in guarded mode is local-only (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.
  • Strict biometric-only (no device-passcode fallback): the plugin's gated path exposes only BIOMETRY_CURRENT_SET/BIOMETRY_ANY on iOS and AUTH_BIOMETRIC_STRONG keys on Android. After an OS biometric lockout the escape hatch is Log out → fresh login.
  • OTA rollback after migration cleanup looks signed-out (re-login) — accepted, worth a release-note line.

Testing

  • 168 suites / 2206 tests green, including new coverage: mode-detection truth table (incl. fail-closed uncertainty rule), authReady park/release, suspend-without-epoch-bump, locked setAuthToken no-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).
  • Typecheck: same 228 pre-existing errors before/after (fresh-worktree asset-decl noise), zero introduced.
  • Needs a binary release (new plugin) + the on-device matrix from Native app lock: move the session token into biometric-guarded Keychain/Keystore (real control behind #2461) #2472 before store rollout: unlock→token released; cancel→stays locked; 6-min background; kill/relaunch; re-enroll→session-expired; old-build→new-build migration.

Plugin gate verified by code-read

iOS: SecAccessControlCreateWithFlags(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet) + SecItemCopyMatching with LAContext. Android: Keystore AES-GCM with setUserAuthenticationRequired(true), setUserAuthenticationValidityDurationSeconds(-1) (per-op), setInvalidatedByBiometricEnrollment(true), reads/writes through BiometricPrompt.CryptoObject ciphers. The ungated getCredentials()/verifyIdentity() paths are never used.

…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).
@innolope-dev innolope-dev self-assigned this Jul 23, 2026
@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Error Error Jul 24, 2026 11:04am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: daab744c-02a6-49db-8588-2f5d98a2a038

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/guarded-session-token

Comment @coderabbitai help to get the list of available commands.

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').
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Pushed dab8c99 — lock-screen copy reworded to match #2493 (friendlier log-in framing instead of 'locked / could not confirm'), updated in all three locales. Tests green (messages + app-lock-gate).

@kushagrasarathe kushagrasarathe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:39 skips the gate — add await 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):

  1. Run the on-device iOS + Android matrix — CI cannot prove the native biometric binding actually holds; this must be verified on hardware.
  2. 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.
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Pushed f38dd83 addressing the review:

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 OPEN_GATED flag (NEXT_PUBLIC_APP_OPEN_GATED, default false):

  • AppLockGate renders straight through when off — no lock screen, no biometric to open.
  • Guarded-storage use in auth-token.ts is gated via guardedModeEnabled(), so the JWT stays in plain Preferences and the session is readable without an unlock. The entire guarded infrastructure (secure-token-store, guarded mode, the unlock ceremony) stays intact and re-activates by flipping the flag to true.

Requested code change: onramp-quote.ts now await authReady() before building headers, so the one un-gated caller parks instead of firing unauthenticated mid-lock.

Copy nit: appLock button 'Log in' → 'Unlock' (en/es-419/pt-BR) — it's a biometric unlock of an existing session.

Added tests covering guarded mode staying dormant when the flag is off; auth-token/secure-token-store suites green (69 passing), typecheck clean.

The device-matrix + residual sign-off gates only apply when OPEN_GATED is enabled — with it off there's no guarded token to protect on-device.

@kushagrasarathe

Copy link
Copy Markdown
Contributor

Re-reviewed f38dd83f1 — the design changes are good, but CI is red. One test fix needed before this can merge.

What's addressed:

  • authReady() is now awaited in onramp-quote.ts — the one un-gated caller is fixed. ✅
  • The OPEN_GATED feature-flag approach (default off) is a genuinely better rollout than what I asked for: it ships the biometric-guarded infrastructure dormant, so a default build carries zero on-device biometric-binding risk, and the two residuals I flagged (migration-window plaintext, legacy fail-open) are moot while the flag is off — guardedModeEnabled() short-circuits every guarded path. The guardedModeEnabled() helper centralizes the gate cleanly and all call sites are updated consistently. My earlier conditions are effectively resolved by this design: on-device iOS/Android testing becomes the gate for flipping the flag on, not for merging.

Blocker — CI is red (unit + ci-success failing):
The new gate if (!isCapacitor() || !OPEN_GATED) return in AppLockGate broke two pre-existing tests in src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx:

  • guarded mode: locks and suspends the session without waiting for the user query (D7)
  • guarded mode: stays locked when the prompt is cancelled

OPEN_GATED defaults to false in the test env (no NEXT_PUBLIC_APP_OPEN_GATED set), so the gate now early-returns and never locks — the guarded-mode assertions fail. This file wasn't touched in the commit, so it's stale relative to the new flag.
Fix: set process.env.NEXT_PUBLIC_APP_OPEN_GATED = 'true' at the top of app-lock-gate.test.tsx (same pattern already added to auth-token.test.ts) so the guarded-mode tests actually engage the gate. Then re-run unit.

Two things to flag for the merge decision (not blockers, but be aware):

  1. Merging this ships the app-open lock OFF by default — the JWT stays in plain Preferences, same as current production (no regression, but issue Native app lock: move the session token into biometric-guarded Keychain/Keystore (real control behind #2461) #2472's protection is dormant until NEXT_PUBLIC_APP_OPEN_GATED=true is set, which should follow the on-device test pass).
  2. This commit reverts the lock-screen copy to "Unlock" (from "Log in"), which supersedes fix(app-lock): friendlier lock-screen copy #2493fix(app-lock): friendlier lock-screen copy #2493 can be closed once this lands.

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.
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.

2 participants