@coinbase/cdp-app-attest 0.0.94 → 0.0.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/README.md +0 -678
package/package.json
CHANGED
package/README.md
DELETED
|
@@ -1,678 +0,0 @@
|
|
|
1
|
-
# @coinbase/cdp-app-attest
|
|
2
|
-
|
|
3
|
-
CDP native module for iOS App Attest, providing device attestation capabilities to verify that requests come from legitimate instances of your app running on genuine Apple devices.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- ✅ **Simplified API** - Automatic key management, no manual key ID tracking
|
|
8
|
-
- ✅ **Secure** - Keys stored in iOS Keychain, private keys in Secure Enclave
|
|
9
|
-
- ✅ **iOS 14+** - Uses Apple's DCAppAttestService
|
|
10
|
-
- ✅ Assertion-based security for sensitive operations
|
|
11
|
-
|
|
12
|
-
## Platform Support
|
|
13
|
-
|
|
14
|
-
- **iOS**: iOS 14.0+ on physical devices (uses DCAppAttestService)
|
|
15
|
-
- ⚠️ Not supported on iOS Simulator
|
|
16
|
-
- ⚠️ Not supported in Expo Go (requires custom dev build)
|
|
17
|
-
- **Android**: Play Integrity support is planned but not available in this release
|
|
18
|
-
|
|
19
|
-
## Installation
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
npx expo install @coinbase/cdp-app-attest
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
## Setup
|
|
26
|
-
|
|
27
|
-
### iOS Configuration
|
|
28
|
-
|
|
29
|
-
1. Open your project in Xcode:
|
|
30
|
-
```bash
|
|
31
|
-
open ios/YourApp.xcworkspace
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
2. Add the App Attest capability:
|
|
35
|
-
- Select your app target
|
|
36
|
-
- Go to **Signing & Capabilities**
|
|
37
|
-
- Click **+ Capability**
|
|
38
|
-
- Add **App Attest**
|
|
39
|
-
|
|
40
|
-
That's it! No additional configuration needed.
|
|
41
|
-
|
|
42
|
-
## Understanding Attestation vs Assertion
|
|
43
|
-
|
|
44
|
-
iOS App Attest uses two different operations:
|
|
45
|
-
|
|
46
|
-
### **Attestation** (One-Time Registration)
|
|
47
|
-
- **What:** Generates a cryptographic key and proves it's in your device's Secure Enclave
|
|
48
|
-
- **When:** Once per app installation (before making sensitive requests)
|
|
49
|
-
- **Signed by:** Apple
|
|
50
|
-
- **Flow:**
|
|
51
|
-
1. Generate server challenge
|
|
52
|
-
2. Call `attest(challenge)` - creates key + attestation object
|
|
53
|
-
3. Send attestation to backend `/attestation/register` endpoint
|
|
54
|
-
4. Backend verifies with Apple and stores your device's public key
|
|
55
|
-
- **Result:** Your device's public key is registered with the backend
|
|
56
|
-
|
|
57
|
-
### **Assertion** (Per-Request Proof)
|
|
58
|
-
- **What:** Cryptographically signed proof that a request comes from your attested device
|
|
59
|
-
- **When:** Every sensitive API request (sign transactions, export keys, etc.)
|
|
60
|
-
- **Signed by:** Your device's Secure Enclave using the private key
|
|
61
|
-
- **Flow:**
|
|
62
|
-
1. Call `createAssertion(clientData)` - signs request data
|
|
63
|
-
2. Add assertion to request headers: `X-App-Attestation-Assertion` + `X-App-Attestation-Key-ID`
|
|
64
|
-
3. Backend validates signature using stored public key
|
|
65
|
-
- **Result:** Backend confirms request is from genuine, attested device
|
|
66
|
-
|
|
67
|
-
**Think of it like:**
|
|
68
|
-
- **Attestation** = Getting a passport (register your identity once)
|
|
69
|
-
- **Assertion** = Signing documents with your passport (prove it's you for each transaction)
|
|
70
|
-
|
|
71
|
-
## Usage
|
|
72
|
-
|
|
73
|
-
### Basic Example
|
|
74
|
-
|
|
75
|
-
```typescript
|
|
76
|
-
import * as AppAttest from '@coinbase/cdp-app-attest';
|
|
77
|
-
|
|
78
|
-
// 1. Check if device supports attestation
|
|
79
|
-
const supported = await AppAttest.isSupported();
|
|
80
|
-
console.log('Attestation supported:', supported);
|
|
81
|
-
|
|
82
|
-
if (supported) {
|
|
83
|
-
// ONE-TIME: Attest the app installation
|
|
84
|
-
// Get a challenge from your server (32+ bytes, base64-encoded)
|
|
85
|
-
const challenge = await yourBackend.getChallenge();
|
|
86
|
-
|
|
87
|
-
// Attest with the challenge (generates key automatically)
|
|
88
|
-
const result = await AppAttest.attest(challenge);
|
|
89
|
-
// Result: { ios: { keyId: "...", attestation: "...", bundleId: "..." } }
|
|
90
|
-
|
|
91
|
-
// Send attestation to backend for verification and public key storage
|
|
92
|
-
await yourBackend.exchangeAttestation({
|
|
93
|
-
challenge: challenge,
|
|
94
|
-
...result.ios // Send iOS attestation data
|
|
95
|
-
});
|
|
96
|
-
console.log('Device attested and public key registered!');
|
|
97
|
-
|
|
98
|
-
// ONGOING: Generate assertions for sensitive operations
|
|
99
|
-
const clientData = btoa('POST||/v2/wallets/sign-transaction');
|
|
100
|
-
const assertion = await AppAttest.createAssertion(clientData);
|
|
101
|
-
// Result: { ios: { assertion: "...", keyId: "..." } }
|
|
102
|
-
|
|
103
|
-
// Include assertion in request headers
|
|
104
|
-
await fetch('https://api.example.com/v2/wallets/sign-transaction', {
|
|
105
|
-
method: 'POST',
|
|
106
|
-
headers: {
|
|
107
|
-
'X-App-Attestation-Assertion': assertion.ios.assertion,
|
|
108
|
-
'X-App-Attestation-Key-ID': assertion.ios.keyId,
|
|
109
|
-
'Content-Type': 'application/json',
|
|
110
|
-
},
|
|
111
|
-
body: JSON.stringify({ /* transaction data */ }),
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
### Complete Attestation Flow
|
|
117
|
-
|
|
118
|
-
```typescript
|
|
119
|
-
import * as AppAttest from '@coinbase/cdp-app-attest';
|
|
120
|
-
|
|
121
|
-
async function setupAttestation() {
|
|
122
|
-
// Check support
|
|
123
|
-
const supported = await AppAttest.isSupported();
|
|
124
|
-
if (!supported) {
|
|
125
|
-
console.log('App Attest not supported on this device');
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
// ONE-TIME: Attest the app instance
|
|
131
|
-
// This generates a key (stored in Keychain) and creates an attestation
|
|
132
|
-
// object signed by Apple that proves this is a legitimate installation
|
|
133
|
-
const challenge = await yourBackend.getChallenge();
|
|
134
|
-
const result = await AppAttest.attest(challenge);
|
|
135
|
-
|
|
136
|
-
// result: { ios: { keyId: "...", attestation: "...", bundleId: "..." } }
|
|
137
|
-
|
|
138
|
-
// Send to backend for verification and public key storage
|
|
139
|
-
await yourBackend.exchangeAttestation({
|
|
140
|
-
challenge: challenge,
|
|
141
|
-
keyId: result.ios.keyId,
|
|
142
|
-
attestation: result.ios.attestation,
|
|
143
|
-
bundleId: result.ios.bundleId,
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
console.log('✅ Device attested and public key registered!');
|
|
147
|
-
return true;
|
|
148
|
-
} catch (error) {
|
|
149
|
-
console.error('❌ Attestation failed:', error);
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async function makeAttestedRequest(method: string, path: string, body: any) {
|
|
155
|
-
try {
|
|
156
|
-
// ONGOING: Generate assertion for each sensitive request
|
|
157
|
-
// Client data should match backend expectation (e.g., "METHOD||PATH")
|
|
158
|
-
const clientData = btoa(`${method}||${path}`);
|
|
159
|
-
const result = await AppAttest.createAssertion(clientData);
|
|
160
|
-
|
|
161
|
-
// result: { ios: { assertion: "...", keyId: "..." } }
|
|
162
|
-
|
|
163
|
-
// Include assertion in request headers
|
|
164
|
-
const response = await fetch(`https://api.example.com${path}`, {
|
|
165
|
-
method: method,
|
|
166
|
-
headers: {
|
|
167
|
-
'X-App-Attestation-Assertion': result.ios.assertion,
|
|
168
|
-
'X-App-Attestation-Key-ID': result.ios.keyId,
|
|
169
|
-
'Content-Type': 'application/json',
|
|
170
|
-
},
|
|
171
|
-
body: JSON.stringify(body),
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
if (!response.ok) {
|
|
175
|
-
throw new Error(`Request failed: ${response.status}`);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return await response.json();
|
|
179
|
-
} catch (error) {
|
|
180
|
-
console.error('❌ Attested request failed:', error);
|
|
181
|
-
throw error;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
## When to Re-Attest
|
|
187
|
-
|
|
188
|
-
You need to re-attest when:
|
|
189
|
-
|
|
190
|
-
- **App is installed for the first time** - No key exists yet
|
|
191
|
-
- **Backend reports "key not found"** - Stored public key expired or was lost
|
|
192
|
-
- **App is reinstalled** - Keys are automatically cleared
|
|
193
|
-
- **Key is manually cleared** - After calling `clearAttestation()` for testing/debugging
|
|
194
|
-
|
|
195
|
-
You do **NOT** need to re-attest:
|
|
196
|
-
|
|
197
|
-
- **On every app launch** - Key persists in Keychain across launches
|
|
198
|
-
- **After app updates** - Key survives app updates
|
|
199
|
-
- **After device restart** - Key is stored in Keychain (persistent storage)
|
|
200
|
-
- **For every request** - Use `createAssertion()` for ongoing requests
|
|
201
|
-
|
|
202
|
-
## API Reference
|
|
203
|
-
|
|
204
|
-
### `isSupported(): Promise<boolean>`
|
|
205
|
-
|
|
206
|
-
Check if device attestation is supported on this device.
|
|
207
|
-
|
|
208
|
-
**Returns:** `Promise<boolean>` - `true` if App Attest is available
|
|
209
|
-
|
|
210
|
-
**Example:**
|
|
211
|
-
```typescript
|
|
212
|
-
const supported = await AppAttest.isSupported();
|
|
213
|
-
if (!supported) {
|
|
214
|
-
// Fallback to non-attested flow
|
|
215
|
-
}
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
---
|
|
219
|
-
|
|
220
|
-
### `attest(challenge: string): Promise<AttestationResult>`
|
|
221
|
-
|
|
222
|
-
Attest the app instance with a server-provided challenge.
|
|
223
|
-
|
|
224
|
-
This method automatically:
|
|
225
|
-
- Generates a new cryptographic key on first use (stored in iOS Keychain)
|
|
226
|
-
- Reuses the cached key on subsequent calls
|
|
227
|
-
- Creates an attestation object signed by Apple
|
|
228
|
-
|
|
229
|
-
**⚠️ Important:** Each key can only be attested **once**. Subsequent calls with the same key will fail. After successful attestation, use `createAssertion()` for ongoing requests.
|
|
230
|
-
|
|
231
|
-
**Parameters:**
|
|
232
|
-
- `challenge` (string) - Base64-encoded challenge from your server (minimum 32 bytes)
|
|
233
|
-
|
|
234
|
-
**Returns:** `Promise<AttestationResult>`
|
|
235
|
-
```typescript
|
|
236
|
-
interface AttestationResult {
|
|
237
|
-
ios: {
|
|
238
|
-
keyId: string; // Key identifier (UUID)
|
|
239
|
-
attestation: string; // Base64-encoded attestation object
|
|
240
|
-
bundleId: string; // Bundle identifier (e.g., "com.example.app")
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
**Response Format:**
|
|
246
|
-
```typescript
|
|
247
|
-
{
|
|
248
|
-
ios: {
|
|
249
|
-
keyId: "abc123def456...",
|
|
250
|
-
attestation: "b2F1dGhEYXRh...",
|
|
251
|
-
bundleId: "com.example.app"
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
**Throws:**
|
|
257
|
-
- `Error` if device doesn't support App Attest
|
|
258
|
-
- `Error` if key was already attested (error code 2)
|
|
259
|
-
- `Error` if challenge is invalid
|
|
260
|
-
|
|
261
|
-
**Example:**
|
|
262
|
-
```typescript
|
|
263
|
-
try {
|
|
264
|
-
const challenge = await getServerChallenge();
|
|
265
|
-
const result = await AppAttest.attest(challenge);
|
|
266
|
-
|
|
267
|
-
// Send to backend for verification and public key storage
|
|
268
|
-
await sendToBackend({
|
|
269
|
-
challenge: challenge,
|
|
270
|
-
keyId: result.ios.keyId,
|
|
271
|
-
attestation: result.ios.attestation,
|
|
272
|
-
bundleId: result.ios.bundleId,
|
|
273
|
-
});
|
|
274
|
-
} catch (error) {
|
|
275
|
-
console.error('Attestation failed:', error);
|
|
276
|
-
}
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
---
|
|
280
|
-
|
|
281
|
-
### `createAssertion(clientData: string): Promise<AssertionResult>`
|
|
282
|
-
|
|
283
|
-
Generate a cryptographic assertion for an API request.
|
|
284
|
-
|
|
285
|
-
Must call `attest()` at least once before using this method to ensure a key has been generated and attested.
|
|
286
|
-
|
|
287
|
-
**Parameters:**
|
|
288
|
-
- `clientData` (string) - Base64-encoded data to sign (typically request payload hash)
|
|
289
|
-
|
|
290
|
-
**Returns:** `Promise<AssertionResult>`
|
|
291
|
-
```typescript
|
|
292
|
-
interface AssertionResult {
|
|
293
|
-
ios: {
|
|
294
|
-
assertion: string; // Base64-encoded assertion
|
|
295
|
-
keyId: string; // Key identifier (same as from attest())
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
**Response Format:**
|
|
301
|
-
```typescript
|
|
302
|
-
{
|
|
303
|
-
ios: {
|
|
304
|
-
assertion: "YXNzZXJ0aW9u...",
|
|
305
|
-
keyId: "abc123def456..."
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
**Throws:**
|
|
311
|
-
- `Error` if no attested key exists (call `attest()` first)
|
|
312
|
-
- `Error` if device doesn't support App Attest
|
|
313
|
-
|
|
314
|
-
**Example:**
|
|
315
|
-
```typescript
|
|
316
|
-
// Generate assertion for sensitive request
|
|
317
|
-
const clientData = btoa('POST||/v2/wallets/sign-transaction');
|
|
318
|
-
const result = await AppAttest.createAssertion(clientData);
|
|
319
|
-
|
|
320
|
-
// Include assertion in request headers
|
|
321
|
-
await fetch('https://api.example.com/v2/wallets/sign-transaction', {
|
|
322
|
-
method: 'POST',
|
|
323
|
-
headers: {
|
|
324
|
-
'X-App-Attestation-Assertion': result.ios.assertion,
|
|
325
|
-
'X-App-Attestation-Key-ID': result.ios.keyId,
|
|
326
|
-
'Content-Type': 'application/json',
|
|
327
|
-
},
|
|
328
|
-
body: JSON.stringify({ /* transaction data */ }),
|
|
329
|
-
});
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
---
|
|
333
|
-
|
|
334
|
-
### `confirmRegistration(keyId: string): Promise<void>`
|
|
335
|
-
|
|
336
|
-
Mark attestation as successfully registered with your backend.
|
|
337
|
-
|
|
338
|
-
Call this only after your backend confirms the attestation exchange succeeded.
|
|
339
|
-
This stores a local flag so your app can avoid re-attesting unnecessarily.
|
|
340
|
-
|
|
341
|
-
**Parameters:**
|
|
342
|
-
- `keyId` (string) - The key ID that was registered with the backend
|
|
343
|
-
|
|
344
|
-
**Returns:** `Promise<void>`
|
|
345
|
-
|
|
346
|
-
**Example:**
|
|
347
|
-
```typescript
|
|
348
|
-
const attestation = await AppAttest.attest(challenge);
|
|
349
|
-
await backend.exchangeAttestation(attestation.ios);
|
|
350
|
-
await AppAttest.confirmRegistration(attestation.ios.keyId);
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
---
|
|
354
|
-
|
|
355
|
-
### `getRegisteredKeyId(): Promise<string | null>`
|
|
356
|
-
|
|
357
|
-
Check whether this device's attestation has been confirmed as registered
|
|
358
|
-
with your backend.
|
|
359
|
-
|
|
360
|
-
Returns the stored key ID if registration was confirmed, otherwise `null`.
|
|
361
|
-
|
|
362
|
-
**Returns:** `Promise<string | null>`
|
|
363
|
-
|
|
364
|
-
**Example:**
|
|
365
|
-
```typescript
|
|
366
|
-
const registeredKeyId = await AppAttest.getRegisteredKeyId();
|
|
367
|
-
if (registeredKeyId) {
|
|
368
|
-
console.log('Already registered:', registeredKeyId);
|
|
369
|
-
} else {
|
|
370
|
-
// Perform attestation flow
|
|
371
|
-
}
|
|
372
|
-
```
|
|
373
|
-
|
|
374
|
-
---
|
|
375
|
-
|
|
376
|
-
### `clearAttestation(): Promise<void>`
|
|
377
|
-
|
|
378
|
-
Clears all attestation state, allowing re-attestation with a new key.
|
|
379
|
-
|
|
380
|
-
**When to use:**
|
|
381
|
-
- Backend returns "Attestation key not found" (public key expired or lost)
|
|
382
|
-
- Backend's stored public key has expired
|
|
383
|
-
- Testing/debugging scenarios
|
|
384
|
-
|
|
385
|
-
**Important:** After clearing attestation, you MUST call `attest()` again to generate a new key and perform fresh attestation. Until then, `createAssertion()` will fail.
|
|
386
|
-
|
|
387
|
-
This clears both:
|
|
388
|
-
- Swift's internal keyId (used for generating assertions)
|
|
389
|
-
- SDK's registration confirmation flag (indicates backend has the public key)
|
|
390
|
-
|
|
391
|
-
**Returns:** `Promise<void>`
|
|
392
|
-
|
|
393
|
-
**Throws:**
|
|
394
|
-
- `Error` if the native module doesn't support clearing
|
|
395
|
-
|
|
396
|
-
**Example:**
|
|
397
|
-
```typescript
|
|
398
|
-
// Handle "key not found" error from backend
|
|
399
|
-
try {
|
|
400
|
-
const assertion = await AppAttest.createAssertion(clientData);
|
|
401
|
-
await api.sendTransaction({ assertion });
|
|
402
|
-
} catch (error) {
|
|
403
|
-
if (error.message.includes('Attestation key not found')) {
|
|
404
|
-
console.log('Public key not found on backend - re-attesting');
|
|
405
|
-
|
|
406
|
-
// Clear the old key
|
|
407
|
-
await AppAttest.clearAttestation();
|
|
408
|
-
|
|
409
|
-
// Re-attest with new key
|
|
410
|
-
const challenge = await getServerChallenge();
|
|
411
|
-
const attestation = await AppAttest.attest(challenge);
|
|
412
|
-
await sendToBackend({ challenge, ...attestation });
|
|
413
|
-
|
|
414
|
-
// Retry the original operation
|
|
415
|
-
const newAssertion = await AppAttest.createAssertion(clientData);
|
|
416
|
-
await api.sendTransaction({ assertion: newAssertion });
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Explicit re-attestation flow
|
|
421
|
-
async function forceReAttestation() {
|
|
422
|
-
console.log('Clearing existing key and re-attesting...');
|
|
423
|
-
|
|
424
|
-
// 1. Clear the old key
|
|
425
|
-
await AppAttest.clearAttestation();
|
|
426
|
-
|
|
427
|
-
// 2. Get new challenge
|
|
428
|
-
const challenge = await getServerChallenge();
|
|
429
|
-
|
|
430
|
-
// 3. Attest (generates new key)
|
|
431
|
-
const attestation = await AppAttest.attest(challenge);
|
|
432
|
-
|
|
433
|
-
// 4. Exchange with backend
|
|
434
|
-
await sendToBackend({ challenge, ...attestation });
|
|
435
|
-
|
|
436
|
-
console.log('Re-attestation complete!');
|
|
437
|
-
}
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
## Key Management
|
|
441
|
-
|
|
442
|
-
### Understanding the Key Architecture
|
|
443
|
-
|
|
444
|
-
App Attest uses a cryptographic key pair with three components:
|
|
445
|
-
|
|
446
|
-
| Component | Location | Purpose | Who Accesses |
|
|
447
|
-
|-----------|----------|---------|--------------|
|
|
448
|
-
| **Key ID** | iOS Keychain (in app) | Identifier to reference the key | Your app |
|
|
449
|
-
| **Private Key** | Secure Enclave (hardware) | Signs attestations/assertions | iOS only (never extractable) |
|
|
450
|
-
| **Public Key** | Backend server | Verifies signatures | Backend |
|
|
451
|
-
|
|
452
|
-
**Important:** The private key never leaves the device and can't be extracted, even by your app. iOS uses it internally to create signatures.
|
|
453
|
-
|
|
454
|
-
### Automatic Key Storage
|
|
455
|
-
|
|
456
|
-
Keys are automatically managed:
|
|
457
|
-
- ✅ Generated on first `attest()` call
|
|
458
|
-
- ✅ Key ID stored in iOS Keychain (secure, persistent)
|
|
459
|
-
- ✅ Private key stored in Secure Enclave (hardware-backed, never extractable)
|
|
460
|
-
- ✅ Key ID reused for subsequent `createAssertion()` calls
|
|
461
|
-
- ✅ Persisted across app launches and updates
|
|
462
|
-
- ❌ **Not** persisted across app reinstallation (by design)
|
|
463
|
-
|
|
464
|
-
### What Each Component Needs
|
|
465
|
-
|
|
466
|
-
**Your App (SDK):**
|
|
467
|
-
- Stores: Key ID only (in iOS Keychain)
|
|
468
|
-
- Never has access to: Private key (locked in Secure Enclave)
|
|
469
|
-
- Uses: Key ID to call `createAssertion()`
|
|
470
|
-
|
|
471
|
-
**Backend:**
|
|
472
|
-
- Stores: Public key (extracted from attestation object)
|
|
473
|
-
- Never has access to: Private key
|
|
474
|
-
- Uses: Public key to verify assertion signatures
|
|
475
|
-
|
|
476
|
-
**iOS Secure Enclave:**
|
|
477
|
-
- Stores: Private key (hardware-backed, never extractable)
|
|
478
|
-
- Used by: iOS internally to sign attestations and assertions
|
|
479
|
-
- Never exposed to: The app or the backend
|
|
480
|
-
|
|
481
|
-
### Key Lifecycle
|
|
482
|
-
|
|
483
|
-
```
|
|
484
|
-
App Install
|
|
485
|
-
↓
|
|
486
|
-
First attest() call → Generates key pair
|
|
487
|
-
↓
|
|
488
|
-
├─ Key ID → Stored in Keychain (accessible to app)
|
|
489
|
-
├─ Private Key → Stored in Secure Enclave (never extractable)
|
|
490
|
-
└─ Public Key → Sent to backend in attestation (verified & stored)
|
|
491
|
-
↓
|
|
492
|
-
createAssertion() calls → iOS uses private key to sign (app provides Key ID)
|
|
493
|
-
↓
|
|
494
|
-
App Update → Key survives ✅
|
|
495
|
-
↓
|
|
496
|
-
App Reinstall → Key is lost ❌ (start over)
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
## Error Handling
|
|
500
|
-
|
|
501
|
-
### Common Errors
|
|
502
|
-
|
|
503
|
-
**Error Code 2 (DCAppAttestErrorInvalidKey)**
|
|
504
|
-
```typescript
|
|
505
|
-
// Key was already attested
|
|
506
|
-
try {
|
|
507
|
-
await AppAttest.attest(challenge);
|
|
508
|
-
} catch (error) {
|
|
509
|
-
if (error.message.includes('error 2')) {
|
|
510
|
-
// Option 1: Use createAssertion() for ongoing requests
|
|
511
|
-
console.log('Key already attested, use assertions for ongoing requests');
|
|
512
|
-
const assertion = await AppAttest.createAssertion(clientData);
|
|
513
|
-
|
|
514
|
-
// Option 2: If you need to re-attest (e.g., backend lost public key),
|
|
515
|
-
// clear the key first
|
|
516
|
-
await AppAttest.clearAttestation();
|
|
517
|
-
const newAttestation = await AppAttest.attest(newChallenge);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
**Not Supported**
|
|
523
|
-
```typescript
|
|
524
|
-
const supported = await AppAttest.isSupported();
|
|
525
|
-
if (!supported) {
|
|
526
|
-
// Device doesn't support App Attest
|
|
527
|
-
// - iOS version < 14.0
|
|
528
|
-
// - Running on simulator
|
|
529
|
-
// - Running in Expo Go
|
|
530
|
-
}
|
|
531
|
-
```
|
|
532
|
-
|
|
533
|
-
**No Key Found**
|
|
534
|
-
```typescript
|
|
535
|
-
try {
|
|
536
|
-
await AppAttest.createAssertion(data);
|
|
537
|
-
} catch (error) {
|
|
538
|
-
if (error.message.includes('No attestation key found')) {
|
|
539
|
-
// Need to call attest() first
|
|
540
|
-
await AppAttest.attest(challenge);
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
```
|
|
544
|
-
|
|
545
|
-
## Testing
|
|
546
|
-
|
|
547
|
-
### Development Builds
|
|
548
|
-
|
|
549
|
-
App Attest works in development builds but uses Apple's **development environment**. Be aware:
|
|
550
|
-
- ✅ Works on physical devices
|
|
551
|
-
- ❌ Does NOT work on simulators
|
|
552
|
-
- ⚠️ TestFlight uses a different environment than production
|
|
553
|
-
- ⚠️ Development attestations can't be verified in production
|
|
554
|
-
|
|
555
|
-
### Resetting Keys for Testing
|
|
556
|
-
|
|
557
|
-
For testing, you may need to reset keys between test runs. Use the public `clearAttestation()` method:
|
|
558
|
-
|
|
559
|
-
```typescript
|
|
560
|
-
import * as AppAttest from '@coinbase/cdp-app-attest';
|
|
561
|
-
|
|
562
|
-
// Clear the key to allow re-attestation
|
|
563
|
-
await AppAttest.clearAttestation();
|
|
564
|
-
console.log('Key cleared - can attest again');
|
|
565
|
-
|
|
566
|
-
// Now you can attest with a new key
|
|
567
|
-
const challenge = await getServerChallenge();
|
|
568
|
-
const attestation = await AppAttest.attest(challenge);
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
**Note:** `clearAttestation()` is now part of the public API and can be used in production for legitimate re-attestation scenarios (e.g., when backend's public key expires).
|
|
572
|
-
|
|
573
|
-
## Architecture Details
|
|
574
|
-
|
|
575
|
-
### Key Storage: Keychain vs UserDefaults
|
|
576
|
-
|
|
577
|
-
**What we store:** Key ID only (a UUID-like identifier string)
|
|
578
|
-
|
|
579
|
-
**Where we store it:** iOS Keychain
|
|
580
|
-
|
|
581
|
-
**Why Keychain instead of UserDefaults?**
|
|
582
|
-
- ✅ Best practice for persistent identifiers
|
|
583
|
-
- ✅ More secure by default
|
|
584
|
-
- ✅ Survives app updates (but not reinstalls, by design)
|
|
585
|
-
- ✅ Professional implementation standard
|
|
586
|
-
|
|
587
|
-
**Could we use UserDefaults?** Yes! Apple's documentation says to store the Key ID in "persistent storage, for example by writing it to a file." Both Keychain and UserDefaults qualify as persistent storage.
|
|
588
|
-
|
|
589
|
-
**Is the Key ID a secret?** No. It's just an identifier. Even if exposed, an attacker can't use it without the physical device's Secure Enclave containing the private key.
|
|
590
|
-
|
|
591
|
-
### What The Backend Must Store
|
|
592
|
-
|
|
593
|
-
During attestation verification, the backend **must** extract and store:
|
|
594
|
-
|
|
595
|
-
1. **Public Key** - From the attestation's credCert (x5c certificate)
|
|
596
|
-
2. **Counter** - Starts at 0, increments with each assertion (prevents replay attacks)
|
|
597
|
-
3. **Project/App Association** - Link this key to your app/project
|
|
598
|
-
|
|
599
|
-
Without storing the public key and counter, the backend can't verify assertions later!
|
|
600
|
-
|
|
601
|
-
```javascript
|
|
602
|
-
// Backend stores per attested key:
|
|
603
|
-
{
|
|
604
|
-
projectId: "project123",
|
|
605
|
-
keyId: "abc123def456...", // From iOS attestation
|
|
606
|
-
publicKey: "MFkwEwYHKoZI...", // Extracted from attestation credCert
|
|
607
|
-
counter: 5, // Updated with each assertion to prevent replay
|
|
608
|
-
createdAt: "2026-02-05T10:30:00Z",
|
|
609
|
-
expiresAt: "2026-05-05T10:30:00Z"
|
|
610
|
-
}
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
**Counter validation is critical:**
|
|
614
|
-
- Each assertion includes a counter value
|
|
615
|
-
- Backend MUST verify: `assertion.counter > stored.counter`
|
|
616
|
-
- Backend MUST update stored counter after successful validation
|
|
617
|
-
- Without this, attackers can replay old assertions
|
|
618
|
-
|
|
619
|
-
## Backend Verification
|
|
620
|
-
|
|
621
|
-
The backend must verify both attestations and assertions. See Apple's documentation:
|
|
622
|
-
- [Validating Apps That Connect to Your Server](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server)
|
|
623
|
-
- [Assessing Fraud Risk](https://developer.apple.com/documentation/devicecheck/assessing-fraud-risk)
|
|
624
|
-
|
|
625
|
-
## Important Notes
|
|
626
|
-
|
|
627
|
-
### Device Requirements
|
|
628
|
-
- ✅ Physical iOS device (iPhone, iPad)
|
|
629
|
-
- ✅ iOS 14.0 or later
|
|
630
|
-
- ❌ iOS Simulator (not supported by Apple)
|
|
631
|
-
- ❌ Expo Go (requires custom dev build)
|
|
632
|
-
|
|
633
|
-
### Production Considerations
|
|
634
|
-
- Keys are tied to the app installation
|
|
635
|
-
- Each key can only be attested **once**
|
|
636
|
-
- Keys don't survive app reinstallation
|
|
637
|
-
- TestFlight uses different attestation environment
|
|
638
|
-
- Key generation is automatic (handled by the SDK when calling `attest()`)
|
|
639
|
-
|
|
640
|
-
### Security Best Practices
|
|
641
|
-
- Always verify attestations on your backend
|
|
642
|
-
- Use unique, one-time challenges
|
|
643
|
-
- Challenges should be at least 32 bytes
|
|
644
|
-
- Store attested key IDs server-side
|
|
645
|
-
- Monitor backend logs for suspicious patterns (same user attesting multiple times)
|
|
646
|
-
|
|
647
|
-
## Troubleshooting
|
|
648
|
-
|
|
649
|
-
**"App Attest is not supported on this device"**
|
|
650
|
-
- Check iOS version (requires 14.0+)
|
|
651
|
-
- Ensure using physical device (not simulator)
|
|
652
|
-
- Verify App Attest capability is enabled in Xcode
|
|
653
|
-
|
|
654
|
-
**"Failed to attest key: error 2"**
|
|
655
|
-
- Key was already attested
|
|
656
|
-
- Use `createAssertion()` for ongoing requests
|
|
657
|
-
- To re-attest: call `clearAttestation()` first, then `attest()` with a new challenge
|
|
658
|
-
|
|
659
|
-
**"No attestation key found"**
|
|
660
|
-
- Call `attest()` before `createAssertion()`
|
|
661
|
-
- Key may have been lost (app reinstall)
|
|
662
|
-
- Start attestation flow from beginning
|
|
663
|
-
|
|
664
|
-
**"Attestation key not found" (from backend)**
|
|
665
|
-
- Backend's public key expired or was lost
|
|
666
|
-
- Clear the key: `await AppAttest.clearAttestation()`
|
|
667
|
-
- Re-attest: `await AppAttest.attest(newChallenge)`
|
|
668
|
-
- Send new attestation to backend for verification
|
|
669
|
-
|
|
670
|
-
## Related Documentation
|
|
671
|
-
|
|
672
|
-
- [Apple's App Attest Guide](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity)
|
|
673
|
-
- [Expo Modules Documentation](https://docs.expo.dev/modules/)
|
|
674
|
-
- [@coinbase/cdp-core Integration Guide](../core/README.md)
|
|
675
|
-
|
|
676
|
-
## License
|
|
677
|
-
|
|
678
|
-
Apache-2.0
|