Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -15,6 +16,7 @@
- changed: Use a custom chart icon for the side menu Markets row, so it matches the rest of the menu.
- changed: Use the UI4 warning card for the Reveal Raw Keys and Reveal Master Private Key password confirmation warnings.
- changed: Tron resource staking now describes its claim action as reclaiming your own TRX, instead of claiming a reward.
- 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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 88 additions & 0 deletions src/__tests__/util/attestation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const {
attestedJsonHeaders,
getAttestationToken,
initAttestation,
onAttestationToken,
resetAttestationForTests
} = require('../../util/attestation')

Expand Down Expand Up @@ -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([])
})
})
})
23 changes: 21 additions & 2 deletions src/components/services/EdgeCoreManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -163,8 +164,19 @@ export const EdgeCoreManager: React.FC<Props> = 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) => {

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.

pushToken checks active only at its synchronous entry, so a push already in flight when the context closes still lands after unsubscribe. Worst case is a warn or a set on the discarded context, so minor, but re-checking active when the call settles would tighten it.

sequenceDiagram
    participant att as attestation.ts
    participant mgr as EdgeCoreManager
    participant ctx as EdgeContext
    att->>mgr: listener(token)
    mgr->>mgr: active is true, proceed
    mgr-)ctx: setAttestationToken(token) async
    ctx-->>mgr: close event
    mgr->>mgr: active = false, unsubscribe
    ctx-->>mgr: earlier push settles on closed context
Loading

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.

Still open on 138680b (head unchanged since the review). Related to the Bugbot "stale token left in core" thread on this PR, which I independently confirmed: both are the push bridge lacking a guard the pull path (getAttestationToken) gets for free.

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
Expand Down Expand Up @@ -197,8 +209,8 @@ export const EdgeCoreManager: React.FC<Props> = 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()) {
Expand All @@ -208,6 +220,13 @@ export const EdgeCoreManager: React.FC<Props> = 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 ? (
Expand Down
4 changes: 4 additions & 0 deletions src/envConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
53 changes: 51 additions & 2 deletions src/util/attestation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale token left in core

Medium Severity

onAttestationToken only fires from setCachedToken, but canServeToken can flip to false while cachedToken still holds the JWT. getAttestationToken already withholds that value; opportunistic clears in the refresh timer and failure paths often run later (or after backoff), so EdgeCoreManager can keep feeding edge-core an expired token on login requests until then—especially after a failed proactive refresh or when JS timers lag in the background.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 138680b. Configure here.

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.

Confirmed against 138680b, not a false positive. canServeToken() is purely time-based (monotonicNow() < expiresMono - CLOCK_SKEW_MS) but listeners only fire from setCachedToken, so the servable-to-expired transition emits nothing on its own. getAttestationToken re-evaluates on every read and is safe; the push bridge into edge-core is not.

The window opens when a proactive refresh fails, because the next clear then waits on the failure backoff (and RN throttles background timers):

sequenceDiagram
    participant tmr as refresh timer
    participant att as attestation.ts
    participant core as edge-core-js
    participant srv as login server
    att->>core: setAttestationToken(jwt)
    Note over att: scheduleRefresh at expiry minus 5 min
    tmr->>att: handshake attempt
    att--xtmr: handshake fails, arm backoff
    Note over att: token expires, canServeToken false,<br/>no listener fires
    core->>srv: request with expired jwt
    srv->>srv: verify fails on every key, force-refresh, fails again
    srv-->>core: served as unattested
    tmr->>att: backoff fires, setCachedToken(undefined)
    att->>core: setAttestationToken(undefined)
Loading

Impact is fail-open rather than a security hole (the server re-checks expiry), but it costs the user a CAPTCHA they earned the right to skip, and each such request takes the login server's slow verify path: the double key-loop always, plus a real JWKS fetch up to once per 60s per worker (see my with-api-key thread on EdgeApp/edge-login-server#194).

Cheapest fix that also closes my two nit threads: a single getServableToken() used by setCachedToken, the onAttestationToken replay, and getAttestationToken, with the refresh timer armed at expiry rather than only at expiry minus the lead, so the clear does not depend on a successful handshake.


/**
* 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)

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.

Nit: the servable-token ternary now lives in three places (here, setCachedToken, and getAttestationToken's tail); a getServableToken() used by all three keeps subscribers and pollers in lockstep.

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.

Still open on 138680b. A shared getServableToken() would also give the stale-token issue Bugbot flagged a single place to fix.

} 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
Expand Down Expand Up @@ -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`
)
Expand Down Expand Up @@ -457,6 +490,12 @@ const delay = async (ms: number): Promise<void> => {
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()) {

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.

Nit: this guard is copy-pasted at three sites (armTimer, the handshake catch, the watchdog); a dropUnservableToken() helper keeps a future servability change from missing one path.

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.

Still open on 138680b.

setCachedToken(undefined)
}
runHandshake()
}, delayMs)
}
Expand Down Expand Up @@ -605,7 +644,7 @@ const runHandshake = (): void => {
}
lastFailureAt = undefined
consecutiveFailures = 0
cachedToken = freshToken
setCachedToken(freshToken)
console.log('[attestation] handshake ok')
scheduleRefresh(freshToken.expiresMono)
})
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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)
Expand Down
Loading