@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 +1 -1
- package/README.md +21 -0
- package/index.d.ts +33 -0
- package/index.js +116 -0
- package/package.json +8 -4
- package/web.js +324 -0
package/LICENSE
CHANGED
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
|
|
176
176
|
END OF TERMS AND CONDITIONS
|
|
177
177
|
|
|
178
|
-
Copyright 2024–2026 EMILIA Protocol
|
|
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
|
|
4
|
-
"description": "Zero-dependency
|
|
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": "
|
|
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
|
+
}
|