Skip to content

Commit 0d3aa8c

Browse files
piyalbasuclaude
andcommitted
fix(auth): keep secure-storage faults from demoting a decided session (#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>
1 parent 7d6f534 commit 0d3aa8c

2 files changed

Lines changed: 238 additions & 7 deletions

File tree

‎__tests__/ducks/auth.test.ts‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1730,6 +1730,80 @@ describe("auth duck", () => {
17301730
SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
17311731
);
17321732
});
1733+
1734+
// Clearing the stale marker is a cleanup, not the decision. If it were
1735+
// allowed to reach getAuthStatus's outer catch, a known-expired session
1736+
// would answer NOT_AUTHENTICATED and RootNavigator would route an
1737+
// account-bearing wallet to the onboarding auth stack instead of the
1738+
// lock screen that collects the required password re-auth.
1739+
it.each([
1740+
[
1741+
"an expired key under a persisted soft lock",
1742+
{
1743+
temporaryStore: "encrypted-temp-store",
1744+
hashKey: {
1745+
hashKey: "mock-hash-key",
1746+
salt: "mock-salt",
1747+
generatedAt: Date.now() - 73 * 3600000,
1748+
expiresAt: Date.now() - 3600000,
1749+
},
1750+
},
1751+
],
1752+
[
1753+
"a persisted soft lock with no temporary store",
1754+
{
1755+
temporaryStore: null,
1756+
hashKey: {
1757+
hashKey: "mock-hash-key",
1758+
salt: "mock-salt",
1759+
expiresAt: Date.now() + 3600000,
1760+
},
1761+
},
1762+
],
1763+
])(
1764+
"should still return HASH_KEY_EXPIRED for %s when clearing the stale marker fails",
1765+
async (_label, { temporaryStore, hashKey }) => {
1766+
const { result } = renderHook(() => useAuthenticationStore());
1767+
1768+
act(() => {
1769+
useAuthenticationStore.setState({
1770+
getAuthStatus: originalStoreMethods.getAuthStatus,
1771+
});
1772+
});
1773+
1774+
(dataStorage.getItem as jest.Mock).mockImplementation((key) => {
1775+
if (key === STORAGE_KEYS.ACCOUNT_LIST) {
1776+
return Promise.resolve(JSON.stringify([mockAccount]));
1777+
}
1778+
return Promise.resolve(null);
1779+
});
1780+
1781+
(secureDataStorage.getItem as jest.Mock).mockImplementation((key) => {
1782+
if (key === SENSITIVE_STORAGE_KEYS.AUTH_STATUS) {
1783+
return Promise.resolve(AUTH_STATUS.LOCKED);
1784+
}
1785+
if (key === SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE) {
1786+
return Promise.resolve(temporaryStore);
1787+
}
1788+
return Promise.resolve(null);
1789+
});
1790+
1791+
(getHashKey as jest.Mock).mockResolvedValue(hashKey);
1792+
1793+
(secureDataStorage.remove as jest.Mock).mockRejectedValue(
1794+
new Error("keychain unavailable"),
1795+
);
1796+
1797+
await act(async () => {
1798+
const status = await result.current.getAuthStatus();
1799+
expect(status).toBe(AUTH_STATUS.HASH_KEY_EXPIRED);
1800+
});
1801+
1802+
expect(secureDataStorage.remove).toHaveBeenCalledWith(
1803+
SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
1804+
);
1805+
},
1806+
);
17331807
});
17341808

