@sanning/proof 0.8.3 → 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.3`, 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;
@@ -421,12 +456,26 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
421
456
  // payload_hash. An undetermined binding (no record) is not enough to
422
457
  // anchor the events' inclusion claims to a trustworthy root.
423
458
  envelopeOk = res.ok && res.payloadHashOk === true;
424
- 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) {
425
465
  cpErrors.push("checkpoint envelope signature failed");
466
+ }
426
467
  if (res.payloadHashOk === false)
427
468
  cpErrors.push("checkpoint record does not bind to payload_hash");
428
- if (res.payloadHashOk === null)
429
- 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
+ }
430
479
  }
431
480
  catch (e) {
432
481
  cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
@@ -439,7 +488,8 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
439
488
  const committedRoot = record.event?.merkle_root;
440
489
  merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
441
490
  if (!merkleRootOk) {
442
- 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)}`);
443
493
  }
444
494
  }
445
495
  catch (e) {
@@ -465,7 +515,24 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
465
515
  }
466
516
  // --- Step 4: per-event ------------------------------------------------------
467
517
  const eventResults = [];
468
- 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;
469
536
  const evErrors = [];
470
537
  const eventId = getEventId(ev.envelope);
471
538
  let envelopeOk = false;
@@ -482,8 +549,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
482
549
  const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
483
550
  envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
484
551
  payloadBindingOk = res.payloadHashOk;
485
- 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) {
486
563
  evErrors.push("event envelope signature failed");
564
+ }
487
565
  if (res.payloadHashOk === false)
488
566
  evErrors.push("event record does not bind to payload_hash");
489
567
  }
@@ -507,7 +585,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
507
585
  evErrors.push("event missing inclusion proof");
508
586
  }
509
587
  else if (strayKey !== undefined) {
510
- 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`);
511
589
  }
512
590
  else if (typeof rawKey !== "string") {
513
591
  evErrors.push(`event inclusion missing ${keyField}`);
@@ -522,7 +600,14 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
522
600
  let inclusionOk = false;
523
601
  if (incl && checkpointBound && cp) {
524
602
  try {
525
- 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));
526
611
  if (!inclusionOk)
527
612
  evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
528
613
  // Defense in depth: the claimed leaf_hash MUST be the hash of the
@@ -586,7 +671,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
586
671
  onChainOk: null,
587
672
  };
588
673
  cpr.onChainOk = null;
589
- 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");
590
675
  continue;
591
676
  }
592
677
  const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
@@ -690,6 +775,14 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
690
775
  // slice claim resting on them is unfounded — and unlike a bad signature,
691
776
  // nothing else in the stack would ever notice.
692
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
+ }
693
786
  /** Lowercase hex, even length, non-empty. The family's serialization rule. */
694
787
  function isLowerHex(s, nibbles) {
695
788
  if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0)
@@ -761,7 +854,7 @@ async function verifyDisclosedContent(ev, eventId, recordBytes, sideContent) {
761
854
  contentOk = null;
762
855
  if (disclosed !== undefined && committed.length === 0) {
763
856
  evErrors.push("disclosed content present but no committed content_hash to bind to " +
764
- "(record withheld or record has no event.content_hash) — undetermined");
857
+ "(record withheld or record has no event.content_hash) -- undetermined");
765
858
  }
766
859
  }
767
860
  else {
@@ -1126,8 +1219,19 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1126
1219
  const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
1127
1220
  envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1128
1221
  payloadBindingOk = res.payloadHashOk;
1129
- 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) {
1130
1233
  evErrors.push("event envelope signature failed");
1234
+ }
1131
1235
  if (res.payloadHashOk === false)
1132
1236
  evErrors.push("event record does not bind to payload_hash");
1133
1237
  }
@@ -1356,7 +1460,8 @@ export async function disclosedContent(bundle, options = {}) {
1356
1460
  });
1357
1461
  const traceEvents = resolveTraceEvents(bundle);
1358
1462
  if (traceEvents === undefined) {
1359
- 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 ` +
1360
1465
  `(body_type ${JSON.stringify(verification.bodyType)})`);
1361
1466
  return empty();
1362
1467
  }
@@ -1435,10 +1540,15 @@ export async function disclosedContent(bundle, options = {}) {
1435
1540
  }
1436
1541
  return { status: verification.status, verification, events, content, errors };
1437
1542
  }
1438
- // The anchor-trace events a bundle carries, unwrapping any evidence-export
1439
- // wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
1440
- // recurses into `source_bundle`, so the events line up with its results.
1441
- // `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.
1442
1552
  //
1443
1553
  // Exported for `chain.ts`, which needs the same descent and must not derive it
1444
1554
  // again: two answers to "which events is this bundle actually about" inside one
