Skip to content

feat(auth): re-anchor hash-key hard-expiry on activity instead of sign-in - #993

Open
piyalbasu wants to merge 16 commits into
mainfrom
feat/924-reanchor-hard-expiry
Open

piyalbasu wants to merge 16 commits into
mainfrom
feat/924-reanchor-hard-expiry

Conversation

@piyalbasu

@piyalbasu piyalbasu commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

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 — new refreshHashKeyExpiration(hashKey): re-stamps expiresAt/generatedAt to now + HASH_KEY_EXPIRATION_MS iff the key is valid. Guards, in order: never refresh an expired or clock-rolled-back key (isHashKeyExpired, which moved here from ducks/auth.ts — auth.ts already imports from this module, so the move avoids a cycle); skip while generatedAt is younger than the 1h throttle; skip while a prior attempt is younger than the throttle (module-level lastRefreshAttemptAt, 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 concurrent clearTemporaryData wipe or credential-verified re-stamp is never clobbered. A legacy key without generatedAt refreshes immediately and self-upgrades; no migration needed.
    * the hard-expiry from ever forcing a full re-auth. Treat that as expired
    * (mirrors getBackgroundedAt's future-timestamp guard for the soft timer).
    * generatedAt is optional so keys persisted before this field fall back to the
    * plain expiry check.
    */
    const isHashKeyExpired = (hashKey: HashKey): boolean => {
    const now = Date.now();
    if (hashKey.generatedAt !== undefined && hashKey.generatedAt > now) {
    return true;
    }
    return now > hashKey.expiresAt;
    };
    // Last time a re-anchor write was attempted (module-level, process-lifetime).
    // The generatedAt throttle below only advances when the write SUCCEEDS; if
    // the keychain write fails persistently, generatedAt never moves and every
    // 5s auth tick would retry (and log) forever. Throttling attempts bounds
    // that failure mode to one attempt per throttle window. In-memory on
    // purpose: a process restart retrying immediately is fine.
    let lastRefreshAttemptAt = 0;
    /**
    * Resets the module-level attempt throttle (tests only — module state would
    * otherwise leak across cases in the same file).
    */
    const resetHashKeyRefreshAttemptThrottle = (): void => {
    lastRefreshAttemptAt = 0;
    };
    /**
    * Re-anchors the hash-key hard-expiry on use (#924): pushes expiresAt out to
    * a full HASH_KEY_EXPIRATION_MS from now, so the backstop bounds *inactivity*
    * rather than time since the last credential entry.
    *
    * Guards (defense in depth — the getAuthStatus call site is also gated):
    * - An expired or rolled-back key is never refreshed; only signIn's
    * credential-verified path may re-stamp those.
    * - The write is throttled: a key anchored within HASH_KEY_REFRESH_THROTTLE_MS
    * is left alone, so the 5s foreground auth tick doesn't hammer the keychain.
    * A legacy key without generatedAt can't prove it was recently anchored, so
    * it refreshes immediately (gaining generatedAt, after which the throttle
    * applies).
    * - Refresh *attempts* are throttled too (lastRefreshAttemptAt): the
    * generatedAt throttle only advances on a successful write, so a
    * persistently failing keychain would otherwise be retried (and logged)
    * on every 5s auth tick.
    *
    * Takes the caller-validated HashKey snapshot to run the guards and throttle
    * (every caller has just loaded it for the expiry checks), then re-reads the
    * stored key once at write time for the TOCTOU check below.
    */
    const refreshHashKeyExpiration = async (hashKey: HashKey): Promise<void> => {
    if (isHashKeyExpired(hashKey)) {
    return;
    }
    const now = Date.now();
    if (
    hashKey.generatedAt !== undefined &&
    now - hashKey.generatedAt < HASH_KEY_REFRESH_THROTTLE_MS
    ) {
    return;
    }
    if (now - lastRefreshAttemptAt < HASH_KEY_REFRESH_THROTTLE_MS) {
    return;
    }
    // Set before the write so a throwing write still counts as an attempt.
    lastRefreshAttemptAt = now;
    // TOCTOU guard: the caller validated this key several awaits ago; a logout
    // or corruption wipe (clearTemporaryData) may have removed or replaced it
    // since. Re-stamp only the exact key that was validated — never resurrect
    // a wiped key, never clobber a concurrent credential-verified re-stamp.
    // The remaining window is the single await between this read and the write
    // (same bound as getActiveMnemonicPhrase's re-check pattern in auth.ts).
    const currentHashKey = await getHashKey();
    if (
    !currentHashKey ||
    currentHashKey.hashKey !== hashKey.hashKey ||
    currentHashKey.salt !== hashKey.salt ||
    currentHashKey.expiresAt !== hashKey.expiresAt ||
    currentHashKey.generatedAt !== hashKey.generatedAt
    ) {
    return;
    }
    await secureDataStorage.setItem(
    SENSITIVE_STORAGE_KEYS.HASH_KEY,
    JSON.stringify({
    // Spread the re-read key, not the caller's snapshot: the comparison
    // above proves they are identical today, but if HashKey ever gains a
    // field the explicit check stops covering it and spreading the stale
    // snapshot would silently revert it.
    ...currentHashKey,
    expiresAt: now + HASH_KEY_EXPIRATION_MS,
    generatedAt: now,
    } satisfies HashKey),
    );
    };
  • src/ducks/auth.ts — the single call site in getAuthStatus, gated by hashKey && 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). The temporaryStore condition 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.
    if (AppState.currentState === "active") {
    // Returned within the timer: consume the timestamp so the foreground
    // interval can't lock mid-use. (The hash-key TTL re-anchor happens
    // below, on the shared AUTHENTICATED path — not here — so it also
    // covers ticks where no backgrounded-at timestamp exists.)
    await clearBackgroundedAt();
    }
    // Still backgrounded (periodic background check): leave the timestamp
    // intact so the timer keeps counting from the original moment.
    }
    // Re-anchor the hash-key hard-expiry on use (#924): every authenticated
    // foreground auth check pushes the deadline out, so the backstop bounds
    // *inactivity* — HASH_KEY_EXPIRATION_MS with no foreground use — instead
    // of time since the last credential entry, and an actively-used wallet
    // never hard-expires (parity with the extension's session model). Gated
    // to the active app state so the periodic background check can't extend
    // the deadline of a pocketed device, and throttled inside the helper so
    // the 5s foreground tick doesn't hammer the keychain. This runs strictly
    // after the LOCKED / hard-expiry / clock-rollback checks above, so an
    // expired or rolled-back key can never be resurrected here — those still
    // require signIn's credential-verified re-stamp. An orphan key (present
    // with no temporary store, e.g. a partial wipe) is never re-anchored
    // either, so it still hard-expires and gets cleaned up rather than having
    // its deadline pushed out forever by each tick.
    if (hashKey && temporaryStore && AppState.currentState === "active") {
    try {
    await refreshHashKeyExpiration(hashKey);
    } catch (error) {
    // The re-anchor is opportunistic: a failed keychain write must not
    // demote an authenticated session (the outer catch returns
    // NOT_AUTHENTICATED). Worst case the key keeps its old deadline and
    // hard-expires as it would have before the re-anchor existed.
    logger.error(
    "getAuthStatus",
    "Failed to refresh hash key expiration",
    error,
    );
    }
    }
  • src/config/constants.ts — HASH_KEY_EXPIRATION_MS stays 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. New HASH_KEY_REFRESH_THROTTLE_MS = 1h.
    // Hard-expiry backstop: after this the persisted derived key is discarded and
    // the session fully re-authenticates from the password (HASH_KEY_EXPIRED)
    // rather than taking the fast soft-lock unlock path. It is a separate, coarser
    // bound than the user-configurable soft auto-lock: the soft timer governs how
    // soon the wallet re-locks (fast unlock), while this caps how long key material
    // may live in secure storage without the wallet being used.
    //
    // Anchored on activity, not sign-in (#924): every authenticated foreground
    // auth check re-stamps expiresAt (throttled via HASH_KEY_REFRESH_THROTTLE_MS),
    // so the bound is "72h with no foreground use" — an actively-used wallet never
    // hard-expires, matching the Freighter extension's session model. It must stay
    // strictly above the largest AUTO_LOCK_TIMER preset (24h): the hard expiry is
    // checked before the soft timer, so if this were <= 24h a 24h-preset user
    // would hit the full re-auth instead of that preset's fast unlock. A device
    // left foregrounded and untouched keeps re-anchoring until the foreground-idle
    // soft lock trips (up to 24h on the max preset), so the worst case from last
    // human interaction to hard expiry is ~96h.
    export const HASH_KEY_EXPIRATION_MS = 72 * 60 * 60 * 1000; // 72 hours
    // Minimum age of a hash key's generatedAt anchor before an authenticated
    // foreground auth check re-stamps it. getAuthStatus runs as often as every 5s
    // while the app is active — this gates the secure-storage (keychain) write to
    // at most one per hour rather than one per tick. Granularity is negligible
    // against the 72h backstop: a skipped write leaves generatedAt at most 1h
    // behind the last activity, so the real idle-to-expiry window is 71-72h
    // rather than exactly 72h.
    export const HASH_KEY_REFRESH_THROTTLE_MS = 60 * 60 * 1000; // 1 hour
  • src/hooks/useAuthCheck.ts — untouched: every foreground transition, cold start, and periodic tick already funnels through getAuthStatus.
  • Tests — __tests__/services/autoLock.test.ts unit-covers the helper (stale refresh, throttle, attempt throttle + reset, expired/rollback refusal, legacy key, TOCTOU: missing/different/identical stored key); __tests__/ducks/auth.test.ts integration-covers getAuthStatus against 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:ts clean; eslint clean on all touched src files. Pre-existing repo-wide yarn lint:check failures (56 errors, incl. a quotes/prettier conflict on one line of auth.test.ts) are verbatim on main and untouched — CI runs yarn test --ci only.

