Skip to content

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

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

piyalbasu wants to merge 11 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.tsHASH_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 race is closed at the JS level: the write-time re-read requires a live temporary store, and a wipe-generation counter (bumped synchronously when clearTemporaryData begins, checked with no await before the write) refuses a re-anchor that interleaves with a wipe. Residual exposure is native keychain queue reordering only — 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
@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-69c7c0f8bb6e9a86edaa
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-52f38b8aec2f4542ba80
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
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
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
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
Comment on lines +506 to +509
if (hashKey && isHashKeyExpired(hashKey)) {
await secureDataStorage.remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS);
return AUTH_STATUS.HASH_KEY_EXPIRED;
}
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

2 participants