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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -61,6 +67,8 @@ To create an `EdgeContext` object, you need to mount a component:
<MakeEdgeContext
// Get this from our support team:
apiKey="..."
// Optional native HMAC delegate (takes precedence over apiKey/apiSecret):
// apiSigner={nativeApiSigner}
appId="com.your-app"

// Configure currencies and swap providers you want to use:
Expand Down
78 changes: 78 additions & 0 deletions docs/api-signer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# API request signing (`apiSigner`)

`makeEdgeContext` / `MakeEdgeContext` can delegate HMAC for **login-server**
requests so the HMAC secret never enters the JS bundle.

This is a login-server contract only (existing HMAC in
`edge-login-server/src/middleware/with-api-key.ts`). The GUI’s `GET /v1/getKeys`
call is signed in the app, not by this library. See
[edge-react-gui `docs/HMAC_SIGNING.md`](https://github.com/EdgeApp/edge-react-gui/blob/develop/docs/HMAC_SIGNING.md).

## `EdgeContextOptions.apiSigner`

```ts
interface EdgeApiSignature {
apiKey: string // public id for the Authorization header
signature: string // base64 HMAC-SHA256 of the message
}

interface EdgeApiSigner {
signMessage: (message: string) => Promise<EdgeApiSignature>
}

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/`.
69 changes: 58 additions & 11 deletions src/core/login/login-fetch.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,6 +10,8 @@ import {
import { LoginRequestBody } from '../../types/server-types'
import {
ChallengeError,
EdgeApiSignature,
EdgeApiSigner,
EdgeFetchOptions,
EdgeFetchResponse,
NetworkError,
Expand All @@ -22,6 +25,48 @@ import { utf8 } from '../../util/encoding'
import { timeout } from '../../util/promise'
import { ApiInput } from '../root-pixie'

const asEdgeApiSignature: Cleaner<EdgeApiSignature> = 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<string> {
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)

Expand Down Expand Up @@ -97,6 +142,7 @@ export async function loginFetch(
)
break
} catch (error) {
if (asMaybeApiSignerError(error) != null) throw error
lastError = error
}
}
Expand All @@ -109,28 +155,29 @@ 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<EdgeFetchResponse> {
const { state, io, log } = ai.props
const { state, io, log, apiSigner } = ai.props
const { apiKey, apiSecret, attestationToken } = state.login

const bodyText =
method === 'GET' || body == null
? 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,
Expand All @@ -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
Expand Down
25 changes: 6 additions & 19 deletions src/core/plugins/plugins-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
})
}
3 changes: 2 additions & 1 deletion src/core/root-pixie.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -20,6 +20,7 @@ export interface RootOutput {

// Props passed to the root pixie:
export interface RootProps extends ReduxProps<RootState, Dispatch> {
readonly apiSigner?: EdgeApiSigner
readonly close: () => void
readonly io: EdgeIo
readonly log: EdgeLog
Expand Down
2 changes: 2 additions & 0 deletions src/core/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export async function makeContext(
const {
airbitzSupport = false,
apiSecret,
apiSigner,
appId = '',
appVersion,
authServer,
Expand Down Expand Up @@ -177,6 +178,7 @@ export async function makeContext(
rootPixie,
(props: ReduxProps<RootState, Dispatch>): RootProps => ({
...props,
apiSigner,
close() {
closePixie()
closePlugins()
Expand Down
4 changes: 3 additions & 1 deletion src/io/react-native/react-native-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react'

import { LogBackend } from '../../core/log/log'
import {
EdgeApiSigner,
EdgeContext,
EdgeContextOptions,
EdgeFakeUser,
Expand All @@ -14,7 +15,8 @@ export interface WorkerApi {
nativeIo: EdgeNativeIo,
logBackend: LogBackend,
pluginUris: string[],
opts: EdgeContextOptions
opts: EdgeContextOptions,
apiSigner?: EdgeApiSigner
) => Promise<EdgeContext>

makeFakeEdgeWorld: (
Expand Down
7 changes: 5 additions & 2 deletions src/io/react-native/react-native-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []) {
Expand Down
Loading
Loading