Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/app/ClientProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -60,7 +61,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
<HarnessBootstrap />
</Suspense>
)}
{children}
{/* Wraps rather than sits beside the page: while the
native app is locked, nothing protected renders. */}
<AppLockGate>{children}</AppLockGate>
</TranslationSafeWrapper>
</FooterVisibilityProvider>
</ContextProvider>
Expand Down
165 changes: 165 additions & 0 deletions src/components/Global/AppLock/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 z-[9999] flex flex-col items-center justify-center gap-6 bg-white px-6">
<div className="text-center">
<h1 className="text-2xl font-bold">Peanut is locked</h1>
<p className="mt-2 text-sm text-grey-1">
{failed ? 'Could not confirm it is you. Try again to continue.' : 'Confirm it is you to continue.'}
</p>
</div>
<div className="flex w-full max-w-xs flex-col gap-3">
<Button variant="purple" shadowSize="4" loading={unlocking} onClick={onUnlock}>
Unlock
</Button>
<Button variant="stroke" shadowSize="4" onClick={onLogout}>
Log out
</Button>
</div>
</div>
)
}

export function AppLockGate({ children }: { children: React.ReactNode }) {
const { user, isFetchingUser, logoutUser } = useAuth()
const userId = user?.user.userId
const [state, setState] = useState<GateState>('open')
const [unlocking, setUnlocking] = useState(false)
const [failed, setFailed] = useState(false)
const backgroundedAt = useRef<number | null>(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 <div className="fixed inset-0 z-[9999] bg-white" />

return (
<LockScreen
failed={failed}
unlocking={unlocking}
onUnlock={() => void attemptUnlock()}
onLogout={() => void logoutUser()}
/>
)
}
72 changes: 72 additions & 0 deletions src/utils/__tests__/app-lock.test.ts
Original file line number Diff line number Diff line change
@@ -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<Credential | null>) {
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)
})
})
66 changes: 66 additions & 0 deletions src/utils/app-lock.ts
Original file line number Diff line number Diff line change
@@ -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<UnlockOutcome> {
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<ArrayBuffer>,
// 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'
}
}
Loading