Follow-ups / out of scope:

  • The signIn LOCKED fast path still hand-rolls the same anchor write (deliberately unthrottled/unguarded there — credential-verified and it should clobber); extracting a shared anchorHashKeyExpiration would keep the shape in one place.
  • Pre-existing: an orphan-key state (hash key present, temp store absent) resolves AUTHENTICATED in getAuthStatus; this PR stops re-anchoring it so it now hard-expires, but the status semantics deserve their own issue.
  • The wipe and re-key races are closed at the JS level: the write-time re-read requires a live temporary store, and a hash-key generation counter (bumped synchronously when clearTemporaryData begins and by every credential-verified write via setHashKey, captured by getAuthStatus before 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 existing signIn fast-path read-modify-write already makes.
  • A persistent keychain fault now logs ~1/hr instead of ~720/hr — quieter by design; worth remembering when debugging keystore incidents.

Closes #924

🤖 Generated with Claude Code

piyalbasu and others added 5 commits August 28, 2026 11:59
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>
Copilot AI balanced review requested due to automatic review settings August 28, 2026 17:05
@github-actions github-actions Bot added the preview-degraded-ios iOS PR preview fell back to staging because freighter-config was unreachable label Aug 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/services/autoLock.ts Resolved
@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-3b061866a2c59430ff75
Backend: sandbox (piyalbasu). SDF collaborators only — install instructions in the release description.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-838b091b9f553edec5e2
Backend: sandbox (piyalbasu). SDF collaborators only — install instructions in the release description.

…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>
Copilot AI review requested due to automatic review settings August 28, 2026 18:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread src/services/autoLock.ts Resolved
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>
Copilot AI review requested due to automatic review settings August 31, 2026 15:27
@github-actions github-actions Bot removed the preview-degraded-ios iOS PR preview fell back to staging because freighter-config was unreachable label Aug 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

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>
Copilot AI review requested due to automatic review settings August 31, 2026 16:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread src/config/constants.ts Resolved
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>
Copilot AI review requested due to automatic review settings August 31, 2026 17:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread src/ducks/auth.ts Resolved
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>
Copilot AI review requested due to automatic review settings August 31, 2026 17:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 31, 2026 23:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread src/ducks/auth.ts Resolved
Copilot AI review requested due to automatic review settings September 24, 2026 16:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 High severity · 1 Medium severity

Open (2)

Comment thread src/services/autoLock.ts Resolved
…#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>
Copilot AI review requested due to automatic review settings September 24, 2026 20:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 High severity · 1 Medium severity

Open (3)
Resolved since last review (1)

Comment thread src/services/autoLock.ts Resolved
Comment thread __tests__/ducks/auth.test.ts Resolved
piyalbasu and others added 2 commits November 9, 2026 12:07
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>
Comment thread src/ducks/auth.ts Resolved

@CassioMG CassioMG left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM - other than this finding

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>
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.

Auto-lock: re-anchor hash-key hard-expiry on activity instead of sign-in

3 participants