Add app/device attestation for gated info-server requests#6076
Add app/device attestation for gated info-server requests#6076paullinator wants to merge 1 commit 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.
| console.warn('[attestation] handshake watchdog fired; releasing lock') | ||
| inFlight = undefined | ||
| } | ||
| }, HANDSHAKE_WATCHDOG_MS) |
There was a problem hiding this comment.
Watchdog allows overlapping handshakes
High Severity
The 90s handshake watchdog clears inFlight without cancelling the underlying performHandshake promise. A new handshake can start while the hung one is still running native attestation, so two enrollments may run concurrently and race on the shared Android keystore alias or iOS attestation state.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 03eb1ba. Configure 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>
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).
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.
Reviewed by Cursor Bugbot for commit 1c7b261. Configure here.
| await EdgeAttestation.clearKey().catch(() => {}) | ||
| } catch (error) { | ||
| // noKey / native failure: fall through to full attestation. | ||
| console.log('[attestation] assertion unavailable:', String(error)) |
There was a problem hiding this comment.
Unguarded attestation console logging
Low Severity
The attestation engine logs assertion fallback paths with bare console.log calls. Project convention requires debug logging to be gated (for example behind ENV.DEBUG_VERBOSE_LOGGING) so production builds do not emit routine attestation noise to the console.
Reviewed by Cursor Bugbot for commit 1c7b261. 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