diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ae18d07a9..bbf1b57b11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ - changed: Replace the flat `ENV` singleton with separate `CONFIG`, `KEYS`/`globalKeys`, and `pluginMaps` accessors (no top-level globalKeys flatten) - changed: WalletConnect Smart Contract Call warning restated in the solid UI4 card - changed: WalletConnect Connect button pinned to the bottom of Confirm Connection +- fixed: Paybis and Simplex going missing from buy and sell on the launch after a slow or retried device attestation +- fixed: Missing wrong-device-clock warning on a launch that runs no device attestation +- fixed: Missing exchange rates when the device clock is set ahead +- fixed: Export Logs failing with a bare status 400 when the device clock is wrong - fixed: A send whose funds are not spendable yet says so instead of reporting a network error - fixed: A swap retried after a failure fetches a fresh quote instead of failing with a "closed proxy" error - fixed: WalletConnect Bitcoin message signing returns a BIP-322 signature when the dapp requests `bip322` diff --git a/docs/CONFIG_KEYS_ARCHITECTURE.md b/docs/CONFIG_KEYS_ARCHITECTURE.md index e440c71a383..4bd51357f37 100644 --- a/docs/CONFIG_KEYS_ARCHITECTURE.md +++ b/docs/CONFIG_KEYS_ARCHITECTURE.md @@ -436,8 +436,10 @@ outright has nothing to wait for and simply does nothing. The on-disk cache does **not** expire. Any mergeable `keysCache` is used as a warm start so later launches never block on the network; a background refresh -updates the cache for the _next_ launch. `fetchedAt` may still be recorded for -diagnostics, but it does not gate the warm path. There is no TTL. +updates the cache for the _next_ launch. `fetchedAt` does not gate the warm +path, and there is no TTL. Its one use is to bound how long an attested entry is +kept over an unattested answer (see +[Which answer the cache keeps](#which-answer-the-cache-keeps)). The baked-in file is the **base layer** of a `deepMerge`, not a wholesale replacement, so a partial remote payload degrades gracefully instead of blanking @@ -675,9 +677,58 @@ to win **this** launch: If attestation finishes inside its budget the fetch goes out attested and receives the full payload immediately; otherwise it goes out unattested and takes -the `default` tier, and the background refresh upgrades the cached payload for the -next launch. A feature needing a key absent from the current payload can trigger -an on-demand foreground escalation. +the `default` tier. A token that arrives later in the launch refreshes the +cache for the next one (see below). + +#### A token that arrives after the fetch + +The handshake can still produce a token later in the same launch: it ran past +`ATTESTATION_BUDGET_MS`, or it failed and a retry succeeded after its backoff. +Without a second request the `default` payload would stay in the cache, and the +next launch would warm-start on it with no partner keys from the attested +layers. + +So a request that goes out with no token arms `refreshWhenAttested`, which +subscribes to the attestation engine (`onAttestationToken`). When a token +arrives it fetches again and writes the answer to the cache for the next launch. +The rules it follows: + +- It writes the cache only. The running `KEYS` and plugins keep the payload the + launch started on, the same rule as every other background refresh. +- It subscribes at most once per launch and stays subscribed until the answer to + a request that carried a token is in the cache. The engine reports every new + token, so a refetch that fails (network error, 5xx) is tried again at the + engine's next token instead of leaving the cache on `default` for the rest of + the launch. +- One refetch runs at a time. A token that arrives while a refetch is in flight + does not start a second one. + +#### Which answer the cache keeps + +The cache entry records whether its request carried a token (`attested`) next to +`fetchedAt`. `cacheKeys` reads the entry before every write and applies one +rule: the answer to a request without a token does not replace an attested +entry that is younger than `ATTESTED_CACHE_HOLD_MS` (3 days) and still merges. + +That rule covers three cases: + +- **A launch that misses the budget on a device that still attests.** The + handshake was slow, failed, or was rate limited (a 429 backs off for at least + 60 seconds and up to 30 minutes), and the user may close the app before the + retry lands. Without the rule the `default` answer would be cached at once and + the next launch would start without the attested partner keys. +- **Two requests in flight at once.** The first request and the late-token + refetch can overlap. `writeKeysCache` updates the in-memory entry before it + touches disk, so whichever answer lands second sees the first, and the + attested one is kept in either order. +- **A device that can no longer attest.** Only an attested answer renews + `fetchedAt`, so once the entry is 3 days old the `default` answer replaces + it. Old attested keys are not served from disk indefinitely. + +An entry written before the `attested` field existed counts as unattested, and +so does one that no longer merges, so any answer replaces either. The age is +measured in both directions: an entry stamped later than the current clock by +more than the hold (the clock has since been set back) is not kept. ### Where the cache lives diff --git a/src/__tests__/actions/DeviceSettingsActions.test.ts b/src/__tests__/actions/DeviceSettingsActions.test.ts index c78c554d9a2..2691add3926 100644 --- a/src/__tests__/actions/DeviceSettingsActions.test.ts +++ b/src/__tests__/actions/DeviceSettingsActions.test.ts @@ -77,7 +77,8 @@ describe('patchDeviceSettings', () => { const written = writeKeysCache({ keys: { EDGE_API_KEY: 'k' }, fetchedAt: 1, - assuranceLevel: 'default' + assuranceLevel: 'default', + attested: false }) openGate() @@ -87,6 +88,7 @@ describe('patchDeviceSettings', () => { expect(file.themeMode).toBe('light') expect(file.defaultScreen).toBe('assets') expect(file.keysCache?.assuranceLevel).toBe('default') + expect(file.keysCache?.attested).toBe(false) }) it('boots on defaults when the read hangs, then applies the late read', async () => { diff --git a/src/__tests__/actions/ExchangeRateActions.test.ts b/src/__tests__/actions/ExchangeRateActions.test.ts index 1a099d0167c..951611c5b3e 100644 --- a/src/__tests__/actions/ExchangeRateActions.test.ts +++ b/src/__tests__/actions/ExchangeRateActions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from '@jest/globals' import { convertToRatesParams, type ExchangeRateCache, + getServerNow, mergePairCache } from '../../actions/ExchangeRateActions' @@ -179,3 +180,20 @@ describe('convertToRatesParams', () => { ) }) }) + +describe('getServerNow', () => { + const deviceNow = Date.parse('2026-10-09T12:00:00.000Z') + + it('reads the server clock from the Date header', () => { + // The device clock is two days ahead here. A rate the server stamped a + // moment ago is current by the server clock, but two days old by the + // device clock, which would file it as historical. + const serverNow = getServerNow('Wed, 07 Oct 2026 12:00:00 GMT', deviceNow) + expect(serverNow).toBe(Date.parse('2026-10-07T12:00:00.000Z')) + }) + + it('falls back to the device clock without a usable header', () => { + expect(getServerNow(null, deviceNow)).toBe(deviceNow) + expect(getServerNow('not a date', deviceNow)).toBe(deviceNow) + }) +}) diff --git a/src/__tests__/actions/LogActions.test.ts b/src/__tests__/actions/LogActions.test.ts new file mode 100644 index 00000000000..4d25d25ffa7 --- /dev/null +++ b/src/__tests__/actions/LogActions.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals' + +import { + type LogOutput, + LogsClockError, + sendLogs +} from '../../actions/LogActions' +import { lstrings } from '../../locales/strings' + +const makeLog = (): LogOutput => ({ + isoDate: '2026-10-09T12:00:00.000Z', + uniqueId: 'abc123_info', + userMessage: '', + deviceInfo: 'test device', + appVersion: '0.0.0', + OS: 'test', + accounts: [], + data: '' +}) + +const mockFetch = (status: number, body: string): void => { + const response = { + ok: status >= 200 && status < 300, + status, + text: async () => body + } + global.fetch = jest.fn(async () => response) as unknown as typeof fetch +} + +describe('sendLogs', () => { + const realFetch = global.fetch + afterEach(() => { + global.fetch = realFetch + }) + + it('resolves when the logs server accepts the upload', async () => { + mockFetch(200, '') + await expect(sendLogs(makeLog(), false)).resolves.toBeUndefined() + }) + + it('names the device clock when the server says the time is out of sync', async () => { + mockFetch(400, 'Time Out of Sync') + const error = await sendLogs(makeLog(), false).catch( + (error: unknown) => error + ) + expect(error).toBeInstanceOf(LogsClockError) + expect((error as Error).message).toBe( + lstrings.settings_modal_send_logs_clock_error + ) + }) + + it('keeps the status code for any other refusal', async () => { + mockFetch(400, 'Missing Request fields.') + const error = await sendLogs(makeLog(), false).catch( + (error: unknown) => error + ) + expect(error).not.toBeInstanceOf(LogsClockError) + expect((error as Error).message).toContain('returned status 400') + }) +}) diff --git a/src/__tests__/util/attestation.test.ts b/src/__tests__/util/attestation.test.ts index 7ffdf78dd72..81701275cbe 100644 --- a/src/__tests__/util/attestation.test.ts +++ b/src/__tests__/util/attestation.test.ts @@ -66,6 +66,7 @@ const { attestedJsonHeaders, getAttestationToken, initAttestation, + maybeWarnClockSkew, onAttestationToken, resetAttestationForTests } = require('../../util/attestation') @@ -676,6 +677,25 @@ describe('attestation engine', () => { expect(mockShowButtonsModal.mock.calls.length).toBe(0) }) + it('warns from an HTTP Date header, with no handshake involved', () => { + const serverDate = new Date(Date.now() - 2 * 86_400_000).toUTCString() + + maybeWarnClockSkew(serverDate) + expect(mockShowButtonsModal.mock.calls.length).toBe(1) + expect(mockFetchInfo.mock.calls.length).toBe(0) + + // Same once-per-day latch as the handshake's own readings. + maybeWarnClockSkew(serverDate) + expect(mockShowButtonsModal.mock.calls.length).toBe(1) + }) + + it('ignores a server time it cannot read', () => { + maybeWarnClockSkew(undefined) + maybeWarnClockSkew('not a date') + maybeWarnClockSkew(new Date(Date.now() - 5000).toUTCString()) + expect(mockShowButtonsModal.mock.calls.length).toBe(0) + }) + it('does not warn for a few seconds of skew', async () => { const slightlyOff = new Date(Date.now() - 5 * 1000).toISOString() mockFetchInfo.mockImplementation(async (path: string) => { diff --git a/src/__tests__/util/keysServer.test.ts b/src/__tests__/util/keysServer.test.ts index 9213930026f..9b155b18577 100644 --- a/src/__tests__/util/keysServer.test.ts +++ b/src/__tests__/util/keysServer.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, jest } from '@jest/globals' import type { EdgeFetchFunction } from 'edge-core-js' import { signHmacAuthorization } from '../../util/hmacAuth' -import { fetchRemoteKeys } from '../../util/keysServer' +import { fetchRemoteKeys, RemoteKeysError } from '../../util/keysServer' const apiKey = 'test-api-key' const secret = new Uint8Array( @@ -28,6 +28,19 @@ const makeOkResponse = (body: unknown): Response => text: async () => JSON.stringify(body) } as unknown as Response) +const makeErrorResponse = ( + status: number, + text: string, + headers: Record = {} +): Response => + ({ + ok: false, + status, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, + json: async () => ({}), + text: async () => text + } as unknown as Response) + describe('fetchRemoteKeys', () => { it('requests v1/infoRollup with a URL-encoded appId', async () => { const infoFetch = jest.fn(async () => @@ -153,14 +166,8 @@ describe('fetchRemoteKeys', () => { }) it('throws with the status on a non-OK response', async () => { - const infoFetch = jest.fn( - async () => - ({ - ok: false, - status: 503, - json: async () => ({}), - text: async () => 'unavailable' - } as unknown as Response) + const infoFetch = jest.fn(async () => + makeErrorResponse(503, 'unavailable') ) await expect( @@ -168,6 +175,40 @@ describe('fetchRemoteKeys', () => { ).rejects.toThrow('fetchRemoteKeys 503: unavailable') }) + it('carries the status and the server Date header on a refusal', async () => { + const serverDate = 'Wed, 07 Oct 2026 19:55:14 GMT' + const infoFetch = jest.fn(async () => + makeErrorResponse(401, 'Unauthorized', { date: serverDate }) + ) + + const error: unknown = await fetchRemoteKeys({ + apiKey, + secret, + appId: 'edge', + infoFetch, + ...query + }).catch((e: unknown) => e) + + expect(error).toBeInstanceOf(RemoteKeysError) + expect(error).toMatchObject({ status: 401, serverDate }) + }) + + it('leaves the server date unset when the response has no Date header', async () => { + const infoFetch = jest.fn(async () => + makeErrorResponse(401, 'Unauthorized') + ) + + const error: unknown = await fetchRemoteKeys({ + apiKey, + secret, + appId: 'edge', + infoFetch, + ...query + }).catch((e: unknown) => e) + + expect(error).toMatchObject({ status: 401, serverDate: undefined }) + }) + it('rejects a malformed body where appKeys is a string', async () => { const infoFetch = jest.fn(async () => makeOkResponse({ appKeys: 'not-an-object' }) diff --git a/src/__tests__/util/keysStore.test.ts b/src/__tests__/util/keysStore.test.ts index c990d10b8a0..5cc0d2b765f 100644 --- a/src/__tests__/util/keysStore.test.ts +++ b/src/__tests__/util/keysStore.test.ts @@ -48,23 +48,27 @@ jest.mock( const mockInitDeviceSettings = jest.fn(async (..._args: unknown[]) => {}) const mockAwaitDeviceSettingsDisk = jest.fn(async (..._args: unknown[]) => {}) -const mockGetKeysCache = jest.fn( - () => - undefined as - | undefined - | { - keys: unknown - fetchedAt: number - assuranceLevel: string - } -) -const mockWriteKeysCache = jest.fn( - async (_entry: { - keys: unknown - fetchedAt: number - assuranceLevel: string - }) => {} -) +interface MockCacheEntry { + keys: unknown + fetchedAt: number + assuranceLevel: string + attested?: boolean +} +const mockGetKeysCache = jest.fn(() => undefined as MockCacheEntry | undefined) +const mockWriteKeysCache = jest.fn(async (_entry: MockCacheEntry) => {}) + +// Stands in for the in-memory settings copy, where a write can be read back +// before it reaches disk: +const useLiveCache = ( + initial?: MockCacheEntry +): { current: MockCacheEntry | undefined } => { + const cache = { current: initial } + mockGetKeysCache.mockImplementation(() => cache.current) + mockWriteKeysCache.mockImplementation(async entry => { + cache.current = entry + }) + return cache +} jest.mock('../../actions/DeviceSettingsActions', () => ({ initDeviceSettings: async (...args: unknown[]) => { @@ -75,15 +79,7 @@ jest.mock('../../actions/DeviceSettingsActions', () => ({ }, getKeysCache: () => mockGetKeysCache(), writeKeysCache: async (...args: unknown[]) => { - await mockWriteKeysCache( - ...(args as [ - { - keys: unknown - fetchedAt: number - assuranceLevel: string - } - ]) - ) + await mockWriteKeysCache(...(args as [MockCacheEntry])) } })) @@ -94,17 +90,53 @@ const mockFetchRemoteKeys = jest.fn< }> >() +// Stands in for the real error class. Defined once out here so the copy that +// `keysStore` checks with `instanceof` inside each isolated module registry is +// the same one the tests construct. +class MockRemoteKeysError extends Error { + readonly status: number + readonly serverDate: string | undefined + + constructor(status: number, serverDate?: string) { + super(`fetchRemoteKeys ${status}`) + this.status = status + this.serverDate = serverDate + } +} + jest.mock('../../util/keysServer', () => ({ + RemoteKeysError: MockRemoteKeysError, fetchRemoteKeys: async (...args: unknown[]) => await mockFetchRemoteKeys(...(args as [unknown])) })) const mockGetAttestationToken = jest.fn( async (_ms?: number) => undefined as string | undefined ) +const mockMaybeWarnClockSkew = jest.fn((_serverTime: unknown) => {}) + +// Stands in for the engine's token subscription, which replays the current +// token to a new listener before it returns: +type MockTokenListener = (token: string | undefined) => void +const mockTokenListeners = new Set() +const mockTokenState: { current: string | undefined } = { current: undefined } +const deliverToken = (token: string | undefined): void => { + mockTokenState.current = token + for (const listener of [...mockTokenListeners]) listener(token) +} jest.mock('../../util/attestation', () => ({ getAttestationToken: async (...args: unknown[]) => - await mockGetAttestationToken(...(args as [number?])) + await mockGetAttestationToken(...(args as [number?])), + maybeWarnClockSkew: (serverTime: unknown) => { + mockMaybeWarnClockSkew(serverTime) + }, + onAttestationToken: (listener: MockTokenListener) => { + mockTokenListeners.add(listener) + listener(mockTokenState.current) + return () => { + mockTokenListeners.delete(listener) + } + } })) const mockRebuildAllPlugins = jest.fn() @@ -142,6 +174,8 @@ const freshModules = (): FreshModules => { return { keysStore, config, keys, pluginMaps } } +const DAY_MS = 24 * 60 * 60 * 1000 + const flushMicrotasks = async (): Promise => { for (let i = 0; i < 20; i++) await Promise.resolve() } @@ -227,7 +261,10 @@ describe('initializeKeys', () => { mockAwaitDeviceSettingsDisk.mockImplementation(async () => {}) mockGetKeysCache.mockReturnValue(undefined) mockWriteKeysCache.mockImplementation(async () => {}) + mockGetAttestationToken.mockReset() mockGetAttestationToken.mockResolvedValue(undefined) + mockTokenListeners.clear() + mockTokenState.current = undefined mockFetchRemoteKeys.mockReset() }) @@ -409,6 +446,410 @@ describe('initializeKeys', () => { expect(keysStore.getKeysTier()).toBe('baked-in') }) + it('checks the device clock against the server when the fetch is refused', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const serverDate = 'Wed, 07 Oct 2026 19:55:14 GMT' + mockFetchRemoteKeys.mockRejectedValue( + new MockRemoteKeysError(401, serverDate) + ) + + try { + const { keysStore } = freshModules() + await keysStore.initializeKeys() + + expect(keysStore.getKeysTier()).toBe('baked-in') + expect(mockMaybeWarnClockSkew).toHaveBeenCalledTimes(1) + expect(mockMaybeWarnClockSkew).toHaveBeenCalledWith(serverDate) + } finally { + warn.mockRestore() + } + }) + + it('leaves the clock alone when the fetch fails for another reason', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + mockFetchRemoteKeys + .mockRejectedValueOnce( + new MockRemoteKeysError(503, 'Wed, 07 Oct 2026 19:55:14 GMT') + ) + .mockRejectedValue(new Error('network down')) + + try { + const first = freshModules() + await first.keysStore.initializeKeys() + const second = freshModules() + await second.keysStore.initializeKeys() + + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(2) + expect(mockMaybeWarnClockSkew).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + + it('fetches again and caches once a token arrives after an unattested fetch', async () => { + mockGetAttestationToken + .mockResolvedValueOnce(undefined) + .mockResolvedValue('late-token') + mockFetchRemoteKeys + .mockResolvedValueOnce({ + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default' + }) + .mockResolvedValue({ + keys: { globalKeys: { AZTECO_API_KEY: 'hardware-layer' } }, + assuranceLevel: 'hardware' + }) + + const { keysStore, keys } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(1) + expect(mockWriteKeysCache).toHaveBeenCalledTimes(1) + expect(mockWriteKeysCache).toHaveBeenLastCalledWith( + expect.objectContaining({ assuranceLevel: 'default', attested: false }) + ) + expect(mockTokenListeners.size).toBe(1) + + // The handshake fails first and succeeds on a later retry: + deliverToken(undefined) + await flushMicrotasks() + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(1) + + deliverToken('late-token') + await flushMicrotasks() + + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(2) + expect(mockFetchRemoteKeys).toHaveBeenLastCalledWith( + expect.objectContaining({ attestationToken: 'late-token' }) + ) + expect(mockWriteKeysCache).toHaveBeenLastCalledWith( + expect.objectContaining({ + keys: { globalKeys: { AZTECO_API_KEY: 'hardware-layer' } }, + assuranceLevel: 'hardware', + attested: true + }) + ) + // The cache is for the next launch. This one keeps the keys it booted on: + expect(keysStore.getKeysTier()).toBe('remote') + expect(keys.globalKeys.AZTECO_API_KEY).toBe('default-layer') + + // The answer that carried a token is cached, so the wait is over however + // often the token rotates afterwards: + expect(mockTokenListeners.size).toBe(0) + deliverToken('rotated-token') + await flushMicrotasks() + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(2) + }) + + it('fetches again when the token landed just after the wait gave up', async () => { + mockTokenState.current = 'just-landed' + mockGetAttestationToken + .mockResolvedValueOnce(undefined) + .mockResolvedValue('just-landed') + mockFetchRemoteKeys.mockImplementation(async opts => { + const { attestationToken } = opts as { attestationToken?: string } + return attestationToken == null + ? { + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default' + } + : { + keys: { globalKeys: { AZTECO_API_KEY: 'hardware-layer' } }, + assuranceLevel: 'hardware' + } + }) + + const cache = useLiveCache() + + const { keysStore } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(2) + expect(mockTokenListeners.size).toBe(0) + // Whichever of the two answers landed first: + expect(cache.current).toEqual( + expect.objectContaining({ assuranceLevel: 'hardware', attested: true }) + ) + }) + + it('does not wait for a token when the fetch went out with one', async () => { + mockGetAttestationToken.mockResolvedValue('token') + mockFetchRemoteKeys.mockResolvedValue({ + keys: { globalKeys: { AZTECO_API_KEY: 'hardware-layer' } }, + assuranceLevel: 'hardware' + }) + + const { keysStore } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + + expect(mockTokenListeners.size).toBe(0) + deliverToken('rotated-token') + await flushMicrotasks() + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(1) + expect(mockWriteKeysCache).toHaveBeenCalledTimes(1) + }) + + it('keeps an attested cache entry when the unattested answer lands after it', async () => { + // Written before the cache recorded whether the request carried a token, + // so nothing but the overlap rule protects what the refetch caches: + const cache = useLiveCache({ + keys: { globalKeys: { AZTECO_API_KEY: 'from-cache' } }, + fetchedAt: 1, + assuranceLevel: 'hardware' + }) + mockGetAttestationToken + .mockResolvedValueOnce(undefined) + .mockResolvedValue('late-token') + let resolveUnattested!: (value: { + keys: Record + assuranceLevel?: string + }) => void + mockFetchRemoteKeys + .mockImplementationOnce( + async () => + await new Promise(resolve => { + resolveUnattested = resolve + }) + ) + .mockResolvedValue({ + keys: { globalKeys: { AZTECO_API_KEY: 'hardware-layer' } }, + assuranceLevel: 'hardware' + }) + + const { keysStore } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + expect(keysStore.getKeysTier()).toBe('cache') + + deliverToken('late-token') + await flushMicrotasks() + expect(mockWriteKeysCache).toHaveBeenCalledTimes(1) + expect(mockWriteKeysCache).toHaveBeenLastCalledWith( + expect.objectContaining({ assuranceLevel: 'hardware' }) + ) + + resolveUnattested({ + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default' + }) + await flushMicrotasks() + await flushMicrotasks() + expect(mockWriteKeysCache).toHaveBeenCalledTimes(1) + expect(cache.current).toEqual( + expect.objectContaining({ assuranceLevel: 'hardware', attested: true }) + ) + }) + + it('keeps a recent attested cache entry when the launch gets no token', async () => { + const entry = { + keys: { globalKeys: { AZTECO_API_KEY: 'from-cache' } }, + fetchedAt: Date.now() - DAY_MS, + assuranceLevel: 'hardware', + attested: true + } + const cache = useLiveCache(entry) + mockFetchRemoteKeys.mockResolvedValue({ + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default' + }) + + const { keysStore } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + await flushMicrotasks() + + expect(keysStore.getKeysTier()).toBe('cache') + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(1) + expect(mockWriteKeysCache).not.toHaveBeenCalled() + expect(cache.current).toBe(entry) + // Still waiting for a token to refresh the entry with: + expect(mockTokenListeners.size).toBe(1) + }) + + it.each([ + ['older than the hold', -4 * DAY_MS], + ['stamped by a clock that has since been set back', 4 * DAY_MS] + ])( + 'replaces an attested cache entry %s when the launch gets no token', + async (_name, offset) => { + const cache = useLiveCache({ + keys: { globalKeys: { AZTECO_API_KEY: 'from-cache' } }, + fetchedAt: Date.now() + offset, + assuranceLevel: 'hardware', + attested: true + }) + mockFetchRemoteKeys.mockResolvedValue({ + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default' + }) + + const { keysStore } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + await flushMicrotasks() + + expect(keysStore.getKeysTier()).toBe('cache') + expect(cache.current).toEqual( + expect.objectContaining({ + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default', + attested: false + }) + ) + } + ) + + it('replaces an attested cache entry that will not merge', async () => { + const cache = useLiveCache({ + keys: { guiApiKeys: 'not-an-object' }, + fetchedAt: Date.now(), + assuranceLevel: 'hardware', + attested: true + }) + mockFetchRemoteKeys.mockResolvedValue({ + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default' + }) + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const { keysStore } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + + expect(keysStore.getKeysTier()).toBe('remote') + expect(cache.current).toEqual( + expect.objectContaining({ assuranceLevel: 'default', attested: false }) + ) + } finally { + warn.mockRestore() + } + }) + + it('fetches again on the next token when the refetch fails', async () => { + mockGetAttestationToken + .mockResolvedValueOnce(undefined) + .mockResolvedValue('token') + mockFetchRemoteKeys + .mockResolvedValueOnce({ + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default' + }) + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValue({ + keys: { globalKeys: { AZTECO_API_KEY: 'hardware-layer' } }, + assuranceLevel: 'hardware' + }) + const cache = useLiveCache() + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const { keysStore } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + + deliverToken('first-token') + await flushMicrotasks() + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(2) + expect(cache.current?.assuranceLevel).toBe('default') + expect(mockTokenListeners.size).toBe(1) + + deliverToken('refreshed-token') + await flushMicrotasks() + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(3) + expect(cache.current).toEqual( + expect.objectContaining({ assuranceLevel: 'hardware', attested: true }) + ) + expect(mockTokenListeners.size).toBe(0) + } finally { + warn.mockRestore() + } + }) + + it('stays subscribed when the token is gone before the refetch is sent', async () => { + mockGetAttestationToken + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValue('token') + mockFetchRemoteKeys.mockImplementation(async opts => { + const { attestationToken } = opts as { attestationToken?: string } + return attestationToken == null + ? { + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default' + } + : { + keys: { globalKeys: { AZTECO_API_KEY: 'hardware-layer' } }, + assuranceLevel: 'hardware' + } + }) + const cache = useLiveCache() + + const { keysStore } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + + deliverToken('dropped-token') + await flushMicrotasks() + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(2) + expect(cache.current?.attested).toBe(false) + expect(mockTokenListeners.size).toBe(1) + + deliverToken('token') + await flushMicrotasks() + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(3) + expect(cache.current).toEqual( + expect.objectContaining({ assuranceLevel: 'hardware', attested: true }) + ) + expect(mockTokenListeners.size).toBe(0) + }) + + it('does not start a second refetch while one is in flight', async () => { + mockGetAttestationToken + .mockResolvedValueOnce(undefined) + .mockResolvedValue('token') + let resolveRefetch!: (value: { + keys: Record + assuranceLevel?: string + }) => void + mockFetchRemoteKeys + .mockResolvedValueOnce({ + keys: { globalKeys: { AZTECO_API_KEY: 'default-layer' } }, + assuranceLevel: 'default' + }) + .mockImplementationOnce( + async () => + await new Promise(resolve => { + resolveRefetch = resolve + }) + ) + const cache = useLiveCache() + + const { keysStore } = freshModules() + await keysStore.initializeKeys() + await flushMicrotasks() + + deliverToken('first-token') + await flushMicrotasks() + deliverToken('refreshed-token') + await flushMicrotasks() + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(2) + + resolveRefetch({ + keys: { globalKeys: { AZTECO_API_KEY: 'hardware-layer' } }, + assuranceLevel: 'hardware' + }) + await flushMicrotasks() + expect(mockFetchRemoteKeys).toHaveBeenCalledTimes(2) + expect(cache.current).toEqual( + expect.objectContaining({ assuranceLevel: 'hardware', attested: true }) + ) + expect(mockTokenListeners.size).toBe(0) + }) + it('never rejects even when awaitDeviceSettingsDisk throws', async () => { mockAwaitDeviceSettingsDisk.mockRejectedValue(new Error('disk broken')) diff --git a/src/actions/ExchangeRateActions.ts b/src/actions/ExchangeRateActions.ts index 7f638a00fe0..fd67d7c62c1 100644 --- a/src/actions/ExchangeRateActions.ts +++ b/src/actions/ExchangeRateActions.ts @@ -352,6 +352,11 @@ async function fetchExchangeRates( const cleanedRates = asRatesParams(json) const targetFiat = fixFiatCurrencyCode(cleanedRates.targetFiat) + // The server stamps each rate with its own clock, so judge a rate's + // age against that clock. A device clock set ahead would otherwise + // file every current rate as historical and leave no rates to show: + const serverNow = getServerNow(response.headers.get('date'), now) + if (verbose) { // The requested pairs are already logged above (request body), so // here we only summarize the outcome and name the pairs the server @@ -395,7 +400,7 @@ async function fetchExchangeRates( const rateObj = rates.crypto[pluginId][safeTokenId][targetFiat] const isHistorical = - isoDate != null && isoDate.getTime() < now - ONE_HOUR + isoDate != null && isoDate.getTime() < serverNow - ONE_HOUR if (isHistorical) { const dateTimestamp = isoDate.getTime() const yesterdayTargetTimestamp = Date.parse(yesterday) @@ -432,7 +437,7 @@ async function fetchExchangeRates( const rateObj = rates.fiat[fiatCode][targetFiat] const isHistorical = - isoDate != null && isoDate.getTime() < now - ONE_HOUR + isoDate != null && isoDate.getTime() < serverNow - ONE_HOUR if (isHistorical) { const dateTimestamp = isoDate.getTime() const yesterdayTargetTimestamp = Date.parse(yesterday) @@ -577,6 +582,19 @@ export function mergePairCache( } } +/** + * The rates server's current time, read from its `Date` response header. + * Falls back to the device clock when the header is missing or unreadable. + */ +export const getServerNow = ( + dateHeader: string | null, + deviceNow: number +): number => { + if (dateHeader == null) return deviceNow + const serverNow = Date.parse(dateHeader) + return isNaN(serverNow) ? deviceNow : serverNow +} + const getYesterdayDateRoundDownHour = (now?: Date | number): Date => { const yesterday = now == null ? new Date() : new Date(now) yesterday.setMinutes(0) diff --git a/src/actions/LogActions.tsx b/src/actions/LogActions.tsx index 00a5a4d6b2c..43d91faa758 100644 --- a/src/actions/LogActions.tsx +++ b/src/actions/LogActions.tsx @@ -36,6 +36,21 @@ import { getExchangeRateCacheDump } from './ExchangeRateActions' const logsUri = 'https://logs1.edge.app/v1/log/' +// The logs server answers 400 with this body when a log is dated more than +// five minutes away from its own clock: +const LOGS_CLOCK_REFUSAL = 'Time Out of Sync' + +/** + * The logs server refused an upload because the device clock is wrong. The + * message is ready to show to the user as-is. + */ +export class LogsClockError extends Error { + constructor() { + super(lstrings.settings_modal_send_logs_clock_error) + this.name = 'LogsClockError' + } +} + export interface MultiLogOutput { activity: LogOutput info: LogOutput @@ -288,8 +303,12 @@ export const sendLogs = async ( }, body: JSON.stringify(logs) }) - .then(response => { + .then(async response => { if (!response.ok) { + const text = await response.text() + if (response.status === 400 && text.trim() === LOGS_CLOCK_REFUSAL) { + throw new LogsClockError() + } throw Error(`${logsUri} returned status ${response.status}`) } console.log(`====== SENDING LOGS SUCCESS ======`, response) diff --git a/src/components/modals/LogsModal.tsx b/src/components/modals/LogsModal.tsx index 343573e0b0a..c72c7fe770b 100644 --- a/src/components/modals/LogsModal.tsx +++ b/src/components/modals/LogsModal.tsx @@ -5,7 +5,11 @@ import RNFS from 'react-native-fs' import Share, { type ShareOptions } from 'react-native-share' import { sprintf } from 'sprintf-js' -import { type MultiLogOutput, sendLogs } from '../../actions/LogActions' +import { + LogsClockError, + type MultiLogOutput, + sendLogs +} from '../../actions/LogActions' import { lstrings } from '../../locales/strings' import { config } from '../../theme/appConfig' import { useSelector } from '../../types/reactRedux' @@ -91,12 +95,14 @@ export const LogsModal: React.FC = props => { await Promise.all([ sendLogs(logs.activity, underDuress).catch((error: unknown) => { + if (error instanceof LogsClockError) throw error const message = error instanceof Error ? error.message : String(error) throw new Error( `${lstrings.settings_modal_send_logs_failure} activity logs code ${message}` ) }), sendLogs(logs.info, underDuress).catch((error: unknown) => { + if (error instanceof LogsClockError) throw error const message = error instanceof Error ? error.message : String(error) throw new Error( `${lstrings.settings_modal_send_logs_failure} info logs code ${message}` diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 5a406c10979..7f23af6c1de 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -666,6 +666,8 @@ const strings = { settings_modal_clear_logs_success: 'Logs have been cleared', settings_modal_send_logs_success: 'Logs have been sent', settings_modal_send_logs_failure: 'Sending logs has failed', + settings_modal_send_logs_clock_error: + 'Logs could not be sent because your device clock is wrong. Turn on automatic date & time in your system settings and try again.', settings_modal_share_logs_failure: 'Sharing logs has failed', settings_modal_send_unsafe: 'These logs appear to contain sensitive information, such as private keys and addresses, that could result in the loss of funds. Therefore, it is not safe to send these logs to Edge servers.', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index ab32432ad03..0bb9b7e7a4d 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -492,6 +492,7 @@ "settings_modal_clear_logs_success": "Logs have been cleared", "settings_modal_send_logs_success": "Logs have been sent", "settings_modal_send_logs_failure": "Sending logs has failed", + "settings_modal_send_logs_clock_error": "Logs could not be sent because your device clock is wrong. Turn on automatic date & time in your system settings and try again.", "settings_modal_share_logs_failure": "Sharing logs has failed", "settings_modal_send_unsafe": "These logs appear to contain sensitive information, such as private keys and addresses, that could result in the loss of funds. Therefore, it is not safe to send these logs to Edge servers.", "settings_modal_send_logs_label": "Type Notes Here", diff --git a/src/types/types.ts b/src/types/types.ts index 7e07c998665..4a99a885b37 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -261,10 +261,15 @@ const asDeviceSettingsInner = asObject({ keysCache: asMaybe( asObject({ keys: asUnknown, - // When the cache was last written. Diagnostic only: the warm path does - // not expire; a new signed infoRollup refresh replaces the blob for the next launch. + // When the cache was last written. The warm path does not expire; a new + // signed infoRollup refresh replaces the blob for the next launch. The + // stamp only bounds how long an `attested` entry is kept over an answer + // to a request without a token (see `cacheKeys` in `keysStore.ts`). fetchedAt: asMaybe(asNumber, 0), - assuranceLevel: asMaybe(asString) + assuranceLevel: asMaybe(asString), + // Whether the request this answer came from carried an attestation + // token. Missing on caches written before the field existed. + attested: asMaybe(asBoolean) }) ) }) diff --git a/src/util/attestation.ts b/src/util/attestation.ts index d2cfdeb863a..5df9bf0c5c8 100644 --- a/src/util/attestation.ts +++ b/src/util/attestation.ts @@ -235,8 +235,13 @@ export const resetAttestationForTests = (): void => { * Warn once per day of app uptime when the device wall clock disagrees with the * server by more than `CLOCK_WARN_MS`. `serverTime` is a UX signal only - it * never feeds lifetimes, backoff, or refresh math. + * + * Accepts any date string `Date.parse` understands: the ISO `serverTime` of an + * attestation response, or the HTTP `Date` header of a signed request the + * server refused (see `keysStore`). The second source covers a launch that runs + * no handshake, where the first never arrives. */ -const maybeWarnClockSkew = (serverTime: unknown): void => { +export const maybeWarnClockSkew = (serverTime: unknown): void => { if (typeof serverTime !== 'string') return const parsed = Date.parse(serverTime) if (Number.isNaN(parsed)) return diff --git a/src/util/keysServer.ts b/src/util/keysServer.ts index 70903e07d6b..424c06bf001 100644 --- a/src/util/keysServer.ts +++ b/src/util/keysServer.ts @@ -39,6 +39,23 @@ export type FetchCredentials = | { apiSigner: EdgeApiSigner } | { apiKey: string; secret: Uint8Array } +/** + * A non-OK answer to the signed infoRollup request. Carries the status and the + * response's HTTP `Date` header, so the caller can tell a refused signature + * from an outage and compare the device clock against the server's. + */ +export class RemoteKeysError extends Error { + readonly status: number + readonly serverDate: string | undefined + + constructor(status: number, text: string, serverDate: string | undefined) { + super(`fetchRemoteKeys ${status}: ${text.slice(0, 200)}`) + this.name = 'RemoteKeysError' + this.status = status + this.serverDate = serverDate + } +} + export async function fetchRemoteKeys( opts: FetchCredentials & { appId: string @@ -100,7 +117,11 @@ export async function fetchRemoteKeys( ) if (!response.ok) { const text = await response.text() - throw new Error(`fetchRemoteKeys ${response.status}: ${text.slice(0, 200)}`) + throw new RemoteKeysError( + response.status, + text, + response.headers.get('date') ?? undefined + ) } const parsed = asSignedInfoRollupKeysFile(await response.text()) diff --git a/src/util/keysStore.ts b/src/util/keysStore.ts index 32a9c95eb6c..1964b79aa41 100644 --- a/src/util/keysStore.ts +++ b/src/util/keysStore.ts @@ -19,10 +19,18 @@ import { applyRuntimeKeys, bakedKeys, globalKeys, KEYS } from '../keys' import { LOCAL_ONLY_PREFIXES, LOCAL_ONLY_TOP_LEVEL } from '../localOnlyKeys' import { rebuildPluginMaps } from '../pluginMaps' import { config } from '../theme/appConfig' -import { getAttestationToken } from './attestation' +import { + getAttestationToken, + maybeWarnClockSkew, + onAttestationToken +} from './attestation' import { rebuildAllPlugins } from './corePlugins' import { getNativeApiSigner, isUsableApiKey } from './edgeApiSigner' -import { type FetchCredentials, fetchRemoteKeys } from './keysServer' +import { + type FetchCredentials, + fetchRemoteKeys, + RemoteKeysError +} from './keysServer' import { debugLog } from './logger' import { fetchPublicRollup, infoServerData } from './network' import { raceTimeout, TIMED_OUT } from './raceTimeout' @@ -58,6 +66,13 @@ const SETTINGS_READ_TIMEOUT_MS = 2000 const SETTINGS_SALVAGE_TIMEOUT_MS = SETTINGS_READ_TIMEOUT_MS /** Cap on a hung background refresh so it does not linger forever. */ const BACKGROUND_CACHE_TIMEOUT_MS = COLD_TOTAL_TIMEOUT_MS +/** + * How long a cached answer to a request that carried an attestation token is + * kept over the answer to a request without one. Long enough to ride out + * launches where the handshake is slow, failing, or rate limited; short enough + * that a device which can no longer attest stops serving old attested keys. + */ +const ATTESTED_CACHE_HOLD_MS = 3 * 24 * 60 * 60 * 1000 /** * Secrets the info server must never serve, so a remote payload can never @@ -74,6 +89,8 @@ const KEYS_JSON_FIELDS = new Set(Object.keys(asKeysJson.shape)) let keysTier: KeysTier = 'baked-in' let initPromise: Promise | undefined +/** True once this launch has subscribed for its late-token refresh. */ +let lateTokenRefreshArmed = false function isLocalOnlyTopLevel(key: string): boolean { return ( @@ -164,6 +181,8 @@ function applyKeys(keys: unknown): boolean { interface FetchedKeys { keys: unknown assuranceLevel: string + /** Whether the request carried an attestation token. */ + attested: boolean } function applyPublicRollup(raw: unknown): void { @@ -236,6 +255,7 @@ async function fetchKeysInner(): Promise { try { const attestationToken = await getAttestationToken(ATTESTATION_BUDGET_MS) const attested = attestationToken != null && attestationToken !== '' + if (!attested) refreshWhenAttested() const result = await fetchRemoteKeys({ ...credentials, appId: config.appId ?? 'edge', @@ -250,15 +270,97 @@ async function fetchKeysInner(): Promise { // The server reports the layer it actually served; fall back to what we // asked for when talking to an older server that omits the field. assuranceLevel: - result.assuranceLevel ?? (attested ? 'attested' : 'unattested') + result.assuranceLevel ?? (attested ? 'attested' : 'unattested'), + attested } } catch (error: unknown) { + // The server signs off on a request only when its timestamp is inside a + // few minutes of server time, so a wrong device clock fails every launch + // here with a 401. An error response is never served from an HTTP cache, + // which makes its `Date` header a current reading of the server clock. + if (error instanceof RemoteKeysError && error.status === 401) { + maybeWarnClockSkew(error.serverDate) + } console.warn('initializeKeys: remote keys fetch failed', String(error)) return null } } -async function cacheKeys(result: FetchedKeys): Promise { +/** + * A request that went out with no attestation token is answered from the + * `default` layer, which lacks the partner keys an attested device is served. + * The handshake can still deliver a token later in the same launch: it ran past + * the budget, or it failed and a retry succeeded. Fetch once more when that + * happens and cache the answer, so the next launch starts on the full payload + * instead of the reduced one. + * + * Subscribes at most once per launch, and stays subscribed until the answer to + * a request that carried a token is in the cache: a refetch that fails is tried + * again when the engine next hands out a token. Only writes the cache: the keys + * a running core was built with are never swapped. + */ +function refreshWhenAttested(): void { + if (lateTokenRefreshArmed) return + lateTokenRefreshArmed = true + + const subscription: { busy: boolean; stop?: () => void } = { busy: false } + const refresh = async (): Promise => { + const result = await fetchKeysInner() + if (result == null) return + const cached = await cacheKeys(result) + // The token can be gone again by the time the request is built, so only an + // answer to a request that carried one ends the wait: + if (cached && result.attested) subscription.stop?.() + } + const handleToken = (token: string | undefined): void => { + if (token == null || token === '') return + // One refetch at a time. The engine can hand out its next token while the + // request for this one is still in flight: + if (subscription.busy) return + subscription.busy = true + refresh() + .catch((error: unknown) => { + console.warn('initializeKeys: attested refresh failed', String(error)) + }) + .finally(() => { + subscription.busy = false + }) + } + // The subscription replays the current token before it returns, so a token + // that landed since the wait above gave up starts a refetch from in here: + subscription.stop = onAttestationToken(handleToken) +} + +/** + * True when the cache holds a usable answer to a request that carried a token, + * recent enough to be kept over the answer to a request without one. + */ +function isHoldingAttestedCache(): boolean { + const cache = getKeysCache() + if (cache?.attested !== true) return false + // Measured in both directions, so a stamp left by a clock that has since + // been set back is not held until the clock catches up with it: + const age = Math.abs(Date.now() - cache.fetchedAt) + if (age >= ATTESTED_CACHE_HOLD_MS) return false + // An entry that will not merge counts as no cache, and any answer repairs it: + return asMaybe(asMergeableKeys)(cache.keys) != null +} + +/** + * Write a fetched payload to the cache for the next launch. Resolves `true` + * when the payload was written. + */ +async function cacheKeys(result: FetchedKeys): Promise { + // The cached entry decides whether the answer to a request without a token + // may replace it. A launch can miss the attestation budget while the device + // is still good (slow handshake, rate limit), and the `default` layer that + // request is answered from would strip the partner keys from the next + // launch. Two requests can also be in flight at once (see + // `refreshWhenAttested`): `writeKeysCache` updates the entry in memory before + // it touches disk, so this check sees the other answer as soon as it is + // written, whichever order the two land in. + if (!result.attested && isHoldingAttestedCache()) return false + let overlay: Record try { overlay = nestGlobalKeys( @@ -269,13 +371,15 @@ async function cacheKeys(result: FetchedKeys): Promise { 'initializeKeys: refusing to cache unusable keys payload', String(error) ) - return + return false } await writeKeysCache({ keys: overlay, fetchedAt: Date.now(), - assuranceLevel: result.assuranceLevel + assuranceLevel: result.assuranceLevel, + attested: result.attested }) + return true } /**