Skip to content

Commit 4e9a177

Browse files
Add app/device attestation for gated info-server requests
Introduce a background attestation engine (src/util/attestation.ts) that runs the platform attestation handshake (Apple App Attest / Android Keystore attestation) at app boot and caches a short-lived token, self-rescheduling to refresh it two minutes before expiry. getAttestationToken() returns the cached token immediately, or waits up to three seconds for the initial handshake before returning undefined. fetchInfo (util/network.ts) carries no attestation logic; the attestation-gated plugins (Simplex/Banxa jwtSign and createHmac) call getAttestationToken() and attach the x-attestation-token header themselves only when a token is available, otherwise letting the info server decide. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 133532c commit 4e9a177

20 files changed

Lines changed: 826 additions & 18 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased (develop)
44

5+
- added: App/device attestation for gated info-server requests
6+
57
## 4.50.0 (staging)
68

79
- added: Changelly swap provider
@@ -17,6 +19,7 @@
1719

1820
## 4.49.1 (2026-07-09)
1921

22+
2023
- fixed: iOS crashes on older devices
2124
- fixed: TRON token syncing
2225

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package co.edgesecure.app
2+
3+
import android.os.Build
4+
import android.security.keystore.KeyGenParameterSpec
5+
import android.security.keystore.KeyProperties
6+
import android.security.keystore.StrongBoxUnavailableException
7+
import android.util.Base64
8+
import com.facebook.react.bridge.Arguments
9+
import com.facebook.react.bridge.Promise
10+
import com.facebook.react.bridge.ReactApplicationContext
11+
import com.facebook.react.bridge.ReactContextBaseJavaModule
12+
import com.facebook.react.bridge.ReactMethod
13+
import java.security.KeyPairGenerator
14+
import java.security.KeyStore
15+
import java.security.spec.ECGenParameterSpec
16+
17+
/**
18+
* Native bridge for Android Keystore hardware key attestation (device-level
19+
* attestation). Generates a fresh EC key in the AndroidKeyStore with the given
20+
* attestation challenge and returns the resulting X.509 certificate chain, which
21+
* the info server verifies against Google's hardware attestation roots.
22+
*
23+
* This stays fully open source: it uses only the platform KeyStore APIs, not the
24+
* closed-source Play Integrity / SafetyNet SDKs.
25+
*/
26+
class EdgeAttestationModule(
27+
reactContext: ReactApplicationContext
28+
) : ReactContextBaseJavaModule(reactContext) {
29+
override fun getName(): String = "EdgeAttestation"
30+
31+
@ReactMethod
32+
fun isSupported(promise: Promise) {
33+
// Key attestation (setAttestationChallenge) requires API 24+.
34+
promise.resolve(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
35+
}
36+
37+
@ReactMethod
38+
fun getAttestation(
39+
challenge: String,
40+
promise: Promise
41+
) {
42+
// Key generation can be slow; run off the JS thread.
43+
Thread {
44+
val keyAlias = "edge_attestation_" + System.currentTimeMillis()
45+
try {
46+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
47+
promise.reject(
48+
"unsupported",
49+
"Key attestation requires Android 7.0 (API 24) or later"
50+
)
51+
return@Thread
52+
}
53+
54+
val generator =
55+
KeyPairGenerator.getInstance(
56+
KeyProperties.KEY_ALGORITHM_EC,
57+
"AndroidKeyStore"
58+
)
59+
// The challenge's UTF-8 bytes are bound into the attestation extension;
60+
// the server compares them against the challenge it issued.
61+
// Builds the spec, optionally requesting a StrongBox-backed key. A
62+
// StrongBox (dedicated secure element, e.g. Pixel Titan M) key attests
63+
// at `attestationSecurityLevel = strongBox`, which the info server maps
64+
// to `secureElement`; a plain TEE key attests as `trustedEnvironment`
65+
// -> `hardware`.
66+
fun buildSpec(strongBox: Boolean): KeyGenParameterSpec {
67+
val builder =
68+
KeyGenParameterSpec
69+
.Builder(
70+
keyAlias,
71+
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
72+
).setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
73+
.setDigests(KeyProperties.DIGEST_SHA256)
74+
.setAttestationChallenge(challenge.toByteArray(Charsets.UTF_8))
75+
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
76+
builder.setIsStrongBoxBacked(true)
77+
}
78+
return builder.build()
79+
}
80+
81+
// Prefer the highest assurance (StrongBox / secure element) and fall
82+
// back to the TEE only when this device has no StrongBox.
83+
val wantStrongBox = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
84+
try {
85+
generator.initialize(buildSpec(wantStrongBox))
86+
generator.generateKeyPair()
87+
} catch (e: StrongBoxUnavailableException) {
88+
// No StrongBox on this device; fall back to the TEE (hardware).
89+
generator.initialize(buildSpec(false))
90+
generator.generateKeyPair()
91+
}
92+
93+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
94+
keyStore.load(null)
95+
val chain = keyStore.getCertificateChain(keyAlias)
96+
if (chain == null || chain.isEmpty()) {
97+
promise.reject("attestation_error", "Empty attestation certificate chain")
98+
return@Thread
99+
}
100+
101+
val certChain = Arguments.createArray()
102+
for (cert in chain) {
103+
certChain.pushString(Base64.encodeToString(cert.encoded, Base64.NO_WRAP))
104+
}
105+
106+
val result = Arguments.createMap()
107+
result.putArray("certChain", certChain)
108+
promise.resolve(result)
109+
} catch (e: Exception) {
110+
promise.reject("attestation_error", e.message, e)
111+
} finally {
112+
// Don't accumulate one-shot attestation keys in the keystore.
113+
try {
114+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
115+
keyStore.load(null)
116+
keyStore.deleteEntry(keyAlias)
117+
} catch (ignored: Exception) {
118+
// Best effort cleanup.
119+
}
120+
}
121+
}.start()
122+
}
123+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package co.edgesecure.app
2+
3+
import com.facebook.react.ReactPackage
4+
import com.facebook.react.bridge.NativeModule
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.uimanager.ViewManager
7+
8+
/** Registers the EdgeAttestation native module with React Native. */
9+
class EdgeAttestationPackage : ReactPackage {
10+
override fun createNativeModules(
11+
reactContext: ReactApplicationContext
12+
): List<NativeModule> = listOf(EdgeAttestationModule(reactContext))
13+
14+
override fun createViewManagers(
15+
reactContext: ReactApplicationContext
16+
): List<ViewManager<*, *>> = emptyList()
17+
}

