Closes #49 Implemented Biometric Gated Session#49
Open
p3ris0n wants to merge 1 commit into
Open
Conversation
Member
|
Your PR title isn't definitive enough.. It should carry what you fixed not just Closes #49 |
EmeditWeb
requested changes
Jul 18, 2026
EmeditWeb
left a comment
Member
There was a problem hiding this comment.
Your PR Summary Tittle is vague.. please check recent comments and fix. Thank you
Author
|
@EmeditWeb I've fixed the description, thank you |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(security): persist lock state to secure storage, enforce backoff/idle lock across restarts, and add full WalletConnect v2 teardown on sign-out
Closes #39, #41
Summary
security.store.tscurrently holdsisLocked,failedAttempts, andbiometricCheckDonein memory only. Killing and relaunching the app resets all three to their defaults, which means:handleSignOut()clears local app state but never tells the WalletConnect relay to close the session, so the dApp side (and the relay) still sees an active, signable session after the user believes they've signed out.This PR makes lock state durable and hardware-backed, makes the biometric gate re-run on every cold start and resume (not just once per install), and makes sign-out a real teardown that revokes the WC v2 session and clears every store atomically.
Root cause
The bug isn't just "state isn't persisted" — it's that
isLockeddefaulting tofalseon every launch means the app never routes through the biometric gate at all, because the gate is only renderedif (isLocked).biometricCheckDoneresetting tofalselooks like a bug but is actually correct behavior in isolation (you want a fresh check per session) — it only becomes a bypass becauseisLockedalso resets tofalse, so nothing ever asks for that fresh check.So the fix treats these fields differently:
Implementation
src/security/secureStorage.ts(new)Thin wrapper around
expo-secure-storethat:getItem/setItem/removeItemwith JSON serialization, matching theStateStorageinterface Zustand'spersistmiddleware expects.SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY(iOS Keychain) so the vault can't be restored onto another device from a backup.Mapwith a console warning ifSecureStore.isAvailableAsync()is false (e.g. some emulators), rather than throwing — app still works, just without persistence, and we log this loudly so it's not silently insecure in prod. Emits aSecurityStorageDegradedErrorevent other layers can watch for.src/security/security.store.ts(rewrite)persist(..., { storage: createJSONStorage(() => secureStorage) }).partializeexcludesbiometricCheckDonefrom the persisted blob so it always bootsfalse.onRehydrateStorage: after hydration, immediately re-evaluatesisLocked— iflockoutUntilis in the past, clears it and resetsfailedAttemptsto 0; if idle threshold (default 5 min, configurable) has elapsed sincelastActiveAt, forcesisLocked = true. This runs before any protected route can render (see gate below), so there's no frame where a stale unlocked state flashes.recordFailedAttempt(): incrementsfailedAttempts, computes backoff via exponential schedule[0, 0, 0, 30_000, 60_000, 120_000, 300_000](index-clamped, first 3 attempts free), setslockoutUntil = Date.now() + backoff, setsisLocked = true.recordSuccessfulAuth(): resetsfailedAttemptsto 0, clearslockoutUntil, setsisLocked = false, setsbiometricCheckDone = true(in-memory).touchActivity(): updateslastActiveAt; called from a root-levelPanResponder/onTouchStartlistener and on navigation events, throttled to once per 10s to avoid excessive writes.lock()/evaluateIdleLock(): pure functions, unit-testable without React or native modules involved.src/security/BiometricGate.tsx(new)app/_layout.tsx.isLockedor idle-expired, callsLocalAuthentication.authenticateAsync(). Success →recordSuccessfulAuth(). Failure →recordFailedAttempt(). Renders a lock screen (with the current lockout countdown, sourced fromlockoutUntil) whileisLockedis true, regardless ofbiometricCheckDone.AppStatechanges: onbackground → active, setsbiometricCheckDone = falseand re-runs the same check — this is the actual fix for "bypassed by backgrounding/relaunching."SecureStorehydration is in flight (hasHydratedflag from the store) — previously the app could render protected content for one frame before hydration completed.LocalAuthentication.hasHardwareAsync()is false or no biometrics are enrolled, falls back to a PIN/passcode prompt rather than silently unlocking — never fail open.isLocked: false, failedAttempts: 0but still requires biometric enrollment prompt if the user enabled the "require biometric" setting elsewhere.services/wallet.service.tsdisconnectSession(): Promise<void>:clearPersistedSession()explicitly clears the WC SDK's storage keys (it uses its own AsyncStorage-backed store by default) since those aren't covered by our SecureStore migration and shouldn't hold session material after sign-out either way.app/settings.tsxhandleSignOutis nowasyncand sequences teardown as:await walletService.disconnectSession()— best-effort, never throws past this point.resetAllStores()— new helper (see below) clears wallet, auth, and loan stores.isSigningOut) that disables the sign-out button and shows a spinner during the async teardown, so a slow relay disconnect can't be interrupted by a double-tap.disconnectSession()reports a hard failure via the error-reporting hook — sign-out still completes locally (we don't want to trap a user in a signed-in state because the relay was unreachable), but they're told the remote session may still be active and to revoke it manually as a fallback.src/security/resetAllStores.ts(new)getState().reset()(wallet, auth, loan) inside one synchronous pass, plussecurityStoragecleanup of any auth-adjacent secure keys (session tokens, cached credentials) — deliberately does not resetsecurity.store.tsitself, so failed-attempt/lockout history survives a sign-out (a device that was locked out stays locked out for the next user of that device).awaitbetween them, so there's no interleaving window where one store is cleared and another still holds stale user data. It is not a DB transaction — if this is meant to be transactional across an actual persistence layer, flag it and I'll add a rollback path.Testing
security.store.test.ts:isLocked/failedAttempts/lockoutUntilsurvive andbiometricCheckDonedoes not.lockoutUntil(not a relative timer) is what's checked on "restart."isLockedis forced true on rehydrate.BiometricGate.test.tsx: mocksAppStatetransitions, assertsauthenticateAsyncis called again on background→active even when a prior session hadbiometricCheckDone: true.wallet.service.test.ts: assertsdisconnectSessioncallssignClient.disconnectwith the active topic, resolves even whendisconnectrejects, and is a no-op (doesn't throw) when there's no active session.settings.test.tsx: assertshandleSignOutclears wallet/auth/loan stores even whendisconnectSessionrejects, and thatsecurity.storeis untouched by sign-out.Manual QA
Acceptance criteria
Pre-PR checklist
context/architecture-context.mdandcontext/code-standards.mdreadnpx expo export --platform webpassesconstants/colors.tslucide-react-nativeonly (lock/unlock icons on the gate screen, spinner on sign-out button)Open questions / follow-ups
security.storeitself be cleared on sign-out (fresh lockout state for the next user) or preserved (current behavior, treats lockout as device-level not account-level)? Went with device-level for now — easy to flip if that's wrong for this product.secureStoragewrapper (rather than just wiping it on sign-out) is a larger change I scoped out of this PR — worth a follow-up issue if session data at rest is in scope for fix: persist security store state to prevent lockout bypass #39's threat model.Closes #45