@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 CHANGED
@@ -1,9 +1,8 @@
1
1
  # supabase-integrity-attest
2
2
 
3
- Server-side TypeScript library for verifying Apple App Attest attestations and
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
- ## Installation
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
- ## Subpath imports
15
+ ## Quick start
17
16
 
18
- If you only need assertion verification, import from the lighter entry point:
17
+ Verify a device attestation and extract its public key:
19
18
 
20
- ```typescript
21
- // Full library (attestation + assertion)
22
- import { verifyAttestation, verifyAssertion } from "@bradford-tech/supabase-integrity-attest";
19
+ ```ts
20
+ import { verifyAttestation } from "@bradford-tech/supabase-integrity-attest";
23
21
 
24
- // Assertion only skips asn1js and @noble/curves
25
- import { verifyAssertion } from "@bradford-tech/supabase-integrity-attest/assertion";
22
+ const clientDataHash = new Uint8Array(
23
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(challenge)),
24
+ );
26
25
 
27
- // Attestation only
28
- import { verifyAttestation } from "@bradford-tech/supabase-integrity-attest/attestation";
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
- ## Usage
32
-
33
- ### Attestation (one-time per device)
36
+ Store `publicKeyPem` and `signCount` for this device. Use them to verify future assertions.
34
37
 
