@primust/verifier 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/chunk-LTWQK3HT.js +432 -0
  2. package/dist/chunk-ZADQUKKN.js +2963 -0
  3. package/dist/cli.d.ts +3 -2
  4. package/dist/cli.js +309 -361
  5. package/dist/index.d.ts +335 -13
  6. package/dist/index.js +1181 -13
  7. package/dist/v29-envelope-GFVVA2S6.js +42 -0
  8. package/package.json +7 -8
  9. package/dist/bounded-trace.d.ts +0 -46
  10. package/dist/bounded-trace.d.ts.map +0 -1
  11. package/dist/bounded-trace.js +0 -558
  12. package/dist/bounded-trace.js.map +0 -1
  13. package/dist/cli.d.ts.map +0 -1
  14. package/dist/cli.js.map +0 -1
  15. package/dist/index.d.ts.map +0 -1
  16. package/dist/index.js.map +0 -1
  17. package/dist/key-cache.d.ts +0 -20
  18. package/dist/key-cache.d.ts.map +0 -1
  19. package/dist/key-cache.js +0 -68
  20. package/dist/key-cache.js.map +0 -1
  21. package/dist/scoped.d.ts +0 -35
  22. package/dist/scoped.d.ts.map +0 -1
  23. package/dist/scoped.js +0 -582
  24. package/dist/scoped.js.map +0 -1
  25. package/dist/types.d.ts +0 -60
  26. package/dist/types.d.ts.map +0 -1
  27. package/dist/types.js +0 -5
  28. package/dist/types.js.map +0 -1
  29. package/dist/upstream_resolver.d.ts +0 -60
  30. package/dist/upstream_resolver.d.ts.map +0 -1
  31. package/dist/upstream_resolver.js +0 -126
  32. package/dist/upstream_resolver.js.map +0 -1
  33. package/dist/v29-envelope.d.ts +0 -55
  34. package/dist/v29-envelope.d.ts.map +0 -1
  35. package/dist/v29-envelope.js +0 -450
  36. package/dist/v29-envelope.js.map +0 -1
  37. package/dist/verifier.d.ts +0 -36
  38. package/dist/verifier.d.ts.map +0 -1
  39. package/dist/verifier.js +0 -1235
  40. package/dist/verifier.js.map +0 -1
  41. package/dist/verifier.test.d.ts +0 -2
  42. package/dist/verifier.test.d.ts.map +0 -1
  43. package/dist/verifier.test.js +0 -395
  44. package/dist/verifier.test.js.map +0 -1
  45. package/dist/verify-html-template.d.ts +0 -45
  46. package/dist/verify-html-template.d.ts.map +0 -1
  47. package/dist/verify-html-template.js +0 -182
  48. package/dist/verify-html-template.js.map +0 -1