android/app/src/main/java/co/edgesecure/app/MainApplication.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ class MainApplication :
3434
// Packages that cannot be autolinked yet can be added manually here, for
3535
// example:
3636
// packages.add(new MyReactNativePackage());
37-
return PackageList(this).packages
37+
val packages = PackageList(this).packages
38+
packages.add(EdgeAttestationPackage())
39+
return packages
3840
}
3941

4042
override fun getJSMainModuleName(): String = "index"

docs/APP_ATTESTATION.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# App Attestation (GUI client)
2+
3+
Post-implementation architecture for **application-level** attestation in **edge-react-gui**. The info server issues challenges, verifies platform attestations, mints tokens, and gates signing endpoints; see **edge-info-server** `docs/APP_ATTESTATION.md` for the server side.
4+
5+
## Goals
6+
7+
- On supported physical devices, prove this install is the genuine Edge app (`co.edgesecure.app`).
8+
- Keep a short-lived attestation JWT cached in JS and attach it to info-server signing calls that need it.
9+
- Never block app boot: attestation is best-effort. If unsupported, offline, or slow, gated plugins simply omit the header and the **info server** decides (403 when that provider requires attestation).
10+
11+
## High-level flow
12+
13+
```mermaid
14+
sequenceDiagram
15+
participant Boot as app.ts
16+
participant Net as initInfoServer
17+
participant Eng as util/attestation.ts
18+
participant Nat as EdgeAttestation native
19+
participant Info as edge-info-server
20+
participant Plugin as simplex / banxa plugin
21+
22+
Boot->>Net: initInfoServer()
23+
Net->>Eng: initAttestation()
24+
Eng->>Info: GET /v1/attest/challenge
25+
Info-->>Eng: challenge
26+
Eng->>Nat: getAttestation(challenge)
27+
Nat-->>Eng: keyId+attestation or certChain
28+
Eng->>Info: POST /v1/attest/apple|android
29+
Info-->>Eng: { token, expires, assuranceLevel }
30+
Note over Eng: cache token; refresh 2 min before expiry
31+
32+
Plugin->>Eng: getAttestationToken()
33+
Eng-->>Plugin: JWT or undefined
34+
Plugin->>Info: POST jwtSign|createHmac<br/>x-attestation-token? (only if JWT)
35+
```
36+
37+
Server endpoints and Couch gating policy: **edge-info-server** `docs/APP_ATTESTATION.md`.
38+
39+
## Boot wiring
40+
41+
1. [`src/app.ts`](../src/app.ts) calls `initInfoServer()` during startup.
42+
2. [`src/util/network.ts`](../src/util/network.ts) `initInfoServer()` pings info servers and calls **`initAttestation()`** (does not await the handshake).
43+
3. [`src/util/attestation.ts`](../src/util/attestation.ts) runs the background engine.
44+
45+
**`fetchInfo` has no attestation logic.** It only fans out to `INFO_SERVER` / production info hosts. Plugins attach `x-attestation-token` themselves.
46+
47+
### Local testing target
48+
49+
Optional `env.json` / `ENV.INFO_SERVER` (see `envConfig.ts`) overrides the default `https://info1.edge.app` / `info2.edge.app` list so devices can hit a LAN info server (e.g. `["http://10.x.x.x:8008"]`). Android may also use `adb reverse tcp:8008 tcp:8008`.
50+
51+
## JS attestation engine (`src/util/attestation.ts`)
52+
53+
| API | Behavior |
54+
| --- | --- |
55+
| `initAttestation()` | If no live token, start a non-blocking handshake |
56+
| `getAttestationToken()` | Return cached JWT if live; else start handshake, wait ≤ **3s**, then return JWT or `undefined` |
57+
| Refresh | On success, `setTimeout` to re-handshake **2 minutes before** `expires` |
58+
| Clock skew | Token treated expired within **5s** of `expires` |
59+
60+
Handshake steps:
61+
62+
1. `GET v1/attest/challenge` via `fetchInfo`
63+
2. Native `EdgeAttestation.getAttestation(challenge)`
64+
3. `POST v1/attest/apple` (iOS) or `v1/attest/android` (Android)
65+
4. Cache `{ token, expires }` from the JSON body (`expires` is epoch **milliseconds**)
66+
67+
Failures are logged (`console.warn`) and never thrown to boot.
68+
69+
## Native modules
70+
71+
### iOS — App Attest
72+
73+
| File | Role |
74+
| --- | --- |
75+
| `ios/edge/EdgeAttestation.swift` | `DCAppAttestService`: `isSupported`, `generateKey` + `attestKey` |
76+
| `ios/edge/EdgeAttestation.m` | React Native bridge |
77+
| `ios/edge/edge.entitlements` | `com.apple.developer.devicecheck.appattest-environment` = **production** (all configs) |
78+
| `scripts/addAttestationIosFiles.js` | Adds Swift/ObjC sources to the Xcode project |
79+
80+
**Key lifecycle:** a **fresh** App Attest key is generated on **every** handshake. An App Attest key can only be attested once; Keychain persist + assertion refresh are not implemented yet.
81+
82+
Returns `{ keyId, attestation (base64 CBOR), bundleId }`. Simulator: `isSupported` is false → no token.
83+
84+
Production entitlement → info server maps AAGUID to **`secureElement`**.
85+
86+
### Android — Keystore attestation
87+
88+
| File | Role |
89+
| --- | --- |
90+
| `android/.../EdgeAttestationModule.kt` | Keystore EC key with `setAttestationChallenge` |
91+
| `android/.../EdgeAttestationPackage.kt` | RN package |
92+
| `MainApplication.kt` | Registers the package |
93+
94+
**StrongBox first**, TEE fallback on `StrongBoxUnavailableException`. Returns `{ certChain: base64 DER[] }`. Requires API 24+. Uses only platform Keystore APIs (**no Play Integrity**).
95+
96+
Info server maps StrongBox → `secureElement`, TEE → `hardware`, debug-keystore digest → `debug`.
97+
98+
## Where tokens are attached
99+
100+
Call sites await `getAttestationToken()` and set the header only when non-null:
101+
102+
| File | Info-server path |
103+
| --- | --- |
104+
| `src/plugins/gui/providers/simplexProvider.ts` | `v1/jwtSign/simplex` (quote + approve) |
105+
| `src/plugins/ramps/simplex/simplexRampPlugin.ts` | `v1/jwtSign/...` |
106+
| `src/plugins/gui/providers/banxaProvider.ts` | `v1/createHmac/...` |
107+
| `src/plugins/ramps/banxa/banxaRampPlugin.ts` | `v1/createHmac/...` |
108+
109+
Pattern:
110+
111+
```ts
112+
const attestationToken = await getAttestationToken()
113+
await fetchInfo(`v1/createHmac/${hmacUser}`, {
114+
method: 'POST',
115+
headers: {
116+
'Content-Type': 'application/json',
117+
...(attestationToken != null
118+
? { 'x-attestation-token': attestationToken }
119+
: {})
120+
},
121+
body
122+
})
123+
```
124+
125+
Whether a missing/invalid token fails the quote is decided by the info server’s Couch `attestationLevel` for that provider (string / `null` = ungated; e.g. `"hardware"` = required). See **edge-info-server** `docs/APP_ATTESTATION.md`.
126+
127+
## Device requirements
128+
129+
| Platform | Notes |
130+
| --- | --- |
131+
| iOS | Physical device required (no Secure Enclave on simulator). Network needed for Apple’s attest servers. |
132+
| Android | Emulator can exercise the API path at `debug`/`software` assurance; production-gated providers need a real TEE/StrongBox device (e.g. Pixel 10 → `secureElement`). |
133+
134+
## Source map
135+
136+
| Path | Role |
137+
| --- | --- |
138+
| `src/util/attestation.ts` | Background engine |
139+
| `src/util/network.ts` | `fetchInfo`, `initInfoServer``initAttestation` |
140+
| `src/app.ts` | Boot → `initInfoServer` |
141+
| `ios/edge/EdgeAttestation.*` | App Attest native |
142+
| `android/.../EdgeAttestation*.kt` | Keystore native |
143+
| `scripts/extractSigningCert.ts` | Helper for Android allow-list digests |
144+
145+
## Related server
146+
147+
The GUI depends on these info-server endpoints:
148+
149+
- `GET /v1/attest/challenge`
150+
- `POST /v1/attest/apple` / `POST /v1/attest/android`
151+
- `POST /v1/jwtSign/:provider` / `POST /v1/createHmac/:provider` (optional `x-attestation-token`)
152+
- (optional) `GET /v1/attest/jwks` for other services verifying tokens
153+
154+
Full contracts, Couch allow-lists, Redis challenges, and per-provider gating: **edge-info-server** `docs/APP_ATTESTATION.md`.