35
- ```typescript
36
- import { verifyAttestation } from "@bradford-tech/supabase-integrity-attest";
38
+ ## Why this library
37
39
 
38
- const result = await verifyAttestation(
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
- // Store result.publicKeyPem and signCount (0) for this device
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
- ### Protecting edge functions with `withAssertion`
44
+ ## Middleware
49
45
 
50
- `withAssertion` wraps a request handler so that assertion verification,
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
- ```typescript
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
- updateSignCount: async (deviceId, newSignCount) => {
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 { text, voice } = JSON.parse(new TextDecoder().decode(rawBody));
82
- // business logic
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
- Headers:
95
- Content-Type: application/json
96
- X-App-Attest-Assertion: <base64-encoded assertion>
97
- X-App-Attest-Device-Id: <base64-encoded keyId>
98
- Body:
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
- Once you have multiple protected functions, extract the shared options into
103
- a helper:
142
+ ### Sign count atomicity
104
143
 
105
- ```typescript
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 attestOptions: WithAssertionOptions = {
162
+ export const assertionOptions: WithAssertionOptions = {
110
163
  appId: Deno.env.get("APP_ATTEST_APP_ID")!,
111
- // ... getDeviceKey, updateSignCount as above
164
+ // ... getDeviceKey, commitSignCount as above
112
165
  };
113
166
  ```
114
167
 
115
- ```typescript
168
+ ```ts
116
169
  // supabase/functions/text-to-speech/index.ts
117
170
  import { withAssertion } from "@bradford-tech/supabase-integrity-attest";
118
- import { attestOptions } from "../_shared/attest.ts";
171
+ import { assertionOptions } from "../_shared/attest.ts";
119
172
 
120
- Deno.serve(withAssertion(attestOptions, async (_req, { rawBody }) => {
173
+ Deno.serve(withAssertion(assertionOptions, async (_req, { rawBody }) => {
121
174
  const { text, voice } = JSON.parse(new TextDecoder().decode(rawBody));
122
- return new Response(JSON.stringify({ audio: "..." }));
175
+ return Response.json({ audio: "..." });
123
176
  }));
124
177
  ```
125
178
 
126
- ### Assertion (low-level)
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
- For full control over the verification flow, use `verifyAssertion` directly:
185
+ ### Assertion
129
186
 
130
- ```typescript
187
+ ```ts
131
188
  import { verifyAssertion } from "@bradford-tech/supabase-integrity-attest";
132
189
 
133
- const result = await verifyAssertion(
134
- { appId: "TEAMID.com.your.bundleid" },
135
- assertion, // base64 CBOR from client
136
- clientData, // the request payload that was signed
137
- storedPublicKeyPem, // from attestation
138
- storedSignCount, // last known counter
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
- // Update stored signCount to result.signCount
211
+ // Attestation only
212
+ import { verifyAttestation, withAttestation } from "@bradford-tech/supabase-integrity-attest/attestation";
142
213
  ```
143
214
 
144
- ### Error handling
215
+ ## Error handling
145
216
 
146
- ```typescript
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, challenge, attestation);
224
+ await verifyAttestation(appInfo, keyId, clientDataHash, attestation);
156
225
  } catch (e) {
157
226
  if (e instanceof AttestationError) {
158
- console.log(e.code); // e.g. "NONCE_MISMATCH", "INVALID_CERTIFICATE_CHAIN"
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
- ```typescript
263
+ ```ts
168
264
  await verifyAttestation(
169
- { appId: "TEAMID.com.your.bundleid", developmentEnv: true },
265
+ { appId: "TEAMID.com.example.app", developmentEnv: true },
170
266
  keyId,
171
- challenge,
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.
@@ -40,7 +40,12 @@ export interface AttestationCbor {
40
40
  };
41
41
  authData: Uint8Array;
42
42
  }
43
- /** Decode an Apple App Attest attestation object from raw CBOR bytes. */
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":"AAaA,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,yEAAyE;AACzE,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,UAAU,GAAG,eAAe,CAyEvE;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,CAkJ5B"}
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"}
@@ -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
- /** Decode an Apple App Attest attestation object from raw CBOR bytes. */
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
- // Verify top-level is a CBOR map
94
- const majorType = (data[0] >> 5) & 0x07;
95
- if (majorType !== 5) {
96
- throw new Error("Expected CBOR map at top level");
97
- }
98
- // Find "fmt" key and read its value
99
- const fmtKeyPos = findCborTextKey(data, "fmt", 0);
100
- if (fmtKeyPos === -1)
101
- throw new Error('Missing "fmt" key');
102
- const fmtKeyEnd = fmtKeyPos + 1 + 3; // 0x63 + "fmt"
103
- const { value: fmt } = readCborText(data, fmtKeyEnd);
104
- // Find "attStmt" key
105
- const attStmtKeyPos = findCborTextKey(data, "attStmt", 0);
106
- if (attStmtKeyPos === -1)
107
- throw new Error('Missing "attStmt" key');
108
- // After "attStmt" key: 0x67 + "attStmt" = 8 bytes
109
- const attStmtValuePos = attStmtKeyPos + 8;
110
- // attStmt value should be a map
111
- const attStmtMajor = (data[attStmtValuePos] >> 5) & 0x07;
112
- if (attStmtMajor !== 5)
113
- throw new Error("attStmt is not a CBOR map");
114
- // Find "x5c" key within attStmt
115
- const x5cKeyPos = findCborTextKey(data, "x5c", attStmtValuePos);
116
- if (x5cKeyPos === -1)
117
- throw new Error('Missing "x5c" key in attStmt');
118
- const x5cValuePos = x5cKeyPos + 4; // 0x63 + "x5c"
119
- // x5c value is an array
120
- const x5cMajor = (data[x5cValuePos] >> 5) & 0x07;
121
- if (x5cMajor !== 4)
122
- throw new Error("x5c is not a CBOR array");
123
- const { value: x5cCount, end: x5cFirstItemPos } = readCborUint(data, x5cValuePos);
124
- // Read each certificate byte string
125
- const x5c = [];
126
- let pos = x5cFirstItemPos;
127
- for (let i = 0; i < x5cCount; i++) {
128
- const { value: cert, end } = readCborBytes(data, pos);
129
- x5c.push(cert);
130
- pos = end;
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
- // After x5c, find "receipt" key
133
- const receiptKeyPos = findCborTextKey(data, "receipt", pos);
134
- if (receiptKeyPos === -1)
135
- throw new Error('Missing "receipt" key in attStmt');
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
- let decoded;
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
- const parsedAuthData = parseAttestationAuthData(authData);
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)) {
@@ -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;IACzB,YAAY,EAAE,UAAU,CAAC;CAC1B;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,UAAU,GAAG,iBAAiB,CAgB1E;AAED,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,UAAU,GACf,mBAAmB,CAiCrB"}
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"}
@@ -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;AAmCF;;;;;;;;;;;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"}
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
- : 401;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bradford-tech/supabase-integrity-attest",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Verify Apple App Attest attestations and assertions using WebCrypto.",
5
5
  "homepage": "https://integrity-attest.bradford.tech",
6
6
  "repository": {