@primust/verifier 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-LTWQK3HT.js +432 -0
- package/dist/chunk-NOADQWB6.js +3012 -0
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +309 -361
- package/dist/index.d.ts +335 -13
- package/dist/index.js +1181 -13
- package/dist/tsa-chain-7KSQ5LAH.js +235 -0
- package/dist/v29-envelope-GFVVA2S6.js +42 -0
- package/package.json +7 -8
- package/dist/bounded-trace.d.ts +0 -46
- package/dist/bounded-trace.d.ts.map +0 -1
- package/dist/bounded-trace.js +0 -558
- package/dist/bounded-trace.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/key-cache.d.ts +0 -20
- package/dist/key-cache.d.ts.map +0 -1
- package/dist/key-cache.js +0 -68
- package/dist/key-cache.js.map +0 -1
- package/dist/scoped.d.ts +0 -35
- package/dist/scoped.d.ts.map +0 -1
- package/dist/scoped.js +0 -582
- package/dist/scoped.js.map +0 -1
- package/dist/types.d.ts +0 -60
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/dist/upstream_resolver.d.ts +0 -60
- package/dist/upstream_resolver.d.ts.map +0 -1
- package/dist/upstream_resolver.js +0 -126
- package/dist/upstream_resolver.js.map +0 -1
- package/dist/v29-envelope.d.ts +0 -55
- package/dist/v29-envelope.d.ts.map +0 -1
- package/dist/v29-envelope.js +0 -450
- package/dist/v29-envelope.js.map +0 -1
- package/dist/verifier.d.ts +0 -36
- package/dist/verifier.d.ts.map +0 -1
- package/dist/verifier.js +0 -1235
- package/dist/verifier.js.map +0 -1
- package/dist/verifier.test.d.ts +0 -2
- package/dist/verifier.test.d.ts.map +0 -1
- package/dist/verifier.test.js +0 -395
- package/dist/verifier.test.js.map +0 -1
- package/dist/verify-html-template.d.ts +0 -45
- package/dist/verify-html-template.d.ts.map +0 -1
- package/dist/verify-html-template.js +0 -182
- 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
|