Skip to content
Merged
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 @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ PODS:
- disklet (0.6.0):
- React
- DoubleConversion (1.1.6)
- edge-core-js (2.47.1):
- edge-core-js (2.48.0):
- React-Core
- edge-currency-accountbased (4.88.0):
- React-Core
Expand Down Expand Up @@ -3340,7 +3340,7 @@ SPEC CHECKSUMS:
CNIOWindows: 3047f2d8165848a3936a0a755fee27c6b5ee479b
disklet: ef8ef081e35a73fbed579888d29e49edc2f327cc
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
edge-core-js: c04529b2fc93a9ffca498c8b8bff94fc9e6e4b40
edge-core-js: 65a405086f5a6603342edd366c5c5598284af496
edge-currency-accountbased: 5473424f61984e99e89593835021d17bc035abff
edge-currency-plugins: 2b0de648624d46c0e32e4530f853673a3edfeaa6
edge-exchange-plugins: 849a09d9de22c8e1fd9e9af89f457921800cdf6a
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions 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 Expand Up @@ -106,7 +106,7 @@
"deprecated-react-native-prop-types": "^5.0.0",
"detect-bundler": "^1.1.0",
"disklet": "^0.6.0",
"edge-core-js": "^2.47.1",
"edge-core-js": "^2.48.0",
"edge-currency-accountbased": "^4.88.0",
"edge-currency-plugins": "^3.12.0",
"edge-exchange-plugins": "^2.53.0",
Expand Down
119 changes: 119 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,122 @@ 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('fires with undefined when a cached token becomes unservable', async () => {
// Refresh is armed while the token is still servable, and a failed
// refresh then waits out FAILURE_BACKOFF_MS. Listeners must still drop
// the JWT at the skew window - not whenever that later tick happens.
const { CLOCK_SKEW_MS, FAILURE_BACKOFF_MS, MIN_REFRESH_MS } =
attestationTimingForTests
const lifetimeMs = MIN_REFRESH_MS + CLOCK_SKEW_MS + 10 * 1000
const listener = jest.fn<(token: string | undefined) => void>()
onAttestationToken(listener)
listener.mockClear()
mockSuccessfulHandshake(lifetimeMs / 1000)
initAttestation()
await flush()
expect(listener.mock.calls).toEqual([['jwt-token']])
listener.mockClear()

mockCheapFailingHandshake()
await jest.advanceTimersByTimeAsync(MIN_REFRESH_MS)
await flush()
// Still inside the servable window, so the failure path must not clear.
expect(listener.mock.calls).toEqual([])
await expect(getAttestationToken()).resolves.toBe('jwt-token')

// Cross the skew window, but stay well short of the failure backoff.
await jest.advanceTimersByTimeAsync(CLOCK_SKEW_MS + 10 * 1000)
await flush()
expect(CLOCK_SKEW_MS + 10 * 1000).toBeLessThan(FAILURE_BACKOFF_MS)
expect(listener.mock.calls).toEqual([[undefined]])
await expect(getAttestationToken()).resolves.toBeUndefined()
})

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([])
})
})
})
24 changes: 22 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,20 @@ 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f20b6af.

pushToken now re-checks active when setAttestationToken rejects, so a context close during the in-flight bridge call no longer warns on a discarded context.

if (!active) return
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 +210,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 +221,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
Loading
Loading