eslint.config.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,6 @@ export default [
458458
'src/plugins/gui/providers/mtpelerinProvider.ts',
459459

460460
'src/plugins/gui/providers/revolutProvider.ts',
461-
'src/plugins/gui/providers/simplexProvider.ts',
462461
'src/plugins/gui/RewardsCardPlugin.tsx',
463462

464463
'src/plugins/gui/util/fetchRevolut.ts',

ios/edge.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
04DBAACE71E94A3B9BCAF10A /* EdgeAttestation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6252FDC1E314D61A7E315B7 /* EdgeAttestation.swift */; };
1011
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
1112
3D18A8FA2A5333DC00F3B19B /* audio_received.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 3D18A8F82A5333DC00F3B19B /* audio_received.mp3 */; };
1213
3D18A8FB2A5333DC00F3B19B /* audio_sent.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 3D18A8F92A5333DC00F3B19B /* audio_sent.mp3 */; };
@@ -40,6 +41,7 @@
4041
812B284944A10A722B22763D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08ADAD14914ABC9FD7D54456 /* ExpoModulesProvider.swift */; };
4142
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
4243
BF115CD26A29F1C032E30289 /* Pods_edge.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E261C56FB78E202F218C2DCA /* Pods_edge.framework */; };
44+
E180252EBEC449FE9A1DFAE6 /* EdgeAttestation.m in Sources */ = {isa = PBXBuildFile; fileRef = 79E2D7E4637343A7B1DCB004 /* EdgeAttestation.m */; };
4345
E469AC702DC43791006A2530 /* AdServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E469AC6F2DC43791006A2530 /* AdServices.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
4446
/* End PBXBuildFile section */
4547