17351809
describe("getAuthStatus with auto-lock timer", () => {
@@ -1825,6 +1899,36 @@ describe("auth duck", () => {
18251899
);
18261900
});
18271901

1902+
// Unlike the reads above, secureDataStorage.setItem really does throw on
1903+
// a keychain failure. Letting that reach the outer catch would answer
1904+
// NOT_AUTHENTICATED for a session just decided to be LOCKED, dropping an
1905+
// account-bearing wallet onto the onboarding stack.
1906+
it("should still soft-lock when persisting the LOCKED marker fails", async () => {
1907+
const { result } = renderHook(() => useAuthenticationStore());
1908+
restoreGetAuthStatus();
1909+
1910+
mockAuthenticatedStorage({
1911+
backgroundedAt: Date.now() - 2 * ONE_HOUR_MS,
1912+
autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR,
1913+
});
1914+
1915+
(secureDataStorage.setItem as jest.Mock).mockRejectedValue(
1916+
new Error("Failed to store item in keychain"),
1917+
);
1918+
1919+
await act(async () => {
1920+
const status = await result.current.getAuthStatus();
1921+
expect(status).toBe(AUTH_STATUS.LOCKED);
1922+
});
1923+
1924+
// The timestamp is deliberately NOT consumed: with no persisted
1925+
// marker, the next check must re-derive the same lock from elapsed
1926+
// background time rather than resolving AUTHENTICATED.
1927+
expect(secureDataStorage.remove).not.toHaveBeenCalledWith(
1928+
SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
1929+
);
1930+
});
1931+
18281932
it("should consume the timestamp and re-anchor a stale hash-key TTL on active use (#924)", async () => {
18291933
const { result } = renderHook(() => useAuthenticationStore());
18301934
restoreGetAuthStatus();
@@ -2383,6 +2487,77 @@ describe("auth duck", () => {
23832487
expect(result.current.isSoftLocked).toBe(true);
23842488
});
23852489

2490+
// Companion to the module-level "persisting the LOCKED marker fails"
2491+
// test, which starts from NOT_AUTHENTICATED (cold start) and so never
2492+
// reaches softLock. On the AUTHENTICATED -> LOCKED transition the store
2493+
// funnels through softLock, whose deliberate policy is retry-once-then-
2494+
// rethrow. The contract that matters is that the in-memory lock lands
2495+
// first, so a keychain outage surfaces the fault without ever leaving
2496+
// the wallet unlocked.
2497+
it("should still land the in-memory soft lock when the keychain write fails throughout", async () => {
2498+
const { result } = renderHook(() => useAuthenticationStore());
2499+
act(() => {
2500+
useAuthenticationStore.setState({
2501+
getAuthStatus: originalStoreMethods.getAuthStatus,
2502+
softLock: originalStoreMethods.softLock,
2503+
authStatus: AUTH_STATUS.AUTHENTICATED,
2504+
isSoftLocked: false,
2505+
});
2506+
});
2507+
2508+
(dataStorage.getItem as jest.Mock).mockImplementation((key) => {
2509+
if (key === STORAGE_KEYS.ACCOUNT_LIST) {
2510+
return Promise.resolve(JSON.stringify([mockAccount]));
2511+
}
2512+
return Promise.resolve(null);
2513+
});
2514+
(secureDataStorage.getItem as jest.Mock).mockImplementation((key) => {
2515+
if (key === SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE) {
2516+
return Promise.resolve("encrypted-temp-store");
2517+
}
2518+
if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT) {
2519+
return Promise.resolve(String(Date.now() - 7200000)); // 2h ago
2520+
}
2521+
if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING) {
2522+
return Promise.resolve(AUTO_LOCK_TIMER.ONE_HOUR);
2523+
}
2524+
return Promise.resolve(null);
2525+
});
2526+
(getHashKey as jest.Mock).mockResolvedValue({
2527+
hashKey: "mock-hash-key",
2528+
salt: "mock-salt",
2529+
expiresAt: Date.now() + 3600000,
2530+
});
2531+
(secureDataStorage.setItem as jest.Mock).mockRejectedValue(
2532+
new Error("Failed to store item in keychain"),
2533+
);
2534+
2535+
const observedInvalidStates: string[] = [];
2536+
const unsubscribe = useAuthenticationStore.subscribe((state) => {
2537+
if (state.authStatus === AUTH_STATUS.LOCKED && !state.isSoftLocked) {
2538+
observedInvalidStates.push(state.authStatus);
2539+
}
2540+
});
2541+
2542+
await act(async () => {
2543+
// softLock rethrows after its retry, by design — the module-level
2544+
// swallow moves the failure here rather than hiding it.
2545+
await expect(result.current.getAuthStatus()).rejects.toThrow(
2546+
"Failed to store item in keychain",
2547+
);
2548+
});
2549+
2550+
unsubscribe();
2551+
expect(observedInvalidStates).toHaveLength(0);
2552+
expect(result.current.authStatus).toBe(AUTH_STATUS.LOCKED);
2553+
expect(result.current.isSoftLocked).toBe(true);
2554+
// The backgrounded-at timestamp survives, so a cold start after the
2555+
// outage re-derives the same lock instead of resolving AUTHENTICATED.
2556+
expect(secureDataStorage.remove).not.toHaveBeenCalledWith(
2557+
SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
2558+
);
2559+
});
2560+
23862561
it("should make navigateToLockScreen a no-op while soft-locked", () => {
23872562
const { result } = renderHook(() => useAuthenticationStore());
23882563

‎src/ducks/auth.ts‎

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,36 @@ const getAllAccounts = async (): Promise<Account[]> => {
462462
return JSON.parse(accountListRaw) as Account[];
463463
};
464464

465+
/**
466+
* Clears a stale persisted soft-lock marker, best-effort.
467+
*
468+
* Only for `getAuthStatus`'s expiry paths, which have already decided the
469+
* session is over and are just tidying up after themselves. That cleanup must
470+
* not be able to change the decision: an error escaping to `getAuthStatus`'s
471+
* outer catch downgrades a known HASH_KEY_EXPIRED session to
472+
* NOT_AUTHENTICATED, which routes an account-bearing wallet to the onboarding
473+
* auth stack instead of the lock screen that collects the required password
474+
* re-auth. The marker is an optimisation; the returned status is the security
475+
* decision.
476+
*
477+
* `secureDataStorage.remove` swallows its own keychain errors today, so this is
478+
* defense in depth rather than a live fix. Deliberately NOT used for signIn's
479+
* marker clear: there the removal is part of establishing the new session, not
480+
* cleanup after a decision, so a failure genuinely means the persisted state
481+
* disagrees with the session and should surface.
482+
*/
483+
const clearPersistedAuthStatus = async (): Promise<void> => {
484+
try {
485+
await secureDataStorage.remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS);
486+
} catch (error) {
487+
logger.error(
488+
"clearPersistedAuthStatus",
489+
"Failed to clear stale persisted auth status",
490+
error,
491+
);
492+
}
493+
};
494+
465495
/**
466496
* Validates the authentication status of the user
467497
*
@@ -504,14 +534,14 @@ const getAuthStatus = async (): Promise<AuthStatus> => {
504534
);
505535
if (persistedAuthStatus === AUTH_STATUS.LOCKED) {
506536
if (hashKey && isHashKeyExpired(hashKey)) {
507-
await secureDataStorage.remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS);
537+
await clearPersistedAuthStatus();
508538
return AUTH_STATUS.HASH_KEY_EXPIRED;
509539
}
510540
if (temporaryStore) {
511541
return AUTH_STATUS.LOCKED;
512542
}
513543
// Temp store missing: LOCKED state is invalid, treat as expired
514-
await secureDataStorage.remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS);
544+
await clearPersistedAuthStatus();
515545
return AUTH_STATUS.HASH_KEY_EXPIRED;
516546
}
517547

@@ -539,11 +569,37 @@ const getAuthStatus = async (): Promise<AuthStatus> => {
539569
// unlock path (all presets are positive durations, so no zero/null case
540570
// to exclude).
541571
if (elapsedInBackground >= autoLockTimerMs && temporaryStore) {
542-
await secureDataStorage.setItem(
543-
SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
544-
AUTH_STATUS.LOCKED,
545-
);
546-
await clearBackgroundedAt();
572+
// Persisting the marker is best-effort here: unlike `remove`,
573+
// `secureDataStorage.setItem` does throw on a keychain failure, and
574+
// letting that reach the outer catch would answer NOT_AUTHENTICATED
575+
// for a session we have just decided is LOCKED - sending an
576+
// account-bearing wallet to the onboarding stack instead of the lock
577+
// screen. That matters most on a cold start, where no in-memory lock
578+
// state exists yet to fall back on. On failure the backgrounded-at
579+
// timestamp is deliberately left intact (the clear is inside the try),
580+
// so the next check re-derives the same lock from elapsed time and the
581+
// wallet is never auto-unlockable.
582+
//
583+
// The store's softLock() takes the opposite line on the same write -
584+
// retry once, then rethrow - and both are right: softLock also serves
585+
// foreground-idle locks that have no backgrounded-at timestamp to
586+
// re-derive from, so there a lost write really can leave the wallet
587+
// unlocked on the next cold launch. It also sets the in-memory LOCKED
588+
// state before writing, so its throw surfaces the fault without
589+
// unlocking anything.
590+
try {
591+
await secureDataStorage.setItem(
592+
SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
593+
AUTH_STATUS.LOCKED,
594+
);
595+
await clearBackgroundedAt();
596+
} catch (error) {
597+
logger.error(
598+
"getAuthStatus",
599+
"Failed to persist soft-lock auth status",
600+
error,
601+
);
602+
}
547603
return AUTH_STATUS.LOCKED;
548604
}
549605

0 commit comments

Comments
 (0)