Skip to content

Commit 4ae023b

Browse files
fixup! Add app/device attestation for gated info-server requests
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4e9a177 commit 4ae023b

3 files changed

Lines changed: 390 additions & 20 deletions

File tree

docs/APP_ATTESTATION.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ sequenceDiagram
2323
Net->>Eng: initAttestation()
2424
Eng->>Info: GET /v1/attest/challenge
2525
Info-->>Eng: challenge
26+
Note over Eng,Nat: Refresh path (after first enrollment)
27+
Eng->>Nat: generateAssertion (iOS) / signChallenge (Android)
28+
Nat-->>Eng: keyId + assertion|signature
29+
Eng->>Info: POST /v1/attest/apple/assert or /v1/attest/android/assert
30+
Info-->>Eng: { token, expires }
31+
Note over Eng,Nat: First-run / rejection → full attestation
2632
Eng->>Nat: getAttestation(challenge)
2733
Nat-->>Eng: keyId+attestation or certChain
2834
Eng->>Info: POST /v1/attest/apple|android
@@ -53,16 +59,17 @@ Optional `env.json` / `ENV.INFO_SERVER` (see `envConfig.ts`) overrides the defau
5359
| API | Behavior |
5460
| --- | --- |
5561
| `initAttestation()` | If no live token, start a non-blocking handshake |
56-
| `getAttestationToken()` | Return cached JWT if live; else start handshake, wait ≤ **3s**, then return JWT or `undefined` |
62+
| `getAttestationToken()` | Return cached JWT if live; else start handshake, wait ≤ **3s**, then return JWT or `undefined`; after a failed handshake, retries are suppressed for 60s and callers return `undefined` immediately |
5763
| Refresh | On success, `setTimeout` to re-handshake **2 minutes before** `expires` |
5864
| Clock skew | Token treated expired within **5s** of `expires` |
65+
| Watchdog | A handshake pending after **90s** is treated as hung and the lock is released |
5966

6067
Handshake steps:
6168

6269
1. `GET v1/attest/challenge` via `fetchInfo`
6370
2. Native `EdgeAttestation.getAttestation(challenge)`
6471
3. `POST v1/attest/apple` (iOS) or `v1/attest/android` (Android)
65-
4. Cache `{ token, expires }` from the JSON body (`expires` is epoch **milliseconds**)
72+
4. Cache `{ token, expires }` from the JSON body (`expires` is epoch **milliseconds**); both `token` and `expires` are validated — a malformed response is treated as a failed handshake
6673

6774
Failures are logged (`console.warn`) and never thrown to boot.
6875

@@ -72,27 +79,29 @@ Failures are logged (`console.warn`) and never thrown to boot.
7279

7380
| File | Role |
7481
| --- | --- |
75-
| `ios/edge/EdgeAttestation.swift` | `DCAppAttestService`: `isSupported`, `generateKey` + `attestKey` |
82+
| `ios/edge/EdgeAttestation.swift` | `DCAppAttestService`: `isSupported`, `generateKey` + `attestKey`, `generateAssertion`, `clearKey` (Keychain key-id persistence) |
7683
| `ios/edge/EdgeAttestation.m` | React Native bridge |
7784
| `ios/edge/edge.entitlements` | `com.apple.developer.devicecheck.appattest-environment` = **production** (all configs) |
7885
| `scripts/addAttestationIosFiles.js` | Adds Swift/ObjC sources to the Xcode project |
7986

80-
**Key lifecycle:** a **fresh** App Attest key is generated on **every** handshake. An App Attest key can only be attested once; Keychain persist + assertion refresh are not implemented yet.
87+
**Key lifecycle:** a key is generated and attested **once per install** — the key id is persisted in the Keychain (`kSecClassGenericPassword`, service `co.edgesecure.app.appattest`). Subsequent handshakes refresh the token with `generateAssertion` (a local Secure Enclave signature, no Apple round trip and no new key). The key is discarded and re-attested when iOS reports `invalidKey` (reinstall/restore/device migration) or the server rejects an assertion. Reinstalls, device migration, and restores invalidate the key by design.
8188

82-
Returns `{ keyId, attestation (base64 CBOR), bundleId }`. Simulator: `isSupported` is false → no token.
89+
Returns `{ keyId, attestation (base64 CBOR), bundleId }` (attest) or `{ keyId, assertion (base64 CBOR), bundleId }` (assert). Simulator: `isSupported` is false → no token.
8390

8491
Production entitlement → info server maps AAGUID to **`secureElement`**.
8592

8693
### Android — Keystore attestation
8794

8895
| File | Role |
8996
| --- | --- |
90-
| `android/.../EdgeAttestationModule.kt` | Keystore EC key with `setAttestationChallenge` |
97+
| `android/.../EdgeAttestationModule.kt` | Keystore EC key with `setAttestationChallenge`, plus `signChallenge` and `clearKey` |
9198
| `android/.../EdgeAttestationPackage.kt` | RN package |
9299
| `MainApplication.kt` | Registers the package |
93100

