Native HMAC apiSigner and signed infoRollup appKeys - #6183
paullinator wants to merge 31 commits into
Conversation
c8f9826 to
44a9d19
Compare
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.
Autofix Details
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.
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.)
There was a problem hiding this comment.
Addressed in f152e78: the guard skips a boolean entry as well as null. A related case (ramp inits stored as {}) is in this review.
| 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.
There was a problem hiding this comment.
Addressed in 2fe55f9: the signature now goes through isUsableApiKey too.
| 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.
There was a problem hiding this comment.
Addressed: at fac86e7 the boot path is a single try/catch around initializeKeys and buildContextOptions, with no retry.
| 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.
There was a problem hiding this comment.
Addressed in 16544a6: both lines now go through debugLog('keys', ...).
| } | ||
|
|
||
| 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.
There was a problem hiding this comment.
Addressed in b5ce923: legacyGet returns unknown, and the callers clean the responses with asV1Settings and asLegacySettings.
| * 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.
There was a problem hiding this comment.
Addressed in 67ee654: redactKey and its test are removed.
568056f to
0535766
Compare
There was a problem hiding this comment.
Claude Code Review
Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.
Tip: disable this comment in your organization's Code Review settings.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Signed rollup omits rollupRaw
- applyPublicRollup now stores the pre-cleaner payload on infoServerData.rollupRaw so signed fetches keep unknown public fields such as giftCardInfo.
Or push these changes by commenting:
@cursor push 987a73754b
Preview (987a73754b)
diff --git a/src/util/keysStore.ts b/src/util/keysStore.ts
--- a/src/util/keysStore.ts
+++ b/src/util/keysStore.ts
@@ -177,6 +177,7 @@
console.warn('initializeKeys: signed infoRollup failed to clean')
return
}
+ infoServerData.rollupRaw = raw
infoServerData.rollup = cleaned
// `queryInfo` runs this on the unsigned path. Without it here, builds that
// take the signed path would defer the force-upgrade check by up to oneYou can send follow-ups to the cloud agent here.
0535766 to
b1b87a0
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: False overlay disables plugin secrets
- Per-plugin false values are now stripped from remote/cache overlays before deepMerge so they cannot replace baked-in secrets or disable providers.
Or push these changes by commenting:
@cursor push 851aab64df
Preview (851aab64df)
diff --git a/src/__tests__/configKeysMerge.test.ts b/src/__tests__/configKeysMerge.test.ts
--- a/src/__tests__/configKeysMerge.test.ts
+++ b/src/__tests__/configKeysMerge.test.ts
@@ -6,6 +6,7 @@
asMergeableKeys,
deepMerge,
nestGlobalKeys,
+ omitFalsePluginEntries,
redactValue,
resolvePluginMaps
} from '../configKeysMerge'
@@ -132,6 +133,32 @@
})
})
+describe('omitFalsePluginEntries', () => {
+ it('drops per-plugin false so deepMerge cannot wipe baked-in secrets', () => {
+ expect(
+ omitFalsePluginEntries({
+ guiApiKeys: { moonpay: false, banxa: { apiKey: 'k' } },
+ swapPlugins: { thorchain: false },
+ corePlugins: { bitcoin: { nowNodesApiKey: 'abc' } },
+ rampPlugins: { infinite: false },
+ globalKeys: { COINGECKO_API_KEY: 'cg' }
+ })
+ ).toEqual({
+ guiApiKeys: { banxa: { apiKey: 'k' } },
+ swapPlugins: {},
+ corePlugins: { bitcoin: { nowNodesApiKey: 'abc' } },
+ rampPlugins: {},
+ globalKeys: { COINGECKO_API_KEY: 'cg' }
+ })
+ })
+
+ it('leaves maps without false entries untouched', () => {
+ const keys = { guiApiKeys: { moonpay: 'm' } }
+ expect(omitFalsePluginEntries(keys)).toEqual(keys)
+ expect(omitFalsePluginEntries(keys).guiApiKeys).toBe(keys.guiApiKeys)
+ })
+})
+
describe('resolvePluginMaps', () => {
it('uses the keys object when config enables a plugin with true', () => {
const config = { corePlugins: { bitcoin: true } }
diff --git a/src/__tests__/util/keysStore.test.ts b/src/__tests__/util/keysStore.test.ts
--- a/src/__tests__/util/keysStore.test.ts
+++ b/src/__tests__/util/keysStore.test.ts
@@ -310,6 +310,36 @@
)
})
+ it('does not let a false plugin overlay wipe baked-in secrets', async () => {
+ mockFetchRemoteKeys.mockResolvedValue({
+ keys: {
+ guiApiKeys: { moonpay: false },
+ globalKeys: { AZTECO_API_KEY: 'from-remote' }
+ },
+ assuranceLevel: 'unattested'
+ })
+
+ const { keysStore, keys, pluginMaps } = freshModules()
+ await keysStore.initializeKeys()
+
+ expect(keysStore.getKeysTier()).toBe('remote')
+ expect(keys.globalKeys.AZTECO_API_KEY).toBe('from-remote')
+ expect((keys.KEYS.guiApiKeys as { moonpay?: unknown }).moonpay).toBe(
+ 'baked-moonpay'
+ )
+ expect(
+ (pluginMaps.pluginMaps.guiApiKeys as { moonpay?: unknown }).moonpay
+ ).toBe('baked-moonpay')
+ expect(mockWriteKeysCache).toHaveBeenCalledWith(
+ expect.objectContaining({
+ keys: {
+ globalKeys: { AZTECO_API_KEY: 'from-remote' },
+ guiApiKeys: {}
+ }
+ })
+ )
+ })
+
it('keeps unrelated baked-in secrets when the remote payload is partial', async () => {
mockFetchRemoteKeys.mockResolvedValue({
keys: { globalKeys: { AZTECO_API_KEY: 'from-remote' } },
diff --git a/src/configKeysMerge.ts b/src/configKeysMerge.ts
--- a/src/configKeysMerge.ts
+++ b/src/configKeysMerge.ts
@@ -82,6 +82,35 @@
}
/**
+ * Drop per-plugin `false` from a keys overlay. Keys carry secrets, never kill
+ * switches: `mergePluginInit` already ignores `false`, but `applyKeys`
+ * deep-merges the overlay into KEYS first, and `deepMerge` replaces on type
+ * mismatch. Leaving `false` in would wipe a baked-in object or string and
+ * leave providers with the `true` sentinel they skip.
+ */
+export function omitFalsePluginEntries(
+ keys: Record<string, unknown>
+): Record<string, unknown> {
+ const out: Record<string, unknown> = { ...keys }
+ for (const field of KEYS_PAYLOAD_MAP_FIELDS) {
+ if (field === 'globalKeys') continue
+ const value = out[field]
+ if (!isPlainObject(value)) continue
+ const filtered: Record<string, unknown> = {}
+ let dropped = false
+ for (const [id, entry] of Object.entries(value)) {
+ if (entry === false) {
+ dropped = true
+ continue
+ }
+ filtered[id] = entry
+ }
+ if (dropped) out[field] = filtered
+ }
+ return out
+}
+
+/**
* Combine the config-side enablement flag with the keys-side value for one
* plugin ID across corePlugins, swapPlugins, guiApiKeys, and rampPlugins.
*/
diff --git a/src/util/keysStore.ts b/src/util/keysStore.ts
--- a/src/util/keysStore.ts
+++ b/src/util/keysStore.ts
@@ -12,7 +12,8 @@
asMergeableKeys,
deepMerge,
isPlainObject,
- nestGlobalKeys
+ nestGlobalKeys,
+ omitFalsePluginEntries
} from '../configKeysMerge'
import { asKeysJson, type RuntimeKeys } from '../configKeysSchema'
import { applyRuntimeKeys, bakedKeys, globalKeys, KEYS } from '../keys'
@@ -154,7 +155,7 @@
}
const nestedOverlay = nestGlobalKeys(
- stripLocalOnlyFields(keepKeysFields(mergeable))
+ omitFalsePluginEntries(stripLocalOnlyFields(keepKeysFields(mergeable)))
)
const mergedKeys = deepMerge(bakedKeys, nestedOverlay) as RuntimeKeys
applyRuntimeKeys(
@@ -265,7 +266,9 @@
let overlay: Record<string, unknown>
try {
overlay = nestGlobalKeys(
- stripLocalOnlyFields(keepKeysFields(asMergeableKeys(result.keys)))
+ omitFalsePluginEntries(
+ stripLocalOnlyFields(keepKeysFields(asMergeableKeys(result.keys)))
+ )
)
} catch (error: unknown) {
console.warn(You can send follow-ups to the cloud agent here.
b1b87a0 to
cbc1984
Compare
cbc1984 to
0660bd7
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Deploy overrides ignore false flags
- Deploy config.json branch overrides now use a nested merge that treats false as a real disable, so flags like guiApiKeys.phaze: false win over a true base value.
Or push these changes by commenting:
@cursor push eed84c9d29
Preview (eed84c9d29)
diff --git a/scripts/deploy.ts b/scripts/deploy.ts
--- a/scripts/deploy.ts
+++ b/scripts/deploy.ts
@@ -3,7 +3,7 @@
import { join } from 'path'
import { sprintf } from 'sprintf-js'
-import { deepMerge } from '../src/configKeysMerge'
+import { deepMerge, deepMergeOverrides } from '../src/configKeysMerge'
import { deleteOldDirsSync } from './cleanDirectories'
const BUILD_ARCHIVE_MONTHS = 6
@@ -174,17 +174,21 @@
/**
* Deep-merge a branch's override object into the file contents, or return the
* file unchanged when this branch has nothing to say.
+ *
+ * Config uses `deepMergeOverrides` so `false` can disable a plugin or flag.
+ * Keys keep `deepMerge` so a `false` / `{}` overlay cannot wipe credentials.
*/
function applyBranchOverrides(
file: Record<string, unknown> | undefined,
overridesByBranch: Record<string, object> | undefined,
branch: string,
- fileLabel: string
+ fileLabel: string,
+ merge: (a: unknown, b: unknown) => unknown
): Record<string, unknown> | undefined {
const overrides = overridesByBranch?.[branch]
if (overrides == null) return file
if (file == null) throw new Error(`${fileLabel} file is missing`)
- return deepMerge(file, overrides) as Record<string, unknown>
+ return merge(file, overrides) as Record<string, unknown>
}
function makeCommonPost(buildObj: BuildObj): void {
@@ -228,13 +232,15 @@
configJson,
buildObj.configJson,
buildObj.repoBranch,
- 'config.json'
+ 'config.json',
+ deepMergeOverrides
)
keysJson = applyBranchOverrides(
keysJson,
buildObj.keysJson,
buildObj.repoBranch,
- 'keys.json'
+ 'keys.json',
+ deepMerge
)
if (buildObj.maestroBuild) {
if (configJson == null) throw new Error('config.json file is missing')
diff --git a/src/__tests__/configKeysMerge.test.ts b/src/__tests__/configKeysMerge.test.ts
--- a/src/__tests__/configKeysMerge.test.ts
+++ b/src/__tests__/configKeysMerge.test.ts
@@ -5,6 +5,7 @@
import {
asMergeableKeys,
deepMerge,
+ deepMergeOverrides,
mergePluginInit,
nestGlobalKeys,
redactValue,
@@ -73,6 +74,32 @@
})
})
+describe('deepMergeOverrides', () => {
+ // Deploy configJson branch blocks use false to disable a plugin or flag.
+ // That must win even when the copied config.json still has true.
+ it('lets a false override disable an enabled flag', () => {
+ expect(deepMergeOverrides(true, false)).toBe(false)
+ expect(
+ deepMergeOverrides(
+ { guiApiKeys: { banxa: true, phaze: true } },
+ { guiApiKeys: { phaze: false } }
+ )
+ ).toEqual({ guiApiKeys: { banxa: true, phaze: false } })
+ })
+
+ it('still merges nested objects field-by-field', () => {
+ expect(
+ deepMergeOverrides(
+ { swapPlugins: { changelly: true }, BETA_FEATURES: false },
+ { swapPlugins: { thorchain: true }, BETA_FEATURES: true }
+ )
+ ).toEqual({
+ swapPlugins: { changelly: true, thorchain: true },
+ BETA_FEATURES: true
+ })
+ })
+})
+
describe('a keys overlay never destroys a baked value', () => {
// A remote infoRollup overlay or a stale disk cache can carry `false` or
// `{}` for a plugin. Neither may wipe the credential that shipped in the
diff --git a/src/configKeysMerge.ts b/src/configKeysMerge.ts
--- a/src/configKeysMerge.ts
+++ b/src/configKeysMerge.ts
@@ -98,6 +98,28 @@
}
/**
+ * Recursively merge two values for deploy-config branch overrides. `b` wins
+ * on conflict, including `false`: that is how a branch disables a plugin or
+ * flag. Unlike `deepMerge`, `false` is a real override, not "no opinion".
+ *
+ * Plain objects are merged field-by-field; arrays and other primitives are
+ * replaced wholesale.
+ */
+export function deepMergeOverrides(a: unknown, b: unknown): unknown {
+ if (b === undefined) return a
+ if (a === undefined) return b
+ if (isPlainObject(a) && isPlainObject(b)) {
+ const out: Record<string, unknown> = { ...a }
+ for (const key of Object.keys(b)) {
+ if (FORBIDDEN_MERGE_KEYS.has(key)) continue
+ out[key] = deepMergeOverrides(a[key], b[key])
+ }
+ return out
+ }
+ return b
+}
+
+/**
* Combine the config-side enablement flag with the keys-side value for one
* plugin ID across corePlugins, swapPlugins, guiApiKeys, and rampPlugins.
*/You can send follow-ups to the cloud agent here.
|
Bugbot Autofix prepared a fix for the issue found in the latest run.
Or push these changes by commenting: Preview (66daa879d3)diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -8,10 +8,11 @@
/android/app/google-services.json
/android/google-java-format-*.jar
/deploy-config.json
+/edgeKey.json
/env.json
/config.json
/keys.json
-keys.*.json
+/keys.*.json
/fastlane.json
/ios/edge/GoogleService-Info.plist
/ios/Pods/
@@ -20,6 +21,7 @@
android-release.bundle.map
ios-release.bundle.map
keystores/
+/.edgeApiSigner.stamp
# Debugging
overrideTheme.json
@@ -38,6 +40,13 @@
# Generated headers
/android/app/src/main/java/co/edgesecure/app/EdgeApiKey.java
/ios/EdgeApiKey.swift
+/ios/EdgeApiSecret.c
+/ios/EdgeApiSecret.h
+/android/app/src/main/cpp/edge_api_secret.c
+/android/app/src/main/cpp/edge_api_secret.h
+/vendor/*.tgz
+/vendor/edge-core-js-*.tgz
+/*.tgz
# Checkpoint jsons
/android/app/src/main/assets/saplingtree/
diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,8 +14,9 @@
## 4.51.0 (staging)
- added: Robinhood Chain wallets
+- added: 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.
- 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: Remote `GET /v1/getKeys` fetch so plugin secrets can rotate without an app release, with DeviceSettings cache and baked-in `keys.json` fallback
+- added: Remote signed `GET /v1/infoRollup/:appId` `appKeys` fetch so plugin secrets can rotate without an app release, with DeviceSettings cache and baked-in `keys.json` fallback
- added: App/device attestation for gated info-server requests
- added: Swapter swap provider
- added: "-m" tag on the version number in the Help scene for Maestro test builds
diff --git a/android/app/build.gradle b/android/app/build.gradle
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -103,8 +103,21 @@
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a' // Exclude Intel
}
+ externalNativeBuild {
+ cmake {
+ cppFlags ""
+ arguments "-DANDROID_STL=c++_shared"
+ }
+ }
}
+ externalNativeBuild {
+ cmake {
+ path file("src/main/cpp/CMakeLists.txt")
+ version "3.22.1"
+ }
+ }
+
// Edge addition: sideloadable per-ABI APKs for distribution outside
// Google Play (Play already serves per-ABI installs from the AAB).
// Each one is roughly 31 MB smaller than the universal APK (about
@@ -395,3 +408,55 @@
telemetry = true
}
}
+
+// Gradle daemons started from Android Studio inherit a minimal PATH that
+// usually lacks nvm / Homebrew node, so reuse the NODE_BINARY that the React
+// Native iOS build already depends on before falling back to a PATH lookup.
+def resolveNodeBinary(File repoRoot) {
+ def pattern = ~'^\\s*export\\s+NODE_BINARY=(.+)$'
+ for (String name : ['ios/.xcode.env.local', 'ios/.xcode.env']) {
+ File file = new File(repoRoot, name)
+ if (!file.exists()) continue
+ for (String line : file.readLines()) {
+ def matcher = pattern.matcher(line)
+ if (!matcher.find()) continue
+ String value = matcher.group(1).trim().replaceAll('^["\']|["\']$', '')
+ // Skip `$(command -v node)` and friends: this is not a shell.
+ if (value.contains('$')) continue
+ if (new File(value).canExecute()) return value
+ }
+ }
+ return 'node'
+}
+
+// Regenerate XOR-split API secret C sources and EdgeApiKey.{swift,java} before
+// every native build. Explicitly clear ALLOW_STUB so a stub from `npm prepare`
+// cannot leak into the signer outputs.
+def nodeBinary = resolveNodeBinary(rootProject.projectDir.parentFile)
+tasks.register("generateEdgeApiSigner", Exec) {
+ def repoRoot = rootProject.projectDir.parentFile
+ workingDir repoRoot
+ environment "EDGE_API_SIGNER_ALLOW_STUB", ""
+ commandLine nodeBinary, "-r", "sucrase/register", "./scripts/makeApiSigner.ts"
+}
+// Same edgeKey.json feeds EdgeApiKey used by native push registration; keep it
+// in lockstep with the signer so a key rotation cannot leave AppDelegate /
+// MessagesWorker on the previous public key.
+tasks.register("generateEdgeApiKeyHeaders", Exec) {
+ def repoRoot = rootProject.projectDir.parentFile
+ workingDir repoRoot
+ commandLine nodeBinary, "-r", "sucrase/register", "./scripts/makeNativeHeaders.ts"
+}
+generateEdgeApiKeyHeaders.dependsOn("generateEdgeApiSigner")
+preBuild.dependsOn("generateEdgeApiKeyHeaders")
+
+// edge_api_secret.c is gitignored but listed in CMakeLists.txt, and the CMake
+// configure/build tasks do not run behind preBuild, so wire them up directly
+// or a fresh checkout fails with "Cannot find source file".
+tasks.matching {
+ it.name.startsWith("configureCMake") ||
+ it.name.startsWith("buildCMake") ||
+ it.name.startsWith("externalNativeBuild")
+}.configureEach {
+ dependsOn("generateEdgeApiSigner")
+}
diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/android/app/src/main/cpp/CMakeLists.txt
@@ -1,0 +1,27 @@
+cmake_minimum_required(VERSION 3.18.1)
+project(edge_api_signer)
+
+set(NATIVE_SIGNER_DIR "${CMAKE_SOURCE_DIR}/../../../../../native/edge-api-signer")
+
+add_library(
+ edge_api_signer
+ SHARED
+ edge_api_secret.c
+ edge_api_signer_jni.c
+ "${NATIVE_SIGNER_DIR}/edge_hmac.c"
+)
+
+target_include_directories(
+ edge_api_signer
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}
+ ${NATIVE_SIGNER_DIR}
+)
+
+target_compile_options(edge_api_signer PRIVATE -fvisibility=hidden -O2)
+
+# Pixel / Android 15+: 16 KB page-size ELF alignment
+target_link_options(edge_api_signer PRIVATE "-Wl,-z,max-page-size=16384")
+
+find_library(log-lib log)
+target_link_libraries(edge_api_signer ${log-lib})
diff --git a/android/app/src/main/cpp/edge_api_signer_jni.c b/android/app/src/main/cpp/edge_api_signer_jni.c
new file mode 100644
--- /dev/null
+++ b/android/app/src/main/cpp/edge_api_signer_jni.c
@@ -1,0 +1,95 @@
+#include <jni.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "edge_api_sign.h"
+
+static void throw_by_name(JNIEnv *env, const char *class_name, const char *msg) {
+ jclass ex = (*env)->FindClass(env, class_name);
+ if (ex != NULL) {
+ (*env)->ThrowNew(env, ex, msg);
+ }
+}
+
+static void throw_illegal_argument(JNIEnv *env, const char *msg) {
+ throw_by_name(env, "java/lang/IllegalArgumentException", msg);
+}
+
+static void throw_runtime(JNIEnv *env, const char *msg) {
+ throw_by_name(env, "java/lang/RuntimeException", msg);
+}
+
+JNIEXPORT jbyteArray JNICALL
+Java_co_edgesecure_app_EdgeApiSignerModule_nativeSignMessage(
+ JNIEnv *env,
+ jobject thiz,
+ jbyteArray message_utf8,
+ jbyteArray package_name_utf8
+) {
+ if (message_utf8 == NULL || package_name_utf8 == NULL) {
+ throw_illegal_argument(env, "messageUtf8 and packageNameUtf8 are required");
+ return NULL;
+ }
+
+ jsize msg_len = (*env)->GetArrayLength(env, message_utf8);
+ jbyte *msg_bytes = (*env)->GetByteArrayElements(env, message_utf8, NULL);
+ if (msg_bytes == NULL) return NULL;
+
+ jsize pkg_len = (*env)->GetArrayLength(env, package_name_utf8);
+ jbyte *pkg_bytes = (*env)->GetByteArrayElements(env, package_name_utf8, NULL);
+ if (pkg_bytes == NULL) {
+ (*env)->ReleaseByteArrayElements(env, message_utf8, msg_bytes, JNI_ABORT);
+ return NULL;
+ }
+
+ /* edge_api_hmac_sign expects a C string bundle id (NUL-terminated). */
+ char *bundle_id = (char *)malloc((size_t)pkg_len + 1);
+ if (bundle_id == NULL) {
+ (*env)->ReleaseByteArrayElements(env, message_utf8, msg_bytes, JNI_ABORT);
+ (*env)->ReleaseByteArrayElements(env, package_name_utf8, pkg_bytes, JNI_ABORT);
+ throw_by_name(env, "java/lang/OutOfMemoryError", "bundle id allocation failed");
+ return NULL;
+ }
+ memcpy(bundle_id, pkg_bytes, (size_t)pkg_len);
+ bundle_id[pkg_len] = '\0';
+
+ uint8_t signature[32];
+ int rc = edge_api_hmac_sign(
+ (const uint8_t *)msg_bytes,
+ (size_t)msg_len,
+ bundle_id,
+ signature
+ );
+ (*env)->ReleaseByteArrayElements(env, message_utf8, msg_bytes, JNI_ABORT);
+ (*env)->ReleaseByteArrayElements(env, package_name_utf8, pkg_bytes, JNI_ABORT);
+ free(bundle_id);
+
+ if (rc != 0) {
+ throw_runtime(env, "edge_api_hmac_sign failed");
+ return NULL;
+ }
+
+ /* nativeSignMessage is declared non-null in Kotlin, so a bare NULL return
+ would surface as an NPE far from its cause. */
+ jbyteArray out = (*env)->NewByteArray(env, 32);
+ if (out == NULL) {
+ throw_by_name(env, "java/lang/OutOfMemoryError", "signature allocation failed");
+ return NULL;
+ }
+ (*env)->SetByteArrayRegion(env, out, 0, 32, (const jbyte *)signature);
+ memset(signature, 0, sizeof(signature));
+ return out;
+}
+
+JNIEXPORT jstring JNICALL
+Java_co_edgesecure_app_EdgeApiSignerModule_nativeApiKey(
+ JNIEnv *env,
+ jobject thiz
+) {
+ jstring out = (*env)->NewStringUTF(env, edge_api_key());
+ if (out == NULL) {
+ throw_runtime(env, "apiKey allocation failed");
+ }
+ return out;
+}
diff --git a/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerModule.kt b/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerModule.kt
new file mode 100644
--- /dev/null
+++ b/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerModule.kt
@@ -1,0 +1,82 @@
+package co.edgesecure.app
+
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.WritableMap
+import android.util.Base64
+import java.nio.charset.StandardCharsets
+
+/**
+ * React Native bridge to the native HMAC API signer.
+ * The secret never enters Java as a contiguous plaintext constant.
+ */
+class EdgeApiSignerModule(
+ reactContext: ReactApplicationContext,
+) : ReactContextBaseJavaModule(reactContext) {
+ companion object {
+ /**
+ * React Native constructs every module while building the package list, so
+ * an UnsatisfiedLinkError here would kill the app at startup. Record the
+ * failure instead, so EdgeApiSignerPackage can leave the module unregistered
+ * and JS sees an honestly absent signer rather than one that rejects every
+ * call.
+ */
+ val libraryLoaded: Boolean =
+ try {
+ System.loadLibrary("edge_api_signer")
+ true
+ } catch (e: UnsatisfiedLinkError) {
+ false
+ }
+ }
+
+ override fun getName(): String = "EdgeApiSigner"
+
+ @ReactMethod
+ fun signMessage(
+ message: String,
+ promise: Promise,
+ ) {
+ if (!libraryLoaded) {
+ promise.reject("EDGE_API_SIGNER", "edge_api_signer library is unavailable")
+ return
+ }
+ try {
+ // Real UTF-8 bytes for both message and packageName (not JNI Modified UTF-8).
+ val messageUtf8 = message.toByteArray(StandardCharsets.UTF_8)
+ val packageNameUtf8 =
+ reactApplicationContext.packageName.toByteArray(StandardCharsets.UTF_8)
+ val signature = nativeSignMessage(messageUtf8, packageNameUtf8)
+ val apiKey = nativeApiKey()
+ val map: WritableMap = Arguments.createMap()
+ map.putString("apiKey", apiKey)
+ map.putString("signature", Base64.encodeToString(signature, Base64.NO_WRAP))
+ promise.resolve(map)
+ } catch (e: Throwable) {
+ promise.reject("EDGE_API_SIGNER", e.message, e)
+ }
+ }
+
+ @ReactMethod
+ fun getApiKey(promise: Promise) {
+ if (!libraryLoaded) {
+ promise.reject("EDGE_API_SIGNER", "edge_api_signer library is unavailable")
+ return
+ }
+ try {
+ promise.resolve(nativeApiKey())
+ } catch (e: Throwable) {
+ promise.reject("EDGE_API_SIGNER", e.message, e)
+ }
+ }
+
+ private external fun nativeSignMessage(
+ messageUtf8: ByteArray,
+ packageNameUtf8: ByteArray,
+ ): ByteArray
+
+ private external fun nativeApiKey(): String
+}
diff --git a/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerPackage.kt b/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerPackage.kt
new file mode 100644
--- /dev/null
+++ b/android/app/src/main/java/co/edgesecure/app/EdgeApiSignerPackage.kt
@@ -1,0 +1,21 @@
+package co.edgesecure.app
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+/** Registers the EdgeApiSigner native module with React Native. */
+class EdgeApiSignerPackage : ReactPackage {
+ /**
+ * Registering a module whose JNI library is missing would make
+ * `hasNativeApiSigner()` true and steer JS away from its credential
+ * fallback, so an unusable signer is simply not registered.
+ */
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
+ if (EdgeApiSignerModule.libraryLoaded) listOf(EdgeApiSignerModule(reactContext))
+ else emptyList()
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
+ emptyList()
+}
diff --git a/android/app/src/main/java/co/edgesecure/app/MainApplication.kt b/android/app/src/main/java/co/edgesecure/app/MainApplication.kt
--- a/android/app/src/main/java/co/edgesecure/app/MainApplication.kt
+++ b/android/app/src/main/java/co/edgesecure/app/MainApplication.kt
@@ -36,6 +36,7 @@
// packages.add(new MyReactNativePackage());
val packages = PackageList(this).packages
packages.add(EdgeAttestationPackage())
+ packages.add(EdgeApiSignerPackage())
return packages
}
diff --git a/docs/CONFIG_KEYS_ARCHITECTURE.md b/docs/CONFIG_KEYS_ARCHITECTURE.md
--- a/docs/CONFIG_KEYS_ARCHITECTURE.md
+++ b/docs/CONFIG_KEYS_ARCHITECTURE.md
@@ -7,19 +7,29 @@
plugin enablement) with real credential material (API keys, secrets, tokens) in
one flat, `ALLCAPS_*_INIT`-keyed blob.
-This refactor splits that single file into two gitignored inputs and reshapes
+This refactor splits that single file into three gitignored inputs and reshapes
the schema so that plugin configuration is keyed by real plugin ID:
- **`config.json`**: non-secret app/debug settings and the non-secret halves of
each plugin's init options. Safe to commit to a private build-config repo.
- **`keys.json`**: every secret (API keys, tokens, credentials), including the
- secret halves of plugin init options.
+ secret halves of plugin init options, **except** the Edge login HMAC
+ credentials when using the native signer.
+- **`edgeKey.json`**: `{ apiKey, apiSecret }` for Edge login HMAC. Build-time
+ only: `scripts/makeApiSigner.ts` embeds XOR-split native shards from it and
+ `scripts/makeNativeHeaders.ts` reads the public `apiKey`. The Metro bundle
+ never loads it, so `KEYS.EDGE_API_KEY` / `KEYS.EDGE_API_SECRET` are absent in
+ native-signer builds and every consumer must handle that (native
+ `EdgeApiSigner` or JS fallback).
-At runtime the two files stay separate accessors rather than flattening into one
+HMAC request signing (login-server via core, and info-server signed
+infoRollup) is documented in [HMAC_SIGNING.md](./HMAC_SIGNING.md).
+
+At runtime the config/keys files stay separate accessors rather than flattening into one
`ENV` singleton:
- **`CONFIG`** (`src/config.ts`): immutable cleaned `config.json`. Never updated
- by remote getKeys overlays.
+ by remote appKeys overlays.
- **`KEYS`** / **`globalKeys`** (`src/keys.ts`): mutable cleaned keys. Partner
secrets live only under `KEYS.globalKeys`; `globalKeys` is a live alias of that
same object (no top-level flatten onto `KEYS`).
@@ -67,7 +77,7 @@
Those defaults were duplicates: every plugin cleans its own init options and
declares the same default itself, so an omitted field still ends up with the
-same value. The one exception was `pluginApiKeys.paybis.partnerUrl`, whose
+same value. The one exception was `guiApiKeys.paybis.partnerUrl`, whose
consumer required the field outright, so that default now lives in
`paybisProvider.ts` where it is used.
@@ -84,14 +94,29 @@
| `src/config.ts` | Cleans `config.json` with `asConfigJson.withRest` and exports immutable `CONFIG`. |
| `src/keys.ts` | Cleans `keys.json`, nests flat partner secrets via `nestGlobalKeys`, exports immutable merge-base `bakedKeys`, mutable `KEYS`, live `globalKeys` alias, and `applyRuntimeKeys`. |
| `src/pluginMaps.ts` | Builds `pluginMaps` via `resolvePluginMaps(CONFIG, KEYS)` and exports `rebuildPluginMaps` for in-place updates after key overlays. |
-| `src/util/keysStore.ts` | Tier selection, the remote/cache/baked-in resolution promise, the local-only strip list, and `applyKeys` (mutates `KEYS`/`globalKeys`, then `rebuildPluginMaps` + `rebuildAllPlugins`). |
-| `src/util/keysServer.ts` | Signs and issues `GET /v1/getKeys`, and validates the response shape. |
+| `src/util/keysStore.ts` | Tier selection, the remote/cache/baked-in resolution promise, the local-only strip list, and `applyKeys` (mutates `KEYS`/`globalKeys`, then `rebuildPluginMaps` + `rebuildAllPlugins`). Prefers native `apiSigner` for signed infoRollup when linked. |
+| `src/util/keysServer.ts` | Signs and issues `GET /v1/infoRollup/:appId` (JS HMAC or `apiSigner`), extracts `appKeys`, and validates the overlay shape. |
+| `src/util/edgeApiSigner.ts` | Detects the native `EdgeApiSigner` module, builds the core's `apiSigner`, and caches the public `apiKey` for push / notification callers. |
| `src/configKeysMerge.ts` | Runtime merge layer: `deepMerge`, `mergePluginInit`, `nestGlobalKeys`, `resolvePluginMaps`, and `asMergeableKeys`. Also holds redaction helpers for unit tests. |
| `src/configKeysSchema.ts` | Per-file cleaners `asConfigJson` (non-secret) and `asKeysJson` (secret), `globalKeysShape` / `asGlobalKeys`, and the `ConfigJson` / `KeysJson` / `RuntimeKeys` / `GlobalKeys` types. |
-| `scripts/splitEnvJson.ts` | Migration-only CLI (`npm run split-env-json`) that classifies a legacy `env.json` and writes `config.json` + `keys.json`. Never prints secrets; `--force` to overwrite. Not imported by the app. |
+| `scripts/splitEnvJson.ts` | Migration-only CLI (`npm run split-env-json`) that classifies a legacy `env.json` and writes `config.json` + `keys.json` + `edgeKey.json`. Never prints secrets; `--force` to overwrite. Not imported by the app. |
| `src/__tests__/configKeysMerge.test.ts` | Golden-equivalence + deep-merge + redaction unit tests. |
| `scripts/configure.ts` | Runs `makeConfig(asConfigJson.withRest, 'config.json')` and `makeConfig(asKeysJson.withRest, 'keys.json')` so `prepare` can bootstrap both files without writing secrets into `config.json`. |
+## Debug logging
+
+Which tier of keys won, and whether the native signer was used, is reported
+through `debugLog('keys', ...)` rather than a bare `console.log`, so nothing is
+printed in a normal build. Turn it on in `config.json`:
+
+```json
+"LOG_CONFIG": { "enabledCategories": ["keys"] }
+```
+
+That covers `logTier` in `src/util/keysStore.ts` (tier, assurance level, and
+the `LAYER-*` sentinels of a matched overlay) and the signer summary in
+`src/components/services/EdgeCoreManager.tsx`. Neither prints key material.
+
## The CONFIG / KEYS / pluginMaps schema
`asConfigJson` and `asKeysJson` (`src/configKeysSchema.ts`) define the two
@@ -110,12 +135,12 @@
```ts
export const asConfigJson = asObject({
- corePlugins, swapPlugins, pluginApiKeys, rampPlugins, // shared plugin maps
+ corePlugins, swapPlugins, guiApiKeys, rampPlugins, // shared plugin maps
...non-secret config fields
})
export const asKeysJson = asObject({
- pluginApiKeys, rampPlugins, // secret-bearing plugin maps
+ corePlugins, swapPlugins, guiApiKeys, rampPlugins, // secret-bearing plugin maps
globalKeys: asOptional(asGlobalKeys, () => ({})),
...globalKeysShape, // legacy flat partner keys still accepted on disk
...secret fields // EDGE_API_*, SENTRY_*, POSTHOG_API_KEY, …
@@ -146,11 +171,11 @@
Each value is the same `object | true | false` union as before.
- **`swapPlugins`**: swap plugin inits keyed by real swap plugin ID
(`changehero`, `thorchain`, `0xgasless`, ...).
-- **`pluginApiKeys`** — GUI provider keys (formerly `PLUGIN_API_KEYS`), plus the
- migrated `walletconnect` (`projectId`) and `posthog` (`apiKey`, `apiHost`)
- entries where those still appear as plugin-shaped maps.
+- **`guiApiKeys`**: GUI fiat / gift-card provider credentials (formerly
+ `PLUGIN_API_KEYS`: banxa, paybis, phaze, revolut, simplex, …). WalletConnect
+ is **not** in this map; its `projectId` is `globalKeys.WALLETCONNECT_PROJECT_ID`.
- **`rampPlugins`**: ramp plugin inits (formerly `RAMP_PLUGIN_INITS`). Kept
- distinct from `pluginApiKeys` on purpose: `banxa` exists in both maps with
+ distinct from `guiApiKeys` on purpose: `banxa` exists in both maps with
different shapes, so merging them would collide.
There are **no `*_INIT` fields** left in the schema or in any consumer. The dead
@@ -166,17 +191,18 @@
`evmScanApiKey`, `ninerealmsClientId`, `thorswapApiKey`, `privateKeyB64`,
`hmacUser`, `jwtTokenProvider`, `clientSecret`, `heliusApiKey`,
`alchemyApiKey`, `blockfrostProjectId`, `glifApiKey`, `subscanApiKey`,
- `tonCenterApiKeys`, `projectId` (walletconnect), auth/telemetry top-level
+ `WALLETCONNECT_PROJECT_ID` (from `WALLET_CONNECT_INIT.projectId`), auth/telemetry top-level
fields (`EDGE_API_KEY`/`EDGE_API_SECRET`, `SENTRY_*`, `BUGSNAG_API_KEY`,
`POSTHOG_API_KEY`), and the partner secrets, the "global keys". On disk those
partner secrets may still appear **flat** at the top level for legacy files;
load and overlay paths run `nestGlobalKeys` so the runtime `KEYS` object keeps
them only under `KEYS.globalKeys` (`AZTECO_API_KEY`, `COINGECKO_API_KEY`,
- `IP_API_KEY`, `STAKEKIT_API_KEY`, `UNSTOPPABLE_DOMAINS_API_KEY`, `KILN_*`, …).
- A `GET /v1/getKeys` payload delivers the same partner secrets nested under a
- `globalKeys` section; the client keeps that nesting (no top-level flatten onto
- `KEYS`). `YOLO_*` and `POSTHOG_API_HOST` live in `config.json` (local-only
- developer / host wiring, never served).
+ `IP_API_KEY`, `STAKEKIT_API_KEY`, `UNSTOPPABLE_DOMAINS_API_KEY`,
+ `WALLETCONNECT_PROJECT_ID`, `KILN_*`, …).
+ A signed infoRollup `appKeys` overlay delivers the same partner secrets nested
+ under a `globalKeys` section; the client keeps that nesting (no top-level
+ flatten onto `KEYS`). `YOLO_*` and `POSTHOG_API_HOST` live in `config.json`
+ (local-only developer / host wiring, never served).
Both files are gitignored (`.gitignore` lists `/config.json` and `/keys.json`
alongside the retained `/env.json`).
@@ -187,7 +213,7 @@
resolved `pluginMaps`, and normalizes partner secrets under `globalKeys`:
1. **`CONFIG` top-level fields** stay on `CONFIG` only. They are never overwritten
- by getKeys overlays (`keysStore` also drops non-`asKeysJson` fields from
+ by appKeys overlays (`keysStore` also drops non-`asKeysJson` fields from
overlays via `keepKeysFields`).
2. **`KEYS` top-level secret fields** (`EDGE_API_*`, `SENTRY_*`, `POSTHOG_API_KEY`,
plugin maps, …) live on `KEYS`. Remote/cache overlays deep-merge onto
@@ -196,20 +222,20 @@
`globalKeys` section are normalized by `nestGlobalKeys`. Consumers read
`globalKeys.COINGECKO_API_KEY` (or `KEYS.globalKeys.…`); there is no
top-level `KEYS.COINGECKO_API_KEY` after nesting.
-4. **Currency & swap plugins** — for each ID present in
- `CONFIG.corePlugins` / `CONFIG.swapPlugins`, the non-secret config value is
- combined with the matching secret from `KEYS.pluginApiKeys[id]` via
- `mergePluginInit`:
+4. **Currency & swap plugins**: for each ID present in config or keys
+ `corePlugins` / `swapPlugins` (union), the non-secret config value is
+ combined with the matching secret from `KEYS.corePlugins[id]` /
+ `KEYS.swapPlugins[id]` via `mergePluginInit`:
- a `false` config value keeps the plugin disabled (secrets ignored);
- a `false` keys value is ignored: keys carry secrets, never kill
switches, so a remote/cache overlay cannot turn a plugin off;
- a `true`/absent config value with an object secret becomes the secret
object (an object always wins over a bare boolean enablement flag);
- otherwise the two are deep-merged with the keys side winning.
-5. **GUI provider keys (`pluginApiKeys`)** — every `pluginApiKeys` ID that is
- _not_ a currency or swap plugin (those secrets live inside
- `corePlugins`/`swapPlugins` after resolve). Config and keys are deep-merged
- per ID.
+ Extra remote IDs on `pluginMaps.corePlugins` do **not** register a new
+ engine: `corePlugins.ts` is a hardcoded table.
+5. **GUI provider keys (`guiApiKeys`)**: union of config and keys IDs, merged
+ per ID. Currency/swap secrets do not live here.
6. **Ramp plugins (`rampPlugins`)**: `CONFIG.rampPlugins[id]` deep-merged with
`KEYS.rampPlugins[id]` per ID.
@@ -225,10 +251,11 @@
`thorchain` for swap).
- `isSecretField` (a field-name regex) and `isSecretTopLevel` classify each
field. Secret-looking fields go to `keys.json`; the rest go to `config.json`.
-- `PLUGIN_API_KEYS` → `pluginApiKeys`, `RAMP_PLUGIN_INITS` → `rampPlugins`.
+- `PLUGIN_API_KEYS` → `guiApiKeys`, `RAMP_PLUGIN_INITS` → `rampPlugins`.
- `POSTHOG_INIT` → `config.POSTHOG_API_HOST` + a flat `keys.POSTHOG_API_KEY`
(PostHog is not a plugin; the api key stays top-level on `KEYS` at runtime).
-- `WALLET_CONNECT_INIT` → `pluginApiKeys.walletconnect`.
+- `WALLET_CONNECT_INIT.projectId` → flat `keys.WALLETCONNECT_PROJECT_ID` (then
+ nested under `globalKeys` at load). No config flag; disable = omit the key.
- Loose partner secrets (`AZTECO_*`, `KILN_*`, CoinGecko, …) → flat top-level
fields in `keys.json` (nested under `globalKeys` at runtime load).
- `YOLO_*` stays in `config.json`.
@@ -257,11 +284,11 @@
`thorchainrunestagenet` both read `corePlugins.thorchainrune`.
- `src/hooks/useRampPlugins.ts`: `pluginMaps.rampPlugins[pluginId]`.
- `src/plugins/gui/util/initializeProviders.ts`, `fetchRevolut.ts`, and the
- gift-card / WalletConnect paths — `pluginMaps.pluginApiKeys.*`.
+ gift-card paths: `pluginMaps.guiApiKeys.*`.
- Inner-field readers: `FioAddressUtils.ts` (`pluginMaps.corePlugins.fio`),
`thorchainYield.ts` + `stakePlugins.ts` (`pluginMaps.swapPlugins.thorchain`),
`fantomEcosystem.ts` (`pluginMaps.corePlugins.fantom`),
- `WalletConnectService.tsx` (`pluginMaps.pluginApiKeys.walletconnect.projectId`),
+ `WalletConnectService.tsx` (`globalKeys.WALLETCONNECT_PROJECT_ID`),
`tracking.ts` (`KEYS.POSTHOG_API_KEY` + `CONFIG.POSTHOG_API_HOST`).
## Scripts
@@ -274,6 +301,12 @@
(`configJson` / `keysJson` branch-override fields, already shaped like the
files they patch).
+After `npm run split-env-json`, `npm run split-baked-and-server-keys` rewrites
+`keys.json` to the local-only keep-list (`slimKeysJson` / `localOnlyKeys`) and
+writes `appKeys.json` for the info-server Couch default layer: `corePlugins`,
+`swapPlugins`, `guiApiKeys`, `rampPlugins`, and nested `globalKeys`. It never
+prints secret values.
+
---
## Status of remaining Env config code
@@ -339,13 +372,19 @@
- Private build-config repos must ship `config.json` + `keys.json` instead of
`env.json` before release builds use this branch.
- Deploy deep-merges explicit `configJson` / `keysJson` per-branch overrides into
- the matching files and does not run overrides through `splitEnv`. Legacy
- `envJson` is ignored (with a migration error when a branch block exists only
- there) so the same file can still serve older GUI builds that read it.
+ the matching files and does not run overrides through `splitEnv`. Outer keys
+ are **git branch names** (`develop`, `beta`, `yolo`, …). Inner `keysJson[branch]`
+ is the same overlay as `info_keys` layer `keys` / signed rollup `appKeys`
+ (four maps + `globalKeys.WALLETCONNECT_PROJECT_ID`). Inner `configJson[branch]`
+ is enablement / non-secret init. See `deploy-config.sample.json`. Never-serve
+ fields (`POSTHOG_API_KEY`, `EDGE_API_*`, `SENTRY_*`, `YOLO_*`) do not belong
+ in `keysJson`. Legacy `envJson` is ignored (with a migration error when a
+ branch block exists only there) so the same file can still serve older GUI
+ builds that read it.
- Optional: update `README.md` and native comments to reference the new files;
eventually retire `env.json` + `scripts/splitEnvJson.ts` together.
-## Remote keys via the info server (`GET /v1/getKeys`)
+## Remote keys via the info server (signed `infoRollup` `appKeys`)
Client support for remote keys is implemented on this branch (`keysStore`,
`keysServer`, DeviceSettings `keysCache`, EdgeCoreManager gate). The design
@@ -363,7 +402,7 @@
| Tier | Source | When it applies |
| ---------- | ------------------------------------ | ------------------------------------------------------------------- |
| `cache` | `keysCache` in `DeviceSettings.json` | Any launch with a mergeable on-disk cache (does not expire) |
-| `remote` | `GET /v1/getKeys` on the info server | Cold start (no usable cache), fetch succeeded within budget |
+| `remote` | Signed `GET /v1/infoRollup/:appId` `appKeys` | Cold start (no usable cache), fetch succeeded within budget |
| `baked-in` | `keys.json` compiled into the binary | Cold start where the fetch failed/missed budget and no usable cache |
The cache takes precedence over the network rather than the other way round.
@@ -379,8 +418,8 @@
overwrites it. Paying the budget once repairs it.
Both tiers are held to the same definition of "will not merge", `asMergeableKeys`
-in `configKeysMerge.ts`: a top-level object whose `pluginApiKeys`, `rampPlugins`,
-and `globalKeys` are objects if present. It is checked in `applyKeys`, which
+in `configKeysMerge.ts`: a top-level object whose `corePlugins`, `swapPlugins`,
+`guiApiKeys`, `rampPlugins`, and `globalKeys` are objects if present. It is checked in `applyKeys`, which
every tier passes through, and again at the fetch so a bad response never reaches
disk. Validating only the fetch would leave the cache unguarded, and because
`deepMerge` replaces rather than merges when the two sides disagree on type, a
@@ -409,114 +448,94 @@
Two consequences worth stating plainly:
- **`keys.json` does not go away.** It keeps its full schema with every field
- optional; only `EDGE_API_KEY` and `EDGE_API_SECRET` are required, since those
- are the credentials used to authenticate the fetch. A release build may ship
- either a minimal bootstrap file or a fully populated fallback file.
-- **A shipped binary may therefore still contain every secret.** This work
+ optional. `EDGE_API_KEY` / `EDGE_API_SECRET` authenticate signed infoRollup and login
+ when the native signer is **not** linked. Native-signer builds embed those
+ credentials at compile time from `edgeKey.json` and omit them from the Metro
+ bundle; see [HMAC signing](HMAC_SIGNING.md).
+- **A shipped binary may therefore still contain partner secrets.** This work
_reduces_ secret exposure and enables server-side rotation; it does not make
the IPA/APK secret-free.
### Authentication
-The endpoint reuses the login server's HMAC-signed `Authorization` scheme
-(`edge-login-server/src/middleware/with-api-key.ts`), with one deliberate
-divergence — a required, signed `X-Timestamp`:
+The endpoint uses HMAC `Authorization` plus a required `X-Timestamp`. That is
+the existing login-server scheme (`with-api-key.ts`) with one extra signed line.
+Canonical server behavior, layer matching, and the Couch schema live in
+[edge-info-server `docs/INFO_ROLLUP.md`](https://github.com/EdgeApp/edge-info-server/blob/master/docs/INFO_ROLLUP.md).
-GET /v1/getKeys -GET\n/v1/getKeys\n\n{timestamp} -info_keys/
... diff truncated: showing 800 of 4952 lines |
1d2229d to
8ab5158
Compare
4bedbb4 to
011f9a8
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Boot error hardcodes Edge branding
- Replaced the hardcoded Edge brand in boot_failed_message with config.appName so white-label builds show the partner app name.
Or push these changes by commenting:
@cursor push 9d207fae60
Preview (9d207fae60)
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
@@ -39,6 +39,7 @@
import { useIsAppForeground } from '../../hooks/useIsAppForeground'
import { KEYS } from '../../keys'
import { lstrings } from '../../locales/strings'
+import { config } from '../../theme/appConfig'
import { addMetadataToContext } from '../../util/addMetadataToContext'
import { onAttestationToken } from '../../util/attestation'
import { allPlugins } from '../../util/corePlugins'
@@ -319,7 +320,11 @@
return (
<View style={styles.bootErrorContainer}>
<Text style={styles.bootErrorText}>
- {sprintf(lstrings.boot_failed_message_1s, bootFatalError)}
+ {sprintf(
+ lstrings.boot_failed_message_2s,
+ config.appName,
+ bootFatalError
+ )}
</Text>
</View>
)
diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts
--- a/src/locales/en_US.ts
+++ b/src/locales/en_US.ts
@@ -73,7 +73,7 @@
bitcoin_received: '%1$s Received',
// Shown instead of the app when the core fails to start at all:
- boot_failed_message_1s: 'Edge failed to start: %1$s',
+ boot_failed_message_2s: '%1$s failed to start: %2$s',
dialog_title: 'Set Auto Logoff time',
share_subject: 'Hey, I think you should try %s',You can send follow-ups to the cloud agent here.
| bitcoin_received: '%1$s Received', | ||
|
|
||
| // Shown instead of the app when the core fails to start at all: | ||
| boot_failed_message_1s: 'Edge failed to start: %1$s', |
There was a problem hiding this comment.
Boot error hardcodes Edge branding
Low Severity
The new boot-failure copy in boot_failed_message_1s hardcodes the Edge brand. This screen is user-facing when the core fails to start, so white-label builds would show the wrong app name.
Additional Locations (1)
Triggered by learned rule: White-label awareness: no hardcoded Edge branding or URLs in user-facing features
Reviewed by Cursor Bugbot for commit 011f9a8. Configure here.
059fba1 to
e35aead
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.
e35aead to
fac86e7
Compare
Boot from baked-in KEYS, then overlay a signed infoRollup appKeys payload and device cache. Mutate KEYS and globalKeys in place and rebuild pluginMaps.
cfefee7 to
b6d700c
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Robinhood init dropped on env split
- Added ROBINHOOD_INIT to CURRENCY_INIT_MAP so the env split preserves Robinhood enablement and secrets like every other coreInit currency.
Or push these changes by commenting:
@cursor push 8e5f942f26
Preview (8e5f942f26)
diff --git a/scripts/splitEnvJson.ts b/scripts/splitEnvJson.ts
--- a/scripts/splitEnvJson.ts
+++ b/scripts/splitEnvJson.ts
@@ -60,6 +60,7 @@
POLKADOT_INIT: 'polkadot',
POLYGON_INIT: 'polygon',
PULSECHAIN_INIT: 'pulsechain',
+ ROBINHOOD_INIT: 'robinhood',
RSK_INIT: 'rsk',
SEPOLIA_INIT: 'sepolia',
SOLANA_INIT: 'solana',You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit b6d700c. Configure here.
| LITECOIN_INIT: 'litecoin', | ||
| PIVX_INIT: 'pivx', | ||
| ZCOIN_INIT: 'zcoin' | ||
| } |
There was a problem hiding this comment.
Robinhood init dropped on env split
Medium Severity
CURRENCY_INIT_MAP has no ROBINHOOD_INIT entry, so split-env-json drops that legacy field. coreInit('robinhood') then falls back to false when the id is absent from both maps, which turns Robinhood wallets off after migration unless a remote overlay happens to reintroduce the id.
Additional Locations (1)
Triggered by learned rule: Verify hardcoded pluginId lists are complete against the plugin repo
Reviewed by Cursor Bugbot for commit b6d700c. Configure here.
b6d700c to
4e84748
Compare
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. Keys-only plugins are left out of the diff: they take nothing from the info server, so remote keys cannot break them, and they are hidden from the user regardless. Plugin loading happens once per core context, so this reports once per session rather than on every login.
4e84748 to
78d8225
Compare



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
Touches authentication (native HMAC, infoRollup signing), secret handling, and cold-start key resolution before the core loads—failures or merge bugs could break login, plugins, or first launch.
Overview
Replaces the monolithic gitignored
env.jsonwithconfig.json(non-secret settings),keys.json(secrets and plugin init material), and build-onlyedgeKey.jsonfor Edge login HMAC. Runtime code usesCONFIG, mutableKEYS/globalKeys, andpluginMapsinstead of a flatENVsingleton; plugin settings move to ID-keyed maps (corePlugins,swapPlugins,guiApiKeys,rampPlugins).Adds a native Edge API HMAC signer (XOR-split C shards from
edgeKey.json, Android JNI + iOS bridge) wired into core asapiSigner, with Gradle/Xcode generate steps and JSKEYS.EDGE_API_*fallback when the module is absent.Boot can fetch signed
GET /v1/infoRollup/:appIdappKeys, merge overlays intoKEYS/pluginMaps(DeviceSettings cache + bakedkeys.jsonfallbacks), and gate Edge core startup untilinitializeKeys()resolves. Deploy andpreparepaths, migration scripts (split-env-json, slim/server key split), and docs were retargeted; legacy deployenvJsonoverrides are ignored on this branch.Reviewed by Cursor Bugbot for commit 78d8225. Bugbot is set up for automated code reviews on this repo. Configure here.