@@ -1452,7 +1562,17 @@ export function resolveTraceEvents(bundle) {
1452
1562
  return undefined;
1453
1563
  const b = cur;
1454
1564
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
1455
- 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) {
1456
1576
  const body = b.body;
1457
1577
  if (body === null || typeof body !== "object" || !Array.isArray(body.events))
1458
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.8.3",
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;
@@ -1038,9 +1074,26 @@ async function verifyAnchorTrace(
1038
1074
  // payload_hash. An undetermined binding (no record) is not enough to
1039
1075
  // anchor the events' inclusion claims to a trustworthy root.
1040
1076
  envelopeOk = res.ok && res.payloadHashOk === true;
1041
- 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
+ }
1042
1084
  if (res.payloadHashOk === false) cpErrors.push("checkpoint record does not bind to payload_hash");
1043
- 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
+ }
1044
1097
  } catch (e) {
1045
1098
  cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
1046
1099
  }
@@ -1056,7 +1109,8 @@ async function verifyAnchorTrace(
1056
1109
  merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
1057
1110
  if (!merkleRootOk) {
1058
1111
  cpErrors.push(
1059
- `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)}`,
1060
1114
  );
1061
1115
  }
1062
1116
  } catch (e) {
@@ -1084,7 +1138,24 @@ async function verifyAnchorTrace(
1084
1138
 
1085
1139
  // --- Step 4: per-event ------------------------------------------------------
1086
1140
  const eventResults: EventResult[] = [];
1087
- 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;
1088
1159
  const evErrors: string[] = [];
1089
1160
  const eventId = getEventId(ev.envelope);
1090
1161
  let envelopeOk = false;
@@ -1103,7 +1174,18 @@ async function verifyAnchorTrace(
1103
1174
  );
1104
1175
  envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
1105
1176
  payloadBindingOk = res.payloadHashOk;
1106
- 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
+ }
1107
1189
  if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
1108
1190
  } catch (e) {
1109
1191
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
@@ -1126,7 +1208,7 @@ async function verifyAnchorTrace(
1126
1208
  evErrors.push("event missing inclusion proof");
1127
1209
  } else if (strayKey !== undefined) {
1128
1210
  evErrors.push(
1129
- `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`,
1130
1212
  );
1131
1213
  } else if (typeof rawKey !== "string") {
1132
1214
  evErrors.push(`event inclusion missing ${keyField}`);
@@ -1148,7 +1230,13 @@ async function verifyAnchorTrace(
1148
1230
  incl.leaf_index,
1149
1231
  incl.leaf_count,
1150
1232
  (incl.audit_path ?? []).map((h) => hexToBytes(h)),
1151
- 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),
1152
1240
  );
1153
1241
  if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
1154
1242
 
@@ -1215,7 +1303,7 @@ async function verifyAnchorTrace(
1215
1303
  onChainOk: null,
1216
1304
  };
1217
1305
  cpr.onChainOk = null;
1218
- 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");
1219
1307
  continue;
1220
1308
  }
1221
1309
  const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
@@ -1319,6 +1407,15 @@ async function verifyAnchorTrace(
1319
1407
  // nothing else in the stack would ever notice.
1320
1408
  // ===========================================================================
1321
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
+
1322
1419
  /** Lowercase hex, even length, non-empty. The family's serialization rule. */
1323
1420
  function isLowerHex(s: unknown, nibbles?: number): s is string {
1324
1421
  if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0) return false;
@@ -1398,7 +1495,7 @@ async function verifyDisclosedContent(
1398
1495
  if (disclosed !== undefined && committed.length === 0) {
1399
1496
  evErrors.push(
1400
1497
  "disclosed content present but no committed content_hash to bind to " +
1401
- "(record withheld or record has no event.content_hash) — undetermined",
1498
+ "(record withheld or record has no event.content_hash) -- undetermined",
1402
1499
  );
1403
1500
  }
1404
1501
  } else {
@@ -1828,7 +1925,18 @@ async function verifyStampEvidence(
1828
1925
  );
1829
1926
  envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1830
1927
  payloadBindingOk = res.payloadHashOk;
1831
- 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
+ }
1832
1940
  if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
1833
1941
  } catch (e) {
1834
1942
  evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
@@ -2142,7 +2250,8 @@ export async function disclosedContent(
2142
2250
  const traceEvents = resolveTraceEvents(bundle);
2143
2251
  if (traceEvents === undefined) {
2144
2252
  errors.push(
2145
- `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 ` +
2146
2255
  `(body_type ${JSON.stringify(verification.bodyType)})`,
2147
2256
  );
2148
2257
  return empty();
@@ -2235,10 +2344,15 @@ export async function disclosedContent(
2235
2344
  return { status: verification.status, verification, events, content, errors };
2236
2345
  }
2237
2346
 
2238
- // The anchor-trace events a bundle carries, unwrapping any evidence-export
2239
- // wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
2240
- // recurses into `source_bundle`, so the events line up with its results.
2241
- // `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.
2242
2356
  //
2243
2357
  // Exported for `chain.ts`, which needs the same descent and must not derive it
2244
2358
  // again: two answers to "which events is this bundle actually about" inside one
@@ -2251,7 +2365,19 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
2251
2365
  if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
2252
2366
  const b = cur as EvidenceBundle;
2253
2367
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
2254
- 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
+ ) {
2255
2381
  const body = b.body as AnchorTraceBody | null | undefined;
2256
2382
  if (body === null || typeof body !== "object" || !Array.isArray(body.events)) return undefined;
2257
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
  }