@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 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.2`, 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.
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 withheld", Y)
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)} sig ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`);
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 — one bundle speaks one vocabulary");
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 — record_bytes missing or not hex");
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 cp of body.checkpoints) {
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
- if (!res.signatureOk)
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
- if (res.payloadHashOk === null)
418
- cpErrors.push("checkpoint record_bytes missing — root untrusted");
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} committed=${String(committedRoot)}`);
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 ev of body.events) {
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
- if (!res.signatureOk)
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 — one bundle speaks one vocabulary`);
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)), hexToBytes(cp.merkle_root ?? ""));
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 — not yet witnessed, nothing to re-fetch");
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
- cpr.ok = cpr.envelopeOk && cpr.merkleRootOk && cpr.onChainOk !== false;
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) — undetermined");
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
- if (!res.signatureOk)
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(" / ")} body to materialize content from ` +
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 anchor-trace events a bundle carries, unwrapping any evidence-export
1418
- // wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
1419
- // recurses into `source_bundle`, so the events line up with its results.
1420
- // `undefined` when there is no anchor-trace body to materialize from.
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
- if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
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
- // Iteratively test whether `value` nests JSON containers deeper than `max`
55
- // levels; short-circuits `true` on the first over-deep path. Deliberately NOT
56
- // recursive: this guard runs on already-parsed, possibly-hostile input, so it
57
- // must never itself overflow the call stack — it walks an explicit heap stack
58
- // instead. The root container is depth 1; each nested array/object is one
59
- // deeper; scalars have no children and never increase depth.
60
- export function exceedsDepth(value, max) {
61
- const stack = [[value, 1]];
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 [node, depth] = stack.pop();
64
- if (node === null || typeof node !== "object")
65
- continue; // scalar — always fine
66
- if (depth > max)
67
- return true; // a container nested past the bound
68
- const children = Array.isArray(node) ? node : Object.values(node);
69
- for (const child of children)
70
- stack.push([child, depth + 1]);
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 false;
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
- if (exceedsDepth(value, MAX_CANONICAL_DEPTH)) {
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(value);
93
- const canonical = canonicalize(value);
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
- payloadHashOk: false,
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.8.2",
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 withheld", Y)
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)} sig ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`,
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 — one bundle speaks one vocabulary",
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 — record_bytes missing or not hex");
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 cp of body.checkpoints as AnyTraceCheckpoint[]) {
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
- if (!res.signatureOk) cpErrors.push("checkpoint envelope signature failed");
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
- if (res.payloadHashOk === null) cpErrors.push("checkpoint record_bytes missing — root untrusted");
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} committed=${String(committedRoot)}`,
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 ev of body.events) {
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
- if (!res.signatureOk) evErrors.push("event envelope signature failed");
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 — one bundle speaks one vocabulary`,
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
- hexToBytes(cp.merkle_root ?? ""),
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 — not yet witnessed, nothing to re-fetch");
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
- cpr.ok = cpr.envelopeOk && cpr.merkleRootOk && cpr.onChainOk !== false;
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) — undetermined",
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
- if (!res.signatureOk) evErrors.push("event envelope signature failed");
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(" / ")} body to materialize content from ` +
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 anchor-trace events a bundle carries, unwrapping any evidence-export
2218
- // wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
2219
- // recurses into `source_bundle`, so the events line up with its results.
2220
- // `undefined` when there is no anchor-trace body to materialize from.
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
- if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
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
- // Iteratively test whether `value` nests JSON containers deeper than `max`
61
- // levels; short-circuits `true` on the first over-deep path. Deliberately NOT
62
- // recursive: this guard runs on already-parsed, possibly-hostile input, so it
63
- // must never itself overflow the call stack — it walks an explicit heap stack
64
- // instead. The root container is depth 1; each nested array/object is one
65
- // deeper; scalars have no children and never increase depth.
66
- export function exceedsDepth(value: unknown, max: number): boolean {
67
- const stack: Array<[unknown, number]> = [[value, 1]];
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 [node, depth] = stack.pop()!;
70
- if (node === null || typeof node !== "object") continue; // scalar — always fine
71
- if (depth > max) return true; // a container nested past the bound
72
- const children = Array.isArray(node) ? node : Object.values(node);
73
- for (const child of children) stack.push([child, depth + 1]);
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 false;
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
- if (exceedsDepth(value, MAX_CANONICAL_DEPTH)) {
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(value);
99
- const canonical = canonicalize(value);
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
- payloadHashOk: false,
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,