Repository navigation
Conversation
Moves isHashKeyExpired into services/autoLock and adds refreshHashKeyExpiration, the activity re-anchor primitive for #924. Not yet wired into getAuthStatus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getAuthStatus now re-stamps expiresAt/generatedAt on every authenticated active-state check (throttled to 1/hour), so the 72h backstop bounds inactivity instead of time-since-sign-in. Expired, rolled-back, locked, and backgrounded states never refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#924) Adds the assertion the background-check test's comment already promised (a backgrounded device must not consume the still-counting backgrounded-at timestamp), and a test that a failed re-anchor keychain write leaves the session AUTHENTICATED rather than demoting it via getAuthStatus's outer catch. Both were mutation-verified to fail without the code they cover. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…924) refreshHashKeyExpiration re-reads the stored key immediately before the write and re-stamps only if it is field-for-field identical to the caller-validated snapshot, so a logout or corruption wipe landing in the window can no longer be resurrected with a fresh 72h deadline. The getAuthStatus gate also requires a temporary store, so an orphan key from a partial wipe hard-expires instead of being re-anchored by every tick. Also stops a rejected-keychain mock leaking into later tests, and restores AppState.currentState in an afterEach so a failing auto-lock test can no longer cascade into unrelated call-count assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final-review fixes for the activity-anchored hash-key hard-expiry. The generatedAt throttle only advances when the keychain write succeeds, so a persistently failing secureDataStorage.setItem meant every 5s getAuthStatus tick re-attempted the read + write + logger.error — ~720 error lines/hour, drowning the log buffer during exactly the incident you'd want breadcrumbs for. Add a module-level attempt throttle, set before the write so a throwing write still counts, bounding that failure mode to one attempt per window. Export resetHashKeyRefreshAttemptThrottle (mirroring clearSessionAuthValidMemo) so the module state can't leak across tests, and reset it in both suites. Also: - Spread the TOCTOU re-read key rather than the caller's snapshot when re-stamping: identical today, but correct-by-construction if HashKey ever gains a field the explicit comparison stops covering. - Fix the HASH_KEY_REFRESH_THROTTLE_MS comment, which had the bound backwards (a skipped write shortens the idle window to 71-72h, it does not extend it). - Document the ~96h foregrounded-idle worst case on HASH_KEY_EXPIRATION_MS. - Correct the isSessionAuthValid comment: the active path can now perform a throttled keychain write, so it is no longer secure reads only. - Note why the isSessionAuthValid getHashKey call counts hold only under the jest AppState mock's non-active default. - Drop the inline AppState restores made redundant by the describe-level afterEach, and add the missing blank line in constants.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Re-anchors hash-key expiration on foreground activity while preserving hard-expiry and clock-rollback protections.
Changes:
- Adds throttled, TOCTOU-aware hash-key expiration refresh.
- Integrates refresh into active authenticated checks.
- Expands unit and integration coverage for expiry behavior and races.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/services/autoLock.ts |
Adds expiry validation and refresh logic. |
src/ducks/auth.ts |
Refreshes valid keys during foreground authentication checks. |
src/config/constants.ts |
Defines and documents the one-hour refresh throttle. |
__tests__/services/autoLock.test.ts |
Tests refresh guards, throttling, and TOCTOU behavior. |
__tests__/ducks/auth.test.ts |
Tests authentication-flow integration and failure cases. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-3b061866a2c59430ff75 |
|
Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-838b091b9f553edec5e2 |
…lback A backward clock change left lastRefreshAttemptAt in the future, which suppressed every hash-key re-anchor until wall time caught up; past ~71h of rollback that hard-expired even the freshly re-authenticated key despite continued use. Treat a future marker as invalid, mirroring the module's other future-timestamp guards. Flagged by Copilot on #993. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TOCTOU re-read compared only the hash key, so a wipe that removed the temporary store (but not yet the key) between getAuthStatus's entry-time read and the write could still resurrect or extend an orphan key for a fresh 72h. The write-time re-read now also requires the temp store to exist, moving the orphan invariant into the helper itself rather than only the call-site gate. From /code-review confirmed findings on #993. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The write-time re-read proved storage state only up to the point it resolved; a clearTemporaryData wipe starting while it was in flight could still land its removes after the re-anchor's write, re-creating wiped key material. clearTemporaryData now bumps a generation counter synchronously at entry, and refreshHashKeyExpiration compares it — captured before the re-read, checked with no await before the write — refusing the write when a wipe began mid-refresh. A wipe starting after the write is dispatched lands last and wins, so either ordering ends with the key absent. Raised by Copilot on #993. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under sign-in anchoring (#905), LOCKED deliberately outranked the expiry check so routine lock-screen expiry kept the fast unlock reachable. With the TTL re-anchored on activity, an expired key under a persisted soft lock means 72h of genuine idleness — the exact case AC2 requires to force the full password re-auth. getAuthStatus now reports it as HASH_KEY_EXPIRED (consuming the stale LOCKED marker), and the signIn fast path refuses to re-stamp an expired key, falling through to the full rebuild. Raised by Copilot on #993. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fast-path guard added in bb83074 had no direct coverage. Two cases (expired, clock-rolled-back) now assert the LOCKED sign-in falls through to the full temporary-store rebuild — a fresh temp store is written and no write re-stamps the stale key material — using stateful hash-key mocks so the background getActiveAccount sees the rebuilt key. Both go red if the guard is removed (mutation-verified). Raised by Copilot on #993. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The refresh can overwrite a concurrently generated credential-verified hash key, leaving the temporary store undecryptable.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
…#924) getAuthStatus's outer catch answers NOT_AUTHENTICATED, which routes an account-bearing wallet to the onboarding auth stack instead of the lock screen. Two writes on already-decided paths could reach it: - Clearing a stale persisted LOCKED marker after deciding HASH_KEY_EXPIRED. secureDataStorage.remove swallows its own keychain errors today, so this is defense in depth via a best-effort clearPersistedAuthStatus helper. signIn's marker clear deliberately keeps the unguarded call: there the removal establishes the new session rather than tidying up after a decision, so a failure should surface. - Persisting the soft-lock marker once the auto-lock timer has elapsed. secureDataStorage.setItem does throw, making this one reachable. The write is now best-effort and the path still returns LOCKED; the backgrounded-at timestamp is deliberately left unconsumed so the next check re-derives the same lock from elapsed time. The store's softLock keeps its opposite retry-then-rethrow policy: it also serves foreground-idle locks with no backgrounded-at timestamp to fall back on, and it sets the in-memory LOCKED state before writing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The wipe coordination still permits a key-resurrection race, and one sticky test mock contaminates subsequent coverage.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 2
Open (3)
Resolved since last review (1)
Two Copilot findings on #993 showed the re-anchor's generation check was scoped too narrowly in both dimensions: - What it tracked: only clearTemporaryData bumped the counter, so a credential-verified signIn/createTemporaryStore write landing between the re-anchor's re-read and its write would be reverted to the old key/salt while the temporary store had already been re-encrypted with the new one. Every credential-verified write now goes through setHashKey, which bumps the same counter synchronously before the write is dispatched; the re-anchor stays the one writer that bypasses it because it is the one checking. - When it was captured: inside the helper, after the caller had already loaded hashKey/temporaryStore. A wipe starting in that gap bumped the counter before the capture, so a stale re-read compared equal and the write could recreate the key after the removes landed. getAuthStatus now captures the generation before its load and passes it in, so any mutation that began after the load is refused. The comments no longer claim that a mutation dispatched after the re-anchor's write "lands last and wins": that holds on iOS (serial methodQueue) but not on Android, where react-native-keychain 10.0.0 runs setGenericPassword in a mutex'd coroutine on Dispatchers.Default and resetGenericPassword synchronously on the bridge thread. That window is the residual the PR description already names; closing it needs a JS-level serialization of hash-key mutations, left as a decision. Test hygiene from the third finding: the "persisting the LOCKED marker fails" rejection is one-shot, and the following re-anchor test asserts no error was logged so it demonstrably runs the success path. The claimed leak did not reproduce (the fixture re-arms setItem per test), but the sticky value was a latent hazard; the sibling in the HASH_KEY_EXPIRED marker-clear test is one-shot as well, the "fails throughout" softLock test keeps its sticky rejection (getAuthStatus's own swallowed write plus softLock's attempt and retry all have to fail) and resets it in afterEach, and the helpers test re-arms setItem/remove per case so its held-open promises can't hang later tests. The generation-refusal integration test asserts both generation reads were consumed so it can't pass vacuously and leak the queued value into the next case. Verification: new helper and autoLock tests seen failing against the pre-fix code (write proceeded; setHashKey undefined); a mutant routing both auth.ts writes around setHashKey flips all three LOCKED sign-in tests red. tsc clean; full suite 5207 passed / 348 suites after a yarn install --immutable (worktree node_modules was behind the merged lockfile). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Cassio's review found that clearing the persisted soft-lock marker on hard expiry (added in bb83074 for the "~96h not enforced" thread) let a clock change undo the lock: expiry is judged by Date.now(), so moving the clock past expiresAt (or back before generatedAt) made getAuthStatus return HASH_KEY_EXPIRED and delete the marker; restoring the clock then found a valid key, no marker and no backgrounded-at, and resolved AUTHENTICATED with no password. On main the branch returned LOCKED regardless of the clock. The marker is now left in place and the branch just returns HASH_KEY_EXPIRED. It is the only clock-independent lock state, and every route back to AUTHENTICATED (signIn after the password, signUp and importWallet via resetAutoLockForNewWallet) already clears it. A genuine 72h idle still gets the full rebuild, and the LOCKED fast path's !isHashKeyExpired guard keeps an expired key off it. Trade-off: a clock rolled back after a real expiry gets the fast unlock instead of the full rebuild; it still needs the password. Tests: Cassio's two clock-restore cases (forward past expiresAt, back before generatedAt) against stateful secure storage, seen failing on 0b5366d with Received: "AUTHENTICATED"; the expired-under-soft-lock test now asserts the marker is NOT removed; the marker-clear-failure it.each drops its expired-key row since that path no longer clears anything. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>


