@coinbase/cdp-app-attest 0.0.0 → 0.0.94
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/README.md +678 -0
- package/android/build.gradle +40 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/cdpappattest/CdpAppAttestModule.kt +105 -0
- package/dist/esm/index.js +34 -0
- package/dist/types/index.d.ts +43 -0
- package/expo-module.config.json +9 -0
- package/ios/CdpAppAttest.podspec +27 -0
- package/ios/CdpAppAttestModule.swift +282 -0
- package/package.json +62 -6
- package/index.js +0 -1
package/README.md
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
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
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
apply plugin: 'expo-module-gradle-plugin'
|
|
4
|
+
|
|
5
|
+
android {
|
|
6
|
+
namespace "expo.modules.cdpappattest"
|
|
7
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
|
8
|
+
|
|
9
|
+
defaultConfig {
|
|
10
|
+
minSdkVersion safeExtGet("minSdkVersion", 23)
|
|
11
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
|
12
|
+
versionName "0.0.1"
|
|
13
|
+
versionCode 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
compileOptions {
|
|
17
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
18
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
kotlinOptions {
|
|
22
|
+
jvmTarget = "17"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
dependencies {
|
|
27
|
+
implementation project(':expo-modules-core')
|
|
28
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
|
29
|
+
implementation "com.google.android.play:integrity:1.4.0"
|
|
30
|
+
implementation "com.google.android.gms:play-services-base:18.5.0"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def safeExtGet(prop, fallback) {
|
|
34
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def getKotlinVersion() {
|
|
38
|
+
def kotlinVersion = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "1.9.23"
|
|
39
|
+
return kotlinVersion
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest />
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
package expo.modules.cdpappattest
|
|
2
|
+
|
|
3
|
+
import com.google.android.play.core.integrity.IntegrityManagerFactory
|
|
4
|
+
import com.google.android.play.core.integrity.StandardIntegrityManager
|
|
5
|
+
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider
|
|
6
|
+
import expo.modules.kotlin.Promise
|
|
7
|
+
import expo.modules.kotlin.exception.CodedException
|
|
8
|
+
import expo.modules.kotlin.modules.Module
|
|
9
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
10
|
+
import expo.modules.kotlin.records.Field
|
|
11
|
+
import expo.modules.kotlin.records.Record
|
|
12
|
+
|
|
13
|
+
class InitializeConfig : Record {
|
|
14
|
+
@Field
|
|
15
|
+
val cloudProjectNumber: String = ""
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class CdpAppAttestModule : Module() {
|
|
19
|
+
private var tokenProvider: StandardIntegrityTokenProvider? = null
|
|
20
|
+
|
|
21
|
+
override fun definition() = ModuleDefinition {
|
|
22
|
+
Name("CdpAppAttest")
|
|
23
|
+
|
|
24
|
+
AsyncFunction("isSupported") {
|
|
25
|
+
val context = appContext.reactContext ?: return@AsyncFunction false
|
|
26
|
+
try {
|
|
27
|
+
com.google.android.gms.common.GoogleApiAvailability.getInstance()
|
|
28
|
+
.isGooglePlayServicesAvailable(context) == com.google.android.gms.common.ConnectionResult.SUCCESS
|
|
29
|
+
} catch (_: Exception) {
|
|
30
|
+
false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
AsyncFunction("initialize") { config: InitializeConfig, promise: Promise ->
|
|
35
|
+
val context = appContext.reactContext
|
|
36
|
+
if (context == null) {
|
|
37
|
+
promise.reject(CodedException("ERR_MISSING_CONTEXT", "Application context is not available", null))
|
|
38
|
+
return@AsyncFunction
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
val projectNumber = config.cloudProjectNumber.toLongOrNull()
|
|
42
|
+
if (projectNumber == null) {
|
|
43
|
+
promise.reject(CodedException("ERR_INVALID_CONFIG", "cloudProjectNumber must be a valid number", null))
|
|
44
|
+
return@AsyncFunction
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
val manager = IntegrityManagerFactory.createStandard(context)
|
|
49
|
+
val prepareRequest = StandardIntegrityManager.PrepareIntegrityTokenRequest.builder()
|
|
50
|
+
.setCloudProjectNumber(projectNumber)
|
|
51
|
+
.build()
|
|
52
|
+
|
|
53
|
+
manager.prepareIntegrityToken(prepareRequest)
|
|
54
|
+
.addOnSuccessListener { provider ->
|
|
55
|
+
tokenProvider = provider
|
|
56
|
+
promise.resolve(null)
|
|
57
|
+
}
|
|
58
|
+
.addOnFailureListener { e ->
|
|
59
|
+
promise.reject(CodedException("ERR_PREPARE_FAILED", "Failed to prepare integrity token: ${e.message}", e))
|
|
60
|
+
}
|
|
61
|
+
} catch (e: Exception) {
|
|
62
|
+
promise.reject(CodedException("ERR_INITIALIZE_FAILED", "Failed to initialize Play Integrity: ${e.message}", e))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
AsyncFunction("attest") { _: String, promise: Promise ->
|
|
67
|
+
promise.reject(CodedException("ERR_NOT_SUPPORTED", "Attestation exchange is not supported on Android. Use createAssertion() instead.", null))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
AsyncFunction("createAssertion") { clientData: String, promise: Promise ->
|
|
71
|
+
val provider = tokenProvider
|
|
72
|
+
if (provider == null) {
|
|
73
|
+
promise.reject(CodedException("ERR_NOT_INITIALIZED", "Token provider not initialized. Call initialize() first.", null))
|
|
74
|
+
return@AsyncFunction
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
val request = StandardIntegrityManager.StandardIntegrityTokenRequest.builder()
|
|
79
|
+
.setRequestHash(clientData)
|
|
80
|
+
.build()
|
|
81
|
+
|
|
82
|
+
provider.request(request)
|
|
83
|
+
.addOnSuccessListener { response ->
|
|
84
|
+
val result = mapOf(
|
|
85
|
+
"android" to mapOf(
|
|
86
|
+
"integrityToken" to response.token()
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
promise.resolve(result)
|
|
90
|
+
}
|
|
91
|
+
.addOnFailureListener { e ->
|
|
92
|
+
promise.reject(CodedException("ERR_INTEGRITY_TOKEN", "Failed to get integrity token: ${e.message}", e))
|
|
93
|
+
}
|
|
94
|
+
} catch (e: Exception) {
|
|
95
|
+
promise.reject(CodedException("ERR_INTEGRITY_TOKEN", "Failed to request integrity token: ${e.message}", e))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
AsyncFunction("clearKey") { promise: Promise ->
|
|
100
|
+
tokenProvider = null
|
|
101
|
+
promise.resolve(true)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { requireNativeModule as a } from "expo-modules-core";
|
|
2
|
+
const t = a("CdpAppAttest");
|
|
3
|
+
async function r() {
|
|
4
|
+
return await t.isSupported();
|
|
5
|
+
}
|
|
6
|
+
async function c(e) {
|
|
7
|
+
if (typeof t.initialize == "function")
|
|
8
|
+
return await t.initialize(e);
|
|
9
|
+
}
|
|
10
|
+
async function o(e) {
|
|
11
|
+
return await t.attest(e);
|
|
12
|
+
}
|
|
13
|
+
async function s(e) {
|
|
14
|
+
return await t.createAssertion(e);
|
|
15
|
+
}
|
|
16
|
+
const i = "coinbase.cdp.attestation-registration-confirmed";
|
|
17
|
+
async function u(e) {
|
|
18
|
+
await t.setKeychainValue(i, e);
|
|
19
|
+
}
|
|
20
|
+
async function p() {
|
|
21
|
+
return await t.getKeychainValue(i);
|
|
22
|
+
}
|
|
23
|
+
async function f() {
|
|
24
|
+
await t.clearKey(), await t.deleteKeychainValue(i);
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
o as attest,
|
|
28
|
+
f as clearAttestation,
|
|
29
|
+
u as confirmRegistration,
|
|
30
|
+
s as createAssertion,
|
|
31
|
+
p as getRegisteredKeyId,
|
|
32
|
+
c as initialize,
|
|
33
|
+
r as isSupported
|
|
34
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
declare interface AndroidIntegrityData {
|
|
2
|
+
integrityToken: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export declare interface AssertionResult {
|
|
6
|
+
ios?: IOSAssertionData;
|
|
7
|
+
android?: AndroidIntegrityData;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export declare function attest(challenge: string): Promise<AttestationResult>;
|
|
11
|
+
|
|
12
|
+
export declare interface AttestationResult {
|
|
13
|
+
ios?: IOSAttestationData;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export declare function clearAttestation(): Promise<void>;
|
|
17
|
+
|
|
18
|
+
export declare function confirmRegistration(keyId: string): Promise<void>;
|
|
19
|
+
|
|
20
|
+
export declare function createAssertion(clientData: string): Promise<AssertionResult>;
|
|
21
|
+
|
|
22
|
+
export declare function getRegisteredKeyId(): Promise<string | null>;
|
|
23
|
+
|
|
24
|
+
export declare function initialize(config?: InitializeConfig): Promise<void>;
|
|
25
|
+
|
|
26
|
+
export declare interface InitializeConfig {
|
|
27
|
+
cloudProjectNumber?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
declare interface IOSAssertionData {
|
|
31
|
+
assertion: string;
|
|
32
|
+
keyId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
declare interface IOSAttestationData {
|
|
36
|
+
keyId: string;
|
|
37
|
+
attestation: string;
|
|
38
|
+
bundleId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export declare function isSupported(): Promise<boolean>;
|
|
42
|
+
|
|
43
|
+
export { }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'CdpAppAttest'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.homepage = package['homepage']
|
|
13
|
+
s.platforms = { :ios => '14.0' }
|
|
14
|
+
s.swift_version = '5.4'
|
|
15
|
+
s.source = { git: 'https://github.com/coinbase/cdp-web' }
|
|
16
|
+
s.static_framework = true
|
|
17
|
+
|
|
18
|
+
s.dependency 'ExpoModulesCore'
|
|
19
|
+
|
|
20
|
+
# Swift/Objective-C compatibility
|
|
21
|
+
s.pod_target_xcconfig = {
|
|
22
|
+
'DEFINES_MODULE' => 'YES',
|
|
23
|
+
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
27
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import DeviceCheck
|
|
3
|
+
import CryptoKit
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CDP module for iOS App Attest.
|
|
7
|
+
*
|
|
8
|
+
* Wraps DCAppAttestService to provide JavaScript access to Apple's App Attest
|
|
9
|
+
* device attestation APIs. Allows apps to prove they are legitimate instances
|
|
10
|
+
* running on genuine Apple devices.
|
|
11
|
+
*/
|
|
12
|
+
public class CdpAppAttestModule: Module {
|
|
13
|
+
// Storage key for the attestation key ID in Keychain
|
|
14
|
+
private let keyIdStorageKey = "cdp_attestation_key_id"
|
|
15
|
+
// Keychain service name
|
|
16
|
+
private let keychainService = "com.coinbase.cdp.attestation"
|
|
17
|
+
|
|
18
|
+
// MARK: - Keychain Helpers
|
|
19
|
+
|
|
20
|
+
/// Saves a string to the Keychain
|
|
21
|
+
private func saveToKeychain(key: String, value: String) -> Bool {
|
|
22
|
+
guard let data = value.data(using: .utf8) else {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// First try to update existing item
|
|
27
|
+
let updateQuery: [String: Any] = [
|
|
28
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
29
|
+
kSecAttrService as String: keychainService,
|
|
30
|
+
kSecAttrAccount as String: key
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
let updateAttributes: [String: Any] = [
|
|
34
|
+
kSecValueData as String: data
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
|
|
38
|
+
|
|
39
|
+
if updateStatus == errSecSuccess {
|
|
40
|
+
return true
|
|
41
|
+
} else if updateStatus == errSecItemNotFound {
|
|
42
|
+
// Item doesn't exist, add it
|
|
43
|
+
let addQuery: [String: Any] = [
|
|
44
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
45
|
+
kSecAttrService as String: keychainService,
|
|
46
|
+
kSecAttrAccount as String: key,
|
|
47
|
+
kSecValueData as String: data,
|
|
48
|
+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
52
|
+
return addStatus == errSecSuccess
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Retrieves a string from the Keychain
|
|
59
|
+
private func getFromKeychain(key: String) -> String? {
|
|
60
|
+
let query: [String: Any] = [
|
|
61
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
62
|
+
kSecAttrService as String: keychainService,
|
|
63
|
+
kSecAttrAccount as String: key,
|
|
64
|
+
kSecReturnData as String: true,
|
|
65
|
+
kSecMatchLimit as String: kSecMatchLimitOne
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
var result: AnyObject?
|
|
69
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
70
|
+
|
|
71
|
+
guard status == errSecSuccess,
|
|
72
|
+
let data = result as? Data,
|
|
73
|
+
let string = String(data: data, encoding: .utf8) else {
|
|
74
|
+
return nil
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Deletes a value from the Keychain
|
|
81
|
+
private func deleteFromKeychain(key: String) -> Bool {
|
|
82
|
+
let query: [String: Any] = [
|
|
83
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
84
|
+
kSecAttrService as String: keychainService,
|
|
85
|
+
kSecAttrAccount as String: key
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
let status = SecItemDelete(query as CFDictionary)
|
|
89
|
+
return status == errSecSuccess || status == errSecItemNotFound
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// MARK: - Key Management
|
|
93
|
+
|
|
94
|
+
/// Gets cached key ID from Keychain or generates a new one
|
|
95
|
+
private func getOrGenerateKeyId(completion: @escaping (String?, Error?) -> Void) {
|
|
96
|
+
if #available(iOS 14.0, *) {
|
|
97
|
+
let service = DCAppAttestService.shared
|
|
98
|
+
|
|
99
|
+
// Check if we have a cached key ID in Keychain
|
|
100
|
+
if let cachedKeyId = getFromKeychain(key: keyIdStorageKey) {
|
|
101
|
+
completion(cachedKeyId, nil)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Generate new key
|
|
106
|
+
service.generateKey { keyId, error in
|
|
107
|
+
if let error = error {
|
|
108
|
+
completion(nil, error)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
guard let keyId = keyId else {
|
|
113
|
+
completion(nil, NSError(domain: "CdpAppAttest", code: -1, userInfo: [NSLocalizedDescriptionKey: "Key generation returned nil"]))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Save to Keychain
|
|
118
|
+
if !self.saveToKeychain(key: self.keyIdStorageKey, value: keyId) {
|
|
119
|
+
completion(nil, NSError(domain: "CdpAppAttest", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to save key ID to Keychain"]))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
completion(keyId, nil)
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
completion(nil, NSError(domain: "CdpAppAttest", code: -1, userInfo: [NSLocalizedDescriptionKey: "iOS 14.0+ required"]))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Define module metadata
|
|
131
|
+
public func definition() -> ModuleDefinition {
|
|
132
|
+
Name("CdpAppAttest")
|
|
133
|
+
|
|
134
|
+
// Check if App Attest is supported on this device
|
|
135
|
+
AsyncFunction("isSupported") { () -> Bool in
|
|
136
|
+
if #available(iOS 14.0, *) {
|
|
137
|
+
return DCAppAttestService.shared.isSupported
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Attest the app instance with automatic key management
|
|
143
|
+
AsyncFunction("attest") { (challenge: String, promise: Promise) in
|
|
144
|
+
if #available(iOS 14.0, *) {
|
|
145
|
+
let service = DCAppAttestService.shared
|
|
146
|
+
|
|
147
|
+
guard service.isSupported else {
|
|
148
|
+
promise.reject("ERR_NOT_SUPPORTED", "App Attest is not supported on this device")
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Get or generate key ID
|
|
153
|
+
self.getOrGenerateKeyId { keyId, error in
|
|
154
|
+
if let error = error {
|
|
155
|
+
promise.reject("ERR_GET_KEY", "Failed to get or generate key: \(error.localizedDescription)")
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
guard let keyId = keyId else {
|
|
160
|
+
promise.reject("ERR_GET_KEY", "Failed to get key ID")
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Decode base64 challenge
|
|
165
|
+
guard let challengeData = Data(base64Encoded: challenge) else {
|
|
166
|
+
promise.reject("ERR_INVALID_CHALLENGE", "Challenge must be base64-encoded")
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Hash the challenge
|
|
171
|
+
let challengeHash = Data(SHA256.hash(data: challengeData))
|
|
172
|
+
|
|
173
|
+
// Attest the key
|
|
174
|
+
service.attestKey(keyId, clientDataHash: challengeHash) { attestation, error in
|
|
175
|
+
if let error = error {
|
|
176
|
+
promise.reject("ERR_ATTEST_KEY", "Failed to attest key: \(error.localizedDescription)")
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
guard let attestation = attestation else {
|
|
181
|
+
promise.reject("ERR_ATTEST_KEY", "Attestation returned nil")
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let bundleId = Bundle.main.bundleIdentifier ?? "unknown"
|
|
186
|
+
|
|
187
|
+
// Wrap response in "ios" key for CDP API spec
|
|
188
|
+
let result: [String: Any] = [
|
|
189
|
+
"ios": [
|
|
190
|
+
"keyId": keyId,
|
|
191
|
+
"attestation": attestation.base64EncodedString(),
|
|
192
|
+
"bundleId": bundleId
|
|
193
|
+
]
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
promise.resolve(result)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
promise.reject("ERR_NOT_SUPPORTED", "App Attest requires iOS 14.0 or later")
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Clear cached attestation key (allows re-attestation with a new key)
|
|
205
|
+
AsyncFunction("clearKey") { (promise: Promise) in
|
|
206
|
+
let success = self.deleteFromKeychain(key: self.keyIdStorageKey)
|
|
207
|
+
promise.resolve(success)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Generic Keychain storage (allows SDK to store custom flags)
|
|
211
|
+
AsyncFunction("setKeychainValue") { (key: String, value: String, promise: Promise) in
|
|
212
|
+
let success = self.saveToKeychain(key: key, value: value)
|
|
213
|
+
promise.resolve(success)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Generic Keychain retrieval
|
|
217
|
+
AsyncFunction("getKeychainValue") { (key: String, promise: Promise) in
|
|
218
|
+
if let value = self.getFromKeychain(key: key) {
|
|
219
|
+
promise.resolve(value)
|
|
220
|
+
} else {
|
|
221
|
+
promise.resolve(nil)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Generic Keychain deletion
|
|
226
|
+
AsyncFunction("deleteKeychainValue") { (key: String, promise: Promise) in
|
|
227
|
+
let success = self.deleteFromKeychain(key: key)
|
|
228
|
+
promise.resolve(success)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Simplified generateAssertion that uses cached key
|
|
232
|
+
AsyncFunction("createAssertion") { (clientData: String, promise: Promise) in
|
|
233
|
+
if #available(iOS 14.0, *) {
|
|
234
|
+
let service = DCAppAttestService.shared
|
|
235
|
+
|
|
236
|
+
guard service.isSupported else {
|
|
237
|
+
promise.reject("ERR_NOT_SUPPORTED", "App Attest is not supported on this device")
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Get cached key ID from Keychain
|
|
242
|
+
guard let keyId = self.getFromKeychain(key: self.keyIdStorageKey) else {
|
|
243
|
+
promise.reject("ERR_NO_KEY", "No attestation key found. Call attest() first to generate a key.")
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Decode base64 client data
|
|
248
|
+
guard let clientDataBytes = Data(base64Encoded: clientData) else {
|
|
249
|
+
promise.reject("ERR_INVALID_DATA", "Client data must be base64-encoded")
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Hash the client data
|
|
254
|
+
let clientDataHash = Data(SHA256.hash(data: clientDataBytes))
|
|
255
|
+
|
|
256
|
+
service.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in
|
|
257
|
+
if let error = error {
|
|
258
|
+
promise.reject("ERR_GENERATE_ASSERTION", "Failed to generate assertion: \(error.localizedDescription)")
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
guard let assertion = assertion else {
|
|
263
|
+
promise.reject("ERR_GENERATE_ASSERTION", "Assertion generation returned nil")
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Wrap response in "ios" key for CDP API spec
|
|
268
|
+
let result: [String: Any] = [
|
|
269
|
+
"ios": [
|
|
270
|
+
"assertion": assertion.base64EncodedString(),
|
|
271
|
+
"keyId": keyId
|
|
272
|
+
]
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
promise.resolve(result)
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
promise.reject("ERR_NOT_SUPPORTED", "App Attest requires iOS 14.0 or later")
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coinbase/cdp-app-attest",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
"version": "0.0.94",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "CDP native module for iOS App Attest and Android Play Integrity",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**",
|
|
8
|
+
"ios/**",
|
|
9
|
+
"android/**",
|
|
10
|
+
"expo-module.config.json",
|
|
11
|
+
"!dist/**/*.tsbuildinfo"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/types/index.d.ts",
|
|
16
|
+
"default": "./dist/esm/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"react-native",
|
|
22
|
+
"expo",
|
|
23
|
+
"app-attest",
|
|
24
|
+
"play-integrity",
|
|
25
|
+
"attestation"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/coinbase/cdp-web.git",
|
|
30
|
+
"directory": "packages/cdp-app-attest"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/coinbase/cdp-web/issues"
|
|
34
|
+
},
|
|
35
|
+
"author": "Coinbase",
|
|
36
|
+
"license": "Apache-2.0",
|
|
37
|
+
"homepage": "https://github.com/coinbase/cdp-web/tree/master/packages/cdp-app-attest",
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"expo": "*",
|
|
40
|
+
"expo-modules-core": "*",
|
|
41
|
+
"react": "*",
|
|
42
|
+
"react-native": "*"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@size-limit/preset-small-lib": "^11.2.0",
|
|
46
|
+
"size-limit": "^11.2.0"
|
|
47
|
+
},
|
|
48
|
+
"size-limit": [
|
|
49
|
+
{
|
|
50
|
+
"name": "full-package",
|
|
51
|
+
"path": "./dist/esm/index.js",
|
|
52
|
+
"limit": "2 KB"
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "pnpm run clean && pnpm run check:types && vite build",
|
|
57
|
+
"check:types": "tsc --noEmit",
|
|
58
|
+
"clean": "rm -rf dist build",
|
|
59
|
+
"clean:all": "pnpm clean && rm -rf node_modules",
|
|
60
|
+
"size-limit": "size-limit",
|
|
61
|
+
"test": "vitest",
|
|
62
|
+
"test:run": "vitest --run"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
console.log("Temporary package");
|