@emilia-protocol/verify 1.0.1 → 1.2.0

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/LICENSE CHANGED
@@ -175,7 +175,7 @@
175
175
 
176
176
  END OF TERMS AND CONDITIONS
177
177
 
178
- Copyright 2024–2026 EMILIA Protocol Contributors
178
+ Copyright 2024–2026 EMILIA Protocol, Inc. and contributors
179
179
 
180
180
  Licensed under the Apache License, Version 2.0 (the "License");
181
181
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -28,6 +28,27 @@ console.log(result);
28
28
  // { valid: true, checks: { version: true, signature: true, anchor: null } }
29
29
  ```
30
30
 
31
+ ## In the browser, edge, or Deno
32
+
33
+ The default entry uses Node's `crypto`. For any runtime with the W3C Web Crypto
34
+ API — every modern browser, Deno, Cloudflare Workers, Vercel Edge — import the
35
+ `/web` build instead. Same inputs, same `{ valid, checks }` output (proven
36
+ byte-for-byte in `web.test.js`); the functions are `async` because Web Crypto is.
37
+
38
+ ```js
39
+ import { verifyReceipt, verifyWebAuthnSignoff } from '@emilia-protocol/verify/web';
40
+
41
+ const r = await verifyReceipt(receipt, publicKey); // Ed25519
42
+ const s = await verifyWebAuthnSignoff(signoff, approverKey, // ECDSA P-256
43
+ { rpId: 'emiliaprotocol.ai' });
44
+ ```
45
+
46
+ This is what powers [emiliaprotocol.ai/verify](https://www.emiliaprotocol.ai/verify):
47
+ a relying party verifies a receipt entirely in their own tab — nothing uploaded,
48
+ no server trusted. Receipts use Ed25519; Class-A device signoffs use ECDSA P-256
49
+ over a WebAuthn assertion (the `/web` build converts the DER signature to the raw
50
+ form Web Crypto expects). Call `isSupported()` to feature-detect.
51
+
31
52
  ## API
32
53
 
33
54
  ### `verifyReceipt(doc, publicKeyBase64url)`
package/index.d.ts CHANGED
@@ -59,3 +59,36 @@ export function verifyReceiptBundle(
59
59
  bundle: Record<string, unknown>,
60
60
  publicKeyBase64url: string
61
61
  ): BundleVerificationResult;
62
+
63
+ export interface WebAuthnSignoffChecks {
64
+ challenge_binding: boolean;
65
+ client_data_type: boolean;
66
+ user_present: boolean;
67
+ user_verified: boolean;
68
+ rp_id_hash: boolean | null;
69
+ signature: boolean;
70
+ }
71
+
72
+ export interface WebAuthnSignoffResult {
73
+ valid: boolean;
74
+ checks: WebAuthnSignoffChecks;
75
+ error?: string;
76
+ }
77
+
78
+ /**
79
+ * Verify a Class A (approver-held key, WebAuthn) signoff fully offline.
80
+ * Proves the device signed SHA-256(JCS(context)) with user verification,
81
+ * against the approver's enrolled P-256 key. Pure math — no network.
82
+ */
83
+ export function verifyWebAuthnSignoff(
84
+ signoff: {
85
+ context: Record<string, unknown>;
86
+ webauthn: {
87
+ authenticator_data: string;
88
+ client_data_json: string;
89
+ signature: string;
90
+ };
91
+ },
92
+ approverPublicKeySpkiB64u: string,
93
+ opts?: { rpId?: string }
94
+ ): WebAuthnSignoffResult;
package/index.js CHANGED
@@ -145,6 +145,122 @@ export function verifyMerkleAnchor(leafHash, proof, expectedRoot) {
145
145
  return current === expectedRoot;
146
146
  }
147
147
 
148
+ // =============================================================================
149
+ // CLASS A SIGNOFF VERIFICATION (WebAuthn, offline)
150
+ // =============================================================================
151
+
152
+ // authenticatorData layout (WebAuthn L2 §6.1): rpIdHash(32) | flags(1) |
153
+ // signCount(4) | ... Flags bit 0 = UP (user present), bit 2 = UV (user
154
+ // verified — biometric/PIN).
155
+ const FLAG_UP = 0x01;
156
+ const FLAG_UV = 0x04;
157
+
158
+ /**
159
+ * Verify a Class A (approver-held key) signoff fully offline.
160
+ *
161
+ * What this proves with pure math, no network, no EP server:
162
+ * - the WebAuthn challenge the device signed equals
163
+ * SHA-256(JCS(context)) for the EXACT context in the signoff — which
164
+ * binds the action hash, nonce, approver, and validity window;
165
+ * - the signature verifies against the approver's enrolled P-256 key;
166
+ * - the authenticator asserted user presence AND user verification
167
+ * (a human with the biometric/PIN was there);
168
+ * - (if rpId supplied) the assertion was scoped to the expected relying
169
+ * party.
170
+ *
171
+ * What it does NOT prove (EP draft §6.3): that the key wasn't revoked
172
+ * after commit time, or what the human SAW when they signed (§11.3).
173
+ *
174
+ * @param {object} signoff - {
175
+ * context: object, // the canonical Authorization Context
176
+ * webauthn: {
177
+ * authenticator_data: string, // b64u
178
+ * client_data_json: string, // b64u
179
+ * signature: string, // b64u (DER ECDSA)
180
+ * }
181
+ * }
182
+ * @param {string} approverPublicKeySpkiB64u - enrolled P-256 key, SPKI DER b64u
183
+ * @param {{ rpId?: string }} [opts]
184
+ * @returns {{ valid: boolean, checks: object, error?: string }}
185
+ */
186
+ export function verifyWebAuthnSignoff(signoff, approverPublicKeySpkiB64u, opts = {}) {
187
+ const checks = {
188
+ challenge_binding: false,
189
+ client_data_type: false,
190
+ user_present: false,
191
+ user_verified: false,
192
+ rp_id_hash: null,
193
+ signature: false,
194
+ };
195
+
196
+ try {
197
+ if (!signoff?.context || !signoff?.webauthn) {
198
+ return { valid: false, checks, error: 'Missing context or webauthn evidence' };
199
+ }
200
+ const { authenticator_data, client_data_json, signature } = signoff.webauthn;
201
+ if (!authenticator_data || !client_data_json || !signature) {
202
+ return { valid: false, checks, error: 'Missing webauthn fields' };
203
+ }
204
+
205
+ // 1. Challenge binding: clientDataJSON.challenge must equal
206
+ // b64u(SHA-256(canonical(context))). The context is re-canonicalized
207
+ // here — tamper any field (amount, approver, nonce) and this fails.
208
+ const clientDataBytes = Buffer.from(client_data_json, 'base64url');
209
+ const clientData = JSON.parse(clientDataBytes.toString('utf8'));
210
+ const expectedChallenge = crypto
211
+ .createHash('sha256')
212
+ .update(canonicalize(signoff.context), 'utf8')
213
+ .digest()
214
+ .toString('base64url');
215
+ checks.challenge_binding = clientData.challenge === expectedChallenge;
216
+
217
+ // 2. Ceremony type must be an assertion, not a registration.
218
+ checks.client_data_type = clientData.type === 'webauthn.get';
219
+
220
+ // 3. Authenticator flags: user present + user verified.
221
+ const authData = Buffer.from(authenticator_data, 'base64url');
222
+ if (authData.length < 37) {
223
+ return { valid: false, checks, error: 'authenticator_data too short' };
224
+ }
225
+ const flags = authData[32];
226
+ checks.user_present = (flags & FLAG_UP) === FLAG_UP;
227
+ checks.user_verified = (flags & FLAG_UV) === FLAG_UV;
228
+
229
+ // 4. Optional rpId scope check.
230
+ if (opts.rpId) {
231
+ const expectedRpIdHash = crypto.createHash('sha256').update(opts.rpId, 'utf8').digest();
232
+ checks.rp_id_hash = expectedRpIdHash.equals(authData.subarray(0, 32));
233
+ }
234
+
235
+ // 5. Signature: ECDSA P-256/SHA-256 over authData || SHA-256(clientDataJSON).
236
+ const signedData = Buffer.concat([
237
+ authData,
238
+ crypto.createHash('sha256').update(clientDataBytes).digest(),
239
+ ]);
240
+ const keyObject = crypto.createPublicKey({
241
+ key: Buffer.from(approverPublicKeySpkiB64u, 'base64url'),
242
+ format: 'der',
243
+ type: 'spki',
244
+ });
245
+ checks.signature = crypto.verify(
246
+ 'sha256',
247
+ signedData,
248
+ keyObject,
249
+ Buffer.from(signature, 'base64url'),
250
+ );
251
+ } catch (e) {
252
+ return { valid: false, checks, error: `WebAuthn verification failed: ${e.message}` };
253
+ }
254
+
255
+ const valid = checks.challenge_binding
256
+ && checks.client_data_type
257
+ && checks.user_present
258
+ && checks.user_verified
259
+ && checks.signature
260
+ && (checks.rp_id_hash === null || checks.rp_id_hash === true);
261
+ return { valid, checks };
262
+ }
263
+
148
264
  // =============================================================================
149
265
  // COMMITMENT PROOF VERIFICATION
150
266
  // =============================================================================
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@emilia-protocol/verify",
3
- "version": "1.0.1",
4
- "description": "Zero-dependency standalone verification for EP trust receipts, Merkle anchors, and commitment proofs. Uses only Node.js built-in crypto. No EP infrastructure required.",
3
+ "version": "1.2.0",
4
+ "description": "Zero-dependency offline verification for EP trust receipts, Merkle anchors, commitment proofs, and Class-A WebAuthn device signoffs. Node build uses built-in crypto; the /web build runs in any browser, edge, or Deno via Web Crypto. No EP infrastructure required.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "index.js",
@@ -10,10 +10,14 @@
10
10
  ".": {
11
11
  "import": "./index.js",
12
12
  "types": "./index.d.ts"
13
+ },
14
+ "./web": {
15
+ "import": "./web.js"
13
16
  }
14
17
  },
15
18
  "files": [
16
19
  "index.js",
20
+ "web.js",
17
21
  "index.d.ts",
18
22
  "README.md",
19
23
  "LICENSE"
@@ -41,8 +45,8 @@
41
45
  "bugs": {
42
46
  "url": "https://github.com/emiliaprotocol/emilia-protocol/issues"
43
47
  },
44
- "author": "Emilia Protocol Contributors",
48
+ "author": "EMILIA Protocol, Inc.",
45
49
  "scripts": {
46
- "test": "node --test test.js"
50
+ "test": "node --test test.js web.test.js"
47
51
  }
48
52
  }
package/web.js ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * @emilia-protocol/verify/web — Zero-Dependency Trust Verification (Web Crypto)
3
+ *
4
+ * The browser/edge/Deno counterpart to index.js. Identical verification
5
+ * semantics, but built on the W3C Web Crypto API (globalThis.crypto.subtle)
6
+ * instead of Node's `crypto` module — so a receipt can be verified entirely
7
+ * inside the relying party's own browser tab, with nothing uploaded and no
8
+ * server trusted. Same input, same `{ valid, checks }` output as index.js
9
+ * (proven byte-for-byte in web.test.js).
10
+ *
11
+ * Pure ESM, zero dependencies. Functions are async because Web Crypto is.
12
+ *
13
+ * @license Apache-2.0
14
+ */
15
+
16
+ const SUPPORTED_VERSIONS = ['EP-RECEIPT-v1'];
17
+ const SUPPORTED_PROOF_VERSIONS = ['EP-PROOF-v1'];
18
+
19
+ const subtle = globalThis.crypto?.subtle;
20
+
21
+ // =============================================================================
22
+ // PRIMITIVES (pure — no Node Buffer, no Node crypto)
23
+ // =============================================================================
24
+
25
+ const ENC = new TextEncoder();
26
+ const DEC = new TextDecoder();
27
+
28
+ function utf8(str) {
29
+ return ENC.encode(str);
30
+ }
31
+
32
+ // base64url (and tolerant of standard base64) → Uint8Array.
33
+ function b64uToBytes(b64u) {
34
+ const b64 = b64u.replace(/-/g, '+').replace(/_/g, '/');
35
+ const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4));
36
+ const bin = atob(b64 + pad);
37
+ const out = new Uint8Array(bin.length);
38
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
39
+ return out;
40
+ }
41
+
42
+ function bytesToB64u(bytes) {
43
+ let bin = '';
44
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
45
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
46
+ }
47
+
48
+ function bytesToHex(bytes) {
49
+ let hex = '';
50
+ for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
51
+ return hex;
52
+ }
53
+
54
+ // Same recursive canonical JSON as index.js / lib/guard-policies.js — depth-first
55
+ // key sort at every level. Signer and verifier MUST compute byte-identical bytes.
56
+ function canonicalize(value) {
57
+ if (value === null || value === undefined) return JSON.stringify(value);
58
+ if (Array.isArray(value)) {
59
+ return `[${value.map(canonicalize).join(',')}]`;
60
+ }
61
+ if (typeof value === 'object') {
62
+ return `{${Object.keys(value)
63
+ .sort()
64
+ .map((k) => JSON.stringify(k) + ':' + canonicalize(value[k]))
65
+ .join(',')}}`;
66
+ }
67
+ return JSON.stringify(value);
68
+ }
69
+
70
+ async function sha256Bytes(bytes) {
71
+ return new Uint8Array(await subtle.digest('SHA-256', bytes));
72
+ }
73
+
74
+ async function sha256Hex(str) {
75
+ return bytesToHex(await sha256Bytes(utf8(str)));
76
+ }
77
+
78
+ async function hashPairHex(a, b) {
79
+ const sorted = [a, b].sort();
80
+ return sha256Hex(sorted[0] + sorted[1]);
81
+ }
82
+
83
+ function concatBytes(a, b) {
84
+ const out = new Uint8Array(a.length + b.length);
85
+ out.set(a, 0);
86
+ out.set(b, a.length);
87
+ return out;
88
+ }
89
+
90
+ function bytesEqual(a, b) {
91
+ if (a.length !== b.length) return false;
92
+ let diff = 0;
93
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
94
+ return diff === 0;
95
+ }
96
+
97
+ // WebAuthn ECDSA signatures are ASN.1 DER: SEQUENCE { INTEGER r, INTEGER s }.
98
+ // Web Crypto's ECDSA verify wants raw r‖s (two fixed-width 32-byte integers).
99
+ // Node's crypto.verify accepts DER directly, which is why index.js needs no
100
+ // conversion — the browser does. Returns a 64-byte Uint8Array, or null if the
101
+ // DER is malformed.
102
+ function derEcdsaToRawP256(der) {
103
+ let i = 0;
104
+ if (der[i++] !== 0x30) return null; // SEQUENCE
105
+ // sequence length (short or long form) — we don't need the value, just skip it.
106
+ if (der[i] & 0x80) i += 1 + (der[i] & 0x7f);
107
+ else i += 1;
108
+
109
+ const readInt = () => {
110
+ if (der[i++] !== 0x02) return null; // INTEGER
111
+ let len = der[i++];
112
+ let val = der.subarray(i, i + len);
113
+ i += len;
114
+ // strip any leading 0x00 sign-padding
115
+ while (val.length > 1 && val[0] === 0x00) val = val.subarray(1);
116
+ if (val.length > 32) return null; // not a P-256 component
117
+ const padded = new Uint8Array(32);
118
+ padded.set(val, 32 - val.length); // left-pad to 32 bytes
119
+ return padded;
120
+ };
121
+
122
+ const r = readInt();
123
+ if (!r) return null;
124
+ const s = readInt();
125
+ if (!s) return null;
126
+ return concatBytes(r, s);
127
+ }
128
+
129
+ // =============================================================================
130
+ // RECEIPT VERIFICATION (Ed25519)
131
+ // =============================================================================
132
+
133
+ /**
134
+ * Verify an EP receipt document in the browser. Mirrors index.js verifyReceipt.
135
+ * @param {object} doc
136
+ * @param {string} publicKeyBase64url - Ed25519 public key (SPKI DER, base64url)
137
+ * @returns {Promise<{valid:boolean, checks:{version:boolean,signature:boolean,anchor:boolean|null}, error?:string}>}
138
+ */
139
+ export async function verifyReceipt(doc, publicKeyBase64url) {
140
+ const checks = { version: false, signature: false, anchor: null };
141
+
142
+ if (!doc?.['@version'] || !SUPPORTED_VERSIONS.includes(doc['@version'])) {
143
+ return { valid: false, checks, error: `Unsupported version: ${doc?.['@version']}` };
144
+ }
145
+ checks.version = true;
146
+
147
+ if (!doc.payload || !doc.signature?.value || !doc.signature?.algorithm) {
148
+ return { valid: false, checks, error: 'Missing payload or signature' };
149
+ }
150
+
151
+ try {
152
+ const payloadBytes = utf8(canonicalize(doc.payload));
153
+ const key = await subtle.importKey(
154
+ 'spki', b64uToBytes(publicKeyBase64url), { name: 'Ed25519' }, false, ['verify'],
155
+ );
156
+ checks.signature = await subtle.verify(
157
+ { name: 'Ed25519' }, key, b64uToBytes(doc.signature.value), payloadBytes,
158
+ );
159
+ } catch (e) {
160
+ return { valid: false, checks, error: `Signature verification failed: ${e.message}` };
161
+ }
162
+
163
+ if (doc.anchor?.merkle_proof && doc.anchor?.leaf_hash && doc.anchor?.merkle_root) {
164
+ checks.anchor = await verifyMerkleAnchor(doc.anchor.leaf_hash, doc.anchor.merkle_proof, doc.anchor.merkle_root);
165
+ }
166
+
167
+ const valid = checks.version && checks.signature && (checks.anchor === null || checks.anchor === true);
168
+ return { valid, checks };
169
+ }
170
+
171
+ // =============================================================================
172
+ // MERKLE ANCHOR VERIFICATION
173
+ // =============================================================================
174
+
175
+ /** @returns {Promise<boolean>} */
176
+ export async function verifyMerkleAnchor(leafHash, proof, expectedRoot) {
177
+ if (typeof leafHash !== 'string' || !leafHash) return false;
178
+ if (typeof expectedRoot !== 'string' || !expectedRoot) return false;
179
+ if (!Array.isArray(proof)) return false;
180
+ if (proof.length > 20) return false;
181
+
182
+ let current = leafHash;
183
+ for (const step of proof) {
184
+ if (!step || typeof step.hash !== 'string') return false;
185
+ if (step.position !== 'left' && step.position !== 'right') return false;
186
+ current = step.position === 'left'
187
+ ? await hashPairHex(step.hash, current)
188
+ : await hashPairHex(current, step.hash);
189
+ }
190
+ return current === expectedRoot;
191
+ }
192
+
193
+ // =============================================================================
194
+ // CLASS A SIGNOFF VERIFICATION (WebAuthn, offline, ECDSA P-256)
195
+ // =============================================================================
196
+
197
+ const FLAG_UP = 0x01;
198
+ const FLAG_UV = 0x04;
199
+
200
+ /**
201
+ * Verify a Class A (approver-held key) signoff fully offline, in the browser.
202
+ * Mirrors index.js verifyWebAuthnSignoff.
203
+ * @returns {Promise<{valid:boolean, checks:object, error?:string}>}
204
+ */
205
+ export async function verifyWebAuthnSignoff(signoff, approverPublicKeySpkiB64u, opts = {}) {
206
+ const checks = {
207
+ challenge_binding: false,
208
+ client_data_type: false,
209
+ user_present: false,
210
+ user_verified: false,
211
+ rp_id_hash: null,
212
+ signature: false,
213
+ };
214
+
215
+ try {
216
+ if (!signoff?.context || !signoff?.webauthn) {
217
+ return { valid: false, checks, error: 'Missing context or webauthn evidence' };
218
+ }
219
+ const { authenticator_data, client_data_json, signature } = signoff.webauthn;
220
+ if (!authenticator_data || !client_data_json || !signature) {
221
+ return { valid: false, checks, error: 'Missing webauthn fields' };
222
+ }
223
+
224
+ // 1. Challenge binding: clientData.challenge === b64u(SHA-256(canonical(context))).
225
+ const clientDataBytes = b64uToBytes(client_data_json);
226
+ const clientData = JSON.parse(DEC.decode(clientDataBytes));
227
+ const expectedChallenge = bytesToB64u(await sha256Bytes(utf8(canonicalize(signoff.context))));
228
+ checks.challenge_binding = clientData.challenge === expectedChallenge;
229
+
230
+ // 2. Ceremony type must be an assertion.
231
+ checks.client_data_type = clientData.type === 'webauthn.get';
232
+
233
+ // 3. Authenticator flags: user present + user verified.
234
+ const authData = b64uToBytes(authenticator_data);
235
+ if (authData.length < 37) {
236
+ return { valid: false, checks, error: 'authenticator_data too short' };
237
+ }
238
+ const flags = authData[32];
239
+ checks.user_present = (flags & FLAG_UP) === FLAG_UP;
240
+ checks.user_verified = (flags & FLAG_UV) === FLAG_UV;
241
+
242
+ // 4. Optional rpId scope check.
243
+ if (opts.rpId) {
244
+ const expectedRpIdHash = await sha256Bytes(utf8(opts.rpId));
245
+ checks.rp_id_hash = bytesEqual(expectedRpIdHash, authData.subarray(0, 32));
246
+ }
247
+
248
+ // 5. Signature: ECDSA P-256/SHA-256 over authData ‖ SHA-256(clientDataJSON).
249
+ const signedData = concatBytes(authData, await sha256Bytes(clientDataBytes));
250
+ const rawSig = derEcdsaToRawP256(b64uToBytes(signature));
251
+ if (!rawSig) {
252
+ return { valid: false, checks, error: 'Malformed ECDSA signature' };
253
+ }
254
+ const key = await subtle.importKey(
255
+ 'spki', b64uToBytes(approverPublicKeySpkiB64u),
256
+ { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'],
257
+ );
258
+ checks.signature = await subtle.verify(
259
+ { name: 'ECDSA', hash: 'SHA-256' }, key, rawSig, signedData,
260
+ );
261
+ } catch (e) {
262
+ return { valid: false, checks, error: `WebAuthn verification failed: ${e.message}` };
263
+ }
264
+
265
+ const valid = checks.challenge_binding
266
+ && checks.client_data_type
267
+ && checks.user_present
268
+ && checks.user_verified
269
+ && checks.signature
270
+ && (checks.rp_id_hash === null || checks.rp_id_hash === true);
271
+ return { valid, checks };
272
+ }
273
+
274
+ // =============================================================================
275
+ // COMMITMENT PROOF VERIFICATION (Ed25519)
276
+ // =============================================================================
277
+
278
+ /** @returns {Promise<{valid:boolean, claim:object|null, error?:string}>} */
279
+ export async function verifyCommitmentProof(proof, publicKeyBase64url) {
280
+ if (!proof?.['@version'] || !SUPPORTED_PROOF_VERSIONS.includes(proof['@version'])) {
281
+ return { valid: false, claim: null, error: `Unsupported version: ${proof?.['@version']}` };
282
+ }
283
+ if (proof.expires_at && new Date(proof.expires_at) < new Date()) {
284
+ return { valid: false, claim: proof.claim, error: 'Proof has expired' };
285
+ }
286
+ if (publicKeyBase64url && proof.signature) {
287
+ try {
288
+ const key = await subtle.importKey(
289
+ 'spki', b64uToBytes(publicKeyBase64url), { name: 'Ed25519' }, false, ['verify'],
290
+ );
291
+ const ok = await subtle.verify(
292
+ { name: 'Ed25519' }, key, b64uToBytes(proof.signature.value), utf8(canonicalize(proof.commitment)),
293
+ );
294
+ if (!ok) return { valid: false, claim: proof.claim, error: 'Invalid signature' };
295
+ } catch (e) {
296
+ return { valid: false, claim: proof.claim, error: `Signature check failed: ${e.message}` };
297
+ }
298
+ }
299
+ return { valid: true, claim: proof.claim };
300
+ }
301
+
302
+ // =============================================================================
303
+ // BUNDLE VERIFICATION
304
+ // =============================================================================
305
+
306
+ /** @returns {Promise<{valid:boolean, total:number, verified:number, failed:string[]}>} */
307
+ export async function verifyReceiptBundle(bundle, publicKeyBase64url) {
308
+ if (bundle?.['@version'] !== 'EP-BUNDLE-v1') {
309
+ return { valid: false, total: 0, verified: 0, failed: ['Invalid bundle version'] };
310
+ }
311
+ const failed = [];
312
+ let verified = 0;
313
+ for (let i = 0; i < bundle.documents.length; i++) {
314
+ const result = await verifyReceipt(bundle.documents[i], publicKeyBase64url);
315
+ if (result.valid) verified++;
316
+ else failed.push(`doc[${i}]: ${result.error || 'verification failed'}`);
317
+ }
318
+ return { valid: failed.length === 0, total: bundle.documents.length, verified, failed };
319
+ }
320
+
321
+ /** True if Web Crypto with the algorithms EP needs is available in this runtime. */
322
+ export function isSupported() {
323
+ return Boolean(subtle && typeof atob === 'function' && typeof btoa === 'function');
324
+ }