TL;DR
Re-anchors the hash-key hard-expiry backstop on activity instead of sign-in (closes #924, follow-up to #905): every authenticated check while the app is actively foregrounded pushes the 72h deadline out, throttled to at most one secure-storage write per hour. An actively-used wallet — including one on the 24h auto-lock preset — can no longer hit a surprise full re-authentication, while a device left idle for the backstop window still forces a full password re-auth on next open, matching the extension's session model.
Security posture, stated explicitly: the hard expiry now fires 71–72h after the last foreground use, or up to ~96h after the last human interaction in the degenerate case where the app is left foregrounded and untouched on the 24h preset until the foreground-idle soft lock trips. Expired, clock-rolled-back, soft-locked, and backgrounded states can never extend the deadline, and a key wiped by logout mid-check cannot be resurrected.
Implementation details (for agents)
What changed:
src/services/autoLock.ts— newrefreshHashKeyExpiration(hashKey): re-stampsexpiresAt/generatedAttonow + HASH_KEY_EXPIRATION_MSiff the key is valid. Guards, in order: never refresh an expired or clock-rolled-back key (isHashKeyExpired, which moved here fromducks/auth.ts— auth.ts already imports from this module, so the move avoids a cycle); skip whilegeneratedAtis younger than the 1h throttle; skip while a prior attempt is younger than the throttle (module-levellastRefreshAttemptAt, so a persistently failing keychain write can't retry+log every 5s tick); TOCTOU re-read — the write proceeds only if the stored key is field-for-field identical to the validated snapshot, and spreads the re-read key, so a concurrentclearTemporaryDatawipe or credential-verified re-stamp is never clobbered. A legacy key withoutgeneratedAtrefreshes immediately and self-upgrades; no migration needed.freighter-mobile/src/services/autoLock.ts
Lines 100 to 199 in a559017
src/ducks/auth.ts— the single call site ingetAuthStatus, gated byhashKey && temporaryStore && AppState.currentState === "active"and placed strictly after the LOCKED / hard-expiry / rollback / soft-lock checks (all of which return early), wrapped in try/catch so a keychain-write failure logs instead of escaping to the outer catch (which returns NOT_AUTHENTICATED and would demote an authenticated session). ThetemporaryStorecondition means an orphan key (temp store wiped, key remove failed) is never re-anchored — it hard-expires and gets cleaned up. Removed the now-false "TTL deliberately NOT refreshed here" comment.freighter-mobile/src/ducks/auth.ts
Lines 544 to 583 in a559017
src/config/constants.ts—HASH_KEY_EXPIRATION_MSstays 72h (it must exceed the 24h max preset so that preset's fast unlock stays reachable — hard expiry is checked before the soft timer); comment rewritten for the new inactivity semantics, including the 71–72h effective bound and the ~96h foregrounded-idle worst case. NewHASH_KEY_REFRESH_THROTTLE_MS = 1h.freighter-mobile/src/config/constants.ts
Lines 92 to 118 in a559017
src/hooks/useAuthCheck.ts— untouched: every foreground transition, cold start, and periodic tick already funnels throughgetAuthStatus.__tests__/services/autoLock.test.tsunit-covers the helper (stale refresh, throttle, attempt throttle + reset, expired/rollback refusal, legacy key, TOCTOU: missing/different/identical stored key);__tests__/ducks/auth.test.tsintegration-coversgetAuthStatusagainst the REAL helper (re-anchor on active use, throttle, background tick never refreshes nor consumes the soft timer, expired/rollback still expire, wipe-mid-check not resurrected, orphan key not re-anchored, write failure stays AUTHENTICATED without touching the outer catch). Mutation-verified during development: removing the try/catch, the active gate, or the TOCTOU guard each flips a specific test red.Verification: full suite 3065 passed / 226 suites / 0 failures (same totals as base plus the 20 new tests);
yarn lint:tsclean; eslint clean on all touchedsrcfiles. Pre-existing repo-wideyarn lint:checkfailures (56 errors, incl. aquotes/prettier conflict on one line ofauth.test.ts) are verbatim onmainand untouched — CI runsyarn test --cionly.Follow-ups / out of scope:
signInLOCKED fast path still hand-rolls the same anchor write (deliberately unthrottled/unguarded there — credential-verified and it should clobber); extracting a sharedanchorHashKeyExpirationwould keep the shape in one place.getAuthStatus; this PR stops re-anchoring it so it now hard-expires, but the status semantics deserve their own issue.clearTemporaryDatabegins and by every credential-verified write viasetHashKey, captured bygetAuthStatusbefore it loads the key, checked with no await before the re-anchor's write) refuses a re-anchor that interleaves with either. Residual exposure is native keychain queue reordering only (serial on iOS; Android runs set in a mutex'd coroutine but reset synchronously, so it is not guaranteed there) — the same assumption the existingsignInfast-path read-modify-write already makes.Closes #924
🤖 Generated with Claude Code