diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbf4c5cd..bd61c72c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- added: `EdgeContextOptions.apiSigner`, for delegating API request signing to native code. +- fixed: Logging in no longer fails when a plugin fails to load. Such a plugin is absent from `currencyConfig` and `swapConfig`, as already documented, instead of blocking every login in the app. + ## 2.48.1 (2026-08-31) - fixed: Stop rebuilding the NYM mixFetch client on every request while its gateway is failing. Each attempt spawns a web worker holding megabytes of WASM that the library gives no way to terminate, so a poll loop retrying every few seconds exhausted the host's memory and killed the JS context, which on iOS reads to the user as being logged out. A failed setup now starts a cooldown that doubles up to five minutes. diff --git a/README.md b/README.md index 45d484826..fae205506 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ This library implements the Edge login system. It runs inside a client applicati We have documentation at https://developer.airbitz.co/javascript/, but our [TypeScript types](./src/types/types.ts) are the best, most up-to-date reference for what this library contains. +HMAC delegation for login-server requests (`EdgeContextOptions.apiSigner`) is documented in [docs/api-signer.md](./docs/api-signer.md). Wallet key formats (not API HMAC) are in [docs/key-formats.md](./docs/key-formats.md). + ## Account Management UI To quickly get up and running with the UI for account creation, login, and management, use [edge-login-ui-web](https://github.com/EdgeApp/edge-login-ui/tree/develop/packages/edge-login-ui-web) for the web or [edge-login-ui-rn](https://github.com/EdgeApp/edge-login-ui/tree/develop/packages/edge-login-ui-rn) for React Native. @@ -25,6 +27,10 @@ To create an `EdgeContext` object, which provides various methods for logging in ```javascript const context = await makeEdgeContext({ apiKey: '...', // Get this from our support team + // Optional: HMAC secret in JS. Prefer `apiSigner` so the secret can live + // outside the bundle (see docs/api-signer.md). + // apiSecret: uint8ArraySecret, + // apiSigner: { signMessage: async (message) => ({ apiKey, signature }) }, appId: 'com.your-app', plugins: { // Configure currencies, exchange rates, and swap providers you want to use: @@ -61,6 +67,8 @@ To create an `EdgeContext` object, you need to mount a component: Promise +} + +interface EdgeContextOptions { + apiKey?: string + apiSecret?: Uint8Array + apiSigner?: EdgeApiSigner // takes precedence over apiKey / apiSecret + appId: string + // ... +} +``` + +On React Native, pass the same `apiSigner` prop to `MakeEdgeContext`. The +bridge `bridgifyObject`s it and the WebView worker forwards it into +`makeContext`. Implementors must return a usable `apiKey` (non-empty, no +whitespace) and a non-empty signature. + +## Canonical string + +`loginFetchInner` builds the UTF-8 message the signer (or `apiSecret`) HMACs: + +``` +{METHOD}\n/api{path}\n{BODY} +``` + +- `METHOD` is the HTTP method (`POST`, `GET`, …). +- Path is `/api` plus the login route, including any query string + (`/api/v2/login`, `/api/v2/login/create`, …). +- `BODY` is `JSON.stringify(wasLoginRequestBody(body))`, or empty for GET / + omitted bodies. + +The Authorization header is: + +``` +HMAC {apiKey} {signature} +``` + +When `apiSigner` is set, its `apiKey` and `signature` are used even if +`apiKey` / `apiSecret` were also passed. When `apiSigner` is absent and +`apiSecret` is present, the core HMACs with that secret. When neither is +present, the core sends the legacy `Token {apiKey}` header. + +There is **no** timestamp line and **no** `X-Timestamp` header. That extra line +is info-server `getKeys` only; do not feed a four-line getKeys string into this +signer for login, or a three-line login string into getKeys. + +## Attestation (separate from HMAC) + +`EdgeContext.setAttestationToken(jwt | undefined)` copies a short-lived +info-server attestation JWT onto subsequent login-server requests as +`x-attestation-token`. It does not participate in HMAC. A missing or invalid +token does not change how this library signs; the login server may treat it as +unattested and continue. getKeys (GUI → info-server) 401s on a bad token +instead. + +## Tests + +`test/core/login/api-signer.test.ts` checks that `apiSigner` wins over +`apiSecret` and that the signed message starts with `POST\n/api/`. diff --git a/src/core/login/login-fetch.ts b/src/core/login/login-fetch.ts index d0fe5f720..371c05efd 100644 --- a/src/core/login/login-fetch.ts +++ b/src/core/login/login-fetch.ts @@ -1,6 +1,7 @@ -import { asMaybe } from 'cleaners' +import { asMaybe, asObject, asString, Cleaner } from 'cleaners' import { base64 } from 'rfc4648' +import { ApiSignerError, asMaybeApiSignerError } from '../../types/error' import { asChallengeErrorPayload, asLoginResponseBody, @@ -9,6 +10,8 @@ import { import { LoginRequestBody } from '../../types/server-types' import { ChallengeError, + EdgeApiSignature, + EdgeApiSigner, EdgeFetchOptions, EdgeFetchResponse, NetworkError, @@ -22,6 +25,48 @@ import { utf8 } from '../../util/encoding' import { timeout } from '../../util/promise' import { ApiInput } from '../root-pixie' +const asEdgeApiSignature: Cleaner = asObject({ + apiKey: asString, + signature: asString +}) + +function isUsableSignerKey(apiKey: string, signature: string): boolean { + return apiKey !== '' && !/\s/.test(apiKey) && signature !== '' +} + +/** + * Build the login-server Authorization header from apiSigner, apiSecret, or + * the legacy Token fallback. + */ +export async function makeLoginAuthorization(opts: { + apiSigner?: EdgeApiSigner + apiKey?: string + apiSecret?: Uint8Array | null + requestText: string +}): Promise { + const { apiSigner, apiKey, apiSecret, requestText } = opts + if (apiSigner != null) { + try { + const signed = asEdgeApiSignature( + await timeout(apiSigner.signMessage(requestText), 30000) + ) + if (!isUsableSignerKey(signed.apiKey, signed.signature)) { + throw new Error('apiSigner returned an unusable apiKey or signature') + } + return `HMAC ${signed.apiKey} ${signed.signature}` + } catch (error: unknown) { + throw new ApiSignerError( + error instanceof Error ? error.message : String(error) + ) + } + } + if (apiSecret != null) { + const hash = hmacSha256(utf8.parse(requestText), apiSecret) + return `HMAC ${apiKey ?? ''} ${base64.stringify(hash)}` + } + return `Token ${apiKey ?? ''}` +} + export function parseReply(json: unknown): unknown { const clean = asLoginResponseBody(json) @@ -97,6 +142,7 @@ export async function loginFetch( ) break } catch (error) { + if (asMaybeApiSignerError(error) != null) throw error lastError = error } } @@ -109,14 +155,14 @@ export async function loginFetch( return parseReply(json) } -export function loginFetchInner( +export async function loginFetchInner( ai: ApiInput, serverUri: string, method: string, path: string, body?: LoginRequestBody ): Promise { - const { state, io, log } = ai.props + const { state, io, log, apiSigner } = ai.props const { apiKey, apiSecret, attestationToken } = state.login const bodyText = @@ -124,13 +170,14 @@ export function loginFetchInner( ? undefined : JSON.stringify(wasLoginRequestBody(body)) - // API key: - let authorization = `Token ${apiKey}` - if (apiSecret != null) { - const requestText = `${method}\n/api${path}\n${bodyText ?? ''}` - const hash = hmacSha256(utf8.parse(requestText), apiSecret) - authorization = `HMAC ${apiKey} ${base64.stringify(hash)}` - } + // Authorization: + const requestText = `${method}\n/api${path}\n${bodyText ?? ''}` + const authorization = await makeLoginAuthorization({ + apiSigner, + apiKey, + apiSecret, + requestText + }) const opts: EdgeFetchOptions = { body: bodyText, @@ -148,7 +195,7 @@ export function loginFetchInner( const start = Date.now() const fullUri = `${serverUri}/api${path}` - return timeout(io.fetch(fullUri, opts), 30000).then( + return await timeout(io.fetch(fullUri, opts), 30000).then( response => { // Log the results: const time = Date.now() - start diff --git a/src/core/plugins/plugins-selectors.ts b/src/core/plugins/plugins-selectors.ts index c5ed16b6d..63c17a8e3 100644 --- a/src/core/plugins/plugins-selectors.ts +++ b/src/core/plugins/plugins-selectors.ts @@ -60,29 +60,16 @@ export function getCurrencyTools( } /** - * Waits for the plugins to load, - * then validates that all plugins are present. + * Waits for the plugins to finish loading. + * + * A plugin that fails to load is simply absent from `currencyConfig` and + * `swapConfig`, so this does not treat that as an error. Failing the login + * would take down every account in the app over one unusable plugin. */ export async function waitForPlugins(ai: ApiInput): Promise { await ai.waitFor((props: RootProps): true | undefined => { - const { init, locked } = props.state.plugins + const { locked } = props.state.plugins if (!locked) return - - const { currency, swap } = props.state.plugins - const missingPlugins: string[] = [] - for (const pluginId of Object.keys(init)) { - const shouldLoad = init[pluginId] !== false && init[pluginId] != null - if (shouldLoad && currency[pluginId] == null && swap[pluginId] == null) { - missingPlugins.push(pluginId) - } - } - if (missingPlugins.length > 0) { - throw new Error( - 'The following plugins are missing or failed to load: ' + - missingPlugins.join(', ') - ) - } - return true }) } diff --git a/src/core/root-pixie.ts b/src/core/root-pixie.ts index 47b0a3aad..c20fec712 100644 --- a/src/core/root-pixie.ts +++ b/src/core/root-pixie.ts @@ -1,7 +1,7 @@ import { SyncClient } from 'edge-sync-client' import { combinePixies, PixieInput, ReduxProps, TamePixie } from 'redux-pixies' -import { EdgeIo, EdgeLog } from '../types/types' +import { EdgeApiSigner, EdgeIo, EdgeLog } from '../types/types' import { AccountOutput, accounts } from './account/account-pixie' import { Dispatch } from './actions' import { context, ContextOutput } from './context/context-pixie' @@ -20,6 +20,7 @@ export interface RootOutput { // Props passed to the root pixie: export interface RootProps extends ReduxProps { + readonly apiSigner?: EdgeApiSigner readonly close: () => void readonly io: EdgeIo readonly log: EdgeLog diff --git a/src/core/root.ts b/src/core/root.ts index c5410f879..a3ea7f6f1 100644 --- a/src/core/root.ts +++ b/src/core/root.ts @@ -36,6 +36,7 @@ export async function makeContext( const { airbitzSupport = false, apiSecret, + apiSigner, appId = '', appVersion, authServer, @@ -177,6 +178,7 @@ export async function makeContext( rootPixie, (props: ReduxProps): RootProps => ({ ...props, + apiSigner, close() { closePixie() closePlugins() diff --git a/src/io/react-native/react-native-types.ts b/src/io/react-native/react-native-types.ts index b71ffe2ee..43469454c 100644 --- a/src/io/react-native/react-native-types.ts +++ b/src/io/react-native/react-native-types.ts @@ -2,6 +2,7 @@ import * as React from 'react' import { LogBackend } from '../../core/log/log' import { + EdgeApiSigner, EdgeContext, EdgeContextOptions, EdgeFakeUser, @@ -14,7 +15,8 @@ export interface WorkerApi { nativeIo: EdgeNativeIo, logBackend: LogBackend, pluginUris: string[], - opts: EdgeContextOptions + opts: EdgeContextOptions, + apiSigner?: EdgeApiSigner ) => Promise makeFakeEdgeWorld: ( diff --git a/src/io/react-native/react-native-worker.ts b/src/io/react-native/react-native-worker.ts index 4eb777c89..2b83f559e 100644 --- a/src/io/react-native/react-native-worker.ts +++ b/src/io/react-native/react-native-worker.ts @@ -261,10 +261,13 @@ export function normalizePath(path: string): string { // Send the root object: const workerApi: WorkerApi = bridgifyObject({ - async makeEdgeContext(nativeIo, logBackend, pluginUris, opts) { + async makeEdgeContext(nativeIo, logBackend, pluginUris, opts, apiSigner) { loadPlugins(pluginUris) const io = await makeIo(logBackend) - return await makeContext({ io, nativeIo }, logBackend, opts) + return await makeContext({ io, nativeIo }, logBackend, { + ...opts, + apiSigner + }) }, async makeFakeEdgeWorld(nativeIo, logBackend, pluginUris, users = []) { diff --git a/src/react-native.tsx b/src/react-native.tsx index cc69aff33..b44ddd7d6 100644 --- a/src/react-native.tsx +++ b/src/react-native.tsx @@ -6,18 +6,17 @@ import { base64 } from 'rfc4648' import { bridgifyObject } from 'yaob' import { defaultOnLog, LogBackend } from './core/log/log' -import { parseReply } from './core/login/login-fetch' +import { makeLoginAuthorization, parseReply } from './core/login/login-fetch' import { EdgeCoreBridge } from './io/react-native/react-native-webview' import { EdgeContextProps, EdgeFakeWorldProps } from './types/exports' import { asMessagesPayload } from './types/server-cleaners' import { + EdgeApiSigner, EdgeFetchOptions, EdgeLoginMessage, EdgeNativeIo, NetworkError } from './types/types' -import { hmacSha256 } from './util/crypto/hashes' -import { utf8 } from './util/encoding' import { timeout } from './util/promise' export { makeFakeIo } from './core/fake/fake-io' @@ -51,6 +50,7 @@ export function MakeEdgeContext(props: EdgeContextProps): JSX.Element { airbitzSupport = false, apiKey, apiSecret, + apiSigner, appId = '', appVersion, authServer, @@ -98,7 +98,8 @@ export function MakeEdgeContext(props: EdgeContextProps): JSX.Element { plugins, skipBlockHeight, syncServer - } + }, + apiSigner == null ? undefined : bridgifyObject(apiSigner) ) await onLoad(context) }} @@ -164,7 +165,8 @@ const asUsernameStash = asObject({ */ export async function fetchLoginMessages( apiKey: string, - apiSecret?: Uint8Array + apiSecret?: Uint8Array, + apiSigner?: EdgeApiSigner ): Promise { const disklet = makeReactNativeDisklet() @@ -185,13 +187,14 @@ export async function fetchLoginMessages( const bodyText = JSON.stringify({ loginIds: Object.keys(loginMap) }) - // API key: - let authorization = `Token ${apiKey}` - if (apiSecret != null) { - const requestText = `POST\n/api/v2/messages\n${bodyText}` - const hash = hmacSha256(utf8.parse(requestText), apiSecret) - authorization = `HMAC ${apiKey} ${base64.stringify(hash)}` - } + // Authorization: + const requestText = `POST\n/api/v2/messages\n${bodyText}` + const authorization = await makeLoginAuthorization({ + apiSigner, + apiKey, + apiSecret, + requestText + }) const uri = 'https://login.edge.app/api/v2/messages' const opts: EdgeFetchOptions = { @@ -201,7 +204,7 @@ export async function fetchLoginMessages( accept: 'application/json', authorization }, - body: JSON.stringify({ loginIds: Object.keys(loginMap) }) + body: bodyText } return await timeout( diff --git a/src/types/error.ts b/src/types/error.ts index 738dd7a10..f36e737b0 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -85,6 +85,20 @@ export class InsufficientFundsError extends Error { } } +/** + * Signing failed before the HTTP request could be made. + * `loginFetch` must not treat this as a per-server network error, + * so it does not retry the remaining login servers. + */ +export class ApiSignerError extends Error { + name: string + + constructor(message: string = 'Cannot sign the API request') { + super(message) + this.name = 'ApiSignerError' + } +} + /** * Could not reach the server at all. */ @@ -411,6 +425,8 @@ export const asMaybeDustSpendError = asMaybeError('DustSpendError') export const asMaybeInsufficientFundsError = asMaybeError('InsufficientFundsError') +export const asMaybeApiSignerError = + asMaybeError('ApiSignerError') export const asMaybeNetworkError = asMaybeError('NetworkError') export const asMaybeNoAmountSpecifiedError = asMaybeError('NoAmountSpecifiedError') diff --git a/src/types/exports.ts b/src/types/exports.ts index d4213e865..a35b6619e 100644 --- a/src/types/exports.ts +++ b/src/types/exports.ts @@ -1,4 +1,5 @@ import type { + EdgeApiSigner, EdgeContext, EdgeContextOptions, EdgeCorePlugins, @@ -79,6 +80,7 @@ export interface EdgeContextProps extends CommonProps { airbitzSupport?: boolean apiKey?: string apiSecret?: Uint8Array + apiSigner?: EdgeApiSigner appId?: string appVersion?: string osType?: string @@ -133,4 +135,8 @@ export declare const MakeFakeEdgeWorld: ComponentType /** * React Native function for getting login alerts without a context: */ -export declare function fetchLoginMessages(apiKey: string): EdgeLoginMessage[] +export declare function fetchLoginMessages( + apiKey: string, + apiSecret?: Uint8Array, + apiSigner?: EdgeApiSigner +): Promise diff --git a/src/types/types.ts b/src/types/types.ts index 49c9556bd..0557724c3 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -146,6 +146,23 @@ export interface EdgeCrashReporter { readonly logCrash: (crash: EdgeCrashEvent) => void } +/** + * Result of signing a login-server request with an HMAC API key. + */ +export interface EdgeApiSignature { + /** The public API key identifier to place in the header. */ + apiKey: string + /** Base64 HMAC-SHA256 of the message. */ + signature: string +} + +/** + * Delegates API request signing so the HMAC secret can live outside JS. + */ +export interface EdgeApiSigner { + signMessage: (message: string) => Promise +} + /** * Receives log messages. * The app should implement this function and pass it to the context. @@ -1963,6 +1980,12 @@ export interface EdgeContextOptions { apiSecret?: Uint8Array appId: string + /** + * Delegates API request signing, so the HMAC secret can live outside JS. + * Takes precedence over `apiKey` / `apiSecret` when present. + */ + apiSigner?: EdgeApiSigner + /** The application version (e.g., "1.0.0") */ appVersion?: string @@ -2195,6 +2218,7 @@ export interface EdgeFakeContextOptions { airbitzSupport?: boolean apiKey?: string apiSecret?: Uint8Array + apiSigner?: EdgeApiSigner appId: string deviceDescription?: string hideKeys?: boolean diff --git a/test/core/login/api-signer.test.ts b/test/core/login/api-signer.test.ts new file mode 100644 index 000000000..59fe90137 --- /dev/null +++ b/test/core/login/api-signer.test.ts @@ -0,0 +1,127 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' +import { base64 } from 'rfc4648' +import { bridgifyObject } from 'yaob' + +import { makeLoginAuthorization } from '../../../src/core/login/login-fetch' +import { makeFakeEdgeWorld } from '../../../src/index' +import { asMaybeApiSignerError } from '../../../src/types/error' +import { EdgeApiSigner } from '../../../src/types/types' +import { hmacSha256 } from '../../../src/util/crypto/hashes' +import { utf8 } from '../../../src/util/encoding' +import { fakeUser } from '../../fake/fake-user' + +const quiet = { onLog() {} } + +describe('makeLoginAuthorization', function () { + const requestText = 'POST\n/api/v2/login\n{"userId":"1"}' + + it('uses apiSigner over apiSecret', async function () { + const header = await makeLoginAuthorization({ + apiKey: 'from-opts', + apiSecret: utf8.parse('secret-bytes'), + requestText, + apiSigner: { + async signMessage() { + return { apiKey: 'from-signer', signature: 'sig-from-signer' } + } + } + }) + expect(header).equals('HMAC from-signer sig-from-signer') + }) + + it('HMACs with apiSecret when apiSigner is absent', async function () { + const secret = utf8.parse('unit-test-secret') + const header = await makeLoginAuthorization({ + apiKey: 'token-key', + apiSecret: secret, + requestText + }) + const expected = base64.stringify( + hmacSha256(utf8.parse(requestText), secret) + ) + expect(header).equals(`HMAC token-key ${expected}`) + }) + + it('sends Token when neither signer nor secret is present', async function () { + const header = await makeLoginAuthorization({ + apiKey: 'token-key', + requestText + }) + expect(header).equals('Token token-key') + }) + + it('rejects a whitespace apiKey from the signer', async function () { + try { + await makeLoginAuthorization({ + requestText, + apiSigner: { + async signMessage() { + return { apiKey: 'not a key', signature: 'sig' } + } + } + }) + expect.fail('expected ApiSignerError') + } catch (error: unknown) { + // Assert on `name`, not the prototype chain: the production build runs + // babel-plugin-transform-fake-error-class, which rewrites the class into + // a factory so `instanceof` is always false there. Mocha runs via + // sucrase, which skips that plugin, so an instanceof assertion would + // pass here while the shipped guard silently failed. + expect(asMaybeApiSignerError(error)).not.equals(undefined) + } + }) +}) + +describe('apiSigner', function () { + it('prefers apiSigner over apiSecret for login-server requests', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const messages: string[] = [] + + // YAOB requires bridgifyObject for callbacks passed into makeEdgeContext: + const apiSigner: EdgeApiSigner = bridgifyObject({ + async signMessage(message: string) { + messages.push(message) + return { + apiKey: 'from-signer', + signature: 'sig-from-signer' + } + } + }) + + const context = await world.makeEdgeContext({ + apiKey: 'from-opts', + apiSecret: utf8.parse('secret-bytes'), + apiSigner, + appId: '' + }) + + await context.usernameAvailable('brand-new-user-xyz') + + expect(messages.length).to.be.greaterThan(0) + const message = messages[0] + expect(message.startsWith('POST\n/api/')).equals(true) + + // Prove apiSecret would have produced a different signature: + const secretHash = base64.stringify( + hmacSha256(utf8.parse(message), utf8.parse('secret-bytes')) + ) + expect(secretHash).to.not.equal('sig-from-signer') + + await context.close() + await world.close() + }) + + it('falls back to apiSecret when apiSigner is absent', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext({ + apiKey: 'token-key', + apiSecret: utf8.parse('unit-test-secret'), + appId: '' + }) + const available = await context.usernameAvailable('another-new-user') + expect(available).equals(true) + await context.close() + await world.close() + }) +}) diff --git a/test/core/plugins/plugins.test.ts b/test/core/plugins/plugins.test.ts index bcba37a74..7cccc518e 100644 --- a/test/core/plugins/plugins.test.ts +++ b/test/core/plugins/plugins.test.ts @@ -2,7 +2,6 @@ import { expect } from 'chai' import { describe, it } from 'mocha' import { makeFakeEdgeWorld } from '../../../src/index' -import { expectRejection } from '../../expect-rejection' import { fakeUser } from '../../fake/fake-user' const contextOptions = { apiKey: '', appId: '' } @@ -25,19 +24,21 @@ describe('plugins system', function () { expect(Object.keys(account.swapConfig)).deep.equals(['fakeswap']) }) - it('cannot log in with broken plugins', async function () { + it('logs in with broken plugins', async function () { const world = await makeFakeEdgeWorld([fakeUser], quiet) const context = await world.makeEdgeContext({ ...contextOptions, plugins: { 'broken-plugin': true, 'missing-plugin': true, + fakecoin: true, fakeswap: false } }) - await expectRejection( - context.loginWithPIN(fakeUser.username, fakeUser.pin), - 'Error: The following plugins are missing or failed to load: broken-plugin, missing-plugin' - ) + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + + // The working plugin is available, and the broken ones are simply absent: + expect(Object.keys(account.currencyConfig)).deep.equals(['fakecoin']) + expect(Object.keys(account.swapConfig)).deep.equals([]) }) })