Skip to content

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

Open
paullinator wants to merge 2 commits into
developfrom
paul/appAttestation
Open

Add app/device attestation for gated info-server requests#6076
paullinator wants to merge 2 commits into
developfrom
paul/appAttestation

Conversation

@paullinator

@paullinator paullinator commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds iOS App Attest and Android Keystore native modules plus a JS attestation engine that obtains tokens from the info server.
  • Attaches x-attestation-token to Simplex jwtSign and Banxa createHmac requests so gated signing can require hardware attestation.
  • Documents the client flow in docs/APP_ATTESTATION.md and adds an optional INFO_SERVER env override for local testing.

Test plan

  • Cold-start app and confirm attestation handshake succeeds (token obtained without blocking UI)
  • Buy/sell via Simplex with attestation required on the info-server provider config; confirm quotes still work
  • Buy/sell via Banxa with attestation required; confirm HMAC creation still works
  • Break attestation (e.g. wrong package / rotated keys) and confirm gated providers disappear or fail closed
  • Restore attestation and confirm providers return

Made with Cursor


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

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.

Create PR

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.

Comment thread src/util/attestation.ts
Comment thread src/util/attestation.ts Outdated

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

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.

Create PR

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.

Comment thread src/util/attestation.ts
@paullinator
paullinator force-pushed the paul/appAttestation branch from 03eb1ba to 1c7b261 Compare July 17, 2026 03:40

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

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.

Create PR

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.

Comment thread src/util/attestation.ts
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>
@paullinator
paullinator force-pushed the paul/appAttestation branch from 1c7b261 to 552fef1 Compare July 21, 2026 23:03

@claude claude 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.

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.

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

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.

Create PR

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.

Comment thread src/util/attestation.ts

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

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.

Create PR

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 = 0

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

Comment thread src/util/attestation.ts Outdated

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

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.

Create PR

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.

Reviewed by Cursor Bugbot for commit 7cc4997. Configure here.

Comment thread ios/edge/EdgeAttestation.swift
@j0ntz
j0ntz force-pushed the paul/appAttestation branch from ca6d0c1 to 5091e83 Compare July 22, 2026 19:05
@j0ntz
j0ntz force-pushed the paul/appAttestation branch from 5091e83 to 40713eb Compare July 22, 2026 19:33
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.

2 participants