@@ -81,7 +83,9 @@
8183
3DB299A52BCEF2A600D867B0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = edge/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
8284
5709B34CF0A7D63546082F79 /* Pods-edge.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-edge.release.xcconfig"; path = "Target Support Files/Pods-edge/Pods-edge.release.xcconfig"; sourceTree = "<group>"; };
8385
5B7EB9410499542E8C5724F5 /* Pods-edge-edgeTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-edge-edgeTests.debug.xcconfig"; path = "Target Support Files/Pods-edge-edgeTests/Pods-edge-edgeTests.debug.xcconfig"; sourceTree = "<group>"; };
86+
79E2D7E4637343A7B1DCB004 /* EdgeAttestation.m */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.objc; name = EdgeAttestation.m; path = edge/EdgeAttestation.m; sourceTree = "<group>"; };
8487
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = edge/LaunchScreen.storyboard; sourceTree = "<group>"; };
88+
A6252FDC1E314D61A7E315B7 /* EdgeAttestation.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = EdgeAttestation.swift; path = edge/EdgeAttestation.swift; sourceTree = "<group>"; };
8589
E261C56FB78E202F218C2DCA /* Pods_edge.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_edge.framework; sourceTree = BUILT_PRODUCTS_DIR; };
8690
E469AC6F2DC43791006A2530 /* AdServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdServices.framework; path = System/Library/Frameworks/AdServices.framework; sourceTree = SDKROOT; };
8791
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
@@ -110,6 +114,8 @@
110114
13B07FB61A68108700A75B9A /* Info.plist */,
111115
3DB299A52BCEF2A600D867B0 /* PrivacyInfo.xcprivacy */,
112116
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
117+
A6252FDC1E314D61A7E315B7 /* EdgeAttestation.swift */,
118+
79E2D7E4637343A7B1DCB004 /* EdgeAttestation.m */,
113119
);
114120
name = edge;
115121
sourceTree = "<group>";
@@ -460,6 +466,8 @@
460466
3D88EE2D2E3C3BE80086BA9D /* AppDelegate.swift in Sources */,
461467
3D5BD9862A4CEFB900590088 /* EdgeCore.swift in Sources */,
462468
812B284944A10A722B22763D /* ExpoModulesProvider.swift in Sources */,
469+
04DBAACE71E94A3B9BCAF10A /* EdgeAttestation.swift in Sources */,
470+
E180252EBEC449FE9A1DFAE6 /* EdgeAttestation.m in Sources */,
463471
);
464472
runOnlyForDeploymentPostprocessing = 0;
465473
};

ios/edge/EdgeAttestation.m

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#import <React/RCTBridgeModule.h>
2+
3+
// Objective-C bridge that exposes the Swift `EdgeAttestation` class to the
4+
// React Native (old architecture) bridge.
5+
@interface RCT_EXTERN_MODULE (EdgeAttestation, NSObject)
6+
7+
RCT_EXTERN_METHOD(isSupported
8+
: (RCTPromiseResolveBlock)resolve
9+
rejecter:(RCTPromiseRejectBlock)reject)
10+
11+
RCT_EXTERN_METHOD(getAttestation
12+
: (NSString *)challenge
13+
resolver:(RCTPromiseResolveBlock)resolve
14+
rejecter:(RCTPromiseRejectBlock)reject)
15+
16+
@end

0 commit comments

Comments
 (0)