diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index e1892bdc37..6bb0f37aca 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -10,6 +10,7 @@ import { ConsoleGreeting } from '@/components/Global/ConsoleGreeting' import RainCooldownIntroModal from '@/components/Global/RainCooldown/IntroModal' import StaleCardApprovalReEnableModal from '@/components/Global/StaleCardApproval/ReEnableModal' import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' +import { AppLockGate } from '@/components/Global/AppLock' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' import { PeanutProvider } from '@/config/peanut.config' @@ -60,7 +61,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { )} - {children} + {/* Wraps rather than sits beside the page: while the + native app is locked, nothing protected renders. */} + {children} diff --git a/src/components/Global/AppLock/index.tsx b/src/components/Global/AppLock/index.tsx new file mode 100644 index 0000000000..0d5b956140 --- /dev/null +++ b/src/components/Global/AppLock/index.tsx @@ -0,0 +1,165 @@ +'use client' + +/** + * Native app lock. Wraps the app on cold start and whenever it returns from + * more than LOCK_AFTER_BACKGROUND_MS in the background, so a session that + * outlives the user's attention doesn't hand the account to whoever picks the + * phone up next. + * + * It is a gate, not an overlay: while locked, the protected tree is not + * rendered at all. Nothing paints behind the lock screen and nothing back + * there is focusable or reachable by assistive tech. The cost is that + * remounting on unlock loses in-flight component state — acceptable, since the + * lock only fires on cold start (no state yet) or after five minutes + * backgrounded (where iOS has often discarded the webview anyway). + * + * Web is unaffected — there is no OS-backed presence check to lean on there, + * and a browser tab has no equivalent of "resumed from background". + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Button } from '@/components/0_Bruddle/Button' +import { useAuth } from '@/context/authContext' +import { isCapacitor } from '@/utils/capacitor' +import { getUserPreferences } from '@/utils/general.utils' +import { LOCK_AFTER_BACKGROUND_MS, requestLocalUserPresence } from '@/utils/app-lock' + +/** + * `pending` is the state that makes this a boundary rather than a curtain: on + * native we enter it on the very first client paint, before the user query can + * resolve and render balances. Only once auth settles do we learn whether this + * becomes `locked` or, for a signed-out user or one with no usable credential, + * `open`. + */ +type GateState = 'open' | 'pending' | 'locked' + +function LockScreen({ + failed, + unlocking, + onUnlock, + onLogout, +}: { + failed: boolean + unlocking: boolean + onUnlock: () => void + onLogout: () => void +}) { + return ( +
+
+

Peanut is locked

+

+ {failed ? 'Could not confirm it is you. Try again to continue.' : 'Confirm it is you to continue.'} +

+
+
+ + +
+
+ ) +} + +export function AppLockGate({ children }: { children: React.ReactNode }) { + const { user, isFetchingUser, logoutUser } = useAuth() + const userId = user?.user.userId + const [state, setState] = useState('open') + const [unlocking, setUnlocking] = useState(false) + const [failed, setFailed] = useState(false) + const backgroundedAt = useRef(null) + + const credentialId = userId ? getUserPreferences(userId)?.webAuthnKey?.authenticatorId : undefined + + // Close the gate on the first client paint, before anything protected can + // render. Runs once — later transitions are driven by resume or unlock. + useEffect(() => { + if (isCapacitor()) setState('pending') + }, []) + + useEffect(() => { + if (state !== 'pending' || isFetchingUser) return + // Nothing to protect, or no credential we could ever prompt against — + // a gate we can't open would strand the user in their own app. + setState(userId && credentialId ? 'locked' : 'open') + }, [state, isFetchingUser, userId, credentialId]) + + const attemptUnlock = useCallback(async () => { + setUnlocking(true) + const outcome = await requestLocalUserPresence(credentialId) + setUnlocking(false) + if (outcome === 'unlocked' || outcome === 'unsupported') { + setFailed(false) + setState('open') + return + } + setFailed(true) + }, [credentialId]) + + useEffect(() => { + if (!isCapacitor() || !userId || !credentialId) return + + let removeListener: (() => void) | undefined + let cancelled = false + + import('@capacitor/app') + .then(({ App }) => + App.addListener('appStateChange', ({ isActive }) => { + if (!isActive) { + backgroundedAt.current = Date.now() + return + } + const since = backgroundedAt.current + backgroundedAt.current = null + if (since !== null && Date.now() - since > LOCK_AFTER_BACKGROUND_MS) { + setFailed(false) + setState('locked') + } + }) + ) + .then((handle) => { + if (cancelled) { + handle.remove() + return + } + removeListener = () => handle.remove() + }) + .catch(() => { + // No @capacitor/app bridge (web bundle, or an old native shell): + // resume-locking is unavailable. Cold-start locking still works. + }) + + return () => { + cancelled = true + removeListener?.() + } + }, [userId, credentialId]) + + // Prompt as soon as the gate closes, so the common case is one Face ID + // prompt and no taps at all. + useEffect(() => { + if (state === 'locked' && !unlocking && !failed) void attemptUnlock() + // Deliberately keyed on `state` alone: including attemptUnlock or the + // transient flags would re-fire the prompt in a loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]) + + if (state === 'open') return <>{children} + + // 'pending': auth hasn't settled, so we don't yet know whether to prompt. + // Show the bare cover rather than the "locked" copy, which would be a lie + // for a signed-out user about to be let straight through. + if (state === 'pending') return
+ + return ( + void attemptUnlock()} + onLogout={() => void logoutUser()} + /> + ) +} diff --git a/src/utils/__tests__/app-lock.test.ts b/src/utils/__tests__/app-lock.test.ts new file mode 100644 index 0000000000..e5d8558fb2 --- /dev/null +++ b/src/utils/__tests__/app-lock.test.ts @@ -0,0 +1,72 @@ +/** + * The lock's safety property is asymmetric: failing to lock is a security + * weakness, but failing to UNLOCK strands the user in their own app with no + * way out. These tests pin which failure modes fall which way. + */ +import { webcrypto } from 'node:crypto' + +import { requestLocalUserPresence } from '../app-lock' +import { base64URLToBytes } from '../native-webauthn' + +if (!globalThis.crypto?.getRandomValues) { + Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true }) +} + +const CREDENTIAL_ID = 'YWJjZGVmZ2g' + +function withCredentialsGet(impl: () => Promise) { + Object.defineProperty(globalThis, 'PublicKeyCredential', { value: function () {}, configurable: true }) + Object.defineProperty(globalThis.navigator, 'credentials', { + value: { get: impl }, + configurable: true, + }) +} + +describe('requestLocalUserPresence', () => { + it('reports unsupported when there is no stored credential to prompt against', async () => { + withCredentialsGet(async () => ({}) as Credential) + await expect(requestLocalUserPresence(undefined)).resolves.toBe('unsupported') + }) + + it('reports unsupported when the platform has no WebAuthn', async () => { + Object.defineProperty(globalThis, 'PublicKeyCredential', { value: undefined, configurable: true }) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unsupported') + }) + + it('unlocks when the authenticator returns an assertion', async () => { + withCredentialsGet(async () => ({}) as Credential) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unlocked') + }) + + it('stays locked when the user cancels the prompt', async () => { + withCredentialsGet(async () => { + throw Object.assign(new Error('cancelled'), { name: 'NotAllowedError' }) + }) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('dismissed') + }) + + it('reports unsupported when the authenticator rejects the request outright', async () => { + withCredentialsGet(async () => { + throw Object.assign(new Error('nope'), { name: 'NotSupportedError' }) + }) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unsupported') + }) + + it('pins the request to the stored credential and demands user verification', async () => { + let received: PublicKeyCredentialRequestOptions | undefined + withCredentialsGet(async (options?: CredentialRequestOptions) => { + received = options?.publicKey + return {} as Credential + }) + await requestLocalUserPresence(CREDENTIAL_ID) + expect(received?.userVerification).toBe('required') + expect(received?.allowCredentials).toHaveLength(1) + // Assert the actual bytes, not just the count: a descriptor for the + // WRONG credential would satisfy a length check while letting some + // other passkey on the device open the lock. + expect(Array.from(new Uint8Array(received!.allowCredentials![0].id as ArrayBuffer))).toEqual( + Array.from(base64URLToBytes(CREDENTIAL_ID)) + ) + expect(new Uint8Array(received!.challenge as ArrayBuffer)).not.toHaveLength(0) + }) +}) diff --git a/src/utils/app-lock.ts b/src/utils/app-lock.ts new file mode 100644 index 0000000000..050f1750e2 --- /dev/null +++ b/src/utils/app-lock.ts @@ -0,0 +1,66 @@ +// Native app lock: a local user-presence gate shown when the app is opened +// cold or resumed after a spell in the background. +// +// This is deliberately a LOCAL check — the assertion is never sent to the API +// for verification. The threat it addresses is physical: someone holding an +// already-unlocked phone. The OS will not produce an assertion without the +// user's biometric or device passcode, which is exactly the property we want. +// Server-side proof of a fresh assertion is a separate concern (step-up auth on +// sensitive endpoints) and does not belong in a UI lock. +// +// It is a privacy screen and a deterrent, NOT account protection: the session +// token stays where it always was (webview storage / cookie jar), reachable by +// anything that can read the app's filesystem, and clearing the stored +// credential id disables the gate entirely. Making it a genuine control means +// moving the token into biometric-guarded Keychain/Keystore — tracked +// separately. + +import { base64URLToBytes } from './native-webauthn' + +/** How long the app may sit in the background before it relocks. */ +export const LOCK_AFTER_BACKGROUND_MS = 5 * 60 * 1000 + +export type UnlockOutcome = 'unlocked' | 'dismissed' | 'unsupported' + +/** + * Prompts for the device biometric/passcode via a WebAuthn assertion against + * the user's existing passkey. + * + * Returns 'unsupported' when we cannot prompt at all — no WebAuthn, no stored + * credential id, or the authenticator rejecting the request outright + * (NotSupportedError). Callers must treat that as "do not lock": a lock we + * can't lift would strand the user in their own app with no way back. That + * makes every 'unsupported' path fail OPEN — this gate is a presence check for + * an honest user, not a security boundary against someone who can strip the + * stored credential id (see the module comment). + */ +export async function requestLocalUserPresence(credentialId?: string): Promise { + if (typeof window === 'undefined') return 'unsupported' + if (!credentialId) return 'unsupported' + if (!window.PublicKeyCredential || !navigator.credentials?.get) return 'unsupported' + + const challenge = new Uint8Array(32) + crypto.getRandomValues(challenge) + + try { + const assertion = await navigator.credentials.get({ + publicKey: { + challenge, + // Copy into a fresh buffer: BufferSource wants Uint8Array, + // and base64URLToBytes is typed over the wider ArrayBufferLike. + allowCredentials: [{ id: new Uint8Array(base64URLToBytes(credentialId)), type: 'public-key' }], + userVerification: 'required', + timeout: 60_000, + }, + }) + return assertion ? 'unlocked' : 'dismissed' + } catch (error) { + // A cancelled prompt and a genuinely broken authenticator are + // indistinguishable here; both leave the app locked with a retry, which + // is the safe direction. NotSupportedError is the exception: it fails + // open (like the pre-flight checks), since it means this device cannot + // complete the ceremony at all and retrying would strand the user. + if (error instanceof Error && error.name === 'NotSupportedError') return 'unsupported' + return 'dismissed' + } +}