From d263e7f533d33299ce22449e131a818233e7e053 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Fri, 3 Jul 2026 22:45:52 -0700 Subject: [PATCH 1/3] 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 --- CHANGELOG.md | 2 + .../edgesecure/app/EdgeAttestationModule.kt | 213 +++++++++++ .../edgesecure/app/EdgeAttestationPackage.kt | 17 + .../java/co/edgesecure/app/MainApplication.kt | 4 +- docs/APP_ATTESTATION.md | 165 +++++++++ eslint.config.mjs | 1 - ios/edge.xcodeproj/project.pbxproj | 8 + ios/edge/EdgeAttestation.m | 25 ++ ios/edge/EdgeAttestation.swift | 203 +++++++++++ ios/edge/edge.entitlements | 2 + package.json | 1 + scripts/addAttestationIosFiles.js | 47 +++ scripts/extractSigningCert.ts | 140 ++++++++ src/__tests__/util/attestation.test.ts | 289 +++++++++++++++ src/envConfig.ts | 3 + src/plugins/gui/providers/banxaProvider.ts | 14 +- src/plugins/gui/providers/simplexProvider.ts | 47 ++- src/plugins/ramps/banxa/banxaRampPlugin.ts | 14 +- .../ramps/simplex/simplexRampPlugin.ts | 11 +- src/util/attestation.ts | 335 ++++++++++++++++++ src/util/network.ts | 15 +- 21 files changed, 1538 insertions(+), 18 deletions(-) create mode 100644 android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt create mode 100644 android/app/src/main/java/co/edgesecure/app/EdgeAttestationPackage.kt create mode 100644 docs/APP_ATTESTATION.md create mode 100644 ios/edge/EdgeAttestation.m create mode 100644 ios/edge/EdgeAttestation.swift create mode 100644 scripts/addAttestationIosFiles.js create mode 100644 scripts/extractSigningCert.ts create mode 100644 src/__tests__/util/attestation.test.ts create mode 100644 src/util/attestation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b553e3b1b..b0e679b27d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -42,6 +43,7 @@ ## 4.49.1 (2026-07-14) + - 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..2eff348a89e --- /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, HMAC-signed challenges (no Redis), and per-provider gating: **edge-info-server** `docs/APP_ATTESTATION.md`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 2b4d8469310..4f32bb280e7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -456,7 +456,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 1876654c96b..dd584e2245f 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..a34a6cfe06a --- /dev/null +++ b/src/__tests__/util/attestation.test.ts @@ -0,0 +1,289 @@ +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('keeps a late valid JWT when a post-watchdog retry fails into backoff', async () => { + const flush = async (): Promise => { + for (let i = 0; i < 10; i++) await Promise.resolve() + } + + // Handshake A hangs in native attestation after fetching a challenge. + let resolveHungAttestation: + | ((value: { + keyId?: string + attestation?: string + bundleId?: string + }) => void) + | undefined + mockGetAttestation.mockImplementation( + async () => + await new Promise(resolve => { + resolveHungAttestation = resolve + }) + ) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-hung' }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + expect(resolveHungAttestation).toBeDefined() + + // Watchdog releases A's lock so a newer attempt can start. + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.HANDSHAKE_WATCHDOG_MS + ) + + // Handshake B fails quickly at the challenge step and enters backoff. + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({}, false, 500) + } + throw new Error(`unexpected path ${path}`) + }) + const failedPromise = getAttestationToken() + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.GET_TOKEN_TIMEOUT_MS + ) + await expect(failedPromise).resolves.toBeUndefined() + + // A finally completes with a valid JWT after B has entered backoff. The + // generation guard must still accept it because nothing fresher is cached. + const expires = Date.now() + 10 * 60 * 1000 + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-late' }) + } + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse({ token: 'late-jwt', expires }) + } + throw new Error(`unexpected path ${path}`) + }) + resolveHungAttestation?.({ + keyId: 'key-late', + attestation: 'att-late', + bundleId: 'co.edgesecure.app' + }) + await flush() + + // During B's backoff window, callers still get A's late token. + await expect(getAttestationToken()).resolves.toBe('late-jwt') + }) + + 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 aa36cb07027..f127a6c0334 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' @@ -436,15 +437,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..50da56ddbae --- /dev/null +++ b/src/util/attestation.ts @@ -0,0 +1,335 @@ +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; used so a stale (watchdog- +// released) completion cannot clobber a token a newer handshake already +// cached (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 a token a newer handshake already + // produced - while still accepting a late valid JWT when nothing is cached + // (the newer attempt may have failed into backoff). + const generation = ++handshakeGeneration + const handshake: Promise = performHandshake(generation) + .then(freshToken => { + if (freshToken == null) { + if (generation === handshakeGeneration) lastFailureAt = 0 + return + } + // A stale (watchdog-released) attempt may still land a valid JWT. Take + // it when nothing live is cached - the newer attempt may have failed + // into backoff - but never clobber a token a newer handshake produced. + if (generation !== handshakeGeneration && hasLiveToken()) return + lastFailureAt = 0 + 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() From c670afc2d222c8acfdf06629973004f9358be70f Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Mon, 27 Jul 2026 21:47:30 -0700 Subject: [PATCH 2/3] fixup! Add app/device attestation for gated info-server requests Schedule a FAILURE_BACKOFF_MS retry after a failed handshake so a proactive refresh that fails does not leave the background engine idle until the next gated call or app restart. Export REFRESH_LEAD_MS for tests and cover the no-gated-call retry path. Co-authored-by: Cursor --- src/__tests__/util/attestation.test.ts | 50 ++++++++++++++++++++++++++ src/util/attestation.ts | 10 +++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/__tests__/util/attestation.test.ts b/src/__tests__/util/attestation.test.ts index a34a6cfe06a..fe4fa94ca11 100644 --- a/src/__tests__/util/attestation.test.ts +++ b/src/__tests__/util/attestation.test.ts @@ -286,4 +286,54 @@ describe('attestation engine', () => { await Promise.resolve() await expect(retryPromise).resolves.toBe('jwt-token') }) + + it('retries a failed proactive refresh after the backoff without a gated call', async () => { + const flush = async (): Promise => { + for (let i = 0; i < 10; i++) await Promise.resolve() + } + + // First handshake succeeds with a token that refreshes in 1s. + const expires = + Date.now() + attestationTimingForTests.REFRESH_LEAD_MS + 1000 + mockSuccessfulHandshake(expires) + + initAttestation() + await flush() + await expect(getAttestationToken()).resolves.toBe('jwt-token') + const callsAfterSuccess = mockFetchInfo.mock.calls.length + + // Next proactive refresh 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}`) + }) + await jest.advanceTimersByTimeAsync(1000) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan(callsAfterSuccess) + const callsAfterFailedRefresh = mockFetchInfo.mock.calls.length + + // After backoff, the engine retries on its own (no getAttestationToken). + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-retry' }) + } + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse({ + token: 'jwt-retried', + expires: Date.now() + 10 * 60 * 1000 + }) + } + throw new Error(`unexpected path ${path}`) + }) + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.FAILURE_BACKOFF_MS + ) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan( + callsAfterFailedRefresh + ) + await expect(getAttestationToken()).resolves.toBe('jwt-retried') + }) }) diff --git a/src/util/attestation.ts b/src/util/attestation.ts index 50da56ddbae..d06f0a8df62 100644 --- a/src/util/attestation.ts +++ b/src/util/attestation.ts @@ -79,7 +79,8 @@ export const resetAttestationForTests = (): void => { export const attestationTimingForTests = { GET_TOKEN_TIMEOUT_MS, HANDSHAKE_WATCHDOG_MS, - FAILURE_BACKOFF_MS + FAILURE_BACKOFF_MS, + REFRESH_LEAD_MS } const hasLiveToken = (): boolean => @@ -293,6 +294,13 @@ const runHandshake = (): void => { if (generation !== handshakeGeneration) return lastFailureAt = Date.now() console.warn('[attestation] handshake failed:', String(error)) + // Keep the background loop alive after a failed proactive refresh: + // scheduleRefresh only runs on success, so without this retry the + // engine would stay idle until getAttestationToken or an app restart. + if (refreshTimer != null) clearTimeout(refreshTimer) + refreshTimer = setTimeout(() => { + runHandshake() + }, FAILURE_BACKOFF_MS) }) .finally(() => { if (inFlight === handshake) inFlight = undefined From 89b7598e1797fbc3f131ad9b08147a3e3ffcf0f3 Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Mon, 27 Jul 2026 22:16:06 -0700 Subject: [PATCH 3/3] fixup! Add app/device attestation for gated info-server requests Bound the handshake retry loop and close the hang path. Retrying every FAILURE_BACKOFF_MS forever means a device that cannot attest (offline, or rejected by the server) burns an Apple App Attest or Android Keystore attestation every minute for as long as the app is open, and both are rate-limited. Double the wait after each consecutive failure up to MAX_BACKOFF_MS instead. The lastFailureAt gate stays at FAILURE_BACKOFF_MS so a gated caller can still retry sooner. Never retry sooner than scheduleRefresh would have: a stale handshake can land a long-lived token while a newer attempt is failing, and the flat backoff replaced that token's refresh with a minute-by-minute re-attest loop. Have the watchdog arm the same retry when it releases the lock. A hung handshake either never settles or rejects with a stale generation, so nothing rescheduled it and the engine sat idle until a gated call - the failure this commit series set out to fix. --- src/__tests__/util/attestation.test.ts | 185 +++++++++++++++++++++++-- src/util/attestation.ts | 66 +++++++-- 2 files changed, 227 insertions(+), 24 deletions(-) diff --git a/src/__tests__/util/attestation.test.ts b/src/__tests__/util/attestation.test.ts index fe4fa94ca11..b9843ea5bcb 100644 --- a/src/__tests__/util/attestation.test.ts +++ b/src/__tests__/util/attestation.test.ts @@ -61,6 +61,11 @@ const { resetAttestationForTests } = require('../../util/attestation') +/** Drain the handshake promise chain without advancing the fake clock. */ +const flush = async (): Promise => { + for (let i = 0; i < 10; i++) await Promise.resolve() +} + const jsonResponse = (body: unknown, ok = true, status = 200): MockResponse => { const response: MockResponse = { ok, @@ -178,10 +183,6 @@ describe('attestation engine', () => { }) it('keeps a late valid JWT when a post-watchdog retry fails into backoff', async () => { - const flush = async (): Promise => { - for (let i = 0; i < 10; i++) await Promise.resolve() - } - // Handshake A hangs in native attestation after fetching a challenge. let resolveHungAttestation: | ((value: { @@ -288,13 +289,11 @@ describe('attestation engine', () => { }) it('retries a failed proactive refresh after the backoff without a gated call', async () => { - const flush = async (): Promise => { - for (let i = 0; i < 10; i++) await Promise.resolve() - } - - // First handshake succeeds with a token that refreshes in 1s. + // First handshake succeeds with a token that refreshes in + // `REFRESH_UNTIL_MS`. + const REFRESH_UNTIL_MS = 1000 const expires = - Date.now() + attestationTimingForTests.REFRESH_LEAD_MS + 1000 + Date.now() + attestationTimingForTests.REFRESH_LEAD_MS + REFRESH_UNTIL_MS mockSuccessfulHandshake(expires) initAttestation() @@ -309,11 +308,18 @@ describe('attestation engine', () => { } throw new Error(`unexpected path ${path}`) }) - await jest.advanceTimersByTimeAsync(1000) + await jest.advanceTimersByTimeAsync(REFRESH_UNTIL_MS) await flush() expect(mockFetchInfo.mock.calls.length).toBeGreaterThan(callsAfterSuccess) const callsAfterFailedRefresh = mockFetchInfo.mock.calls.length + // Nothing happens until the backoff has fully elapsed. + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.FAILURE_BACKOFF_MS - 1 + ) + await flush() + expect(mockFetchInfo.mock.calls.length).toBe(callsAfterFailedRefresh) + // After backoff, the engine retries on its own (no getAttestationToken). mockFetchInfo.mockImplementation(async (path: string) => { if (path === 'v1/attest/challenge') { @@ -336,4 +342,161 @@ describe('attestation engine', () => { ) await expect(getAttestationToken()).resolves.toBe('jwt-retried') }) + + const mockFailingHandshake = (): void => { + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({}, false, 500) + } + throw new Error(`unexpected path ${path}`) + }) + } + + it('doubles the retry backoff after each consecutive failure', async () => { + const { FAILURE_BACKOFF_MS } = attestationTimingForTests + mockFailingHandshake() + + initAttestation() + await flush() + const afterFirst = mockFetchInfo.mock.calls.length + expect(afterFirst).toBeGreaterThan(0) + + // Second attempt lands one backoff after the first failure. + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + const afterSecond = mockFetchInfo.mock.calls.length + expect(afterSecond).toBeGreaterThan(afterFirst) + + // The third waits two backoffs, so one is not enough. + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockFetchInfo.mock.calls.length).toBe(afterSecond) + + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan(afterSecond) + }) + + it('caps the retry backoff so a failing device keeps retrying', async () => { + const { MAX_BACKOFF_MS } = attestationTimingForTests + mockFailingHandshake() + + initAttestation() + await flush() + + // However long the device has been failing, every window of + // MAX_BACKOFF_MS must still hold an attempt. Uncapped doubling goes + // quiet for hours instead. + for (let i = 0; i < 10; i++) { + const callsBefore = mockFetchInfo.mock.calls.length + await jest.advanceTimersByTimeAsync(MAX_BACKOFF_MS) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan(callsBefore) + } + }) + + it('retries on its own after a hung handshake trips the watchdog', 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() + await flush() + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.HANDSHAKE_WATCHDOG_MS + ) + const callsAfterWatchdog = mockFetchInfo.mock.calls.length + + // The hung attempt never settles, so only the watchdog can restart the + // loop. No gated call here. + mockGetAttestation.mockResolvedValue({ + keyId: 'key2', + attestation: 'att2', + bundleId: 'co.edgesecure.app' + }) + mockSuccessfulHandshake(Date.now() + 10 * 60 * 1000) + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.FAILURE_BACKOFF_MS + ) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan(callsAfterWatchdog) + await expect(getAttestationToken()).resolves.toBe('jwt-token') + }) + + it('does not pull a live token refresh sooner after a failed attempt', async () => { + // Handshake A hangs in native attestation after fetching a challenge. + let resolveHungAttestation: + | ((value: { + keyId?: string + attestation?: string + bundleId?: string + }) => void) + | undefined + mockGetAttestation.mockImplementation( + async () => + await new Promise(resolve => { + resolveHungAttestation = resolve + }) + ) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-hung' }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + + // Watchdog releases A's lock and arms a retry. + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.HANDSHAKE_WATCHDOG_MS + ) + + // Handshake B stalls on its challenge so we control when it fails, while A + // can still complete with a long-lived token. + const expires = Date.now() + 60 * 60 * 1000 + let rejectChallenge: ((error: Error) => void) | undefined + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return await new Promise((resolve, reject) => { + rejectChallenge = reject + }) + } + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse({ token: 'jwt-long', expires }) + } + throw new Error(`unexpected path ${path}`) + }) + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.FAILURE_BACKOFF_MS + ) + await flush() + expect(rejectChallenge).toBeDefined() + + // A lands its token first, scheduling a refresh an hour out. + resolveHungAttestation?.({ + keyId: 'key-late', + attestation: 'att-late', + bundleId: 'co.edgesecure.app' + }) + await flush() + await expect(getAttestationToken()).resolves.toBe('jwt-long') + + // B then fails. Its backoff retry must not replace A's refresh. + rejectChallenge?.(new Error('challenge failed')) + await flush() + const callsAfterFailure = mockFetchInfo.mock.calls.length + + await jest.advanceTimersByTimeAsync(10 * 60 * 1000) + await flush() + expect(mockFetchInfo.mock.calls.length).toBe(callsAfterFailure) + await expect(getAttestationToken()).resolves.toBe('jwt-long') + }) }) diff --git a/src/util/attestation.ts b/src/util/attestation.ts index d06f0a8df62..c6e0108e268 100644 --- a/src/util/attestation.ts +++ b/src/util/attestation.ts @@ -55,11 +55,19 @@ const HANDSHAKE_WATCHDOG_MS = 90 * 1000 // for this long. Keeps a persistently-failing device from adding 3s of // latency to every gated request. const FAILURE_BACKOFF_MS = 60 * 1000 +// Ceiling for the exponential retry backoff. A device that keeps failing +// (offline, or rejected by the server) must not re-attest every minute for as +// long as the app is open: each attempt burns an Apple App Attest / Android +// Keystore attestation, and both are rate-limited. +const MAX_BACKOFF_MS = 30 * 60 * 1000 let cachedToken: CachedToken | undefined let inFlight: Promise | undefined let refreshTimer: ReturnType | undefined let lastFailureAt = 0 +// Failed (or hung) attempts since the last success, used to grow the retry +// backoff. Reset wherever `lastFailureAt` is cleared. +let consecutiveFailures = 0 // Monotonic id of the latest handshake attempt; used so a stale (watchdog- // released) completion cannot clobber a token a newer handshake already // cached (see runHandshake). @@ -72,6 +80,7 @@ export const resetAttestationForTests = (): void => { if (refreshTimer != null) clearTimeout(refreshTimer) refreshTimer = undefined lastFailureAt = 0 + consecutiveFailures = 0 handshakeGeneration = 0 } @@ -80,6 +89,7 @@ export const attestationTimingForTests = { GET_TOKEN_TIMEOUT_MS, HANDSHAKE_WATCHDOG_MS, FAILURE_BACKOFF_MS, + MAX_BACKOFF_MS, REFRESH_LEAD_MS } @@ -262,10 +272,35 @@ const scheduleRefresh = (expires: number): void => { }, delayMs) } +/** + * Schedule the next handshake after a failed or hung attempt. `scheduleRefresh` + * only runs on success, so without this the engine would sit idle until a gated + * call or an app restart. The wait doubles with each consecutive failure up to + * `MAX_BACKOFF_MS` to keep a hopeless device from re-attesting forever. + */ +const scheduleRetryAfterFailure = (): void => { + const backoffMs = Math.min( + FAILURE_BACKOFF_MS * 2 ** Math.max(0, consecutiveFailures - 1), + MAX_BACKOFF_MS + ) + // A cached token may still have most of its life left - a stale handshake can + // land one while a newer attempt is failing. Never retry sooner than + // `scheduleRefresh` would have, or a failing device would re-attest every + // backoff while a perfectly good token sits in the cache. + const refreshMs = + cachedToken == null ? 0 : cachedToken.expires - Date.now() - REFRESH_LEAD_MS + if (refreshTimer != null) clearTimeout(refreshTimer) + refreshTimer = setTimeout(() => { + runHandshake() + }, Math.max(backoffMs, refreshMs)) +} + /** * 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. + * the token and schedules the next refresh; on failure or hang, schedules a + * backoff retry. Every exit path must leave either a handshake in flight or a + * timer armed, or the background engine stalls. */ const runHandshake = (): void => { if (inFlight != null) return @@ -279,7 +314,10 @@ const runHandshake = (): void => { const handshake: Promise = performHandshake(generation) .then(freshToken => { if (freshToken == null) { - if (generation === handshakeGeneration) lastFailureAt = 0 + if (generation === handshakeGeneration) { + lastFailureAt = 0 + consecutiveFailures = 0 + } return } // A stale (watchdog-released) attempt may still land a valid JWT. Take @@ -287,20 +325,16 @@ const runHandshake = (): void => { // into backoff - but never clobber a token a newer handshake produced. if (generation !== handshakeGeneration && hasLiveToken()) return lastFailureAt = 0 + consecutiveFailures = 0 cachedToken = freshToken scheduleRefresh(freshToken.expires) }) .catch((error: unknown) => { if (generation !== handshakeGeneration) return lastFailureAt = Date.now() + consecutiveFailures += 1 console.warn('[attestation] handshake failed:', String(error)) - // Keep the background loop alive after a failed proactive refresh: - // scheduleRefresh only runs on success, so without this retry the - // engine would stay idle until getAttestationToken or an app restart. - if (refreshTimer != null) clearTimeout(refreshTimer) - refreshTimer = setTimeout(() => { - runHandshake() - }, FAILURE_BACKOFF_MS) + scheduleRetryAfterFailure() }) .finally(() => { if (inFlight === handshake) inFlight = undefined @@ -309,10 +343,16 @@ const runHandshake = (): void => { // 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 - } + if (inFlight !== handshake) return + console.warn('[attestation] handshake watchdog fired; releasing lock') + inFlight = undefined + // A hang leaves nothing to re-arm the loop: the hung attempt either never + // settles or rejects once a newer generation owns the state, and neither + // schedules a retry. Count it as a failure and retry, or the engine sits + // idle until a gated call. `lastFailureAt` stays untouched so a gated + // caller can still start a fresh handshake right away. + consecutiveFailures += 1 + scheduleRetryAfterFailure() }, HANDSHAKE_WATCHDOG_MS) }