package/dist/verifier.js DELETED
@@ -1,1235 +0,0 @@
1
- /**
2
- * primust-verify — Offline VPEC artifact verifier.
3
- *
4
- * ZERO runtime dependencies on Primust infrastructure after initial
5
- * public key fetch. Must verify a VPEC produced today in 10 years.
6
- *
7
- * Verification steps (in order):
8
- * 1. Schema validation
9
- * 2. SHA-256 integrity + Ed25519 signature (combined)
10
- * 3. Kid resolution
11
- * 4. Signer status check (Rekor — stubbed in v1)
12
- * 5. RFC 3161 timestamp verification (stubbed in v1)
13
- * 6. Proof level integrity
14
- * 7. Manifest hash audit
15
- * 8. ZK proof verification (stubbed in v1)
16
- * 9. test_mode check
17
- */
18
- import { createHash, createPublicKey } from 'node:crypto';
19
- import { canonical, verify as ed25519Verify, validateArtifact, fromBase64Url, BN254_MODULUS, } from '@primust/artifact-core';
20
- import { poseidon2Hash } from '@zkpassport/poseidon2';
21
- import { sha256 } from '@noble/hashes/sha256';
22
- import { getKey } from './key-cache.js';
23
- /**
24
- * Check recursively for reliance_mode field anywhere in the artifact.
25
- */
26
- function hasRelianceMode(obj, path = '') {
27
- for (const [key, value] of Object.entries(obj)) {
28
- const currentPath = path ? `${path}.${key}` : key;
29
- if (key === 'reliance_mode') {
30
- return currentPath;
31
- }
32
- if (value && typeof value === 'object' && !Array.isArray(value)) {
33
- const found = hasRelianceMode(value, currentPath);
34
- if (found)
35
- return found;
36
- }
37
- }
38
- return null;
39
- }
40
- /**
41
- * Build a default (failed/empty) VerificationResult from an artifact dict.
42
- */
43
- function baseResult(artifact) {
44
- const sig = artifact.signature;
45
- const issuer = artifact.issuer;
46
- const proofDist = artifact.proof_distribution;
47
- const coverage = artifact.coverage;
48
- const gaps = Array.isArray(artifact.gaps) ? artifact.gaps : [];
49
- return {
50
- vpec_id: artifact.vpec_id ?? '',
51
- valid: false,
52
- schema_version: artifact.schema_version ?? '',
53
- proof_level: artifact.proof_level ?? '',
54
- proof_distribution: proofDist ?? {},
55
- org_id: artifact.org_id ?? '',
56
- workflow_id: artifact.workflow_id ?? '',
57
- process_context_hash: artifact.process_context_hash ?? null,
58
- partial: artifact.partial ?? false,
59
- test_mode: artifact.test_mode ?? false,
60
- signer_id: issuer?.signer_id ?? sig?.signer_id ?? '',
61
- kid: issuer?.kid ?? sig?.kid ?? '',
62
- signed_at: sig?.signed_at ?? '',
63
- timestamp_anchor_valid: null,
64
- rekor_status: 'skipped',
65
- zk_proof_valid: null,
66
- commitment_root_valid: null,
67
- manifest_hashes: {},
68
- gaps: gaps.map((g) => ({
69
- gap_id: g.gap_id ?? '',
70
- gap_type: g.gap_type ?? '',
71
- severity: g.severity ?? '',
72
- })),
73
- violations_present: false,
74
- violation_count: 0,
75
- coverage: coverage ?? {},
76
- errors: [],
77
- warnings: [],
78
- };
79
- }
80
- function signatureBody(artifact) {
81
- const { signature: _sig, ...documentBody } = artifact;
82
- void _sig;
83
- return documentBody;
84
- }
85
- function timestampBody(artifact) {
86
- const { signature: _sig, timestamp_anchor: _ts, ...documentBody } = artifact;
87
- void _sig;
88
- void _ts;
89
- return documentBody;
90
- }
91
- /**
92
- * Verify a VPEC artifact.
93
- *
94
- * @param artifact - Parsed artifact JSON (Record<string, unknown>)
95
- * @param options - Verification options
96
- * @param upstreamRootResolver - Optional synchronous resolver returning
97
- * the PARENT VPEC's `commitment_root_poseidon2` for a given upstream
98
- * VPEC envelope ID. When provided, governance_upstream_vpec_inclusion
99
- * proofs are anchored to `Poseidon2(lineage_commitment(child_run_id,
100
- * upstream_vpec_ids), parentRoot)` — the same reduction
101
- * `PolicySnapshotService.openRun` performs at issuance. When omitted,
102
- * the verifier falls back to anchoring against the artifact's own
103
- * `commitment_root_poseidon2` (legacy behavior — preserves backward
104
- * compat for callers that lack upstream-store context).
105
- * @returns VerificationResult with errors/warnings
106
- */
107
- export async function verify(artifact, options = {}, upstreamRootResolver) {
108
- const result = baseResult(artifact);
109
- const { production = false, skip_network = false, trust_root } = options;
110
- // ── Step 1: Schema validation ──
111
- const schemaResult = validateArtifact(artifact);
112
- if (!schemaResult.valid) {
113
- for (const err of schemaResult.errors) {
114
- if (err.code === 'RELIANCE_MODE_FORBIDDEN') {
115
- result.errors.push('banned_field_reliance_mode');
116
- }
117
- else if (err.code === 'MANIFEST_HASHES_NOT_MAP') {
118
- result.errors.push('manifest_hashes_not_object');
119
- }
120
- else {
121
- result.errors.push(`schema_validation_failed: ${err.code}`);
122
- }
123
- }
124
- return result;
125
- }
126
- // Extra check: reliance_mode anywhere (validateArtifact already catches this,
127
- // but we ensure the specific error string)
128
- const reliancePath = hasRelianceMode(artifact);
129
- if (reliancePath) {
130
- result.errors.push('banned_field_reliance_mode');
131
- return result;
132
- }
133
- // ── Step 4 (early): Kid resolution — issuer.kid must match signature.kid ──
134
- const issuer = artifact.issuer;
135
- const sig = artifact.signature;
136
- if (!issuer || !sig) {
137
- result.errors.push('missing_issuer_or_signature');
138
- return result;
139
- }
140
- if (issuer.kid !== sig.kid) {
141
- result.errors.push('kid_mismatch');
142
- return result;
143
- }
144
- // ── Step 2+3: Integrity + Ed25519 signature verification ──
145
- // The signing process signs the artifact body (everything except 'signature').
146
- // We reconstruct the document by stripping the signature field.
147
- const documentBody = signatureBody(artifact);
148
- // Resolve public key
149
- let publicKeyB64Url;
150
- try {
151
- const pem = await getKey(sig.kid, issuer.public_key_url, trust_root);
152
- // PEM may be raw base64url or PEM-wrapped. Handle both.
153
- publicKeyB64Url = extractKeyFromPem(pem);
154
- }
155
- catch (err) {
156
- result.errors.push(err.message);
157
- return result;
158
- }
159
- const signatureEnvelope = {
160
- signer_id: sig.signer_id,
161
- kid: sig.kid,
162
- algorithm: sig.algorithm,
163
- signature: sig.signature,
164
- signed_at: sig.signed_at,
165
- };
166
- const sigValid = ed25519Verify(documentBody, signatureEnvelope, publicKeyB64Url);
167
- if (!sigValid) {
168
- result.errors.push('integrity_check_failed');
169
- return result;
170
- }
171
- // ── Step 5: Signer status check (Rekor) ──
172
- if (skip_network) {
173
- result.rekor_status = 'skipped';
174
- }
175
- else {
176
- result.rekor_status = await checkRekor(publicKeyB64Url, sig.kid);
177
- if (result.rekor_status === 'unavailable') {
178
- result.warnings.push('rekor_check_unavailable');
179
- }
180
- else if (result.rekor_status === 'revoked') {
181
- result.errors.push('signer_key_revoked');
182
- return result;
183
- }
184
- }
185
- // ── Step 6: RFC 3161 timestamp verification ──
186
- const tsAnchor = artifact.timestamp_anchor;
187
- if (tsAnchor && tsAnchor.type === 'rfc3161' && typeof tsAnchor.value === 'string') {
188
- result.timestamp_anchor_valid = verifyTimestampImprint(tsAnchor.value, timestampBody(artifact));
189
- if (result.timestamp_anchor_valid === false) {
190
- result.warnings.push('rfc3161_imprint_mismatch');
191
- }
192
- else if (result.timestamp_anchor_valid === true) {
193
- result.warnings.push('rfc3161_tsa_cert_chain_not_verified');
194
- }
195
- }
196
- else {
197
- result.timestamp_anchor_valid = null;
198
- }
199
- // ── Step 7: Proof level integrity ──
200
- // Already checked by validateArtifact in step 1 (PROOF_LEVEL_MISMATCH),
201
- // but we verify again explicitly for the spec requirement.
202
- const proofDist = artifact.proof_distribution;
203
- if (artifact.proof_level !== proofDist.weakest_link) {
204
- result.errors.push('proof_level_mismatch');
205
- return result;
206
- }
207
- // ── Step 8: Manifest hash audit ──
208
- const manifestHashes = artifact.manifest_hashes;
209
- result.manifest_hashes = manifestHashes;
210
- // ── Step 8b: Commitment root validation ──
211
- if (artifact.commitment_root != null) {
212
- const commitmentRoot = artifact.commitment_root;
213
- const checkRecords = artifact.check_execution_records;
214
- // Extract commitment hashes from check execution records
215
- const hashes = [];
216
- if (Array.isArray(checkRecords)) {
217
- for (const rec of checkRecords) {
218
- const h = (rec.commitment_hash ?? rec.input_commitment_hash);
219
- if (h)
220
- hashes.push(h);
221
- }
222
- }
223
- if (hashes.length > 0) {
224
- const recomputed = computeMerkleRoot(hashes);
225
- if (recomputed === commitmentRoot) {
226
- result.commitment_root_valid = true;
227
- }
228
- else {
229
- result.commitment_root_valid = false;
230
- result.errors.push('commitment_root_mismatch');
231
- return result;
232
- }
233
- }
234
- else {
235
- // commitment_root present but no hashes to verify against
236
- result.commitment_root_valid = null;
237
- result.warnings.push('commitment_root_no_hashes_to_verify');
238
- }
239
- }
240
- else {
241
- result.commitment_root_valid = null;
242
- result.warnings.push('no_commitment_root');
243
- }
244
- // ── Step 8c: skip_condition_proof commitment_root anchoring ──
245
- // Founder review Finding 5 (post-PR #49): the witness builder now
246
- // produces a real Merkle path from the skipped record's slot up to a
247
- // modified-tree root. The verifier reproduces the same modified root
248
- // from the artifact's check_execution_records and the proof's
249
- // skip_condition_hash, then asserts equality with the public input.
250
- //
251
- // For each `check_result === 'skipped'` record, the modified tree
252
- // overrides that record's leaf with
253
- // leaf' = Poseidon2(skip_condition_hash, record.commitment_hash)
254
- // and walks Poseidon2 sibling pairs up to the root. Verifier accepts
255
- // if ANY skipped record produces a modified root that matches the
256
- // proof's commitment_root public input — covers single-skip and
257
- // multi-skip runs (the latter where the proof binds to one of
258
- // several skipped records).
259
- //
260
- // Single-record runs collapse: the modified root is the leaf override
261
- // itself, and the witness uses the 1-level fallback. Both still
262
- // checkable.
263
- //
264
- // Backward compatibility: this branch handles both legacy single-leaf
265
- // shape (commitment_root = leaf override) and the new multi-record
266
- // shape (commitment_root = modified-tree root). The verifier doesn't
267
- // need to know which one the witness produced — it just iterates
268
- // skipped records and compares.
269
- const skipAnchorResult = verifySkipConditionProofAnchoring(artifact);
270
- if (skipAnchorResult === 'mismatch') {
271
- result.errors.push('skip_condition_proof_root_mismatch');
272
- return result;
273
- }
274
- if (skipAnchorResult === 'no_proof_artifact_for_multi_record_artifact') {
275
- result.errors.push('skip_condition_proof_no_proof_artifact_for_multi_record_artifact');
276
- return result;
277
- }
278
- if (skipAnchorResult === 'unanchored') {
279
- result.warnings.push('skip_condition_proof_no_skipped_record_to_anchor');
280
- }
281
- // ── Step 8d: governance_upstream_vpec_inclusion / merkle_inclusion anchoring ──
282
- //
283
- // Mirrors Step 8c for upstream-VPEC inclusion proofs. When the artifact
284
- // emits per-circuit ZK proofs in `proof_artifacts[]` (production wire
285
- // shape on multi-record runs), the verifier walks EVERY matching entry
286
- // and reproduces the merkle_root anchor from the artifact's
287
- // commitment_root_poseidon2 (or the entry's bound merkle_root).
288
- //
289
- // Closes Gap 1 / Gap 2 of the post-PR #70 corrective: the
290
- // governance.upstream_vpec ZK proof's public commitment_root MUST equal
291
- // the upstream VPEC's commitment_root_poseidon2 (NOT its SHA-256
292
- // commitment_root, which is over a different domain). Without this
293
- // check, a downstream consumer cannot tell whether the Merkle inclusion
294
- // proof is anchored to the real upstream tree or to a fabricated root.
295
- //
296
- // Rejection: for multi-record artifacts (>1 check_execution_records)
297
- // without any `proof_artifacts[]` entry AND no top-level `zk_proof` of
298
- // a matching circuit, the verifier MUST refuse — the 1-level fallback
299
- // would silently accept a proof whose anchor was never bound. Single-
300
- // record artifacts that only carry the legacy top-level `zk_proof`
301
- // are still acceptable for backward compat.
302
- //
303
- // For cross-org chains, the verifier depends on the upstream VPEC
304
- // envelope being available; the helper returns 'unanchored' in that
305
- // case so the caller can warn rather than fail (cross-org anchor is
306
- // checked at the boundary, not here).
307
- const upstreamAnchorResult = verifyUpstreamVpecInclusionAnchoring(artifact, upstreamRootResolver);
308
- if (upstreamAnchorResult === 'mismatch') {
309
- result.errors.push('upstream_vpec_proof_root_mismatch');
310
- return result;
311
- }
312
- if (upstreamAnchorResult === 'no_proof_artifact_for_multi_record_artifact') {
313
- result.errors.push('upstream_vpec_inclusion_no_proof_artifact_for_multi_record_artifact');
314
- return result;
315
- }
316
- if (upstreamAnchorResult === 'unanchored') {
317
- result.warnings.push('upstream_vpec_proof_no_anchor_root_in_artifact');
318
- }
319
- // ── Step 9: ZK proof verification ──
320
- const pendingFlags = artifact.pending_flags;
321
- const proofPending = pendingFlags?.proof_pending === true;
322
- const proofArtifacts = Array.isArray(artifact.proof_artifacts)
323
- ? artifact.proof_artifacts
324
- : [];
325
- if (artifact.zk_proof && !proofPending) {
326
- const zkProof = artifact.zk_proof;
327
- // Accept both 'prover_system' (canonical) and 'proving_system' (legacy)
328
- const provingSystem = (zkProof.prover_system ?? zkProof.proving_system);
329
- if (provingSystem === 'ultrahonk') {
330
- result.zk_proof_valid = await verifyUltraHonk(zkProof);
331
- if (result.zk_proof_valid === false) {
332
- result.errors.push('zk_proof_invalid');
333
- }
334
- }
335
- else if (provingSystem === 'ezkl') {
336
- // EZKL Tier 2: explicit stub — requires EZKL verifier integration
337
- result.zk_proof_valid = null;
338
- result.warnings.push('ezkl_verification_not_implemented');
339
- }
340
- else {
341
- result.zk_proof_valid = null;
342
- result.warnings.push(`unknown_proving_system: ${provingSystem ?? 'none'}`);
343
- }
344
- }
345
- else if (proofPending) {
346
- // Proof in flight — cannot verify yet
347
- result.zk_proof_valid = null;
348
- result.warnings.push('proof_pending');
349
- }
350
- else {
351
- result.zk_proof_valid = null;
352
- }
353
- // ── Step 9b: ZK proof integrity ──
354
- // If a ZK proof was present but failed verification → error.
355
- // If a ZK proof was present but verifier unavailable → warning.
356
- // If no ZK proof → valid (mathematical level from deterministic rules
357
- // is established through commitment chain + policy hash binding).
358
- if (result.zk_proof_valid === false) {
359
- result.errors.push('zk_proof_verification_failed');
360
- return result;
361
- }
362
- if (artifact.zk_proof && result.zk_proof_valid === null) {
363
- result.warnings.push('zk_proof_verifier_unavailable');
364
- }
365
- if (proofArtifacts.length > 0) {
366
- const pendingArtifacts = proofArtifacts.filter((artifactEntry) => artifactEntry.verification_status === 'pending').length;
367
- const failedArtifacts = proofArtifacts.filter((artifactEntry) => artifactEntry.verification_status === 'failed').length;
368
- const verifiedArtifacts = proofArtifacts.filter((artifactEntry) => artifactEntry.verification_status === 'verified').length;
369
- if (pendingArtifacts > 0) {
370
- result.warnings.push(`proof_artifacts_pending:${pendingArtifacts}`);
371
- }
372
- if (failedArtifacts > 0) {
373
- result.warnings.push(`proof_artifacts_failed:${failedArtifacts}`);
374
- }
375
- if (!artifact.zk_proof) {
376
- result.warnings.push(`proof_artifacts_present:${verifiedArtifacts}`);
377
- }
378
- }
379
- // ── Step 10: test_mode check ──
380
- if (artifact.test_mode === true) {
381
- if (production) {
382
- result.errors.push('test_mode_rejected_in_production');
383
- return result;
384
- }
385
- result.warnings.push('test_credential');
386
- }
387
- // ── Step 11: Violations surface verification ──
388
- const violations = artifact.violations;
389
- if (Array.isArray(violations) && violations.length > 0) {
390
- result.violations_present = true;
391
- result.violation_count = violations.length;
392
- }
393
- else {
394
- // Backward compatible: treat missing/empty violations as 0
395
- result.violations_present = false;
396
- result.violation_count = 0;
397
- }
398
- // Verify governance_decision_summary includes deferred if present
399
- const gdSummary = artifact.governance_decision_summary;
400
- if (gdSummary && typeof gdSummary === 'object') {
401
- // Ensure deferred key is present (default 0 for backward compatibility)
402
- if (!('deferred' in gdSummary)) {
403
- result.warnings.push('governance_decision_summary_missing_deferred');
404
- }
405
- }
406
- // F6 (review-pass-2) — v29 conformance check. When the artifact carries
407
- // v29 envelope shape (envelope_version + run_header.records), run the
408
- // dedicated conformance battery so dashboard/browser verification
409
- // catches v29-specific tampering (record kind/trust-edge enums,
410
- // aggregations reproduction, manifest hashes, runtime binding hash,
411
- // signed_at window, manifest cross-checks, aggregations, scope-claim
412
- // hash). The Py CLI runs this same path; without it, TS verifiers
413
- // pass shape-valid envelopes that fail offline conformance.
414
- if (artifact.envelope_version != null && (artifact.run_header || artifact.records)) {
415
- try {
416
- const { verifyV29 } = await import('./v29-envelope.js');
417
- const shapeEnvelope = {
418
- envelope_version: artifact.envelope_version,
419
- run_header: artifact.run_header ?? {},
420
- // Mirror the Py CLI's read-side fallback (envelope_records shim
421
- // for envelopes signed before the legacy records → envelope.records
422
- // rename landed).
423
- records: artifact.records ??
424
- artifact.envelope_records ??
425
- [],
426
- aggregations: artifact.aggregations ?? {},
427
- };
428
- const rh = shapeEnvelope.run_header ?? {};
429
- const rb = rh.runtime_binding;
430
- const v29Result = verifyV29({
431
- envelope: shapeEnvelope,
432
- runtimeBinding: rb,
433
- // Pubkey resolver wiring is deferred — when the trust-root path
434
- // is wired the CLI will pass one, and require_signatures: true
435
- // will then enforce the strict-mode preconditions.
436
- });
437
- if (!v29Result.ok) {
438
- result.errors.push(`v29_conformance_failed:${v29Result.reasonCode}`);
439
- }
440
- }
441
- catch (e) {
442
- // Defensive — if the v29 module fails to import (very old env),
443
- // record a warning rather than blocking legacy VPEC verification.
444
- result.warnings.push(`v29_conformance_error:${e.message}`);
445
- }
446
- }
447
- // All checks passed
448
- result.valid = result.errors.length === 0;
449
- return result;
450
- }
451
- /**
452
- * Compute a Merkle root from an array of hash strings.
453
- *
454
- * Each hash is expected as "sha256:<hex>" or raw hex.
455
- * Returns "sha256:<hex>" root.
456
- */
457
- function computeMerkleRoot(hashes) {
458
- // Convert hash strings to 32-byte Buffers
459
- let leaves = hashes.map((h) => {
460
- const hex = h.startsWith('sha256:') ? h.slice(7) : h;
461
- return Buffer.from(hex, 'hex');
462
- });
463
- // Build binary Merkle tree
464
- while (leaves.length > 1) {
465
- // If odd number of leaves, duplicate last
466
- if (leaves.length % 2 !== 0) {
467
- leaves.push(leaves[leaves.length - 1]);
468
- }
469
- const next = [];
470
- for (let i = 0; i < leaves.length; i += 2) {
471
- const combined = Buffer.concat([leaves[i], leaves[i + 1]]);
472
- next.push(createHash('sha256').update(combined).digest());
473
- }
474
- leaves = next;
475
- }
476
- return 'sha256:' + leaves[0].toString('hex');
477
- }
478
- /**
479
- * Parse a Poseidon2 hash string ("poseidon2:<64-hex>") to a BN254 field element.
480
- * Returns null if the input does not have the expected shape.
481
- */
482
- function parsePoseidon2HashToField(hash) {
483
- if (typeof hash !== 'string')
484
- return null;
485
- const colonIdx = hash.indexOf(':');
486
- if (colonIdx === -1)
487
- return null;
488
- const algorithm = hash.slice(0, colonIdx);
489
- if (algorithm !== 'poseidon2')
490
- return null;
491
- const hex = hash.slice(colonIdx + 1);
492
- if (!/^[0-9a-f]+$/i.test(hex))
493
- return null;
494
- try {
495
- return BigInt('0x' + hex) % BN254_MODULUS;
496
- }
497
- catch {
498
- return null;
499
- }
500
- }
501
- /**
502
- * Format a BN254 field element back as a poseidon2:hex string with the
503
- * canonical 64-char zero-padded representation used everywhere else in
504
- * the codebase.
505
- */
506
- function formatPoseidon2Field(field) {
507
- return 'poseidon2:' + field.toString(16).padStart(64, '0');
508
- }
509
- /**
510
- * Extract the (skip_condition_hash, commitment_root) pair from the artifact's
511
- * zk_proof.public_inputs. The Noir circuit at
512
- * `circuits/skip_condition_proof/src/main.nr` declares public inputs in
513
- * the order [skip_condition_hash, commitment_root], so we read the first
514
- * two entries.
515
- */
516
- function extractSkipConditionPublicInputs(zkProof) {
517
- const inputs = zkProof.public_inputs;
518
- if (!Array.isArray(inputs) || inputs.length < 2)
519
- return null;
520
- const skipConditionHash = inputs[0];
521
- const commitmentRoot = inputs[1];
522
- if (typeof skipConditionHash !== 'string' || typeof commitmentRoot !== 'string') {
523
- return null;
524
- }
525
- return { skipConditionHash, commitmentRoot };
526
- }
527
- /**
528
- * Recompute a modified-tree root for the `skip_condition_proof` circuit.
529
- *
530
- * Mirrors `recomputeSkipConditionModifiedRoot` from
531
- * `@primust/zk-core/src/witnesses/skip_condition_proof.ts`. We
532
- * intentionally inline the helper rather than depend on @primust/zk-core
533
- * — the verifier package's invariant is ZERO runtime dependencies on
534
- * Primust infrastructure after the initial public-key fetch (see file
535
- * header). The construction:
536
- *
537
- * modifiedLeaves[k*] = Poseidon2(skip_condition_hash,
538
- * record.commitment_hash)
539
- * at the skipped slot k*
540
- * modifiedLeaves[k] = record.commitment_hash[k] for k != k*
541
- *
542
- * then walks layered Poseidon2 pairs up to a single root. Single-leaf
543
- * trees collapse to the override leaf itself (matches the witness
544
- * builder's fallback path).
545
- *
546
- * Returns null if any of the leaf hashes fail to parse — signals the
547
- * caller to treat that skipped record as un-anchorable.
548
- */
549
- function recomputeSkipConditionModifiedRoot(skipConditionField, leaves, skippedIndex) {
550
- if (leaves.length === 0)
551
- return null;
552
- if (skippedIndex < 0 || skippedIndex >= leaves.length)
553
- return null;
554
- const overrideLeaf = poseidon2Hash([
555
- skipConditionField,
556
- leaves[skippedIndex],
557
- ]);
558
- if (leaves.length === 1)
559
- return overrideLeaf;
560
- let layer = leaves.map((leaf, i) => i === skippedIndex ? overrideLeaf : leaf);
561
- while (layer.length > 1) {
562
- const next = [];
563
- for (let i = 0; i < layer.length; i += 2) {
564
- const left = layer[i];
565
- const right = i + 1 < layer.length ? layer[i + 1] : layer[i];
566
- next.push(poseidon2Hash([left, right]));
567
- }
568
- layer = next;
569
- }
570
- return layer[0];
571
- }
572
- /**
573
- * Verify the commitment_root of a `skip_condition_proof` zk_proof against
574
- * the artifact's full record-tree, with leaf override at each skipped
575
- * record's slot.
576
- *
577
- * For each `check_result === 'skipped'` record, the verifier reproduces
578
- * the modified-tree root the witness builder would have produced (see
579
- * `buildSkipConditionWitness`'s full-path branch) and asserts equality
580
- * with the proof's `commitment_root` public input. ANY skipped record
581
- * matching anchors the proof — covers single-skip and multi-skip runs.
582
- *
583
- * Returns:
584
- * - 'ok' — at least one skipped record's modified-tree root
585
- * matches the proof's commitment_root.
586
- * - 'mismatch' — public inputs are present and the artifact has at
587
- * least one skipped record, but no skipped record's
588
- * modified root matches. Verifier MUST fail.
589
- * - 'unanchored' — no `skipped` records on the VPEC, so the proof
590
- * cannot be anchored. Surfaces a warning.
591
- * - 'not_applicable' — no skip_condition_proof present, or the proof
592
- * shape didn't match (no public inputs). Skip silently.
593
- *
594
- * Note on `commitment_root_poseidon2`:
595
- * When the artifact carries `commitment_root_poseidon2`, the verifier
596
- * ALSO checks that the artifact's record list reproduces it (the
597
- * un-modified Poseidon2 record-tree root) — that pin guarantees the
598
- * skipped-record list the verifier iterates is the canonical one.
599
- * Absent the field (legacy artifacts pre-Step 1), the verifier still
600
- * iterates skipped records and recomputes modified roots; the proof
601
- * binds to whatever record list the artifact published, but the
602
- * record list itself is anchored only by the SHA-256 commitment_root.
603
- */
604
- function verifySkipConditionProofAnchoring(artifact) {
605
- const checkRecords = artifact.check_execution_records;
606
- const recordCount = Array.isArray(checkRecords) ? checkRecords.length : 0;
607
- // Collect all proof bodies whose circuit discriminator is
608
- // `skip_condition_proof`. Production multi-record runs emit per-circuit
609
- // proofs in `proof_artifacts[]`; legacy single-record runs put a single
610
- // proof in `artifact.zk_proof`. We scan BOTH shapes so the verifier
611
- // accepts either without forking into two top-level paths.
612
- const proofs = collectMatchingProofs(artifact, ['skip_condition_proof']);
613
- // Multi-record artifacts MUST carry per-record proofs in
614
- // `proof_artifacts[]`. Falling back to the top-level `zk_proof`
615
- // single-leaf hash on a multi-record run would silently anchor the
616
- // proof to a record list the proof never witnessed — the exact
617
- // misuse this gate exists to prevent.
618
- if (recordCount > 1 && proofs.fromArray.length === 0) {
619
- if (proofs.legacy !== null) {
620
- return 'no_proof_artifact_for_multi_record_artifact';
621
- }
622
- // No matching proof at all → genuinely not_applicable (no anchoring
623
- // claim to verify).
624
- }
625
- const candidates = [...proofs.fromArray];
626
- // Single-record artifacts may use the legacy top-level zk_proof; multi-
627
- // record artifacts only consider it when proof_artifacts[] also matched
628
- // (it would not be reached because of the gate above when array is empty).
629
- if (proofs.legacy && (recordCount <= 1 || proofs.fromArray.length > 0)) {
630
- candidates.push(proofs.legacy);
631
- }
632
- if (candidates.length === 0)
633
- return 'not_applicable';
634
- if (recordCount === 0)
635
- return 'unanchored';
636
- // Parse all record commitment_hashes once (full leaf set in record
637
- // order). Records lacking a parseable commitment_hash are flagged but
638
- // we still attempt the iteration — a skipped record with a malformed
639
- // hash naturally fails to anchor.
640
- const allLeafFields = (checkRecords ?? []).map((rec) => {
641
- const h = rec.commitment_hash;
642
- return typeof h === 'string' ? parsePoseidon2HashToField(h) : null;
643
- });
644
- const fullyParsed = allLeafFields.every((f) => f !== null);
645
- // Walk EVERY candidate proof. ALL of them must anchor (or the artifact
646
- // is rejected). 'ok' returns only when every candidate ties to a
647
- // skipped record's modified-tree root.
648
- let sawSkippedRecord = false;
649
- for (const proof of candidates) {
650
- const inputs = extractSkipConditionPublicInputs(proof);
651
- if (!inputs)
652
- return 'not_applicable';
653
- const skipConditionField = parsePoseidon2HashToField(inputs.skipConditionHash);
654
- if (skipConditionField === null)
655
- return 'not_applicable';
656
- const proofRootField = parsePoseidon2HashToField(inputs.commitmentRoot);
657
- if (proofRootField === null)
658
- return 'not_applicable';
659
- let matched = false;
660
- for (let i = 0; i < (checkRecords ?? []).length; i++) {
661
- const rec = (checkRecords ?? [])[i];
662
- if (rec.check_result !== 'skipped')
663
- continue;
664
- sawSkippedRecord = true;
665
- const skippedLeafField = allLeafFields[i];
666
- if (skippedLeafField === null)
667
- continue;
668
- if (fullyParsed && (checkRecords ?? []).length > 1) {
669
- const modifiedRoot = recomputeSkipConditionModifiedRoot(skipConditionField, allLeafFields, i);
670
- if (modifiedRoot !== null && modifiedRoot === proofRootField) {
671
- matched = true;
672
- break;
673
- }
674
- }
675
- // 1-level fallback for the single-record collapsed shape.
676
- const overrideLeaf = poseidon2Hash([
677
- skipConditionField,
678
- skippedLeafField,
679
- ]);
680
- if (overrideLeaf === proofRootField) {
681
- matched = true;
682
- break;
683
- }
684
- }
685
- if (!matched) {
686
- if (!sawSkippedRecord)
687
- return 'unanchored';
688
- return 'mismatch';
689
- }
690
- }
691
- if (!sawSkippedRecord)
692
- return 'unanchored';
693
- return 'ok';
694
- }
695
- /**
696
- * Canonicalize an arbitrary string to a BN254 field element.
697
- *
698
- * Mirrors `parseStringToField` in
699
- * `@primust/runtime-core/src/utils/lineage_commitment.ts`: SHA-256 the
700
- * UTF-8 bytes, pack the full 32-byte big-endian digest into a bigint,
701
- * reduce mod BN254. The verifier package's invariant is ZERO runtime
702
- * dependencies on Primust infrastructure beyond `@primust/artifact-core`
703
- * (the wire-format spec) and the public-key fetch path — depending on
704
- * `@primust/runtime-core` would drag a `better-sqlite3` native binary
705
- * into a CLI/browser-friendly verifier. The helper is inlined verbatim,
706
- * not adapted; both sides MUST produce byte-identical field elements.
707
- */
708
- function parseStringToFieldLocal(s) {
709
- const bytes = new TextEncoder().encode(s);
710
- const digest = sha256(bytes);
711
- let n = 0n;
712
- for (let i = 0; i < 32; i++) {
713
- n = (n << 8n) | BigInt(digest[i]);
714
- }
715
- return n % BN254_MODULUS;
716
- }
717
- /**
718
- * Compute the lineage-commitment leaf for a run with a given list of
719
- * upstream VPEC IDs.
720
- *
721
- * Mirrors `computeLineageCommitment` in
722
- * `@primust/runtime-core/src/utils/lineage_commitment.ts` byte-for-byte.
723
- * Both the issuer (PolicySnapshotService.openRun) and the witness
724
- * dispatcher reduce to this function; the verifier reproduces it locally
725
- * so the parent-anchored Merkle-root check is a pure-math equality.
726
- *
727
- * Output: Poseidon2(parseStringToField(runId),
728
- * ...parseStringToField(upstreamVpecIds))
729
- * reduced mod BN254 (poseidon2Hash already returns a field
730
- * element).
731
- */
732
- function computeLineageCommitmentLocal(runId, upstreamVpecIds) {
733
- const inputs = [
734
- parseStringToFieldLocal(runId),
735
- ...upstreamVpecIds.map(parseStringToFieldLocal),
736
- ];
737
- return poseidon2Hash(inputs);
738
- }
739
- /**
740
- * Discriminator field on a proof_artifacts[] entry: prefer `circuit_name`
741
- * (forward-compat surface used by the issuer when emitting per-circuit
742
- * proofs on multi-record artifacts), fall back to `circuit` (matches
743
- * the top-level zk_proof shape for entries that mirror the same field).
744
- */
745
- function readCircuitName(entry) {
746
- const cn = entry.circuit_name;
747
- if (typeof cn === 'string' && cn.length > 0)
748
- return cn;
749
- const c = entry.circuit;
750
- if (typeof c === 'string' && c.length > 0)
751
- return c;
752
- return null;
753
- }
754
- /**
755
- * Scan the artifact for proofs of the requested circuits.
756
- *
757
- * Returns:
758
- * - fromArray: proof_artifacts[] entries whose circuit discriminator
759
- * matches one of `circuitNames`. Each entry is treated as a proof
760
- * body for verification purposes; the entry SHOULD carry the same
761
- * `public_inputs` shape as a top-level zk_proof. Entries without a
762
- * usable proof body are still surfaced (the caller decides whether
763
- * to ignore them — e.g. when verification_status === 'pending').
764
- * - legacy: the top-level `artifact.zk_proof` if its circuit matches.
765
- *
766
- * The scan is shape-agnostic and does NOT mutate the artifact.
767
- */
768
- function collectMatchingProofs(artifact, circuitNames) {
769
- const wanted = new Set(circuitNames);
770
- const fromArray = [];
771
- const proofArtifacts = Array.isArray(artifact.proof_artifacts)
772
- ? artifact.proof_artifacts
773
- : [];
774
- for (const entry of proofArtifacts) {
775
- if (!entry || typeof entry !== 'object')
776
- continue;
777
- // Skip non-verified entries — they cannot be the source of a
778
- // verifiable anchor (pending or failed proofs do not bind a
779
- // commitment_root). 'verified' is the only status that should
780
- // contribute to anchoring; absence of the field is treated as
781
- // verified for forward-compat.
782
- const status = entry.verification_status;
783
- if (typeof status === 'string' && status !== 'verified')
784
- continue;
785
- const name = readCircuitName(entry);
786
- if (name && wanted.has(name)) {
787
- fromArray.push(entry);
788
- }
789
- }
790
- let legacy = null;
791
- const zkProof = artifact.zk_proof;
792
- if (zkProof && typeof zkProof === 'object') {
793
- const name = readCircuitName(zkProof);
794
- if (name && wanted.has(name))
795
- legacy = zkProof;
796
- }
797
- return { fromArray, legacy };
798
- }
799
- /**
800
- * Verify governance_upstream_vpec_inclusion / merkle_inclusion proofs
801
- * are bound to the artifact's commitment chain.
802
- *
803
- * Production multi-record artifacts emit per-circuit proofs in
804
- * `proof_artifacts[]` rather than in the legacy top-level `zk_proof`
805
- * field. Walking only `zk_proof` would silently accept artifacts whose
806
- * inclusion proof was never anchored.
807
- *
808
- * Two anchoring modes:
809
- *
810
- * A. Parent-anchored (preferred — Follow-6 fix, Follow-12 chain).
811
- * When the caller provides an `upstreamRootResolver` AND the
812
- * artifact declares `upstream_vpec_ids` (non-empty), the proof's
813
- * merkle_root MUST equal the chain-walk reduction:
814
- *
815
- * node = lineage_commitment(child_run_id, upstream_vpec_ids)
816
- * for ancestor_id in upstream_vpec_ids: // parent-first
817
- * node = Poseidon2(node, resolver(ancestor_id))
818
- * expected = node
819
- *
820
- * For length=1 this collapses to the legacy
821
- * `Poseidon2(lineage, parent_root)` reduction (byte-identical to
822
- * Follow-6). For length>1 (capped at 8) it walks every ancestor.
823
- * Any missing ancestor → 'unanchored' (cross-org chains verify
824
- * boundaries at receive time; here we warn rather than fail).
825
- *
826
- * B. Self-anchored fallback (legacy). When no resolver is provided OR
827
- * the artifact has no `upstream_vpec_ids`, fall back to matching
828
- * against `artifact.commitment_root_poseidon2` (the child's own
829
- * record-tree root) or the entry's own published `merkle_root`.
830
- * This branch preserves backward compat for callers that lack
831
- * upstream-store context (e.g. the CLI verifier when no SqliteStore
832
- * is attached) and for non-upstream inclusion circuits (CVE
833
- * inventory etc. that bind to an out-of-band tree).
834
- *
835
- * Logic:
836
- * 1. Collect every `proof_artifacts[]` entry whose
837
- * circuit_name (or `circuit`) is `governance_upstream_vpec_inclusion`
838
- * or `merkle_inclusion`.
839
- * 2. For multi-record artifacts (check_execution_records.length > 1),
840
- * reject if no matching entry was found AND no top-level zk_proof
841
- * of those circuits exists — emit
842
- * `no_proof_artifact_for_multi_record_artifact`.
843
- * 3. For each matching proof, choose the anchoring mode and compare.
844
- *
845
- * Returns:
846
- * - 'ok' / 'mismatch' / 'unanchored' / 'not_applicable' /
847
- * 'no_proof_artifact_for_multi_record_artifact'
848
- */
849
- /**
850
- * Match a precomputed expected merkle_root field against every
851
- * candidate proof's published merkle_root.
852
- *
853
- * Public input convention for merkle_inclusion (per
854
- * packages/zk-core/circuits/merkle_inclusion/src/main.nr):
855
- * public_inputs[0] = merkle_root
856
- * public_inputs[1] = leaf_exists (must be 1)
857
- *
858
- * Entry-level `proof.merkle_root` overrides public_inputs[0] when both
859
- * are present (cheaper and unambiguous).
860
- *
861
- * Returns 'ok' if every candidate matches the expected field, otherwise
862
- * 'mismatch'. Used by both Mode A priority-1 (artifact-stamped chain
863
- * roots) and Mode A priority-2 (resolver walk) so the comparison logic
864
- * stays byte-equivalent across paths.
865
- */
866
- function matchCandidatesAgainstExpectedRoot(candidates, expectedRootField) {
867
- for (const proof of candidates) {
868
- const inputs = proof.public_inputs;
869
- let proofMerkleRoot = null;
870
- if (Array.isArray(inputs) && inputs.length > 0) {
871
- const root = inputs[0];
872
- if (typeof root === 'string')
873
- proofMerkleRoot = root;
874
- }
875
- if (typeof proof.merkle_root === 'string') {
876
- proofMerkleRoot = proof.merkle_root;
877
- }
878
- if (proofMerkleRoot === null)
879
- return 'mismatch';
880
- const proofRootField = parsePoseidon2HashToField(proofMerkleRoot);
881
- if (proofRootField === null)
882
- return 'mismatch';
883
- if (proofRootField !== expectedRootField) {
884
- // The bug Follow-6 closes: a proof anchored to the CHILD's own
885
- // commitment_root_poseidon2 (which is fabricable) would have
886
- // matched in the legacy fallback. The parent-anchored expected
887
- // root is the only sound anchor for an upstream-VPEC inclusion
888
- // proof.
889
- return 'mismatch';
890
- }
891
- }
892
- return 'ok';
893
- }
894
- function verifyUpstreamVpecInclusionAnchoring(artifact, upstreamRootResolver) {
895
- const checkRecords = artifact.check_execution_records;
896
- const recordCount = Array.isArray(checkRecords) ? checkRecords.length : 0;
897
- const proofs = collectMatchingProofs(artifact, [
898
- 'governance_upstream_vpec_inclusion',
899
- 'merkle_inclusion',
900
- ]);
901
- if (recordCount > 1 && proofs.fromArray.length === 0) {
902
- if (proofs.legacy !== null) {
903
- return 'no_proof_artifact_for_multi_record_artifact';
904
- }
905
- }
906
- const candidates = [...proofs.fromArray];
907
- if (proofs.legacy && (recordCount <= 1 || proofs.fromArray.length > 0)) {
908
- candidates.push(proofs.legacy);
909
- }
910
- if (candidates.length === 0)
911
- return 'not_applicable';
912
- // ── Mode A: parent-anchored path (Follow-6) ──
913
- //
914
- // Triggered when the artifact declares an upstream chain. The proof's
915
- // merkle_root MUST equal the parent-first chain reduction
916
- //
917
- // node = lineage_commitment(child_run_id, upstream_vpec_ids)
918
- // for ancestor_root in chain_roots:
919
- // node = Poseidon2(node, ancestor_root)
920
- // expected = node // == topmost ancestor's commitment_root_poseidon2
921
- //
922
- // Chain-roots resolution priority (parent-first list of fields):
923
- // 1. `artifact.upstream_vpec_chain_roots` — when present and
924
- // non-empty, this is the canonical witness/snapshot-stamped list
925
- // (PolicySnapshotService.openRun → witness dispatcher path).
926
- // Length MUST equal `upstream_vpec_ids` length to bind one
927
- // ancestor root per declared upstream id (mirrors
928
- // verifier-py/verifier.py:639-640).
929
- // 2. `upstreamRootResolver(vpec_id)` for every id in
930
- // `upstream_vpec_ids` — used when the host injects a local
931
- // upstream-VPEC store but the artifact does not stamp the chain.
932
- // 3. Depth-1 legacy fallback handled below in Mode B when neither
933
- // chain_roots nor a resolver is available.
934
- //
935
- // Note we ONLY engage Mode A when upstream_vpec_ids has at least one
936
- // entry; a resolver passed alongside a non-upstream artifact (no
937
- // chain) falls back to Mode B as today.
938
- const VERIFIER_MAX_CHAIN_DEPTH = 8;
939
- const upstreamVpecIdsRaw = artifact.upstream_vpec_ids;
940
- const upstreamVpecIds = Array.isArray(upstreamVpecIdsRaw)
941
- ? upstreamVpecIdsRaw.filter((x) => typeof x === 'string')
942
- : [];
943
- const chainRootsRaw = artifact.upstream_vpec_chain_roots;
944
- const hasArtifactChainRoots = Array.isArray(chainRootsRaw) && chainRootsRaw.length > 0;
945
- if (upstreamVpecIds.length > 0 && hasArtifactChainRoots) {
946
- // ── Priority 1: artifact.upstream_vpec_chain_roots ──
947
- //
948
- // Length mismatch is a hard 'mismatch' (mirrors verifier-py priority
949
- // 1: declared chain length disagrees with declared upstream ids ⇒
950
- // the proof is bound to a different chain than the artifact claims).
951
- if (chainRootsRaw.length !== upstreamVpecIds.length) {
952
- return 'mismatch';
953
- }
954
- // Verifier-side TS cap mirrors the witness builder + policy
955
- // snapshot: the Noir circuit supports up to depth 20 but the TS
956
- // layer caps at 8 as a safety margin. Beyond the cap we treat the
957
- // proof as unanchored rather than risk a partial walk.
958
- if (chainRootsRaw.length > VERIFIER_MAX_CHAIN_DEPTH) {
959
- return 'unanchored';
960
- }
961
- const chainFields = [];
962
- for (const raw of chainRootsRaw) {
963
- if (typeof raw !== 'string' || raw.length === 0)
964
- return 'unanchored';
965
- const field = parsePoseidon2HashToField(raw);
966
- if (field === null)
967
- return 'unanchored';
968
- chainFields.push(field);
969
- }
970
- const runId = typeof artifact.run_id === 'string' ? artifact.run_id : '';
971
- const lineage = computeLineageCommitmentLocal(runId, upstreamVpecIds);
972
- let node = lineage;
973
- for (const ancestorField of chainFields) {
974
- node = poseidon2Hash([node, ancestorField]);
975
- }
976
- return matchCandidatesAgainstExpectedRoot(candidates, node);
977
- }
978
- if (upstreamRootResolver && upstreamVpecIds.length > 0) {
979
- // ── Priority 2: resolver-walk (PR #89 path) ──
980
- if (upstreamVpecIds.length > VERIFIER_MAX_CHAIN_DEPTH) {
981
- return 'unanchored';
982
- }
983
- const runId = typeof artifact.run_id === 'string' ? artifact.run_id : '';
984
- const lineage = computeLineageCommitmentLocal(runId, upstreamVpecIds);
985
- // ── Chain-walk anchor (Follow-12) ──
986
- //
987
- // length === 1: depth-1 single-leaf path. expected = Poseidon2(lineage, parent_root).
988
- // length > 1: depth-N chain walk. Resolve EVERY ancestor's root in
989
- // parent-first order and reduce
990
- // node = lineage
991
- // for ancestor_root in chain_roots:
992
- // node = Poseidon2(node, ancestor_root)
993
- // expected = node // == topmost ancestor's published root
994
- //
995
- // The reduction MUST match `buildUpstreamVpecInclusionWitness` and
996
- // `PolicySnapshotService.openRun`. Any divergence breaks anchoring
997
- // for legitimate proofs.
998
- let expectedRootField;
999
- if (upstreamVpecIds.length === 1) {
1000
- const directParentId = upstreamVpecIds[0];
1001
- const parentRoot = upstreamRootResolver(directParentId);
1002
- if (typeof parentRoot !== 'string' || parentRoot.length === 0) {
1003
- // Offline-tolerant: parent root not locally available. Cross-org
1004
- // chains verify the boundary at receive time; here we warn rather
1005
- // than fail. Caller surfaces this as the
1006
- // `upstream_vpec_proof_no_anchor_root_in_artifact` warning.
1007
- return 'unanchored';
1008
- }
1009
- const parentRootField = parsePoseidon2HashToField(parentRoot);
1010
- if (parentRootField === null)
1011
- return 'unanchored';
1012
- expectedRootField = poseidon2Hash([lineage, parentRootField]);
1013
- }
1014
- else {
1015
- // Walk every ancestor in parent-first order. If ANY is missing,
1016
- // emit 'unanchored' (caller surfaces the no-anchor warning) —
1017
- // matching the depth-1 miss-handling semantics. Partial walks
1018
- // would silently anchor against a wrong terminal.
1019
- let node = lineage;
1020
- for (const ancestorId of upstreamVpecIds) {
1021
- const root = upstreamRootResolver(ancestorId);
1022
- if (typeof root !== 'string' || root.length === 0) {
1023
- return 'unanchored';
1024
- }
1025
- const rootField = parsePoseidon2HashToField(root);
1026
- if (rootField === null)
1027
- return 'unanchored';
1028
- node = poseidon2Hash([node, rootField]);
1029
- }
1030
- expectedRootField = node;
1031
- }
1032
- return matchCandidatesAgainstExpectedRoot(candidates, expectedRootField);
1033
- }
1034
- // ── Mode B: self-anchored / legacy fallback ──
1035
- //
1036
- // Cross-org chain: a proof exists but the artifact carries no
1037
- // commitment_root_poseidon2 to anchor against AND no per-entry
1038
- // merkle_root binding. The cross-org boundary verifier checks the
1039
- // anchor at receive time; here we surface a warning so callers know
1040
- // the binding wasn't checked locally.
1041
- const hasArtifactRoot = typeof artifact.commitment_root_poseidon2 === 'string' &&
1042
- artifact.commitment_root_poseidon2.length > 0;
1043
- const hasPerEntryRoot = candidates.some((p) => typeof p.merkle_root === 'string');
1044
- if (!hasArtifactRoot && !hasPerEntryRoot)
1045
- return 'unanchored';
1046
- // Acceptable anchors:
1047
- // - artifact.commitment_root_poseidon2 (record-tree root)
1048
- // - per-entry merkle_root (when the proof binds to an out-of-band
1049
- // vulnerability tree root, e.g. CVE inventory)
1050
- // Either is acceptable per-entry; we accept whichever the prover chose.
1051
- const artifactRootField = parsePoseidon2HashToField(typeof artifact.commitment_root_poseidon2 === 'string'
1052
- ? artifact.commitment_root_poseidon2
1053
- : '');
1054
- for (const proof of candidates) {
1055
- // Public input convention for merkle_inclusion (per
1056
- // packages/zk-core/circuits/merkle_inclusion/src/main.nr):
1057
- // public_inputs[0] = merkle_root
1058
- // public_inputs[1] = leaf_exists (must be 1)
1059
- const inputs = proof.public_inputs;
1060
- let proofMerkleRoot = null;
1061
- if (Array.isArray(inputs) && inputs.length > 0) {
1062
- const root = inputs[0];
1063
- if (typeof root === 'string')
1064
- proofMerkleRoot = root;
1065
- }
1066
- // Entry-level explicit binding: prefer this when the prover ships
1067
- // it (cheaper than parsing public_inputs and gives a clear field).
1068
- if (typeof proof.merkle_root === 'string') {
1069
- proofMerkleRoot = proof.merkle_root;
1070
- }
1071
- if (proofMerkleRoot === null)
1072
- return 'mismatch';
1073
- const proofRootField = parsePoseidon2HashToField(proofMerkleRoot);
1074
- let matched = false;
1075
- // Match path 1: artifact's record-tree Poseidon2 root.
1076
- if (artifactRootField !== null &&
1077
- proofRootField !== null &&
1078
- proofRootField === artifactRootField) {
1079
- matched = true;
1080
- }
1081
- // Match path 2: per-entry merkle_root reproduced verbatim from the
1082
- // entry's own commitment field. When the prover binds to an
1083
- // out-of-band tree (CVE inventory etc.) the entry MUST publish the
1084
- // root it bound to so the verifier can confirm the public input is
1085
- // the same. Mismatch here is a hard fail.
1086
- if (!matched && typeof proof.merkle_root === 'string') {
1087
- // String equality on canonical "poseidon2:hex" form.
1088
- if (proof.merkle_root === proofMerkleRoot) {
1089
- // Re-anchor to a per-entry binding: this is informational, not
1090
- // a true cryptographic anchor unless the entry is itself bound
1091
- // into the signed VPEC body — which it IS (proof_artifacts[]
1092
- // is part of the canonical body). So we accept.
1093
- matched = true;
1094
- }
1095
- }
1096
- if (!matched)
1097
- return 'mismatch';
1098
- }
1099
- return 'ok';
1100
- }
1101
- /**
1102
- * Extract base64url key from PEM or raw base64url string.
1103
- * Handles both PEM-wrapped keys and raw base64url.
1104
- */
1105
- function extractKeyFromPem(pem) {
1106
- const cleaned = pem.trim();
1107
- if (cleaned.includes('-----BEGIN')) {
1108
- try {
1109
- const jwk = createPublicKey(cleaned).export({ format: 'jwk' });
1110
- if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519' && typeof jwk.x === 'string') {
1111
- return jwk.x;
1112
- }
1113
- }
1114
- catch {
1115
- // Fall through to the legacy normalization path below.
1116
- }
1117
- const b64 = cleaned
1118
- .replace(/-----BEGIN [A-Z ]+-----/g, '')
1119
- .replace(/-----END [A-Z ]+-----/g, '')
1120
- .replace(/\s/g, '');
1121
- return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1122
- }
1123
- return cleaned.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1124
- }
1125
- // ── RFC 3161 Timestamp Imprint Verification ──
1126
- /**
1127
- * Verify the message imprint inside an RFC 3161 TimeStampResp matches
1128
- * SHA-256(canonical(documentBody)).
1129
- *
1130
- * Parses enough DER to extract the hashed message from the MessageImprint
1131
- * field. Returns true if imprint matches, false if mismatch, null if unparseable.
1132
- */
1133
- function verifyTimestampImprint(tsTokenB64, documentBody) {
1134
- try {
1135
- const tsResp = Buffer.from(tsTokenB64, 'base64');
1136
- // Find SHA-256 OID (2.16.840.1.101.3.4.2.1) in the DER
1137
- const sha256Oid = Buffer.from([0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01]);
1138
- const oidIdx = findBuffer(tsResp, sha256Oid);
1139
- if (oidIdx === -1)
1140
- return null;
1141
- // The message imprint hash follows the AlgorithmIdentifier.
1142
- // After OID + NULL, look for OCTET STRING (0x04) containing 32-byte hash.
1143
- const searchStart = oidIdx + sha256Oid.length;
1144
- for (let i = searchStart; i < Math.min(searchStart + 20, tsResp.length - 33); i++) {
1145
- if (tsResp[i] === 0x04 && tsResp[i + 1] === 0x20) {
1146
- const extractedHash = tsResp.subarray(i + 2, i + 2 + 32);
1147
- // Recompute expected hash
1148
- const canonicalDoc = canonical(documentBody);
1149
- const expectedHash = createHash('sha256').update(canonicalDoc).digest();
1150
- return extractedHash.equals(expectedHash);
1151
- }
1152
- }
1153
- return null; // Could not find hash in DER
1154
- }
1155
- catch {
1156
- return null;
1157
- }
1158
- }
1159
- function findBuffer(haystack, needle) {
1160
- for (let i = 0; i <= haystack.length - needle.length; i++) {
1161
- if (haystack.subarray(i, i + needle.length).equals(needle))
1162
- return i;
1163
- }
1164
- return -1;
1165
- }
1166
- // ── Rekor Status Check ──
1167
- const REKOR_API = 'https://rekor.sigstore.dev/api/v1';
1168
- /**
1169
- * Check Rekor for key revocation by querying with SHA-256 fingerprint
1170
- * of the public key bytes.
1171
- */
1172
- async function checkRekor(publicKeyB64Url, kid) {
1173
- try {
1174
- // Decode public key bytes and compute SHA-256 fingerprint
1175
- const keyBytes = fromBase64Url(publicKeyB64Url);
1176
- const fingerprint = createHash('sha256').update(Buffer.from(keyBytes)).digest('hex');
1177
- // Search Rekor index by key fingerprint
1178
- const resp = await fetch(`${REKOR_API}/index/retrieve`, {
1179
- method: 'POST',
1180
- headers: { 'Content-Type': 'application/json' },
1181
- body: JSON.stringify({ hash: `sha256:${fingerprint}` }),
1182
- signal: AbortSignal.timeout(5000),
1183
- });
1184
- if (!resp.ok) {
1185
- return 'unavailable';
1186
- }
1187
- const entries = await resp.json();
1188
- if (!entries || entries.length === 0) {
1189
- // No entries found — key has not been submitted to Rekor (not necessarily bad)
1190
- return 'not_found';
1191
- }
1192
- // Key found in Rekor — it's been logged (active, not revoked)
1193
- return 'active';
1194
- }
1195
- catch {
1196
- return 'unavailable';
1197
- }
1198
- }
1199
- // ── ZK Proof Verification (UltraHonk) ──
1200
- /**
1201
- * Verify an UltraHonk ZK proof using @aztec/bb.js.
1202
- *
1203
- * Requires:
1204
- * - zk_proof.proof: base64-encoded proof bytes
1205
- * - zk_proof.public_inputs: array of field elements (hex strings)
1206
- * - zk_proof.verification_key: base64-encoded verification key
1207
- *
1208
- * Returns true if valid, false if invalid, null if verification unavailable.
1209
- */
1210
- async function verifyUltraHonk(zkProof) {
1211
- try {
1212
- // Dynamic import — @aztec/bb.js is an optional dependency
1213
- // @ts-expect-error — @aztec/bb.js is optional, may not have type declarations
1214
- const bb = await import('@aztec/bb.js').catch(() => null);
1215
- if (!bb) {
1216
- return null; // bb.js not available
1217
- }
1218
- const proofB64 = zkProof.proof;
1219
- const publicInputs = zkProof.public_inputs;
1220
- const vkB64 = zkProof.verification_key;
1221
- if (!proofB64 || !publicInputs || !vkB64) {
1222
- return false;
1223
- }
1224
- const proofBytes = Buffer.from(proofB64, 'base64');
1225
- const vkBytes = Buffer.from(vkB64, 'base64');
1226
- // UltraHonk verification
1227
- const api = await bb.newBarretenbergApiAsync();
1228
- const valid = await api.acirVerifyUltraHonk(proofBytes, vkBytes);
1229
- return valid;
1230
- }
1231
- catch {
1232
- return null; // Verification error — treat as unavailable, not invalid
1233
- }
1234
- }
1235
- //# sourceMappingURL=verifier.js.map