Skip to content

Add app/device attestation for gated info-server requests - #6127

Closed
paullinator wants to merge 13 commits into
developfrom
paul/appAttestationV2
Closed

Add app/device attestation for gated info-server requests#6127
paullinator wants to merge 13 commits into
developfrom
paul/appAttestationV2

Conversation

@paullinator

Copy link
Copy Markdown
Member

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:

  • Single-flight handshakes with a monotonic generation counter, so an attempt the watchdog retired cannot clobber state a live attempt owns.
  • Two-tier backoff: only attempts that actually spent a platform attestation grow it. Hangs count as failures, so a device whose native call never answers stops re-attesting on every gated request.
  • Escalation to the rate-limited path is reserved for the two things it can fix — a key that cannot sign, and a key the server has judged and rejected. A 5xx/429 from the assert endpoint, an unusable 200 body, and a transient native timeout all fail into backoff instead, so an info-server outage cannot make the whole fleet discard its keys and re-attest at once.
  • iOS: operations are bounded so a hung attestKey cannot 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.
  • Android: keystoreLock is a ReentrantLock with 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.md documents 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 above
  • src/__tests__/util/attestationNativeBridge.test.ts — compares the Swift @objc selectors 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)
  • tsc and ESLint clean
  • ./gradlew :app:compileDebugKotlin succeeds
  • swiftc -typecheck succeeds
  • Exercise a real handshake on an iOS device (App Attest needs real hardware) and an Android device

Note: 21 UI suites fail on develop independently of this branch (React act errors in component tests such as Card); verified identical failures at the merge base with none of this code present.

Made with Cursor

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>
Comment thread scripts/extractSigningCert.ts Outdated
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>
@paullinator

Copy link
Copy Markdown
Member Author

bugbot run

Comment thread ios/edge/EdgeAttestation.swift
Comment thread src/util/attestation.ts Outdated
paullinator and others added 2 commits July 29, 2026 17:15
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>
Comment thread src/plugins/gui/providers/simplexProvider.ts
@cursor

This comment has been minimized.

@paullinator

Copy link
Copy Markdown
Member Author

bugbot review

Comment thread src/util/attestation.ts
@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Done

You can send follow-ups to the cloud agent here.

paullinator and others added 2 commits July 29, 2026 17:29
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>
Comment thread src/util/attestation.ts
throw error
}
// noKey / invalidKey / native signing failure: fall back to full attestation.
console.log('[attestation] assertion unavailable:', String(error))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Fix in Cursor Fix in Web

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>
@paullinator

Copy link
Copy Markdown
Member Author

bugbot review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

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>
@paullinator

Copy link
Copy Markdown
Member Author

bugbot review

@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

paullinator and others added 2 commits July 29, 2026 21:52
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>
@paullinator

Copy link
Copy Markdown
Member Author

bugbot review

@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot 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>
@paullinator

Copy link
Copy Markdown
Member Author

bugbot review

@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

paullinator and others added 2 commits July 29, 2026 22:38
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>
@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

@paullinator

Copy link
Copy Markdown
Member Author

bugbot review

@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

@paullinator

Copy link
Copy Markdown
Member Author

Closing to keep GitHub PR automation off this branch while the follow-up structural work lands. The branch paul/appAttestationV2 stays open as the head of that work: history is being squashed to the primary commit plus fixups, and a new PR will be opened once the restructure is complete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant