Skip to content

Native HMAC apiSigner and signed infoRollup appKeys - #6183

Open
paullinator wants to merge 7 commits into
developfrom
paul/nativeHmacApiSigner
Open

paullinator wants to merge 7 commits into
developfrom
paul/nativeHmacApiSigner

Conversation

@paullinator

@paullinator paullinator commented Aug 29, 2026

Copy link
Copy Markdown
Member

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

Requirements

If you have made any visual changes to the GUI. Make sure you have:

  • Tested on iOS device
  • Tested on Android device
  • Tested on small-screen device (iPod Touch)
  • Tested on large-screen device (tablet)

Description

Splits runtime env.json into non-secret config.json and secret keys.json, with four plugin maps (corePlugins, swapPlugins, guiApiKeys, rampPlugins) plus globalKeys. WalletConnect is globalKeys.WALLETCONNECT_PROJECT_ID. Plugin maps are opaque objects (no field-by-field *_INIT / *_API_KEY flattening).

Fetches remote secrets from signed GET /v1/infoRollup/:appId as sibling appKeys (partner id config.appId ?? 'edge'), with DeviceSettings getKeysCache and baked-in keys.json as fallbacks. pluginApiKeys.posthog is never served.

Adds a native Edge API HMAC signer (edgeKey.json + XOR-split C shards) so login-server requests can be signed outside the JS bundle via apiSigner, with JS KEYS.EDGE_API_* remaining as a fallback.

Rebased onto current develop (keeps Swapter and marketing-push tracking).


Note

High Risk
Changes authentication (native HMAC + infoRollup signing), boot-time secret resolution, and how every plugin receives credentials—misconfiguration or timing bugs could break login, partner APIs, or silently pin stale keys.

Overview
Replaces the monolithic env.json / ENV setup with config.json (non-secret), keys.json (secrets), and build-only edgeKey.json, exposed at runtime as CONFIG, KEYS / globalKeys, and resolved pluginMaps. Plugin configuration moves to four ID-keyed maps (corePlugins, swapPlugins, guiApiKeys, rampPlugins) instead of flat *_INIT fields; deploy and local tooling now patch configJson / keysJson per branch (legacy envJson is ignored here).

Adds native Edge API HMAC signing on iOS and Android: build scripts XOR-shard the secret from edgeKey.json into generated C sources, expose EdgeApiSigner to React Native, and wire apiSigner into edge-core for login-server auth while keeping secrets out of the Metro bundle (JS KEYS.EDGE_API_* remains a fallback when the native module is absent).

Adds signed remote plugin secrets via GET /v1/infoRollup/:appId appKeys, merged over baked-in keys.json with a DeviceSettings keysCache tier and cold-start timeouts; EdgeCoreManager waits on key resolution before building plugins, and consumers that need rotatable secrets must read them lazily after overlay apply.

Ships migration scripts (split-env-json, split-baked-and-server-keys), expanded docs (CONFIG_KEYS_ARCHITECTURE.md, HMAC_SIGNING.md), and tests for merge semantics, HMAC vectors, keysServer, and serialized settings writes.

Reviewed by Cursor Bugbot for commit 568056f. Bugbot is set up for automated code reviews on this repo. Configure here.

@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch 4 times, most recently from c8f9826 to 44a9d19 Compare August 31, 2026 23:52
@paullinator
paullinator marked this pull request as ready for review September 1, 2026 16:24
paullinator and others added 7 commits September 4, 2026 16:45
Apply strict-boolean, nullish, and return-type fixes in files leaving
the relaxed-rules list.
Single-flight the initial load and serialize every write through a
promise chain so overlapping patches cannot clobber each other or
blank on-disk fields. Adds keysCache fields for remote key fetch.
Replace the flat env.json/ENV singleton with config.json + keys.json and
runtime CONFIG, KEYS, globalKeys, and pluginMaps accessors. Partner
secrets live nested under globalKeys.
Boot from baked-in KEYS, then overlay a signed infoRollup appKeys payload and device
cache. Mutate KEYS and globalKeys in place and rebuild pluginMaps.
Print only LAYER-* overlay markers from the local info_keys seed, plus whether the native signer loaded, so device e2e can confirm remote key fetch without dumping secrets.
Plugins whose API keys are absent or malformed do not register with the
core, which leaves them out of `currencyConfig` and `swapConfig`. Diff the
plugin list we handed to `makeEdgeContext` against what the account came
back with, and show the missing plugin IDs in an error drop-down so a
misconfigured key surfaces instead of silently removing assets and
exchanges from the app.

