Native HMAC apiSigner and signed infoRollup appKeys - #6183
paullinator wants to merge 7 commits into
Conversation
c8f9826 to
44a9d19
Compare
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>
44a9d19 to
568056f
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
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.
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 != nullYou 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 ?? {}), |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 568056f. Configure here.
There was a problem hiding this comment.
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:
-
Em dashes (U+2014) in committed code and comments. 68 added lines carry one, 28 of them outside
docs/:scripts/makeApiSigner.ts(6, including theauto-generated ... do not editbanner 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 inconfigKeysMerge.ts,configKeysSchema.ts,initializeProviders.ts,types/types.ts,network.ts,tracking.ts,makeNativeHeaders.ts, and 41 indocs/. A comma, colon, or parentheses reads the same. Ruleset: https://github.com/EdgeApp/edge-dev-agents/blob/main/.cursor/skills/no-slop/SKILL.md -
Commit hygiene.
5707c51c0has 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) { |
There was a problem hiding this comment.
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.)
| 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 === '') { |
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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=${ |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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.
| * 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 { |
There was a problem hiding this comment.
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.



CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
Dependencies
Requirements
If you have made any visual changes to the GUI. Make sure you have:
Description
Splits runtime
env.jsoninto non-secretconfig.jsonand secretkeys.json, with four plugin maps (corePlugins,swapPlugins,guiApiKeys,rampPlugins) plusglobalKeys. WalletConnect isglobalKeys.WALLETCONNECT_PROJECT_ID. Plugin maps are opaque objects (no field-by-field*_INIT/*_API_KEYflattening).Fetches remote secrets from signed
GET /v1/infoRollup/:appIdas siblingappKeys(partner idconfig.appId ?? 'edge'), with DeviceSettingsgetKeysCacheand baked-inkeys.jsonas fallbacks.pluginApiKeys.posthogis 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 viaapiSigner, with JSKEYS.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/ENVsetup withconfig.json(non-secret),keys.json(secrets), and build-onlyedgeKey.json, exposed at runtime asCONFIG,KEYS/globalKeys, and resolvedpluginMaps. Plugin configuration moves to four ID-keyed maps (corePlugins,swapPlugins,guiApiKeys,rampPlugins) instead of flat*_INITfields; deploy and local tooling now patchconfigJson/keysJsonper branch (legacyenvJsonis ignored here).Adds native Edge API HMAC signing on iOS and Android: build scripts XOR-shard the secret from
edgeKey.jsoninto generated C sources, exposeEdgeApiSignerto React Native, and wireapiSignerinto edge-core for login-server auth while keeping secrets out of the Metro bundle (JSKEYS.EDGE_API_*remains a fallback when the native module is absent).Adds signed remote plugin secrets via
GET /v1/infoRollup/:appIdappKeys, merged over baked-inkeys.jsonwith aDeviceSettingskeysCachetier and cold-start timeouts;EdgeCoreManagerwaits 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.