diff --git a/CHANGELOG.md b/CHANGELOG.md index f96ecff2b1f..847e119f430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- added: App/device attestation for gated info-server requests + ## 4.50.0 (staging) - added: Changelly swap provider @@ -20,6 +22,7 @@ ## 4.49.1 (2026-07-09) + - fixed: iOS crashes on older devices - fixed: TRON token syncing diff --git a/android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt b/android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt new file mode 100644 index 00000000000..7bca501cf23 --- /dev/null +++ b/android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt @@ -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) + } +} diff --git a/android/app/src/main/java/co/edgesecure/app/EdgeAttestationPackage.kt b/android/app/src/main/java/co/edgesecure/app/EdgeAttestationPackage.kt new file mode 100644 index 00000000000..25c5372a609 --- /dev/null +++ b/android/app/src/main/java/co/edgesecure/app/EdgeAttestationPackage.kt @@ -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 = listOf(EdgeAttestationModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): List> = 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 index a8b84011564..28e44718e40 100644 --- a/android/app/src/main/java/co/edgesecure/app/MainApplication.kt +++ b/android/app/src/main/java/co/edgesecure/app/MainApplication.kt @@ -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" diff --git a/docs/APP_ATTESTATION.md b/docs/APP_ATTESTATION.md new file mode 100644 index 00000000000..e7f1a48066e --- /dev/null +++ b/docs/APP_ATTESTATION.md @@ -0,0 +1,165 @@ +# App Attestation (GUI client) + +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. + +## Goals + +- On supported physical devices, prove this install is the genuine Edge app (`co.edgesecure.app`). +- Keep a short-lived attestation JWT cached in JS and attach it to info-server signing calls that need it. +- 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). + +## High-level flow + +```mermaid +sequenceDiagram + participant Boot as app.ts + participant Net as initInfoServer + participant Eng as util/attestation.ts + participant Nat as EdgeAttestation native + participant Info as edge-info-server + participant Plugin as simplex / banxa plugin + + Boot->>Net: initInfoServer() + Net->>Eng: initAttestation() + Eng->>Info: GET /v1/attest/challenge + Info-->>Eng: challenge + Note over Eng,Nat: Refresh path (after first enrollment) + Eng->>Nat: generateAssertion (iOS) / signChallenge (Android) + Nat-->>Eng: keyId + assertion|signature + Eng->>Info: POST /v1/attest/apple/assert or /v1/attest/android/assert + Info-->>Eng: { token, expires } + Note over Eng,Nat: First-run / rejection → full attestation + Eng->>Nat: getAttestation(challenge) + Nat-->>Eng: keyId+attestation or certChain + Eng->>Info: POST /v1/attest/apple|android + Info-->>Eng: { token, expires, assuranceLevel } + Note over Eng: cache token; refresh 2 min before expiry + + Plugin->>Eng: getAttestationToken() + Eng-->>Plugin: JWT or undefined + Plugin->>Info: POST jwtSign|createHmac
x-attestation-token? (only if JWT) +``` + +Server endpoints and Couch gating policy: **edge-info-server** `docs/APP_ATTESTATION.md`. + +## Boot wiring + +1. [`src/app.ts`](../src/app.ts) calls `initInfoServer()` during startup. +2. [`src/util/network.ts`](../src/util/network.ts) `initInfoServer()` pings info servers and calls **`initAttestation()`** (does not await the handshake). +3. [`src/util/attestation.ts`](../src/util/attestation.ts) runs the background engine. + +**`fetchInfo` has no attestation logic.** It only fans out to `INFO_SERVER` / production info hosts. Plugins attach `x-attestation-token` themselves. + +### Local testing target + +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`. + +## JS attestation engine (`src/util/attestation.ts`) + +| API | Behavior | +| --- | --- | +| `initAttestation()` | If no live token, start a non-blocking handshake | +| `getAttestationToken()` | Return cached JWT if live; else start handshake, wait ≤ **3s**, then return JWT or `undefined`; after a failed handshake, retries are suppressed for 60s and callers return `undefined` immediately | +| Refresh | On success, `setTimeout` to re-handshake **2 minutes before** `expires` | +| Clock skew | Token treated expired within **5s** of `expires` | +| Watchdog | A handshake pending after **90s** is treated as hung and the lock is released | + +Handshake steps: + +1. `GET v1/attest/challenge` via `fetchInfo` +2. Native `EdgeAttestation.getAttestation(challenge)` +3. `POST v1/attest/apple` (iOS) or `v1/attest/android` (Android) +4. Cache `{ token, expires }` from the JSON body (`expires` is epoch **milliseconds**); both `token` and `expires` are validated — a malformed response is treated as a failed handshake + +Failures are logged (`console.warn`) and never thrown to boot. + +## Native modules + +### iOS — App Attest + +| File | Role | +| --- | --- | +| `ios/edge/EdgeAttestation.swift` | `DCAppAttestService`: `isSupported`, `generateKey` + `attestKey`, `generateAssertion`, `clearKey` (Keychain key-id persistence) | +| `ios/edge/EdgeAttestation.m` | React Native bridge | +| `ios/edge/edge.entitlements` | `com.apple.developer.devicecheck.appattest-environment` = **production** (all configs) | +| `scripts/addAttestationIosFiles.js` | Adds Swift/ObjC sources to the Xcode project | + +**Key lifecycle:** a key is generated and attested **once per install** — the key id is persisted in the Keychain (`kSecClassGenericPassword`, service `co.edgesecure.app.appattest`). Subsequent handshakes refresh the token with `generateAssertion` (a local Secure Enclave signature, no Apple round trip and no new key). The key is discarded and re-attested when iOS reports `invalidKey` (reinstall/restore/device migration) or the server rejects an assertion. Reinstalls, device migration, and restores invalidate the key by design. + +Returns `{ keyId, attestation (base64 CBOR), bundleId }` (attest) or `{ keyId, assertion (base64 CBOR), bundleId }` (assert). Simulator: `isSupported` is false → no token. + +Production entitlement → info server maps AAGUID to **`secureElement`**. + +### Android — Keystore attestation + +| File | Role | +| --- | --- | +| `android/.../EdgeAttestationModule.kt` | Keystore EC key with `setAttestationChallenge`, plus `signChallenge` and `clearKey` | +| `android/.../EdgeAttestationPackage.kt` | RN package | +| `MainApplication.kt` | Registers the package | + +**StrongBox first**, TEE fallback on `StrongBoxUnavailableException`. Returns `{ certChain: base64 DER[] }`. Requires API 24+. Uses only platform Keystore APIs (**no Play Integrity**). + +**Key lifecycle:** the Keystore key is enrolled **once** under the stable `edge_attestation_key` alias and reused to sign challenges (`signChallenge` → `SHA256withECDSA` over the challenge; `keyId = base64url(SHA-256(leaf SPKI))`). It survives app updates, is destroyed on uninstall/factory reset (backup and restore do not transfer Keystore keys), and is cleared + re-enrolled when the server rejects an assertion (unknown key, revoked serial, disabled app). + +Info server maps StrongBox → `secureElement`, TEE → `hardware`, debug-keystore digest → `debug`. + +## Where tokens are attached + +Call sites await `getAttestationToken()` and set the header only when non-null: + +| File | Info-server path | +| --- | --- | +| `src/plugins/gui/providers/simplexProvider.ts` | `v1/jwtSign/simplex` (quote + approve) | +| `src/plugins/ramps/simplex/simplexRampPlugin.ts` | `v1/jwtSign/...` | +| `src/plugins/gui/providers/banxaProvider.ts` | `v1/createHmac/...` | +| `src/plugins/ramps/banxa/banxaRampPlugin.ts` | `v1/createHmac/...` | + +Pattern: + +```ts +const attestationToken = await getAttestationToken() +await fetchInfo(`v1/createHmac/${hmacUser}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(attestationToken != null + ? { 'x-attestation-token': attestationToken } + : {}) + }, + body +}) +``` + +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`. + +## Device requirements + +| Platform | Notes | +| --- | --- | +| iOS | Physical device required (no Secure Enclave on simulator). Network needed for Apple’s attest servers. | +| 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`). | + +## Source map + +| Path | Role | +| --- | --- | +| `src/util/attestation.ts` | Background engine | +| `src/util/network.ts` | `fetchInfo`, `initInfoServer` → `initAttestation` | +| `src/app.ts` | Boot → `initInfoServer` | +| `ios/edge/EdgeAttestation.*` | App Attest native | +| `android/.../EdgeAttestation*.kt` | Keystore native | +| `scripts/extractSigningCert.ts` | Helper for Android allow-list digests | + +## Related server + +The GUI depends on these info-server endpoints: + +- `GET /v1/attest/challenge` +- `POST /v1/attest/apple` / `POST /v1/attest/android` +- `POST /v1/attest/apple/assert` (iOS token refresh via assertion) +- `POST /v1/attest/android/assert` (Android token refresh via challenge signature) +- `POST /v1/jwtSign/:provider` / `POST /v1/createHmac/:provider` (optional `x-attestation-token`) +- (optional) `GET /v1/attest/jwks` for other services verifying tokens + +Full contracts, Couch allow-lists, Redis challenges, and per-provider gating: **edge-info-server** `docs/APP_ATTESTATION.md`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 988cc271d09..1abdd9017b0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -458,7 +458,6 @@ export default [ 'src/plugins/gui/providers/mtpelerinProvider.ts', 'src/plugins/gui/providers/revolutProvider.ts', - 'src/plugins/gui/providers/simplexProvider.ts', 'src/plugins/gui/RewardsCardPlugin.tsx', 'src/plugins/gui/util/fetchRevolut.ts', diff --git a/ios/edge.xcodeproj/project.pbxproj b/ios/edge.xcodeproj/project.pbxproj index 64007c6eb5b..72954219091 100644 --- a/ios/edge.xcodeproj/project.pbxproj +++ b/ios/edge.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 04DBAACE71E94A3B9BCAF10A /* EdgeAttestation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6252FDC1E314D61A7E315B7 /* EdgeAttestation.swift */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 3D18A8FA2A5333DC00F3B19B /* audio_received.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 3D18A8F82A5333DC00F3B19B /* audio_received.mp3 */; }; 3D18A8FB2A5333DC00F3B19B /* audio_sent.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 3D18A8F92A5333DC00F3B19B /* audio_sent.mp3 */; }; @@ -40,6 +41,7 @@ 812B284944A10A722B22763D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08ADAD14914ABC9FD7D54456 /* ExpoModulesProvider.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; BF115CD26A29F1C032E30289 /* Pods_edge.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E261C56FB78E202F218C2DCA /* Pods_edge.framework */; }; + E180252EBEC449FE9A1DFAE6 /* EdgeAttestation.m in Sources */ = {isa = PBXBuildFile; fileRef = 79E2D7E4637343A7B1DCB004 /* EdgeAttestation.m */; }; E469AC702DC43791006A2530 /* AdServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E469AC6F2DC43791006A2530 /* AdServices.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; /* End PBXBuildFile section */ @@ -81,7 +83,9 @@ 3DB299A52BCEF2A600D867B0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = edge/PrivacyInfo.xcprivacy; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; + 79E2D7E4637343A7B1DCB004 /* EdgeAttestation.m */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.objc; name = EdgeAttestation.m; path = edge/EdgeAttestation.m; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = edge/LaunchScreen.storyboard; sourceTree = ""; }; + A6252FDC1E314D61A7E315B7 /* EdgeAttestation.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = EdgeAttestation.swift; path = edge/EdgeAttestation.swift; sourceTree = ""; }; E261C56FB78E202F218C2DCA /* Pods_edge.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_edge.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E469AC6F2DC43791006A2530 /* AdServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdServices.framework; path = System/Library/Frameworks/AdServices.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; @@ -110,6 +114,8 @@ 13B07FB61A68108700A75B9A /* Info.plist */, 3DB299A52BCEF2A600D867B0 /* PrivacyInfo.xcprivacy */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, + A6252FDC1E314D61A7E315B7 /* EdgeAttestation.swift */, + 79E2D7E4637343A7B1DCB004 /* EdgeAttestation.m */, ); name = edge; sourceTree = ""; @@ -460,6 +466,8 @@ 3D88EE2D2E3C3BE80086BA9D /* AppDelegate.swift in Sources */, 3D5BD9862A4CEFB900590088 /* EdgeCore.swift in Sources */, 812B284944A10A722B22763D /* ExpoModulesProvider.swift in Sources */, + 04DBAACE71E94A3B9BCAF10A /* EdgeAttestation.swift in Sources */, + E180252EBEC449FE9A1DFAE6 /* EdgeAttestation.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/edge/EdgeAttestation.m b/ios/edge/EdgeAttestation.m new file mode 100644 index 00000000000..872203b7dcc --- /dev/null +++ b/ios/edge/EdgeAttestation.m @@ -0,0 +1,25 @@ +#import + +// Objective-C bridge that exposes the Swift `EdgeAttestation` class to the +// React Native (old architecture) bridge. +@interface RCT_EXTERN_MODULE (EdgeAttestation, NSObject) + +RCT_EXTERN_METHOD(isSupported + : (RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getAttestation + : (NSString *)challenge + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(generateAssertion + : (NSString *)challenge + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(clearKey + : (RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/ios/edge/EdgeAttestation.swift b/ios/edge/EdgeAttestation.swift new file mode 100644 index 00000000000..05491c43f68 --- /dev/null +++ b/ios/edge/EdgeAttestation.swift @@ -0,0 +1,203 @@ +import CryptoKit +import DeviceCheck +import Foundation +import React +import Security + +/// Native bridge for iOS App Attest (app-level attestation). +/// +/// Exposes two methods to JS: +/// - isSupported(): resolves true only on real devices that support App Attest +/// - getAttestation(challenge): generates a fresh App Attest key, attests it +/// against SHA256(challenge), and resolves { keyId, attestation }, where +/// attestation is the base64-encoded CBOR attestation object. +@objc(EdgeAttestation) +class EdgeAttestation: NSObject { + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + // Serializes all key operations. The JS engine normally single-flights + // handshakes, but its 90s watchdog can release the lock and start a second + // handshake while an older native call is still running. Without this queue, + // overlapping getAttestation / generateAssertion / clearKey calls could race + // on the stored key id and leave assertions out of sync with the cached JWT. + // Each async App Attest operation holds the queue (via a semaphore) until it + // completes, so the operations never interleave. + private static let serialQueue = DispatchQueue( + label: "co.edgesecure.app.appattest.serial" + ) + + // Keychain persistence for the attested App Attest key id. App Attest private + // keys live in the Secure Enclave keyed by this id; Apple recommends storing + // the id in the Keychain so it survives across launches and is reused for + // assertions (no re-attestation). + private static let keychainService = "co.edgesecure.app.appattest" + private static let keychainAccount = "keyId" + + private func storeKeyId(_ keyId: String) { + clearKeyId() + guard let data = keyId.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: EdgeAttestation.keychainService, + kSecAttrAccount as String: EdgeAttestation.keychainAccount, + kSecValueData as String: data + ] + SecItemAdd(query as CFDictionary, nil) + } + + private func loadKeyId() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: EdgeAttestation.keychainService, + kSecAttrAccount as String: EdgeAttestation.keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private func clearKeyId() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: EdgeAttestation.keychainService, + kSecAttrAccount as String: EdgeAttestation.keychainAccount + ] + SecItemDelete(query as CFDictionary) + } + + @objc(isSupported:rejecter:) + func isSupported( + _ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + if #available(iOS 14.0, *) { + resolve(DCAppAttestService.shared.isSupported) + } else { + resolve(false) + } + } + + @objc(getAttestation:resolver:rejecter:) + func getAttestation( + _ challenge: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 14.0, *) else { + reject("unsupported", "App Attest requires iOS 14 or later", nil) + return + } + let service = DCAppAttestService.shared + guard service.isSupported else { + reject("unsupported", "App Attest is not supported on this device", nil) + return + } + + // Serialize against other key operations; hold the queue until the async + // attest completes. + EdgeAttestation.serialQueue.async { + let done = DispatchSemaphore(value: 0) + + // A fresh key is generated per handshake: an App Attest key can only be + // attested once, so reuse would require the assertion flow instead. + service.generateKey { keyId, error in + if let error = error { + reject("generateKey", error.localizedDescription, error) + done.signal() + return + } + guard let keyId = keyId else { + reject("generateKey", "Failed to generate an App Attest key", nil) + done.signal() + return + } + + // The client data is the challenge's UTF-8 bytes; the server recomputes + // SHA256(challenge) to validate the attestation nonce. + let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8))) + + service.attestKey(keyId, clientDataHash: clientDataHash) { attestation, error in + defer { done.signal() } + if let error = error { + reject("attestKey", error.localizedDescription, error) + return + } + guard let attestation = attestation else { + reject("attestKey", "Failed to produce an attestation object", nil) + return + } + // Persist the key id so subsequent handshakes refresh via assertions + // instead of a full (rate-limited) attestation. + self.storeKeyId(keyId) + resolve([ + "keyId": keyId, + "attestation": attestation.base64EncodedString(), + "bundleId": Bundle.main.bundleIdentifier ?? "" + ]) + } + } + + done.wait() + } + } + + @objc(generateAssertion:resolver:rejecter:) + func generateAssertion( + _ challenge: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 14.0, *), DCAppAttestService.shared.isSupported else { + reject("unsupported", "App Attest is not supported on this device", nil) + return + } + + EdgeAttestation.serialQueue.async { + guard let keyId = self.loadKeyId() else { + reject("noKey", "No attested App Attest key is stored", nil) + return + } + let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8))) + let done = DispatchSemaphore(value: 0) + DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in + defer { done.signal() } + if let error = error as? DCError, error.code == .invalidKey { + // The key no longer exists (reinstall/restore); force re-attestation. + self.clearKeyId() + reject("invalidKey", "Stored App Attest key is invalid", error) + return + } + if let error = error { + reject("generateAssertion", error.localizedDescription, error) + return + } + guard let assertion = assertion else { + reject("generateAssertion", "Failed to produce an assertion", nil) + return + } + resolve([ + "keyId": keyId, + "assertion": assertion.base64EncodedString(), + "bundleId": Bundle.main.bundleIdentifier ?? "" + ]) + } + done.wait() + } + } + + @objc(clearKey:rejecter:) + func clearKey( + _ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + EdgeAttestation.serialQueue.async { + self.clearKeyId() + resolve(nil) + } + } +} diff --git a/ios/edge/edge.entitlements b/ios/edge/edge.entitlements index 3ee27306f01..89283760728 100644 --- a/ios/edge/edge.entitlements +++ b/ios/edge/edge.entitlements @@ -4,6 +4,8 @@ aps-environment development + com.apple.developer.devicecheck.appattest-environment + production com.apple.developer.associated-domains applinks:dl.edge.app diff --git a/package.json b/package.json index 9f48c5c3bb1..7bbe0fe7361 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "android:release": "cd android && ./gradlew assembleRelease; cd ../", "android": "react-native run-android", "androidKeysCreate": "node -r sucrase/register scripts/createAndroidKeys.ts", + "extractSigningCert": "node -r sucrase/register scripts/extractSigningCert.ts", "configure": "node -r sucrase/register scripts/configure.ts", "deploy": "node -r sucrase/register scripts/deploy.ts", "fix-kotlin": "cd android; ./gradlew :app:ktlintFormat", diff --git a/scripts/addAttestationIosFiles.js b/scripts/addAttestationIosFiles.js new file mode 100644 index 00000000000..7c78adbab61 --- /dev/null +++ b/scripts/addAttestationIosFiles.js @@ -0,0 +1,47 @@ +// One-off helper: wire the EdgeAttestation native files into the iOS Xcode +// project (PBXBuildFile + PBXFileReference + group + Sources build phase) for +// the `edge` target. Idempotent. Run with: node scripts/addAttestationIosFiles.js +const fs = require('fs') +const xcode = require('xcode') + +const projPath = 'ios/edge.xcodeproj/project.pbxproj' +const proj = xcode.project(projPath) +proj.parseSync() + +const unquote = s => (typeof s === 'string' ? s.replace(/^"|"$/g, '') : s) + +// Locate the `edge` native target (not `edgeTests`). +const targets = proj.pbxNativeTargetSection() +let edgeTargetKey +for (const key of Object.keys(targets)) { + if (key.endsWith('_comment')) continue + if (unquote(targets[key].name) === 'edge') { + edgeTargetKey = key + break + } +} +if (edgeTargetKey == null) throw new Error('Could not find the `edge` target') + +const groupKey = proj.findPBXGroupKey({ name: 'edge' }) +if (groupKey == null) throw new Error('Could not find the `edge` group') + +const fileRefs = proj.pbxFileReferenceSection() +const isPresent = relPath => + Object.keys(fileRefs).some( + k => !k.endsWith('_comment') && unquote(fileRefs[k].path) === relPath + ) + +for (const relPath of [ + 'edge/EdgeAttestation.swift', + 'edge/EdgeAttestation.m' +]) { + if (isPresent(relPath)) { + console.log('already present:', relPath) + continue + } + proj.addSourceFile(relPath, { target: edgeTargetKey }, groupKey) + console.log('added:', relPath) +} + +fs.writeFileSync(projPath, proj.writeSync()) +console.log('wrote', projPath) diff --git a/scripts/extractSigningCert.ts b/scripts/extractSigningCert.ts new file mode 100644 index 00000000000..03db62d196e --- /dev/null +++ b/scripts/extractSigningCert.ts @@ -0,0 +1,140 @@ +import childProcess from 'child_process' +import prompts from 'prompts' + +// ----------------------------------------------------------------------------- +// extractSigningCert +// +// Extract the SHA-256 signing-certificate digest(s) from an Android keystore in +// the exact form the info server's attestation allow-list expects +// (info_data/appAttestation -> androidApps -> { release: [...], debug: [...] }). +// +// Android key attestation reports each signing certificate as +// SHA-256(DER-encoded X.509 cert). `keytool -list -v` prints that same value as +// its "SHA256:" fingerprint, so this tool runs keytool and normalizes the +// fingerprint to lowercase hex with the colons stripped. +// +// This is the reusable tool for turning Edge's (decrypted) production keystore +// into the digest that must be pinned in the info server. For local testing it +// is also used on the fake release keystore and the debug keystore. +// +// Usage: +// node -r sucrase/register scripts/extractSigningCert.ts [alias] \ +// [--storepass ] +// +// Password resolution order: --storepass flag, KEYSTORE_PASSWORD env var, then +// an interactive prompt. When no alias is given, every entry in the keystore is +// printed. +// ----------------------------------------------------------------------------- + +interface Args { + keystore?: string + alias?: string + storepass?: string +} + +const parseArgs = (argv: string[]): Args => { + const args: Args = {} + const positional: string[] = [] + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--storepass' || arg === '-p') { + args.storepass = argv[++i] + } else if (arg.startsWith('--storepass=')) { + args.storepass = arg.slice('--storepass='.length) + } else { + positional.push(arg) + } + } + args.keystore = positional[0] + args.alias = positional[1] + return args +} + +const normalizeDigest = (fingerprint: string): string => + fingerprint.replace(/:/g, '').trim().toLowerCase() + +const main = async (): Promise => { + const args = parseArgs(process.argv.slice(2)) + + if (args.keystore == null || args.keystore === '') { + mylog( + 'Usage: node -r sucrase/register scripts/extractSigningCert.ts [alias] [--storepass ]' + ) + process.exit(1) + } + + let storepass = args.storepass ?? process.env.KEYSTORE_PASSWORD + if (storepass == null || storepass === '') { + const answer = await prompts({ + name: 'storepass', + type: 'password', + message: `Enter store password for ${args.keystore}`, + validate: (v: string) => v.trim() !== '' + }) + storepass = answer.storepass + } + if (storepass == null || storepass === '') { + mylog('No store password provided; aborting.') + process.exit(1) + } + + // -J-Duser.language=en forces English labels so parsing is locale-independent. + const aliasArg = args.alias != null ? `-alias ${args.alias}` : '' + const output = call( + `keytool -list -v -J-Duser.language=en -keystore "${args.keystore}" ${aliasArg} -storepass "${storepass}"` + ) + + // Pair each "Alias name:" with the following "SHA256:" fingerprint. When a + // single alias was requested keytool omits the alias header, so fall back to + // the requested alias name. + const lines = output.split('\n') + const entries: Array<{ alias: string; digest: string }> = [] + let currentAlias = args.alias ?? '(unknown)' + for (const line of lines) { + const aliasMatch = /^Alias name:\s*(.+)$/.exec(line) + if (aliasMatch != null) { + currentAlias = aliasMatch[1].trim() + continue + } + const shaMatch = /SHA256:\s*([0-9A-Fa-f:]+)/.exec(line) + if (shaMatch != null) { + entries.push({ + alias: currentAlias, + digest: normalizeDigest(shaMatch[1]) + }) + } + } + + if (entries.length === 0) { + mylog('No SHA-256 fingerprint found in keytool output:') + mylog(output) + process.exit(1) + } + + mylog('') + mylog('Signing certificate SHA-256 digest(s):') + mylog( + ' (paste into info_data/appAttestation -> androidApps -> release/debug)' + ) + mylog('') + for (const entry of entries) { + mylog(` alias "${entry.alias}":`) + mylog(` ${entry.digest}`) + } + mylog('') +} + +const mylog = console.log + +function call(cmdstring: string): string { + return childProcess.execSync(cmdstring, { + encoding: 'utf8', + timeout: 600000, + killSignal: 'SIGKILL' + }) +} + +main().catch((e: unknown) => { + console.log(String(e)) + process.exit(1) +}) diff --git a/src/__tests__/util/attestation.test.ts b/src/__tests__/util/attestation.test.ts new file mode 100644 index 00000000000..a7e9259164a --- /dev/null +++ b/src/__tests__/util/attestation.test.ts @@ -0,0 +1,218 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest +} from '@jest/globals' +import { NativeModules } from 'react-native' + +interface MockResponse { + ok: boolean + status: number + json: () => Promise + text: () => Promise +} + +const mockFetchInfo = + jest.fn<(path: string, opts?: RequestInit) => Promise>() + +jest.mock('../../util/network', () => ({ + fetchInfo: async (...args: unknown[]) => + await mockFetchInfo(...(args as [string])) +})) + +const mockIsSupported = jest.fn<() => Promise>() +const mockGetAttestation = jest.fn< + (challenge: string) => Promise<{ + keyId?: string + attestation?: string + bundleId?: string + certChain?: string[] + }> +>() +const mockGenerateAssertion = jest.fn< + (challenge: string) => Promise<{ + keyId?: string + assertion?: string + bundleId?: string + }> +>() +const mockClearKey = jest.fn<() => Promise>() +const mockSignChallenge = + jest.fn< + (challenge: string) => Promise<{ keyId?: string; signature?: string }> + >() + +NativeModules.EdgeAttestation = { + isSupported: mockIsSupported, + getAttestation: mockGetAttestation, + generateAssertion: mockGenerateAssertion, + signChallenge: mockSignChallenge, + clearKey: mockClearKey +} + +// Import after mocks so the module binds to mockFetchInfo / NativeModules. +const { + attestationTimingForTests, + getAttestationToken, + initAttestation, + resetAttestationForTests +} = require('../../util/attestation') + +const jsonResponse = (body: unknown, ok = true, status = 200): MockResponse => { + const response: MockResponse = { + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body) + } + return response +} + +describe('attestation engine', () => { + beforeEach(() => { + jest.useFakeTimers() + resetAttestationForTests() + mockFetchInfo.mockReset() + mockIsSupported.mockReset() + mockGetAttestation.mockReset() + mockGenerateAssertion.mockReset() + mockSignChallenge.mockReset() + mockClearKey.mockReset() + mockIsSupported.mockResolvedValue(true) + mockGetAttestation.mockResolvedValue({ + keyId: 'key', + attestation: 'att', + bundleId: 'co.edgesecure.app' + }) + // Default: no stored key yet, so the assert fast path fails over to full + // attestation (matches first-run behavior on both platforms). + mockGenerateAssertion.mockRejectedValue(new Error('noKey')) + mockSignChallenge.mockRejectedValue(new Error('noKey')) + mockClearKey.mockResolvedValue(undefined) + }) + + afterEach(() => { + resetAttestationForTests() + jest.useRealTimers() + }) + + const mockSuccessfulHandshake = (expires: number): void => { + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse({ token: 'jwt-token', expires }) + } + throw new Error(`unexpected path ${path}`) + }) + } + + it('rejects attest responses with a non-finite expires (Task 2.1)', async () => { + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + return jsonResponse({ token: 'jwt-token', expires: 'soon' }) + }) + + initAttestation() + const tokenPromise = getAttestationToken() + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.GET_TOKEN_TIMEOUT_MS + ) + await expect(tokenPromise).resolves.toBeUndefined() + }) + + it('caches a token when expires is a finite number (Task 2.1)', async () => { + const expires = Date.now() + 10 * 60 * 1000 + mockSuccessfulHandshake(expires) + + initAttestation() + const tokenPromise = getAttestationToken() + // Flush the in-flight handshake promise chain. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await expect(tokenPromise).resolves.toBe('jwt-token') + }) + + it('releases a hung handshake after the watchdog so a later attempt can succeed (Task 2.2)', async () => { + mockGetAttestation.mockImplementation( + async () => await new Promise(() => {}) // never settles + ) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-hung' }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + // Let the hung handshake start and grab the lock. + await Promise.resolve() + await Promise.resolve() + + // Watchdog fires and clears the lock. + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.HANDSHAKE_WATCHDOG_MS + ) + + // A subsequent attempt can start after the lock is released. + const expires = Date.now() + 10 * 60 * 1000 + mockGetAttestation.mockResolvedValue({ + keyId: 'key2', + attestation: 'att2', + bundleId: 'co.edgesecure.app' + }) + mockSuccessfulHandshake(expires) + + const tokenPromise = getAttestationToken() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await expect(tokenPromise).resolves.toBe('jwt-token') + }) + + it('suppresses retries during the failure backoff window (Task 2.3)', async () => { + // First handshake fails at the challenge step. + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({}, false, 500) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + const firstPromise = getAttestationToken() + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.GET_TOKEN_TIMEOUT_MS + ) + await expect(firstPromise).resolves.toBeUndefined() + + const callsAfterFailure = mockFetchInfo.mock.calls.length + + // A subsequent call during the backoff window must not start a new + // handshake and must return immediately without the 3s wait. + const backoffPromise = getAttestationToken() + await Promise.resolve() + await expect(backoffPromise).resolves.toBeUndefined() + expect(mockFetchInfo.mock.calls.length).toBe(callsAfterFailure) + + // After the backoff elapses, a handshake can succeed again. + const expires = Date.now() + 10 * 60 * 1000 + mockSuccessfulHandshake(expires) + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.FAILURE_BACKOFF_MS + ) + + const retryPromise = getAttestationToken() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await expect(retryPromise).resolves.toBe('jwt-token') + }) +}) diff --git a/src/envConfig.ts b/src/envConfig.ts index 20df307be82..f0181d68c3d 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -559,6 +559,9 @@ export const asEnvConfig = asObject({ ENABLE_FIAT_SANDBOX: asOptional(asBoolean, false), ENABLE_MAESTRO_BUILD: asOptional(asBoolean, false), ENABLE_TEST_SERVERS: asOptional(asBoolean), + // Optional override of the info server URL(s), e.g. for pointing a debug build + // at a local info server: ["http://127.0.0.1:8008"]. Absent in production. + INFO_SERVER: asOptional(asArray(asString)), ENABLE_REDUX_PERF_LOGGING: asOptional(asBoolean, false), LOG_SERVER: asNullable( asObject({ diff --git a/src/plugins/gui/providers/banxaProvider.ts b/src/plugins/gui/providers/banxaProvider.ts index ec3fbdae9d8..3b78b4866d9 100644 --- a/src/plugins/gui/providers/banxaProvider.ts +++ b/src/plugins/gui/providers/banxaProvider.ts @@ -17,6 +17,7 @@ import { lstrings } from '../../../locales/strings' import { getExchangeDenom } from '../../../selectors/DenominationSelectors' import type { FiatProviderLink } from '../../../types/DeepLinkTypes' import type { StringMap } from '../../../types/types' +import { getAttestationToken } from '../../../util/attestation' import { CryptoAmount } from '../../../util/CryptoAmount' import { fetchInfo } from '../../../util/network' import { consify, removeIsoPrefix } from '../../../util/utils' @@ -1015,15 +1016,26 @@ const generateHmac = async ( nonce: string ): Promise => { const body = JSON.stringify({ data }) + // createHmac is attestation-gated. Attach the attestation token if one is + // available; otherwise proceed without it (the info server decides). + const attestationToken = await getAttestationToken() const response = await fetchInfo( `v1/createHmac/${hmacUser}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(attestationToken != null + ? { 'x-attestation-token': attestationToken } + : {}) + }, body }, 3000 ) + // A missing/rejected attestation token returns 403 here; fail loudly rather + // than parsing an error body as a signature. + if (!response.ok) throw new Error('Banxa failed to create HMAC signature') const reply = await response.json() const { signature } = asInfoCreateHmacResponse(reply) diff --git a/src/plugins/gui/providers/simplexProvider.ts b/src/plugins/gui/providers/simplexProvider.ts index 8e6d3224b95..95418018609 100644 --- a/src/plugins/gui/providers/simplexProvider.ts +++ b/src/plugins/gui/providers/simplexProvider.ts @@ -5,6 +5,7 @@ import { asArray, asEither, asNumber, asObject, asString } from 'cleaners' import { showError } from '../../../components/services/AirshipInstance' import { lstrings } from '../../../locales/strings' import type { FiatProviderLink } from '../../../types/DeepLinkTypes' +import { getAttestationToken } from '../../../util/attestation' import { CryptoAmount } from '../../../util/CryptoAmount' import { fetchInfo } from '../../../util/network' import { asFiatPaymentType, type FiatPaymentType } from '../fiatPluginTypes' @@ -211,7 +212,7 @@ export const simplexProvider: FiatProviderFactory = { let simplexUserId = await store .getItem('simplex_user_id') - .catch(e => undefined) + .catch((e: unknown) => undefined) if (simplexUserId == null || simplexUserId === '') { simplexUserId = await makeUuid() await store.setItem('simplex_user_id', simplexUserId) @@ -248,8 +249,8 @@ export const simplexProvider: FiatProviderFactory = { if (isDailyCheckDue(lastChecked)) { const response = await fetch( `https://api.simplexcc.com/v2/supported_fiat_currencies?public_key=${publicKey}` - ).catch(e => undefined) - if (!response?.ok) return allowedCurrencyCodes + ).catch((e: unknown) => undefined) + if (response?.ok !== true) return allowedCurrencyCodes const result = await response.json() const fiatCurrencies = asSimplexFiatCurrencies(result) @@ -259,7 +260,7 @@ export const simplexProvider: FiatProviderFactory = { const response2 = await fetch( `https://api.simplexcc.com/v2/supported_countries?public_key=${publicKey}&payment_methods=credit_card` - ).catch(e => undefined) + ).catch((e: unknown) => undefined) if (response2 == null || !response.ok) throw new Error('Simplex failed to fetch supported countries') const result2 = await response2.json() @@ -294,7 +295,8 @@ export const simplexProvider: FiatProviderFactory = { }) } - if (!allowedCountryCodes[regionCode.countryCode]) + const countryEntry = allowedCountryCodes[regionCode.countryCode] + if (countryEntry == null || countryEntry === false) throw new FiatProviderError({ providerId, errorType: 'regionRestricted', @@ -313,7 +315,7 @@ export const simplexProvider: FiatProviderFactory = { let foundPaymentType = false for (const type of paymentTypes) { const t = asFiatPaymentType(type) - if (allowedPaymentTypes[t]) { + if (allowedPaymentTypes[t] === true) { foundPaymentType = true break } @@ -340,21 +342,30 @@ export const simplexProvider: FiatProviderFactory = { tacn = simplexFiatCode } + // jwtSign is attestation-gated. Attach the attestation token if one is + // available; otherwise proceed without it (the info server decides). + const attestationToken = await getAttestationToken() const response = await fetchInfo( 'v1/jwtSign/simplex', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(attestationToken != null + ? { 'x-attestation-token': attestationToken } + : {}) + }, body: JSON.stringify({ data: { euid: simplexUserId, ts, soam, socn, tacn } }) }, 3000 - ).catch(e => { + ).catch((e: unknown) => { console.log(e) return undefined }) - if (!response?.ok) throw new Error('Simplex failed to fetch jwttoken') + if (response?.ok !== true) + throw new Error('Simplex failed to fetch jwttoken') const result = await response.json() const { token } = asInfoJwtSignResponse(result) @@ -436,15 +447,27 @@ export const simplexProvider: FiatProviderFactory = { fiam: goodQuote.fiat_money.amount } + // jwtSign is attestation-gated. Attach the attestation token if one + // is available; otherwise proceed without it (server decides). + const attestationToken = await getAttestationToken() const response = await fetchInfo(`v1/jwtSign/${jwtTokenProvider}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(attestationToken != null + ? { 'x-attestation-token': attestationToken } + : {}) + }, body: JSON.stringify({ data }) - }).catch(e => { + }).catch((e: unknown) => { console.log(e) return undefined }) - if (!response?.ok) return + // Surface the failure (mirrors the quote-fetch step above) so the + // user sees an error instead of the approval silently no-oping. A + // 403 here means attestation was required but missing/rejected. + if (response?.ok !== true) + throw new Error('Simplex failed to sign approval request') const result = await response.json() const { token } = asInfoJwtSignResponse(result) diff --git a/src/plugins/ramps/banxa/banxaRampPlugin.ts b/src/plugins/ramps/banxa/banxaRampPlugin.ts index 4587603cd2c..c274fce0a3e 100644 --- a/src/plugins/ramps/banxa/banxaRampPlugin.ts +++ b/src/plugins/ramps/banxa/banxaRampPlugin.ts @@ -22,6 +22,7 @@ import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' import { lstrings } from '../../../locales/strings' import { getExchangeDenom } from '../../../selectors/DenominationSelectors' import type { StringMap } from '../../../types/types' +import { getAttestationToken } from '../../../util/attestation' import { CryptoAmount } from '../../../util/CryptoAmount' import { getTokenId } from '../../../util/CurrencyInfoHelpers' import { fetchInfo } from '../../../util/network' @@ -429,15 +430,26 @@ const generateHmac = async ( nonce: string ): Promise => { const body = JSON.stringify({ data }) + // createHmac is attestation-gated. Attach the attestation token if one is + // available; otherwise proceed without it (the info server decides). + const attestationToken = await getAttestationToken() const response = await fetchInfo( `v1/createHmac/${hmacUser}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(attestationToken != null + ? { 'x-attestation-token': attestationToken } + : {}) + }, body }, 3000 ) + // A missing/rejected attestation token returns 403 here; fail loudly rather + // than parsing an error body as a signature. + if (!response.ok) throw new Error('Banxa failed to create HMAC signature') const reply = await response.json() const { signature } = asInfoCreateHmacResponse(reply) diff --git a/src/plugins/ramps/simplex/simplexRampPlugin.ts b/src/plugins/ramps/simplex/simplexRampPlugin.ts index 38b234abe31..19e7ae82979 100644 --- a/src/plugins/ramps/simplex/simplexRampPlugin.ts +++ b/src/plugins/ramps/simplex/simplexRampPlugin.ts @@ -4,6 +4,7 @@ import { Platform } from 'react-native' import { showToast } from '../../../components/services/AirshipInstance' import { EDGE_CONTENT_SERVER_URI } from '../../../constants/CdnConstants' import { lstrings } from '../../../locales/strings' +import { getAttestationToken } from '../../../util/attestation' import { CryptoAmount } from '../../../util/CryptoAmount' import { fetchInfo } from '../../../util/network' import { makeUuid } from '../../../util/rnUtils' @@ -291,11 +292,19 @@ export const simplexRampPlugin: RampPluginFactory = ( endpoint: string, data: SimplexJwtData | SimplexQuoteJwtData ): Promise => { + // jwtSign is attestation-gated. Attach the attestation token if one is + // available; otherwise proceed without it (the info server decides). + const attestationToken = await getAttestationToken() const response = await fetchInfo( `v1/jwtSign/${endpoint}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(attestationToken != null + ? { 'x-attestation-token': attestationToken } + : {}) + }, body: JSON.stringify({ data }) }, 3000 diff --git a/src/util/attestation.ts b/src/util/attestation.ts new file mode 100644 index 00000000000..b77e5e4a4b3 --- /dev/null +++ b/src/util/attestation.ts @@ -0,0 +1,327 @@ +import { NativeModules, Platform } from 'react-native' + +import { fetchInfo } from './network' + +/** + * Shape of the native EdgeAttestation module (iOS Swift / Android Kotlin). + * iOS returns `{ keyId, attestation }`; Android returns `{ certChain }`. + */ +interface NativeAttestation { + isSupported: () => Promise + getAttestation: (challenge: string) => Promise<{ + keyId?: string + attestation?: string + bundleId?: string + certChain?: string[] + }> + // iOS-only: refresh a token via an App Attest assertion using the stored key. + generateAssertion: (challenge: string) => Promise<{ + keyId?: string + assertion?: string + bundleId?: string + }> + // Android-only: refresh a token by signing the challenge with the enrolled + // Keystore key. + signChallenge: (challenge: string) => Promise<{ + keyId?: string + signature?: string + }> + // Discard the stored attested key so the next handshake re-attests. + // Available on both platforms. Guarded by Platform.OS for the assert paths. + clearKey: () => Promise +} + +const EdgeAttestation: NativeAttestation | undefined = + NativeModules.EdgeAttestation + +interface CachedToken { + token: string + expires: number // epoch milliseconds +} + +// Relaunch the handshake this long before the current token expires, so a fresh +// token is (re)fetched by the background engine well ahead of expiry. +const REFRESH_LEAD_MS = 2 * 60 * 1000 +// Small skew so a token that is about to expire is treated as unusable. +const CLOCK_SKEW_MS = 5 * 1000 +// Max time getAttestationToken() blocks waiting on the initial handshake. +const GET_TOKEN_TIMEOUT_MS = 3 * 1000 +// Watchdog: a handshake that has not settled after this long is considered +// hung; release the lock so a later attempt can start. Sized well above a +// slow-but-legitimate handshake so concurrent handshakes never overlap in +// normal operation (Apple rate-limits attestation). +const HANDSHAKE_WATCHDOG_MS = 90 * 1000 +// After a failed handshake, don't retry (and don't make gated callers wait) +// for this long. Keeps a persistently-failing device from adding 3s of +// latency to every gated request. +const FAILURE_BACKOFF_MS = 60 * 1000 + +let cachedToken: CachedToken | undefined +let inFlight: Promise | undefined +let refreshTimer: ReturnType | undefined +let lastFailureAt = 0 +// Monotonic id of the latest handshake attempt; a resolved handshake only +// commits its token when its generation is still current (see runHandshake). +let handshakeGeneration = 0 + +/** Test-only: clear module state between Jest cases. */ +export const resetAttestationForTests = (): void => { + cachedToken = undefined + inFlight = undefined + if (refreshTimer != null) clearTimeout(refreshTimer) + refreshTimer = undefined + lastFailureAt = 0 + handshakeGeneration = 0 +} + +/** Test-only: expose timing constants used by unit tests. */ +export const attestationTimingForTests = { + GET_TOKEN_TIMEOUT_MS, + HANDSHAKE_WATCHDOG_MS, + FAILURE_BACKOFF_MS +} + +const hasLiveToken = (): boolean => + cachedToken != null && Date.now() < cachedToken.expires - CLOCK_SKEW_MS + +/** Obtain a single-use challenge from the info server. */ +const fetchChallenge = async (): Promise => { + const challengeResponse = await fetchInfo('v1/attest/challenge') + if (!challengeResponse.ok) { + throw new Error(`challenge request failed: ${challengeResponse.status}`) + } + const { challenge } = await challengeResponse.json() + if (typeof challenge !== 'string' || challenge === '') { + throw new Error('challenge response missing challenge') + } + return challenge +} + +/** + * Validate an attest/assert token response. Both `token` and `expires` are + * validated; a malformed response throws and is treated as a failed handshake + * (a non-finite `expires` would otherwise fire `setTimeout` immediately and + * spin the handshake loop). The parsed token is returned to the caller rather + * than cached directly, so `runHandshake` can drop a stale (watchdog-released) + * result before it clobbers a fresher token. + */ +const parseTokenResponse = (json: unknown): CachedToken => { + const { token, expires } = (json ?? {}) as { + token?: unknown + expires?: unknown + } + if (typeof token !== 'string') { + throw new Error('attest response missing token') + } + if (typeof expires !== 'number' || !Number.isFinite(expires)) { + throw new Error('attest response missing expires') + } + return { token, expires } +} + +/** + * Run one attestation handshake and return the fresh token, or `undefined` when + * there is nothing to do (no native module / unsupported platform). Never + * caches directly; the caller commits the result. `generation` identifies this + * attempt so a stale (watchdog-released) handshake never mutates shared token + * state that a newer handshake already owns. + */ +const performHandshake = async ( + generation: number +): Promise => { + // No native module (e.g. unsupported platform / dev environment). + if (EdgeAttestation == null) return undefined + + let supported = false + try { + supported = await EdgeAttestation.isSupported() + } catch { + supported = false + } + if (!supported) return undefined + + const isIos = Platform.OS === 'ios' + + // 1. Obtain a single-use challenge from the info server. + let challenge = await fetchChallenge() + + // iOS fast path: assert with the stored attested key (no Apple round + // trip, no new key). Falls back to full attestation when there is no + // stored key, the key is invalid, or the server rejects the assertion. + if (isIos) { + try { + const native = await EdgeAttestation.generateAssertion(challenge) + const assertResponse = await fetchInfo('v1/attest/apple/assert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + keyId: native.keyId, + assertion: native.assertion, + challenge + }) + }) + if (assertResponse.ok) { + return parseTokenResponse(await assertResponse.json()) + } + // Server rejected the assertion: the enrolled key is no longer trusted, + // so any previously-minted token is suspect too. Drop it now so gated + // callers do not keep sending a token the server already rejects while + // re-enrollment is in progress; discard the key and re-attest. Only when + // this is still the current handshake, so a stale (watchdog-released) + // attempt cannot wipe a token a newer handshake already cached. + if (generation === handshakeGeneration) cachedToken = undefined + console.warn( + `[attestation] assertion rejected (${assertResponse.status}); re-attesting` + ) + await EdgeAttestation.clearKey().catch(() => {}) + } catch (error) { + // noKey / invalidKey / native failure: fall through to full attestation. + console.log('[attestation] assertion unavailable:', String(error)) + } + // The challenge above was consumed (or expired); fetch a fresh one for + // the fallback attestation. + challenge = await fetchChallenge() + } + + // Android fast path: sign the challenge with the enrolled Keystore key + // (no new key, no RKP dependency). Falls back to full attestation when + // there is no stored key or the server rejects the assertion. + if (!isIos) { + try { + const native = await EdgeAttestation.signChallenge(challenge) + const assertResponse = await fetchInfo('v1/attest/android/assert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + keyId: native.keyId, + signature: native.signature, + challenge + }) + }) + if (assertResponse.ok) { + return parseTokenResponse(await assertResponse.json()) + } + // Server rejected the assertion: drop the now-suspect cached token (see + // the iOS branch above), guarded by the current generation, before + // discarding the key and re-attesting. + if (generation === handshakeGeneration) cachedToken = undefined + console.warn( + `[attestation] assertion rejected (${assertResponse.status}); re-attesting` + ) + await EdgeAttestation.clearKey().catch(() => {}) + } catch (error) { + // noKey / native failure: fall through to full attestation. + console.log('[attestation] assertion unavailable:', String(error)) + } + // The challenge above was consumed (or expired); fetch a fresh one for + // the fallback attestation. + challenge = await fetchChallenge() + } + + // 2. Produce a platform attestation bound to the challenge. + const native = await EdgeAttestation.getAttestation(challenge) + + // 3. Submit the attestation and receive a signed token. + const path = isIos ? 'v1/attest/apple' : 'v1/attest/android' + const body = isIos + ? { + keyId: native.keyId, + attestation: native.attestation, + bundleId: native.bundleId, + challenge + } + : { certChain: native.certChain, challenge } + + const attestResponse = await fetchInfo(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + if (!attestResponse.ok) { + const text = await attestResponse.text() + throw new Error(`attest request failed: ${attestResponse.status} ${text}`) + } + return parseTokenResponse(await attestResponse.json()) +} + +const delay = async (ms: number): Promise => { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Schedule the next handshake to run `REFRESH_LEAD_MS` before the given token + * expiry, so the background engine keeps a fresh token cached ahead of time. + */ +const scheduleRefresh = (expires: number): void => { + if (refreshTimer != null) clearTimeout(refreshTimer) + const delayMs = Math.max(0, expires - Date.now() - REFRESH_LEAD_MS) + refreshTimer = setTimeout(() => { + runHandshake() + }, delayMs) +} + +/** + * Kick off a handshake in the background if one is not already running. Never + * throws (failures are logged) and never blocks the caller. On success, caches + * the token and schedules the next refresh. + */ +const runHandshake = (): void => { + if (inFlight != null) return + if (Date.now() - lastFailureAt < FAILURE_BACKOFF_MS) return + // A handshake whose native call hangs past the watchdog has its `inFlight` + // lock released so a newer handshake can start. Tag each attempt so a stale + // one that finally resolves cannot clobber the newer handshake's token. + const generation = ++handshakeGeneration + const handshake: Promise = performHandshake(generation) + .then(freshToken => { + if (generation !== handshakeGeneration) return + lastFailureAt = 0 + if (freshToken != null) { + cachedToken = freshToken + scheduleRefresh(freshToken.expires) + } + }) + .catch((error: unknown) => { + if (generation !== handshakeGeneration) return + lastFailureAt = Date.now() + console.warn('[attestation] handshake failed:', String(error)) + }) + .finally(() => { + if (inFlight === handshake) inFlight = undefined + }) + inFlight = handshake + // A hung native call must not block all future attempts. Only clear the + // lock if this same handshake still holds it. + setTimeout(() => { + if (inFlight === handshake) { + console.warn('[attestation] handshake watchdog fired; releasing lock') + inFlight = undefined + } + }, HANDSHAKE_WATCHDOG_MS) +} + +/** + * Start the background attestation engine. Called once at app boot. Kicks off an + * initial handshake (unless a live token is already cached) without blocking; + * the engine then self-reschedules to refresh the token ahead of each expiry. + */ +export const initAttestation = (): void => { + if (hasLiveToken()) return + runHandshake() +} + +/** + * Return the most recent attestation token for an attestation-gated caller. + * Resolves immediately with the cached token when one is live. Otherwise it + * ensures a handshake is running and waits at most `GET_TOKEN_TIMEOUT_MS`, + * returning `undefined` on timeout. Callers treat `undefined` as "no token" and + * let the info server decide (it may still serve a fallback response). + */ +export const getAttestationToken = async (): Promise => { + if (hasLiveToken()) return cachedToken?.token + runHandshake() + if (inFlight != null) { + await Promise.race([inFlight, delay(GET_TOKEN_TIMEOUT_MS)]) + } + return hasLiveToken() ? cachedToken?.token : undefined +} diff --git a/src/util/network.ts b/src/util/network.ts index 9feee0fb785..d597b3652f1 100644 --- a/src/util/network.ts +++ b/src/util/network.ts @@ -8,11 +8,18 @@ import { asInfoRollup, type InfoRollup } from 'edge-info-server' import { Platform } from 'react-native' import { getVersion } from 'react-native-device-info' +import { ENV } from '../env' import { config } from '../theme/appConfig' +import { initAttestation } from './attestation' import { runOnce } from './runOnce' import { asyncWaterfall, getOsVersion, shuffleArray } from './utils' import { checkAppVersion } from './versionCheck' -const INFO_SERVERS = ['https://info1.edge.app', 'https://info2.edge.app'] +// `ENV.INFO_SERVER` (from env.json) overrides the production info servers, e.g. +// to point a debug build at a local info server. Absent in production builds. +const INFO_SERVERS = + ENV.INFO_SERVER != null && ENV.INFO_SERVER.length > 0 + ? ENV.INFO_SERVER + : ['https://info1.edge.app', 'https://info2.edge.app'] const RATES_SERVERS = ['https://rates3.edge.app', 'https://rates4.edge.app'] const RATES_SERVER_V2 = ['https://rates1.edge.app', 'https://rates2.edge.app'] @@ -127,6 +134,12 @@ export const fetchPush = async ( export const infoServerData: { rollup?: InfoRollup } = {} export const initInfoServer = async (): Promise => { + // Start the background attestation engine at boot (best-effort, non-blocking) + // so a token is usually cached before any attestation-gated request is made. + // This is intentionally not inside fetchInfo: the fetch wrapper carries no + // attestation logic; gated plugins attach the token via getAttestationToken(). + initAttestation() + const osType = Platform.OS.toLowerCase() const osVersion = getOsVersion() const version = getVersion()