94101
**StrongBox first**, TEE fallback on `StrongBoxUnavailableException`. Returns `{ certChain: base64 DER[] }`. Requires API 24+. Uses only platform Keystore APIs (**no Play Integrity**).
95102

103+
**Key lifecycle:** the Keystore key is enrolled **once** under the stable `edge_attestation_key` alias and reused to sign challenges (`signChallenge``SHA256withECDSA` over the challenge; `keyId = base64url(SHA-256(leaf SPKI))`). It survives app updates, is destroyed on uninstall/factory reset (backup and restore do not transfer Keystore keys), and is cleared + re-enrolled when the server rejects an assertion (unknown key, revoked serial, disabled app).
104+
96105
Info server maps StrongBox → `secureElement`, TEE → `hardware`, debug-keystore digest → `debug`.
97106

98107
## Where tokens are attached
@@ -148,6 +157,8 @@ The GUI depends on these info-server endpoints:
148157

149158
- `GET /v1/attest/challenge`
150159
- `POST /v1/attest/apple` / `POST /v1/attest/android`
160+
- `POST /v1/attest/apple/assert` (iOS token refresh via assertion)
161+
- `POST /v1/attest/android/assert` (Android token refresh via challenge signature)
151162
- `POST /v1/jwtSign/:provider` / `POST /v1/createHmac/:provider` (optional `x-attestation-token`)
152163
- (optional) `GET /v1/attest/jwks` for other services verifying tokens
153164

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import {
2+
afterEach,
3+
beforeEach,
4+
describe,
5+
expect,
6+
it,
7+
jest
8+
} from '@jest/globals'
9+
import { NativeModules } from 'react-native'
10+
11+
interface MockResponse {
12+
ok: boolean
13+
status: number
14+
json: () => Promise<unknown>
15+
text: () => Promise<string>
16+
}
17+
18+
const mockFetchInfo =
19+
jest.fn<(path: string, opts?: RequestInit) => Promise<MockResponse>>()
20+
21+
jest.mock('../../util/network', () => ({
22+
fetchInfo: async (...args: unknown[]) =>
23+
await mockFetchInfo(...(args as [string]))
24+
}))
25+
26+
const mockIsSupported = jest.fn<() => Promise<boolean>>()
27+
const mockGetAttestation = jest.fn<
28+
(challenge: string) => Promise<{
29+
keyId?: string
30+
attestation?: string
31+
bundleId?: string
32+
certChain?: string[]
33+
}>
34+
>()
35+
const mockGenerateAssertion = jest.fn<
36+
(challenge: string) => Promise<{
37+
keyId?: string
38+
assertion?: string
39+
bundleId?: string
40+
}>
41+
>()
42+
const mockClearKey = jest.fn<() => Promise<void>>()
43+
const mockSignChallenge =
44+
jest.fn<
45+
(challenge: string) => Promise<{ keyId?: string; signature?: string }>
46+
>()
47+
48+
NativeModules.EdgeAttestation = {
49+
isSupported: mockIsSupported,
50+
getAttestation: mockGetAttestation,
51+
generateAssertion: mockGenerateAssertion,
52+
signChallenge: mockSignChallenge,
53+
clearKey: mockClearKey
54+
}
55+
56+
// Import after mocks so the module binds to mockFetchInfo / NativeModules.
57+
const {
58+
attestationTimingForTests,
59+
getAttestationToken,
60+
initAttestation,
61+
resetAttestationForTests
62+
} = require('../../util/attestation')
63+
64+
const jsonResponse = (body: unknown, ok = true, status = 200): MockResponse => {
65+
const response: MockResponse = {
66+
ok,
67+
status,
68+
json: async () => body,
69+
text: async () => JSON.stringify(body)
70+
}
71+
return response
72+
}
73+
74+
describe('attestation engine', () => {
75+
beforeEach(() => {
76+
jest.useFakeTimers()
77+
resetAttestationForTests()
78+
mockFetchInfo.mockReset()
79+
mockIsSupported.mockReset()
80+
mockGetAttestation.mockReset()
81+
mockGenerateAssertion.mockReset()
82+
mockSignChallenge.mockReset()
83+
mockClearKey.mockReset()
84+
mockIsSupported.mockResolvedValue(true)
85+
mockGetAttestation.mockResolvedValue({
86+
keyId: 'key',
87+
attestation: 'att',
88+
bundleId: 'co.edgesecure.app'
89+
})
90+
// Default: no stored key yet, so the assert fast path fails over to full
91+
// attestation (matches first-run behavior on both platforms).
92+
mockGenerateAssertion.mockRejectedValue(new Error('noKey'))
93+
mockSignChallenge.mockRejectedValue(new Error('noKey'))
94+
mockClearKey.mockResolvedValue(undefined)
95+
})
96+
97+
afterEach(() => {
98+
resetAttestationForTests()
99+
jest.useRealTimers()
100+
})
101+
102+
const mockSuccessfulHandshake = (expires: number): void => {
103+
mockFetchInfo.mockImplementation(async (path: string) => {
104+
if (path === 'v1/attest/challenge') {
105+
return jsonResponse({ challenge: 'chal-1' })
106+
}
107+
if (path === 'v1/attest/apple' || path === 'v1/attest/android') {
108+
return jsonResponse({ token: 'jwt-token', expires })
109+
}
110+
throw new Error(`unexpected path ${path}`)
111+
})
112+
}
113+
114+
it('rejects attest responses with a non-finite expires (Task 2.1)', async () => {
115+
mockFetchInfo.mockImplementation(async (path: string) => {
116+
if (path === 'v1/attest/challenge') {
117+
return jsonResponse({ challenge: 'chal-1' })
118+
}
119+
return jsonResponse({ token: 'jwt-token', expires: 'soon' })
120+
})
121+
122+
initAttestation()
123+
const tokenPromise = getAttestationToken()
124+
await jest.advanceTimersByTimeAsync(
125+
attestationTimingForTests.GET_TOKEN_TIMEOUT_MS
126+
)
127+
await expect(tokenPromise).resolves.toBeUndefined()
128+
})
129+
130+
it('caches a token when expires is a finite number (Task 2.1)', async () => {
131+
const expires = Date.now() + 10 * 60 * 1000
132+
mockSuccessfulHandshake(expires)
133+
134+
initAttestation()
135+
const tokenPromise = getAttestationToken()
136+
// Flush the in-flight handshake promise chain.
137+
await Promise.resolve()
138+
await Promise.resolve()
139+
await Promise.resolve()
140+
await expect(tokenPromise).resolves.toBe('jwt-token')
141+
})
142+
143+
it('releases a hung handshake after the watchdog so a later attempt can succeed (Task 2.2)', async () => {
144+
mockGetAttestation.mockImplementation(
145+
async () => await new Promise(() => {}) // never settles
146+
)
147+
mockFetchInfo.mockImplementation(async (path: string) => {
148+
if (path === 'v1/attest/challenge') {
149+
return jsonResponse({ challenge: 'chal-hung' })
150+
}
151+
throw new Error(`unexpected path ${path}`)
152+
})
153+
154+
initAttestation()
155+
// Let the hung handshake start and grab the lock.
156+
await Promise.resolve()
157+
await Promise.resolve()
158+
159+
// Watchdog fires and clears the lock.
160+
await jest.advanceTimersByTimeAsync(
161+
attestationTimingForTests.HANDSHAKE_WATCHDOG_MS
162+
)
163+
164+
// A subsequent attempt can start after the lock is released.
165+
const expires = Date.now() + 10 * 60 * 1000
166+
mockGetAttestation.mockResolvedValue({
167+
keyId: 'key2',
168+
attestation: 'att2',
169+
bundleId: 'co.edgesecure.app'
170+
})
171+
mockSuccessfulHandshake(expires)
172+
173+
const tokenPromise = getAttestationToken()
174+
await Promise.resolve()
175+
await Promise.resolve()
176+
await Promise.resolve()
177+
await expect(tokenPromise).resolves.toBe('jwt-token')
178+
})
179+
180+
it('suppresses retries during the failure backoff window (Task 2.3)', async () => {
181+
// First handshake fails at the challenge step.
182+
mockFetchInfo.mockImplementation(async (path: string) => {
183+
if (path === 'v1/attest/challenge') {
184+
return jsonResponse({}, false, 500)
185+
}
186+
throw new Error(`unexpected path ${path}`)
187+
})
188+
189+
initAttestation()
190+
const firstPromise = getAttestationToken()
191+
await jest.advanceTimersByTimeAsync(
192+
attestationTimingForTests.GET_TOKEN_TIMEOUT_MS
193+
)
194+
await expect(firstPromise).resolves.toBeUndefined()
195+
196+
const callsAfterFailure = mockFetchInfo.mock.calls.length
197+
198+
// A subsequent call during the backoff window must not start a new
199+
// handshake and must return immediately without the 3s wait.
200+
const backoffPromise = getAttestationToken()
201+
await Promise.resolve()
202+
await expect(backoffPromise).resolves.toBeUndefined()
203+
expect(mockFetchInfo.mock.calls.length).toBe(callsAfterFailure)
204+
205+
// After the backoff elapses, a handshake can succeed again.
206+
const expires = Date.now() + 10 * 60 * 1000
207+
mockSuccessfulHandshake(expires)
208+
await jest.advanceTimersByTimeAsync(
209+
attestationTimingForTests.FAILURE_BACKOFF_MS
210+
)
211+
212+
const retryPromise = getAttestationToken()
213+
await Promise.resolve()
214+
await Promise.resolve()
215+
await Promise.resolve()
216+
await expect(retryPromise).resolves.toBe('jwt-token')
217+
})
218+
})

0 commit comments

Comments
 (0)