Skip to content

feat(security): biometric app lock for the native app - #2461

Merged
kushagrasarathe merged 3 commits into
mainfrom
feat/native-app-lock
Jul 22, 2026
Merged

feat(security): biometric app lock for the native app#2461
kushagrasarathe merged 3 commits into
mainfrom
feat/native-app-lock

Conversation

@innolope-dev

@innolope-dev innolope-dev commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Why

Peanut sessions persist for weeks, so an unlocked phone shows a funded account to whoever is holding it. Every peer consumer-fintech app gates the native shell behind a device biometric; we had nothing.

This is item C of the session-security series. Companion PRs cover step-up auth on sensitive endpoints (the actual server-side control), session lifetime bounds, and a report-only CSP.

What this is — and is not

This is a privacy screen and a deterrent: it stops a person holding the phone from seeing or navigating the app without the owner's biometric. It is not account protection, and it must not be described to users as such:

  • The session token stays where it always was (webview storage / native cookie jar), untouched by this PR. Anything that can read the app's filesystem — a webview inspector on an unlocked device, a backup — gets full API access without ever seeing the lock.
  • Background API polling continues while the gate is up; the gate blocks rendering, not the session.
  • Deleting the stored credentialId preference disables the gate entirely.

Real account protection on a lost device is server-side (step-up auth, #2463/#1217) plus, as a follow-up, moving the token into biometric-guarded Keychain/Keystore so it only materializes after a successful assertion.

What

  • AppLockGate covers the app on cold start and whenever it returns from more than 5 minutes in the background.
  • Unlock is a local WebAuthn assertion against the user's existing passkey — Face ID / Touch ID / device passcode.
  • While pending or locked, the protected tree is not rendered at all — nothing behind the lock screen to focus, screenshot, or expose to assistive tech.
  • Mounted once in ClientProviders. Renders children directly on web.

No new native dependency: @capacitor/app and @capgo/capacitor-passkey are already in the binary, so this ships over Capgo rather than needing a store release.

The local-check decision

The assertion is not sent to the API for verification. The threat model here is physical — someone holding an already-unlocked phone — and the OS will not mint an assertion without user verification, which is the property we want. Proving assertion freshness to the server is a different control (step-up auth on card/withdrawal endpoints) and is deliberately not conflated with a UI lock.

Failing open, on purpose

A lock that cannot be lifted is worse than no lock — it strands users in their own app. The full fail-open surface, explicitly: no stored credential, no WebAuthn on the platform, and the authenticator rejecting the ceremony with NotSupportedError all resolve to unsupported, which the gate treats as "do not lock". A cancelled prompt (NotAllowedError or any other error) keeps the gate up with a retry and a log-out escape. This asymmetry is acceptable precisely because the gate is a deterrent, not a security boundary — see "What this is — and is not".

Testing

src/utils/__tests__/app-lock.test.ts pins which failure modes fall open vs. closed, credential pinning including the exact credential-id bytes, and userVerification: 'required'. Typecheck clean.

  • Native smoke test before merge (not yet run on a device): cold start, background 6 min, resume, cancel the prompt, log-out escape — iOS and Android.

Summary by CodeRabbit

  • New Features
    • Added a native app security gate that unlocks only after a local WebAuthn user-presence check.
    • While locked, protected screens don’t render and a full-screen lock UI provides “Unlock” and “Log out”.
    • Automatically relocks when the app returns from the background after a set timeout.
  • Tests
    • Added Jest coverage for unlock outcomes (unlocked, dismissed/canceled, unsupported) and validation of the generated WebAuthn request parameters.

Sessions persist for weeks, so an unlocked phone was enough to reach a
funded account. Gate the native app behind a local WebAuthn assertion on
cold start and after LOCK_AFTER_BACKGROUND_MS in the background.

The assertion is checked locally, not proven to the API: the threat is
physical device access, and the OS will not produce one without the
user's biometric or passcode. Server-side proof of freshness belongs to
step-up auth on sensitive endpoints, which is a separate change.

Fails open when there is no credential to prompt against or the platform
has no WebAuthn — a lock that cannot be lifted would strand the user.
@innolope-dev innolope-dev self-assigned this Jul 21, 2026
@vercel

vercel Bot commented Jul 21, 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, Comment Jul 22, 2026 9:49am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The PR adds a native Capacitor app lock using local WebAuthn presence verification, including startup and long-background relocking. The gate is mounted globally, and utility tests cover supported, unsupported, dismissed, and successful authentication outcomes.

Native app lock

Layer / File(s) Summary
Local presence verification
src/utils/app-lock.ts, src/utils/__tests__/app-lock.test.ts
Defines WebAuthn unlock outcomes, performs credential-based local presence checks, handles unsupported or rejected prompts, and tests request formation and result mapping.
App lock lifecycle
src/components/Global/AppLock/index.tsx
Tracks lock state, applies native startup and background-timeout locking, automatically prompts for presence, and renders unlock or logout controls.
Global provider integration
src/app/ClientProviders.tsx
Mounts AppLockGate around protected children after the conditional HarnessBootstrap block.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Capacitor
  participant AppLockGate
  participant PresenceCheck
  participant WebAuthn
  Capacitor->>AppLockGate: startup or appStateChange
  AppLockGate->>PresenceCheck: requestLocalUserPresence(credentialId)
  PresenceCheck->>WebAuthn: credentials.get with required verification
  WebAuthn-->>PresenceCheck: assertion or error
  PresenceCheck-->>AppLockGate: UnlockOutcome
  AppLockGate-->>Capacitor: render children or lock overlay
Loading

Suggested labels: enhancement

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Clearly summarizes the main change: adding a biometric app lock for the native app.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/native-app-lock

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

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6212.38 → 6227.37 (+14.99)
Findings: +8 net (+9 new, -1 resolved)

🆕 New findings (9)

  • high complexity — src/components/Global/AppLock/index.tsx — CC 37, MI 66.31, SLOC 96
  • medium high-mdd — src/components/Global/AppLock/index.tsx:67 — AppLockGate: MDD 26.9 (uses across many lines from declarations)
  • medium complexity — src/utils/app-lock.ts — CC 9, MI 57.67, SLOC 26
  • medium react-effect-derives-state — src/components/Global/AppLock/index.tsx:79 — useEffect with empty deps + setState — derived state anti-pattern
  • medium react-effect-derives-state — src/components/Global/AppLock/index.tsx:83 — small useEffect that only sets state from deps
  • low high-dlt — src/components/Global/AppLock/index.tsx:67 — AppLockGate: DLT 19 (calls 19 distinct functions — high context load)
  • low high-mdd — src/components/Global/AppLock/index.tsx:102 — : MDD 17.8 (uses across many lines from declarations)
  • low missing-return-type — src/app/ClientProviders.tsx:34 — ClientProviders: exported fn missing return type annotation
  • low missing-return-type — src/components/Global/AppLock/index.tsx:67 — AppLockGate: exported fn missing return type annotation

✅ Resolved (1)

  • src/app/ClientProviders.tsx:33 — ClientProviders: exported fn missing return type annotation

📈 Painscore deltas (top movers)

File Before After Δ
src/components/Global/AppLock/index.tsx 0.0 7.9 +7.9
src/utils/app-lock.ts 0.0 6.7 +6.7

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2006 ran, 0 failed, 0 skipped, 32.7s

📊 Coverage (unit)

metric %
statements 59.6%
branches 43.4%
functions 48.4%
lines 59.9%
⏱ 10 slowest test cases
time test
3.7s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.1s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s 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/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.2s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.2s src/utils/__tests__/demo-balance.test.ts › resetDemoBalance refills and restarts the TTL window
0.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › keeps stickers off the username pill (final pass respects the keep-out)
0.2s src/components/Card/__tests__/CardCountryConfirmScreen.test.tsx › passes the picked ISO-2 code to onConfirm
0.2s src/utils/__tests__/demo-balance.test.ts › auto-refills a stored balance that has no timestamp (legacy install)
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/utils/__tests__/app-lock.test.ts (1)

61-63: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert the pinned credential ID bytes.

Checking only the descriptor count would still pass if the stored credential were replaced with a different one. Compare allowCredentials[0].id with base64URLToBytes(CREDENTIAL_ID).

Proposed test strengthening
+import { base64URLToBytes } from '../native-webauthn'
+
 ...
         expect(received?.userVerification).toBe('required')
         expect(received?.allowCredentials).toHaveLength(1)
+        expect(received?.allowCredentials?.[0]?.id).toEqual(base64URLToBytes(CREDENTIAL_ID))
         expect(new Uint8Array(received!.challenge as ArrayBufferView['buffer'])).not.toHaveLength(0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/__tests__/app-lock.test.ts` around lines 61 - 63, Strengthen the
assertions in the app-lock test by comparing the first allowCredentials entry’s
id with base64URLToBytes(CREDENTIAL_ID), while retaining the existing length
check and other assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/Global/AppLock/index.tsx`:
- Around line 23-26: Update AppLockGate in
src/components/Global/AppLock/index.tsx at lines 23-26 to initialize native
protected content as pending/blocked until support, credential, and presence
checks complete; at lines 101-120, trap focus and remove or inert all protected
content while locked. In src/app/ClientProviders.tsx lines 59-61, wrap the
protected provider/content branch with AppLockGate instead of mounting the gate
beside it.

---

Nitpick comments:
In `@src/utils/__tests__/app-lock.test.ts`:
- Around line 61-63: Strengthen the assertions in the app-lock test by comparing
the first allowCredentials entry’s id with base64URLToBytes(CREDENTIAL_ID),
while retaining the existing length check and other assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2b221adf-6f00-40e2-bea8-942e06555894

📥 Commits

Reviewing files that changed from the base of the PR and between 4d23ef9 and d1a4751.

📒 Files selected for processing (4)
  • src/app/ClientProviders.tsx
  • src/components/Global/AppLock/index.tsx
  • src/utils/__tests__/app-lock.test.ts
  • src/utils/app-lock.ts

Comment thread src/components/Global/AppLock/index.tsx Outdated
The gate started unlocked and was mounted beside the page, so protected
content could paint before the lock engaged and stayed focusable behind
the overlay.

It now wraps children and closes on the first client paint on native —
before the user query can resolve and render balances. A 'pending' state
covers the window where auth hasn't settled yet, resolving to 'open' for
a signed-out user or one with no usable credential. While locked, the
protected tree is not rendered at all, so nothing sits behind the lock
screen for tab or screen reader to reach.

Tradeoff: unlocking remounts, losing in-flight component state. The lock
only fires on cold start (no state yet) or after five minutes background,
where iOS has usually discarded the webview anyway.

Also assert the pinned credential's actual bytes — a descriptor for the
wrong credential passed the old length-only check.

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

Requesting changes — the lock is cosmetic; don't ship it as account protection

The code is clean, native-scoped, no new dep. But it gates rendering only and does not protect the credential it claims to:

  • The session JWT stays in plain localStorage (auth-token.ts), untouched by this PR, and /users/me keeps polling with that Bearer token while the gate is up.
  • Bypasses on an unlocked/filesystem-accessible device: (a) read localStorage['jwt-token'] via a webview inspector / backup → full API access, biometric never involved; (b) delete ${userId}:user-preferencescredentialId becomes undefined → the gate resolves straight to open.
  • Fails open on no-credential and on NotSupportedError.

Required before merge:

  • Re-scope the claim — describe it as a deterrent / privacy screen, not account protection. Do not tell users (or the "session-security series") that it protects a funded account on a lost device; that's false.
  • Fix the fail-open doc comment — the catch block returns unsupported on NotSupportedError, which attemptUnlock treats as unlock, so the fail-open surface is larger than "only the pre-flight checks."
  • Native smoke test (cold start, 6-min background resume, cancel prompt, log-out escape) — never run on a device.

Follow-up to make it a genuine control: keep the JWT in biometric-guarded Keychain/Keystore and materialize it only after a successful assertion.

The catch block's NotSupportedError path also resolves to unsupported
(which the gate treats as unlock), so 'only the pre-flight checks fail
open' undersold it. Also spell out in the module header that this is a
privacy screen, not account protection — the session token is untouched
by this PR and the gate disappears if the stored credential id is
stripped.
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Accepted in full — the "cosmetic" framing is accurate, and the PR now says so out loud.

Claim re-scoped. The PR description has been rewritten: this is a privacy screen and deterrent, explicitly not account protection. It now spells out the three bypasses you listed (token untouched in webview storage, polling continues behind the gate, deleting the stored credentialId disarms it) so nobody quotes this PR as "lost-device protection" in the session-security series. The module header in app-lock.ts carries the same statement (2131b43).

Fail-open doc fixed (same commit). The docstring and catch comment now state the full fail-open surface including the NotSupportedError path — "only the pre-flight checks fail open" was indeed underselling it.

Native smoke test — still outstanding, listed as an unchecked box in the description; needs a device (cold start, 6-min background resume, cancel prompt, log-out escape, iOS + Android). Same device session should cover #2463's passkey path.

The real control (JWT into biometric-guarded Keychain/Keystore, materialized only after assertion) — agreed as the follow-up; it dovetails with the native transport/header-auth PR that's queued behind #1195, since that's the change that makes JS/native token custody explicit.

One judgment call to flag: your review says the gate "fails open on no-credential and NotSupportedError" as a defect. That behavior is retained deliberately — a lock that can't be lifted strands the user in their own app, and given the gate is now honestly scoped as a deterrent, fail-open on can't-prompt seems like the right trade. If you'd rather it fail closed with a log-out-only escape, say so and it's a small change.

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

Re-reviewed. The honesty condition IS met — the doc now accurately states it is a privacy screen / deterrent, NOT account protection, and names the full fail-open surface. The commits also made it a real render boundary (gates the subtree) and fixed the credential byte-equality check. But it is still fundamentally cosmetic as a security control: the JWT is still in plain localStorage, useUserQuery/sliding-refresh still run while locked (the gate is a child of the auth context, not a parent), and both bypasses remain open by design — reading the token off-device, and deleting ${userId}:user-preferences resolves the gate straight to open. It does no harm and fail-open is the right UX direction. Decision for you/PM: merge it as a deterrent (fine), but it does NOT close the "unlocked phone reaches a funded account" risk — that needs the JWT moved into biometric-guarded Keychain/Keystore (explicitly deferred here). Not clearing my change-request until that framing is acknowledged.

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

Clearing my change request — the framing concern is already fully handled in the PR (the "What this is — and is not" section explicitly states it's a privacy screen / deterrent, NOT account protection, and lists its own bypasses). Approving to ship as an honest deterrent.

The real control (biometric-guarded token storage) is filed as a follow-up: #2472. One non-engineering note: user-facing release/store copy must not upgrade this to "protects your account if your phone is lost."

@kushagrasarathe
kushagrasarathe merged commit e14cddb into main Jul 22, 2026
23 of 25 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