Add app/device attestation for gated info-server requests - #6076
Add app/device attestation for gated info-server requests#6076paullinator wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Hung handshake blocks later attempts
- The shared handshake lock now expires after the caller timeout and stale attempts cannot clear a newer lock.
- ✅ Fixed: Unvalidated expires breaks token cache
- Attestation responses now require expires to be a finite number before caching the token.
Or push these changes by commenting:
@cursor push 836aed9d72
Preview (836aed9d72)
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -29,7 +29,7 @@
const REFRESH_LEAD_MS = 2 * 60 * 1000
// Small skew so a token that is about to expire is treated as unusable.
const CLOCK_SKEW_MS = 5 * 1000
-// Max time getAttestationToken() blocks waiting on the initial handshake.
+// Max time a caller waits and one handshake attempt holds the shared lock.
const GET_TOKEN_TIMEOUT_MS = 3 * 1000
let cachedToken: CachedToken | undefined
@@ -89,6 +89,9 @@
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')
+ }
cachedToken = { token, expires }
}
@@ -115,7 +118,7 @@
*/
const runHandshake = (): void => {
if (inFlight != null) return
- inFlight = performHandshake()
+ const handshake: Promise<void> = performHandshake()
.then(() => {
if (cachedToken != null) scheduleRefresh(cachedToken.expires)
})
@@ -123,8 +126,13 @@
console.warn('[attestation] handshake failed:', String(error))
})
.finally(() => {
- inFlight = undefined
+ if (inFlight === handshake) inFlight = undefined
})
+ inFlight = handshake
+ // A stuck native call may continue, but it must not block later attempts.
+ setTimeout(() => {
+ if (inFlight === handshake) inFlight = undefined
+ }, GET_TOKEN_TIMEOUT_MS)
}
/**You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Watchdog allows overlapping handshakes
- Kept the handshake lock after the watchdog until the uncancellable native promise settles and added regression coverage proving no second native attestation starts.
Or push these changes by commenting:
@cursor push fdfed897e3
Preview (fdfed897e3)
diff --git a/src/__tests__/util/attestation.test.ts b/src/__tests__/util/attestation.test.ts
--- a/src/__tests__/util/attestation.test.ts
+++ b/src/__tests__/util/attestation.test.ts
@@ -140,7 +140,7 @@
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 () => {
+ it('keeps a hung handshake locked after the watchdog', async () => {
mockGetAttestation.mockImplementation(
async () => await new Promise(() => {}) // never settles
)
@@ -156,25 +156,19 @@
await Promise.resolve()
await Promise.resolve()
- // Watchdog fires and clears the lock.
+ // The watchdog reports the hung handshake without clearing its lock.
await jest.advanceTimersByTimeAsync(
attestationTimingForTests.HANDSHAKE_WATCHDOG_MS
)
- // A subsequent attempt can start after the lock is released.
- const expires = Date.now() + 10 * 60 * 1000
- mockGetAttestation.mockResolvedValue({
- keyId: 'key2',
- attestation: 'att2',
- bundleId: 'co.edgesecure.app'
- })
- mockSuccessfulHandshake(expires)
-
+ // A subsequent request waits on the existing handshake rather than
+ // starting overlapping native attestation work.
const tokenPromise = getAttestationToken()
- await Promise.resolve()
- await Promise.resolve()
- await Promise.resolve()
- await expect(tokenPromise).resolves.toBe('jwt-token')
+ await jest.advanceTimersByTimeAsync(
+ attestationTimingForTests.GET_TOKEN_TIMEOUT_MS
+ )
+ await expect(tokenPromise).resolves.toBeUndefined()
+ expect(mockGetAttestation).toHaveBeenCalledTimes(1)
})
it('suppresses retries during the failure backoff window (Task 2.3)', async () => {
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -46,10 +46,8 @@
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).
+// Watchdog: log when a handshake has not settled after this long. The lock
+// remains held since native attestation cannot be cancelled safely.
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
@@ -258,12 +256,11 @@
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.
+ // Do not release the lock here: the native call may still be running, and
+ // overlapping attempts can corrupt shared attestation key state.
setTimeout(() => {
if (inFlight === handshake) {
- console.warn('[attestation] handshake watchdog fired; releasing lock')
- inFlight = undefined
+ console.warn('[attestation] handshake watchdog fired; still in progress')
}
}, HANDSHAKE_WATCHDOG_MS)
}You can send follow-ups to the cloud agent here.
03eb1ba to
1c7b261
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Unguarded attestation console logging
- Guarded both routine assertion fallback logs with ENV.DEBUG_VERBOSE_LOGGING so production builds remain quiet.
Or push these changes by commenting:
@cursor push 7b1cb6d483
Preview (7b1cb6d483)
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -1,5 +1,6 @@
import { NativeModules, Platform } from 'react-native'
+import { ENV } from '../env'
import { fetchInfo } from './network'
/**
@@ -156,7 +157,9 @@
await EdgeAttestation.clearKey().catch(() => {})
} catch (error) {
// noKey / invalidKey / native failure: fall through to full attestation.
- console.log('[attestation] assertion unavailable:', String(error))
+ if (ENV.DEBUG_VERBOSE_LOGGING) {
+ console.log('[attestation] assertion unavailable:', String(error))
+ }
}
// The challenge above was consumed (or expired); fetch a fresh one for
// the fallback attestation.
@@ -188,7 +191,9 @@
await EdgeAttestation.clearKey().catch(() => {})
} catch (error) {
// noKey / native failure: fall through to full attestation.
- console.log('[attestation] assertion unavailable:', String(error))
+ if (ENV.DEBUG_VERBOSE_LOGGING) {
+ console.log('[attestation] assertion unavailable:', String(error))
+ }
}
// The challenge above was consumed (or expired); fetch a fresh one for
// the fallback attestation.You can send follow-ups to the cloud agent here.
1c7b261 to
552fef1
Compare
There was a problem hiding this comment.
Claude Code Review
Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.
Tip: disable this comment in your organization's Code Review settings.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Stale JWT after assert rejection
- Cleared cachedToken immediately when either iOS or Android assertion refresh is rejected before full re-attestation begins.
Or push these changes by commenting:
@cursor push db21325fa5
Preview (db21325fa5)
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -150,6 +150,7 @@
return
}
// Server rejected the assertion: discard the key and re-attest.
+ cachedToken = undefined
console.warn(
`[attestation] assertion rejected (${assertResponse.status}); re-attesting`
)
@@ -182,6 +183,7 @@
cacheTokenFromResponse(await assertResponse.json())
return
}
+ cachedToken = undefined
console.warn(
`[attestation] assertion rejected (${assertResponse.status}); re-attesting`
)You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Stale handshake clears valid token
- Guarded assertion-rejection cache invalidation with the handshake generation so stale attempts cannot clear a newer token.
Or push these changes by commenting:
@cursor push c8daef9e90
Preview (c8daef9e90)
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -124,7 +124,9 @@
* there is nothing to do (no native module / unsupported platform). Never
* caches directly; the caller commits the result.
*/
-const performHandshake = async (): Promise<CachedToken | undefined> => {
+const performHandshake = async (
+ generation: number
+): Promise<CachedToken | undefined> => {
// No native module (e.g. unsupported platform / dev environment).
if (EdgeAttestation == null) return undefined
@@ -163,7 +165,7 @@
// 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.
- cachedToken = undefined
+ if (generation === handshakeGeneration) cachedToken = undefined
console.warn(
`[attestation] assertion rejected (${assertResponse.status}); re-attesting`
)
@@ -197,7 +199,7 @@
}
// Server rejected the assertion: drop the now-suspect cached token (see
// the iOS branch above) before discarding the key and re-attesting.
- cachedToken = undefined
+ if (generation === handshakeGeneration) cachedToken = undefined
console.warn(
`[attestation] assertion rejected (${assertResponse.status}); re-attesting`
)
@@ -265,7 +267,7 @@
// lock released so a newer handshake can start. Tag each attempt so a stale
// one that finally resolves cannot clobber the newer handshake's token.
const generation = ++handshakeGeneration
- const handshake: Promise<void> = performHandshake()
+ const handshake: Promise<void> = performHandshake(generation)
.then(freshToken => {
if (generation !== handshakeGeneration) return
lastFailureAt = 0You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: iOS attestation lacks native serialization
- Serialized getAttestation, generateAssertion, and clearKey through a dedicated native queue held until each asynchronous App Attest operation completes.
Or push these changes by commenting:
@cursor push f334b72fdd
Preview (f334b72fdd)
diff --git a/ios/edge/EdgeAttestation.swift b/ios/edge/EdgeAttestation.swift
--- a/ios/edge/EdgeAttestation.swift
+++ b/ios/edge/EdgeAttestation.swift
@@ -23,7 +23,24 @@
// assertions (no re-attestation).
private static let keychainService = "co.edgesecure.app.appattest"
private static let keychainAccount = "keyId"
+ // The JS watchdog can start another handshake before an older native call
+ // returns, so keep App Attest and Keychain operations serialized end-to-end.
+ private static let appAttestQueue = DispatchQueue(
+ label: "co.edgesecure.app.appattest.operations"
+ )
+ private func performSerialized(
+ _ operation: @escaping (@escaping () -> Void) -> Void
+ ) {
+ EdgeAttestation.appAttestQueue.async {
+ let finished = DispatchSemaphore(value: 0)
+ operation {
+ finished.signal()
+ }
+ finished.wait()
+ }
+ }
+
private func storeKeyId(_ keyId: String) {
clearKeyId()
guard let data = keyId.data(using: .utf8) else { return }
@@ -87,39 +104,44 @@
return
}
- // A fresh key is generated per handshake: an App Attest key can only be
- // attested once, so reuse would require the assertion flow instead.
- service.generateKey { keyId, error in
- if let error = error {
- reject("generateKey", error.localizedDescription, error)
- return
- }
- guard let keyId = keyId else {
- reject("generateKey", "Failed to generate an App Attest key", nil)
- return
- }
-
- // The client data is the challenge's UTF-8 bytes; the server recomputes
- // SHA256(challenge) to validate the attestation nonce.
- let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
-
- service.attestKey(keyId, clientDataHash: clientDataHash) { attestation, error in
+ performSerialized { finish in
+ // A fresh key is generated per handshake: an App Attest key can only be
+ // attested once, so reuse would require the assertion flow instead.
+ service.generateKey { keyId, error in
if let error = error {
- reject("attestKey", error.localizedDescription, error)
+ reject("generateKey", error.localizedDescription, error)
+ finish()
return
}
- guard let attestation = attestation else {
- reject("attestKey", "Failed to produce an attestation object", nil)
+ guard let keyId = keyId else {
+ reject("generateKey", "Failed to generate an App Attest key", nil)
+ finish()
return
}
- // Persist the key id so subsequent handshakes refresh via assertions
- // instead of a full (rate-limited) attestation.
- self.storeKeyId(keyId)
- resolve([
- "keyId": keyId,
- "attestation": attestation.base64EncodedString(),
- "bundleId": Bundle.main.bundleIdentifier ?? ""
- ])
+
+ // The client data is the challenge's UTF-8 bytes; the server recomputes
+ // SHA256(challenge) to validate the attestation nonce.
+ let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
+
+ service.attestKey(keyId, clientDataHash: clientDataHash) { attestation, error in
+ defer { finish() }
+ if let error = error {
+ reject("attestKey", error.localizedDescription, error)
+ return
+ }
+ guard let attestation = attestation else {
+ reject("attestKey", "Failed to produce an attestation object", nil)
+ return
+ }
+ // Persist the key id so subsequent handshakes refresh via assertions
+ // instead of a full (rate-limited) attestation.
+ self.storeKeyId(keyId)
+ resolve([
+ "keyId": keyId,
+ "attestation": attestation.base64EncodedString(),
+ "bundleId": Bundle.main.bundleIdentifier ?? ""
+ ])
+ }
}
}
}
@@ -134,31 +156,36 @@
reject("unsupported", "App Attest is not supported on this device", nil)
return
}
- guard let keyId = loadKeyId() else {
- reject("noKey", "No attested App Attest key is stored", nil)
- return
- }
- let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
- DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in
- if let error = error as? DCError, error.code == .invalidKey {
- // The key no longer exists (reinstall/restore); force re-attestation.
- self.clearKeyId()
- reject("invalidKey", "Stored App Attest key is invalid", error)
+ performSerialized { finish in
+ guard let keyId = self.loadKeyId() else {
+ reject("noKey", "No attested App Attest key is stored", nil)
+ finish()
return
}
- if let error = error {
- reject("generateAssertion", error.localizedDescription, error)
- return
+ let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
+ DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash) {
+ assertion, error in
+ defer { finish() }
+ if let error = error as? DCError, error.code == .invalidKey {
+ // The key no longer exists (reinstall/restore); force re-attestation.
+ self.clearKeyId()
+ reject("invalidKey", "Stored App Attest key is invalid", error)
+ return
+ }
+ if let error = error {
+ reject("generateAssertion", error.localizedDescription, error)
+ return
+ }
+ guard let assertion = assertion else {
+ reject("generateAssertion", "Failed to produce an assertion", nil)
+ return
+ }
+ resolve([
+ "keyId": keyId,
+ "assertion": assertion.base64EncodedString(),
+ "bundleId": Bundle.main.bundleIdentifier ?? ""
+ ])
}
- guard let assertion = assertion else {
- reject("generateAssertion", "Failed to produce an assertion", nil)
- return
- }
- resolve([
- "keyId": keyId,
- "assertion": assertion.base64EncodedString(),
- "bundleId": Bundle.main.bundleIdentifier ?? ""
- ])
}
}
@@ -167,7 +194,10 @@
_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
- clearKeyId()
- resolve(nil)
+ performSerialized { finish in
+ self.clearKeyId()
+ resolve(nil)
+ finish()
+ }
}
}You can send follow-ups to the cloud agent here.
5091e83 to
40713eb
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 4 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 4 issues found in the latest run.
- ✅ Fixed: Assert errors wipe enrolled keys
- Assertion transport, server, parsing, and unexpected native failures now propagate without clearing the enrolled key or triggering full re-attestation.
- ✅ Fixed: Stale handshake clears live key
- Key invalidation and deletion now occur only while the rejecting handshake generation is still current.
- ✅ Fixed: Android clearKey blocks bridge thread
- Android key deletion and lock acquisition now run on a background thread before resolving the bridge promise.
- ✅ Fixed: Network responses skip cleaners
- Challenge and token JSON responses now pass through dedicated cleaners with the cached token type derived from its cleaner.
Or push these changes by commenting:
@cursor push bc1e67e9a9
Preview (bc1e67e9a9)
diff --git a/android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt b/android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt
--- a/android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt
+++ b/android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt
@@ -198,16 +198,19 @@
@ReactMethod
fun clearKey(promise: Promise) {
// Best-effort: force re-enrollment when the server rejects an assertion
- // (unknown key, revoked serial, disabled app). Resolve regardless.
- try {
- synchronized(keystoreLock) {
- val keyStore = KeyStore.getInstance("AndroidKeyStore")
- keyStore.load(null)
- keyStore.deleteEntry(KEY_ALIAS)
+ // (unknown key, revoked serial, disabled app). Keystore access can block
+ // behind key generation, so keep it off the React Native bridge thread.
+ Thread {
+ try {
+ synchronized(keystoreLock) {
+ val keyStore = KeyStore.getInstance("AndroidKeyStore")
+ keyStore.load(null)
+ keyStore.deleteEntry(KEY_ALIAS)
+ }
+ } catch (ignored: Exception) {
+ // Best effort.
}
- } catch (ignored: Exception) {
- // Best effort.
- }
- promise.resolve(null)
+ promise.resolve(null)
+ }.start()
}
}
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -1,3 +1,4 @@
+import { asNumber, asObject, asString } from 'cleaners'
import { NativeModules, Platform } from 'react-native'
import { fetchInfo } from './network'
@@ -34,10 +35,9 @@
const EdgeAttestation: NativeAttestation | undefined =
NativeModules.EdgeAttestation
-interface CachedToken {
- token: string
- expires: number // epoch milliseconds
-}
+const asChallengeResponse = asObject({ challenge: asString })
+const asCachedToken = asObject({ token: asString, expires: asNumber })
+type CachedToken = ReturnType<typeof asCachedToken>
// 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.
@@ -55,6 +55,7 @@
// for this long. Keeps a persistently-failing device from adding 3s of
// latency to every gated request.
const FAILURE_BACKOFF_MS = 60 * 1000
+const KEY_REJECTION_STATUSES = new Set([400, 401, 403, 404])
let cachedToken: CachedToken | undefined
let inFlight: Promise<void> | undefined
@@ -84,14 +85,26 @@
const hasLiveToken = (): boolean =>
cachedToken != null && Date.now() < cachedToken.expires - CLOCK_SKEW_MS
+const isMissingKeyError = (error: unknown): boolean => {
+ if (typeof error !== 'object' || error == null) return false
+ const code = 'code' in error ? error.code : undefined
+ const message = error instanceof Error ? error.message : undefined
+ return (
+ code === 'noKey' ||
+ code === 'invalidKey' ||
+ message === 'noKey' ||
+ message === 'invalidKey'
+ )
+}
+
/** Obtain a single-use challenge from the info server. */
const fetchChallenge = async (): Promise<string> => {
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 === '') {
+ const { challenge } = asChallengeResponse(await challengeResponse.json())
+ if (challenge === '') {
throw new Error('challenge response missing challenge')
}
return challenge
@@ -106,17 +119,12 @@
* 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)) {
+ const tokenResponse = asCachedToken(json)
+ const { expires } = tokenResponse
+ if (!Number.isFinite(expires)) {
throw new Error('attest response missing expires')
}
- return { token, expires }
+ return tokenResponse
}
/**
@@ -146,11 +154,20 @@
let challenge = await fetchChallenge()
// iOS fast path: assert with the stored attested key (no Apple round
- // trip, no new key). Falls back to full attestation when there is no
- // stored key, the key is invalid, or the server rejects the assertion.
+ // trip, no new key). Falls back to full attestation only when there is no
+ // stored key, the key is invalid, or the server rejects the enrolled key.
if (isIos) {
+ let native:
+ | Awaited<ReturnType<NativeAttestation['generateAssertion']>>
+ | undefined
try {
- const native = await EdgeAttestation.generateAssertion(challenge)
+ native = await EdgeAttestation.generateAssertion(challenge)
+ } catch (error) {
+ if (!isMissingKeyError(error)) throw error
+ console.log('[attestation] assertion unavailable:', String(error))
+ challenge = await fetchChallenge()
+ }
+ if (native != null) {
const assertResponse = await fetchInfo('v1/attest/apple/assert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -163,32 +180,34 @@
if (assertResponse.ok) {
return parseTokenResponse(await assertResponse.json())
}
- // Server rejected the assertion: the enrolled key is no longer trusted,
- // so any previously-minted token is suspect too. Drop it now so gated
- // callers do not keep sending a token the server already rejects while
- // re-enrollment is in progress; discard the key and re-attest. Only when
- // this is still the current handshake, so a stale (watchdog-released)
- // attempt cannot wipe a token a newer handshake already cached.
- if (generation === handshakeGeneration) cachedToken = undefined
+ if (!KEY_REJECTION_STATUSES.has(assertResponse.status)) {
+ throw new Error(`assert request failed: ${assertResponse.status}`)
+ }
+ if (generation !== handshakeGeneration) return undefined
+ cachedToken = undefined
console.warn(
`[attestation] assertion rejected (${assertResponse.status}); re-attesting`
)
- await EdgeAttestation.clearKey().catch(() => {})
- } catch (error) {
- // noKey / invalidKey / native failure: fall through to full attestation.
- console.log('[attestation] assertion unavailable:', String(error))
+ await EdgeAttestation.clearKey()
+ challenge = await fetchChallenge()
}
- // The challenge above was consumed (or expired); fetch a fresh one for
- // the fallback attestation.
- challenge = await fetchChallenge()
}
// Android fast path: sign the challenge with the enrolled Keystore key
- // (no new key, no RKP dependency). Falls back to full attestation when
- // there is no stored key or the server rejects the assertion.
+ // (no new key, no RKP dependency). Falls back to full attestation only when
+ // there is no stored key or the server rejects the enrolled key.
if (!isIos) {
+ let native:
+ | Awaited<ReturnType<NativeAttestation['signChallenge']>>
+ | undefined
try {
- const native = await EdgeAttestation.signChallenge(challenge)
+ native = await EdgeAttestation.signChallenge(challenge)
+ } catch (error) {
+ if (!isMissingKeyError(error)) throw error
+ console.log('[attestation] assertion unavailable:', String(error))
+ challenge = await fetchChallenge()
+ }
+ if (native != null) {
const assertResponse = await fetchInfo('v1/attest/android/assert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -201,21 +220,17 @@
if (assertResponse.ok) {
return parseTokenResponse(await assertResponse.json())
}
- // Server rejected the assertion: drop the now-suspect cached token (see
- // the iOS branch above), guarded by the current generation, before
- // discarding the key and re-attesting.
- if (generation === handshakeGeneration) cachedToken = undefined
+ if (!KEY_REJECTION_STATUSES.has(assertResponse.status)) {
+ throw new Error(`assert request failed: ${assertResponse.status}`)
+ }
+ if (generation !== handshakeGeneration) return undefined
+ cachedToken = undefined
console.warn(
`[attestation] assertion rejected (${assertResponse.status}); re-attesting`
)
- await EdgeAttestation.clearKey().catch(() => {})
- } catch (error) {
- // noKey / native failure: fall through to full attestation.
- console.log('[attestation] assertion unavailable:', String(error))
+ await EdgeAttestation.clearKey()
+ challenge = await fetchChallenge()
}
- // The challenge above was consumed (or expired); fetch a fresh one for
- // the fallback attestation.
- challenge = await fetchChallenge()
}
// 2. Produce a platform attestation bound to the challenge.You can send follow-ups to the cloud agent here.
27e32bb to
4e5569c
Compare
271e330 to
1e8bbfe
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Watchdog drops late valid token
- Stale handshake completions now cache a late valid JWT when no fresher live token exists, so a post-watchdog failed retry no longer discards a successful older attestation during backoff.
Or push these changes by commenting:
@cursor push 98b1157448
Preview (98b1157448)
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -60,8 +60,9 @@
let inFlight: Promise<void> | undefined
let refreshTimer: ReturnType<typeof setTimeout> | undefined
let lastFailureAt = 0
-// Monotonic id of the latest handshake attempt; a resolved handshake only
-// commits its token when its generation is still current (see runHandshake).
+// Monotonic id of the latest handshake attempt; used so a stale (watchdog-
+// released) completion cannot clobber a token a newer handshake already
+// cached (see runHandshake).
let handshakeGeneration = 0
/** Test-only: clear module state between Jest cases. */
@@ -274,7 +275,13 @@
const generation = ++handshakeGeneration
const handshake: Promise<void> = performHandshake(generation)
.then(freshToken => {
- if (generation !== handshakeGeneration) return
+ if (generation !== handshakeGeneration) {
+ // Watchdog-superseded: a newer attempt already owns the generation.
+ // Still accept a late valid JWT when nothing fresher is cached (the
+ // newer attempt may have failed and entered backoff). Never clobber
+ // a live token a newer handshake already produced.
+ if (freshToken == null || hasLiveToken()) return
+ }
lastFailureAt = 0
if (freshToken != null) {
cachedToken = freshTokenYou can send follow-ups to the cloud agent here.
1e8bbfe to
8c97735
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Done
Or push these changes by commenting:
@cursor push 3d21ad7707
Preview (3d21ad7707)
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -175,7 +175,11 @@
)
await EdgeAttestation.clearKey().catch(() => {})
} catch (error) {
- // noKey / invalidKey / native failure: fall through to full attestation.
+ // noKey / invalidKey / native failure: the enrolled key is unusable
+ // (invalidKey also clears the stored key id natively), so drop any
+ // cached JWT before falling through to full attestation - same
+ // fail-closed semantics as a non-OK assert response above.
+ if (generation === handshakeGeneration) cachedToken = undefined
console.log('[attestation] assertion unavailable:', String(error))
}
// The challenge above was consumed (or expired); fetch a fresh one for
@@ -210,7 +214,9 @@
)
await EdgeAttestation.clearKey().catch(() => {})
} catch (error) {
- // noKey / native failure: fall through to full attestation.
+ // noKey / native failure: drop any cached JWT before falling through
+ // to full attestation (see the iOS catch above).
+ if (generation === handshakeGeneration) cachedToken = undefined
console.log('[attestation] assertion unavailable:', String(error))
}
// The challenge above was consumed (or expired); fetch a fresh one forYou can send follow-ups to the cloud agent here.
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>
e7359c8 to
d263e7f
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Done
Or push these changes by commenting:
@cursor push f7173c7ecb
Preview (f7173c7ecb)
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -293,6 +293,14 @@
if (generation !== handshakeGeneration) return
lastFailureAt = Date.now()
console.warn('[attestation] handshake failed:', String(error))
+ // Keep the background engine alive across transient failures. The timer
+ // that fired this attempt is gone, and scheduleRefresh only runs on
+ // success — without a backoff retry the loop would stall until a gated
+ // caller or app restart kicks runHandshake again.
+ if (refreshTimer != null) clearTimeout(refreshTimer)
+ refreshTimer = setTimeout(() => {
+ runHandshake()
+ }, FAILURE_BACKOFF_MS)
})
.finally(() => {
if (inFlight === handshake) inFlight = undefinedYou can send follow-ups to the cloud agent here.
Schedule a FAILURE_BACKOFF_MS retry after a failed handshake so a proactive refresh that fails does not leave the background engine idle until the next gated call or app restart. Export REFRESH_LEAD_MS for tests and cover the no-gated-call retry path. Co-authored-by: Cursor <cursoragent@cursor.com>
Bound the handshake retry loop and close the hang path. Retrying every FAILURE_BACKOFF_MS forever means a device that cannot attest (offline, or rejected by the server) burns an Apple App Attest or Android Keystore attestation every minute for as long as the app is open, and both are rate-limited. Double the wait after each consecutive failure up to MAX_BACKOFF_MS instead. The lastFailureAt gate stays at FAILURE_BACKOFF_MS so a gated caller can still retry sooner. Never retry sooner than scheduleRefresh would have: a stale handshake can land a long-lived token while a newer attempt is failing, and the flat backoff replaced that token's refresh with a minute-by-minute re-attest loop. Have the watchdog arm the same retry when it releases the lock. A hung handshake either never settles or rejects with a stale generation, so nothing rescheduled it and the engine sat idle until a gated call - the failure this commit series set out to fix.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Gated calls bypass exponential backoff
- runHandshake now uses the same exponential failureBackoffMs() as scheduleRetryAfterFailure, so gated plugin calls respect the growing backoff instead of a flat 60s gate.
Or push these changes by commenting:
@cursor push a2adf47c69
Preview (a2adf47c69)
diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -273,16 +273,25 @@
}
/**
+ * Current failure backoff: base `FAILURE_BACKOFF_MS`, doubling per consecutive
+ * failure up to `MAX_BACKOFF_MS`. Shared by the background retry timer and the
+ * gated-call gate in `runHandshake` so plugin traffic cannot outpace the
+ * exponential policy.
+ */
+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. The wait doubles with each consecutive failure up to
* `MAX_BACKOFF_MS` to keep a hopeless device from re-attesting forever.
*/
const scheduleRetryAfterFailure = (): void => {
- const backoffMs = Math.min(
- FAILURE_BACKOFF_MS * 2 ** Math.max(0, consecutiveFailures - 1),
- MAX_BACKOFF_MS
- )
+ const backoffMs = failureBackoffMs()
// 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
@@ -304,7 +313,7 @@
*/
const runHandshake = (): void => {
if (inFlight != null) return
- if (Date.now() - lastFailureAt < FAILURE_BACKOFF_MS) return
+ if (Date.now() - lastFailureAt < failureBackoffMs()) return
// A handshake whose native call hangs past the watchdog has its `inFlight`
// lock released so a newer handshake can start. Tag each attempt so a stale
// one that finally resolves cannot clobber a token a newer handshake alreadyYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 89b7598. Configure here.
| */ | ||
| const runHandshake = (): void => { | ||
| if (inFlight != null) return | ||
| if (Date.now() - lastFailureAt < FAILURE_BACKOFF_MS) return |
There was a problem hiding this comment.
Gated calls bypass exponential backoff
Medium Severity
scheduleRetryAfterFailure doubles the wait up to MAX_BACKOFF_MS, but runHandshake only suppresses starts for a flat FAILURE_BACKOFF_MS. After the first minute, Simplex/Banxa getAttestationToken calls can kick off a new handshake every 60s even when consecutive failures have scheduled a much longer background backoff, defeating the rate-limit protection.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 89b7598. Configure here.



Summary
x-attestation-tokento SimplexjwtSignand BanxacreateHmacrequests so gated signing can require hardware attestation.docs/APP_ATTESTATION.mdand adds an optionalINFO_SERVERenv override for local testing.Test plan
Made with Cursor