Skip to content

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

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

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

Conversation

@paullinator

@paullinator paullinator commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

  • Add a background attestation engine (src/util/attestation.ts) plus iOS App Attest and Android Keystore native modules that enroll once, refresh via assertion/signature, and cache JWTs on a monotonic clock with lead-ahead refresh and failure backoff.
  • Wire Simplex/Banxa jwtSign and createHmac call sites to attach x-attestation-token when available; surface gated signing failures in ramp UI instead of silently no-oping.
  • Document the client architecture in docs/APP_ATTESTATION.md. Depends on info-server lifetime/expiresIn/serverTime support (edge-info-server#158).

Test plan

  • npm test -- src/__tests__/util/attestation.test.ts src/__tests__/util/attestationNativeBridge.test.ts
  • tsc / lint on changed files; npm run fix-kotlin / ktlint on attestation Kotlin modules
  • Physical iOS device: boot → challenge → App Attest enroll → token cached; later refresh via generateAssertion
  • Physical Android (TEE/StrongBox): enroll via Keystore attestation; refresh via signChallenge
  • Point ENV.INFO_SERVER at a local info server from edge-info-server#158; confirm Banxa/Simplex signing sends x-attestation-token and fails loudly on 403
  • Simulator / unsupported device: engine stops cleanly; gated plugins omit the header

Note

Cursor Bugbot is generating a summary for commit e88bd5c. Configure here.

@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 high effort and found 2 potential issues.

Fix All in Cursor

Done

Create PR

Or push these changes by commenting:

@cursor push b8caa6fd86
Preview (b8caa6fd86)
diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts
--- a/src/locales/en_US.ts
+++ b/src/locales/en_US.ts
@@ -694,7 +694,7 @@
   string_ok_cap: 'OK',
   clock_skew_title: 'Device clock is wrong',
   clock_skew_message:
-    "Your device time differs from Edge's servers by more than two minutes. Turn on automatic date & time in your system settings so one-time codes and other time-based features keep working.",
+    'Your device time differs from our servers by more than two minutes. Turn on automatic date & time in your system settings so one-time codes and other time-based features keep working.',
   string_forget: 'Forget',
   string_delete: 'Delete',
   string_keep: 'Keep',

diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json
--- a/src/locales/strings/enUS.json
+++ b/src/locales/strings/enUS.json
@@ -523,7 +523,7 @@
   "string_cancel": "CANCEL",
   "string_ok_cap": "OK",
   "clock_skew_title": "Device clock is wrong",
-  "clock_skew_message": "Your device time differs from Edge's servers by more than two minutes. Turn on automatic date & time in your system settings so one-time codes and other time-based features keep working.",
+  "clock_skew_message": "Your device time differs from our servers by more than two minutes. Turn on automatic date & time in your system settings so one-time codes and other time-based features keep working.",
   "string_forget": "Forget",
   "string_delete": "Delete",
   "string_keep": "Keep",

diff --git a/src/util/attestation.ts b/src/util/attestation.ts
--- a/src/util/attestation.ts
+++ b/src/util/attestation.ts
@@ -1,6 +1,7 @@
 import { NativeModules, Platform } from 'react-native'
 
 import { showButtonsModal } from '../components/modals/ButtonsModal'
+import { ENV } from '../env'
 import { lstrings } from '../locales/strings'
 import { monotonicNow } from './monotonicTime'
 import { fetchInfo } from './network'
@@ -318,7 +319,9 @@
       throw error
     }
     // noKey / invalidKey / native signing failure: fall back to full attestation.
-    console.log('[attestation] assertion unavailable:', String(error))
+    if (ENV.DEBUG_VERBOSE_LOGGING) {
+      console.log('[attestation] assertion unavailable:', String(error))
+    }
     return undefined
   }
 
@@ -589,7 +592,9 @@
       lastFailureAt = undefined
       consecutiveFailures = 0
       cachedToken = freshToken
-      console.log('[attestation] handshake ok')
+      if (ENV.DEBUG_VERBOSE_LOGGING) {
+        console.log('[attestation] handshake ok')
+      }
       scheduleRefresh(freshToken.expiresMono)
     })
     .catch((error: unknown) => {

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

Reviewed by Cursor Bugbot for commit 52196d0. Configure here.

Comment thread src/locales/en_US.ts Outdated
Comment thread src/util/attestation.ts
@paullinator
paullinator force-pushed the paul/appAttestationV2 branch from ae37d31 to 95985d3 Compare August 4, 2026 14:37
Run a background attestation engine that handshakes with Apple App Attest
or Android Keystore, caches a short-lived JWT on a monotonic clock, and
refreshes ahead of expiry. Gated Simplex and Banxa signing calls attach
x-attestation-token when a token is available; otherwise the info server
decides. Includes native modules, Keychain/Keystore key lifecycle, ramp
error surfacing, and the Simplex supported-countries response check.
@paullinator
paullinator force-pushed the paul/appAttestationV2 branch from 95985d3 to faaf688 Compare August 4, 2026 14:37
@j0ntz

j0ntz commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review note from an automated pass (sonnet-5 subagents + adversarial verification) run per the Asana task. Two info-server findings are fixed on #158; this one is in your native concurrency design, so I am raising it rather than changing it.

getAttestation can issue a second attestKey for a key the first call may already have consumed (ios/edge/EdgeAttestation.swift, the operationTimeout path).

Sequence:

  1. Handshake A generates key K, stores it as pendingKeyId, calls attestKey(K).
  2. attestKey(K) runs long; done.wait hits the 120s operationTimeout, A rejects with timeout, and the serialQueue closure returns. Per the comment there, pendingKeyId is deliberately left in place because "the attestation never completed, so the key was never consumed".
  3. Handshake B loads pendingKeyId K and calls attestKey(K) again, while A's call is still outstanding with Apple.
  4. If A's call then succeeds, its completion finds promise.hasSettled, so it drops the attestation and calls clearPendingKeyId(ifMatches: K). B's attestKey(K) fails because a key can only be attested once.

The premise in step 2 is the part worth a second look: after a timeout (as opposed to an explicit retryable error) the key may in fact have been consumed. The outcome is bounded and self-healing the way the adjacent races in that file are (B fails, the pending slot clears, the next handshake generates a fresh key and enrolls), so this costs one wasted attestation and one extra round trip, not a stuck device.

Both obvious remedies have real costs you already reasoned about in that file, which is why I did not pick one:

  • Reuse the pending key only after an explicit retryable error (not after a timeout): avoids the double-attest, but burns a key per timeout, and Apple treats keys as a limited resource.
  • Let the late callback enroll the key anyway: recovers the attestation, but your comment is right that the server never saw it, so the next handshake would spend an assertion round trip and re-attest regardless.

No action needed if you consider it in the same bounded-and-self-healing class as the ifMatches windows; in that case a line in the comment saying the timeout path may double-attest would keep the reasoning complete.

fixup! Add app/device attestation for gated info-server requests

Document that an operationTimeout may leave a pending key that Apple has
already consumed, so the next handshake can double-attest and self-heal.

Co-authored-by: Cursor <cursoragent@cursor.com>
@paullinator

Copy link
Copy Markdown
Member Author

Agreed — the timeout-path premise was slightly wrong. After operationTimeout the key may already have been consumed by a still-outstanding attestKey, so the next handshake can double-attest and waste one round trip. Same bounded/self-healing class as the ifMatches windows: B fails, pending clears, the next attempt generates a fresh key.

Pushed a comment-only fixup clarifying that. Not changing the policy — clearing pending on timeout burns keys; enrolling from the late callback doesn’t help because the server never saw that attestation.

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