Skip to content

Closes #49 Implemented Biometric Gated Session#49

Open
p3ris0n wants to merge 1 commit into
StepFi-app:mainfrom
p3ris0n:feat/implement-biometric-gated-session
Open

Closes #49 Implemented Biometric Gated Session#49
p3ris0n wants to merge 1 commit into
StepFi-app:mainfrom
p3ris0n:feat/implement-biometric-gated-session

Conversation

@p3ris0n

@p3ris0n p3ris0n commented Jul 18, 2026

Copy link
Copy Markdown

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.ts currently holds isLocked, failedAttempts, and biometricCheckDone in memory only. Killing and relaunching the app resets all three to their defaults, which means:

  • A device that was locked out after repeated failed biometric attempts is unlocked again just by force-quitting and reopening the app.
  • Idle auto-lock never survives a restart, so it can't actually enforce anything.
  • 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 isLocked defaulting to false on every launch means the app never routes through the biometric gate at all, because the gate is only rendered if (isLocked). biometricCheckDone resetting to false looks like a bug but is actually correct behavior in isolation (you want a fresh check per session) — it only becomes a bypass because isLocked also resets to false, so nothing ever asks for that fresh check.

So the fix treats these fields differently:

Field | Persisted? | Why -- | -- | -- isLocked | ✅ SecureStore | Must survive restart — this is the actual gate failedAttempts | ✅ SecureStore | Backoff math depends on this surviving restart lockoutUntil | ✅ SecureStore (new) | Absolute timestamp, not a countdown — can't be reset by relaunching lastActiveAt | ✅ SecureStore (new) | Needed to compute idle time across a cold start biometricCheckDone | ❌ in-memory only | Intentionally session-scoped — reset on every cold start and every background→active transition, so the gate is forced to re-run. The old bug was that it never got asked to.

Implementation

src/security/secureStorage.ts (new)

Thin wrapper around expo-secure-store that:

  • Exposes getItem/setItem/removeItem with JSON serialization, matching the StateStorage interface Zustand's persist middleware expects.
  • Uses SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY (iOS Keychain) so the vault can't be restored onto another device from a backup.
  • Debounces writes (150ms trailing) so rapid failed-attempt increments don't hammer the Keychain/Keystore with synchronous writes on every keystroke-speed retry.
  • Falls back to an in-memory Map with a console warning if SecureStore.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 a SecurityStorageDegradedError event other layers can watch for.

src/security/security.store.ts (rewrite)

  • Zustand store with persist(..., { storage: createJSONStorage(() => secureStorage) }).
  • New shape:
ts
  interface SecurityState {
    isLocked: boolean;
    failedAttempts: number;
    lockoutUntil: number | null;   // epoch ms, absolute — not a duration
    lastActiveAt: number;          // epoch ms, updated on any user interaction
    biometricCheckDone: boolean;   // NOT persisted — see partialize below
  }
  • partialize excludes biometricCheckDone from the persisted blob so it always boots false.
  • onRehydrateStorage: after hydration, immediately re-evaluates isLocked — if lockoutUntil is in the past, clears it and resets failedAttempts to 0; if idle threshold (default 5 min, configurable) has elapsed since lastActiveAt, forces isLocked = true. This runs before any protected route can render (see gate below), so there's no frame where a stale unlocked state flashes.
  • recordFailedAttempt(): increments failedAttempts, computes backoff via exponential schedule [0, 0, 0, 30_000, 60_000, 120_000, 300_000] (index-clamped, first 3 attempts free), sets lockoutUntil = Date.now() + backoff, sets isLocked = true.
  • recordSuccessfulAuth(): resets failedAttempts to 0, clears lockoutUntil, sets isLocked = false, sets biometricCheckDone = true (in-memory).
  • touchActivity(): updates lastActiveAt; called from a root-level PanResponder/onTouchStart listener 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)

  • Wraps the protected navigator stack in app/_layout.tsx.
  • On mount (cold start): if isLocked or idle-expired, calls LocalAuthentication.authenticateAsync(). Success → recordSuccessfulAuth(). Failure → recordFailedAttempt(). Renders a lock screen (with the current lockout countdown, sourced from lockoutUntil) while isLocked is true, regardless of biometricCheckDone.
  • Subscribes to AppState changes: on background → active, sets biometricCheckDone = false and re-runs the same check — this is the actual fix for "bypassed by backgrounding/relaunching."
  • Loading state: shows a spinner while SecureStore hydration is in flight (hasHydrated flag from the store) — previously the app could render protected content for one frame before hydration completed.
  • Error state: if LocalAuthentication.hasHardwareAsync() is false or no biometrics are enrolled, falls back to a PIN/passcode prompt rather than silently unlocking — never fail open.
  • Empty state: first-ever launch (no persisted record) defaults to isLocked: false, failedAttempts: 0 but still requires biometric enrollment prompt if the user enabled the "require biometric" setting elsewhere.