Plugin loading happens once per core context, so this reports once per
session rather than on every login.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@paullinator
paullinator force-pushed the paul/nativeHmacApiSigner branch from 44a9d19 to 568056f Compare September 5, 2026 00:20

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Token-only API key ignored
    • buildContextOptions now forwards a usable EDGE_API_KEY to MakeEdgeContext even when EDGE_API_SECRET is missing, so core can use legacy Token auth.

Create PR

Or push these changes by commenting:

@cursor push 61a8982da7
Preview (61a8982da7)
diff --git a/src/components/services/EdgeCoreManager.tsx b/src/components/services/EdgeCoreManager.tsx
--- a/src/components/services/EdgeCoreManager.tsx
+++ b/src/components/services/EdgeCoreManager.tsx
@@ -123,10 +123,12 @@
   const { EDGE_API_KEY: apiKey, EDGE_API_SECRET: apiSecret } = KEYS
   const nativeKey = hasNativeApiSigner() ? await warmNativeApiKey() : ''
   const nativeApiSigner = nativeKey !== '' ? makeNativeApiSigner() : undefined
-  const jsPair =
-    isUsableApiKey(apiKey) && apiSecret != null && apiSecret.byteLength > 0
+  // Token-only keys are valid: core uses `Authorization: Token {apiKey}`.
+  const jsPair = isUsableApiKey(apiKey)
+    ? apiSecret != null && apiSecret.byteLength > 0
       ? { apiKey, apiSecret }
