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..9a67320dba0 --- /dev/null +++ b/android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt @@ -0,0 +1,295 @@ +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.MessageDigest +import java.security.spec.ECGenParameterSpec +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock + +/** + * 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 = ReentrantLock() + + // Caps how long an operation waits for keystoreLock. Keystore work is local + // and synchronous, so a wedged generateKeyPair or sign cannot be interrupted + // - but the operations queued behind it can refuse to wait forever, which is + // what stops one wedge from taking down every later attestation, refresh and + // clear for the life of the process. Well above a slow-but-healthy attested + // key generation, and below the JS watchdog so JS gets a real rejection + // instead of timing out blind. + private const val LOCK_TIMEOUT_SECONDS = 60L + } + + override fun getName(): String = "EdgeAttestation" + + /** + * Runs [body] holding [keystoreLock], rejecting with `lockTimeout` if the lock + * cannot be acquired within [LOCK_TIMEOUT_SECONDS]. + * + * That rejection code matters twice over. The JS engine reads it as saying + * nothing about whether the enrolled key can sign, so it retries the cheap + * refresh path instead of escalating to a full attestation. It also reads it as + * proof that no attestation was spent - this fires before the lock is held, so + * [body] never ran and no key was generated - which keeps a contended lock from + * doubling the failure backoff. It is deliberately not the `timeout` iOS + * reports: that one fires while waiting on an App Attest callback, so the + * platform operation did start and may have counted against the quota. + */ + private fun withKeystoreLock( + promise: Promise, + body: () -> Unit + ) { + try { + if (!keystoreLock.tryLock(LOCK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + promise.reject("lockTimeout", "Timed out waiting for the Keystore lock") + return + } + } catch (interrupted: InterruptedException) { + // Nothing interrupts these threads today: they are plain Threads and no + // reference to them is kept. But this call sits outside the try/catch the + // callers wrap their own work in, so an escape here would leave the + // promise unsettled and take the process down with an uncaught exception + // on a background thread. Swapping Thread for an executor or a coroutine, + // where interruption is how cancellation arrives, would make that + // reachable without anyone touching this file. The same code as the + // timeout is right: the lock was never held, so nothing was spent. + promise.reject("lockTimeout", "Interrupted waiting for the Keystore lock") + return + } + try { + body() + } finally { + keystoreLock.unlock() + } + } + + /** + * `keyId = base64url(SHA-256(leaf SPKI))` for the enrolled key, matching the + * server's derivation. Null when no key is enrolled. + */ + private fun currentKeyId(keyStore: KeyStore): String? { + val cert = keyStore.getCertificate(KEY_ALIAS) ?: return null + return Base64.encodeToString( + MessageDigest.getInstance("SHA-256").digest(cert.publicKey.encoded), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + ) + } + + @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 { + withKeystoreLock(promise) { + 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@withKeystoreLock + } + + // 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 { + withKeystoreLock(promise) { + 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@withKeystoreLock + } + val keyId = currentKeyId(keyStore) + if (keyId == null) { + promise.reject("noKey", "No attested key is stored") + return@withKeystoreLock + } + 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( + keyId: String?, + promise: Promise + ) { + // Off the caller's thread like the other two methods. This runs on the + // shared native-modules thread, and keystoreLock can be held for seconds by + // an in-progress getAttestation - attested EC key generation is slow, more + // so for StrongBox. Waiting for it here would stall every native module in + // the app, and JS calls this exactly when a handshake is already in flight. + Thread { + // Best-effort: force re-enrollment when the server rejects an assertion + // (unknown key, revoked serial, disabled app). Resolves even when the + // delete fails, since JS treats this as advisory - the only case that + // rejects is failing to acquire the lock, and JS ignores that too. A + // getAttestation replaces the alias regardless of whether this succeeded. + try { + withKeystoreLock(promise) { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + // Only the key JS named. Waiting for the lock can take a while, and a + // newer handshake may have enrolled a replacement in the meantime - + // deleting that one would discard a working key over a verdict about + // its predecessor. A null id means discard whatever is stored. + if (keyId != null && currentKeyId(keyStore) != keyId) { + promise.resolve(null) + return@withKeystoreLock + } + keyStore.deleteEntry(KEY_ALIAS) + promise.resolve(null) + } + } catch (ignored: Exception) { + // Best effort. + promise.resolve(null) + } + }.start() + } +} 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..c49dee50393 --- /dev/null +++ b/docs/APP_ATTESTATION.md @@ -0,0 +1,246 @@ +# 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 — every handshake after 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: Only on no/invalid key or a rejected assertion:
fresh challenge, then full (rate-limited) attestation + Eng->>Info: GET /v1/attest/challenge + 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.
On failure or hang: back off and retry + + 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 ensure a handshake is running and wait ≤ **3s**, then return JWT or `undefined`. While the engine is backing off it starts nothing, so the caller gets `undefined` **immediately** rather than paying the 3s wait | +| Refresh | On success, `setTimeout` to re-handshake **2 minutes before** `expires`, floored at **60s** | +| Clock skew | Token treated expired within **5s** of `expires` | +| Watchdog | A handshake pending after **90s** is treated as hung: the lock is released, the attempt is retired, and it counts as a failure | + +Single-flight, and only one clock. At most one handshake runs at a time (`inFlight`), and every path that declines to start one re-arms the single background timer. A path that neither starts a handshake nor arms a timer would stall the engine until the next gated call, so the only deliberate dead end is a device that reports it can never attest. + +### Handshake steps + +1. `GET v1/attest/challenge` via `fetchInfo` +2. **Refresh with the enrolled key** — `generateAssertion` (iOS) / `signChallenge` (Android), then `POST v1/attest/{apple,android}/assert`. Local signature, no Apple/Google round trip, no new key. This is the path every handshake after enrollment takes +3. Only if there is no usable key, or the server rejects the one we have: fetch a fresh challenge, `EdgeAttestation.getAttestation(challenge)`, `POST v1/attest/apple` or `v1/attest/android` +4. Cache `{ token, expires }` from the JSON body (`expires` is epoch **milliseconds**) + +`token` and `expires` are both validated, and `expires` must be in the future: a token that is unusable on arrival is treated as a failed handshake rather than cached, so a bad mint or a skewed device clock cannot leave the engine believing it succeeded. + +**Step 3 is the rate-limited path, so escalating to it is deliberate.** It is reserved for the two things it can actually fix — a key that cannot sign, and a key the server has judged and rejected: + +| Outcome of step 2 | Result | +| --- | --- | +| `200`, usable body | Done, token cached | +| `200`, unusable body (bad `expires`, malformed) | **Fail into backoff.** Re-attesting cannot fix a bad mint, and would spend quota every retry to hide it | +| `4xx` other than `429` | **Escalate.** The server looked at the key and said no, so clear it and re-enroll | +| `5xx` or `429` | **Fail into backoff.** The server failed to answer or throttled us; neither is a judgement on the key. Reading these as a rejection would have the whole fleet discard its keys and re-attest during an info-server outage — a fleet-wide run at the platform rate limits, caused by something that fixes itself | +| Native `noKey` / `invalidKey` / signing failure | **Escalate.** The key cannot sign, so re-enrolling is the only way forward | +| Native `timeout` (iOS) | **Fail into backoff.** Says nothing about whether the key can sign. Raised when an App Attest operation outlives its 120s bound | +| Native `lockTimeout` (Android) | **Fail into backoff, without growing it.** Raised when the Keystore lock cannot be acquired in 60s. The two codes are distinct on purpose: this one fires *before* the lock is held, so no key was generated and nothing rate-limited was spent, whereas iOS's `timeout` fires waiting on an `attestKey` callback that did start. Sharing one code would make a contended lock double the backoff toward `MAX_BACKOFF_MS` over failures that cost nothing | + +Failures are logged (`console.warn`) and never thrown to boot. + +Only failures that spent a platform attestation grow the backoff. The engine marks an attempt as having spent one just before it calls native, since a call that hangs or never answers may well have consumed the attestation — the flag is then withdrawn if native reports a code that proves the operation never ran (see `lockTimeout` above). Erring in this direction is deliberate: under-counting real quota burn is the expensive mistake, while over-counting only silences a device that could have retried. + +The watchdog counts an outstanding call on the same assumption, and that verdict can arrive too late to be right: the 90s watchdog starts at the top of the handshake, so a slow challenge fetch leaves Android's 60s lock timeout landing after the attempt has been retired. A retired attempt that settles saying it spent nothing therefore **takes its own count back**, once, leaving any newer attempt's count alone. A retired attempt that settles for a reason which may have spent quota keeps it. + +### Backoff + +Platform attestation is rate-limited on both platforms, and tripping those limits locks out exactly the devices that could otherwise recover. So the engine backs off by what a failure actually cost: + +| Failure | Next attempt | +| --- | --- | +| Before any attestation is spent (offline, info server down, challenge failed) | **60s**, flat — nothing rate-limited was spent and the network may be back any moment | +| After the platform attestation was produced (server rejected it, or the native call hung inside it) | 60s **doubling** per consecutive failure, capped at **30 min** | + +Two properties matter more than the numbers: + +- **Gated callers obey the same policy.** `getAttestationToken()` cannot outpace it — the Banxa order screen polls every 3s, and a flat or timer-only gate would let plugin traffic re-attest continuously no matter how far the backoff had grown. +- **A hang is a failure.** The watchdog records the failure time, not just the count. Recording only the count leaves the gate reading a timestamp no hang ever set, which re-opens the same continuous-re-attestation hole. + +There is also a floor (**30s**) between handshake starts regardless of outcome. The backoff only covers failures and the refresh floor only covers the timer, but a token whose lifetime is shorter than either leaves a window with nothing cached where every gated call would want a handshake of its own. + +### Retired attempts + +The watchdog can release the lock while an older native call is still outstanding, so two handshakes can overlap. Each attempt carries a generation, and the watchdog retires the one it abandons. A retired attempt: + +- **stops** rather than finishing. Continuing would clear the key a live handshake just enrolled and spend a rate-limited attestation nobody is waiting on +- may still **land a late valid JWT**, which is accepted when nothing live is cached (the newer attempt may have failed into backoff) but never clobbers a fresher token +- cannot count its failure twice, since the watchdog already counted it + +A `false` from `isSupported()` is terminal — the engine stops. A native *rejection* is not: that is the bridge failing to answer, so it retries on the normal backoff. + +## 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. + +Two Keychain accounts under that service: + +| Account | Holds | +| --- | --- | +| `keyId` | A successfully attested key, reused for assertions | +| `pendingKeyId` | A key that was generated but whose attestation has not succeeded yet | + +`generateKey` is a limited resource, and a key whose `attestKey` failed was never consumed — so a failed attestation keeps its key in `pendingKeyId` and the next `getAttestation` retries that one instead of burning a new one. This follows Apple's guidance to retry `DCError.serverUnavailable` with the same key. Any other `attestKey` error may be permanent for that key, so it is discarded rather than retried for the life of the install. `clearKey()` clears **only** `keyId`: JS calls it when the *server* rejects an assertion, which says nothing about a pending key the server has never seen. + +**Concurrency:** `serialQueue` serializes all key operations, because the JS watchdog can start a second handshake while an older native call is still running. Each async operation holds the queue on a semaphore, bounded by a **120s** timeout — above the JS watchdog so JS gives up first. Without that bound, an `attestKey` that never calls back would wedge the queue for the life of the process and every later operation would block behind it, including the `clearKey` the JS engine uses to recover. Promises settle exactly once (`PromiseOnce`), since the timeout and a late callback can both reach for the same one. + +**Late callbacks may not speak for the current key.** Giving up on the timeout releases the queue while the App Attest callback is still outstanding, so it can run alongside a newer operation that has since generated or enrolled a different key. Two rules keep a stale callback from doing damage: + +- A late `attestKey` **success** does not enrol its key. The attestation object went out with the handshake that already failed, so the server never verified that key and would reject an assertion from it; storing it would only cost the next handshake a pointless round trip before it re-attests anyway. +- Every clear from a callback is conditional on the stored id still being the one that operation was working on (`ifMatches`). Otherwise a verdict about an old key would delete a newer one — costing a fresh `generateKey`, or in the `invalidKey` case a full rate-limited attestation to replace an enrolled key that was working fine. +- A late `generateKey` **stops without recording its key or attesting it**, as soon as it sees its own promise has already settled. Storing would clobber a pending key a newer handshake owns and lose it, since this operation clears the slot after its own `attestKey` — so the next attempt would spend another `generateKey`. Attesting is worse: the promise is settled, so `storeKeyId` refuses the result and a rate-limited attestation plus the key are spent on something nothing can use. The promise is the ownership test rather than the state of the pending slot, which only says whether a newer handshake has reached that point yet; it is also already atomic, where a load-then-store on the slot is not. + +The key is still dropped from `pendingKeyId` after a successful `attestKey`, late or not, because it can never be attested again. + +Both of those last two rules are checks against state that can change immediately afterwards, and neither window is closable. The timeout owes JS an answer at 120s whatever a callback is doing, and an `attestKey` already handed to Apple cannot be recalled, so the most any check can establish is that the operation was live a moment ago. A second look at the pending slot would not help: what saves the attestation is the settlement check, already as late as it can be, and a store that wins the race to an empty slot passes any such test anyway. The cost of landing in either window is one wasted attestation and a pending key the next re-enrollment discards, since Apple will not attest it twice — bounded, self-healing, and reached only by a callback that returns within microseconds of a two-minute deadline. + +The Swift class reaches JS through a hand-written Objective-C bridge (`EdgeAttestation.m`) that redeclares every selector. Nothing catches drift between them: `swiftc` never reads the bridge, and an `RCT_EXTERN_METHOD` mismatch is not a build error — React Native only discovers it on the device, as a selector that fails to resolve. `src/__tests__/util/attestationNativeBridge.test.ts` compares the two files so a signature change cannot ship half-applied. + +**`clearKey(keyId)` is scoped the same way, on both platforms.** It is tempting to treat it as unconditional on the grounds that it runs under the lock and is about whatever is enrolled now, but that is wrong for the same reason: the call can wait a long time for the lock, and by the time it runs a newer handshake may have enrolled a replacement. JS therefore names the key the server actually refused — the `keyId` from the assertion that came back rejected — and native drops it only while it is still the stored one. Passing no id keeps the old "discard whatever is there" behaviour. + +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). + +**Concurrency:** all three methods take `keystoreLock`, since they read and mutate the one shared alias and the JS watchdog can overlap two handshakes. Each does so on its **own spawned `Thread`**, never on the shared native-modules thread: attested EC key generation is slow (more so for StrongBox), so `getAttestation` can hold the lock for seconds, and anything waiting on the native-modules thread would stall every other native module in the app. That matters most for `clearKey`, which JS calls precisely when a handshake is already in flight. + +The lock is a `ReentrantLock` acquired with a **60s** `tryLock`, not `synchronized`. Keystore work is local and synchronous, so a wedged `generateKeyPair` or `sign` cannot be interrupted the way the iOS semaphore bounds a hung `attestKey` — but the operations queued behind it can refuse to wait forever, which is what stops one wedge from taking down every later attestation, refresh and clear for the life of the process. Failing to acquire rejects with **`lockTimeout`**, which the JS engine reads as transient (so a wedge retries the cheap path instead of escalating) and also as proof that nothing rate-limited was spent (so the backoff stays flat rather than doubling). The bound sits below the 90s JS watchdog so JS gets a real rejection rather than timing out blind. + +Unlike App Attest there is no separate pending-key state, and no rate limit to protect: attestation is a local Keystore operation, so a failed or discarded attempt just regenerates. That is why the JS engine's default of escalating an unrecognised native signing failure to a full attestation is the right trade-off here, even though the same default would be expensive on iOS. + +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/__tests__/util/attestation.test.ts` | Engine tests: backoff, watchdog, retired attempts, token validation | +| `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 | + +The engine tests drive the module with fake timers and mocked `fetchInfo` / native calls. Timing constants are exported as `attestationTimingForTests` so a test never hardcodes a duration, and `resetAttestationForTests()` clears module state between cases. Note that `flush()` drains microtasks generously on purpose: a handshake is a long chain of awaits, and a short drain silently samples a half-finished one and reads as "nothing happened". + +## 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..10dcb7ca0d6 --- /dev/null +++ b/ios/edge/EdgeAttestation.m @@ -0,0 +1,29 @@ +#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) + +// keyId is nullable: JS passes the key it means to discard, or null for +// whichever one is stored. Keep this declaration in step with the Swift +// @objc selector - a mismatch is not a build error, it fails at runtime. +RCT_EXTERN_METHOD(clearKey + : (NSString *)keyId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/ios/edge/EdgeAttestation.swift b/ios/edge/EdgeAttestation.swift new file mode 100644 index 00000000000..4f4de4c32bb --- /dev/null +++ b/ios/edge/EdgeAttestation.swift @@ -0,0 +1,403 @@ +import CryptoKit +import DeviceCheck +import Foundation +import React +import Security + +/// Guarantees a React Native promise settles exactly once. The operation timeout +/// in `getAttestation` / `generateAssertion` and a late App Attest callback can +/// both reach for the same promise, and settling one twice is a hard error in +/// React Native. +private final class PromiseOnce { + private let lock = NSLock() + private var isSettled = false + private let resolveBlock: RCTPromiseResolveBlock + private let rejectBlock: RCTPromiseRejectBlock + + init( + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + self.resolveBlock = resolve + self.rejectBlock = reject + } + + private func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + if isSettled { return false } + isSettled = true + return true + } + + /// Whether the promise has already settled, so a callback can tell that it + /// outlived its operation: the only thing that settles one early is the + /// operation timeout, and JS has stopped waiting by then. + var hasSettled: Bool { + lock.lock() + defer { lock.unlock() } + return isSettled + } + + /// Returns whether this call is the one that settled the promise, so a caller + /// can skip work that only makes sense if JS actually receives the result. + @discardableResult + func resolve(_ value: Any?) -> Bool { + guard claim() else { return false } + resolveBlock(value) + return true + } + + @discardableResult + func reject(_ code: String, _ message: String, _ error: Error? = nil) -> Bool { + guard claim() else { return false } + rejectBlock(code, message, error) + return true + } +} + +/// Native bridge for iOS App Attest (app-level attestation). +/// +/// Exposes to JS: +/// - isSupported(): resolves true only on real devices that support App Attest +/// - getAttestation(challenge): attests an App Attest key against +/// SHA256(challenge) and resolves { keyId, attestation }, where attestation +/// is the base64-encoded CBOR attestation object +/// - generateAssertion(challenge): refreshes using the attested key +/// - clearKey(): discards the attested key so the next handshake re-attests +@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" + ) + + // Caps how long one operation may hold `serialQueue`. attestKey can fail to + // call back at all on a bad network, and an unbounded wait would wedge the + // queue for the life of the process: every later getAttestation, + // generateAssertion and clearKey would block behind it forever, including the + // clearKey the JS engine uses to recover. Sized above the JS watchdog so JS + // still gives up first in the normal case and this only catches the wedge. + // + // Giving up releases the queue while the App Attest callback is still + // outstanding, so that callback can later run alongside a newer operation. Its + // verdict applies only to the key it was given, which by then may not be the + // stored one - hence the `ifMatches` clears below. + private static let operationTimeout: DispatchTimeInterval = .seconds(120) + + // Keychain persistence for App Attest key ids. 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. + // + // `keyId` holds a successfully attested key, reused for assertions so later + // handshakes never re-attest. `pendingKeyId` holds a key that was generated + // but not yet attested, kept only across failures Apple says to retry (see + // getAttestation) so a transient outage does not burn a new key per attempt. + private static let keychainService = "co.edgesecure.app.appattest" + private static let keychainAccount = "keyId" + private static let keychainPendingAccount = "pendingKeyId" + + private func storeAccount(_ account: String, value: String) { + clearAccount(account) + guard let data = value.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: EdgeAttestation.keychainService, + kSecAttrAccount as String: account, + kSecValueData as String: data + ] + SecItemAdd(query as CFDictionary, nil) + } + + private func loadAccount(_ account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: EdgeAttestation.keychainService, + kSecAttrAccount as String: account, + 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 clearAccount(_ account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: EdgeAttestation.keychainService, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + } + + /// Clears the stored id only while it is still the one the caller was working + /// on. + /// + /// An App Attest callback can arrive after `operationTimeout` released the + /// queue, by which point a newer handshake may have generated or enrolled a + /// different key. An unconditional delete would then throw away that newer + /// key on the strength of a verdict about an older one - costing a fresh + /// `generateKey`, or worse, a full rate-limited attestation to replace an + /// enrolled key that was working fine. + /// + /// Read-then-delete is not atomic, so a callback racing an operation that is + /// mid-write can still clear the newer id. That window is microseconds against + /// the two-minute one it closes, and it costs a retry rather than corrupting + /// anything. + private func clearAccount(_ account: String, ifMatches keyId: String) { + guard loadAccount(account) == keyId else { return } + clearAccount(account) + } + + private func storeKeyId(_ keyId: String) { + storeAccount(EdgeAttestation.keychainAccount, value: keyId) + } + + private func loadKeyId() -> String? { + return loadAccount(EdgeAttestation.keychainAccount) + } + + private func clearKeyId() { + clearAccount(EdgeAttestation.keychainAccount) + } + + private func clearKeyId(ifMatches keyId: String) { + clearAccount(EdgeAttestation.keychainAccount, ifMatches: keyId) + } + + private func storePendingKeyId(_ keyId: String) { + storeAccount(EdgeAttestation.keychainPendingAccount, value: keyId) + } + + private func loadPendingKeyId() -> String? { + return loadAccount(EdgeAttestation.keychainPendingAccount) + } + + private func clearPendingKeyId(ifMatches keyId: String) { + clearAccount(EdgeAttestation.keychainPendingAccount, ifMatches: keyId) + } + + @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 or `operationTimeout` gives up on it. + let promise = PromiseOnce(resolve: resolve, reject: reject) + EdgeAttestation.serialQueue.async { + let done = DispatchSemaphore(value: 0) + + // The client data is the challenge's UTF-8 bytes; the server recomputes + // SHA256(challenge) to validate the attestation nonce. + let attest: (String) -> Void = { keyId in + let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8))) + + service.attestKey(keyId, clientDataHash: clientDataHash) { attestation, error in + defer { done.signal() } + if let error = error { + // Apple's guidance is to retry a serverUnavailable attestation + // later with the same key, because generating keys is a limited + // resource. Any other failure may be permanent for this key, so + // discard it rather than retrying a dead key on every handshake + // for the life of the install. + let isRetryable = (error as? DCError)?.code == .serverUnavailable + if !isRetryable { self.clearPendingKeyId(ifMatches: keyId) } + promise.reject("attestKey", error.localizedDescription, error) + return + } + guard let attestation = attestation else { + self.clearPendingKeyId(ifMatches: keyId) + promise.reject("attestKey", "Failed to produce an attestation object") + return + } + // Persist the key id so subsequent handshakes refresh via assertions + // instead of a full (rate-limited) attestation - but only once we know + // JS is actually receiving this attestation. A callback that loses the + // race arrives after the timeout below already failed the handshake, + // which discarded the attestation object with it, so the server will + // never have verified this key. Enrolling it anyway would cost the next + // handshake a pointless assertion round trip before it re-attests. + if promise.resolve([ + "keyId": keyId, + "attestation": attestation.base64EncodedString(), + "bundleId": Bundle.main.bundleIdentifier ?? "" + ]) { + self.storeKeyId(keyId) + } + // Either way the key is spent: attestKey succeeded, so it can never be + // attested again and must not be retried as a pending key. + self.clearPendingKeyId(ifMatches: keyId) + } + } + + // A key may only be attested once, so a successful handshake always + // needs a new one - but a key whose attestation failed was never + // consumed. Retry that one before asking for another. + if let pendingKeyId = self.loadPendingKeyId() { + attest(pendingKeyId) + } else { + service.generateKey { keyId, error in + if let error = error { + promise.reject("generateKey", error.localizedDescription, error) + done.signal() + return + } + guard let keyId = keyId else { + promise.reject("generateKey", "Failed to generate an App Attest key") + done.signal() + return + } + // A callback that outlived its operation must not touch the stored + // state. Storing would clobber a pending key a newer handshake owns, + // losing it: this operation clears the slot after its own attestKey, + // so the next attempt would spend another `generateKey`. Attesting is + // worse still - the timeout has already settled the promise, so + // `storeKeyId` would refuse the result and a rate-limited attestation + // plus the key itself would be spent on something nothing can use. + // + // The promise is the ownership test rather than the state of the + // pending slot: an empty slot only implies no newer handshake has + // reached this point yet, whereas a settled promise means this one is + // over. Rejecting here would be a no-op for the same reason. + // + // The gap between this check and the two lines after it cannot be + // closed. The timeout has to answer JS at 120s whatever this callback + // is doing, and an attestKey already handed to Apple cannot be + // recalled, so any test can only ever be "unsettled a moment ago". + // Nothing further would help either: what saves the attestation is + // this check, placed as late as it can be, and a second look at the + // pending slot would not stop a store that wins the race to an empty + // one. Landing in the gap costs one attestation and leaves a pending + // key the next re-enrollment discards on Apple's refusal to attest it + // twice - bounded and self-healing, like the `ifMatches` window above. + if promise.hasSettled { + done.signal() + return + } + // Record the key before attesting it, so an attestKey failure Apple + // wants retried can reuse it instead of burning a new one. + self.storePendingKeyId(keyId) + attest(keyId) + } + } + + if done.wait(timeout: .now() + EdgeAttestation.operationTimeout) == .timedOut { + // Leave the pending key id in place: the attestation never completed, so + // the key was never consumed and the next attempt should reuse it. + promise.reject("timeout", "App Attest attestation timed out") + } + } + } + + @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 + } + + let promise = PromiseOnce(resolve: resolve, reject: reject) + EdgeAttestation.serialQueue.async { + guard let keyId = self.loadKeyId() else { + promise.reject("noKey", "No attested App Attest key is stored") + 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. + // Only if it is still the enrolled one: a callback that arrives after + // the timeout below may be condemning a key a newer handshake has + // already replaced, and deleting that replacement would spend a full + // attestation to enrol a key we just had. + self.clearKeyId(ifMatches: keyId) + promise.reject("invalidKey", "Stored App Attest key is invalid", error) + return + } + if let error = error { + promise.reject("generateAssertion", error.localizedDescription, error) + return + } + guard let assertion = assertion else { + promise.reject("generateAssertion", "Failed to produce an assertion") + return + } + promise.resolve([ + "keyId": keyId, + "assertion": assertion.base64EncodedString(), + "bundleId": Bundle.main.bundleIdentifier ?? "" + ]) + } + if done.wait(timeout: .now() + EdgeAttestation.operationTimeout) == .timedOut { + promise.reject("timeout", "App Attest assertion timed out") + } + } + } + + @objc(clearKey:resolver:rejecter:) + func clearKey( + _ keyId: String?, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + EdgeAttestation.serialQueue.async { + // Only the attested key. JS calls this when the *server* rejects an + // assertion, which says nothing about a pending key the server has never + // seen - and discarding that one would throw away the retry it is held for. + // + // Scoped to the key JS named. This block can wait a long time for the + // queue, and running on "whatever is enrolled now" would let a verdict + // about a rejected key delete the replacement a newer handshake enrolled + // in the meantime - costing a full attestation to re-enrol a key that + // worked. A nil id means the caller really does want whatever is stored. + if let keyId = keyId { + self.clearKeyId(ifMatches: keyId) + } else { + 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..5b214780a44 --- /dev/null +++ b/scripts/extractSigningCert.ts @@ -0,0 +1,172 @@ +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. The password reaches keytool over stdin, so it never +// appears in keytool's argv; note that --storepass still puts it in this +// script's own argv, so prefer the env var or the prompt for the real keystore. +// 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 keytoolArgs = [ + '-list', + '-v', + '-J-Duser.language=en', + '-keystore', + args.keystore + ] + if (args.alias != null) keytoolArgs.push('-alias', args.alias) + const output = runKeytool(keytoolArgs, 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 + +/** + * Run keytool and return its stdout. + * + * Arguments are passed as an array rather than a shell string. Keystore + * passwords routinely contain characters the shell acts on, and interpolating + * them yields either a silently wrong password or an outright syntax error + * instead of a digest - `se$cret`x123` fails with "unexpected EOF". The + * password goes over stdin rather than `-storepass`, which keeps it out of the + * process list too; keytool prompts for it on stderr, leaving stdout parseable. + */ +function runKeytool(args: string[], storepass: string): string { + try { + return childProcess.execFileSync('keytool', args, { + encoding: 'utf8', + input: `${storepass}\n`, + timeout: 600000, + killSignal: 'SIGKILL', + stdio: ['pipe', 'pipe', 'pipe'] + }) + } catch (error) { + // keytool reports why it failed ("keystore password was incorrect", "Alias + // does not exist") on *stdout*, followed by a Java stack trace; + // stderr holds only the password prompt. Surface the first line, so the + // developer gets the reason instead of a bare non-zero exit. + const stdout = (error as { stdout?: string }).stdout ?? '' + const reason = stdout + .split('\n') + .find(line => line.startsWith('keytool error:')) + mylog(reason ?? stdout.split('\n')[0]) + throw error + } +} + +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..b6f2e6f526f --- /dev/null +++ b/src/__tests__/util/attestation.test.ts @@ -0,0 +1,1539 @@ +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<(keyId?: string) => 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') + +/** + * Drain the handshake promise chain without advancing the fake clock. A + * handshake is a long series of awaits (challenge, assertion, second challenge, + * native attestation, token POST, then the commit/schedule tail), so drain + * generously: a short drain samples a half-finished handshake and reads as + * "nothing happened". + */ +const flush = async (): Promise => { + for (let i = 0; i < 100; i++) await Promise.resolve() +} + +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('fails the handshake when expires is already past', async () => { + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + return jsonResponse({ token: 'jwt-token', expires: Date.now() - 1 }) + }) + + initAttestation() + const tokenPromise = getAttestationToken() + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.GET_TOKEN_TIMEOUT_MS + ) + await expect(tokenPromise).resolves.toBeUndefined() + const callsAfterMint = mockFetchInfo.mock.calls.length + + // Caching an unusable token would leave the engine in its success state, + // free to hand the next caller straight back into another handshake. + await expect(getAttestationToken()).resolves.toBeUndefined() + await flush() + expect(mockFetchInfo.mock.calls.length).toBe(callsAfterMint) + }) + + 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 flush() + + // Watchdog fires and clears the lock. + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.HANDSHAKE_WATCHDOG_MS + ) + + // A subsequent attempt can start once the lock is released and the hang's + // backoff has elapsed. + 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() + + await expect(getAttestationToken()).resolves.toBe('jwt-token') + }) + + it('keeps a late valid JWT when a post-watchdog retry fails into backoff', 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() + expect(resolveHungAttestation).toBeDefined() + + // Watchdog releases A's lock so a newer attempt can start. + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.HANDSHAKE_WATCHDOG_MS + ) + + // Handshake B starts once the hang's backoff elapses, then fails quickly at + // the challenge step and enters a backoff of its own. + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({}, false, 500) + } + throw new Error(`unexpected path ${path}`) + }) + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.FAILURE_BACKOFF_MS + ) + await flush() + await expect(getAttestationToken()).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') + }) + + it('retries a failed proactive refresh after the backoff without a gated call', async () => { + // First handshake succeeds with a token that refreshes in + // `REFRESH_UNTIL_MS` - comfortably past `MIN_REFRESH_MS`, so the floor + // does not decide this schedule. + const REFRESH_UNTIL_MS = 5 * 60 * 1000 + const expires = + Date.now() + attestationTimingForTests.REFRESH_LEAD_MS + REFRESH_UNTIL_MS + 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(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') { + 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') + }) + + /** Fails before any platform attestation is spent (offline, server down). */ + const mockCheapFailingHandshake = (): void => { + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({}, false, 500) + } + throw new Error(`unexpected path ${path}`) + }) + } + + /** Fails only after the attestation is produced (device rejected). */ + const mockAttestFailingHandshake = (): void => { + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-rejected' }) + } + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse({}, false, 403) + } + throw new Error(`unexpected path ${path}`) + }) + } + + it('doubles the retry backoff after each rejected attestation', async () => { + const { FAILURE_BACKOFF_MS } = attestationTimingForTests + mockAttestFailingHandshake() + + 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('keeps retrying every backoff while failures cost no attestation', async () => { + const { FAILURE_BACKOFF_MS } = attestationTimingForTests + mockCheapFailingHandshake() + + initAttestation() + await flush() + + // An offline device must not back off into a half-hour silence: nothing + // rate-limited was spent, and it may be back on the network any moment. + for (let i = 0; i < 5; i++) { + const callsBefore = mockFetchInfo.mock.calls.length + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan(callsBefore) + } + }) + + it('keeps retrying every backoff when native never acquired the lock', async () => { + const { FAILURE_BACKOFF_MS } = attestationTimingForTests + mockSuccessfulHandshake(Date.now() + 10 * 60 * 1000) + mockGetAttestation.mockRejectedValue( + Object.assign(new Error('Timed out waiting for the Keystore lock'), { + code: 'lockTimeout' + }) + ) + + initAttestation() + await flush() + + // Contention on the lock means some earlier native call is wedged; doubling + // the backoff would take a recoverable device off the air for up to + // MAX_BACKOFF_MS over failures that cost nothing. + for (let i = 0; i < 5; i++) { + const callsBefore = mockGetAttestation.mock.calls.length + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockGetAttestation.mock.calls.length).toBeGreaterThan(callsBefore) + } + }) + + it('un-counts a watchdog failure that native later says cost nothing', async () => { + const { FAILURE_BACKOFF_MS, HANDSHAKE_WATCHDOG_MS } = + attestationTimingForTests + // The watchdog has to count an outstanding native call as expensive, since a + // call that may never answer may also have spent the attestation. But the + // answer can still arrive afterwards: the 90s watchdog starts at the top of + // the handshake, so a slow challenge fetch leaves Android's 60s lock timeout + // landing after it. The attempt is retired by then, so without a correction + // the count stands for a failure that provably cost nothing. + let rejectLockWait: ((error: Error) => void) | undefined + mockGetAttestation.mockImplementation( + async () => + await new Promise((_resolve, reject) => { + rejectLockWait = reject + }) + ) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + + // Twice, because the backoff is `FAILURE_BACKOFF_MS * 2 ** (count - 1)`: + // counts of zero and one give the same flat window, so a single uncorrected + // count is invisible and cannot tell the two behaviours apart. + for (let round = 0; round < 2; round++) { + await jest.advanceTimersByTimeAsync(HANDSHAKE_WATCHDOG_MS) + await flush() + rejectLockWait?.( + Object.assign(new Error('Timed out waiting for the Keystore lock'), { + code: 'lockTimeout' + }) + ) + await flush() + if (round === 0) { + // Let the retry the watchdog scheduled start, and hang again. + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + } + } + + // Corrected, the count is back to zero and one flat window is enough. Left + // standing, it would be two and this window would pass in silence. + mockSuccessfulHandshake(Date.now() + 10 * 60 * 1000) + mockGetAttestation.mockResolvedValue({ + keyId: 'key2', + attestation: 'att2', + bundleId: 'co.edgesecure.app' + }) + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + await expect(getAttestationToken()).resolves.toBe('jwt-token') + }) + + it('keeps a watchdog failure counted when native may have spent quota', async () => { + const { FAILURE_BACKOFF_MS, HANDSHAKE_WATCHDOG_MS } = + attestationTimingForTests + // The mirror of the test above, and the reason the correction cannot simply + // undo whatever the watchdog counted. iOS raises `timeout` after attestKey + // was invoked, so Apple may already have counted it; taking that back would + // under-count real quota burn and keep a rate-limited device re-attesting. + let rejectNativeCall: ((error: Error) => void) | undefined + mockGetAttestation.mockImplementation( + async () => + await new Promise((_resolve, reject) => { + rejectNativeCall = reject + }) + ) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + + for (let round = 0; round < 2; round++) { + await jest.advanceTimersByTimeAsync(HANDSHAKE_WATCHDOG_MS) + await flush() + rejectNativeCall?.( + Object.assign(new Error('App Attest attestation timed out'), { + code: 'timeout' + }) + ) + await flush() + if (round === 0) { + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + } + } + + // Two counted failures put the next attempt two windows out, so one window + // must pass in silence and the gated caller must still get nothing. + mockSuccessfulHandshake(Date.now() + 10 * 60 * 1000) + mockGetAttestation.mockResolvedValue({ + keyId: 'key2', + attestation: 'att2', + bundleId: 'co.edgesecure.app' + }) + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + await expect(getAttestationToken()).resolves.toBeUndefined() + }) + + it('stops serving a token the server has just rejected', async () => { + const { REFRESH_LEAD_MS } = attestationTimingForTests + const REFRESH_UNTIL_MS = 5 * 60 * 1000 + mockSuccessfulHandshake(Date.now() + REFRESH_LEAD_MS + REFRESH_UNTIL_MS) + initAttestation() + await flush() + await expect(getAttestationToken()).resolves.toBe('jwt-token') + + // The proactive refresh finds the enrolled key rejected, and re-enrolling + // then fails - so nothing arrives to replace what is cached. + mockGenerateAssertion.mockResolvedValue({ + keyId: 'K1', + assertion: 'assert-1', + bundleId: 'co.edgesecure.app' + }) + mockSignChallenge.mockResolvedValue({ keyId: 'K1', signature: 'sig-1' }) + mockGetAttestation.mockRejectedValue(new Error('attestation unavailable')) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-2' }) + } + if (path.endsWith('/assert')) return jsonResponse({}, false, 401) + throw new Error(`unexpected path ${path}`) + }) + await jest.advanceTimersByTimeAsync(REFRESH_UNTIL_MS) + await flush() + + // The token has not expired, but the server has called the key that minted + // it untrusted. Handing it out anyway means every gated caller keeps + // presenting a credential that is already being refused. + await expect(getAttestationToken()).resolves.toBeUndefined() + }) + + it('does not spend an attestation for an attempt the watchdog retired', async () => { + const { HANDSHAKE_WATCHDOG_MS } = attestationTimingForTests + // The watchdog can fire before the handshake even reaches the native call: + // everything before it is info-server round trips, and on a bad enough + // network those alone outlast the 90s window. + let releaseChallenge: (() => void) | undefined + let challenges = 0 + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + challenges += 1 + if (challenges === 1) { + await new Promise(resolve => { + releaseChallenge = resolve + }) + } + return jsonResponse({ challenge: 'chal-1' }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + await jest.advanceTimersByTimeAsync(HANDSHAKE_WATCHDOG_MS) + await flush() + + // Let the retired attempt run on. It has to stop rather than carry through + // into the one step that costs rate-limited quota. + releaseChallenge?.() + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(0) + }) + + it('leaves the lock alone when a retired attempt settles under a newer one', async () => { + const { + FAILURE_BACKOFF_MS, + GET_TOKEN_TIMEOUT_MS, + HANDSHAKE_WATCHDOG_MS, + MIN_HANDSHAKE_SPACING_MS + } = attestationTimingForTests + // Both handshakes hang in the native call, so the first is still unsettled + // when the second takes the lock. + const rejecters: Array<(error: Error) => void> = [] + mockGetAttestation.mockImplementation( + async () => + await new Promise((_resolve, reject) => { + rejecters.push(reject) + }) + ) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + await jest.advanceTimersByTimeAsync(HANDSHAKE_WATCHDOG_MS) + await flush() + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(rejecters.length).toBe(2) + + // The first answers at last, while the second still holds the lock. + rejecters[0]( + Object.assign(new Error('App Attest attestation timed out'), { + code: 'timeout' + }) + ) + await flush() + + // Its cleanup must not hand the lock back. Releasing it would let a third + // handshake start alongside the second, and both would spend an attestation. + await jest.advanceTimersByTimeAsync( + FAILURE_BACKOFF_MS + MIN_HANDSHAKE_SPACING_MS + ) + await flush() + const gated = getAttestationToken() + await jest.advanceTimersByTimeAsync(GET_TOKEN_TIMEOUT_MS) + await expect(gated).resolves.toBeUndefined() + expect(rejecters.length).toBe(2) + }) + + // Bodies the server should never send, each of which the engine has to treat + // as a failed handshake. Asserting only that no token comes back is not + // enough: a non-finite `expires` also produces no token, because every + // comparison against NaN is false - while `scheduleRefresh` computes NaN, + // `setTimeout` reads that as zero, and the engine spins as fast as the network + // answers. What distinguishes the two is whether the failure is *counted*. + it.each([ + ['a non-string token', () => ({ token: 42, expires: Date.now() + 600000 })], + ['a non-finite expires', () => ({ token: 'jwt', expires: 'soon' })], + [ + 'an expires already past', + () => ({ token: 'jwt', expires: Date.now() - 1 }) + ], + [ + 'an expires inside the clock skew', + () => ({ token: 'jwt', expires: Date.now() + 1000 }) + ] + ])('treats %s as a failed handshake, not a success', async (_label, body) => { + const { FAILURE_BACKOFF_MS } = attestationTimingForTests + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + return jsonResponse(body()) + }) + + initAttestation() + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(1) + + // Each attempt spent an attestation, so the second lands one backoff later + // and the third two - meaning this window has to pass in silence. + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(2) + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(2) + }) + + it('stops serving a token inside the clock-skew window', async () => { + const LIFETIME_MS = 10 * 60 * 1000 + mockSuccessfulHandshake(Date.now() + LIFETIME_MS) + initAttestation() + await flush() + await expect(getAttestationToken()).resolves.toBe('jwt-token') + + // Refreshes fail from here, so nothing replaces what is cached. + mockCheapFailingHandshake() + await jest.advanceTimersByTimeAsync(LIFETIME_MS - 2000) + await flush() + + // Two seconds of stated life left. The request still has to travel and be + // verified, so a token this close to the edge arrives expired - and a 403 is + // worse for the caller than no token, which the info server may still + // answer with a fallback. + await expect(getAttestationToken()).resolves.toBeUndefined() + }) + + it('fails a malformed challenge before spending an attestation', async () => { + // A challenge-less 200 would be POSTed as `undefined`, and on the full + // attest path native would be asked to attest that - spending rate-limited + // quota on a request the server is bound to refuse. + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') return jsonResponse({}) + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(0) + }) + + it('does not start a second handshake at boot when a token is live', async () => { + const LIFETIME_MS = 60 * 60 * 1000 + mockSuccessfulHandshake(Date.now() + LIFETIME_MS) + initAttestation() + await flush() + await expect(getAttestationToken()).resolves.toBe('jwt-token') + const afterBoot = mockGetAttestation.mock.calls.length + + // Well past the spacing floor, so the live token is the only thing left to + // stop another handshake. initAttestation runs again on re-login. + await jest.advanceTimersByTimeAsync(5 * 60 * 1000) + await flush() + initAttestation() + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(afterBoot) + }) + + it('clears a grown backoff after a success', async () => { + const { FAILURE_BACKOFF_MS, REFRESH_LEAD_MS } = attestationTimingForTests + const LIFETIME_MS = 10 * 60 * 1000 + mockAttestFailingHandshake() + initAttestation() + await flush() + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + + // Two rejected attestations, then the server accepts. + mockSuccessfulHandshake(Date.now() + LIFETIME_MS) + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS * 2) + await flush() + await expect(getAttestationToken()).resolves.toBe('jwt-token') + + // The next proactive refresh fails. Counted from a forgotten run this is + // the first failure and sits one flat backoff out; counted from a + // remembered one the doubling carries on and this window passes in silence. + mockAttestFailingHandshake() + await jest.advanceTimersByTimeAsync(LIFETIME_MS - REFRESH_LEAD_MS) + await flush() + const afterFailedRefresh = mockGetAttestation.mock.calls.length + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockGetAttestation.mock.calls.length).toBeGreaterThan( + afterFailedRefresh + ) + }) + + it('grows the backoff when the native attestation itself fails', async () => { + const { FAILURE_BACKOFF_MS } = attestationTimingForTests + // The counterpart to the test above, and the reason it cannot simply trust + // any native rejection: iOS reports `timeout` after App Attest was invoked, + // so Apple may have counted it and the backoff must still grow. + mockSuccessfulHandshake(Date.now() + 10 * 60 * 1000) + mockGetAttestation.mockRejectedValue( + Object.assign(new Error('App Attest attestation timed out'), { + code: 'timeout' + }) + ) + + initAttestation() + await flush() + const afterFirst = mockGetAttestation.mock.calls.length + + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + const afterSecond = mockGetAttestation.mock.calls.length + expect(afterSecond).toBeGreaterThan(afterFirst) + + // The third attempt waits two backoffs, so one is not enough. + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(afterSecond) + }) + + it('suppresses gated calls for the whole grown backoff', async () => { + const { FAILURE_BACKOFF_MS } = attestationTimingForTests + mockAttestFailingHandshake() + + // Two rejected attestations put the next attempt two backoffs out. + initAttestation() + await flush() + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + const callsAfterSecond = mockFetchInfo.mock.calls.length + + // A gated caller one backoff later - the Banxa order poll runs every 3s - + // must not start a handshake, and must not wait around for one. + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + const gatedPromise = getAttestationToken() + await flush() + await expect(gatedPromise).resolves.toBeUndefined() + expect(mockFetchInfo.mock.calls.length).toBe(callsAfterSecond) + }) + + it('caps the retry backoff so a failing device keeps retrying', async () => { + const { MAX_BACKOFF_MS } = attestationTimingForTests + mockAttestFailingHandshake() + + 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('never schedules a refresh sooner than the floor', async () => { + const { MIN_REFRESH_MS } = attestationTimingForTests + // A token lifetime below REFRESH_LEAD_MS is one operator edit away: the + // info server reads it from a synced config doc. It must not put the + // engine into a handshake loop. + mockSuccessfulHandshake(Date.now() + 30 * 1000) + + initAttestation() + await flush() + const callsAfterMint = mockFetchInfo.mock.calls.length + expect(callsAfterMint).toBeGreaterThan(0) + + // Make any further handshake cheap to observe and impossible to loop on. + mockCheapFailingHandshake() + await jest.advanceTimersByTimeAsync(MIN_REFRESH_MS - 1) + await flush() + expect(mockFetchInfo.mock.calls.length).toBe(callsAfterMint) + + await jest.advanceTimersByTimeAsync(1) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan(callsAfterMint) + }) + + it('counts a hang inside the attestation against the backoff', async () => { + const { FAILURE_BACKOFF_MS, HANDSHAKE_WATCHDOG_MS } = + attestationTimingForTests + 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}`) + }) + + // First hang: watchdog releases the lock and retries one backoff later. + initAttestation() + await flush() + await jest.advanceTimersByTimeAsync(HANDSHAKE_WATCHDOG_MS) + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + + // Second hang. The quota is spent whether the native call answers or not, + // so this backoff must have doubled. + await jest.advanceTimersByTimeAsync(HANDSHAKE_WATCHDOG_MS) + await flush() + const callsAfterSecondHang = mockFetchInfo.mock.calls.length + + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockFetchInfo.mock.calls.length).toBe(callsAfterSecondHang) + + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan( + callsAfterSecondHang + ) + }) + + it('counts a hang and its late rejection as one failure', async () => { + const { FAILURE_BACKOFF_MS, HANDSHAKE_WATCHDOG_MS } = + attestationTimingForTests + let rejectHungAttestation: ((error: Error) => void) | undefined + mockGetAttestation.mockImplementation( + async () => + await new Promise((resolve, reject) => { + rejectHungAttestation = reject + }) + ) + 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(HANDSHAKE_WATCHDOG_MS) + expect(rejectHungAttestation).toBeDefined() + + // The watchdog already gave up on this attempt and scheduled a retry one + // backoff out. Its late rejection is the same failure, so it must not + // double the wait. + rejectHungAttestation?.(new Error('attestKey timed out')) + await flush() + const callsAfterRejection = mockFetchInfo.mock.calls.length + + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan(callsAfterRejection) + }) + + 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') + }) + + /** Hangs forever inside the native attestation, after fetching a challenge. */ + const mockHangingAttestation = (): void => { + mockGetAttestation.mockImplementation( + async () => await new Promise(() => {}) + ) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-hung' }) + } + throw new Error(`unexpected path ${path}`) + }) + } + + it('makes a hang back off gated callers, not just the retry timer', async () => { + const { HANDSHAKE_WATCHDOG_MS } = attestationTimingForTests + mockHangingAttestation() + + initAttestation() + await flush() + await jest.advanceTimersByTimeAsync(HANDSHAKE_WATCHDOG_MS) + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(1) + + // The watchdog counted a burnt attestation, so the backoff has to apply to + // gated callers too. Recording only the failure count would leave the gate + // reading a `lastFailureAt` no hang ever set, and every gated call would + // start a fresh handshake the moment the lock was released. + await jest.advanceTimersByTimeAsync(1000) + let settled = false + const gated = getAttestationToken().then((token: string | undefined) => { + settled = true + return token + }) + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(1) + // And it must not wait GET_TOKEN_TIMEOUT_MS to say so. + expect(settled).toBe(true) + await expect(gated).resolves.toBeUndefined() + }) + + it('bounds attestations burned while the native call keeps hanging', async () => { + const { HANDSHAKE_WATCHDOG_MS } = attestationTimingForTests + mockHangingAttestation() + + initAttestation() + await flush() + + // Poll like the Banxa order screen for an hour of solid hangs. + const WINDOW_MS = 60 * 60 * 1000 + const gated: Array> = [] + for (let elapsed = 0; elapsed < WINDOW_MS; elapsed += 3000) { + gated.push(getAttestationToken()) + await jest.advanceTimersByTimeAsync(3000) + } + await flush() + await Promise.all(gated) + + // Without the backoff applying to gated callers this is one attestation per + // watchdog window; with the doubling backoff it is a handful. + expect(mockGetAttestation.mock.calls.length).toBeLessThan( + WINDOW_MS / HANDSHAKE_WATCHDOG_MS / 2 + ) + }) + + it('retries after the bridge fails to answer isSupported', async () => { + mockIsSupported.mockRejectedValue(new Error('native bridge not ready')) + + initAttestation() + await flush() + expect(mockIsSupported.mock.calls.length).toBe(1) + + // A rejection is the bridge failing, not the device saying no, so it must + // not retire the engine. + mockIsSupported.mockResolvedValue(true) + mockSuccessfulHandshake(Date.now() + 10 * 60 * 1000) + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.FAILURE_BACKOFF_MS + ) + await flush() + await expect(getAttestationToken()).resolves.toBe('jwt-token') + }) + + it('stops handshaking once the device reports it cannot attest', async () => { + mockIsSupported.mockResolvedValue(false) + + initAttestation() + await flush() + await expect(getAttestationToken()).resolves.toBeUndefined() + const callsAfterUnsupported = mockIsSupported.mock.calls.length + + // Terminal: no timer should keep waking up to ask a device that will never + // have an answer. + await jest.advanceTimersByTimeAsync(60 * 60 * 1000) + await flush() + await expect(getAttestationToken()).resolves.toBeUndefined() + expect(mockIsSupported.mock.calls.length).toBe(callsAfterUnsupported) + }) + + it('stays armed when a retry tick lands short of the backoff', async () => { + const { FAILURE_BACKOFF_MS } = attestationTimingForTests + mockCheapFailingHandshake() + + initAttestation() + await flush() + const callsAfterFailure = mockFetchInfo.mock.calls.length + + // The retry is armed for exactly the backoff and the gate measures it + // against the wall clock, so a backwards clock step (NTP) can make the tick + // arrive a hair early. Declining it must re-arm, not strand the engine. + const realNow = Date.now + const nowSpy = jest.spyOn(Date, 'now') + nowSpy.mockImplementation(() => realNow() - 1) + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + nowSpy.mockRestore() + + await jest.advanceTimersByTimeAsync(FAILURE_BACKOFF_MS) + await flush() + expect(mockFetchInfo.mock.calls.length).toBeGreaterThan(callsAfterFailure) + }) + + it('spaces out handshakes when the server mints very short-lived tokens', async () => { + // Pinned as a literal on purpose. Deriving the bound from the constant under + // test makes the assertion vacuous exactly when the constant is wrong: + // at zero, `WINDOW_MS / MIN_HANDSHAKE_SPACING_MS` is Infinity, which every + // possible count satisfies. Retuning the floor has to change this line. + const EXPECTED_SPACING_MS = 30 * 1000 + expect(attestationTimingForTests.MIN_HANDSHAKE_SPACING_MS).toBe( + EXPECTED_SPACING_MS + ) + + const POLL_MS = 3000 + const WINDOW_MS = 5 * 60 * 1000 + // A lifetime this short is one operator edit away - the info server reads it + // from a synced config doc - and it leaves most of every cycle with nothing + // cached. A success clears the failure backoff, so nothing else holds the + // gated path back. + mockSuccessfulHandshake(Date.now() + 10 * 1000) + + // Timed at the platform attestation rather than the challenge fetch: with no + // enrolled key each handshake fetches a challenge twice, so challenges do not + // map one-to-one onto handshakes. This is also the call that spends the + // rate-limited resource, which is what the floor exists to protect. + const attestationsAt: number[] = [] + mockGetAttestation.mockImplementation(async () => { + attestationsAt.push(Date.now()) + return { keyId: 'key', attestation: 'att', bundleId: 'co.edgesecure.app' } + }) + + initAttestation() + await flush() + + const gated: Array> = [] + for (let elapsed = 0; elapsed < WINDOW_MS; elapsed += POLL_MS) { + gated.push(getAttestationToken()) + await jest.advanceTimersByTimeAsync(POLL_MS) + } + await flush() + await Promise.all(gated) + + // Assert the observed spacing, not just a count. A count bound is also + // satisfied by the refresh floor on its own, so it would not notice the + // spacing logic disappearing - which is the whole point of this test. + expect(attestationsAt.length).toBeGreaterThan(1) + const gaps = attestationsAt + .slice(1) + .map((at, i) => at - attestationsAt[i]) + .sort((a, b) => a - b) + expect(gaps[0]).toBeGreaterThanOrEqual(EXPECTED_SPACING_MS) + // And still far below the poll rate, so callers cannot drive the handshake. + expect(attestationsAt.length).toBeLessThan(WINDOW_MS / POLL_MS / 4) + }) + + describe('a handshake the watchdog has retired', () => { + /** + * Handshake A holds an enrolled key and stalls on its assert POST until the + * watchdog retires it. B then re-enrolls and caches a good token. Only then + * does A's assert answer 401. + */ + const runRetiredAssertRejection = async (): Promise => { + mockGenerateAssertion.mockResolvedValue({ + keyId: 'K1', + assertion: 'assert-1', + bundleId: 'co.edgesecure.app' + }) + mockSignChallenge.mockResolvedValue({ keyId: 'K1', signature: 'sig-1' }) + + let answerStalledAssert: ((value: MockResponse) => void) | undefined + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-A' }) + } + if (path.endsWith('/assert')) { + return await new Promise(resolve => { + answerStalledAssert = resolve + }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + expect(answerStalledAssert).toBeDefined() + + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.HANDSHAKE_WATCHDOG_MS + ) + await flush() + + // B: the server rejects its assertion, so it clears the key, re-attests, + // and mints a good token. + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-B' }) + } + if (path.endsWith('/assert')) return jsonResponse({}, false, 401) + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse({ + token: 'jwt-B', + expires: Date.now() + 60 * 60 * 1000 + }) + } + throw new Error(`unexpected path ${path}`) + }) + await jest.advanceTimersByTimeAsync( + attestationTimingForTests.FAILURE_BACKOFF_MS + ) + await flush() + await expect(getAttestationToken()).resolves.toBe('jwt-B') + + answerStalledAssert?.(jsonResponse({}, false, 401)) + await flush() + } + + it('does not clear the key a newer handshake enrolled', async () => { + await runRetiredAssertRejection() + // B cleared the untrusted key once. A's late 401 says nothing about the + // key B enrolled afterwards, and wiping it would force a needless + // re-attestation on the next handshake. + expect(mockClearKey.mock.calls.length).toBe(1) + }) + + it('does not burn a platform attestation', async () => { + await runRetiredAssertRejection() + // Nobody is waiting on A's result, so spending rate-limited quota on it + // buys nothing. + expect(mockGetAttestation.mock.calls.length).toBe(1) + }) + + it('leaves the live token alone', async () => { + await runRetiredAssertRejection() + await expect(getAttestationToken()).resolves.toBe('jwt-B') + }) + }) + + describe('an unusable token from the assert fast path', () => { + beforeEach(() => { + mockGenerateAssertion.mockResolvedValue({ + keyId: 'K1', + assertion: 'assert-1', + bundleId: 'co.edgesecure.app' + }) + mockSignChallenge.mockResolvedValue({ keyId: 'K1', signature: 'sig-1' }) + }) + + const mockAssertMint = (body: unknown): void => { + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + if (path.endsWith('/assert')) return jsonResponse(body) + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse(body) + } + throw new Error(`unexpected path ${path}`) + }) + } + + it('fails into backoff instead of re-attesting when expires is past', async () => { + const { MAX_BACKOFF_MS } = attestationTimingForTests + mockAssertMint({ token: 'jwt-stale', expires: Date.now() - 1 }) + + initAttestation() + await flush() + + // The assertion itself succeeded and cost nothing rate-limited. A bad mint + // is a server problem that re-attesting cannot fix, so falling through to + // a full attestation would only spend quota to hide it - once per backoff + // window, for as long as the app is open. + expect(mockGetAttestation.mock.calls.length).toBe(0) + for (let i = 0; i < 6; i++) { + await jest.advanceTimersByTimeAsync(MAX_BACKOFF_MS) + await flush() + } + expect(mockGetAttestation.mock.calls.length).toBe(0) + }) + + it('fails into backoff instead of re-attesting when expires is malformed', async () => { + mockAssertMint({ token: 'jwt-stale', expires: 'soon' }) + + initAttestation() + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(0) + }) + + it.each([500, 502, 503, 429])( + 'keeps the enrolled key when the assert endpoint answers %i', + async (status: number) => { + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + if (path.endsWith('/assert')) return jsonResponse({}, false, status) + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + + // The server failed to answer, or throttled us - neither is a judgement + // on the key. Discarding it here would have the whole fleet re-attest + // during an info-server outage, which is a fleet-wide run at the + // platform rate limits caused by something that fixes itself. + expect(mockClearKey.mock.calls.length).toBe(0) + expect(mockGetAttestation.mock.calls.length).toBe(0) + } + ) + + it('re-attests when the server judges the key untrusted', async () => { + // The contrast case: 4xx means the server looked at the key and said no. + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + if (path.endsWith('/assert')) return jsonResponse({}, false, 403) + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse({ + token: 'jwt-reattested', + expires: Date.now() + 10 * 60 * 1000 + }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + expect(mockClearKey.mock.calls.length).toBe(1) + await expect(getAttestationToken()).resolves.toBe('jwt-reattested') + }) + + it('does not re-attest when the native signature times out', async () => { + const timeout = Object.assign(new Error('assertion timed out'), { + code: 'timeout' + }) + mockGenerateAssertion.mockRejectedValue(timeout) + mockSignChallenge.mockRejectedValue(timeout) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + + // A timeout says nothing about whether the key can sign, so the cheap path + // deserves another try rather than a rate-limited attestation. + expect(mockGetAttestation.mock.calls.length).toBe(0) + }) + + it('does not re-attest when Android cannot get the Keystore lock', async () => { + // Android's own code for the same idea, reported when `tryLock` gives up. + // It has to be transient here too: escalating would spend an attestation + // to replace a key that signs perfectly well and was merely contended. + const lockTimeout = Object.assign( + new Error('Timed out waiting for the Keystore lock'), + { code: 'lockTimeout' } + ) + mockGenerateAssertion.mockRejectedValue(lockTimeout) + mockSignChallenge.mockRejectedValue(lockTimeout) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + + expect(mockGetAttestation.mock.calls.length).toBe(0) + }) + + it('re-attests when the native signature reports no usable key', async () => { + mockGenerateAssertion.mockRejectedValue( + Object.assign(new Error('no key'), { code: 'noKey' }) + ) + mockSignChallenge.mockRejectedValue( + Object.assign(new Error('no key'), { code: 'noKey' }) + ) + mockSuccessfulHandshake(Date.now() + 10 * 60 * 1000) + + initAttestation() + await flush() + expect(mockGetAttestation.mock.calls.length).toBe(1) + await expect(getAttestationToken()).resolves.toBe('jwt-token') + }) + + it('still re-attests when the server rejects the assertion', async () => { + // The contrast case: a rejection *is* about the key, so re-enrolling is + // the right answer and the fast path must still fall through. + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + if (path.endsWith('/assert')) return jsonResponse({}, false, 401) + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse({ + token: 'jwt-reattested', + expires: Date.now() + 10 * 60 * 1000 + }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + expect(mockClearKey.mock.calls.length).toBe(1) + expect(mockGetAttestation.mock.calls.length).toBe(1) + await expect(getAttestationToken()).resolves.toBe('jwt-reattested') + }) + + it('names the rejected key when asking native to clear it', async () => { + mockGenerateAssertion.mockResolvedValue({ + keyId: 'key-rejected', + assertion: 'assertion' + }) + mockSignChallenge.mockResolvedValue({ + keyId: 'key-rejected', + signature: 'signature' + }) + mockFetchInfo.mockImplementation(async (path: string) => { + if (path === 'v1/attest/challenge') { + return jsonResponse({ challenge: 'chal-1' }) + } + if (path.endsWith('/assert')) return jsonResponse({}, false, 401) + if (path === 'v1/attest/apple' || path === 'v1/attest/android') { + return jsonResponse({ + token: 'jwt-reattested', + expires: Date.now() + 10 * 60 * 1000 + }) + } + throw new Error(`unexpected path ${path}`) + }) + + initAttestation() + await flush() + + // Native can sit on this call for a long time behind a slow key + // operation. Passing the id scopes the delete to the key the server + // actually refused, so it cannot take out a replacement a newer handshake + // enrolled while it waited. + expect(mockClearKey.mock.calls[0]).toStrictEqual(['key-rejected']) + }) + }) +}) diff --git a/src/__tests__/util/attestationNativeBridge.test.ts b/src/__tests__/util/attestationNativeBridge.test.ts new file mode 100644 index 00000000000..23bc0116578 --- /dev/null +++ b/src/__tests__/util/attestationNativeBridge.test.ts @@ -0,0 +1,163 @@ +import { readFileSync } from 'fs' +import { join } from 'path' + +/** + * The Swift `EdgeAttestation` class is exposed to React Native through a + * hand-written Objective-C bridge, and the two declare the same selectors + * independently. Nothing else in the repo notices when they drift: `swiftc` does + * not read the bridge, and `RCT_EXTERN_METHOD` mismatches are not build errors - + * React Native discovers them at runtime, on the device, as a selector that does + * not resolve. For `clearKey` that would silently disable the only path that + * recovers from a key the server has rejected. + */ +describe('iOS attestation native bridge', () => { + const iosDir = join(__dirname, '../../../ios/edge') + const swiftSource = readFileSync( + join(iosDir, 'EdgeAttestation.swift'), + 'utf8' + ) + const objcSource = readFileSync(join(iosDir, 'EdgeAttestation.m'), 'utf8') + + /** Selectors the Swift class exports, e.g. `clearKey:resolver:rejecter:`. */ + const swiftSelectors = (source: string): string[] => { + const found = source.match(/@objc\(([^)]+)\)/g) ?? [] + return ( + found + .map(match => match.slice('@objc('.length, -1)) + // `@objc(EdgeAttestation)` names the class, not a method. + .filter(selector => selector.includes(':')) + .sort() + ) + } + + /** + * Selectors the Objective-C bridge declares. Each `RCT_EXTERN_METHOD` body is + * the method name followed by argument labels interleaved with parenthesised + * types, so dropping every `(Type *)argName` leaves just the selector parts. + */ + const objcSelectors = (source: string): string[] => { + const marker = 'RCT_EXTERN_METHOD(' + const selectors: string[] = [] + let index = source.indexOf(marker) + while (index !== -1) { + let depth = 1 + let cursor = index + marker.length + while (cursor < source.length && depth > 0) { + if (source[cursor] === '(') depth += 1 + if (source[cursor] === ')') depth -= 1 + cursor += 1 + } + const body = source.slice(index + marker.length, cursor - 1) + selectors.push( + body.replace(/\(\s*[^)]*\)\s*\w+/g, '').replace(/\s+/g, '') + ) + index = source.indexOf(marker, cursor) + } + return selectors.sort() + } + + it('declares every Swift selector with matching arity', () => { + const swift = swiftSelectors(swiftSource) + expect(swift.length).toBeGreaterThan(0) + expect(objcSelectors(objcSource)).toStrictEqual(swift) + }) + + it('passes clearKey a key id, so JS can scope the delete', () => { + // The check above only sees the two files disagreeing, so it would not + // notice clearKey losing its keyId in both at once. Pin the argument + // itself: without it, native deletes whatever key happens to be enrolled + // when the call finally runs, which may be a newer one. + expect(swiftSelectors(swiftSource)).toContain('clearKey:resolver:rejecter:') + }) +}) + +/** + * Three timeouts across three files have to stay in a particular order, and each + * file documents its own end of the bargain without being able to check it. They + * are read out of the sources here rather than imported, because two of them are + * native and none of them is exported. + */ +describe('attestation timeout ordering', () => { + const root = join(__dirname, '../../..') + const source = (path: string): string => + readFileSync(join(root, path), 'utf8') + + /** Reads `const NAME = 90 * 1000` and multiplies out the literals. */ + const msConstant = (text: string, name: string): number => { + const match = new RegExp(`const ${name} = ([0-9 *]+)`).exec(text) + if (match == null) throw new Error(`could not read ${name}`) + return match[1] + .split('*') + .reduce((total, part) => total * Number(part.trim()), 1) + } + + const engine = source('src/util/attestation.ts') + const watchdogMs = msConstant(engine, 'HANDSHAKE_WATCHDOG_MS') + + it('gives up on a hung Keystore lock before the JS watchdog fires', () => { + // Android rejects with `lockTimeout` when it cannot take the lock, and the + // JS engine reads that as proof no attestation was spent, so it retries the + // cheap path without growing the backoff. Landing after the watchdog throws + // that away: the attempt is already retired, so the rejection arrives to a + // handler that only un-counts, and the engine has spent 90s learning nothing. + const kotlin = source( + 'android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt' + ) + const match = /LOCK_TIMEOUT_SECONDS = (\d+)L/.exec(kotlin) + if (match == null) throw new Error('could not read LOCK_TIMEOUT_SECONDS') + expect(Number(match[1]) * 1000).toBeLessThan(watchdogMs) + }) + + it('reports every failure to take the Keystore lock as unspent', () => { + // Failing to acquire the lock is the one native failure that proves no + // platform attestation was spent, and the engine relies on that to keep a + // merely contended device from backing off as though it were burning quota. + // Every exit from the acquisition therefore has to carry a code the engine + // recognises. A new one carrying anything else would be silent: JS cannot + // tell an unfamiliar code from a genuine failure, so it would assume the + // expensive case, which is the safe assumption but the wrong answer here. + const kotlin = source( + 'android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt' + ) + const start = kotlin.indexOf('private fun withKeystoreLock') + if (start === -1) throw new Error('could not find withKeystoreLock') + const rest = kotlin.slice(start + 1) + const end = rest.search(/\n {2}(private fun|@ReactMethod)/) + const acquisition = end === -1 ? rest : rest.slice(0, end) + + const engine = source('src/util/attestation.ts') + const unspent = /const UNSPENT_NATIVE_CODES = new Set\(\[([^\]]*)\]\)/.exec( + engine + ) + if (unspent == null) throw new Error('could not read UNSPENT_NATIVE_CODES') + const known = [...unspent[1].matchAll(/'([^']+)'/g)].map(match => match[1]) + const rejections = [ + ...acquisition.matchAll(/promise\.reject\(\s*"([^"]+)"/g) + ].map(match => match[1]) + + expect(rejections.length).toBeGreaterThan(0) + expect(rejections.filter(code => !known.includes(code))).toStrictEqual([]) + + // And the other way round, which is where a typo would land: a code in the + // set that no module emits never matches, so the engine quietly falls back + // to assuming quota was spent - the same wrong answer, reached from the + // other side, and just as invisible. + const emitted = [kotlin, source('ios/edge/EdgeAttestation.swift')].flatMap( + text => + [...text.matchAll(/promise\.reject\(\s*"([^"]+)"/g)].map( + match => match[1] + ) + ) + expect(known.filter(code => !emitted.includes(code))).toStrictEqual([]) + }) + + it('holds the App Attest queue past the JS watchdog, not before it', () => { + // The iOS operation timeout exists to unwedge the serial queue, not to beat + // JS to the answer. Below the watchdog it would start rejecting handshakes + // that were merely slow, and every one of those costs an attestation. + const swift = source('ios/edge/EdgeAttestation.swift') + const match = /operationTimeout[^=]*= \.seconds\((\d+)\)/.exec(swift) + if (match == null) throw new Error('could not read operationTimeout') + expect(Number(match[1]) * 1000).toBeGreaterThan(watchdogMs) + }) +}) diff --git a/src/components/scenes/RampSelectOptionScene.tsx b/src/components/scenes/RampSelectOptionScene.tsx index 6af85b5d994..7ddfc0fe9fa 100644 --- a/src/components/scenes/RampSelectOptionScene.tsx +++ b/src/components/scenes/RampSelectOptionScene.tsx @@ -32,7 +32,7 @@ import { SectionHeader } from '../common/SectionHeader' import { SceneContainer } from '../layout/SceneContainer' import { CardListModal } from '../modals/CardListModal' import { ShimmerCard } from '../progress-indicators/ShimmerCard' -import { Airship } from '../services/AirshipInstance' +import { Airship, showError } from '../services/AirshipInstance' import { cacheStyles, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' @@ -107,6 +107,14 @@ export const RampSelectOptionScene: React.FC = (props: Props) => { await quote.approveQuote({ coreWallet: rampQuoteRequest.wallet }) + } catch (error) { + // Nothing up the chain catches this, so without it the rejection is + // unhandled: the spinner clears and the user is left looking at a + // button that did nothing. Attestation makes that reachable in ordinary + // use, because a gated jwtSign answers 403 whenever no token is + // available. Cancellation does not come through here - the plugins + // report that themselves - so this only fires on real failures. + showError(error) } finally { setIsApprovingQuote(false) } 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..6bd0b6ca6b5 --- /dev/null +++ b/src/util/attestation.ts @@ -0,0 +1,583 @@ +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. + // + // Takes the key id the caller means to discard, because this call can sit + // behind a slow native operation for a long time and the stored key may have + // been replaced by a newer handshake before it runs. Native drops the key only + // while it is still that one. Omit the id to discard whatever is stored. + clearKey: (keyId?: string) => Promise +} + +const EdgeAttestation: NativeAttestation | undefined = + NativeModules.EdgeAttestation + +interface CachedToken { + token: string + expires: number // epoch milliseconds +} + +/** Per-attempt state shared between `runHandshake` and `performHandshake`. */ +interface HandshakeAttempt { + // Monotonic id of this attempt, so a stale (watchdog-released) handshake + // never mutates shared state that a newer handshake already owns. + generation: number + // Set once the attempt has consumed a platform attestation. Failures before + // that point cost nothing rate-limited, so they must not grow the backoff. + usedAttestation: boolean + // Whether this attempt has already been counted against the backoff, so a + // later verdict that it spent nothing can take that count back - exactly once. + countedFailure: boolean + // This attempt's watchdog, cleared when it settles so a handshake that + // finished does not leave a timer pending for the whole watchdog window. + watchdog?: ReturnType +} + +// 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 +// Floor for a scheduled refresh. The delay is derived from the server's +// `expires`, and the token lifetime is remote config (info server +// `attestationTokenLifetimeSec`), so treat it as untrusted: a lifetime shorter +// than REFRESH_LEAD_MS - or a device clock running fast - would otherwise +// schedule the next handshake immediately and spin the engine as fast as the +// network answers, for every client at once. +const MIN_REFRESH_MS = 60 * 1000 +// Floor between handshake starts, whatever the previous one returned. The +// backoff only covers failures and MIN_REFRESH_MS only covers the timer, but a +// token whose lifetime is shorter than that floor leaves a window where nothing +// is cached and every gated call would start a handshake of its own - the Banxa +// order poll runs every 3s. Kept below FAILURE_BACKOFF_MS so it never relaxes +// the failure backoff, only bounds the success path. +const MIN_HANDSHAKE_SPACING_MS = 30 * 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 +// Ceiling for the backoff once attempts start burning platform attestations. +// A device the server keeps rejecting must not re-attest every minute for as +// long as the app is open: Apple App Attest and Android Keystore attestation +// are both rate-limited, and tripping those limits locks out the devices that +// could otherwise recover. +const MAX_BACKOFF_MS = 30 * 60 * 1000 + +let cachedToken: CachedToken | undefined +let inFlight: Promise | undefined +let refreshTimer: ReturnType | undefined +let lastFailureAt = 0 +let lastHandshakeAt = 0 +// Attempts that burned a platform attestation and still failed, since the last +// success. Only these grow the backoff (see `failureBackoffMs`). 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). +let handshakeGeneration = 0 +// Set once the platform has told us it can never attest: no native module, or +// `isSupported` resolved false. Terminal, so the engine stops rather than waking +// up forever on a device that will never produce a token. A native *rejection* +// is not this - that is a bridge failure, and it retries (see performHandshake). +let unsupported = false + +/** 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 + lastHandshakeAt = 0 + consecutiveFailures = 0 + handshakeGeneration = 0 + unsupported = false +} + +/** Test-only: expose timing constants used by unit tests. */ +export const attestationTimingForTests = { + GET_TOKEN_TIMEOUT_MS, + HANDSHAKE_WATCHDOG_MS, + FAILURE_BACKOFF_MS, + MAX_BACKOFF_MS, + MIN_HANDSHAKE_SPACING_MS, + MIN_REFRESH_MS, + REFRESH_LEAD_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 + * (an `expires` that is non-finite or already past would otherwise cache a + * token no caller can use). 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') + } + // Never cache a token that is not usable on arrival (see `hasLiveToken`). + // Failing the handshake sends it into backoff, where a bad mint or a skewed + // device clock costs one attempt per backoff rather than a refresh loop. + if (expires - CLOCK_SKEW_MS <= Date.now()) { + throw new Error('attest response expires is not in the future') + } + return { token, expires } +} + +/** + * Abandon a handshake the watchdog has already retired. `runHandshake` ignores + * whatever a retired attempt settles with, so continuing only spends state and + * rate-limited quota that a live attempt now owns. + */ +const assertCurrent = (attempt: HandshakeAttempt): void => { + if (attempt.generation !== handshakeGeneration) { + throw new Error('handshake retired by watchdog') + } +} + +/** + * Whether a non-OK assert response means the server actually judged our enrolled + * key and found it untrusted, as opposed to failing to answer at all. A 5xx is + * the info server (or Couch behind it) being down and a 429 is it throttling; + * neither says anything about the key. Reading those as a rejection would have + * every device in the fleet discard its key and re-attest during an outage - a + * fleet-wide run at the platform rate limits, caused by something that fixes + * itself. + */ +const isKeyRejection = (status: number): boolean => + status < 500 && status !== 429 + +// Native rejection codes that say nothing about whether the enrolled key can +// sign, so the cheap path deserves another try instead of a rate-limited +// attestation. Everything else (`noKey`, `invalidKey`, a native signing failure) +// means the key is unusable, and re-enrolling is the only way forward. +const TRANSIENT_NATIVE_CODES = new Set(['timeout', 'lockTimeout']) + +// Native rejection codes proving the platform attestation never ran, so the +// attempt spent nothing rate-limited even though it had reached the step that +// normally would. Android reports this when it gives up waiting for the Keystore +// lock, before the lock is held and before any key is generated. +// +// iOS's `timeout` is deliberately absent: it fires while waiting on attestKey's +// callback, so App Attest did start and Apple may already have counted it. +// Assuming otherwise there would under-count real quota burn, which is the +// expensive mistake; assuming it here would over-count a failure that cost +// nothing and push a merely contended device toward MAX_BACKOFF_MS. +const UNSPENT_NATIVE_CODES = new Set(['lockTimeout']) + +/** + * Refresh the token with the enrolled key: an assertion on iOS, a challenge + * signature on Android. Both are local signatures - no Apple/Google round trip + * and no new key - so this is the path every handshake after enrollment takes. + * + * Returns `undefined` when there is no usable enrolled key and the caller should + * fall back to a full platform attestation. That fallback is the expensive, + * rate-limited path, so it is reserved for the cases it can actually fix: a key + * that cannot sign, and a key the server has judged and rejected. Everything + * else - an unusable 200 body, a server that failed to answer, a native failure + * that says nothing about the key - throws and fails the handshake into backoff, + * because re-attesting cannot fix any of them and would spend quota every retry + * to hide them. + */ +const refreshWithEnrolledKey = async ( + native: NativeAttestation, + attempt: HandshakeAttempt, + challenge: string +): Promise => { + const isIos = Platform.OS === 'ios' + + let body: unknown + // Remembered outside the try so a rejection below can name the exact key the + // server refused, rather than asking native to discard whatever it holds. + let signedKeyId: string | undefined + try { + if (isIos) { + const { keyId, assertion } = await native.generateAssertion(challenge) + signedKeyId = keyId + body = { keyId, assertion, challenge } + } else { + const { keyId, signature } = await native.signChallenge(challenge) + signedKeyId = keyId + body = { keyId, signature, challenge } + } + } catch (error) { + const code = (error as { code?: unknown } | undefined)?.code + if (typeof code === 'string' && TRANSIENT_NATIVE_CODES.has(code)) { + throw error + } + // noKey / invalidKey / native signing failure: fall back to full attestation. + console.log('[attestation] assertion unavailable:', String(error)) + return undefined + } + + const response = await fetchInfo( + isIos ? 'v1/attest/apple/assert' : 'v1/attest/android/assert', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + } + ) + if (response.ok) return parseTokenResponse(await response.json()) + if (!isKeyRejection(response.status)) { + throw new Error(`assert request failed: ${response.status}`) + } + + // 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. Stop if the watchdog already retired + // this attempt - the key and token belong to a live handshake now, and clearing + // them would force it into a needless re-attestation. + assertCurrent(attempt) + cachedToken = undefined + console.warn( + `[attestation] assertion rejected (${response.status}); re-attesting` + ) + // Name the key the server actually refused. This call can queue behind a slow + // native operation, and by the time it runs a newer handshake may have enrolled + // a replacement - which is working fine and must not be discarded on the + // strength of a verdict about its predecessor. + await native.clearKey(signedKeyId).catch(() => {}) + return undefined +} + +/** + * Run one attestation handshake and return the fresh token, or `undefined` when + * this device can never attest. Never caches directly; the caller commits the + * result. Records progress on `attempt` so the caller can tell a stale handshake + * from the current one, and a cheap failure from one that burned a platform + * attestation. + */ +const performHandshake = async ( + attempt: HandshakeAttempt +): Promise => { + // No native module (e.g. unsupported platform / dev environment). + if (EdgeAttestation == null) return undefined + + // Let a rejection here throw: that is the bridge failing to answer, not the + // device saying no, and swallowing it would retire the engine over a hiccup. + // Only an explicit `false` is terminal. + if (!(await EdgeAttestation.isSupported())) return undefined + + const isIos = Platform.OS === 'ios' + + // 1. Obtain a single-use challenge from the info server, and try to refresh + // with the key enrolled by an earlier handshake. + const refreshed = await refreshWithEnrolledKey( + EdgeAttestation, + attempt, + await fetchChallenge() + ) + if (refreshed != null) return refreshed + + // The challenge above was consumed (or expired); fetch a fresh one for the + // fallback attestation. + const challenge = await fetchChallenge() + + // 2. Produce a platform attestation bound to the challenge. Everything up to + // here is a plain info-server round trip (assertions are signed locally), so + // only from this point on does a failure cost rate-limited quota - which is + // reason enough not to spend it for an attempt nobody is waiting on. + assertCurrent(attempt) + attempt.usedAttestation = true + let native + try { + native = await EdgeAttestation.getAttestation(challenge) + } catch (error) { + // The flag has to be set before the call, because a native call that hangs + // or never answers may well have spent the attestation. When native tells us + // it never got that far, take it back rather than growing the backoff for a + // failure that cost nothing. + const code = (error as { code?: unknown } | undefined)?.code + if (typeof code === 'string' && UNSPENT_NATIVE_CODES.has(code)) { + attempt.usedAttestation = false + } + throw error + } + + // 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)) +} + +/** + * Arm the background timer, replacing whatever was pending. Every path that + * declines to start a handshake has to come through here: the engine has no + * other clock, so a path that neither starts a handshake nor arms a timer stalls + * it until the next gated call (see `runHandshake`). + */ +const armTimer = (delayMs: number): void => { + if (refreshTimer != null) clearTimeout(refreshTimer) + refreshTimer = setTimeout(() => { + runHandshake() + }, delayMs) +} + +/** + * 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, + * but never sooner than `MIN_REFRESH_MS`. + */ +const scheduleRefresh = (expires: number): void => { + armTimer(Math.max(MIN_REFRESH_MS, expires - Date.now() - REFRESH_LEAD_MS)) +} + +/** + * How long to wait before the next attempt. `FAILURE_BACKOFF_MS` while failures + * are cheap (offline, info server down), doubling up to `MAX_BACKOFF_MS` once + * attempts start burning rate-limited platform attestations. + * + * Both the background retry timer and the gate in `runHandshake` use this, so + * gated plugin traffic - the Banxa order poll runs every 3s - cannot outpace + * the policy. Keeping cheap failures at the floor is what makes that safe: a + * user whose network dropped still recovers within a minute of coming back, + * while a device the server keeps rejecting backs off. That device would fail + * its gated requests either way, so the only thing a faster retry buys it is + * burnt quota and a 3s stall per request. + */ +const failureBackoffMs = (): number => + Math.min( + FAILURE_BACKOFF_MS * 2 ** Math.max(0, consecutiveFailures - 1), + MAX_BACKOFF_MS + ) + +/** + * 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. + */ +const scheduleRetryAfterFailure = (): void => { + // 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 + armTimer(Math.max(failureBackoffMs(), refreshMs)) +} + +/** + * How long `runHandshake` must wait before it may start another attempt: the + * failure backoff, and a floor between handshakes that applies whatever the last + * one returned. Zero when an attempt may start now. + */ +const handshakeWaitMs = (): number => + Math.max( + 0, + lastFailureAt + failureBackoffMs() - Date.now(), + lastHandshakeAt + MIN_HANDSHAKE_SPACING_MS - Date.now() + ) + +/** + * 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; on failure or hang, schedules a + * backoff retry. Every exit path leaves either a handshake in flight or a timer + * armed, unless the device is `unsupported` and there is nothing left to try. + */ +const runHandshake = (): void => { + if (unsupported) return + if (inFlight != null) return + const waitMs = handshakeWaitMs() + if (waitMs > 0) { + // Re-arm instead of just returning. This path swallows whatever tick woke + // us, and a timer armed for exactly the backoff can land a hair short of it + // when the wall clock steps backwards, so returning bare would strand the + // engine until the next gated call. + armTimer(waitMs) + return + } + lastHandshakeAt = Date.now() + // 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 attempt: HandshakeAttempt = { + generation: ++handshakeGeneration, + usedAttestation: false, + countedFailure: false + } + const handshake: Promise = performHandshake(attempt) + .then(freshToken => { + if (freshToken == null) { + // Terminal: this device cannot attest, so stop the engine rather than + // waking up forever to ask again. + unsupported = true + 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 (attempt.generation !== handshakeGeneration && hasLiveToken()) return + lastFailureAt = 0 + consecutiveFailures = 0 + cachedToken = freshToken + scheduleRefresh(freshToken.expires) + }) + .catch((error: unknown) => { + if (attempt.generation !== handshakeGeneration) { + // The watchdog counted this attempt while its native call was still + // outstanding, because a call that may never answer may also have spent + // the attestation. It has now answered saying it spent nothing, so take + // that count back - a later attempt's own increment is untouched, which + // is why this cannot simply reset the counter. + if (attempt.countedFailure && !attempt.usedAttestation) { + attempt.countedFailure = false + consecutiveFailures = Math.max(0, consecutiveFailures - 1) + } + return + } + lastFailureAt = Date.now() + if (attempt.usedAttestation) { + consecutiveFailures += 1 + attempt.countedFailure = true + } + console.warn('[attestation] handshake failed:', String(error)) + scheduleRetryAfterFailure() + }) + .finally(() => { + if (attempt.watchdog != null) clearTimeout(attempt.watchdog) + 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. + attempt.watchdog = setTimeout(() => { + if (inFlight !== handshake) return + console.warn('[attestation] handshake watchdog fired; releasing lock') + inFlight = undefined + // Releasing the lock abandons this attempt, so retire its generation too. + // Otherwise it still reads as current until some other handshake starts, + // and a late settle would count this one failure twice, overwrite the + // `lastFailureAt` set below, and push out the retry scheduled here. + handshakeGeneration += 1 + // A hang is a failure and has to be recorded as one. The backoff is what + // stops a device whose native call never answers from starting a fresh + // handshake on every gated request - and stalling each of those requests + // for GET_TOKEN_TIMEOUT_MS, which is the latency this backoff exists to + // avoid. A hang inside the native attestation spends the rate-limited + // quota just like a rejection does, so it grows the backoff too. + lastFailureAt = Date.now() + if (attempt.usedAttestation) { + consecutiveFailures += 1 + attempt.countedFailure = true + } + // An attempt that never settles leaves nothing else to re-arm the loop. + scheduleRetryAfterFailure() + }, 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). + * + * A caller that arrives while the engine is backing off returns `undefined` + * without waiting at all: `runHandshake` declines to start one, so there is + * nothing to await. That is what keeps a persistently-failing device from adding + * `GET_TOKEN_TIMEOUT_MS` to every gated request. + */ +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()