@bradford-tech/supabase-integrity-attest 0.8.1 → 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.
Files changed (2) hide show
  1. package/README.md +181 -75
  2. package/package.json +1 -1
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bradford-tech/supabase-integrity-attest",
3
- "version": "0.8.1",
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": {