diff --git a/CHANGELOG.md b/CHANGELOG.md index cfba92d8c57..1d583e5242b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- added: Push info-server attestation tokens into edge-core-js via `setAttestationToken` so the login server can skip CAPTCHA for attested devices, and allow `LOGIN_SERVER` / `INFO_SERVER` env overrides for local E2E stacks. - added: App/device attestation for gated info-server requests - added: "-m" tag on the version number in the Help scene for Maestro test builds - added: Sign Message option in the wallet list menu for Bitcoin-family wallets, letting users prove self-hosted wallet ownership to exchanges by signing an exchange-provided message. @@ -18,6 +19,7 @@ - changed: Deep links now wait only for the account state they actually use, so a link that just opens a scene, such as the buy/sell entry, follows immediately after login instead of waiting for every wallet to finish loading. - fixed: The buy/sell amount field no longer reads "Amount undefined" while the app is still working out which wallet to use. - fixed: Show the Monero Transaction Key of a send whose key never reached the transaction's saved metadata, by falling back to the key the wallet engine mirrors into `otherParams`. Covers sends made on 4.49.0 and later while the send path reported no key, on devices that still hold the original wallet cache. +- fixed: Force `NODE_ENV=test` in the Jest script so UI tests keep working when npm is invoked via Socket (Socket otherwise sets `NODE_ENV=development`, which makes react-native-gesture-handler treat Jest as a non-test env). - fixed: Bitwave CSV exports now use ISO 8601 UTC timestamps, leave the fee columns blank so Bitwave does not double-count fees, and copy the description into the second custom metadata column. - fixed: Bitwave account ids are no longer capitalized by the keyboard or padded with whitespace when entered, so exports import without hand-editing the account id. - fixed: NYM max swaps from EVM wallets now report the correct limit error instead of an unsupported-route error (edge-exchange-plugins 2.52.1). diff --git a/package.json b/package.json index a0d033424c3..be27047e19c 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "rates-cache-replay": "node -r sucrase/register scripts/ratesCacheReplay.ts", "server": "node ./loggingServer.js", "start": "react-native start", - "test": "TZ=America/Los_Angeles jest", + "test": "NODE_ENV=test TZ=America/Los_Angeles jest", "typechain": "rm -rf './src/plugins/contracts/' && typechain --target ethers-v5 --out-dir ./src/plugins/contracts/ './src/plugins/abis/*.json'", "theme": "node -r sucrase/register ./scripts/themeServer.ts", "updateVersion": "node -r sucrase/register scripts/updateVersion.ts", diff --git a/src/__tests__/util/attestation.test.ts b/src/__tests__/util/attestation.test.ts index 45cb31e1395..f2970050510 100644 --- a/src/__tests__/util/attestation.test.ts +++ b/src/__tests__/util/attestation.test.ts @@ -66,6 +66,7 @@ const { attestedJsonHeaders, getAttestationToken, initAttestation, + onAttestationToken, resetAttestationForTests } = require('../../util/attestation') @@ -2110,4 +2111,91 @@ describe('attestation engine', () => { expect(mockClearKey.mock.calls[0]).toStrictEqual(['key-rejected']) }) }) + + describe('onAttestationToken', () => { + it('fires with the JWT after a successful handshake', async () => { + const listener = jest.fn<(token: string | undefined) => void>() + onAttestationToken(listener) + // Sync emit of the current (empty) cache on subscribe: + expect(listener.mock.calls).toEqual([[undefined]]) + listener.mockClear() + mockSuccessfulHandshake() + initAttestation() + await flush() + + expect(listener.mock.calls).toEqual([['jwt-token']]) + }) + + it('fires with undefined when an assertion is rejected', async () => { + const { REFRESH_LEAD_MS } = attestationTimingForTests + const REFRESH_UNTIL_MS = 5 * 60 * 1000 + const listener = jest.fn<(token: string | undefined) => void>() + onAttestationToken(listener) + listener.mockClear() + mockSuccessfulHandshake((REFRESH_LEAD_MS + REFRESH_UNTIL_MS) / 1000) + initAttestation() + await flush() + expect(listener.mock.calls).toEqual([['jwt-token']]) + listener.mockClear() + + mockGenerateAssertion.mockResolvedValue({ + keyId: 'K1', + assertion: 'assert-1', + bundleId: 'co.edgesecure.app' + }) + mockSignChallenge.mockResolvedValue({ keyId: 'K1', signature: 'sig-1' }) + mockGetAttestation.mockRejectedValue(new Error('attestation unavailable')) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-2' }) + } + if (path.endsWith('/assert')) return jsonResponse({}, false, 401) + throw new Error(`unexpected path ${path}`) + }) + await jest.advanceTimersByTimeAsync(REFRESH_UNTIL_MS) + await flush() + + expect(listener.mock.calls).toContainEqual([undefined]) + }) + + it('stops notifying after unsubscribe', async () => { + const { REFRESH_LEAD_MS } = attestationTimingForTests + const REFRESH_UNTIL_MS = 5 * 60 * 1000 + const listener = jest.fn<(token: string | undefined) => void>() + const stillListening = jest.fn<(token: string | undefined) => void>() + const unsubscribe = onAttestationToken(listener) + onAttestationToken(stillListening) + listener.mockClear() + stillListening.mockClear() + + mockSuccessfulHandshake((REFRESH_LEAD_MS + REFRESH_UNTIL_MS) / 1000) + initAttestation() + await flush() + expect(listener.mock.calls).toEqual([['jwt-token']]) + expect(stillListening.mock.calls).toEqual([['jwt-token']]) + listener.mockClear() + stillListening.mockClear() + unsubscribe() + + mockGenerateAssertion.mockResolvedValue({ + keyId: 'K1', + assertion: 'assert-1', + bundleId: 'co.edgesecure.app' + }) + mockSignChallenge.mockResolvedValue({ keyId: 'K1', signature: 'sig-1' }) + mockGetAttestation.mockRejectedValue(new Error('attestation unavailable')) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-2' }) + } + if (path.endsWith('/assert')) return jsonResponse({}, false, 401) + throw new Error(`unexpected path ${path}`) + }) + await jest.advanceTimersByTimeAsync(REFRESH_UNTIL_MS) + await flush() + + expect(stillListening.mock.calls).toContainEqual([undefined]) + expect(listener.mock.calls).toEqual([]) + }) + }) }) diff --git a/src/components/services/EdgeCoreManager.tsx b/src/components/services/EdgeCoreManager.tsx index 6b9a92422e1..732070eec2d 100644 --- a/src/components/services/EdgeCoreManager.tsx +++ b/src/components/services/EdgeCoreManager.tsx @@ -38,6 +38,7 @@ import { useHandler } from '../../hooks/useHandler' import { useIsAppForeground } from '../../hooks/useIsAppForeground' import { lstrings } from '../../locales/strings' import { addMetadataToContext } from '../../util/addMetadataToContext' +import { onAttestationToken } from '../../util/attestation' import { allPlugins } from '../../util/corePlugins' import { fakeUser } from '../../util/fake-user' import { @@ -163,8 +164,19 @@ export const EdgeCoreManager: React.FC = props => { const handleContext = useHandler((context: EdgeContext) => { console.log('EdgeContext opened') + let active = true + const pushToken = (token: string | undefined): void => { + if (!active) return + context.setAttestationToken(token).catch((error: unknown) => { + console.warn('[attestation] setAttestationToken failed', error) + }) + } + const unsubscribeToken = onAttestationToken(pushToken) + // onAttestationToken sync-replays the current servable token on subscribe. context.on('close', () => { console.log('EdgeContext closed') + active = false + unsubscribeToken() setContext(null) }) ++counter.current @@ -197,8 +209,8 @@ export const EdgeCoreManager: React.FC = props => { ENV.DEBUG_EXCHANGES ? exchangeDebugUri : exchangeUri ] - let infoServer: string | undefined - let loginServer: string | undefined + let infoServer: string | string[] | undefined + let loginServer: string | string[] | undefined let syncServer: string | undefined if (shouldUseTestServers()) { @@ -208,6 +220,13 @@ export const EdgeCoreManager: React.FC = props => { syncServer = SYNC_TEST_SERVER } + if (ENV.LOGIN_SERVER != null && ENV.LOGIN_SERVER.length > 0) { + loginServer = ENV.LOGIN_SERVER + } + if (ENV.INFO_SERVER != null && ENV.INFO_SERVER.length > 0) { + infoServer = ENV.INFO_SERVER + } + return ( <> {ENV.USE_FAKE_CORE ? ( diff --git a/src/envConfig.ts b/src/envConfig.ts index f0181d68c3d..e590a27b17d 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -562,6 +562,10 @@ export const asEnvConfig = asObject({ // Optional override of the info server URL(s), e.g. for pointing a debug build // at a local info server: ["http://127.0.0.1:8008"]. Absent in production. INFO_SERVER: asOptional(asArray(asString)), + // Optional override of the login server URL(s), e.g. for pointing a debug + // build at a local login server: ["http://192.168.1.50:3123"]. Do not include + // `/api` in the path. Absent in production. + LOGIN_SERVER: asOptional(asArray(asString)), ENABLE_REDUX_PERF_LOGGING: asOptional(asBoolean, false), LOG_SERVER: asNullable( asObject({ diff --git a/src/util/attestation.ts b/src/util/attestation.ts index d43efa02434..8b439051056 100644 --- a/src/util/attestation.ts +++ b/src/util/attestation.ts @@ -142,9 +142,42 @@ let unsupported = false // warning per day of app uptime so the refresh cadence cannot re-prompt. let lastClockWarnAtMono: number | undefined +const tokenListeners = new Set<(token: string | undefined) => void>() + +const setCachedToken = (next: CachedToken | undefined): void => { + cachedToken = next + const token = canServeToken() ? cachedToken?.token : undefined + for (const listener of tokenListeners) { + try { + listener(token) + } catch (error) { + console.warn('[attestation] token listener threw', error) + } + } +} + +/** + * Subscribe to attestation token changes. Returns an unsubscribe function. + * Immediately invokes the listener with the current servable token (if any). + */ +export const onAttestationToken = ( + listener: (token: string | undefined) => void +): (() => void) => { + tokenListeners.add(listener) + try { + listener(canServeToken() ? cachedToken?.token : undefined) + } catch (error) { + console.warn('[attestation] token listener threw', error) + } + return () => { + tokenListeners.delete(listener) + } +} + /** Test-only: clear module state between Jest cases. */ export const resetAttestationForTests = (): void => { cachedToken = undefined + tokenListeners.clear() inFlight = undefined if (refreshTimer != null) clearTimeout(refreshTimer) refreshTimer = undefined @@ -355,7 +388,7 @@ const refreshWithEnrolledKey = async ( // this attempt - the key and token belong to a live handshake now, and clearing // them would force it into a needless re-attestation. assertCurrent(attempt) - cachedToken = undefined + setCachedToken(undefined) console.warn( `[attestation] assertion rejected (${response.status}); re-attesting` ) @@ -457,6 +490,12 @@ const delay = async (ms: number): Promise => { const armTimer = (delayMs: number): void => { if (refreshTimer != null) clearTimeout(refreshTimer) refreshTimer = setTimeout(() => { + // If the cached token can no longer be served (expiry), clear it so + // onAttestationToken listeners (e.g. EdgeCoreManager → setAttestationToken) + // drop the stale JWT before the handshake runs. + if (cachedToken != null && !canServeToken()) { + setCachedToken(undefined) + } runHandshake() }, delayMs) } @@ -605,7 +644,7 @@ const runHandshake = (): void => { } lastFailureAt = undefined consecutiveFailures = 0 - cachedToken = freshToken + setCachedToken(freshToken) console.log('[attestation] handshake ok') scheduleRefresh(freshToken.expiresMono) }) @@ -628,6 +667,11 @@ const runHandshake = (): void => { attempt.countedFailure = true } console.warn('[attestation] handshake failed:', String(error)) + // Drop an already-unservable JWT so listeners stop feeding edge-core a + // stale token for the full backoff window. + if (cachedToken != null && !canServeToken()) { + setCachedToken(undefined) + } scheduleRetryAfterFailure() }) .finally(() => { @@ -657,6 +701,11 @@ const runHandshake = (): void => { consecutiveFailures += 1 attempt.countedFailure = true } + // Drop an already-unservable JWT so listeners stop feeding edge-core a + // stale token while we wait out the hang backoff. + if (cachedToken != null && !canServeToken()) { + setCachedToken(undefined) + } // An attempt that never settles leaves nothing else to re-arm the loop. scheduleRetryAfterFailure() }, HANDSHAKE_WATCHDOG_MS)