Skip to content

Commit 1c7b261

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 3b9b95d commit 1c7b261

21 files changed

Lines changed: 1370 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
@@ -18,6 +20,7 @@
1820

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

23+
2124
- fixed: iOS crashes on older devices
2225
- fixed: TRON token syncing
2326

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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+
companion object {
30+
// Stable alias: the key is enrolled once via attestation and then reused
31+
// to sign challenges (see signChallenge). Cleared only on clearKey or
32+
// when re-enrollment is needed.
33+
private const val KEY_ALIAS = "edge_attestation_key"
34+
}
35+
36+
override fun getName(): String = "EdgeAttestation"
37+
38+
@ReactMethod
39+
fun isSupported(promise: Promise) {
40+
// Key attestation (setAttestationChallenge) requires API 24+.
41+
promise.resolve(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
42+
}
43+
44+
@ReactMethod
45+
fun getAttestation(
46+
challenge: String,
47+
promise: Promise
48+
) {
49+
// Key generation can be slow; run off the JS thread.
50+
Thread {
51+
val keyAlias = KEY_ALIAS
52+
try {
53+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
54+
promise.reject(
55+
"unsupported",
56+
"Key attestation requires Android 7.0 (API 24) or later"
57+
)
58+
return@Thread
59+
}
60+
61+
// getAttestation is only called when (re-)enrollment is required, so a
62+
// leftover key under the stable alias is stale; delete it first.
63+
try {
64+
val existing = KeyStore.getInstance("AndroidKeyStore")
65+
existing.load(null)
66+
existing.deleteEntry(keyAlias)
67+
} catch (ignored: Exception) {
68+
// Best effort.
69+
}
70+
71+
val generator =
72+
KeyPairGenerator.getInstance(
73+
KeyProperties.KEY_ALGORITHM_EC,
74+
"AndroidKeyStore"
75+
)
76+
// The challenge's UTF-8 bytes are bound into the attestation extension;
77+
// the server compares them against the challenge it issued.
78+
// Builds the spec, optionally requesting a StrongBox-backed key. A
79+
// StrongBox (dedicated secure element, e.g. Pixel Titan M) key attests
80+
// at `attestationSecurityLevel = strongBox`, which the info server maps
81+
// to `secureElement`; a plain TEE key attests as `trustedEnvironment`
82+
// -> `hardware`.
83+
fun buildSpec(strongBox: Boolean): KeyGenParameterSpec {
84+
val builder =
85+
KeyGenParameterSpec
86+
.Builder(
87+
keyAlias,
88+
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
89+
).setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
90+
.setDigests(KeyProperties.DIGEST_SHA256)
91+
.setAttestationChallenge(challenge.toByteArray(Charsets.UTF_8))
92+
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
93+
builder.setIsStrongBoxBacked(true)
94+
}
95+
return builder.build()
96+
}
97+
98+
// Prefer the highest assurance (StrongBox / secure element) and fall
99+
// back to the TEE only when this device has no StrongBox.
100+
val wantStrongBox = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
101+
try {
102+
generator.initialize(buildSpec(wantStrongBox))
103+
generator.generateKeyPair()
104+
} catch (e: StrongBoxUnavailableException) {
105+
// No StrongBox on this device; fall back to the TEE (hardware).
106+
generator.initialize(buildSpec(false))
107+
generator.generateKeyPair()
108+
}
109+
110+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
111+
keyStore.load(null)
112+
val chain = keyStore.getCertificateChain(keyAlias)
113+
if (chain == null || chain.isEmpty()) {
114+
promise.reject("attestation_error", "Empty attestation certificate chain")
115+
return@Thread
116+
}
117+
118+
val certChain = Arguments.createArray()
119+
for (cert in chain) {
120+
certChain.pushString(Base64.encodeToString(cert.encoded, Base64.NO_WRAP))
121+
}
122+
123+
val result = Arguments.createMap()
124+
result.putArray("certChain", certChain)
125+
promise.resolve(result)
126+
} catch (e: Exception) {
127+
// A failed enrollment should not leave a half-created key behind. The
128+
// key is intentionally NOT deleted on success — it survives so
129+
// signChallenge can reuse it for token refreshes.
130+
try {
131+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
132+
keyStore.load(null)
133+
keyStore.deleteEntry(keyAlias)
134+
} catch (ignored: Exception) {
135+
// Best effort cleanup.
136+
}
137+
promise.reject("attestation_error", e.message, e)
138+
}
139+
}.start()
140+
}
141+
142+
@ReactMethod
143+
fun signChallenge(
144+
challenge: String,
145+
promise: Promise
146+
) {
147+
Thread {
148+
try {
149+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
150+
keyStore.load(null)
151+
val entry =
152+
keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry
153+
if (entry == null) {
154+
promise.reject("noKey", "No attested key is stored")
155+
return@Thread
156+
}
157+
// keyId = base64url(SHA-256(leaf SPKI)), matching the server's
158+
// derivation.
159+
val spki = keyStore.getCertificate(KEY_ALIAS).publicKey.encoded
160+
val keyId =
161+
Base64.encodeToString(
162+
java.security.MessageDigest
163+
.getInstance("SHA-256")
164+
.digest(spki),
165+
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
166+
)
167+
val signer = java.security.Signature.getInstance("SHA256withECDSA")
168+
signer.initSign(entry.privateKey)
169+
signer.update(challenge.toByteArray(Charsets.UTF_8))
170+
val signature = Base64.encodeToString(signer.sign(), Base64.NO_WRAP)
171+
172+
val result = Arguments.createMap()
173+
result.putString("keyId", keyId)
174+
result.putString("signature", signature)
175+
promise.resolve(result)
176+
} catch (e: Exception) {
177+
promise.reject("signChallenge", e.message, e)
178+
}
179+
}.start()
180+
}
181+
182+
@ReactMethod
183+
fun clearKey(promise: Promise) {
184+
// Best-effort: force re-enrollment when the server rejects an assertion
185+
// (unknown key, revoked serial, disabled app). Resolve regardless.
186+
try {
187+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
188+
keyStore.load(null)
189+
keyStore.deleteEntry(KEY_ALIAS)
190+
} catch (ignored: Exception) {
191+
// Best effort.
192+
}
193+
promise.resolve(null)
194+
}
195+
}
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"

0 commit comments

Comments
 (0)