-      : undefined
+      : { apiKey }
+    : undefined
   console.log(
     `[apiSigner] native=${nativeApiSigner != null} keysFallback=${
       jsPair != null

You can send follow-ups to the cloud agent here.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 568056f. Configure here.

return {
...(nativeApiSigner != null
? { apiSigner: nativeApiSigner }
: jsPair ?? {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Token-only API key ignored

Medium Severity

buildContextOptions only forwards JS credentials when both KEYS.EDGE_API_KEY and KEYS.EDGE_API_SECRET are present. An apiKey without a secret is dropped, so core never receives the legacy Token {apiKey} pair that HMAC_SIGNING.md still describes. The README also tells developers to set only EDGE_API_KEY in keys.json, which the native stub path then ignores.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 568056f. Configure here.

@j0ntz j0ntz left a comment

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.

Review of this PR together with its two dependencies, edge-core-js#739 and edge-info-server#163. The HMAC design itself checks out: I compiled native/edge-api-signer/edge_hmac.c standalone and it matches SHA-256("abc"), SHA-256(""), SHA-256(1e6 x 'a'), and RFC 4231 HMAC cases 1, 2, 3, 6 and 7, including the >block-size key path, clean under -Wall -Wextra. The signed string also lines up end to end: keysServer.signPath builds /v1/infoRollup/... and the server verifies req.originalUrl under the /v1 mount (src/indexInfo.ts:44), with a shared test vector on both sides.

Two repo-wide items with no single line to hang them on:

  1. Em dashes (U+2014) in committed code and comments. 68 added lines carry one, 28 of them outside docs/: scripts/makeApiSigner.ts (6, including the auto-generated ... do not edit banner that ends up in the generated native C), scripts/splitEnvJson.ts (4), src/util/keysStore.ts (4), src/keys.ts (2), src/util/edgeApiSigner.ts (2), scripts/splitBakedAndServerKeys.js (2), plus one each in configKeysMerge.ts, configKeysSchema.ts, initializeProviders.ts, types/types.ts, network.ts, tracking.ts, makeNativeHeaders.ts, and 41 in docs/. A comma, colon, or parentheses reads the same. Ruleset: https://github.com/EdgeApp/edge-dev-agents/blob/main/.cursor/skills/no-slop/SKILL.md

  2. Commit hygiene. 5707c51c0 has a 68-character subject ("Log appKeys LAYER sentinels and native HMAC signer status on launch."); the cap is 50. Something like "Log keys tier and signer status on launch" fits.

const initOptions = pluginMaps.rampPlugins[pluginId]

// If there is no init option defined for the plugin, simply skip over it
if (initOptions == null) {

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.

This guard still only rejects null, but the new merge can hand back a bare true.

mergePluginInit(true, undefined) returns true (src/configKeysMerge.ts:88), which is the "enabled in config.json, no credentials on the keys side yet" state. That is the shipped state on any build where slimKeysJson stripped the plugin maps and the signed appKeys fetch has not landed. true is not null, so it flows into factory(config) as initOptions, and asInitOptions(true) throws inside the plugin's asObject cleaner (e.g. banxaRampPlugin.ts:596, paybisRampPlugin.ts:470). The per-plugin catch only console.warns, so the ramp option disappears from buy/sell for the whole session, and the effect deps are [account, navigation, dispatch] so it never retries.

initializeProviders.ts:47 in this same PR added exactly this guard for guiApiKeys, with a comment explaining the case. The same test belongs here:

if (initOptions == null || typeof initOptions === 'boolean') {
  continue
}

(The boolean case only bites GUI-side loaders. Core normalizes true to {} at plugins-actions.ts:82, so corePlugins.ts is fine as written.)

Comment thread src/util/edgeApiSigner.ts
async signMessage(message: string) {
const signed = asSignedMessage(await module.signMessage(message))
// Stub builds embed a placeholder that is not a valid Authorization value.
if (!isUsableApiKey(signed.apiKey) || signed.signature === '') {

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.

isUsableApiKey rejects whitespace in apiKey, but signature is only checked for emptiness, and it goes straight into Authorization: HMAC ${apiKey} ${signature} in keysServer.ts. A native binding that returned a signature with a stray space, tab, or CR/LF would produce a header the server cannot split into three parts, and the failure would look like a signing mismatch rather than a malformed header.

The JS path cannot produce this (it is base64.stringify(...)), so the native delegate is the first place an unvalidated string reaches the header. Cheap to close:

if (!isUsableApiKey(signed.apiKey) || !isUsableApiKey(signed.signature)) {

edge-core-js#739 has the same asymmetry at login-fetch.ts:34.

String(error)
)
try {
setContextOptions(await buildContextOptions())

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.

This fallback re-runs the identical call with identical inputs.

The comment says initializeKeys itself never rejects, which leaves buildContextOptions() as the only thing in the try that can throw. Calling it a second time with nothing changed in between will throw again for the same reason, so the catch adds a duplicate [apiSigner] log line and a second warmNativeApiKey round trip and then lands in the inner catch anyway.

If the goal is "boot with baked-in plugins when the keys store fails", the retry needs to differ from the first attempt (skip the native signer, for instance). Otherwise the outer try/catch can collapse to the inner one.

if (bootFatalError != null) {
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
<Text>Edge failed to start: {bootFatalError}</Text>

@j0ntz j0ntz Sep 16, 2026

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.

The string is hardcoded English rather than lstrings.*, and the style is inline instead of cacheStyles/getStyles.

The modal this replaced at least pulled lstrings.string_ok_cap. lstrings is a plain import with no dependency on Providers or Airship, so it is available here. A boot_failed_message_1s key plus a getStyles entry keeps this consistent with the rest of the app.

? { apiKey, apiSecret }
: undefined
console.log(
`[apiSigner] native=${nativeApiSigner != null} keysFallback=${

@j0ntz j0ntz Sep 16, 2026

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.

Unguarded console.log in production, same as [keys] tier=... at keysStore.ts:428. Both are useful when checking a device run, so rather than dropping them, route them through the category logger the way Phaze does:

debugLog('keys', `tier=${keysTier} assurance=${assuranceLevel ?? 'none'} ...`)
debugLog('keys', `apiSigner native=${nativeApiSigner != null} keysFallback=${jsPair != null}`)

debugLog (src/util/logger.ts) is silent unless keys is in LOG_CONFIG.enabledCategories in config.json, and enableLogCategory('keys') turns it on at runtime. The console.error below reports a real misconfiguration and should stay as is.

}

async function legacyGet(path: string) {
async function legacyGet(path: string): Promise<any> {

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.

New explicit any return type. fetchPush returns an EdgeFetchResponse, so this is Promise<unknown> at worst, and the one caller (fetchLegacySettings) already declares the concrete shape { '1': boolean; '24': boolean; fallbackSettings?: boolean }. Returning Promise<unknown> and cleaning at the call site, or hoisting that shape into a cleaner, keeps the type checker on.

Comment thread src/configKeysMerge.ts
* Truncate a single secret to its first 8 characters so it can be shown for
* debugging without leaking the full value. Non-strings are returned as-is.
*/
export function redactKey(value: unknown): 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.

redactKey has no callers outside src/__tests__/configKeysMerge.test.ts, and its body is the same scalar branch redactValue already implements two functions down. Worth deleting along with its test case, so a future change to the truncation length has one place to land.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants