@mikeargento/bitgraph-verify 1.0.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.
@@ -0,0 +1,844 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+ /**
3
+ * bitgraph-core Verifier
4
+ *
5
+ * Deterministic, offline verification of BitGraphProof structures.
6
+ *
7
+ * Verification steps (in order):
8
+ * 1. Structural validation — required fields present and typed correctly
9
+ * 2. Artifact check — SHA-256 of provided bytes matches proof digest
10
+ * 3. Signed body reconstruction — reproduce the exact canonical bytes
11
+ * 4. Signature verification — Ed25519 signature over canonical bytes
12
+ * 5. Policy checks — enforcement tier, measurement, key, counter,
13
+ * time, attestation constraints
14
+ *
15
+ * Design constraints:
16
+ * - No network calls. All verification is local and deterministic.
17
+ * - Constant-time comparison for digests and signatures where possible.
18
+ * - Returns { valid: false, reason: "..." } rather than throwing for
19
+ * expected verification failures (invalid inputs throw).
20
+ * - attestation.reportB64 is stored in the proof but not interpreted here;
21
+ * platform-specific attestation verification belongs in adapter packages.
22
+ *
23
+ * Trust model:
24
+ * - environment.enforcement is tamper-evident (signed) but self-reported.
25
+ * - Verifiers requiring measured-tee guarantees MUST combine
26
+ * requireEnforcement with allowedMeasurements and requireAttestation.
27
+ * - The measurement allowlist is the primary cryptographic trust anchor.
28
+ *
29
+ * ## Compliant Verifier Requirements
30
+ *
31
+ * Any implementation that claims to verify BitGraph proofs MUST perform ALL of
32
+ * the following steps:
33
+ *
34
+ * 1. **Ed25519 signature verification** — Reconstruct the canonical
35
+ * SignedBody from proof fields (version, artifact, commit, publicKeyB64,
36
+ * enforcement, measurement, attestationFormat, and optional actor,
37
+ * attribution, policy, principal). Canonicalize deterministically and
38
+ * verify signer.signatureB64 over those bytes using signer.publicKeyB64.
39
+ *
40
+ * 2. **Attestation binding** — If environment.attestation is present, the
41
+ * decoded attestation report MUST contain user_data equal to SHA-256 of
42
+ * the same canonical SignedBody bytes. This binds the TEE hardware
43
+ * attestation to the exact signed content. Platform-specific report
44
+ * parsing is out of scope for this library but MUST be performed by
45
+ * production verifiers.
46
+ *
47
+ * 3. **Slot allocation verification** — If slotAllocation is present:
48
+ * (a) verify Ed25519 signature over the canonical slot body,
49
+ * (b) verify commit.slotHashB64 == SHA-256(canonicalize(slotBody)),
50
+ * (c) verify slot.nonceB64 == commit.nonceB64,
51
+ * (d) verify slot.counter < commit.counter.
52
+ * The slot body includes chainId when present.
53
+ *
54
+ * 4. **Chain linking (prevB64)** — If commit.prevB64 is present, verify it
55
+ * equals SHA-256(canonicalize(previousProof)) of the immediately
56
+ * preceding proof in the same chain. Single-proof verifiers may skip
57
+ * this but MUST document that chain integrity is not verified.
58
+ *
59
+ * 5. **Measurement policy enforcement** — Maintain an allowlist of trusted
60
+ * measurements (PCR0 hashes). Proofs with unknown measurements MUST be
61
+ * rejected in production. The measurement is signed (inside SignedBody)
62
+ * so it is tamper-evident, but self-reported — attestation verification
63
+ * (step 2) makes it trustworthy.
64
+ *
65
+ * 6. **metadata is advisory and unsigned** — proof.metadata is NOT covered
66
+ * by the Ed25519 signature. It MUST NOT be treated as authenticated.
67
+ * Only fields inside the SignedBody are cryptographically bound.
68
+ * Attribution, policy, principal, and actor ARE in the signed body.
69
+ */
70
+ import { createVerify, createHash } from "node:crypto";
71
+ import { verifyAsync as ed25519VerifyAsync } from "@noble/ed25519";
72
+ import { sha256 } from "@noble/hashes/sha256";
73
+ import { canonicalize, constantTimeEqual } from "./canonical.js";
74
+ /**
75
+ * Verify a BitGraphProof against the original input bytes.
76
+ *
77
+ * @param opts.proof - The proof to verify
78
+ * @param opts.bytes - The original input bytes that were committed
79
+ * @param opts.trustAnchors - Optional policy constraints
80
+ *
81
+ * @throws {TypeError} if `proof` or `bytes` are not the expected types
82
+ * @returns `{ valid: true }` on success, `{ valid: false, reason }` on failure
83
+ */
84
+ export async function verify(opts) {
85
+ const { proof, bytes, trustAnchors } = opts;
86
+ // ------------------------------------------------------------------
87
+ // 1. Structural validation
88
+ // ------------------------------------------------------------------
89
+ const structureError = validateStructure(proof);
90
+ if (structureError !== null) {
91
+ return fail(structureError);
92
+ }
93
+ // ------------------------------------------------------------------
94
+ // 2. Artifact check: SHA-256 of provided bytes must match proof digest
95
+ // ------------------------------------------------------------------
96
+ const computedDigest = sha256(bytes);
97
+ let proofDigest;
98
+ try {
99
+ proofDigest = fromBase64(proof.artifact.digestB64);
100
+ }
101
+ catch {
102
+ return fail("artifact.digestB64 is not valid base64");
103
+ }
104
+ if (!constantTimeEqual(computedDigest, proofDigest)) {
105
+ return fail("artifact digest mismatch: the provided bytes do not match the committed digest");
106
+ }
107
+ // ------------------------------------------------------------------
108
+ // 3. Reconstruct canonical signed body
109
+ // ------------------------------------------------------------------
110
+ let publicKeyBytes;
111
+ try {
112
+ publicKeyBytes = fromBase64(proof.signer.publicKeyB64);
113
+ }
114
+ catch {
115
+ return fail("signer.publicKeyB64 is not valid base64");
116
+ }
117
+ if (publicKeyBytes.length !== 32) {
118
+ return fail(`signer.publicKeyB64 decodes to ${publicKeyBytes.length} bytes; expected 32 (Ed25519)`);
119
+ }
120
+ const signedBody = {
121
+ version: proof.version,
122
+ artifact: proof.artifact,
123
+ commit: proof.commit,
124
+ publicKeyB64: proof.signer.publicKeyB64,
125
+ enforcement: proof.environment.enforcement,
126
+ measurement: proof.environment.measurement,
127
+ };
128
+ // Include attestationFormat in signed body when present
129
+ if (proof.environment.attestation !== undefined) {
130
+ signedBody.attestationFormat = proof.environment.attestation.format;
131
+ }
132
+ // Include actor in signed body when agency is present
133
+ if (proof.agency !== undefined) {
134
+ signedBody.actor = proof.agency.actor;
135
+ }
136
+ // Include policy binding in signed body when present
137
+ if (proof.policy !== undefined) {
138
+ signedBody.policy = proof.policy;
139
+ }
140
+ // Include attribution in signed body when present
141
+ if (proof.attribution !== undefined) {
142
+ signedBody.attribution = proof.attribution;
143
+ }
144
+ const canonicalBytes = canonicalize(signedBody);
145
+ // ------------------------------------------------------------------
146
+ // 4. Signature verification
147
+ // ------------------------------------------------------------------
148
+ let signatureBytes;
149
+ try {
150
+ signatureBytes = fromBase64(proof.signer.signatureB64);
151
+ }
152
+ catch {
153
+ return fail("signer.signatureB64 is not valid base64");
154
+ }
155
+ if (signatureBytes.length !== 64) {
156
+ return fail(`signer.signatureB64 decodes to ${signatureBytes.length} bytes; expected 64 (Ed25519)`);
157
+ }
158
+ let signatureValid;
159
+ try {
160
+ signatureValid = await ed25519VerifyAsync(signatureBytes, canonicalBytes, publicKeyBytes);
161
+ }
162
+ catch (err) {
163
+ return fail(`signature verification error: ${err instanceof Error ? err.message : String(err)}`);
164
+ }
165
+ if (!signatureValid) {
166
+ return fail("signature verification failed: signature does not match");
167
+ }
168
+ // ------------------------------------------------------------------
169
+ // 4b. Agency verification (P-256 device signature)
170
+ // ------------------------------------------------------------------
171
+ if (proof.agency !== undefined) {
172
+ const agencyError = verifyAgency(proof);
173
+ if (agencyError !== null) {
174
+ return fail(agencyError);
175
+ }
176
+ }
177
+ // ------------------------------------------------------------------
178
+ // 4c. Slot allocation verification (BitGraph causal ordering)
179
+ // ------------------------------------------------------------------
180
+ if (proof.slotAllocation !== undefined) {
181
+ const slotError = await verifySlotAllocation(proof);
182
+ if (slotError !== null) {
183
+ return fail(slotError);
184
+ }
185
+ }
186
+ // ------------------------------------------------------------------
187
+ // 4d. Epoch link verification (cross-epoch lineage)
188
+ // ------------------------------------------------------------------
189
+ if (proof.commit.epochLink !== undefined) {
190
+ const epochLinkError = verifyEpochLink(proof);
191
+ if (epochLinkError !== null) {
192
+ return fail(epochLinkError);
193
+ }
194
+ }
195
+ // ------------------------------------------------------------------
196
+ // 5. Policy checks
197
+ // ------------------------------------------------------------------
198
+ if (trustAnchors !== undefined) {
199
+ const policyError = checkPolicy(proof, trustAnchors);
200
+ if (policyError !== null) {
201
+ return fail(policyError);
202
+ }
203
+ }
204
+ return { valid: true };
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Structural validation
208
+ // ---------------------------------------------------------------------------
209
+ const VALID_ENFORCEMENT_TIERS = new Set([
210
+ "stub",
211
+ "hw-key",
212
+ "measured-tee",
213
+ ]);
214
+ function validateStructure(proof) {
215
+ if (proof === null || typeof proof !== "object") {
216
+ return "proof must be an object";
217
+ }
218
+ const p = proof;
219
+ if (p["version"] !== "bitgraph/1") {
220
+ return `unsupported proof version: ${String(p["version"])}`;
221
+ }
222
+ // artifact
223
+ if (!isObject(p["artifact"]))
224
+ return "proof.artifact is missing or not an object";
225
+ const artifact = p["artifact"];
226
+ if (artifact["hashAlg"] !== "sha256") {
227
+ return `unsupported hashAlg: ${String(artifact["hashAlg"])}`;
228
+ }
229
+ if (typeof artifact["digestB64"] !== "string" || artifact["digestB64"].length === 0) {
230
+ return "proof.artifact.digestB64 must be a non-empty string";
231
+ }
232
+ // commit
233
+ if (!isObject(p["commit"]))
234
+ return "proof.commit is missing or not an object";
235
+ const commit = p["commit"];
236
+ if (typeof commit["nonceB64"] !== "string" || commit["nonceB64"].length === 0) {
237
+ return "proof.commit.nonceB64 must be a non-empty string";
238
+ }
239
+ if (commit["counter"] !== undefined && typeof commit["counter"] !== "string") {
240
+ return "proof.commit.counter must be a string when present";
241
+ }
242
+ if (commit["time"] !== undefined) {
243
+ if (typeof commit["time"] !== "number" || !Number.isFinite(commit["time"]) || commit["time"] < 0) {
244
+ return "proof.commit.time must be a non-negative finite number when present";
245
+ }
246
+ }
247
+ if (commit["prevB64"] !== undefined && typeof commit["prevB64"] !== "string") {
248
+ return "proof.commit.prevB64 must be a string when present";
249
+ }
250
+ if (commit["epochId"] !== undefined && typeof commit["epochId"] !== "string") {
251
+ return "proof.commit.epochId must be a string when present";
252
+ }
253
+ // signer
254
+ if (!isObject(p["signer"]))
255
+ return "proof.signer is missing or not an object";
256
+ const signer = p["signer"];
257
+ if (typeof signer["publicKeyB64"] !== "string" || signer["publicKeyB64"].length === 0) {
258
+ return "proof.signer.publicKeyB64 must be a non-empty string";
259
+ }
260
+ if (typeof signer["signatureB64"] !== "string" || signer["signatureB64"].length === 0) {
261
+ return "proof.signer.signatureB64 must be a non-empty string";
262
+ }
263
+ // environment
264
+ if (!isObject(p["environment"]))
265
+ return "proof.environment is missing or not an object";
266
+ const env = p["environment"];
267
+ if (typeof env["enforcement"] !== "string" || !VALID_ENFORCEMENT_TIERS.has(env["enforcement"])) {
268
+ return `proof.environment.enforcement must be one of: stub, hw-key, measured-tee`;
269
+ }
270
+ if (typeof env["measurement"] !== "string" || env["measurement"].length === 0) {
271
+ return "proof.environment.measurement must be a non-empty string";
272
+ }
273
+ // environment.attestation (optional)
274
+ if (env["attestation"] !== undefined) {
275
+ if (!isObject(env["attestation"])) {
276
+ return "proof.environment.attestation must be an object when present";
277
+ }
278
+ const att = env["attestation"];
279
+ if (typeof att["format"] !== "string" || att["format"].length === 0) {
280
+ return "proof.environment.attestation.format must be a non-empty string";
281
+ }
282
+ if (typeof att["reportB64"] !== "string" || att["reportB64"].length === 0) {
283
+ return "proof.environment.attestation.reportB64 must be a non-empty string";
284
+ }
285
+ }
286
+ return null;
287
+ }
288
+ // ---------------------------------------------------------------------------
289
+ // Policy enforcement
290
+ // ---------------------------------------------------------------------------
291
+ function checkPolicy(proof, policy) {
292
+ // Enforcement tier check
293
+ if (policy.requireEnforcement !== undefined) {
294
+ if (proof.environment.enforcement !== policy.requireEnforcement) {
295
+ return `enforcement tier "${proof.environment.enforcement}" does not meet required tier "${policy.requireEnforcement}"`;
296
+ }
297
+ }
298
+ // Measurement allowlist
299
+ if (policy.allowedMeasurements !== undefined &&
300
+ policy.allowedMeasurements.length > 0) {
301
+ if (!policy.allowedMeasurements.includes(proof.environment.measurement)) {
302
+ return `measurement "${proof.environment.measurement}" is not in the allowed set`;
303
+ }
304
+ }
305
+ // Public key allowlist
306
+ if (policy.allowedPublicKeys !== undefined &&
307
+ policy.allowedPublicKeys.length > 0) {
308
+ if (!policy.allowedPublicKeys.includes(proof.signer.publicKeyB64)) {
309
+ return "proof public key is not in the allowed set";
310
+ }
311
+ }
312
+ // Attestation required
313
+ if (policy.requireAttestation === true) {
314
+ if (proof.environment.attestation === undefined) {
315
+ return "policy requires attestation but proof has none";
316
+ }
317
+ }
318
+ // Attestation format allowlist
319
+ if (policy.requireAttestationFormat !== undefined &&
320
+ policy.requireAttestationFormat.length > 0) {
321
+ if (proof.environment.attestation === undefined) {
322
+ return "policy requires attestation format but proof has no attestation";
323
+ }
324
+ if (!policy.requireAttestationFormat.includes(proof.environment.attestation.format)) {
325
+ return `attestation format "${proof.environment.attestation.format}" is not in the required set`;
326
+ }
327
+ }
328
+ // Counter checks
329
+ if (policy.minCounter !== undefined || policy.maxCounter !== undefined) {
330
+ if (proof.commit.counter === undefined) {
331
+ return "policy requires a counter but proof has none";
332
+ }
333
+ let proofCounter;
334
+ try {
335
+ proofCounter = BigInt(proof.commit.counter);
336
+ }
337
+ catch {
338
+ return "could not parse proof counter value as integer";
339
+ }
340
+ if (policy.minCounter !== undefined) {
341
+ let minCounter;
342
+ try {
343
+ minCounter = BigInt(policy.minCounter);
344
+ }
345
+ catch {
346
+ return "could not parse policy minCounter as integer";
347
+ }
348
+ if (proofCounter < minCounter) {
349
+ return `counter ${proof.commit.counter} is below minimum ${policy.minCounter}`;
350
+ }
351
+ }
352
+ if (policy.maxCounter !== undefined) {
353
+ let maxCounter;
354
+ try {
355
+ maxCounter = BigInt(policy.maxCounter);
356
+ }
357
+ catch {
358
+ return "could not parse policy maxCounter as integer";
359
+ }
360
+ if (proofCounter > maxCounter) {
361
+ return `counter ${proof.commit.counter} is above maximum ${policy.maxCounter}`;
362
+ }
363
+ }
364
+ }
365
+ // Time checks
366
+ if (policy.minTime !== undefined || policy.maxTime !== undefined) {
367
+ if (proof.commit.time === undefined) {
368
+ return "policy requires a time field but proof has none";
369
+ }
370
+ if (policy.minTime !== undefined && proof.commit.time < policy.minTime) {
371
+ return `commit time ${proof.commit.time} is before minimum ${policy.minTime}`;
372
+ }
373
+ if (policy.maxTime !== undefined && proof.commit.time > policy.maxTime) {
374
+ return `commit time ${proof.commit.time} is after maximum ${policy.maxTime}`;
375
+ }
376
+ }
377
+ // Epoch ID check
378
+ if (policy.requireEpochId === true) {
379
+ if (proof.commit.epochId === undefined || proof.commit.epochId.length === 0) {
380
+ return "policy requires epochId but proof has none";
381
+ }
382
+ }
383
+ // Actor required check
384
+ if (policy.requireActor === true) {
385
+ if (proof.agency === undefined) {
386
+ return "policy requires actor (agency) but proof has none";
387
+ }
388
+ }
389
+ // Actor key ID allowlist
390
+ if (policy.allowedActorKeyIds !== undefined &&
391
+ policy.allowedActorKeyIds.length > 0) {
392
+ if (proof.agency === undefined) {
393
+ return "policy requires allowed actor key ID but proof has no agency";
394
+ }
395
+ if (!policy.allowedActorKeyIds.includes(proof.agency.actor.keyId)) {
396
+ return "actor key ID is not in the allowed set";
397
+ }
398
+ }
399
+ // Actor provider allowlist
400
+ if (policy.allowedActorProviders !== undefined &&
401
+ policy.allowedActorProviders.length > 0) {
402
+ if (proof.agency === undefined) {
403
+ return "policy requires allowed actor provider but proof has no agency";
404
+ }
405
+ if (!policy.allowedActorProviders.includes(proof.agency.actor.provider)) {
406
+ return `actor provider "${proof.agency.actor.provider}" is not in the allowed set`;
407
+ }
408
+ }
409
+ // Slot allocation required (BitGraph causal ordering)
410
+ if (policy.requireSlot === true) {
411
+ if (proof.slotAllocation === undefined) {
412
+ return "policy requires slotAllocation (BitGraph causal slot) but proof has none";
413
+ }
414
+ // Slot verification itself is handled in step 4c (before policy checks),
415
+ // so by this point the slot has already been validated if present.
416
+ }
417
+ return null;
418
+ }
419
+ // ---------------------------------------------------------------------------
420
+ // Agency verification (P-256 device signature)
421
+ // ---------------------------------------------------------------------------
422
+ /**
423
+ * Verify the agency envelope: P-256 signature, structural consistency,
424
+ * and artifact binding.
425
+ *
426
+ * Checks:
427
+ * 1. Structural validation of agency fields
428
+ * 2. actor.keyId == hex(SHA-256(SPKI DER pubkey bytes))
429
+ * 3. authorization.actorKeyId == actor.keyId
430
+ * 4. authorization.artifactHash == proof.artifact.digestB64
431
+ * 5. authorization.purpose == "bitgraph/commit-authorize/v1"
432
+ * 6. P-256 signature over canonical authorization payload
433
+ */
434
+ function verifyAgency(proof) {
435
+ const agency = proof.agency;
436
+ const { actor, authorization } = agency;
437
+ const isWebAuthn = "format" in authorization && authorization.format === "webauthn";
438
+ // 1. Structural validation
439
+ if (typeof actor.keyId !== "string" || actor.keyId.length === 0) {
440
+ return "agency.actor.keyId must be a non-empty string";
441
+ }
442
+ if (typeof actor.publicKeyB64 !== "string" || actor.publicKeyB64.length === 0) {
443
+ return "agency.actor.publicKeyB64 must be a non-empty string";
444
+ }
445
+ if (actor.algorithm !== "ES256") {
446
+ return `agency.actor.algorithm must be "ES256", got "${String(actor.algorithm)}"`;
447
+ }
448
+ if (typeof actor.provider !== "string" || actor.provider.length === 0) {
449
+ return "agency.actor.provider must be a non-empty string";
450
+ }
451
+ if (authorization.purpose !== "bitgraph/commit-authorize/v1") {
452
+ return `agency.authorization.purpose must be "bitgraph/commit-authorize/v1", got "${String(authorization.purpose)}"`;
453
+ }
454
+ if (typeof authorization.signatureB64 !== "string" || authorization.signatureB64.length === 0) {
455
+ return "agency.authorization.signatureB64 must be a non-empty string";
456
+ }
457
+ // 2. Verify keyId matches public key
458
+ let pubKeyDer;
459
+ try {
460
+ pubKeyDer = Buffer.from(actor.publicKeyB64, "base64");
461
+ }
462
+ catch {
463
+ return "agency.actor.publicKeyB64 is not valid base64";
464
+ }
465
+ const computedKeyId = createHash("sha256").update(pubKeyDer).digest("hex");
466
+ if (computedKeyId !== actor.keyId) {
467
+ return "agency: actor.keyId does not match SHA-256 of public key";
468
+ }
469
+ // 3. Verify actorKeyId matches actor.keyId
470
+ if (authorization.actorKeyId !== actor.keyId) {
471
+ return "agency: authorization.actorKeyId does not match actor.keyId";
472
+ }
473
+ // 4. Verify artifactHash matches proof.artifact.digestB64
474
+ // For batch proofs, the P-256 signature binds to the first digest in the
475
+ // batch. batchContext (set by the enclave) lists all digests so we can
476
+ // verify this proof's digest is part of the authorized batch.
477
+ if (authorization.artifactHash !== proof.artifact.digestB64) {
478
+ const bc = agency.batchContext;
479
+ if (!bc ||
480
+ !Array.isArray(bc.batchDigests) ||
481
+ !bc.batchDigests.includes(proof.artifact.digestB64) ||
482
+ bc.batchDigests[0] !== authorization.artifactHash) {
483
+ return "agency: authorization.artifactHash does not match proof.artifact.digestB64";
484
+ }
485
+ }
486
+ // 5. Signature verification (format-dependent)
487
+ let sigBytes;
488
+ try {
489
+ sigBytes = Buffer.from(authorization.signatureB64, "base64");
490
+ }
491
+ catch {
492
+ return "agency.authorization.signatureB64 is not valid base64";
493
+ }
494
+ try {
495
+ if (isWebAuthn) {
496
+ // ── WebAuthn assertion verification ──
497
+ const webauthn = authorization;
498
+ if (typeof webauthn.clientDataJSON !== "string" || !webauthn.clientDataJSON) {
499
+ return "agency: WebAuthn authorization missing clientDataJSON";
500
+ }
501
+ if (typeof webauthn.authenticatorDataB64 !== "string" || !webauthn.authenticatorDataB64) {
502
+ return "agency: WebAuthn authorization missing authenticatorDataB64";
503
+ }
504
+ // Parse clientDataJSON
505
+ let clientData;
506
+ try {
507
+ clientData = JSON.parse(webauthn.clientDataJSON);
508
+ }
509
+ catch {
510
+ return "agency: clientDataJSON is not valid JSON";
511
+ }
512
+ if (clientData.type !== "webauthn.get") {
513
+ return `agency: clientDataJSON.type must be "webauthn.get", got "${clientData.type}"`;
514
+ }
515
+ // Verify challenge in clientDataJSON (base64url → base64)
516
+ if (!clientData.challenge) {
517
+ return "agency: clientDataJSON missing challenge field";
518
+ }
519
+ let clientChallenge = clientData.challenge
520
+ .replace(/-/g, "+")
521
+ .replace(/_/g, "/");
522
+ while (clientChallenge.length % 4)
523
+ clientChallenge += "=";
524
+ if (clientChallenge !== authorization.challenge) {
525
+ return "agency: clientDataJSON challenge does not match authorization.challenge";
526
+ }
527
+ // Check authenticatorData flags
528
+ const authData = Buffer.from(webauthn.authenticatorDataB64, "base64");
529
+ if (authData.length < 37) {
530
+ return "agency: authenticatorData too short";
531
+ }
532
+ const flags = authData[32];
533
+ if (!(flags & 0x01))
534
+ return "agency: authenticatorData UP flag not set";
535
+ if (!(flags & 0x04))
536
+ return "agency: authenticatorData UV flag not set";
537
+ // Build signed data: authenticatorData || SHA-256(clientDataJSON)
538
+ const clientDataHash = createHash("sha256")
539
+ .update(Buffer.from(webauthn.clientDataJSON, "utf8"))
540
+ .digest();
541
+ const signedData = Buffer.concat([authData, clientDataHash]);
542
+ // P-256 signature verification over WebAuthn signed data
543
+ const verifier = createVerify("SHA256");
544
+ verifier.update(signedData);
545
+ const valid = verifier.verify({ key: pubKeyDer, format: "der", type: "spki" }, sigBytes);
546
+ if (!valid) {
547
+ return "agency: WebAuthn P-256 signature verification failed";
548
+ }
549
+ }
550
+ else {
551
+ // ── Direct P-256 signature verification ──
552
+ const canonicalPayload = {
553
+ purpose: authorization.purpose,
554
+ actorKeyId: authorization.actorKeyId,
555
+ artifactHash: authorization.artifactHash,
556
+ challenge: authorization.challenge,
557
+ timestamp: authorization.timestamp,
558
+ };
559
+ // Include protocolVersion when present (backward-compatible)
560
+ if ("protocolVersion" in authorization && authorization.protocolVersion !== undefined) {
561
+ canonicalPayload.protocolVersion = authorization.protocolVersion;
562
+ }
563
+ const payloadBytes = Buffer.from(JSON.stringify(canonicalPayload, Object.keys(canonicalPayload).sort()), "utf8");
564
+ const verifier = createVerify("SHA256");
565
+ verifier.update(payloadBytes);
566
+ const valid = verifier.verify({ key: pubKeyDer, format: "der", type: "spki" }, sigBytes);
567
+ if (!valid) {
568
+ return "agency: P-256 signature verification failed";
569
+ }
570
+ }
571
+ }
572
+ catch (err) {
573
+ return `agency: P-256 signature verification error: ${err instanceof Error ? err.message : String(err)}`;
574
+ }
575
+ return null;
576
+ }
577
+ // ---------------------------------------------------------------------------
578
+ // Slot allocation verification (BitGraph causal ordering)
579
+ // ---------------------------------------------------------------------------
580
+ /**
581
+ * Verify the embedded slot allocation record for BitGraph atomic causality.
582
+ *
583
+ * Checks (in order):
584
+ * 1. Slot signature valid (enclave created it independently)
585
+ * 2. Slot body has no artifact data (causal independence)
586
+ * 3. SHA-256(canonicalize(slotBody)) === commit.slotHashB64 (signed binding)
587
+ * 4. slotAllocation.nonceB64 === commit.nonceB64 (nonce binding)
588
+ * 5. slotAllocation.counter < commit.counter (ordering)
589
+ * 6. slotAllocation.publicKeyB64 === signer.publicKeyB64 (same enclave)
590
+ * 7. slotAllocation.epochId === commit.epochId (same lifecycle)
591
+ *
592
+ * Check 3 is critical: it proves the Ed25519 commit signature covers the
593
+ * exact slot allocation record (via the hash in the signed body). Without
594
+ * this, the slot record would be advisory data that could be swapped.
595
+ */
596
+ async function verifySlotAllocation(proof) {
597
+ const slot = proof.slotAllocation;
598
+ // 1. Validate slot structure
599
+ if (slot.version !== "bitgraph/slot/1") {
600
+ return `slotAllocation.version must be "bitgraph/slot/1", got "${String(slot.version)}"`;
601
+ }
602
+ if (typeof slot.nonceB64 !== "string" || slot.nonceB64.length === 0) {
603
+ return "slotAllocation.nonceB64 must be a non-empty string";
604
+ }
605
+ if (typeof slot.counter !== "string" || slot.counter.length === 0) {
606
+ return "slotAllocation.counter must be a non-empty string";
607
+ }
608
+ if (typeof slot.time !== "number" || !Number.isFinite(slot.time) || slot.time < 0) {
609
+ return "slotAllocation.time must be a non-negative finite number";
610
+ }
611
+ if (typeof slot.epochId !== "string" || slot.epochId.length === 0) {
612
+ return "slotAllocation.epochId must be a non-empty string";
613
+ }
614
+ if (typeof slot.publicKeyB64 !== "string" || slot.publicKeyB64.length === 0) {
615
+ return "slotAllocation.publicKeyB64 must be a non-empty string";
616
+ }
617
+ if (typeof slot.signatureB64 !== "string" || slot.signatureB64.length === 0) {
618
+ return "slotAllocation.signatureB64 must be a non-empty string";
619
+ }
620
+ // 2. Verify slot signature (Ed25519 over canonical slot body)
621
+ const slotBody = {
622
+ version: slot.version,
623
+ nonceB64: slot.nonceB64,
624
+ counter: slot.counter,
625
+ time: slot.time,
626
+ epochId: slot.epochId,
627
+ publicKeyB64: slot.publicKeyB64,
628
+ ...(slot.chainId ? { chainId: slot.chainId } : {}),
629
+ };
630
+ const slotCanonicalBytes = canonicalize(slotBody);
631
+ let slotSigBytes;
632
+ let slotPubKeyBytes;
633
+ try {
634
+ slotSigBytes = fromBase64(slot.signatureB64);
635
+ slotPubKeyBytes = fromBase64(slot.publicKeyB64);
636
+ }
637
+ catch {
638
+ return "slotAllocation contains invalid base64";
639
+ }
640
+ if (slotSigBytes.length !== 64) {
641
+ return `slotAllocation.signatureB64 decodes to ${slotSigBytes.length} bytes; expected 64 (Ed25519)`;
642
+ }
643
+ if (slotPubKeyBytes.length !== 32) {
644
+ return `slotAllocation.publicKeyB64 decodes to ${slotPubKeyBytes.length} bytes; expected 32 (Ed25519)`;
645
+ }
646
+ let slotSigValid;
647
+ try {
648
+ slotSigValid = await ed25519VerifyAsync(slotSigBytes, slotCanonicalBytes, slotPubKeyBytes);
649
+ }
650
+ catch (err) {
651
+ return `slotAllocation signature verification error: ${err instanceof Error ? err.message : String(err)}`;
652
+ }
653
+ if (!slotSigValid) {
654
+ return "slotAllocation signature verification failed";
655
+ }
656
+ // 3. Confirm slot body has no artifact data (causal independence)
657
+ // The slot body contains: version, nonceB64, counter, time, epochId, publicKeyB64,
658
+ // and optionally chainId. If any artifact-related field were present, it would
659
+ // break the causal argument. We reject known artifact fields defensively.
660
+ const forbiddenKeys = new Set(["digestB64", "artifact", "hashAlg", "signatureB64"]);
661
+ const slotBodyKeys = Object.keys(slotBody);
662
+ for (const key of slotBodyKeys) {
663
+ if (forbiddenKeys.has(key)) {
664
+ return `slotAllocation body contains forbidden field '${key}' — causal independence violated`;
665
+ }
666
+ }
667
+ // 4. Verify signed binding: SHA-256(canonicalize(slotBody)) === commit.slotHashB64
668
+ // This proves the Ed25519 commit signature covers the exact slot record.
669
+ if (typeof proof.commit.slotHashB64 !== "string" || proof.commit.slotHashB64.length === 0) {
670
+ return "commit.slotHashB64 must be present when slotAllocation is present";
671
+ }
672
+ const computedSlotHash = sha256(slotCanonicalBytes);
673
+ let proofSlotHash;
674
+ try {
675
+ proofSlotHash = fromBase64(proof.commit.slotHashB64);
676
+ }
677
+ catch {
678
+ return "commit.slotHashB64 is not valid base64";
679
+ }
680
+ if (!constantTimeEqual(computedSlotHash, proofSlotHash)) {
681
+ return "commit.slotHashB64 does not match SHA-256 of canonical slot body — slot binding broken";
682
+ }
683
+ // 5. Verify nonce binding: slot nonce === commit nonce
684
+ if (slot.nonceB64 !== proof.commit.nonceB64) {
685
+ return "slotAllocation.nonceB64 does not match commit.nonceB64";
686
+ }
687
+ // 6. Verify ordering: slot counter < commit counter
688
+ if (typeof proof.commit.slotCounter !== "string" || proof.commit.slotCounter.length === 0) {
689
+ return "commit.slotCounter must be present when slotAllocation is present";
690
+ }
691
+ if (proof.commit.slotCounter !== slot.counter) {
692
+ return "commit.slotCounter does not match slotAllocation.counter";
693
+ }
694
+ if (proof.commit.counter === undefined) {
695
+ return "commit.counter must be present for slot ordering verification";
696
+ }
697
+ try {
698
+ const slotCounter = BigInt(slot.counter);
699
+ const commitCounter = BigInt(proof.commit.counter);
700
+ if (slotCounter >= commitCounter) {
701
+ return `slotAllocation.counter (${slot.counter}) must be less than commit.counter (${proof.commit.counter})`;
702
+ }
703
+ }
704
+ catch {
705
+ return "could not parse slot or commit counter as integer";
706
+ }
707
+ // 7. Verify same enclave: same public key
708
+ if (slot.publicKeyB64 !== proof.signer.publicKeyB64) {
709
+ return "slotAllocation.publicKeyB64 does not match signer.publicKeyB64 — different enclave";
710
+ }
711
+ // 8. Verify same lifecycle: same epochId
712
+ if (proof.commit.epochId !== undefined && slot.epochId !== proof.commit.epochId) {
713
+ return "slotAllocation.epochId does not match commit.epochId — different lifecycle";
714
+ }
715
+ return null;
716
+ }
717
+ // ---------------------------------------------------------------------------
718
+ // Epoch link verification (cross-epoch lineage)
719
+ // ---------------------------------------------------------------------------
720
+ /**
721
+ * Single-successor tracking.
722
+ *
723
+ * Maps successorKey → the epochId that consumed it.
724
+ * successorKey = SHA-256(prevEpochId || prevCounter || prevProofHashB64)
725
+ *
726
+ * If a DIFFERENT successor epoch attempts to consume the same predecessor,
727
+ * the verifier detects a fork and rejects.
728
+ *
729
+ * This is an in-memory registry scoped to the verifier's lifecycle.
730
+ * For persistent fork detection across processes, callers should maintain
731
+ * an external store and pass consumed predecessors via the
732
+ * `consumedPredecessors` option.
733
+ */
734
+ const consumedPredecessors = new Map(); // successorKey → toEpochId
735
+ /**
736
+ * Compute the unique key for a consumed predecessor.
737
+ * successorKey = BASE64(SHA-256(prevEpochId + "|" + prevCounter + "|" + prevProofHashB64))
738
+ */
739
+ function computeSuccessorKey(link) {
740
+ const input = `${link.prevEpochId}|${link.prevCounter}|${link.prevProofHashB64}`;
741
+ const hash = sha256(new TextEncoder().encode(input));
742
+ return Buffer.from(hash).toString("base64");
743
+ }
744
+ /**
745
+ * Verify epoch link correctness and single-successor invariant.
746
+ *
747
+ * Checks:
748
+ * 1. Structural validation of epochLink fields
749
+ * 2. Successor binding: toEpochId === proof's epochId
750
+ * 3. Successor binding: toPublicKeyB64 === proof's signer key
751
+ * 4. Single-successor: no other epoch has consumed this predecessor
752
+ *
753
+ * Note: Validating the predecessor proof's signature requires the predecessor
754
+ * proof itself, which is not embedded in the current proof. The enclave
755
+ * performs this validation at init time. The verifier checks structural
756
+ * consistency and fork detection.
757
+ */
758
+ function verifyEpochLink(proof) {
759
+ const link = proof.commit.epochLink;
760
+ // 1. Structural validation
761
+ if (typeof link.prevEpochId !== "string" || link.prevEpochId.length === 0) {
762
+ return "epochLink.prevEpochId must be a non-empty string";
763
+ }
764
+ if (typeof link.prevPublicKeyB64 !== "string" || link.prevPublicKeyB64.length === 0) {
765
+ return "epochLink.prevPublicKeyB64 must be a non-empty string";
766
+ }
767
+ if (typeof link.prevCounter !== "string" || link.prevCounter.length === 0) {
768
+ return "epochLink.prevCounter must be a non-empty string";
769
+ }
770
+ if (typeof link.prevProofHashB64 !== "string" || link.prevProofHashB64.length === 0) {
771
+ return "epochLink.prevProofHashB64 must be a non-empty string";
772
+ }
773
+ if (typeof link.toEpochId !== "string" || link.toEpochId.length === 0) {
774
+ return "epochLink.toEpochId must be a non-empty string";
775
+ }
776
+ if (typeof link.toPublicKeyB64 !== "string" || link.toPublicKeyB64.length === 0) {
777
+ return "epochLink.toPublicKeyB64 must be a non-empty string";
778
+ }
779
+ // Validate prevCounter is a valid decimal string
780
+ try {
781
+ BigInt(link.prevCounter);
782
+ }
783
+ catch {
784
+ return "epochLink.prevCounter is not a valid decimal string";
785
+ }
786
+ // 2. Successor binding: toEpochId must match proof's commit.epochId
787
+ if (proof.commit.epochId !== undefined && link.toEpochId !== proof.commit.epochId) {
788
+ return `epochLink.toEpochId "${link.toEpochId}" does not match proof commit.epochId "${proof.commit.epochId}" — successor binding broken`;
789
+ }
790
+ // 3. Successor binding: toPublicKeyB64 must match proof's signer
791
+ if (link.toPublicKeyB64 !== proof.signer.publicKeyB64) {
792
+ return `epochLink.toPublicKeyB64 does not match proof signer.publicKeyB64 — successor binding broken`;
793
+ }
794
+ // 4. Predecessor must be from a DIFFERENT epoch (same epoch = not an epoch link)
795
+ if (link.prevEpochId === link.toEpochId) {
796
+ return "epochLink.prevEpochId equals toEpochId — epoch link must cross epoch boundary";
797
+ }
798
+ // 5. Predecessor signer must differ from successor signer
799
+ // (new epoch = new keypair, so keys must be different)
800
+ if (link.prevPublicKeyB64 === link.toPublicKeyB64) {
801
+ return "epochLink.prevPublicKeyB64 equals toPublicKeyB64 — epochs must have different keys";
802
+ }
803
+ // 6. Single-successor invariant: detect forks
804
+ const successorKey = computeSuccessorKey(link);
805
+ const existingSuccessor = consumedPredecessors.get(successorKey);
806
+ if (existingSuccessor !== undefined) {
807
+ if (existingSuccessor !== link.toEpochId) {
808
+ return (`FORK DETECTED: predecessor (epoch=${link.prevEpochId.slice(0, 12)}..., counter=${link.prevCounter}) ` +
809
+ `already consumed by epoch ${existingSuccessor.slice(0, 12)}..., ` +
810
+ `but this proof claims consumption by epoch ${link.toEpochId.slice(0, 12)}... — ` +
811
+ `single-successor invariant violated`);
812
+ }
813
+ // Same successor — idempotent (re-verifying same proof)
814
+ }
815
+ else {
816
+ // Record this consumption
817
+ consumedPredecessors.set(successorKey, link.toEpochId);
818
+ }
819
+ return null;
820
+ }
821
+ /**
822
+ * Reset the in-memory single-successor tracking state.
823
+ * Use this when starting a fresh verification context.
824
+ */
825
+ export function resetEpochLinkState() {
826
+ consumedPredecessors.clear();
827
+ }
828
+ // ---------------------------------------------------------------------------
829
+ // Helpers
830
+ // ---------------------------------------------------------------------------
831
+ function fail(reason) {
832
+ return { valid: false, reason };
833
+ }
834
+ function isObject(value) {
835
+ return value !== null && typeof value === "object" && !Array.isArray(value);
836
+ }
837
+ function fromBase64(b64) {
838
+ const buf = Buffer.from(b64, "base64");
839
+ if (buf.toString("base64") !== b64) {
840
+ throw new Error(`invalid base64 string: "${b64}"`);
841
+ }
842
+ return new Uint8Array(buf);
843
+ }
844
+ //# sourceMappingURL=verifier.js.map