@bradford-tech/supabase-integrity-attest 0.8.0 → 0.8.2
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 +181 -75
- package/esm/src/attestation.d.ts +6 -1
- package/esm/src/attestation.d.ts.map +1 -1
- package/esm/src/attestation.js +86 -72
- package/esm/src/authdata.d.ts +0 -1
- package/esm/src/authdata.d.ts.map +1 -1
- package/esm/src/authdata.js +0 -2
- package/esm/src/with-assertion.d.ts.map +1 -1
- package/esm/src/with-assertion.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# supabase-integrity-attest
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
assertions using the WebCrypto API. Built for Deno and Supabase Edge Functions.
|
|
3
|
+
Apple App Attest server-side verification for edge runtimes, using only WebCrypto.
|
|
5
4
|
|
|
6
|
-
##
|
|
5
|
+
## Install
|
|
7
6
|
|
|
8
7
|
```bash
|
|
9
8
|
# Deno
|
|
@@ -13,51 +12,94 @@ deno add jsr:@bradford-tech/supabase-integrity-attest
|
|
|
13
12
|
npm install @bradford-tech/supabase-integrity-attest
|
|
14
13
|
```
|
|
15
14
|
|
|
16
|
-
##
|
|
15
|
+
## Quick start
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
Verify a device attestation and extract its public key:
|
|
19
18
|
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
import { verifyAttestation, verifyAssertion } from "@bradford-tech/supabase-integrity-attest";
|
|
19
|
+
```ts
|
|
20
|
+
import { verifyAttestation } from "@bradford-tech/supabase-integrity-attest";
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const clientDataHash = new Uint8Array(
|
|
23
|
+
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(challenge)),
|
|
24
|
+
);
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const { publicKeyPem, signCount } = await verifyAttestation(
|
|
27
|
+
{ appId: "TEAMID.com.example.app" },
|
|
28
|
+
keyId, // base64 key identifier from client
|
|
29
|
+
clientDataHash, // SHA-256 of the challenge you issued
|
|
30
|
+
attestation, // base64 CBOR attestation from client
|
|
31
|
+
);
|
|
32
|
+
// publicKeyPem: "-----BEGIN PUBLIC KEY-----\nMFkw..."
|
|
33
|
+
// signCount: 0
|
|
29
34
|
```
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
### Attestation (one-time per device)
|
|
36
|
+
Store `publicKeyPem` and `signCount` for this device. Use them to verify future assertions.
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
import { verifyAttestation } from "@bradford-tech/supabase-integrity-attest";
|
|
38
|
+
## Why this library
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
{ appId: "TEAMID.com.your.bundleid" },
|
|
40
|
-
keyId, // base64 from client
|
|
41
|
-
challenge, // the challenge you issued
|
|
42
|
-
attestation, // base64 CBOR from client
|
|
43
|
-
);
|
|
40
|
+
Existing App Attest verification libraries depend on `node:crypto` or packages that crash in edge runtimes. `appattest-checker-node` uses `X509Certificate.verify()`, which throws `ERR_NOT_IMPLEMENTED` in Deno. `pkijs` crashes at module load in Supabase Edge Functions because `self.crypto.name` is undefined. `@peculiar/x509` pulls in `tsyringe` and `reflect-metadata`, which rely on global side effects during module initialization.
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
```
|
|
42
|
+
This library uses only `crypto.subtle` for cryptographic operations, with `asn1js` for X.509 parsing and `@noble/curves` for one operation Deno's WebCrypto doesn't support (P-384 signature verification on Apple's intermediate certificate).
|
|
47
43
|
|
|
48
|
-
|
|
44
|
+
## Middleware
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
device lookup, and sign count updates happen before your business logic runs.
|
|
46
|
+
Both middleware wrappers below use a Supabase service-role client for database access:
|
|
52
47
|
|
|
53
|
-
```
|
|
48
|
+
```ts
|
|
54
49
|
import { createClient } from "jsr:@supabase/supabase-js@2";
|
|
55
|
-
import { withAssertion } from "@bradford-tech/supabase-integrity-attest";
|
|
56
50
|
|
|
57
51
|
const supabase = createClient(
|
|
58
52
|
Deno.env.get("SUPABASE_URL")!,
|
|
59
53
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
|
60
54
|
);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `withAttestation` -- device registration
|
|
58
|
+
|
|
59
|
+
`withAttestation` wraps a one-time device registration endpoint with automatic challenge consumption, attestation verification, and device key storage:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { withAttestation } from "@bradford-tech/supabase-integrity-attest";
|
|
63
|
+
|
|
64
|
+
Deno.serve(withAttestation({
|
|
65
|
+
appId: Deno.env.get("APP_ATTEST_APP_ID")!,
|
|
66
|
+
consumeChallenge: async (challenge) => {
|
|
67
|
+
const id = new TextDecoder().decode(challenge);
|
|
68
|
+
const { data } = await supabase
|
|
69
|
+
.from("attestation_challenges")
|
|
70
|
+
.delete()
|
|
71
|
+
.eq("id", id)
|
|
72
|
+
.select("id");
|
|
73
|
+
return (data?.length ?? 0) > 0;
|
|
74
|
+
},
|
|
75
|
+
storeDeviceKey: async ({ deviceId, publicKeyPem, signCount }) => {
|
|
76
|
+
await supabase
|
|
77
|
+
.from("device_attestations")
|
|
78
|
+
.upsert({ key_id: deviceId, public_key_pem: publicKeyPem, sign_count: signCount });
|
|
79
|
+
},
|
|
80
|
+
}, (_req, ctx) => {
|
|
81
|
+
// ctx.deviceId, ctx.publicKeyPem, ctx.signCount, ctx.receipt, ctx.timings
|
|
82
|
+
return Response.json({ deviceId: ctx.deviceId });
|
|
83
|
+
}));
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`consumeChallenge` must be atomic: return `true` if the challenge was valid, unused, and unexpired (and is now consumed), `false` otherwise. Use `DELETE ... RETURNING` to guarantee single-use semantics.
|
|
87
|
+
|
|
88
|
+
The default extractor reads a JSON body:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
POST /functions/v1/attest
|
|
92
|
+
Content-Type: application/json
|
|
93
|
+
|
|
94
|
+
{"keyId": "<base64>", "challenge": "<base64>", "attestation": "<base64>"}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `withAssertion` -- protected requests
|
|
98
|
+
|
|
99
|
+
`withAssertion` wraps any protected endpoint with automatic assertion verification, device key lookup, and sign count commit:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { withAssertion } from "@bradford-tech/supabase-integrity-attest";
|
|
61
103
|
|
|
62
104
|
Deno.serve(withAssertion({
|
|
63
105
|
appId: Deno.env.get("APP_ATTEST_APP_ID")!,
|
|
@@ -71,108 +113,172 @@ Deno.serve(withAssertion({
|
|
|
71
113
|
? { publicKeyPem: data.public_key_pem, signCount: data.sign_count }
|
|
72
114
|
: null;
|
|
73
115
|
},
|
|
74
|
-
|
|
75
|
-
await supabase
|
|
116
|
+
commitSignCount: async (deviceId, newSignCount) => {
|
|
117
|
+
const { data } = await supabase
|
|
76
118
|
.from("device_attestations")
|
|
77
119
|
.update({ sign_count: newSignCount })
|
|
78
|
-
.eq("key_id", deviceId)
|
|
120
|
+
.eq("key_id", deviceId)
|
|
121
|
+
.lt("sign_count", newSignCount)
|
|
122
|
+
.select("key_id");
|
|
123
|
+
return (data?.length ?? 0) > 0;
|
|
79
124
|
},
|
|
80
125
|
}, async (_req, { rawBody }) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
return new Response(JSON.stringify({ audio: "..." }), {
|
|
84
|
-
headers: { "Content-Type": "application/json" },
|
|
85
|
-
});
|
|
126
|
+
const payload = JSON.parse(new TextDecoder().decode(rawBody));
|
|
127
|
+
return Response.json({ ok: true });
|
|
86
128
|
}));
|
|
87
129
|
```
|
|
88
130
|
|
|
89
|
-
The client sends the assertion and device ID in headers. The request body
|
|
90
|
-
is the client data that was signed:
|
|
131
|
+
The client sends the assertion and device ID in headers. The request body is the signed client data:
|
|
91
132
|
|
|
92
133
|
```
|
|
93
134
|
POST /functions/v1/your-endpoint
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
{"text": "Hello world", "voice": "en-US"}
|
|
135
|
+
X-App-Attest-Assertion: <base64-encoded assertion>
|
|
136
|
+
X-App-Attest-Device-Id: <base64-encoded keyId>
|
|
137
|
+
Content-Type: application/json
|
|
138
|
+
|
|
139
|
+
{"text": "Hello world", "voice": "en-US"}
|
|
100
140
|
```
|
|
101
141
|
|
|
102
|
-
|
|
103
|
-
a helper:
|
|
142
|
+
### Sign count atomicity
|
|
104
143
|
|
|
105
|
-
|
|
144
|
+
`commitSignCount` **must** use compare-and-swap: only update the stored count if the current value is strictly less than `newSignCount`. An unconditional `UPDATE ... SET sign_count = $1` silently breaks replay protection when two requests arrive concurrently.
|
|
145
|
+
|
|
146
|
+
```sql
|
|
147
|
+
UPDATE device_attestations
|
|
148
|
+
SET sign_count = $1, last_seen_at = now()
|
|
149
|
+
WHERE key_id = $2 AND sign_count < $1
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Return `true` if the row was updated. The library converts `false` into `AssertionError(SIGN_COUNT_STALE)`.
|
|
153
|
+
|
|
154
|
+
### Shared options
|
|
155
|
+
|
|
156
|
+
Once you have multiple protected functions, extract the shared options:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
106
159
|
// supabase/functions/_shared/attest.ts
|
|
107
160
|
import type { WithAssertionOptions } from "@bradford-tech/supabase-integrity-attest";
|
|
108
161
|
|
|
109
|
-
export const
|
|
162
|
+
export const assertionOptions: WithAssertionOptions = {
|
|
110
163
|
appId: Deno.env.get("APP_ATTEST_APP_ID")!,
|
|
111
|
-
// ... getDeviceKey,
|
|
164
|
+
// ... getDeviceKey, commitSignCount as above
|
|
112
165
|
};
|
|
113
166
|
```
|
|
114
167
|
|
|
115
|
-
```
|
|
168
|
+
```ts
|
|
116
169
|
// supabase/functions/text-to-speech/index.ts
|
|
117
170
|
import { withAssertion } from "@bradford-tech/supabase-integrity-attest";
|
|
118
|
-
import {
|
|
171
|
+
import { assertionOptions } from "../_shared/attest.ts";
|
|
119
172
|
|
|
120
|
-
Deno.serve(withAssertion(
|
|
173
|
+
Deno.serve(withAssertion(assertionOptions, async (_req, { rawBody }) => {
|
|
121
174
|
const { text, voice } = JSON.parse(new TextDecoder().decode(rawBody));
|
|
122
|
-
return
|
|
175
|
+
return Response.json({ audio: "..." });
|
|
123
176
|
}));
|
|
124
177
|
```
|
|
125
178
|
|
|
126
|
-
|
|
179
|
+
## Low-level API
|
|
180
|
+
|
|
181
|
+
For full control over the verification flow, use `verifyAttestation` and `verifyAssertion` directly.
|
|
182
|
+
|
|
183
|
+
The quick start above shows `verifyAttestation`. Note that `clientDataHash` must be SHA-256 of the challenge, not the raw challenge. Client SDKs (Expo's `attestKeyAsync`, native `DCAppAttestService.attestKey`) hash the challenge internally before passing to Apple; you must produce the same hash server-side. The `withAttestation` middleware handles this automatically.
|
|
127
184
|
|
|
128
|
-
|
|
185
|
+
### Assertion
|
|
129
186
|
|
|
130
|
-
```
|
|
187
|
+
```ts
|
|
131
188
|
import { verifyAssertion } from "@bradford-tech/supabase-integrity-attest";
|
|
132
189
|
|
|
133
|
-
const
|
|
134
|
-
{ appId: "TEAMID.com.
|
|
135
|
-
assertion,
|
|
136
|
-
clientData,
|
|
137
|
-
storedPublicKeyPem,
|
|
138
|
-
storedSignCount,
|
|
190
|
+
const { signCount } = await verifyAssertion(
|
|
191
|
+
{ appId: "TEAMID.com.example.app" },
|
|
192
|
+
assertion, // base64 CBOR from client
|
|
193
|
+
clientData, // the request payload that was signed
|
|
194
|
+
storedPublicKeyPem,
|
|
195
|
+
storedSignCount,
|
|
139
196
|
);
|
|
197
|
+
// Update stored signCount to signCount
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Subpath imports
|
|
201
|
+
|
|
202
|
+
Import only what you need to reduce bundle size:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// Full library (attestation + assertion)
|
|
206
|
+
import { verifyAttestation, verifyAssertion } from "@bradford-tech/supabase-integrity-attest";
|
|
207
|
+
|
|
208
|
+
// Assertion only -- skips asn1js and @noble/curves
|
|
209
|
+
import { verifyAssertion, withAssertion } from "@bradford-tech/supabase-integrity-attest/assertion";
|
|
140
210
|
|
|
141
|
-
//
|
|
211
|
+
// Attestation only
|
|
212
|
+
import { verifyAttestation, withAttestation } from "@bradford-tech/supabase-integrity-attest/attestation";
|
|
142
213
|
```
|
|
143
214
|
|
|
144
|
-
|
|
215
|
+
## Error handling
|
|
145
216
|
|
|
146
|
-
```
|
|
217
|
+
```ts
|
|
147
218
|
import {
|
|
148
219
|
AttestationError,
|
|
149
|
-
AttestationErrorCode,
|
|
150
220
|
AssertionError,
|
|
151
|
-
AssertionErrorCode,
|
|
152
221
|
} from "@bradford-tech/supabase-integrity-attest";
|
|
153
222
|
|
|
154
223
|
try {
|
|
155
|
-
await verifyAttestation(appInfo, keyId,
|
|
224
|
+
await verifyAttestation(appInfo, keyId, clientDataHash, attestation);
|
|
156
225
|
} catch (e) {
|
|
157
226
|
if (e instanceof AttestationError) {
|
|
158
|
-
console.log(e.code);
|
|
227
|
+
console.log(e.code);
|
|
228
|
+
// => "NONCE_MISMATCH"
|
|
159
229
|
}
|
|
160
230
|
}
|
|
161
231
|
```
|
|
162
232
|
|
|
233
|
+
### Attestation error codes
|
|
234
|
+
|
|
235
|
+
| Code | Meaning |
|
|
236
|
+
| --- | --- |
|
|
237
|
+
| `INVALID_FORMAT` | CBOR decoding or structural validation failed |
|
|
238
|
+
| `INVALID_CERTIFICATE_CHAIN` | X.509 certificate chain verification failed |
|
|
239
|
+
| `NONCE_MISMATCH` | Computed nonce does not match the certificate nonce |
|
|
240
|
+
| `RP_ID_MISMATCH` | RP ID hash does not match SHA-256 of the app ID |
|
|
241
|
+
| `KEY_ID_MISMATCH` | Public key hash does not match the provided key ID |
|
|
242
|
+
| `INVALID_COUNTER` | Sign count is not zero (required for attestation) |
|
|
243
|
+
| `INVALID_AAGUID` | AAGUID does not match the expected environment |
|
|
244
|
+
| `CHALLENGE_INVALID` | Challenge missing, expired, or already consumed (`withAttestation` only) |
|
|
245
|
+
| `INTERNAL_ERROR` | Storage callback or internal error (`withAttestation` only) |
|
|
246
|
+
|
|
247
|
+
### Assertion error codes
|
|
248
|
+
|
|
249
|
+
| Code | Meaning |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
| `INVALID_FORMAT` | CBOR decoding or structural validation failed |
|
|
252
|
+
| `RP_ID_MISMATCH` | RP ID hash does not match SHA-256 of the app ID |
|
|
253
|
+
| `COUNTER_NOT_INCREMENTED` | Sign count was not greater than the stored value |
|
|
254
|
+
| `SIGNATURE_INVALID` | ECDSA signature verification failed |
|
|
255
|
+
| `DEVICE_NOT_FOUND` | No device key for the given device ID (`withAssertion` only) |
|
|
256
|
+
| `INTERNAL_ERROR` | Storage callback or internal error (`withAssertion` only) |
|
|
257
|
+
| `SIGN_COUNT_STALE` | Concurrent request already advanced the counter (`withAssertion` only) |
|
|
258
|
+
|
|
163
259
|
## Development environment
|
|
164
260
|
|
|
165
|
-
For apps using Apple's development App Attest environment:
|
|
261
|
+
For apps using Apple's development App Attest environment, pass `developmentEnv: true`:
|
|
166
262
|
|
|
167
|
-
```
|
|
263
|
+
```ts
|
|
168
264
|
await verifyAttestation(
|
|
169
|
-
{ appId: "TEAMID.com.
|
|
265
|
+
{ appId: "TEAMID.com.example.app", developmentEnv: true },
|
|
170
266
|
keyId,
|
|
171
|
-
|
|
267
|
+
clientDataHash,
|
|
172
268
|
attestation,
|
|
173
269
|
);
|
|
174
270
|
```
|
|
175
271
|
|
|
272
|
+
`withAttestation` also accepts `developmentEnv` in its options.
|
|
273
|
+
|
|
274
|
+
## Documentation
|
|
275
|
+
|
|
276
|
+
Full documentation at [integrity-attest.bradford.tech](https://integrity-attest.bradford.tech).
|
|
277
|
+
|
|
278
|
+
## Contributing
|
|
279
|
+
|
|
280
|
+
Issues and pull requests are welcome on [GitHub](https://github.com/bradford-tech/supabase-integrity-attest).
|
|
281
|
+
|
|
176
282
|
## Security
|
|
177
283
|
|
|
178
284
|
See [SECURITY.md](./SECURITY.md) for vulnerability reporting.
|
package/esm/src/attestation.d.ts
CHANGED
|
@@ -40,7 +40,12 @@ export interface AttestationCbor {
|
|
|
40
40
|
};
|
|
41
41
|
authData: Uint8Array;
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Decode an Apple App Attest attestation object from raw CBOR bytes.
|
|
45
|
+
*
|
|
46
|
+
* @throws {AttestationError} with code `INVALID_FORMAT` if the data is not
|
|
47
|
+
* a valid attestation CBOR structure.
|
|
48
|
+
*/
|
|
44
49
|
export declare function decodeAttestationCbor(data: Uint8Array): AttestationCbor;
|
|
45
50
|
/**
|
|
46
51
|
* Verify an Apple App Attest attestation.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attestation.d.ts","sourceRoot":"","sources":["../../src/src/attestation.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"attestation.d.ts","sourceRoot":"","sources":["../../src/src/attestation.ts"],"names":[],"mappings":"AAgBA,yCAAyC;AACzC,MAAM,WAAW,OAAO;IACtB,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC;IACd,kFAAkF;IAClF,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,kDAAkD;AAClD,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,YAAY,EAAE,MAAM,CAAC;IACrB,4DAA4D;IAC5D,OAAO,EAAE,UAAU,CAAC;IACpB,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,iDAAiD;AACjD,MAAM,WAAW,wBAAwB;IACvC,uFAAuF;IACvF,SAAS,CAAC,EAAE,IAAI,CAAC;CAClB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE;QACP,GAAG,EAAE,UAAU,EAAE,CAAC;QAClB,OAAO,EAAE,UAAU,CAAC;KACrB,CAAC;IACF,QAAQ,EAAE,UAAU,CAAC;CACtB;AAsGD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,UAAU,GAAG,eAAe,CAwFvE;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,cAAc,EAAE,UAAU,GAAG,MAAM,EACnC,WAAW,EAAE,UAAU,GAAG,MAAM,EAChC,OAAO,CAAC,EAAE,wBAAwB,GACjC,OAAO,CAAC,iBAAiB,CAAC,CAoJ5B"}
|
package/esm/src/attestation.js
CHANGED
|
@@ -3,7 +3,7 @@ import { decodeBase64 } from "../deps/jsr.io/@std/encoding/1.0.10/base64.js";
|
|
|
3
3
|
import { extractNonceFromCert, extractPublicKeyFromCert, verifyCertificateChain, } from "./certificate.js";
|
|
4
4
|
import { AAGUID_DEVELOPMENT, AAGUID_PRODUCTION } from "./constants.js";
|
|
5
5
|
import { AttestationError, AttestationErrorCode } from "./errors.js";
|
|
6
|
-
import { parseAttestationAuthData } from "./authdata.js";
|
|
6
|
+
import { parseAttestationAuthData, } from "./authdata.js";
|
|
7
7
|
import { concat, constantTimeEqual, exportKeyToPem, toBytes } from "./utils.js";
|
|
8
8
|
/** Read a CBOR unsigned integer (additional info + following bytes). */
|
|
9
9
|
function readCborUint(data, offset) {
|
|
@@ -88,72 +88,86 @@ function findCborTextKey(data, key, startOffset) {
|
|
|
88
88
|
}
|
|
89
89
|
return -1;
|
|
90
90
|
}
|
|
91
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Decode an Apple App Attest attestation object from raw CBOR bytes.
|
|
93
|
+
*
|
|
94
|
+
* @throws {AttestationError} with code `INVALID_FORMAT` if the data is not
|
|
95
|
+
* a valid attestation CBOR structure.
|
|
96
|
+
*/
|
|
92
97
|
export function decodeAttestationCbor(data) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
98
|
+
try {
|
|
99
|
+
// Verify top-level is a CBOR map
|
|
100
|
+
const majorType = (data[0] >> 5) & 0x07;
|
|
101
|
+
if (majorType !== 5) {
|
|
102
|
+
throw new Error("Expected CBOR map at top level");
|
|
103
|
+
}
|
|
104
|
+
// Find "fmt" key and read its value
|
|
105
|
+
const fmtKeyPos = findCborTextKey(data, "fmt", 0);
|
|
106
|
+
if (fmtKeyPos === -1)
|
|
107
|
+
throw new Error('Missing "fmt" key');
|
|
108
|
+
const fmtKeyEnd = fmtKeyPos + 1 + 3; // 0x63 + "fmt"
|
|
109
|
+
const { value: fmt } = readCborText(data, fmtKeyEnd);
|
|
110
|
+
// Find "attStmt" key
|
|
111
|
+
const attStmtKeyPos = findCborTextKey(data, "attStmt", 0);
|
|
112
|
+
if (attStmtKeyPos === -1)
|
|
113
|
+
throw new Error('Missing "attStmt" key');
|
|
114
|
+
// After "attStmt" key: 0x67 + "attStmt" = 8 bytes
|
|
115
|
+
const attStmtValuePos = attStmtKeyPos + 8;
|
|
116
|
+
// attStmt value should be a map
|
|
117
|
+
const attStmtMajor = (data[attStmtValuePos] >> 5) & 0x07;
|
|
118
|
+
if (attStmtMajor !== 5)
|
|
119
|
+
throw new Error("attStmt is not a CBOR map");
|
|
120
|
+
// Find "x5c" key within attStmt
|
|
121
|
+
const x5cKeyPos = findCborTextKey(data, "x5c", attStmtValuePos);
|
|
122
|
+
if (x5cKeyPos === -1)
|
|
123
|
+
throw new Error('Missing "x5c" key in attStmt');
|
|
124
|
+
const x5cValuePos = x5cKeyPos + 4; // 0x63 + "x5c"
|
|
125
|
+
// x5c value is an array
|
|
126
|
+
const x5cMajor = (data[x5cValuePos] >> 5) & 0x07;
|
|
127
|
+
if (x5cMajor !== 4)
|
|
128
|
+
throw new Error("x5c is not a CBOR array");
|
|
129
|
+
const { value: x5cCount, end: x5cFirstItemPos } = readCborUint(data, x5cValuePos);
|
|
130
|
+
// Read each certificate byte string
|
|
131
|
+
const x5c = [];
|
|
132
|
+
let pos = x5cFirstItemPos;
|
|
133
|
+
for (let i = 0; i < x5cCount; i++) {
|
|
134
|
+
const { value: cert, end } = readCborBytes(data, pos);
|
|
135
|
+
x5c.push(cert);
|
|
136
|
+
pos = end;
|
|
137
|
+
}
|
|
138
|
+
// After x5c, find "receipt" key
|
|
139
|
+
const receiptKeyPos = findCborTextKey(data, "receipt", pos);
|
|
140
|
+
if (receiptKeyPos === -1) {
|
|
141
|
+
throw new Error('Missing "receipt" key in attStmt');
|
|
142
|
+
}
|
|
143
|
+
// Find "authData" key - search from after the receipt key position
|
|
144
|
+
const authDataKeyPos = findCborTextKey(data, "authData", receiptKeyPos);
|
|
145
|
+
if (authDataKeyPos === -1) {
|
|
146
|
+
throw new Error('Missing "authData" key');
|
|
147
|
+
}
|
|
148
|
+
// The receipt value is the bytes between the receipt key end and the authData key start.
|
|
149
|
+
// Receipt key: 0x67 + "receipt" = 8 bytes
|
|
150
|
+
const receiptValueStart = receiptKeyPos + 8;
|
|
151
|
+
// Read the receipt CBOR header to get the data start offset
|
|
152
|
+
const receiptMajor = (data[receiptValueStart] >> 5) & 0x07;
|
|
153
|
+
if (receiptMajor !== 2) {
|
|
154
|
+
throw new Error("receipt is not a CBOR byte string");
|
|
155
|
+
}
|
|
156
|
+
const { end: receiptDataStart } = readCborUint(data, receiptValueStart);
|
|
157
|
+
// The actual receipt data extends to just before the authData key.
|
|
158
|
+
// This handles the case where Apple's CBOR length header is incorrect.
|
|
159
|
+
const receipt = data.slice(receiptDataStart, authDataKeyPos);
|
|
160
|
+
// Read authData value
|
|
161
|
+
// authData key: 0x68 + "authData" = 9 bytes
|
|
162
|
+
const authDataValuePos = authDataKeyPos + 9;
|
|
163
|
+
const { value: authData } = readCborBytes(data, authDataValuePos);
|
|
164
|
+
return { fmt, attStmt: { x5c, receipt }, authData };
|
|
131
165
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
throw new Error(
|
|
136
|
-
// Find "authData" key - search from after the receipt key position
|
|
137
|
-
const authDataKeyPos = findCborTextKey(data, "authData", receiptKeyPos);
|
|
138
|
-
if (authDataKeyPos === -1) {
|
|
139
|
-
throw new Error('Missing "authData" key');
|
|
166
|
+
catch (e) {
|
|
167
|
+
if (e instanceof AttestationError)
|
|
168
|
+
throw e;
|
|
169
|
+
throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, `Failed to CBOR-decode attestation object: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
140
170
|
}
|
|
141
|
-
// The receipt value is the bytes between the receipt key end and the authData key start.
|
|
142
|
-
// Receipt key: 0x67 + "receipt" = 8 bytes
|
|
143
|
-
const receiptValueStart = receiptKeyPos + 8;
|
|
144
|
-
// Read the receipt CBOR header to get the data start offset
|
|
145
|
-
const receiptMajor = (data[receiptValueStart] >> 5) & 0x07;
|
|
146
|
-
if (receiptMajor !== 2)
|
|
147
|
-
throw new Error("receipt is not a CBOR byte string");
|
|
148
|
-
const { end: receiptDataStart } = readCborUint(data, receiptValueStart);
|
|
149
|
-
// The actual receipt data extends to just before the authData key.
|
|
150
|
-
// This handles the case where Apple's CBOR length header is incorrect.
|
|
151
|
-
const receipt = data.slice(receiptDataStart, authDataKeyPos);
|
|
152
|
-
// Read authData value
|
|
153
|
-
// authData key: 0x68 + "authData" = 9 bytes
|
|
154
|
-
const authDataValuePos = authDataKeyPos + 9;
|
|
155
|
-
const { value: authData } = readCborBytes(data, authDataValuePos);
|
|
156
|
-
return { fmt, attStmt: { x5c, receipt }, authData };
|
|
157
171
|
}
|
|
158
172
|
/**
|
|
159
173
|
* Verify an Apple App Attest attestation.
|
|
@@ -188,13 +202,7 @@ export async function verifyAttestation(appInfo, keyId, clientDataHash, attestat
|
|
|
188
202
|
attestationBytes = attestation;
|
|
189
203
|
}
|
|
190
204
|
// Step 1: CBOR decode attestation -> { fmt, attStmt: { x5c, receipt }, authData }
|
|
191
|
-
|
|
192
|
-
try {
|
|
193
|
-
decoded = decodeAttestationCbor(attestationBytes);
|
|
194
|
-
}
|
|
195
|
-
catch {
|
|
196
|
-
throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, "Failed to CBOR-decode attestation object");
|
|
197
|
-
}
|
|
205
|
+
const decoded = decodeAttestationCbor(attestationBytes);
|
|
198
206
|
// Step 2: Validate fmt === "apple-appattest"
|
|
199
207
|
if (decoded.fmt !== "apple-appattest") {
|
|
200
208
|
throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, `Invalid attestation format: expected "apple-appattest", got "${decoded.fmt}"`);
|
|
@@ -228,7 +236,13 @@ export async function verifyAttestation(appInfo, keyId, clientDataHash, attestat
|
|
|
228
236
|
throw new AttestationError(AttestationErrorCode.KEY_ID_MISMATCH, "Public key hash does not match keyId");
|
|
229
237
|
}
|
|
230
238
|
// Step 11: Parse authData
|
|
231
|
-
|
|
239
|
+
let parsedAuthData;
|
|
240
|
+
try {
|
|
241
|
+
parsedAuthData = parseAttestationAuthData(authData);
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
throw new AttestationError(AttestationErrorCode.INVALID_FORMAT, `Invalid authenticatorData: ${e instanceof Error ? e.message : String(e)}`);
|
|
245
|
+
}
|
|
232
246
|
// Step 12: Verify rpIdHash === SHA-256(appId)
|
|
233
247
|
const appIdHash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(appInfo.appId)));
|
|
234
248
|
if (!constantTimeEqual(parsedAuthData.rpIdHash, appIdHash)) {
|
package/esm/src/authdata.d.ts
CHANGED
|
@@ -6,7 +6,6 @@ export interface AssertionAuthData {
|
|
|
6
6
|
export interface AttestationAuthData extends AssertionAuthData {
|
|
7
7
|
aaguid: Uint8Array;
|
|
8
8
|
credentialId: Uint8Array;
|
|
9
|
-
coseKeyBytes: Uint8Array;
|
|
10
9
|
}
|
|
11
10
|
export declare function parseAssertionAuthData(data: Uint8Array): AssertionAuthData;
|
|
12
11
|
export declare function parseAttestationAuthData(data: Uint8Array): AttestationAuthData;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authdata.d.ts","sourceRoot":"","sources":["../../src/src/authdata.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,UAAU,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAoB,SAAQ,iBAAiB;IAC5D,MAAM,EAAE,UAAU,CAAC;IACnB,YAAY,EAAE,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"authdata.d.ts","sourceRoot":"","sources":["../../src/src/authdata.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,UAAU,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAoB,SAAQ,iBAAiB;IAC5D,MAAM,EAAE,UAAU,CAAC;IACnB,YAAY,EAAE,UAAU,CAAC;CAC1B;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,UAAU,GAAG,iBAAiB,CAgB1E;AAED,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,UAAU,GACf,mBAAmB,CA+BrB"}
|
package/esm/src/authdata.js
CHANGED
|
@@ -19,11 +19,9 @@ export function parseAttestationAuthData(data) {
|
|
|
19
19
|
throw new Error(`authenticatorData truncated: credentialIdLength=${credentialIdLength} but only ${data.length - 55} bytes remain`);
|
|
20
20
|
}
|
|
21
21
|
const credentialId = data.slice(55, 55 + credentialIdLength);
|
|
22
|
-
const coseKeyBytes = data.slice(55 + credentialIdLength);
|
|
23
22
|
return {
|
|
24
23
|
...base,
|
|
25
24
|
aaguid,
|
|
26
25
|
credentialId,
|
|
27
|
-
coseKeyBytes,
|
|
28
26
|
};
|
|
29
27
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"with-assertion.d.ts","sourceRoot":"","sources":["../../src/src/with-assertion.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAsB,MAAM,aAAa,CAAC;AAEjE,iEAAiE;AACjE,eAAO,MAAM,wBAAwB,2BAA2B,CAAC;AACjE,0DAA0D;AAC1D,eAAO,MAAM,wBAAwB,2BAA2B,CAAC;AAEjE,0EAA0E;AAC1E,MAAM,MAAM,SAAS,GAAG;IACtB,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qDAAqD;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,cAAc,EAAE,MAAM,CAAC;IACvB,6EAA6E;IAC7E,QAAQ,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,mFAAmF;AACnF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,OAAO,EAAE,UAAU,CAAC;IACpB,mEAAmE;IACnE,OAAO,EAAE,gBAAgB,CAAC;CAC3B,CAAC;AAEF,0EAA0E;AAC1E,MAAM,MAAM,kBAAkB,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC;IACzD,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,UAAU,CAAC;CACxB,CAAC,CAAC;AAEH,kEAAkE;AAClE,MAAM,MAAM,oBAAoB,GAAG;IACjC,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAC9D;;;;;;;;;;;;;;;;OAgBG;IACH,eAAe,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9E,8DAA8D;IAC9D,gBAAgB,CAAC,EAAE,kBAAkB,CAAC;IACtC,uEAAuE;IACvE,OAAO,CAAC,EAAE,CACR,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,OAAO,KACT,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACnC,CAAC;
|
|
1
|
+
{"version":3,"file":"with-assertion.d.ts","sourceRoot":"","sources":["../../src/src/with-assertion.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAsB,MAAM,aAAa,CAAC;AAEjE,iEAAiE;AACjE,eAAO,MAAM,wBAAwB,2BAA2B,CAAC;AACjE,0DAA0D;AAC1D,eAAO,MAAM,wBAAwB,2BAA2B,CAAC;AAEjE,0EAA0E;AAC1E,MAAM,MAAM,SAAS,GAAG;IACtB,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qDAAqD;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,cAAc,EAAE,MAAM,CAAC;IACvB,6EAA6E;IAC7E,QAAQ,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,mFAAmF;AACnF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,OAAO,EAAE,UAAU,CAAC;IACpB,mEAAmE;IACnE,OAAO,EAAE,gBAAgB,CAAC;CAC3B,CAAC;AAEF,0EAA0E;AAC1E,MAAM,MAAM,kBAAkB,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC;IACzD,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,UAAU,CAAC;CACxB,CAAC,CAAC;AAEH,kEAAkE;AAClE,MAAM,MAAM,oBAAoB,GAAG;IACjC,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAC9D;;;;;;;;;;;;;;;;OAgBG;IACH,eAAe,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9E,8DAA8D;IAC9D,gBAAgB,CAAC,EAAE,kBAAkB,CAAC;IACtC,uEAAuE;IACvE,OAAO,CAAC,EAAE,CACR,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,OAAO,KACT,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACnC,CAAC;AAqCF;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,oBAAoB,EAC7B,OAAO,EAAE,CACP,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,gBAAgB,KACtB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,GAChC,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAmGrC"}
|
|
@@ -19,7 +19,9 @@ function defaultErrorResponse(error) {
|
|
|
19
19
|
? 500
|
|
20
20
|
: error.code === AssertionErrorCode.INVALID_FORMAT
|
|
21
21
|
? 400
|
|
22
|
-
:
|
|
22
|
+
: error.code === AssertionErrorCode.SIGN_COUNT_STALE
|
|
23
|
+
? 409
|
|
24
|
+
: 401;
|
|
23
25
|
return new Response(JSON.stringify({ error: error.message, code: error.code }), { status, headers: { "Content-Type": "application/json" } });
|
|
24
26
|
}
|
|
25
27
|
/**
|
package/package.json
CHANGED