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