Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased (develop)

- added: App/device attestation for gated info-server requests
- added: Verbose logging for exchange rate queries: the request body, resolved/rate-less counts, and errors are captured when the Verbose Logging setting is enabled.
- added: Exchange-rate cache snapshot in the support log output, plus a `rates-cache-replay` script that re-runs those queries against the rates server and reports the result for each pair.
- added: "-m" tag on the version number in the Help scene for Maestro test builds
Expand Down Expand Up @@ -42,6 +43,7 @@

## 4.49.1 (2026-07-14)


- fixed: iOS crashes on older devices
- fixed: TRON token syncing

Expand Down
213 changes: 213 additions & 0 deletions android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package co.edgesecure.app

import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import android.util.Base64
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 java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.spec.ECGenParameterSpec

/**
* Native bridge for Android Keystore hardware key attestation (device-level
* attestation). Generates a fresh EC key in the AndroidKeyStore with the given
* attestation challenge and returns the resulting X.509 certificate chain, which
* the info server verifies against Google's hardware attestation roots.
*
* This stays fully open source: it uses only the platform KeyStore APIs, not the
* closed-source Play Integrity / SafetyNet SDKs.
*/
class EdgeAttestationModule(
reactContext: ReactApplicationContext
) : ReactContextBaseJavaModule(reactContext) {
companion object {
// Stable alias: the key is enrolled once via attestation and then reused
// to sign challenges (see signChallenge). Cleared only on clearKey or
// when re-enrollment is needed.
private const val KEY_ALIAS = "edge_attestation_key"

// Serializes all AndroidKeyStore access to KEY_ALIAS. getAttestation,
// signChallenge and clearKey each mutate/read the single shared alias; the
// JS engine's watchdog can release its in-flight lock and start a new
// handshake while an older native Thread is still running, so without this
// lock two overlapping getAttestation calls could delete/regenerate the key
// out from under each other and return cross-wired certificate chains.
private val keystoreLock = Any()
}

override fun getName(): String = "EdgeAttestation"

@ReactMethod
fun isSupported(promise: Promise) {
// Key attestation (setAttestationChallenge) requires API 24+.
promise.resolve(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
}

@ReactMethod
fun getAttestation(
challenge: String,
promise: Promise
) {
// Key generation can be slow; run off the JS thread. Serialize all Keystore
// access so an overlapping handshake cannot corrupt the shared alias.
Thread {
synchronized(keystoreLock) {
val keyAlias = KEY_ALIAS
try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
promise.reject(
"unsupported",
"Key attestation requires Android 7.0 (API 24) or later"
)
return@synchronized
}

// getAttestation is only called when (re-)enrollment is required, so
// a leftover key under the stable alias is stale; delete it first.
try {
val existing = KeyStore.getInstance("AndroidKeyStore")
existing.load(null)
existing.deleteEntry(keyAlias)
} catch (ignored: Exception) {
// Best effort.
}

val generator =
KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_EC,
"AndroidKeyStore"
)
// The challenge's UTF-8 bytes are bound into the attestation
// extension; the server compares them against the challenge it
// issued. Builds the spec, optionally requesting a StrongBox-backed
// key. A StrongBox (dedicated secure element, e.g. Pixel Titan M) key
// attests at `attestationSecurityLevel = strongBox`, which the info
// server maps to `secureElement`; a plain TEE key attests as
// `trustedEnvironment` -> `hardware`.
fun buildSpec(strongBox: Boolean): KeyGenParameterSpec {
val builder =
KeyGenParameterSpec
.Builder(
keyAlias,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
).setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setAttestationChallenge(challenge.toByteArray(Charsets.UTF_8))
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setIsStrongBoxBacked(true)
}
return builder.build()
}

// Prefer the highest assurance (StrongBox / secure element) and fall
// back to the TEE only when this device has no StrongBox.
val wantStrongBox = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
try {
generator.initialize(buildSpec(wantStrongBox))
generator.generateKeyPair()
} catch (e: StrongBoxUnavailableException) {
// No StrongBox on this device; fall back to the TEE (hardware).
generator.initialize(buildSpec(false))
generator.generateKeyPair()
}

val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val chain = keyStore.getCertificateChain(keyAlias)
if (chain == null || chain.isEmpty()) {
// Throw so the catch below deletes the half-created key rather than
// leaving it enrolled with an attestation never returned to JS.
throw IllegalStateException("Empty attestation certificate chain")
}

val certChain = Arguments.createArray()
for (cert in chain) {
certChain.pushString(
Base64.encodeToString(cert.encoded, Base64.NO_WRAP)
)
}

val result = Arguments.createMap()
result.putArray("certChain", certChain)
promise.resolve(result)
} catch (e: Exception) {
// A failed enrollment should not leave a half-created key behind. The
// key is intentionally NOT deleted on success: it survives so
// signChallenge can reuse it for token refreshes.
try {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
keyStore.deleteEntry(keyAlias)
} catch (ignored: Exception) {
// Best effort cleanup.
}
promise.reject("attestation_error", e.message, e)
}
}
}.start()
}

@ReactMethod
fun signChallenge(
challenge: String,
promise: Promise
) {
Thread {
synchronized(keystoreLock) {
try {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val entry =
keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry
if (entry == null) {
promise.reject("noKey", "No attested key is stored")
return@synchronized
}
// keyId = base64url(SHA-256(leaf SPKI)), matching the server's
// derivation.
val spki = keyStore.getCertificate(KEY_ALIAS).publicKey.encoded
val keyId =
Base64.encodeToString(
java.security.MessageDigest
.getInstance("SHA-256")
.digest(spki),
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
)
val signer = java.security.Signature.getInstance("SHA256withECDSA")
signer.initSign(entry.privateKey)
signer.update(challenge.toByteArray(Charsets.UTF_8))
val signature = Base64.encodeToString(signer.sign(), Base64.NO_WRAP)

val result = Arguments.createMap()
result.putString("keyId", keyId)
result.putString("signature", signature)
promise.resolve(result)
} catch (e: Exception) {
promise.reject("signChallenge", e.message, e)
}
}
}.start()
}

@ReactMethod
fun clearKey(promise: Promise) {
// Best-effort: force re-enrollment when the server rejects an assertion
// (unknown key, revoked serial, disabled app). Resolve regardless.
try {
synchronized(keystoreLock) {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
keyStore.deleteEntry(KEY_ALIAS)
}
} catch (ignored: Exception) {
// Best effort.
}
promise.resolve(null)
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 EdgeAttestation native module with React Native. */
class EdgeAttestationPackage : ReactPackage {
override fun createNativeModules(
reactContext: ReactApplicationContext
): List<NativeModule> = listOf(EdgeAttestationModule(reactContext))

override fun createViewManagers(
reactContext: ReactApplicationContext
): List<ViewManager<*, *>> = emptyList()
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ class MainApplication :
// Packages that cannot be autolinked yet can be added manually here, for
// example:
// packages.add(new MyReactNativePackage());
return PackageList(this).packages
val packages = PackageList(this).packages
packages.add(EdgeAttestationPackage())
return packages
}

override fun getJSMainModuleName(): String = "index"
Expand Down
Loading
Loading