Add app/device attestation for gated info-server requests - #6127
Add app/device attestation for gated info-server requests#6127paullinator wants to merge 13 commits into
Conversation
Introduce a background attestation engine (src/util/attestation.ts) that runs the platform attestation handshake (Apple App Attest / Android Keystore attestation) at app boot and caches a short-lived token, self-rescheduling to refresh it two minutes before expiry. getAttestationToken() returns the cached token immediately, or waits up to three seconds for the initial handshake before returning undefined. fetchInfo (util/network.ts) carries no attestation logic; the attestation-gated plugins (Simplex/Banxa jwtSign and createHmac) call getAttestationToken() and attach the x-attestation-token header themselves only when a token is available, otherwise letting the info server decide. Co-authored-by: Cursor <cursoragent@cursor.com>
Nothing between the touchable and approveQuote catches a rejection, so a failed approval only cleared the spinner and left the user looking at a button that had done nothing. Attestation makes that reachable in ordinary use rather than as a rare network fault: jwtSign is gated, so it answers 403 whenever no token is available - a device that cannot attest, one still in backoff, or any device during an info-server outage. The equivalent path in the gui provider already reports these, so the two behaved differently on the same underlying failure. Cancellation does not surface here, since the plugins report that themselves, so this only fires on real failures. Co-authored-by: Cursor <cursoragent@cursor.com>
|
bugbot run |
The test derived its expected bound from the constant it was testing, so it went vacuous precisely when that constant was wrong: at zero, WINDOW_MS / MIN_HANDSHAKE_SPACING_MS is Infinity and every possible call count satisfies the bound. Mutation-testing the suite caught this - the floor could be set to zero, dropped from handshakeWaitMs, or have lastHandshakeAt never recorded, and all 36 tests still passed. Pin the floor as a literal and assert the observed gaps between platform attestations instead of only their count. A count bound alone is also satisfied by the refresh floor, so it never distinguished the spacing logic working from it being absent. Timing at the attestation rather than the challenge fetch matters too: with no enrolled key each handshake fetches a challenge twice, so challenges do not map one-to-one onto handshakes. All three mutations are now caught, along with the five that already were. Co-authored-by: Cursor <cursoragent@cursor.com>
The keystore path, alias and password were interpolated into a shell string for execSync. Framed as an injection risk this is minor - it is a developer tool reading the developer's own keystore - but the practical failure is worse than the theoretical one: keystore passwords routinely contain characters the shell acts on, and a password of se$cret`x123 does not merely misbehave, it aborts with "/bin/sh: unexpected EOF". That would land on whoever runs this against the real production keystore, which is the reason the script exists. Pass the arguments to execFileSync as an array so no shell is involved, and hand keytool the password on stdin rather than -storepass, which also keeps it out of the process list. Verified against generated keystores that a metacharacter password now works with an alias, without one, and via the --storepass flag. Report keytool's own reason on failure. It writes that to stdout, not stderr - stderr carries only the password prompt - so a wrong password now prints "keystore password was incorrect" instead of a bare non-zero exit that reads like a parsing bug. Co-authored-by: Cursor <cursoragent@cursor.com>
This comment has been minimized.
This comment has been minimized.
|
bugbot review |
|
Done You can send follow-ups to the cloud agent here. |
performHandshake marks an attempt as having spent a platform attestation just before calling native, which is the right default: a call that hangs or never answers may well have consumed one. But Android's 60s tryLock rejects before the lock is held, so no key was generated and nothing rate-limited was spent - yet both the catch and the watchdog counted it, doubling the backoff toward MAX_BACKOFF_MS. Lock contention means an earlier native call is already wedged, so this took a recoverable device off the air over failures that cost nothing. The underlying problem was one code string meaning two different things. Android reported `timeout` for a lock it never acquired, while iOS reports `timeout` for an attestKey callback that did start and may already have counted against Apple's quota. Give Android's case its own `lockTimeout` code and withdraw the flag only for that, so the platform that spent nothing retries on a flat backoff while the platform that may have spent something still backs off exponentially. Both codes stay transient for the assert path, where neither says anything about whether the enrolled key can sign. Co-authored-by: Cursor <cursoragent@cursor.com>
Mutation-testing found nothing held this: dropping lockTimeout from TRANSIENT_NATIVE_CODES left all 38 tests passing. Android signChallenge losing the Keystore lock would then fall through to a full attestation, spending rate-limited quota to replace a key that signs perfectly well and was merely contended - the bug fixed for iOS's timeout, reopened for Android by giving its lock failure a distinct code. Co-authored-by: Cursor <cursoragent@cursor.com>
| throw error | ||
| } | ||
| // noKey / invalidKey / native signing failure: fall back to full attestation. | ||
| console.log('[attestation] assertion unavailable:', String(error)) |
There was a problem hiding this comment.
Unguarded attestation debug log
Low Severity
The new attestation engine logs assertion fallback paths with an unguarded console.log. Production builds will emit this on every enrolled-key refresh failure that falls through to full attestation, which violates the project rule to guard debug logging (e.g. behind verbose logging config).
Triggered by project rule: Bugbot Review Rules
Reviewed by Cursor Bugbot for commit b4cb718. Configure here.
After getAttestation hits its 120s timeout and releases the queue, a slow generateKey callback still ran storePendingKeyId unconditionally. A newer handshake may have stored a pending key by then, and overwriting it loses that key for good: this operation goes on to clear the slot after its own attestKey, so the newer key is forgotten and the next attempt spends another generateKey - the limited resource the pending slot exists to conserve. Record the key only while the slot is empty. The live handshake reaches that point having just read it as empty, so this only ever declines for a callback that outlived its operation. Losing the race also stops it before attestKey, since the timeout already settled the promise and storeKeyId would refuse the result - the attestation would be spent on a key nothing can use. Report `superseded` for that case, which JS reads as proof nothing was spent, so the retry stays on a flat backoff. Co-authored-by: Cursor <cursoragent@cursor.com>
|
bugbot review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
1 issue from previous review remains unresolved.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit aa8356d. Configure here.
Two problems in the fixes from the previous commits. storePendingKeyId(ifAbsent:) used an empty pending slot as a proxy for "no newer handshake owns this", but an empty slot only means no newer handshake has reached that point yet, and the load-then-store was not atomic. A stale callback could win the race and push the live handshake - the one JS is still awaiting - down the path meant for late losers. PromiseOnce already answers the real question atomically: the only thing that settles a promise early is the operation timeout, so a settled promise means this callback outlived its operation. Use that, and drop the `superseded` code, which can no longer reach JS. Separately, the watchdog counts an outstanding native call as having spent an attestation, 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 is retired. The retired attempt's catch handler returned before correcting the count, so a failure that provably cost nothing still grew the backoff. It now takes its own count back, once, leaving a newer attempt's count alone - and keeps it when the reason may have spent quota. The first test written for this passed either way, because the backoff is FAILURE_BACKOFF_MS * 2 ** (count - 1): counts of zero and one give the same window, so a single uncounted failure is invisible. Both tests now run two rounds, and mutation-testing confirms they fail without each half of the fix. Co-authored-by: Cursor <cursoragent@cursor.com>
|
bugbot review |
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
A mutation sweep over the engine's guards and constants reverted sixteen of them one at a time; four survived with all 41 tests still passing, meaning nothing distinguished the protection from its absence. Three were real gaps, each reachable and each costing something at runtime: - Dropping the cached token when the server rejects an assertion. Without it a token stays in the cache for the rest of its lifetime while the server refuses the key that minted it, so every gated caller keeps presenting a credential already being turned down. - The generation check before the native attestation. The steps ahead of it are all info-server round trips, so a slow enough network lets the 90s watchdog retire an attempt before it gets there, and without the check it carries on and spends rate-limited quota anyway. - Only clearing the in-flight lock when this attempt still owns it. A handshake that hangs past the watchdog and settles later would otherwise hand back a lock the newer handshake holds, letting a third start alongside it with both spending an attestation. The fourth is unreachable: an attempt clears its own watchdog in `.finally`, and those microtasks run before the timer macrotask, so the watchdog never observes a lock it does not hold. Left as it is, untested by nature. Co-authored-by: Cursor <cursoragent@cursor.com>
A second mutation sweep, over the parsing and lifecycle code the first one did not reach, left eleven of sixteen mutations alive. The most instructive was `expires` validation: the existing test feeds a non-finite expires and asserts no token comes back, which holds with the check removed too, because every comparison against NaN is false. The damage it was meant to catch is elsewhere - the token gets cached, `scheduleRefresh` computes NaN, and `setTimeout` reads that as zero, so the engine re-attests as fast as the network answers. The four malformed-body cases are now asserted through the backoff instead: a counted failure puts the next attempt one window out and the one after that two, which a handshake mistaken for a success cannot imitate. Also covered: the clock-skew margin when reading a cached token, a malformed challenge failing before native is asked to attest it, `initAttestation` declining to re-handshake over a live token, and a success clearing a grown backoff. Three mutations still survive, all harmless. The challenge and attest status checks are subsumed by the body validation that follows them, since an error body carries neither a challenge nor a token, so removing them changes only the error message. The null-module guard cannot be exercised by a suite that installs the mock before import. Co-authored-by: Cursor <cursoragent@cursor.com>
|
bugbot review |
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
A review flagged the settlement check in the generateKey callback as a check-then-act race: the 120s timeout can settle the promise between the check passing and the store and attestKey that follow, so a dead operation can still spend an attestation. That is true, and it is irreducible. The timeout owes JS an answer at 120s whatever the callback is doing, and an attestKey already handed to Apple cannot be recalled, so no test can establish more than "live a moment ago". Nothing further would help. What saves the attestation is the settlement check, already as late as it can be placed, and re-reading the pending slot would not stop a store that wins the race to an empty one - it would only add a second non-atomic guard covering part of one sub-case. So the window is recorded instead, next to the `ifMatches` window the file already accepts on the same grounds. Landing in it costs one attestation and leaves a pending key the next re-enrollment discards, because Apple will not attest the same key twice. Comments and documentation only; no behaviour change. Co-authored-by: Cursor <cursoragent@cursor.com>
|
bugbot review |
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
Three timeouts have to stay in one order, and each file documents its end of the bargain without being able to check the others: Android's 60s Keystore lock timeout, the 90s JS watchdog, and iOS's 120s App Attest operation timeout. Both bounds carry weight. Android's `lockTimeout` is the rejection that tells JS no attestation was spent, so it has to arrive while the attempt is still live - after the watchdog it reaches a handler that can only un-count, and 90s bought nothing. iOS's timeout is there to unwedge the serial queue, so below the watchdog it would start failing handshakes that were merely slow, each at the cost of an attestation. Nothing else in the repo would notice these crossing. The constants are read out of the three sources rather than imported, since two are native and none is exported, and a rename throws instead of quietly passing. Co-authored-by: Cursor <cursoragent@cursor.com>
tryLock throws InterruptedException, and it is called outside the try/catch that getAttestation and signChallenge wrap their own work in. An escape there would leave the promise unsettled, hanging the JS caller until the watchdog, and would reach Thread.run uncaught - which on Android means the default handler takes the process down. Nothing interrupts these threads today, since they are plain Threads with no reference kept, so this is unreachable as written. It would stop being unreachable the moment they became executor tasks or coroutines, where interruption is how cancellation arrives, and nothing in this file would have to change for that to happen. `lockTimeout` is the right code for it: the lock was never held, so no platform attestation was spent, which is exactly what that code tells the engine. The accompanying test pins both directions of that agreement. Every rejection inside the lock acquisition has to use a code the engine knows, so a rename cannot quietly turn a free failure into one that doubles the backoff; and every code the engine knows has to be emitted by some module, so a typo in the set cannot quietly stop matching the real one. Either mistake is invisible at runtime - JS cannot distinguish an unfamiliar code from a genuine failure, so it assumes the expensive case both times. Co-authored-by: Cursor <cursoragent@cursor.com>
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
|
bugbot review |
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
|
Closing to keep GitHub PR automation off this branch while the follow-up structural work lands. The branch |