services/wallet.service.ts

  • Added disconnectSession(): Promise<void>:
ts
  async disconnectSession() {
    const topic = this.signClient?.session.getAll()[0]?.topic;
    if (!topic) return; // nothing to tear down
    await this.signClient.disconnect({
      topic,
      reason: getSdkError('USER_DISCONNECTED'),
    });
    await this.clearPersistedSession(); // wipes WC's own pairing/session storage
  }
  • Wrapped in try/catch internally — a relay timeout or dead session shouldn't block sign-out. Logs to the existing error-reporting hook on failure but always resolves.
  • 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.tsx

  • handleSignOut is now async and sequences teardown as:
    1. await walletService.disconnectSession() — best-effort, never throws past this point.
    2. resetAllStores() — new helper (see below) clears wallet, auth, and loan stores.
    3. Navigate to the auth/onboarding stack.
  • Added a loading state (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.
  • Added an error toast if 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)

  • Single function that calls each store's getState().reset() (wallet, auth, loan) inside one synchronous pass, plus securityStorage cleanup of any auth-adjacent secure keys (session tokens, cached credentials) — deliberately does not reset security.store.ts itself, 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).
  • "Atomic" here means: all resets are synchronous Zustand state assignments with no await between 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:
    • Persistence round-trip — write state, simulate restart by re-instantiating the store against the same mocked SecureStore backing, assert isLocked/failedAttempts/lockoutUntil survive and biometricCheckDone does not.
    • Backoff schedule — fake timers, assert lockout duration escalates correctly and lockoutUntil (not a relative timer) is what's checked on "restart."
    • Idle lock — advance fake clock past threshold between two store instantiations, assert isLocked is forced true on rehydrate.
  • BiometricGate.test.tsx: mocks AppState transitions, asserts authenticateAsync is called again on background→active even when a prior session had biometricCheckDone: true.
  • wallet.service.test.ts: asserts disconnectSession calls signClient.disconnect with the active topic, resolves even when disconnect rejects, and is a no-op (doesn't throw) when there's no active session.
  • settings.test.tsx: asserts handleSignOut clears wallet/auth/loan stores even when disconnectSession rejects, and that security.store is untouched by sign-out.

Manual QA

  1. Fail biometric 4x, force-quit the app, relaunch → still locked out with the correct remaining backoff, not reset to attempt 1.
  2. Background the app past the idle threshold, resume → biometric gate re-prompts even though it passed on cold start.
  3. Sign in, connect a dApp via WC v2, sign out, check the dApp/relay side → session shows disconnected, not just "app-side logged out."
  4. Kill the relay connection (airplane mode) mid sign-out → sign-out still completes locally and surfaces the fallback warning toast.

Acceptance criteria

  • Lock status and failed-attempt count persist across app restart
  • Lockout backoff and idle auto-lock cannot be bypassed by relaunching
  • Biometric gate re-runs on cold start and resume
  • Sign-out revokes WalletConnect v2 session and clears all stores
  • No secrets in plain AsyncStorage; secure storage used throughout (WC SDK's own AsyncStorage-backed session data is explicitly wiped on sign-out rather than migrated, since it shouldn't outlive the session anyway)
  • Tests cover lockout persistence and full sign-out teardown

Pre-PR checklist

Open questions / follow-ups

  • Should security.store itself 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.
  • WC SDK's default storage isn't SecureStore-backed. Fully migrating it to our secureStorage wrapper (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

@p3ris0n
p3ris0n requested a review from EmeditWeb as a code owner July 18, 2026 09:22
@EmeditWeb

Copy link
Copy Markdown
Member

Your PR title isn't definitive enough.. It should carry what you fixed not just Closes #49

@EmeditWeb EmeditWeb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your PR Summary Tittle is vague.. please check recent comments and fix. Thank you

@p3ris0n

p3ris0n commented Jul 18, 2026

Copy link
Copy Markdown
Author

@EmeditWeb I've fixed the description, thank you

@p3ris0n
p3ris0n requested a review from EmeditWeb July 23, 2026 07:25
@p3ris0n p3ris0n changed the title Closes #45 Closes #49 Implemented Biometric Gated Session Jul 23, 2026
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.

core: implement biometric-gated secure session vault with auto-lock fix: persist security store state to prevent lockout bypass

2 participants