Skip to content

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

Merged
kushagrasarathe merged 9 commits into
devfrom
feat/guarded-session-token-dev
Jul 30, 2026
Merged

feat(security): biometric-guarded session token (Keychain/Keystore)#2568
kushagrasarathe merged 9 commits into
devfrom
feat/guarded-session-token-dev

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

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, the authReady() gate in onramp-quote.ts, and the follow-up test fixes are all included here).

Why a new PR: #2489's branch was based on the mobile-release lineage. Repository rules forbid force-pushing the branch, and merging dev into it would drag the whole release-branch ancestry into dev on merge (breaking future backmerges). This branch is the same seven commits transplanted onto dev, plus one commit restoring the mobile-release-lineage pieces dev hasn't received yet (clearEpoch sliding-refresh guard, awaited clearAuthToken on 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 in dev.

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

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

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Jul 30, 2026 9:56am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 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: 92b51aaa-b294-427e-9199-14ba89c7d907

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

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

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6739.16 → 6760.38 (+21.22)
Findings: +4 net (+44 new, -40 resolved)

🆕 New findings (44)

  • critical complexity — src/utils/auth-token.ts — CC 69, MI 56.8, SLOC 346
  • critical complexity — src/components/Global/AppLock/index.tsx — CC 57, MI 61.56, SLOC 171
  • high hotspot — src/context/authContext.tsx — 33 commits, +264/-187 lines since 6 months ago
  • medium high-mdd — src/context/authContext.tsx:65 — AuthProvider: MDD 78.6 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/AppLock/index.tsx:78 — AppLockGate: MDD 57.6 (uses across many lines from declarations)
  • medium high-dlt — src/context/authContext.tsx:65 — AuthProvider: DLT 47 (calls 47 distinct functions — high context load)
  • medium complexity — src/context/authContext.tsx — CC 29, MI 57.49, SLOC 204
  • medium high-mdd — src/hooks/query/user.ts:25 — useUserQuery: MDD 25.7 (uses across many lines from declarations)
  • medium high-mdd — src/context/authContext.tsx:211 — : MDD 23.5 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/AppLock/index.tsx:181 — : MDD 21.8 (uses across many lines from declarations)
  • medium complexity — src/utils/secure-token-store.ts — CC 21, MI 60.56, SLOC 94
  • medium complexity — src/services/card.ts — CC 18, MI 63.61, SLOC 63
  • medium complexity — src/hooks/query/user.ts — CC 16, MI 56.89, SLOC 68
  • medium complexity — src/hooks/useUserAutoRefresh.ts — CC 15, MI 68.56, SLOC 45
  • medium complexity — src/app/actions/onramp-quote.ts — CC 6, MI 57, SLOC 29
  • medium react-effect-derives-state — src/components/Global/AppLock/index.tsx:93 — useEffect with empty deps + setState — derived state anti-pattern
  • low high-dlt — src/components/Global/AppLock/index.tsx:78 — AppLockGate: DLT 28 (calls 28 distinct functions — high context load)
  • low high-dlt — src/context/authContext.tsx:211 — : DLT 23 (calls 23 distinct functions — high context load)
  • low high-mdd — src/hooks/query/user.ts:31 — fetchUser: MDD 19.5 (uses across many lines from declarations)
  • low high-mdd — src/context/authContext.tsx:145 — addAccount: MDD 16.0 (uses across many lines from declarations)

…and 24 more.

✅ Resolved (40)

  • src/components/Global/AppLock/index.tsx — CC 37, MI 65.54, SLOC 103
  • src/context/authContext.tsx — 31 commits, +250/-185 lines since 6 months ago
  • src/context/authContext.tsx:64 — AuthProvider: MDD 77.3 (uses across many lines from declarations)
  • src/context/authContext.tsx:64 — AuthProvider: DLT 46 (calls 46 distinct functions — high context load)
  • src/context/authContext.tsx — CC 29, MI 57.6, SLOC 202
  • src/utils/auth-token.ts — CC 29, MI 63.3, SLOC 128
  • src/components/Global/AppLock/index.tsx:67 — AppLockGate: MDD 26.9 (uses across many lines from declarations)
  • src/context/authContext.tsx:199 — : MDD 23.5 (uses across many lines from declarations)
  • src/hooks/query/user.ts:25 — useUserQuery: MDD 22.6 (uses across many lines from declarations)
  • src/services/card.ts — CC 18, MI 63.92, SLOC 62
  • src/hooks/query/user.ts — CC 15, MI 57.6, SLOC 64
  • src/app/actions/onramp-quote.ts — CC 6, MI 57.7, SLOC 27
  • src/components/Global/AppLock/index.tsx:79 — useEffect with empty deps + setState — derived state anti-pattern
  • src/components/Global/AppLock/index.tsx:83 — small useEffect that only sets state from deps
  • src/context/authContext.tsx:199 — : DLT 23 (calls 23 distinct functions — high context load)
  • src/components/Global/AppLock/index.tsx:67 — AppLockGate: DLT 19 (calls 19 distinct functions — high context load)
  • src/components/Global/AppLock/index.tsx:102 — : MDD 17.8 (uses across many lines from declarations)
  • src/hooks/query/user.ts:31 — fetchUser: MDD 16.7 (uses across many lines from declarations)
  • src/context/authContext.tsx:133 — addAccount: MDD 16.0 (uses across many lines from declarations)
  • src/hooks/query/user.ts:25 — useUserQuery: DLT 15 (calls 15 distinct functions — high context load)

…and 20 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/utils/secure-token-store.ts 0.0 6.2 +6.2
src/utils/app-lock-state.ts 0.0 3.9 +3.9
src/utils/auth-token.ts 7.0 10.1 +3.1
src/hooks/useAppLocked.ts 0.0 3.1 +3.1
src/components/Global/AppLock/index.tsx 8.0 9.5 +1.5
src/constants/app-lock.consts.ts 0.0 1.0 +1.0
src/hooks/query/user.ts 11.2 11.8 +0.7
src/app/actions/onramp-quote.ts 7.4 8.0 +0.6

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2379 ran, 0 failed, 0 skipped, 41.7s

📊 Coverage (unit)

metric %
statements 61.6%
branches 44.6%
functions 51.2%
lines 62.1%
⏱ 10 slowest test cases
time test
3.8s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.3s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.4s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/auth-token.test.ts › ignores the guarded marker and falls back to the plain token
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/utils/__tests__/auth-token.test.ts › is none — never guarded — when only the guarded marker is present
0.3s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

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

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() in onramp-quote.ts is present; apiFetch and getSessionTokenForSocket also 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=falseguardedModeEnabled() 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 detectSessionMode returns guarded (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); iOS kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, Android per-op auth key + setInvalidatedByBiometricEnrollment(true).
  • Lifecycle (store/read/clear) consistent; clearAuthToken wipes memory + Keychain + Preferences + cookie jar + localStorage; clearEpoch guard 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.
@kushagrasarathe
kushagrasarathe merged commit 4a20568 into dev Jul 30, 2026
19 of 20 checks passed
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