Supersedes #6076, which carried the same work across nine
fixup!commits. Same tree, squashed into one commit so it can be reviewed as a whole.Summary
Adds application-level attestation for the info-server endpoints that gate signing (Simplex, Banxa). A background engine keeps a short-lived JWT fresh, using Apple App Attest on iOS and Android Keystore key attestation on Android. Gated callers await the token rather than racing their own handshakes.
The bulk of the work beyond the initial implementation went into the concurrency and quota behaviour, since platform attestation is rate-limited on iOS and a mistake there is expensive across a fleet:
5xx/429from the assert endpoint, an unusable200body, and a transient nativetimeoutall fail into backoff instead, so an info-server outage cannot make the whole fleet discard its keys and re-attest at once.attestKeycannot wedge the serial queue; promises settle exactly once; a late callback neither enrols a key the server never verified nor clears one a newer handshake owns.keystoreLockis aReentrantLockwith a bounded wait, so one wedged Keystore call cannot disable every later operation; all methods stay off the shared native-modules thread.clearKey(keyId)is scoped to the key the caller meant, on both platforms, since the call can outlive the state it was reasoning about.docs/APP_ATTESTATION.mddocuments the engine, both native modules, and the escalation rules.Test plan
src/__tests__/util/attestation.test.ts— 36 tests, including regressions for every race fixed abovesrc/__tests__/util/attestationNativeBridge.test.ts— compares the Swift@objcselectors against the hand-written Objective-C bridge, which nothing else in the repo verifies (a mismatch is not a build error; React Native only fails at runtime)tscand ESLint clean./gradlew :app:compileDebugKotlinsucceedsswiftc -typechecksucceedsNote: 21 UI suites fail on
developindependently of this branch (Reactacterrors in component tests such asCard); verified identical failures at the merge base with none of this code present.Made with Cursor