@sanning/proof 0.8.2 → 0.9.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/README.md +1 -1
- package/dist/cli.js +13 -2
- package/dist/evidence.js +164 -23
- package/dist/verifier.js +98 -21
- package/package.json +1 -1
- package/src/cli.ts +13 -2
- package/src/evidence.ts +169 -22
- package/src/verifier.ts +100 -18
package/README.md
CHANGED
|
@@ -203,7 +203,7 @@ The primary signature covers `JCS(envelope minus signature minus co_signatures)`
|
|
|
203
203
|
|
|
204
204
|
## Why you can trust it (without trusting us)
|
|
205
205
|
|
|
206
|
-
Three **independent implementations** of this kernel exist: this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and and the MIT-licensed Go reference. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against one shared corpus, pinned per file by SHA-256 at cut tag `test-vectors-v3.
|
|
206
|
+
Three **independent implementations** of this kernel exist: this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and and the MIT-licensed Go reference. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against one shared corpus, pinned per file by SHA-256 at cut tag `test-vectors-v3.4`, and a cross-kernel agreement gate re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
|
|
207
207
|
|
|
208
208
|
That mutual gate is the point: anyone can verify these provenance records with **no Sanning code in the trust path**, write your own verifier against the specs that ship inside the [`sanning-proof`](https://pypi.org/project/sanning-proof/) sdist, and the corpus will tell you if it is conformant.
|
|
209
209
|
|
package/dist/cli.js
CHANGED
|
@@ -639,14 +639,25 @@ function reportEvidence(result, path, gateways, io, chains) {
|
|
|
639
639
|
io.out("");
|
|
640
640
|
io.out(paint(" Events", DIM));
|
|
641
641
|
for (const ev of result.events) {
|
|
642
|
+
// `~` is "not determined", and the words after it must say only that.
|
|
643
|
+
// This read "record withheld", which is a statement about the PRODUCER:
|
|
644
|
+
// it says the customer chose minimum disclosure and kept these bytes
|
|
645
|
+
// back, which is legitimate and expected. An event whose envelope was
|
|
646
|
+
// never an envelope produces the same `null` on this dimension, and
|
|
647
|
+
// there the sentence is simply untrue: the record may be sitting in the
|
|
648
|
+
// bundle, and nothing about the producer has been established either
|
|
649
|
+
// way. A renderer cannot tell the two apart from a tri-state, so it
|
|
650
|
+
// states what the verifier did and leaves WHY to the error lines below,
|
|
651
|
+
// which now name the malformed envelope instead of a signature check
|
|
652
|
+
// that never ran (proof#76).
|
|
642
653
|
const binding = ev.payloadBindingOk === null
|
|
643
|
-
? paint("~ record
|
|
654
|
+
? paint("~ record not checked", Y)
|
|
644
655
|
: `record ${mark(ev.payloadBindingOk)}`;
|
|
645
656
|
// Show the content (logs) segment only when content was disclosed and
|
|
646
657
|
// evaluated (non-null) — undisclosed content is the default and would just
|
|
647
658
|
// be noise on every line.
|
|
648
659
|
const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
|
|
649
|
-
io.out(` ${mark(ev.ok)} ${shortId(ev.eventId)}
|
|
660
|
+
io.out(` ${mark(ev.ok)} ${shortId(ev.eventId)} envelope ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`);
|
|
650
661
|
for (const e of ev.errors)
|
|
651
662
|
io.out(paint(` - ${e}`, R));
|
|
652
663
|
}
|
package/dist/evidence.js
CHANGED
|
@@ -103,6 +103,19 @@ function malformed(error, specVersionOk = false) {
|
|
|
103
103
|
function stringifyErr(e) {
|
|
104
104
|
return e instanceof Error ? e.message : String(e);
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Spell a value inside a finding the way the Python kernel spells it.
|
|
108
|
+
*
|
|
109
|
+
* `JSON.stringify(undefined)` is `undefined` (the value, not a string), so an
|
|
110
|
+
* absent field interpolates as the word `undefined` where Python writes `null`.
|
|
111
|
+
* One word, and a real difference in what an auditor reads: someone comparing
|
|
112
|
+
* the two CLIs on one bundle sees two different sentences for one fact. Both
|
|
113
|
+
* kernels route every quoted value through this spelling, and the cross-kernel
|
|
114
|
+
* anchor-trace leg byte-compares them.
|
|
115
|
+
*/
|
|
116
|
+
function jsonStr(value) {
|
|
117
|
+
return JSON.stringify(value === undefined ? null : value) ?? "null";
|
|
118
|
+
}
|
|
106
119
|
// Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
|
|
107
120
|
//
|
|
108
121
|
// Returns a structured per-checkpoint + per-event result and a recomputed
|
|
@@ -279,7 +292,7 @@ async function resolveCheckpointKey(cp, recordBytes, isV1) {
|
|
|
279
292
|
const errors = [];
|
|
280
293
|
if (isV1) {
|
|
281
294
|
if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
|
|
282
|
-
errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref)
|
|
295
|
+
errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) -- " +
|
|
283
296
|
"one bundle speaks one vocabulary");
|
|
284
297
|
return { key: null, anchorRef: null, errors };
|
|
285
298
|
}
|
|
@@ -293,7 +306,7 @@ async function resolveCheckpointKey(cp, recordBytes, isV1) {
|
|
|
293
306
|
return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
|
|
294
307
|
}
|
|
295
308
|
if (cp.tx_id !== undefined) {
|
|
296
|
-
errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id
|
|
309
|
+
errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id -- one bundle speaks one vocabulary");
|
|
297
310
|
return { key: null, anchorRef: null, errors };
|
|
298
311
|
}
|
|
299
312
|
if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
|
|
@@ -302,7 +315,7 @@ async function resolveCheckpointKey(cp, recordBytes, isV1) {
|
|
|
302
315
|
}
|
|
303
316
|
if (recordBytes === undefined) {
|
|
304
317
|
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
305
|
-
errors.push("checkpoint_ref cannot be verified
|
|
318
|
+
errors.push("checkpoint_ref cannot be verified -- record_bytes missing or not hex");
|
|
306
319
|
return { key: null, anchorRef: null, errors };
|
|
307
320
|
}
|
|
308
321
|
const derived = await sha256Hex(recordBytes);
|
|
@@ -377,7 +390,29 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
377
390
|
const byKey = new Map();
|
|
378
391
|
const anchorByKey = new Map();
|
|
379
392
|
const checkpointResults = [];
|
|
380
|
-
for (const
|
|
393
|
+
for (const rawCp of body.checkpoints) {
|
|
394
|
+
// `checkpoints[]` is attacker-shaped, and a verifier must never raise on
|
|
395
|
+
// adversarial input: a null entry used to reach `cp.tx_id` and hand the
|
|
396
|
+
// caller a TypeError instead of a verdict, which an auditor cannot tell
|
|
397
|
+
// apart from a broken tool. Graded and reported as "not a checkpoint"
|
|
398
|
+
// rather than coerced into an empty one: probing the envelope layer with
|
|
399
|
+
// nothing asks it a question about an object that was never an envelope.
|
|
400
|
+
// Same treatment, same words, as the §5.3 path's `events[] entry is not an
|
|
401
|
+
// object`.
|
|
402
|
+
if (rawCp === null || typeof rawCp !== "object" || Array.isArray(rawCp)) {
|
|
403
|
+
checkpointResults.push({
|
|
404
|
+
checkpointRef: "",
|
|
405
|
+
anchorRef: null,
|
|
406
|
+
envelopeOk: false,
|
|
407
|
+
merkleRootOk: false,
|
|
408
|
+
onChainOk: null,
|
|
409
|
+
onChain: null,
|
|
410
|
+
ok: false,
|
|
411
|
+
errors: ["checkpoints[] entry is not an object"],
|
|
412
|
+
});
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const cp = rawCp;
|
|
381
416
|
const cpErrors = [];
|
|
382
417
|
let envelopeOk = false;
|
|
383
418
|
let merkleRootOk = false;
|
|
@@ -393,9 +428,20 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
393
428
|
}
|
|
394
429
|
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
395
430
|
cpErrors.push(...keyed.errors);
|
|
431
|
+
// A duplicate key is a HARD failure, not a note. `byKey` below keeps the
|
|
432
|
+
// FIRST occurrence, so a second checkpoint claiming the same key is
|
|
433
|
+
// verified against the first one's envelope and its own stated identity is
|
|
434
|
+
// never independently checked. Recording the finding and leaving `ok` true
|
|
435
|
+
// let the bundle roll up to `verified` while carrying a checkpoint nothing
|
|
436
|
+
// had verified: a false pass, which is what `evidence-bundle.md` §3
|
|
437
|
+
// principle 3 exists to prevent (the displayed verdict must be the
|
|
438
|
+
// recomputed one, and here the recomputation quietly dropped a finding it
|
|
439
|
+
// had made).
|
|
440
|
+
let duplicateKey = false;
|
|
396
441
|
if (keyed.key !== null) {
|
|
397
442
|
if (byKey.has(keyed.key)) {
|
|
398
443
|
cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
|
|
444
|
+
duplicateKey = true;
|
|
399
445
|
}
|
|
400
446
|
else {
|
|
401
447
|
byKey.set(keyed.key, cp);
|
|
@@ -410,12 +456,26 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
410
456
|
// payload_hash. An undetermined binding (no record) is not enough to
|
|
411
457
|
// anchor the events' inclusion claims to a trustworthy root.
|
|
412
458
|
envelopeOk = res.ok && res.payloadHashOk === true;
|
|
413
|
-
|
|
459
|
+
// See the event loop below: a degenerate envelope is a malformed input,
|
|
460
|
+
// not a failed check (proof#76).
|
|
461
|
+
if (!isJsonObject(cp.envelope)) {
|
|
462
|
+
cpErrors.push("checkpoint envelope is not a JSON object");
|
|
463
|
+
}
|
|
464
|
+
else if (!res.signatureOk) {
|
|
414
465
|
cpErrors.push("checkpoint envelope signature failed");
|
|
466
|
+
}
|
|
415
467
|
if (res.payloadHashOk === false)
|
|
416
468
|
cpErrors.push("checkpoint record does not bind to payload_hash");
|
|
417
|
-
|
|
418
|
-
|
|
469
|
+
// WHY the binding is undetermined decides which sentence is true.
|
|
470
|
+
// record_bytes really absent is one thing; record_bytes present and
|
|
471
|
+
// unbindable (a degenerate envelope, unreadable hex) is another, and
|
|
472
|
+
// naming the wrong cause sends a reader to look for a field that is
|
|
473
|
+
// sitting right there (proof#76).
|
|
474
|
+
if (res.payloadHashOk === null) {
|
|
475
|
+
cpErrors.push(recordBytes === undefined
|
|
476
|
+
? "checkpoint record_bytes missing -- root untrusted"
|
|
477
|
+
: "checkpoint record binding not checked: root untrusted");
|
|
478
|
+
}
|
|
419
479
|
}
|
|
420
480
|
catch (e) {
|
|
421
481
|
cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
|
|
@@ -428,7 +488,8 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
428
488
|
const committedRoot = record.event?.merkle_root;
|
|
429
489
|
merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
|
|
430
490
|
if (!merkleRootOk) {
|
|
431
|
-
cpErrors.push(`checkpoint merkle_root mismatch: claimed=${cp.merkle_root}
|
|
491
|
+
cpErrors.push(`checkpoint merkle_root mismatch: claimed=${jsonStr(cp.merkle_root)} ` +
|
|
492
|
+
`committed=${jsonStr(committedRoot)}`);
|
|
432
493
|
}
|
|
433
494
|
}
|
|
434
495
|
catch (e) {
|
|
@@ -448,13 +509,30 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
448
509
|
// standing rule is that malformed is a failure while absent is
|
|
449
510
|
// undetermined (the same rule disclosed `content` follows). Without
|
|
450
511
|
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
451
|
-
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
|
|
512
|
+
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
|
|
452
513
|
errors: cpErrors,
|
|
453
514
|
});
|
|
454
515
|
}
|
|
455
516
|
// --- Step 4: per-event ------------------------------------------------------
|
|
456
517
|
const eventResults = [];
|
|
457
|
-
for (const
|
|
518
|
+
for (const rawEv of body.events) {
|
|
519
|
+
// Same rule as `checkpoints[]` above, and the same words the §5.3 path
|
|
520
|
+
// already uses: a null or non-object entry is graded as not an event,
|
|
521
|
+
// never raised on.
|
|
522
|
+
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
523
|
+
eventResults.push({
|
|
524
|
+
eventId: "<unknown>",
|
|
525
|
+
envelopeOk: false,
|
|
526
|
+
payloadBindingOk: null,
|
|
527
|
+
inclusionOk: false,
|
|
528
|
+
checkpointBound: false,
|
|
529
|
+
contentOk: null,
|
|
530
|
+
ok: false,
|
|
531
|
+
errors: ["events[] entry is not an object"],
|
|
532
|
+
});
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const ev = rawEv;
|
|
458
536
|
const evErrors = [];
|
|
459
537
|
const eventId = getEventId(ev.envelope);
|
|
460
538
|
let envelopeOk = false;
|
|
@@ -471,8 +549,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
471
549
|
const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
|
|
472
550
|
envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
|
|
473
551
|
payloadBindingOk = res.payloadHashOk;
|
|
474
|
-
|
|
552
|
+
// P-10: report what could not be determined, never a fault that was never
|
|
553
|
+
// observed. There is no envelope here, so no signature was checked and no
|
|
554
|
+
// binding was attempted. "signature failed" named a check that never ran,
|
|
555
|
+
// and it was the only thing said about an event whose undetermined
|
|
556
|
+
// binding then rendered as a WITHHELD record, a claim about the
|
|
557
|
+
// producer's disclosure choice out of an input that says nothing about
|
|
558
|
+
// the producer at all (proof#76).
|
|
559
|
+
if (!isJsonObject(ev.envelope)) {
|
|
560
|
+
evErrors.push("event envelope is not a JSON object");
|
|
561
|
+
}
|
|
562
|
+
else if (!res.signatureOk) {
|
|
475
563
|
evErrors.push("event envelope signature failed");
|
|
564
|
+
}
|
|
476
565
|
if (res.payloadHashOk === false)
|
|
477
566
|
evErrors.push("event record does not bind to payload_hash");
|
|
478
567
|
}
|
|
@@ -496,7 +585,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
496
585
|
evErrors.push("event missing inclusion proof");
|
|
497
586
|
}
|
|
498
587
|
else if (strayKey !== undefined) {
|
|
499
|
-
evErrors.push(`event inclusion carries ${wrongField} in a ${bodyType} body
|
|
588
|
+
evErrors.push(`event inclusion carries ${wrongField} in a ${bodyType} body -- one bundle speaks one vocabulary`);
|
|
500
589
|
}
|
|
501
590
|
else if (typeof rawKey !== "string") {
|
|
502
591
|
evErrors.push(`event inclusion missing ${keyField}`);
|
|
@@ -511,7 +600,14 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
511
600
|
let inclusionOk = false;
|
|
512
601
|
if (incl && checkpointBound && cp) {
|
|
513
602
|
try {
|
|
514
|
-
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
603
|
+
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
604
|
+
// No `?? ""` fallback. A checkpoint that claims NO root must not be
|
|
605
|
+
// handed an invented empty one: the Python kernel refuses the same
|
|
606
|
+
// value and reports the decode failure, so substituting "" here had
|
|
607
|
+
// the two kernels say different things about one bundle (TS: "the
|
|
608
|
+
// proof did not reconstruct the checkpoint root", which is not what
|
|
609
|
+
// happened: there was no claimed root to reconstruct).
|
|
610
|
+
hexToBytes(cp.merkle_root));
|
|
515
611
|
if (!inclusionOk)
|
|
516
612
|
evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
517
613
|
// Defense in depth: the claimed leaf_hash MUST be the hash of the
|
|
@@ -575,7 +671,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
575
671
|
onChainOk: null,
|
|
576
672
|
};
|
|
577
673
|
cpr.onChainOk = null;
|
|
578
|
-
cpr.errors.push("checkpoint has no anchor_ref
|
|
674
|
+
cpr.errors.push("checkpoint has no anchor_ref -- not yet witnessed, nothing to re-fetch");
|
|
579
675
|
continue;
|
|
580
676
|
}
|
|
581
677
|
const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
|
|
@@ -590,7 +686,17 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
590
686
|
// gateway (null) leaves the offline-sound checkpoint ok; the run
|
|
591
687
|
// downgrades to "partial" (exit 3) via gatewayUnavailable, not a hard
|
|
592
688
|
// failure. A match (true) keeps it ok.
|
|
593
|
-
|
|
689
|
+
// AND into the verdict already computed, never rebuild it from a
|
|
690
|
+
// hand-listed set of dimensions. Rebuilding silently dropped every
|
|
691
|
+
// term not in that list: the duplicate-key gate (proof#28) and the
|
|
692
|
+
// `keyed.errors` term with it, so supplying a gateway turned a hard
|
|
693
|
+
// reject back into `verified` and an UNREACHABLE gateway turned it
|
|
694
|
+
// into `partial` (exit 3), which reads as a network problem an
|
|
695
|
+
// operator retries. Neither is conditioned on a gateway by the spec:
|
|
696
|
+
// `evidence-bundle.md` says two checkpoints carrying one key is a
|
|
697
|
+
// hard reject, full stop. Written this way, a term added at
|
|
698
|
+
// construction survives here by construction.
|
|
699
|
+
cpr.ok = cpr.ok && cpr.onChainOk !== false;
|
|
594
700
|
}
|
|
595
701
|
}
|
|
596
702
|
}
|
|
@@ -669,6 +775,14 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
669
775
|
// slice claim resting on them is unfounded — and unlike a bad signature,
|
|
670
776
|
// nothing else in the stack would ever notice.
|
|
671
777
|
// ===========================================================================
|
|
778
|
+
// A JSON object, as opposed to null, an array, a string or a number. Used at
|
|
779
|
+
// every point where a nested field MUST be an object before any check of it
|
|
780
|
+
// means anything: probing a verifier with a non-object asks a question about
|
|
781
|
+
// something that was never the thing, and the answer must say so rather than
|
|
782
|
+
// name a check that never ran (proof#76).
|
|
783
|
+
function isJsonObject(v) {
|
|
784
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
785
|
+
}
|
|
672
786
|
/** Lowercase hex, even length, non-empty. The family's serialization rule. */
|
|
673
787
|
function isLowerHex(s, nibbles) {
|
|
674
788
|
if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0)
|
|
@@ -740,7 +854,7 @@ async function verifyDisclosedContent(ev, eventId, recordBytes, sideContent) {
|
|
|
740
854
|
contentOk = null;
|
|
741
855
|
if (disclosed !== undefined && committed.length === 0) {
|
|
742
856
|
evErrors.push("disclosed content present but no committed content_hash to bind to " +
|
|
743
|
-
"(record withheld or record has no event.content_hash)
|
|
857
|
+
"(record withheld or record has no event.content_hash) -- undetermined");
|
|
744
858
|
}
|
|
745
859
|
}
|
|
746
860
|
else {
|
|
@@ -1105,8 +1219,19 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1105
1219
|
const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
|
|
1106
1220
|
envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
|
|
1107
1221
|
payloadBindingOk = res.payloadHashOk;
|
|
1108
|
-
|
|
1222
|
+
// P-10: report what could not be determined, never a fault that was never
|
|
1223
|
+
// observed. There is no envelope here, so no signature was checked and no
|
|
1224
|
+
// binding was attempted. "signature failed" named a check that never ran,
|
|
1225
|
+
// and it was the only thing said about an event whose undetermined
|
|
1226
|
+
// binding then rendered as a WITHHELD record, a claim about the
|
|
1227
|
+
// producer's disclosure choice out of an input that says nothing about
|
|
1228
|
+
// the producer at all (proof#76).
|
|
1229
|
+
if (!isJsonObject(ev.envelope)) {
|
|
1230
|
+
evErrors.push("event envelope is not a JSON object");
|
|
1231
|
+
}
|
|
1232
|
+
else if (!res.signatureOk) {
|
|
1109
1233
|
evErrors.push("event envelope signature failed");
|
|
1234
|
+
}
|
|
1110
1235
|
if (res.payloadHashOk === false)
|
|
1111
1236
|
evErrors.push("event record does not bind to payload_hash");
|
|
1112
1237
|
}
|
|
@@ -1335,7 +1460,8 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
1335
1460
|
});
|
|
1336
1461
|
const traceEvents = resolveTraceEvents(bundle);
|
|
1337
1462
|
if (traceEvents === undefined) {
|
|
1338
|
-
errors.push(`no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")}
|
|
1463
|
+
errors.push(`no ${[...ANCHOR_TRACE_BODY_TYPES, STAMP_EVIDENCE_BODY_TYPE].join(" / ")} ` +
|
|
1464
|
+
`body to materialize content from ` +
|
|
1339
1465
|
`(body_type ${JSON.stringify(verification.bodyType)})`);
|
|
1340
1466
|
return empty();
|
|
1341
1467
|
}
|
|
@@ -1414,10 +1540,15 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
1414
1540
|
}
|
|
1415
1541
|
return { status: verification.status, verification, events, content, errors };
|
|
1416
1542
|
}
|
|
1417
|
-
// The
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
// `undefined` when
|
|
1543
|
+
// The events a bundle carries, unwrapping any evidence-export wrapper(s) to
|
|
1544
|
+
// the inline source bundle — mirroring how verifyEvidenceBundle recurses into
|
|
1545
|
+
// `source_bundle`, so the events line up with its results.
|
|
1546
|
+
// `undefined` when the bundle carries no events this reader understands, which
|
|
1547
|
+
// a caller must distinguish from a body that has events and no links.
|
|
1548
|
+
//
|
|
1549
|
+
// BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
|
|
1550
|
+
// `sanning.stamp.evidence/v1` is the hand-over pack, and it presents the same
|
|
1551
|
+
// event shape.
|
|
1421
1552
|
//
|
|
1422
1553
|
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
1423
1554
|
// again: two answers to "which events is this bundle actually about" inside one
|
|
@@ -1431,7 +1562,17 @@ export function resolveTraceEvents(bundle) {
|
|
|
1431
1562
|
return undefined;
|
|
1432
1563
|
const b = cur;
|
|
1433
1564
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
1434
|
-
|
|
1565
|
+
// Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
|
|
1566
|
+
// `sanning.stamp.evidence/v1` presents the SAME event shape (a
|
|
1567
|
+
// producer-signed envelope, optional `record_bytes`, an inclusion proof),
|
|
1568
|
+
// and it is the HAND-OVER PACK: the only artifact an auditor is ever
|
|
1569
|
+
// handed. Dispatching on anchor-trace alone meant `readChains` reported
|
|
1570
|
+
// zero chains on every pack, however well-formed the links inside its
|
|
1571
|
+
// records were, so "is it in order?" read as NOT CHECKED on the one shape
|
|
1572
|
+
// that matters. The writers have been emitting those links the whole time.
|
|
1573
|
+
if (bodyType === ANCHOR_TRACE_BODY_TYPE ||
|
|
1574
|
+
bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
|
|
1575
|
+
bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
1435
1576
|
const body = b.body;
|
|
1436
1577
|
if (body === null || typeof body !== "object" || !Array.isArray(body.events))
|
|
1437
1578
|
return undefined;
|
package/dist/verifier.js
CHANGED
|
@@ -51,25 +51,81 @@ export const ACCEPTED_SPEC_MAJORS = Object.freeze([
|
|
|
51
51
|
// safely under the tightest kernel recursion limit, with headroom for the JCS
|
|
52
52
|
// library's own frames-per-level. Mirrors the Python kernel's MAX_CANONICAL_DEPTH.
|
|
53
53
|
export const MAX_CANONICAL_DEPTH = 128;
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
// Returned by `boundedInput` in place of a value that nests past the bound.
|
|
55
|
+
// A symbol, so no input can forge it.
|
|
56
|
+
const OVER_DEEP = Symbol("canonicalization input nests past the bound");
|
|
57
|
+
// The serializer's own test (`object.toJSON instanceof Function`), on an object.
|
|
58
|
+
function hasToJSON(node) {
|
|
59
|
+
return (node !== null &&
|
|
60
|
+
typeof node === "object" &&
|
|
61
|
+
node.toJSON instanceof Function);
|
|
62
|
+
}
|
|
63
|
+
// Follow `node.toJSON()` until the value has none, which is what the serializer
|
|
64
|
+
// does (`serialize(object.toJSON())`: in place, at the same depth). Bounded,
|
|
65
|
+
// because the serializer burns a stack frame per hop: a `toJSON` that keeps
|
|
66
|
+
// returning another `toJSON` object is over-deep, not something to follow
|
|
67
|
+
// forever.
|
|
68
|
+
function resolveToJSON(node, max) {
|
|
69
|
+
let resolved = node;
|
|
70
|
+
for (let hops = 0; hasToJSON(resolved); hops++) {
|
|
71
|
+
if (hops >= max)
|
|
72
|
+
return OVER_DEEP;
|
|
73
|
+
resolved = resolved.toJSON();
|
|
74
|
+
}
|
|
75
|
+
return resolved;
|
|
76
|
+
}
|
|
77
|
+
// Bound nesting AND resolve `toJSON`, in one pass, returning the value the rest
|
|
78
|
+
// of `jcs` must use: a structure with every `toJSON` already resolved, so
|
|
79
|
+
// `canonicalize` never calls one itself. `OVER_DEEP` if any container nests
|
|
80
|
+
// deeper than `max` levels. The root container is depth 1; each nested
|
|
81
|
+
// array/object is one deeper; scalars have no children and never increase depth.
|
|
82
|
+
//
|
|
83
|
+
// Deliberately NOT recursive: this runs on already-parsed, possibly-hostile
|
|
84
|
+
// input, so it must never itself overflow the call stack — it walks an explicit
|
|
85
|
+
// heap stack instead.
|
|
86
|
+
//
|
|
87
|
+
// It returns a resolved COPY rather than checking the caller's value in place,
|
|
88
|
+
// and that is the load-bearing part. `canonicalize` serializes `object.toJSON()`
|
|
89
|
+
// IN PLACE OF the object and recurses into the result, so a guard that only
|
|
90
|
+
// LOOKS at what `toJSON` returns and then lets the serializer ask again calls it
|
|
91
|
+
// twice, and the two answers need not match: an unstable `toJSON` hands the
|
|
92
|
+
// guard a shallow value and the serializer a deep one, which is the overflow the
|
|
93
|
+
// bound exists to prevent, reached through the bound (proof#71 review). Copying
|
|
94
|
+
// what was measured is what makes check and use the same value. It also settles
|
|
95
|
+
// a getter, which is read once here and never re-read.
|
|
96
|
+
function boundedInput(value, max) {
|
|
97
|
+
const root = resolveToJSON(value, max);
|
|
98
|
+
if (root === OVER_DEEP || root === null || typeof root !== "object")
|
|
99
|
+
return root;
|
|
100
|
+
const copy = Array.isArray(root) ? [] : {};
|
|
101
|
+
const stack = [[root, copy, 1]];
|
|
62
102
|
while (stack.length > 0) {
|
|
63
|
-
const [
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
103
|
+
const [src, dst, depth] = stack.pop();
|
|
104
|
+
// `Object.entries` is what `canonicalize` walks for an object, and for an
|
|
105
|
+
// array it yields the same index keys its `reduce` visits.
|
|
106
|
+
for (const [key, rawChild] of Object.entries(src)) {
|
|
107
|
+
const child = resolveToJSON(rawChild, max);
|
|
108
|
+
if (child === OVER_DEEP)
|
|
109
|
+
return OVER_DEEP;
|
|
110
|
+
if (child === null || typeof child !== "object") {
|
|
111
|
+
dst[key] = child;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (depth + 1 > max)
|
|
115
|
+
return OVER_DEEP; // a container nested past the bound
|
|
116
|
+
const childCopy = Array.isArray(child) ? [] : {};
|
|
117
|
+
dst[key] = childCopy;
|
|
118
|
+
stack.push([child, childCopy, depth + 1]);
|
|
119
|
+
}
|
|
71
120
|
}
|
|
72
|
-
return
|
|
121
|
+
return copy;
|
|
122
|
+
}
|
|
123
|
+
// Test whether `value` nests JSON containers deeper than `max` levels. Same walk
|
|
124
|
+
// `jcs` runs, so the predicate and the serialization agree by construction.
|
|
125
|
+
// Callers holding a value with a `toJSON` pay for it once per call: this runs it,
|
|
126
|
+
// and so does the `jcs` that usually follows. Within `jcs` itself it runs once.
|
|
127
|
+
export function exceedsDepth(value, max) {
|
|
128
|
+
return boundedInput(value, max) === OVER_DEEP;
|
|
73
129
|
}
|
|
74
130
|
// RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
|
|
75
131
|
// JS implementation; correctness is pinned by the conformance vectors.
|
|
@@ -79,6 +135,11 @@ export function exceedsDepth(value, max) {
|
|
|
79
135
|
// input, so neither can overflow, and a too-deep input is a caller/input error
|
|
80
136
|
// that the callers bucket as malformed (same contract as a malformed key).
|
|
81
137
|
//
|
|
138
|
+
// Both of them then see `boundedInput`'s output, never the caller's value. That
|
|
139
|
+
// is what makes the two checks below true of what actually gets serialized: the
|
|
140
|
+
// bound holds against an unstable `toJSON`, and a lone surrogate behind a
|
|
141
|
+
// `toJSON` is seen rather than emitted (proof#71 and its review).
|
|
142
|
+
//
|
|
82
143
|
// Lone (unpaired) UTF-16 surrogates are REJECTED, never passed through: RFC
|
|
83
144
|
// 8785 requires well-formed UTF-8 output, JS strings can carry lone
|
|
84
145
|
// surrogates that `canonicalize` would emit as-is, and the sibling kernels
|
|
@@ -86,11 +147,12 @@ export function exceedsDepth(value, max) {
|
|
|
86
147
|
// them). Reject-only is the one behavior all three kernels can share —
|
|
87
148
|
// pinned here and by the corpus lone-surrogate negative.
|
|
88
149
|
export function jcs(value) {
|
|
89
|
-
|
|
150
|
+
const input = boundedInput(value, MAX_CANONICAL_DEPTH);
|
|
151
|
+
if (input === OVER_DEEP) {
|
|
90
152
|
throw new Error(`jcs: input nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`);
|
|
91
153
|
}
|
|
92
|
-
rejectLoneSurrogates(
|
|
93
|
-
const canonical = canonicalize(
|
|
154
|
+
rejectLoneSurrogates(input);
|
|
155
|
+
const canonical = canonicalize(input);
|
|
94
156
|
if (typeof canonical !== "string") {
|
|
95
157
|
throw new Error("jcs: canonicalize returned a non-string (input not JSON-serializable?)");
|
|
96
158
|
}
|
|
@@ -236,7 +298,22 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
236
298
|
return {
|
|
237
299
|
ok: false,
|
|
238
300
|
specVersionOk: false,
|
|
239
|
-
|
|
301
|
+
// `null`, not `false` (proof#62). This field carries "we cannot tell"
|
|
302
|
+
// against "it is wrong", and that distinction is what the whole
|
|
303
|
+
// minimum-disclosure posture rests on: a withheld record is `null` BY
|
|
304
|
+
// DESIGN and must never read as a failure (`evidence-bundle.md` §3.1,
|
|
305
|
+
// §6.2). There is no envelope here, so there is nothing to bind and
|
|
306
|
+
// nothing was determined. `false` asserted a binding had been checked
|
|
307
|
+
// and had failed, which is a claim this branch cannot support.
|
|
308
|
+
//
|
|
309
|
+
// The Python kernel already answered `None`, so the two disagreed on
|
|
310
|
+
// the dimension `verify-page` renders: an auditor on one CLI saw a
|
|
311
|
+
// dimension that could not be determined, and on the other one that
|
|
312
|
+
// failed, for the same bytes. `ok` is `false` either way, so no rollup
|
|
313
|
+
// verdict changes; what changes is what we tell the reader.
|
|
314
|
+
//
|
|
315
|
+
// `contentHashOk` on the next line already follows this rule.
|
|
316
|
+
payloadHashOk: null,
|
|
240
317
|
signatureOk: false,
|
|
241
318
|
contentHashOk: expectedContentHash === undefined ? null : false,
|
|
242
319
|
contentRole: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli.ts
CHANGED
|
@@ -730,16 +730,27 @@ function reportEvidence(
|
|
|
730
730
|
io.out("");
|
|
731
731
|
io.out(paint(" Events", DIM));
|
|
732
732
|
for (const ev of result.events) {
|
|
733
|
+
// `~` is "not determined", and the words after it must say only that.
|
|
734
|
+
// This read "record withheld", which is a statement about the PRODUCER:
|
|
735
|
+
// it says the customer chose minimum disclosure and kept these bytes
|
|
736
|
+
// back, which is legitimate and expected. An event whose envelope was
|
|
737
|
+
// never an envelope produces the same `null` on this dimension, and
|
|
738
|
+
// there the sentence is simply untrue: the record may be sitting in the
|
|
739
|
+
// bundle, and nothing about the producer has been established either
|
|
740
|
+
// way. A renderer cannot tell the two apart from a tri-state, so it
|
|
741
|
+
// states what the verifier did and leaves WHY to the error lines below,
|
|
742
|
+
// which now name the malformed envelope instead of a signature check
|
|
743
|
+
// that never ran (proof#76).
|
|
733
744
|
const binding =
|
|
734
745
|
ev.payloadBindingOk === null
|
|
735
|
-
? paint("~ record
|
|
746
|
+
? paint("~ record not checked", Y)
|
|
736
747
|
: `record ${mark(ev.payloadBindingOk)}`;
|
|
737
748
|
// Show the content (logs) segment only when content was disclosed and
|
|
738
749
|
// evaluated (non-null) — undisclosed content is the default and would just
|
|
739
750
|
// be noise on every line.
|
|
740
751
|
const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
|
|
741
752
|
io.out(
|
|
742
|
-
` ${mark(ev.ok)} ${shortId(ev.eventId)}
|
|
753
|
+
` ${mark(ev.ok)} ${shortId(ev.eventId)} envelope ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`,
|
|
743
754
|
);
|
|
744
755
|
for (const e of ev.errors) io.out(paint(` - ${e}`, R));
|
|
745
756
|
}
|
package/src/evidence.ts
CHANGED
|
@@ -675,6 +675,20 @@ function stringifyErr(e: unknown): string {
|
|
|
675
675
|
return e instanceof Error ? e.message : String(e);
|
|
676
676
|
}
|
|
677
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Spell a value inside a finding the way the Python kernel spells it.
|
|
680
|
+
*
|
|
681
|
+
* `JSON.stringify(undefined)` is `undefined` (the value, not a string), so an
|
|
682
|
+
* absent field interpolates as the word `undefined` where Python writes `null`.
|
|
683
|
+
* One word, and a real difference in what an auditor reads: someone comparing
|
|
684
|
+
* the two CLIs on one bundle sees two different sentences for one fact. Both
|
|
685
|
+
* kernels route every quoted value through this spelling, and the cross-kernel
|
|
686
|
+
* anchor-trace leg byte-compares them.
|
|
687
|
+
*/
|
|
688
|
+
function jsonStr(value: unknown): string {
|
|
689
|
+
return JSON.stringify(value === undefined ? null : value) ?? "null";
|
|
690
|
+
}
|
|
691
|
+
|
|
678
692
|
// Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
|
|
679
693
|
//
|
|
680
694
|
// Returns a structured per-checkpoint + per-event result and a recomputed
|
|
@@ -876,7 +890,7 @@ async function resolveCheckpointKey(
|
|
|
876
890
|
if (isV1) {
|
|
877
891
|
if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
|
|
878
892
|
errors.push(
|
|
879
|
-
"sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref)
|
|
893
|
+
"sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) -- " +
|
|
880
894
|
"one bundle speaks one vocabulary",
|
|
881
895
|
);
|
|
882
896
|
return { key: null, anchorRef: null, errors };
|
|
@@ -893,7 +907,7 @@ async function resolveCheckpointKey(
|
|
|
893
907
|
|
|
894
908
|
if (cp.tx_id !== undefined) {
|
|
895
909
|
errors.push(
|
|
896
|
-
"sanning.anchor.trace/v2 checkpoint carries legacy tx_id
|
|
910
|
+
"sanning.anchor.trace/v2 checkpoint carries legacy tx_id -- one bundle speaks one vocabulary",
|
|
897
911
|
);
|
|
898
912
|
return { key: null, anchorRef: null, errors };
|
|
899
913
|
}
|
|
@@ -903,7 +917,7 @@ async function resolveCheckpointKey(
|
|
|
903
917
|
}
|
|
904
918
|
if (recordBytes === undefined) {
|
|
905
919
|
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
906
|
-
errors.push("checkpoint_ref cannot be verified
|
|
920
|
+
errors.push("checkpoint_ref cannot be verified -- record_bytes missing or not hex");
|
|
907
921
|
return { key: null, anchorRef: null, errors };
|
|
908
922
|
}
|
|
909
923
|
const derived = await sha256Hex(recordBytes);
|
|
@@ -991,7 +1005,29 @@ async function verifyAnchorTrace(
|
|
|
991
1005
|
const byKey = new Map<string, AnyTraceCheckpoint>();
|
|
992
1006
|
const anchorByKey = new Map<string, AnchorRef | null>();
|
|
993
1007
|
const checkpointResults: CheckpointResult[] = [];
|
|
994
|
-
for (const
|
|
1008
|
+
for (const rawCp of body.checkpoints as unknown[]) {
|
|
1009
|
+
// `checkpoints[]` is attacker-shaped, and a verifier must never raise on
|
|
1010
|
+
// adversarial input: a null entry used to reach `cp.tx_id` and hand the
|
|
1011
|
+
// caller a TypeError instead of a verdict, which an auditor cannot tell
|
|
1012
|
+
// apart from a broken tool. Graded and reported as "not a checkpoint"
|
|
1013
|
+
// rather than coerced into an empty one: probing the envelope layer with
|
|
1014
|
+
// nothing asks it a question about an object that was never an envelope.
|
|
1015
|
+
// Same treatment, same words, as the §5.3 path's `events[] entry is not an
|
|
1016
|
+
// object`.
|
|
1017
|
+
if (rawCp === null || typeof rawCp !== "object" || Array.isArray(rawCp)) {
|
|
1018
|
+
checkpointResults.push({
|
|
1019
|
+
checkpointRef: "",
|
|
1020
|
+
anchorRef: null,
|
|
1021
|
+
envelopeOk: false,
|
|
1022
|
+
merkleRootOk: false,
|
|
1023
|
+
onChainOk: null,
|
|
1024
|
+
onChain: null,
|
|
1025
|
+
ok: false,
|
|
1026
|
+
errors: ["checkpoints[] entry is not an object"],
|
|
1027
|
+
});
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
const cp = rawCp as AnyTraceCheckpoint;
|
|
995
1031
|
const cpErrors: string[] = [];
|
|
996
1032
|
let envelopeOk = false;
|
|
997
1033
|
let merkleRootOk = false;
|
|
@@ -1007,9 +1043,20 @@ async function verifyAnchorTrace(
|
|
|
1007
1043
|
|
|
1008
1044
|
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
1009
1045
|
cpErrors.push(...keyed.errors);
|
|
1046
|
+
// A duplicate key is a HARD failure, not a note. `byKey` below keeps the
|
|
1047
|
+
// FIRST occurrence, so a second checkpoint claiming the same key is
|
|
1048
|
+
// verified against the first one's envelope and its own stated identity is
|
|
1049
|
+
// never independently checked. Recording the finding and leaving `ok` true
|
|
1050
|
+
// let the bundle roll up to `verified` while carrying a checkpoint nothing
|
|
1051
|
+
// had verified: a false pass, which is what `evidence-bundle.md` §3
|
|
1052
|
+
// principle 3 exists to prevent (the displayed verdict must be the
|
|
1053
|
+
// recomputed one, and here the recomputation quietly dropped a finding it
|
|
1054
|
+
// had made).
|
|
1055
|
+
let duplicateKey = false;
|
|
1010
1056
|
if (keyed.key !== null) {
|
|
1011
1057
|
if (byKey.has(keyed.key)) {
|
|
1012
1058
|
cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
|
|
1059
|
+
duplicateKey = true;
|
|
1013
1060
|
} else {
|
|
1014
1061
|
byKey.set(keyed.key, cp);
|
|
1015
1062
|
anchorByKey.set(keyed.key, keyed.anchorRef);
|
|
@@ -1027,9 +1074,26 @@ async function verifyAnchorTrace(
|
|
|
1027
1074
|
// payload_hash. An undetermined binding (no record) is not enough to
|
|
1028
1075
|
// anchor the events' inclusion claims to a trustworthy root.
|
|
1029
1076
|
envelopeOk = res.ok && res.payloadHashOk === true;
|
|
1030
|
-
|
|
1077
|
+
// See the event loop below: a degenerate envelope is a malformed input,
|
|
1078
|
+
// not a failed check (proof#76).
|
|
1079
|
+
if (!isJsonObject(cp.envelope)) {
|
|
1080
|
+
cpErrors.push("checkpoint envelope is not a JSON object");
|
|
1081
|
+
} else if (!res.signatureOk) {
|
|
1082
|
+
cpErrors.push("checkpoint envelope signature failed");
|
|
1083
|
+
}
|
|
1031
1084
|
if (res.payloadHashOk === false) cpErrors.push("checkpoint record does not bind to payload_hash");
|
|
1032
|
-
|
|
1085
|
+
// WHY the binding is undetermined decides which sentence is true.
|
|
1086
|
+
// record_bytes really absent is one thing; record_bytes present and
|
|
1087
|
+
// unbindable (a degenerate envelope, unreadable hex) is another, and
|
|
1088
|
+
// naming the wrong cause sends a reader to look for a field that is
|
|
1089
|
+
// sitting right there (proof#76).
|
|
1090
|
+
if (res.payloadHashOk === null) {
|
|
1091
|
+
cpErrors.push(
|
|
1092
|
+
recordBytes === undefined
|
|
1093
|
+
? "checkpoint record_bytes missing -- root untrusted"
|
|
1094
|
+
: "checkpoint record binding not checked: root untrusted",
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1033
1097
|
} catch (e) {
|
|
1034
1098
|
cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
|
|
1035
1099
|
}
|
|
@@ -1045,7 +1109,8 @@ async function verifyAnchorTrace(
|
|
|
1045
1109
|
merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
|
|
1046
1110
|
if (!merkleRootOk) {
|
|
1047
1111
|
cpErrors.push(
|
|
1048
|
-
`checkpoint merkle_root mismatch: claimed=${cp.merkle_root}
|
|
1112
|
+
`checkpoint merkle_root mismatch: claimed=${jsonStr(cp.merkle_root)} ` +
|
|
1113
|
+
`committed=${jsonStr(committedRoot)}`,
|
|
1049
1114
|
);
|
|
1050
1115
|
}
|
|
1051
1116
|
} catch (e) {
|
|
@@ -1066,14 +1131,31 @@ async function verifyAnchorTrace(
|
|
|
1066
1131
|
// standing rule is that malformed is a failure while absent is
|
|
1067
1132
|
// undetermined (the same rule disclosed `content` follows). Without
|
|
1068
1133
|
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
1069
|
-
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
|
|
1134
|
+
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
|
|
1070
1135
|
errors: cpErrors,
|
|
1071
1136
|
});
|
|
1072
1137
|
}
|
|
1073
1138
|
|
|
1074
1139
|
// --- Step 4: per-event ------------------------------------------------------
|
|
1075
1140
|
const eventResults: EventResult[] = [];
|
|
1076
|
-
for (const
|
|
1141
|
+
for (const rawEv of body.events as unknown[]) {
|
|
1142
|
+
// Same rule as `checkpoints[]` above, and the same words the §5.3 path
|
|
1143
|
+
// already uses: a null or non-object entry is graded as not an event,
|
|
1144
|
+
// never raised on.
|
|
1145
|
+
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
1146
|
+
eventResults.push({
|
|
1147
|
+
eventId: "<unknown>",
|
|
1148
|
+
envelopeOk: false,
|
|
1149
|
+
payloadBindingOk: null,
|
|
1150
|
+
inclusionOk: false,
|
|
1151
|
+
checkpointBound: false,
|
|
1152
|
+
contentOk: null,
|
|
1153
|
+
ok: false,
|
|
1154
|
+
errors: ["events[] entry is not an object"],
|
|
1155
|
+
});
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const ev = rawEv as TraceEvent;
|
|
1077
1159
|
const evErrors: string[] = [];
|
|
1078
1160
|
const eventId = getEventId(ev.envelope);
|
|
1079
1161
|
let envelopeOk = false;
|
|
@@ -1092,7 +1174,18 @@ async function verifyAnchorTrace(
|
|
|
1092
1174
|
);
|
|
1093
1175
|
envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
|
|
1094
1176
|
payloadBindingOk = res.payloadHashOk;
|
|
1095
|
-
|
|
1177
|
+
// P-10: report what could not be determined, never a fault that was never
|
|
1178
|
+
// observed. There is no envelope here, so no signature was checked and no
|
|
1179
|
+
// binding was attempted. "signature failed" named a check that never ran,
|
|
1180
|
+
// and it was the only thing said about an event whose undetermined
|
|
1181
|
+
// binding then rendered as a WITHHELD record, a claim about the
|
|
1182
|
+
// producer's disclosure choice out of an input that says nothing about
|
|
1183
|
+
// the producer at all (proof#76).
|
|
1184
|
+
if (!isJsonObject(ev.envelope)) {
|
|
1185
|
+
evErrors.push("event envelope is not a JSON object");
|
|
1186
|
+
} else if (!res.signatureOk) {
|
|
1187
|
+
evErrors.push("event envelope signature failed");
|
|
1188
|
+
}
|
|
1096
1189
|
if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
|
|
1097
1190
|
} catch (e) {
|
|
1098
1191
|
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
@@ -1115,7 +1208,7 @@ async function verifyAnchorTrace(
|
|
|
1115
1208
|
evErrors.push("event missing inclusion proof");
|
|
1116
1209
|
} else if (strayKey !== undefined) {
|
|
1117
1210
|
evErrors.push(
|
|
1118
|
-
`event inclusion carries ${wrongField} in a ${bodyType} body
|
|
1211
|
+
`event inclusion carries ${wrongField} in a ${bodyType} body -- one bundle speaks one vocabulary`,
|
|
1119
1212
|
);
|
|
1120
1213
|
} else if (typeof rawKey !== "string") {
|
|
1121
1214
|
evErrors.push(`event inclusion missing ${keyField}`);
|
|
@@ -1137,7 +1230,13 @@ async function verifyAnchorTrace(
|
|
|
1137
1230
|
incl.leaf_index,
|
|
1138
1231
|
incl.leaf_count,
|
|
1139
1232
|
(incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
1140
|
-
|
|
1233
|
+
// No `?? ""` fallback. A checkpoint that claims NO root must not be
|
|
1234
|
+
// handed an invented empty one: the Python kernel refuses the same
|
|
1235
|
+
// value and reports the decode failure, so substituting "" here had
|
|
1236
|
+
// the two kernels say different things about one bundle (TS: "the
|
|
1237
|
+
// proof did not reconstruct the checkpoint root", which is not what
|
|
1238
|
+
// happened: there was no claimed root to reconstruct).
|
|
1239
|
+
hexToBytes(cp.merkle_root as string),
|
|
1141
1240
|
);
|
|
1142
1241
|
if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
1143
1242
|
|
|
@@ -1204,7 +1303,7 @@ async function verifyAnchorTrace(
|
|
|
1204
1303
|
onChainOk: null,
|
|
1205
1304
|
};
|
|
1206
1305
|
cpr.onChainOk = null;
|
|
1207
|
-
cpr.errors.push("checkpoint has no anchor_ref
|
|
1306
|
+
cpr.errors.push("checkpoint has no anchor_ref -- not yet witnessed, nothing to re-fetch");
|
|
1208
1307
|
continue;
|
|
1209
1308
|
}
|
|
1210
1309
|
const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
|
|
@@ -1217,7 +1316,17 @@ async function verifyAnchorTrace(
|
|
|
1217
1316
|
// gateway (null) leaves the offline-sound checkpoint ok; the run
|
|
1218
1317
|
// downgrades to "partial" (exit 3) via gatewayUnavailable, not a hard
|
|
1219
1318
|
// failure. A match (true) keeps it ok.
|
|
1220
|
-
|
|
1319
|
+
// AND into the verdict already computed, never rebuild it from a
|
|
1320
|
+
// hand-listed set of dimensions. Rebuilding silently dropped every
|
|
1321
|
+
// term not in that list: the duplicate-key gate (proof#28) and the
|
|
1322
|
+
// `keyed.errors` term with it, so supplying a gateway turned a hard
|
|
1323
|
+
// reject back into `verified` and an UNREACHABLE gateway turned it
|
|
1324
|
+
// into `partial` (exit 3), which reads as a network problem an
|
|
1325
|
+
// operator retries. Neither is conditioned on a gateway by the spec:
|
|
1326
|
+
// `evidence-bundle.md` says two checkpoints carrying one key is a
|
|
1327
|
+
// hard reject, full stop. Written this way, a term added at
|
|
1328
|
+
// construction survives here by construction.
|
|
1329
|
+
cpr.ok = cpr.ok && cpr.onChainOk !== false;
|
|
1221
1330
|
}
|
|
1222
1331
|
}
|
|
1223
1332
|
}
|
|
@@ -1298,6 +1407,15 @@ async function verifyAnchorTrace(
|
|
|
1298
1407
|
// nothing else in the stack would ever notice.
|
|
1299
1408
|
// ===========================================================================
|
|
1300
1409
|
|
|
1410
|
+
// A JSON object, as opposed to null, an array, a string or a number. Used at
|
|
1411
|
+
// every point where a nested field MUST be an object before any check of it
|
|
1412
|
+
// means anything: probing a verifier with a non-object asks a question about
|
|
1413
|
+
// something that was never the thing, and the answer must say so rather than
|
|
1414
|
+
// name a check that never ran (proof#76).
|
|
1415
|
+
function isJsonObject(v: unknown): v is Record<string, unknown> {
|
|
1416
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1301
1419
|
/** Lowercase hex, even length, non-empty. The family's serialization rule. */
|
|
1302
1420
|
function isLowerHex(s: unknown, nibbles?: number): s is string {
|
|
1303
1421
|
if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0) return false;
|
|
@@ -1377,7 +1495,7 @@ async function verifyDisclosedContent(
|
|
|
1377
1495
|
if (disclosed !== undefined && committed.length === 0) {
|
|
1378
1496
|
evErrors.push(
|
|
1379
1497
|
"disclosed content present but no committed content_hash to bind to " +
|
|
1380
|
-
"(record withheld or record has no event.content_hash)
|
|
1498
|
+
"(record withheld or record has no event.content_hash) -- undetermined",
|
|
1381
1499
|
);
|
|
1382
1500
|
}
|
|
1383
1501
|
} else {
|
|
@@ -1807,7 +1925,18 @@ async function verifyStampEvidence(
|
|
|
1807
1925
|
);
|
|
1808
1926
|
envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
|
|
1809
1927
|
payloadBindingOk = res.payloadHashOk;
|
|
1810
|
-
|
|
1928
|
+
// P-10: report what could not be determined, never a fault that was never
|
|
1929
|
+
// observed. There is no envelope here, so no signature was checked and no
|
|
1930
|
+
// binding was attempted. "signature failed" named a check that never ran,
|
|
1931
|
+
// and it was the only thing said about an event whose undetermined
|
|
1932
|
+
// binding then rendered as a WITHHELD record, a claim about the
|
|
1933
|
+
// producer's disclosure choice out of an input that says nothing about
|
|
1934
|
+
// the producer at all (proof#76).
|
|
1935
|
+
if (!isJsonObject(ev.envelope)) {
|
|
1936
|
+
evErrors.push("event envelope is not a JSON object");
|
|
1937
|
+
} else if (!res.signatureOk) {
|
|
1938
|
+
evErrors.push("event envelope signature failed");
|
|
1939
|
+
}
|
|
1811
1940
|
if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
|
|
1812
1941
|
} catch (e) {
|
|
1813
1942
|
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
@@ -2121,7 +2250,8 @@ export async function disclosedContent(
|
|
|
2121
2250
|
const traceEvents = resolveTraceEvents(bundle);
|
|
2122
2251
|
if (traceEvents === undefined) {
|
|
2123
2252
|
errors.push(
|
|
2124
|
-
`no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")}
|
|
2253
|
+
`no ${[...ANCHOR_TRACE_BODY_TYPES, STAMP_EVIDENCE_BODY_TYPE].join(" / ")} ` +
|
|
2254
|
+
`body to materialize content from ` +
|
|
2125
2255
|
`(body_type ${JSON.stringify(verification.bodyType)})`,
|
|
2126
2256
|
);
|
|
2127
2257
|
return empty();
|
|
@@ -2214,10 +2344,15 @@ export async function disclosedContent(
|
|
|
2214
2344
|
return { status: verification.status, verification, events, content, errors };
|
|
2215
2345
|
}
|
|
2216
2346
|
|
|
2217
|
-
// The
|
|
2218
|
-
//
|
|
2219
|
-
//
|
|
2220
|
-
// `undefined` when
|
|
2347
|
+
// The events a bundle carries, unwrapping any evidence-export wrapper(s) to
|
|
2348
|
+
// the inline source bundle — mirroring how verifyEvidenceBundle recurses into
|
|
2349
|
+
// `source_bundle`, so the events line up with its results.
|
|
2350
|
+
// `undefined` when the bundle carries no events this reader understands, which
|
|
2351
|
+
// a caller must distinguish from a body that has events and no links.
|
|
2352
|
+
//
|
|
2353
|
+
// BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
|
|
2354
|
+
// `sanning.stamp.evidence/v1` is the hand-over pack, and it presents the same
|
|
2355
|
+
// event shape.
|
|
2221
2356
|
//
|
|
2222
2357
|
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
2223
2358
|
// again: two answers to "which events is this bundle actually about" inside one
|
|
@@ -2230,7 +2365,19 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
|
2230
2365
|
if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
|
|
2231
2366
|
const b = cur as EvidenceBundle;
|
|
2232
2367
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
2233
|
-
|
|
2368
|
+
// Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
|
|
2369
|
+
// `sanning.stamp.evidence/v1` presents the SAME event shape (a
|
|
2370
|
+
// producer-signed envelope, optional `record_bytes`, an inclusion proof),
|
|
2371
|
+
// and it is the HAND-OVER PACK: the only artifact an auditor is ever
|
|
2372
|
+
// handed. Dispatching on anchor-trace alone meant `readChains` reported
|
|
2373
|
+
// zero chains on every pack, however well-formed the links inside its
|
|
2374
|
+
// records were, so "is it in order?" read as NOT CHECKED on the one shape
|
|
2375
|
+
// that matters. The writers have been emitting those links the whole time.
|
|
2376
|
+
if (
|
|
2377
|
+
bodyType === ANCHOR_TRACE_BODY_TYPE ||
|
|
2378
|
+
bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
|
|
2379
|
+
bodyType === STAMP_EVIDENCE_BODY_TYPE
|
|
2380
|
+
) {
|
|
2234
2381
|
const body = b.body as AnchorTraceBody | null | undefined;
|
|
2235
2382
|
if (body === null || typeof body !== "object" || !Array.isArray(body.events)) return undefined;
|
|
2236
2383
|
return body.events;
|
package/src/verifier.ts
CHANGED
|
@@ -57,22 +57,83 @@ export const ACCEPTED_SPEC_MAJORS: readonly string[] = Object.freeze([
|
|
|
57
57
|
// library's own frames-per-level. Mirrors the Python kernel's MAX_CANONICAL_DEPTH.
|
|
58
58
|
export const MAX_CANONICAL_DEPTH = 128;
|
|
59
59
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
// Returned by `boundedInput` in place of a value that nests past the bound.
|
|
61
|
+
// A symbol, so no input can forge it.
|
|
62
|
+
const OVER_DEEP = Symbol("canonicalization input nests past the bound");
|
|
63
|
+
|
|
64
|
+
// The serializer's own test (`object.toJSON instanceof Function`), on an object.
|
|
65
|
+
function hasToJSON(node: unknown): boolean {
|
|
66
|
+
return (
|
|
67
|
+
node !== null &&
|
|
68
|
+
typeof node === "object" &&
|
|
69
|
+
(node as { toJSON?: unknown }).toJSON instanceof Function
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Follow `node.toJSON()` until the value has none, which is what the serializer
|
|
74
|
+
// does (`serialize(object.toJSON())`: in place, at the same depth). Bounded,
|
|
75
|
+
// because the serializer burns a stack frame per hop: a `toJSON` that keeps
|
|
76
|
+
// returning another `toJSON` object is over-deep, not something to follow
|
|
77
|
+
// forever.
|
|
78
|
+
function resolveToJSON(node: unknown, max: number): unknown {
|
|
79
|
+
let resolved = node;
|
|
80
|
+
for (let hops = 0; hasToJSON(resolved); hops++) {
|
|
81
|
+
if (hops >= max) return OVER_DEEP;
|
|
82
|
+
resolved = (resolved as { toJSON: () => unknown }).toJSON();
|
|
83
|
+
}
|
|
84
|
+
return resolved;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Bound nesting AND resolve `toJSON`, in one pass, returning the value the rest
|
|
88
|
+
// of `jcs` must use: a structure with every `toJSON` already resolved, so
|
|
89
|
+
// `canonicalize` never calls one itself. `OVER_DEEP` if any container nests
|
|
90
|
+
// deeper than `max` levels. The root container is depth 1; each nested
|
|
91
|
+
// array/object is one deeper; scalars have no children and never increase depth.
|
|
92
|
+
//
|
|
93
|
+
// Deliberately NOT recursive: this runs on already-parsed, possibly-hostile
|
|
94
|
+
// input, so it must never itself overflow the call stack — it walks an explicit
|
|
95
|
+
// heap stack instead.
|
|
96
|
+
//
|
|
97
|
+
// It returns a resolved COPY rather than checking the caller's value in place,
|
|
98
|
+
// and that is the load-bearing part. `canonicalize` serializes `object.toJSON()`
|
|
99
|
+
// IN PLACE OF the object and recurses into the result, so a guard that only
|
|
100
|
+
// LOOKS at what `toJSON` returns and then lets the serializer ask again calls it
|
|
101
|
+
// twice, and the two answers need not match: an unstable `toJSON` hands the
|
|
102
|
+
// guard a shallow value and the serializer a deep one, which is the overflow the
|
|
103
|
+
// bound exists to prevent, reached through the bound (proof#71 review). Copying
|
|
104
|
+
// what was measured is what makes check and use the same value. It also settles
|
|
105
|
+
// a getter, which is read once here and never re-read.
|
|
106
|
+
function boundedInput(value: unknown, max: number): unknown {
|
|
107
|
+
const root = resolveToJSON(value, max);
|
|
108
|
+
if (root === OVER_DEEP || root === null || typeof root !== "object") return root;
|
|
109
|
+
const copy: Record<string, unknown> | unknown[] = Array.isArray(root) ? [] : {};
|
|
110
|
+
const stack: Array<[object, Record<string, unknown> | unknown[], number]> = [[root, copy, 1]];
|
|
68
111
|
while (stack.length > 0) {
|
|
69
|
-
const [
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
112
|
+
const [src, dst, depth] = stack.pop()!;
|
|
113
|
+
// `Object.entries` is what `canonicalize` walks for an object, and for an
|
|
114
|
+
// array it yields the same index keys its `reduce` visits.
|
|
115
|
+
for (const [key, rawChild] of Object.entries(src)) {
|
|
116
|
+
const child = resolveToJSON(rawChild, max);
|
|
117
|
+
if (child === OVER_DEEP) return OVER_DEEP;
|
|
118
|
+
if (child === null || typeof child !== "object") {
|
|
119
|
+
(dst as Record<string, unknown>)[key] = child;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (depth + 1 > max) return OVER_DEEP; // a container nested past the bound
|
|
123
|
+
const childCopy: Record<string, unknown> | unknown[] = Array.isArray(child) ? [] : {};
|
|
124
|
+
(dst as Record<string, unknown>)[key] = childCopy;
|
|
125
|
+
stack.push([child, childCopy, depth + 1]);
|
|
126
|
+
}
|
|
74
127
|
}
|
|
75
|
-
return
|
|
128
|
+
return copy;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Test whether `value` nests JSON containers deeper than `max` levels. Same walk
|
|
132
|
+
// `jcs` runs, so the predicate and the serialization agree by construction.
|
|
133
|
+
// Callers holding a value with a `toJSON` pay for it once per call: this runs it,
|
|
134
|
+
// and so does the `jcs` that usually follows. Within `jcs` itself it runs once.
|
|
135
|
+
export function exceedsDepth(value: unknown, max: number): boolean {
|
|
136
|
+
return boundedInput(value, max) === OVER_DEEP;
|
|
76
137
|
}
|
|
77
138
|
|
|
78
139
|
// RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
|
|
@@ -83,6 +144,11 @@ export function exceedsDepth(value: unknown, max: number): boolean {
|
|
|
83
144
|
// input, so neither can overflow, and a too-deep input is a caller/input error
|
|
84
145
|
// that the callers bucket as malformed (same contract as a malformed key).
|
|
85
146
|
//
|
|
147
|
+
// Both of them then see `boundedInput`'s output, never the caller's value. That
|
|
148
|
+
// is what makes the two checks below true of what actually gets serialized: the
|
|
149
|
+
// bound holds against an unstable `toJSON`, and a lone surrogate behind a
|
|
150
|
+
// `toJSON` is seen rather than emitted (proof#71 and its review).
|
|
151
|
+
//
|
|
86
152
|
// Lone (unpaired) UTF-16 surrogates are REJECTED, never passed through: RFC
|
|
87
153
|
// 8785 requires well-formed UTF-8 output, JS strings can carry lone
|
|
88
154
|
// surrogates that `canonicalize` would emit as-is, and the sibling kernels
|
|
@@ -90,13 +156,14 @@ export function exceedsDepth(value: unknown, max: number): boolean {
|
|
|
90
156
|
// them). Reject-only is the one behavior all three kernels can share —
|
|
91
157
|
// pinned here and by the corpus lone-surrogate negative.
|
|
92
158
|
export function jcs(value: unknown): string {
|
|
93
|
-
|
|
159
|
+
const input = boundedInput(value, MAX_CANONICAL_DEPTH);
|
|
160
|
+
if (input === OVER_DEEP) {
|
|
94
161
|
throw new Error(
|
|
95
162
|
`jcs: input nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`,
|
|
96
163
|
);
|
|
97
164
|
}
|
|
98
|
-
rejectLoneSurrogates(
|
|
99
|
-
const canonical = canonicalize(
|
|
165
|
+
rejectLoneSurrogates(input);
|
|
166
|
+
const canonical = canonicalize(input);
|
|
100
167
|
if (typeof canonical !== "string") {
|
|
101
168
|
throw new Error("jcs: canonicalize returned a non-string (input not JSON-serializable?)");
|
|
102
169
|
}
|
|
@@ -252,7 +319,22 @@ export async function verifyEnvelope(
|
|
|
252
319
|
return {
|
|
253
320
|
ok: false,
|
|
254
321
|
specVersionOk: false,
|
|
255
|
-
|
|
322
|
+
// `null`, not `false` (proof#62). This field carries "we cannot tell"
|
|
323
|
+
// against "it is wrong", and that distinction is what the whole
|
|
324
|
+
// minimum-disclosure posture rests on: a withheld record is `null` BY
|
|
325
|
+
// DESIGN and must never read as a failure (`evidence-bundle.md` §3.1,
|
|
326
|
+
// §6.2). There is no envelope here, so there is nothing to bind and
|
|
327
|
+
// nothing was determined. `false` asserted a binding had been checked
|
|
328
|
+
// and had failed, which is a claim this branch cannot support.
|
|
329
|
+
//
|
|
330
|
+
// The Python kernel already answered `None`, so the two disagreed on
|
|
331
|
+
// the dimension `verify-page` renders: an auditor on one CLI saw a
|
|
332
|
+
// dimension that could not be determined, and on the other one that
|
|
333
|
+
// failed, for the same bytes. `ok` is `false` either way, so no rollup
|
|
334
|
+
// verdict changes; what changes is what we tell the reader.
|
|
335
|
+
//
|
|
336
|
+
// `contentHashOk` on the next line already follows this rule.
|
|
337
|
+
payloadHashOk: null,
|
|
256
338
|
signatureOk: false,
|
|
257
339
|
contentHashOk: expectedContentHash === undefined ? null : false,
|
|
258
340
|
contentRole: null,
|