Native HMAC apiSigner and signed infoRollup appKeys - #6183
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.
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.
e35aead to
fac86e7
Compare
j0ntz
left a comment
There was a problem hiding this comment.
Approving on the condition that the two Warning comments are fixed before merge: eCash belongs back in the UTXO plugin list, and {} ramp inits need to stay enabled. The rest are suggestions.
4bb7863 to
8988d71
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: Empty ramp inits collapse to boolean
- mergePluginInit now keeps a keys-side {} as a real empty init so ramps like bitsofgold and libertyx resolve to {} and load instead of collapsing to true and being skipped.
Or push these changes by commenting:
@cursor push d7d2c96afa
Preview (d7d2c96afa)
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
@@ -11,7 +11,8 @@
- added: Exchange deep links (`edge://exchange/buy|sell|swap`) that open the flow with the asset pre-selected
- added: Promo attribution for buy, sell and swap flows opened from deep links and promo cards
- added: WalletConnect Bitcoin (bip122) message signing for proof of ownership with existing BTC wallets
-- 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: 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: 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
- changed: Standardize wallet list automation test IDs to use period separators.
- changed: Lock the send confirmation slider for the rest of the scene once a broadcast has been attempted, whether the broadcast reported success or failure, and replace the generic failure card with a message that the transaction may have gone through, pointing at the block explorer or confirmation email before trying again.
- changed: Prevent sending to the same wallet's own address for EVM assets.
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 /v1/infoRollup/{appId}?os={ios|android}&osVersion={x.y.z}&appVersion={semver}
Authorization: HMAC {edgeApiKey} {base64(hmacSha256(signedString, secret))}
X-Timestamp: {unix seconds}
-x-attestation-token: {ES256 JWT} // optional
+x-attestation-token: {ES256 JWT} // optional; invalid token → HTTP 401
-The signed string is the login server's `METHOD\nURL\nBODY` plus a timestamp
-line, with an empty body because this is a GET:
+Signed string (empty GET body):
-GET\n/v1/getKeys\n\n{timestamp}
+GET\n/v1/infoRollup/{appId}?os=…&osVersion=…&appVersion=…\n\n{timestamp}
-The login server itself has **no** signature freshness window, so there is no
-existing window to match. The window instead follows the info server's clamped,
-operator-editable remote-config pattern used for attestation challenge
-lifetimes, defaulting to 300 s with a 30 s floor. The wider default reflects
-that `X-Timestamp` comes from a device clock that can drift by minutes, unlike a
-server-issued challenge.
+The client signs that path **including** `/v1` (`keysServer.ts` `signPath`).
+The info server verifies `req.originalUrl`. Login-server HMAC has **no**
+timestamp line and **no** freshness window.
-### Attestation-level layering
+`appId` in the path is the info-rollup partner id (`config.appId ?? 'edge'`).
+It must match `info_keys` document `_id`. The attested bundle id is a different
+field: the JWT `appId` claim (`co.edgesecure.app`).
-The payload is composed by **cumulative ascending deep merge**: `default` is the
-base, then every defined level whose rank is at or below the caller's attested
-rank is merged in ascending order, later levels winning. Ranks are the info
-server's existing assurance levels — `debug` 0, `software` 1, `hardware` 2,
-`secureElement` 3. An unattested caller receives `default` alone; a key with no
-`default` returns an empty payload to an unattested caller, which is a valid way
-to require attestation.
+Disk cache holds the **`appKeys` overlay only** (`DeviceSettings.keysCache`).
+The public rollup (promo/APY/…) stays **in-memory** on `infoServerData.rollup`.
+A 5-minute / NetInfo unsigned poll may live-update those public fields; KEYS
+never hot-swap this session. Boot is a single signed infoRollup when HMAC
+credentials exist (no parallel unsigned fetch at t=0).
-Because `debug` participates in the cumulative chain, production material must
-never be placed under `debug`.
+Native `apiSigner` is preferred when `EdgeApiSigner` is linked; otherwise JS
+HMAC uses `KEYS.EDGE_API_KEY` / `KEYS.EDGE_API_SECRET`.
-### App ID scoping
+### Attestation-level layering
-Keys differ per app, since white-label apps ship from this codebase with their
-own provider credentials. The request carries the logical app ID in the signed
-query string, reusing the same value already sent to `infoRollup`
-(`config.appId ?? 'edge'` in `src/util/network.ts`).
+Payloads are **not** a named ladder (`default` then `debug` then `hardware`)
+nested under each app. The info server walks an ordered `layers` array and
+deep-merges every row that independently matches:
-Two distinct identifiers are both called `appId`, and they must not be
-conflated:
+1. Layer `apiKeys` lists the **memo name** of the HMAC that signed the request
+ (not the presented public id).
+2. Token assurance ≥ `minAssurance` (`default` is unattested, below `debug`).
+3. `bundleIds` is `"*"` (only legal at `minAssurance: default`) **or** the JWT
+ `appId` is in the layer’s bundle list. Unattested callers never match a
+ non-wildcard bundle list.
-| | Logical app ID | Attested app ID |
-| ------ | ---------------------------------------- | -------------------------------------------------- |
-| Value | Build-config slug, e.g. `edge` | Bundle id / package name, e.g. `co.edgesecure.app` |
-| Source | `config.appId`, from `CONFIG.APP_CONFIG` | The `appId` claim in the attestation JWT |
-| Trust | Unverified build-time label | Cryptographically bound |
+Unknown bundles are not a 403; they receive only wildcard/`default` rows.
+A present-but-invalid attestation token is HTTP 401: the GUI must not treat
+that as “unattested floor.”
-The document therefore maps each logical app ID to its iOS and Android
-identifiers, and the server verifies the attestation token's claim against that
-mapping. A token whose bundle id belongs to a different app in the same document
-is rejected rather than downgraded.
+**Invariant:** anything on a `"bundleIds": "*"` / `minAssurance: default` row is
+reachable by any holder of a listed HMAC. App-scoped credentials belong on rows
+that list real bundle IDs and `minAssurance` of `debug` or above.
-**Security invariant:** an unattested caller can name any allowed app ID and
-receive that app's `default` payload, because nothing proves which binary is
-asking. So `default` may only hold keys acceptable to hand to any holder of that
-Edge API key and secret; anything genuinely app-scoped belongs at `software` or
-above, where the bundle id is proven. Apps needing mutually isolated defaults
-need separate Edge API keys.
-
### `info_keys` document shape
-A new CouchDB database `info_keys` holds one document per API-key partner. Each
-document carries an `appIds` allow-list and multiple Edge API keys, mirroring the
-login server's `login-api-keys` layout. Each key holds its own HMAC secret plus
-per-app payloads, nested app then attestation level so layering never crosses app
-boundaries:
+Couch `info_keys/<appId>` (`_id` is the rollup app ID: `edge`,
+`com.testy.wallet`, …). Lookup is the presented HMAC public id via
+`_design/api-key`. Secrets live here, not on login-server Couch (rotate both
+when minting a pair).
-info_keys/
- appIds: [, ...] // allow-list
- apiKeys
-
type, secret, enabled, created, comment -
apps -
<logicalAppId> -
ios: [<bundleId>, ...] // verified against the attestation claim -
android: [<packageName>, ...] -
keys -
default -> keys.json-shaped payload -
debug -> partial override
... diff truncated: showing 800 of 4818 lines
</details>
<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-831d11be-fd4a-4364-b8a6-fe4f69b7d289">here</a>.</sub>
</details>
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).
Autofix Details
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.
4e84748 to
78d8225
Compare
j0ntz
left a comment
There was a problem hiding this comment.
Every thread from my earlier rounds is fixed or answered at 78d8225, including both Warnings the approval was conditional on. Cursor's Robinhood thread at scripts/splitEnvJson.ts:79 is still open and still applies: CURRENCY_INIT_MAP has no ROBINHOOD_INIT entry, and coreInit('robinhood') falls back to false when the id is absent.
78d8225 to
deb4b38
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Prepare corrupts HMAC secret field
- Kept EDGE_API_SECRET as validated hex in asKeysJson so prepare can persist it, and apply asBase16 once in keys.ts at load.
Or push these changes by commenting:
@cursor push a8fe3775ad
Preview (a8fe3775ad)
diff --git a/.cursorignore b/.cursorignore
--- a/.cursorignore
+++ b/.cursorignore
@@ -1,2 +1,3 @@
env.json
config.json
+keys.json
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +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
/fastlane.json
/ios/edge/GoogleService-Info.plist
/ios/Pods/
@@ -17,6 +21,7 @@
android-release.bundle.map
ios-release.bundle.map
keystores/
+/.edgeApiSigner.stamp
# Debugging
overrideTheme.json
@@ -35,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/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,8 +22,8 @@
The plugin itself lives in `edge-exchange-plugins`; this repo only wires it up, and every wiring point below fails SILENTLY when missed (no error, just a blank icon or a provider that never initializes). Registering a new swap `pluginId` means all of:
-- `src/envConfig.ts` - `<NAME>_INIT` via `asCorePluginInit`, when the plugin takes init options (an `apiKey`)
-- `src/util/corePlugins.ts` - `swapPlugins` entry mapping the `pluginId` to that init (or `true` when it needs none)
+- `src/util/corePlugins.ts` - `buildSwapPlugins` entry `swapInit('<pluginId>')` (or `true` when it needs no init options). It is enabled by `swapPlugins.<pluginId>` in `config.json`, with any init options (an `apiKey`) under the same key in `keys.json` or the signed infoRollup `appKeys`
+- `scripts/splitEnvJson.ts` - `SWAP_INIT_MAP` entry from the legacy `env.json` field `<NAME>_INIT` to the `pluginId`, so an old `env.json` still migrates
- `src/actions/CategoriesActions.ts` - `pluginIdIcons` entry, the swap-row icon fallback when no merchant contact matches
- `src/constants/MerchantContacts.ts` - `MERCHANT_CONTACTS` entry whose `displayName` matches the plugin's `swapInfo.displayName` after `normalizeForSearch` (case and whitespace ignored, see `useContactThumbnail`). On a swap row this entry wins over `pluginIdIcons[iconPluginId]` (`TransactionListRow.tsx`)
- `src/components/modals/SwapVerifyTermsModal.tsx` - `pluginData` entry ONLY for centralized providers with terms/KYC to accept; DEX plugins (`isDex: true`) take none
diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@
- added: Promo attribution for buy, sell and swap flows opened from deep links and promo cards
- added: WalletConnect Bitcoin (bip122) message signing for proof of ownership with existing BTC wallets
- added: MoonPay Trade swap provider
+- 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: 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
- changed: Standardize wallet list automation test IDs to use period separators.
- changed: Lock the send confirmation slider for the rest of the scene once a broadcast has been attempted, whether the broadcast reported success or failure, and replace the generic failure card with a message that the transaction may have gone through, pointing at the block explorer or confirmation email before trying again.
- changed: Prevent sending to the same wallet's own address for EVM assets.
@@ -19,6 +21,8 @@
- changed: Balance-effect checks and the login FIO refresh wait for engine readiness on cache-emitted wallets
- changed: Opening any wallet-scoped scene asks the core to prioritize that wallet's engine startup in the post-login queue.
- changed: Buy/sell crypto amount field shows up to nine decimals, matching the asset's precision, instead of six
+- changed: Split runtime `env.json` into non-secret `config.json` and secret `keys.json`; deploy-config branch overrides move from `envJson` to `configJson`/`keysJson` (legacy `envJson` is ignored on this GUI)
+- changed: Replace the flat `ENV` singleton with separate `CONFIG`, `KEYS`/`globalKeys`, and `pluginMaps` accessors (no top-level globalKeys flatten)
- fixed: A send whose funds are not spendable yet says so instead of reporting a network error
- fixed: Auto-login starting two competing accounts when both `YOLO_PASSWORD` and `YOLO_PIN` are set, and attempting a login when either is set to an empty string.
- fixed: Auto-login never running on a device with no accounts, since the welcome carousel took priority over the login scene that owns it.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -41,11 +41,11 @@
This bundler process needs to run in the background, so feel free to run this in its own terminal window.
-### Add API key in env.json
+### Add API key in keys.json
A public API key is built into the edge-core-js which can be used to build and test the Edge app. This key is severely rate limited and should not be used for production. For production use, get an API key by emailing info@edge.app.
-Change the `AIRBITZ_API_KEY` in `env.json` to the API key you received from Edge. To use the public API key, leave `AIRBITZ_API_KEY` blank.
+`npm run prepare` creates `config.json` (non-secret) and `keys.json` (secret) with defaults. Set `EDGE_API_KEY` in `keys.json` to the key you received from Edge. To use the public API key, leave `EDGE_API_KEY` blank. If you still have a legacy `env.json`, run `npm run split-env-json` once to produce the two files.
### Run the app in debug mode
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/deploy-config.sample.json b/deploy-config.sample.json
--- a/deploy-config.sample.json
+++ b/deploy-config.sample.json
@@ -29,21 +29,51 @@
"master": {
"hockeyAppId": "xxxxxxxxx",
"splitArchitectures": [
- { "abi": "arm64-v8a", "zealotChannelKey": "xxxxxxxxxx" },
- { "abi": "armeabi-v7a", "zealotChannelKey": "xxxxxxxxxx" }
+ {
+ "abi": "arm64-v8a",
+ "zealotChannelKey": "xxxxxxxxxx"
+ },
+ {
+ "abi": "armeabi-v7a",
+ "zealotChannelKey": "xxxxxxxxxx"
+ }
]
},
"develop": {
"hockeyAppId": "xxxxxxxxx"
}
},
- "envJson": {
+ "configJson": {
+ "develop": {
+ "corePlugins": { "bitcoin": true },
+ "swapPlugins": { "changelly": true },
+ "guiApiKeys": { "banxa": true, "phaze": false },
+ "rampPlugins": { "moonpay": true },
+ "ENABLE_VISA_PROGRAM": true,
+ "BETA_FEATURES": true
+ },
+ "beta": {
+ "ENABLE_VISA_PROGRAM": true,
+ "BETA_FEATURES": true
+ },
"yolo": {
"YOLO_USERNAME": "",
"YOLO_PASSWORD": "",
"YOLO_PIN": "",
"YOLO_OTP_KEY": ""
}
+ },
+ "keysJson": {
+ "develop": {
+ "corePlugins": { "bitcoin": { "nowNodesApiKey": "xxxxxxxxx" } },
+ "swapPlugins": { "changelly": { "apiKey": "xxxxxxxxx" } },
+ "guiApiKeys": { "banxa": { "apiKey": "xxxxxxxxx" } },
+ "rampPlugins": { "moonpay": {} },
+ "globalKeys": {
+ "WALLETCONNECT_PROJECT_ID": "xxxxxxxxx",
+ "COINGECKO_API_KEY": "xxxxxxxxx"
+ }
+ }
}
}
}
diff --git a/docs/CONFIG_KEYS_ARCHITECTURE.md b/docs/CONFIG_KEYS_ARCHITECTURE.md
new file mode 100644
--- /dev/null
+++ b/docs/CONFIG_KEYS_ARCHITECTURE.md
@@ -1,0 +1,731 @@
+# Edge React GUI - Config & Keys Architecture
+
+## Overview
+
+Historically the app was configured through a single, gitignored `env.json`
+file that mixed non-secret settings (feature flags, hosts, debug options,
+plugin enablement) with real credential material (API keys, secrets, tokens) in
+one flat, `ALLCAPS_*_INIT`-keyed blob.
+
+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, **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).
+
+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 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`).
+- **`pluginMaps`** (`src/pluginMaps.ts`): the four resolved plugin init maps,
+ produced by `resolvePluginMaps(CONFIG, KEYS)` and rebuilt in place when keys
+ overlays apply.
+
+The split is about _where a field lives_ and _which accessor a consumer imports_.
+A golden-equivalence test still proves the merged plugin maps are
+behavior-identical to the legacy `env.json` shape for plugin inits.
+
+## Data flow
+
+```mermaid
+flowchart LR
+ configJson["config.json (non-secret)"] --> cleanC["asConfigJson.withRest"]
+ keysJson["keys.json (secret)"] --> cleanK["asKeysJson.withRest"]
+ cleanC --> CONFIG["CONFIG (immutable)"]
+ cleanK --> nest["nestGlobalKeys"]
+ nest --> baked["bakedKeys"]
+ baked --> KEYS["KEYS + globalKeys alias (mutable)"]
+ CONFIG --> resolve["resolvePluginMaps"]
+ KEYS --> resolve
+ resolve --> maps["pluginMaps"]
+ maps --> core["corePlugins.ts -> allPlugins -> edge-core"]
+ maps --> ramps["useRampPlugins.ts"]
+ maps --> gui["gift-card / revolut / walletconnect / stake consumers"]
+ CONFIG --> configConsumers["feature flags / hosts / PostHog host / YOLO_*"]
+ KEYS --> keyConsumers["EDGE_API_* / SENTRY_* / POSTHOG_API_KEY"]
+ globalKeys["globalKeys"] --> partner["CoinGecko / Kiln / StakeKit / …"]
+```
+
+Each file is validated and cleaned by its own cleaner exactly once. There is no
+union `asEnvConfig` pass over a merged blob: that would re-run single-shot codecs
+such as `EDGE_API_SECRET`'s `asBase16` transform (string ⇄ `Uint8Array`) a second
+time and fail. Runtime types are `ConfigJson`, `KeysJson`, and `RuntimeKeys`
+(after flat partner fields have been nested under `globalKeys`).
+
+### Plugin inits are no longer validated field-by-field
+
+The plugin maps hold each plugin's init options as-is. The legacy flat cleaner
+declared a cleaner per `*_INIT` field, which also meant it supplied defaults for
+fields a config file left out: `thorname: 'ej'`, `affiliateFeeBasis: '50'`,
+`appId: 'edge'`, FIO's `tpid`, and so on.
+
+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 `guiApiKeys.paybis.partnerUrl`, whose
+consumer required the field outright, so that default now lives in
+`paybisProvider.ts` where it is used.
+
+The remaining difference is Rango: it applies a referral only when
+`referrerAddress` and `referrerFee` are both set, and no longer invents a
+`referrerFee` of `'0.75'` for a config that sets an address but no fee. Such a
+config was always ambiguous; it now takes no referral rather than a rate nobody
+wrote down.
+
+## Key modules
+
+| File | Responsibility |
+| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `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`). 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` + `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
+on-disk shapes. Only plugin-owned data was re-keyed; everything else keeps its
+historical name and shape (`ACTION_QUEUE`, `LOG_CONFIG`, `LOG_SERVER`,
+`THEME_SERVER`, `DEBUG_*`, `APP_CONFIG`, `EDGE_API_KEY`, `SENTRY_*`, `KILN_*`,
+`YOLO_*`, etc.), but consumers now import the accessor that owns the field.
+
+**Schema is not the same as a data file.** Each cleaner validates one file.
+A secret field such as `EDGE_API_KEY` or `SENTRY_DSN_URL` appearing in
+`asKeysJson` does **not** mean its value lives in `config.json`: the value comes
+from `keys.json`; the cleaner only types that file.
+
+There is no runtime union cleaner that splat-merges both shapes into one object.
+Ownership is enforced by keeping the accessors separate:
+
+```ts
+export const asConfigJson = asObject({
+ corePlugins, swapPlugins, guiApiKeys, rampPlugins, // shared plugin maps
+ ...non-secret config fields
+})
+
+export const asKeysJson = asObject({
+ 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, …
+})
+
+// RuntimeKeys = KeysJson without flat partner fields, with nested globalKeys
+```
+
+(`.withRest` on each cleaner preserves legacy/extra keys and the JSON "comment"
+separators the files carry.)
+
+Both per-file cleaners are actually used:
+
+- `src/config.ts` runs `asConfigJson.withRest(CONFIG_JSON)` at startup.
+- `src/keys.ts` runs `asKeysJson.withRest(KEYS_JSON)`, then `nestGlobalKeys`, so
+ **each file is validated on its own** (a malformed `keys.json`, or a secret
+ misfiled into `config.json`, fails loudly) before runtime nesting / map
+ resolution.
+- `scripts/configure.ts` runs `makeConfig` with the same cleaners for both
+ files, so `makeConfig` can never default or write a secret field into
+ `config.json`.
+
+The four plugin maps, each `Record<pluginId, init>`, live on `pluginMaps` after
+`resolvePluginMaps`:
+
+- **`corePlugins`**: edge-core currency plugin inits keyed by real edge-core
+ plugin ID (`bitcoin`, `ethereum`, `binancesmartchain`, `thorchainrune`, ...).
+ Each value is the same `object | true | false` union as before.
+- **`swapPlugins`**: swap plugin inits keyed by real swap plugin ID
+ (`changehero`, `thorchain`, `0xgasless`, ...).
+- **`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 `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
+`WYRE_CLIENT_INIT` (0 consumers) and unmapped legacy `*_INIT` fields were dropped.
+
+## File ownership rule
+
+- **`config.json`** holds non-secret app/debug fields and the non-secret plugin
+ fields: `enabled` flags, `appId`, `affiliateFeeBasis`, `integrator`,
+ `thorname`, `apiHost`, `apiUrl`, `widgetUrl`, `partnerUrl`, `referralId`,
+ `feePercentage`, `feeReceiveAddress`, `host`, `port`, and the like.
+- **`keys.json`** holds all credential material: `apiKey`, `nowNodesApiKey`,
+ `evmScanApiKey`, `ninerealmsClientId`, `thorswapApiKey`, `privateKeyB64`,
+ `hmacUser`, `jwtTokenProvider`, `clientSecret`, `heliusApiKey`,
+ `alchemyApiKey`, `blockfrostProjectId`, `glifApiKey`, `subscanApiKey`,
+ `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`,
+ `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`).
+
+## Merge semantics (`resolvePluginMaps` / `nestGlobalKeys`)
+
+`src/configKeysMerge.ts` combines config enablement with keys secrets into the
+resolved `pluginMaps`, and normalizes partner secrets under `globalKeys`:
+
+1. **`CONFIG` top-level fields** stay on `CONFIG` only. They are never overwritten
+ 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
+ `bakedKeys` with keys winning on collision.
+3. **Partner `globalKeys`**: flat on-disk partner fields and any nested
+ `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 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.
+ 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.
+
+Objects are merged field-by-field; arrays and primitives replace wholesale;
+`undefined` on either side yields the other side.
+
+## How the legacy file was split (`scripts/splitEnvJson.ts`)
+
+`splitEnv` converts a flat legacy `env.json` object into `{ config, keys }`:
+
+- `CURRENCY_INIT_MAP` / `SWAP_INIT_MAP` map each `*_INIT` field name to its real
+ edge-core plugin ID (e.g. `THORCHAIN_INIT` → `thorchainrune` for currency and
+ `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` → `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.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`.
+- `WYRE_CLIENT_INIT` and any remaining unmapped `*_INIT` fields are dropped.
+
+This same function is what the golden test uses to synthesize `config`/`keys`
+in memory from the historical `env.json`, and what
+`scripts/splitEnvJson.ts` (`npm run split-env-json`) uses to write the real
+files on disk, guaranteeing the split is value-preserving.
+
+## Consumers
+
+Every reader was re-pointed from the old flat `ENV` / `*_INIT` /
+`PLUGIN_API_KEYS` / `RAMP_PLUGIN_INITS` surface to the matching accessor:
+
+- Import **`CONFIG`** for non-secret settings (`APP_CONFIG`, `DEBUG_*`,
+ `LOG_SERVER`, `POSTHOG_API_HOST`, `YOLO_*`, feature flags, …).
+- Import **`KEYS`** for top-level secrets (`EDGE_API_KEY`, `EDGE_API_SECRET`,
+ `SENTRY_*`, `POSTHOG_API_KEY`, …).
+- Import **`globalKeys`** for partner secrets (`COINGECKO_API_KEY`, `KILN_*`,
+ `STAKEKIT_API_KEY`, …).
+- Import **`pluginMaps`** for resolved plugin inits:
+ - `src/util/corePlugins.ts`: maps each edge-core plugin ID to
+ `pluginMaps.corePlugins[id]` / `pluginMaps.swapPlugins[id]`, preserving the
+ existing `true`/`false` hardcodes. Note `thorchainrune` and
+ `thorchainrunestagenet` both read `corePlugins.thorchainrune`.
+ - `src/hooks/useRampPlugins.ts`: `pluginMaps.rampPlugins[pluginId]`.
+ - `src/plugins/gui/util/initializeProviders.ts`, `fetchRevolut.ts`, and the
+ 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` (`globalKeys.WALLETCONNECT_PROJECT_ID`),
+ `tracking.ts` (`KEYS.POSTHOG_API_KEY` + `CONFIG.POSTHOG_API_HOST`).
... diff truncated: showing 800 of 11396 linesYou 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 deb4b38. Configure here.
413a848 to
e97d559
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. 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.
e97d559 to
efa8813
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 storage/rotation, and cold-start gating before core plugins load—failures can break login, partner plugins, or ship wrong credentials.
Overview
Replaces the monolithic
env.json/ENVsetup withconfig.json,keys.json, and build-onlyedgeKey.json, plus separateCONFIG,KEYS/globalKeys, andpluginMapsaccessors. Plugin wiring moves from flat*_INITfields to plugin-id maps (corePlugins,swapPlugins,guiApiKeys,rampPlugins).Adds a native Edge API HMAC signer (XOR-split C shards from
edgeKey.json, Android JNI + iOS bridge, Gradle/Xcode generate tasks) so login-server and signedGET /v1/infoRollup/:appIdrequests can useapiSignerinstead of bundlingEDGE_API_SECRET. JSKEYS.EDGE_API_*remains the fallback when native signing is unavailable.Boot now resolves secrets asynchronously (DeviceSettings
keysCache→ signed remoteappKeys→ bakedkeys.json) beforeEdgeCoreManagerbuilds plugins; deploy andpreparepaths, migration CLIs, and docs are retargeted accordingly.Reviewed by Cursor Bugbot for commit efa8813. Bugbot is set up for automated code reviews on this repo. Configure here.