@sanning/proof 0.8.3 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +13 -2
- package/dist/evidence.js +141 -21
- package/dist/verifier.js +98 -20
- package/package.json +1 -1
- package/src/cli.ts +13 -2
- package/src/evidence.ts +146 -20
- package/src/verifier.ts +101 -17
package/README.md
CHANGED
|
@@ -203,7 +203,7 @@ The primary signature covers `JCS(envelope minus signature minus co_signatures)`
|
|
|
203
203
|
|
|
204
204
|
## Why you can trust it (without trusting us)
|
|
205
205
|
|
|
206
|
-
Three **independent implementations** of this kernel exist: this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and and the MIT-licensed Go reference. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against one shared corpus, pinned per file by SHA-256 at cut tag `test-vectors-v3.
|
|
206
|
+
Three **independent implementations** of this kernel exist: this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and and the MIT-licensed Go reference. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against one shared corpus, pinned per file by SHA-256 at cut tag `test-vectors-v3.4`, and a cross-kernel agreement gate re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
|
|
207
207
|
|
|
208
208
|
That mutual gate is the point: anyone can verify these provenance records with **no Sanning code in the trust path**, write your own verifier against the specs that ship inside the [`sanning-proof`](https://pypi.org/project/sanning-proof/) sdist, and the corpus will tell you if it is conformant.
|
|
209
209
|
|
package/dist/cli.js
CHANGED
|
@@ -639,14 +639,25 @@ function reportEvidence(result, path, gateways, io, chains) {
|
|
|
639
639
|
io.out("");
|
|
640
640
|
io.out(paint(" Events", DIM));
|
|
641
641
|
for (const ev of result.events) {
|
|
642
|
+
// `~` is "not determined", and the words after it must say only that.
|
|
643
|
+
// This read "record withheld", which is a statement about the PRODUCER:
|
|
644
|
+
// it says the customer chose minimum disclosure and kept these bytes
|
|
645
|
+
// back, which is legitimate and expected. An event whose envelope was
|
|
646
|
+
// never an envelope produces the same `null` on this dimension, and
|
|
647
|
+
// there the sentence is simply untrue: the record may be sitting in the
|
|
648
|
+
// bundle, and nothing about the producer has been established either
|
|
649
|
+
// way. A renderer cannot tell the two apart from a tri-state, so it
|
|
650
|
+
// states what the verifier did and leaves WHY to the error lines below,
|
|
651
|
+
// which now name the malformed envelope instead of a signature check
|
|
652
|
+
// that never ran (proof#76).
|
|
642
653
|
const binding = ev.payloadBindingOk === null
|
|
643
|
-
? paint("~ record
|
|
654
|
+
? paint("~ record not checked", Y)
|
|
644
655
|
: `record ${mark(ev.payloadBindingOk)}`;
|
|
645
656
|
// Show the content (logs) segment only when content was disclosed and
|
|
646
657
|
// evaluated (non-null) — undisclosed content is the default and would just
|
|
647
658
|
// be noise on every line.
|
|
648
659
|
const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
|
|
649
|
-
io.out(` ${mark(ev.ok)} ${shortId(ev.eventId)}
|
|
660
|
+
io.out(` ${mark(ev.ok)} ${shortId(ev.eventId)} envelope ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`);
|
|
650
661
|
for (const e of ev.errors)
|
|
651
662
|
io.out(paint(` - ${e}`, R));
|
|
652
663
|
}
|
package/dist/evidence.js
CHANGED
|
@@ -103,6 +103,19 @@ function malformed(error, specVersionOk = false) {
|
|
|
103
103
|
function stringifyErr(e) {
|
|
104
104
|
return e instanceof Error ? e.message : String(e);
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Spell a value inside a finding the way the Python kernel spells it.
|
|
108
|
+
*
|
|
109
|
+
* `JSON.stringify(undefined)` is `undefined` (the value, not a string), so an
|
|
110
|
+
* absent field interpolates as the word `undefined` where Python writes `null`.
|
|
111
|
+
* One word, and a real difference in what an auditor reads: someone comparing
|
|
112
|
+
* the two CLIs on one bundle sees two different sentences for one fact. Both
|
|
113
|
+
* kernels route every quoted value through this spelling, and the cross-kernel
|
|
114
|
+
* anchor-trace leg byte-compares them.
|
|
115
|
+
*/
|
|
116
|
+
function jsonStr(value) {
|
|
117
|
+
return JSON.stringify(value === undefined ? null : value) ?? "null";
|
|
118
|
+
}
|
|
106
119
|
// Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
|
|
107
120
|
//
|
|
108
121
|
// Returns a structured per-checkpoint + per-event result and a recomputed
|
|
@@ -279,7 +292,7 @@ async function resolveCheckpointKey(cp, recordBytes, isV1) {
|
|
|
279
292
|
const errors = [];
|
|
280
293
|
if (isV1) {
|
|
281
294
|
if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
|
|
282
|
-
errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref)
|
|
295
|
+
errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) -- " +
|
|
283
296
|
"one bundle speaks one vocabulary");
|
|
284
297
|
return { key: null, anchorRef: null, errors };
|
|
285
298
|
}
|
|
@@ -293,7 +306,7 @@ async function resolveCheckpointKey(cp, recordBytes, isV1) {
|
|
|
293
306
|
return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
|
|
294
307
|
}
|
|
295
308
|
if (cp.tx_id !== undefined) {
|
|
296
|
-
errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id
|
|
309
|
+
errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id -- one bundle speaks one vocabulary");
|
|
297
310
|
return { key: null, anchorRef: null, errors };
|
|
298
311
|
}
|
|
299
312
|
if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
|
|
@@ -302,7 +315,7 @@ async function resolveCheckpointKey(cp, recordBytes, isV1) {
|
|
|
302
315
|
}
|
|
303
316
|
if (recordBytes === undefined) {
|
|
304
317
|
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
305
|
-
errors.push("checkpoint_ref cannot be verified
|
|
318
|
+
errors.push("checkpoint_ref cannot be verified -- record_bytes missing or not hex");
|
|
306
319
|
return { key: null, anchorRef: null, errors };
|
|
307
320
|
}
|
|
308
321
|
const derived = await sha256Hex(recordBytes);
|
|
@@ -377,7 +390,29 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
377
390
|
const byKey = new Map();
|
|
378
391
|
const anchorByKey = new Map();
|
|
379
392
|
const checkpointResults = [];
|
|
380
|
-
for (const
|
|
393
|
+
for (const rawCp of body.checkpoints) {
|
|
394
|
+
// `checkpoints[]` is attacker-shaped, and a verifier must never raise on
|
|
395
|
+
// adversarial input: a null entry used to reach `cp.tx_id` and hand the
|
|
396
|
+
// caller a TypeError instead of a verdict, which an auditor cannot tell
|
|
397
|
+
// apart from a broken tool. Graded and reported as "not a checkpoint"
|
|
398
|
+
// rather than coerced into an empty one: probing the envelope layer with
|
|
399
|
+
// nothing asks it a question about an object that was never an envelope.
|
|
400
|
+
// Same treatment, same words, as the §5.3 path's `events[] entry is not an
|
|
401
|
+
// object`.
|
|
402
|
+
if (rawCp === null || typeof rawCp !== "object" || Array.isArray(rawCp)) {
|
|
403
|
+
checkpointResults.push({
|
|
404
|
+
checkpointRef: "",
|
|
405
|
+
anchorRef: null,
|
|
406
|
+
envelopeOk: false,
|
|
407
|
+
merkleRootOk: false,
|
|
408
|
+
onChainOk: null,
|
|
409
|
+
onChain: null,
|
|
410
|
+
ok: false,
|
|
411
|
+
errors: ["checkpoints[] entry is not an object"],
|
|
412
|
+
});
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const cp = rawCp;
|
|
381
416
|
const cpErrors = [];
|
|
382
417
|
let envelopeOk = false;
|
|
383
418
|
let merkleRootOk = false;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
|
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)),
|
|
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
|
|
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)
|
|
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
|
-
|
|
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(" / ")}
|
|
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
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
// `undefined` when
|
|
1543
|
+
// The events a bundle carries, unwrapping any evidence-export wrapper(s) to
|
|
1544
|
+
// the inline source bundle — mirroring how verifyEvidenceBundle recurses into
|
|
1545
|
+
// `source_bundle`, so the events line up with its results.
|
|
1546
|
+
// `undefined` when the bundle carries no events this reader understands, which
|
|
1547
|
+
// a caller must distinguish from a body that has events and no links.
|
|
1548
|
+
//
|
|
1549
|
+
// BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
|
|
1550
|
+
// `sanning.stamp.evidence/v1` is the hand-over pack, and it presents the same
|
|
1551
|
+
// event shape.
|
|
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
|
-
|
|
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,97 @@ export const ACCEPTED_SPEC_MAJORS = Object.freeze([
|
|
|
51
51
|
// safely under the tightest kernel recursion limit, with headroom for the JCS
|
|
52
52
|
// library's own frames-per-level. Mirrors the Python kernel's MAX_CANONICAL_DEPTH.
|
|
53
53
|
export const MAX_CANONICAL_DEPTH = 128;
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
// Returned by `boundedInput` in place of a value that nests past the bound.
|
|
55
|
+
// A symbol, so no input can forge it.
|
|
56
|
+
const OVER_DEEP = Symbol("canonicalization input nests past the bound");
|
|
57
|
+
// The serializer's own test (`object.toJSON instanceof Function`), on an object.
|
|
58
|
+
function hasToJSON(node) {
|
|
59
|
+
return (node !== null &&
|
|
60
|
+
typeof node === "object" &&
|
|
61
|
+
node.toJSON instanceof Function);
|
|
62
|
+
}
|
|
63
|
+
// Follow `node.toJSON()` until the value has none, which is what the serializer
|
|
64
|
+
// does (`serialize(object.toJSON())`: in place, at the same depth). Bounded,
|
|
65
|
+
// because the serializer burns a stack frame per hop: a `toJSON` that keeps
|
|
66
|
+
// returning another `toJSON` object is over-deep, not something to follow
|
|
67
|
+
// forever.
|
|
68
|
+
function resolveToJSON(node, max) {
|
|
69
|
+
let resolved = node;
|
|
70
|
+
for (let hops = 0; hasToJSON(resolved); hops++) {
|
|
71
|
+
if (hops >= max)
|
|
72
|
+
return OVER_DEEP;
|
|
73
|
+
resolved = resolved.toJSON();
|
|
74
|
+
}
|
|
75
|
+
return resolved;
|
|
76
|
+
}
|
|
77
|
+
// Bound nesting AND resolve `toJSON`, in one pass, returning the value the rest
|
|
78
|
+
// of `jcs` must use: a structure with every `toJSON` already resolved, so
|
|
79
|
+
// `canonicalize` never calls one itself. `OVER_DEEP` if any container nests
|
|
80
|
+
// deeper than `max` levels. The root container is depth 1; each nested
|
|
81
|
+
// array/object is one deeper; scalars have no children and never increase depth.
|
|
82
|
+
//
|
|
83
|
+
// Deliberately NOT recursive: this runs on already-parsed, possibly-hostile
|
|
84
|
+
// input, so it must never itself overflow the call stack — it walks an explicit
|
|
85
|
+
// heap stack instead.
|
|
86
|
+
//
|
|
87
|
+
// It returns a resolved COPY rather than checking the caller's value in place,
|
|
88
|
+
// and that is the load-bearing part. `canonicalize` serializes `object.toJSON()`
|
|
89
|
+
// IN PLACE OF the object and recurses into the result, so a guard that only
|
|
90
|
+
// LOOKS at what `toJSON` returns and then lets the serializer ask again calls it
|
|
91
|
+
// twice, and the two answers need not match: an unstable `toJSON` hands the
|
|
92
|
+
// guard a shallow value and the serializer a deep one, which is the overflow the
|
|
93
|
+
// bound exists to prevent, reached through the bound (proof#71 review). Copying
|
|
94
|
+
// what was measured is what makes check and use the same value. It also settles
|
|
95
|
+
// a getter, which is read once here and never re-read.
|
|
96
|
+
//
|
|
97
|
+
// Every object copy has a NULL prototype, and that is load-bearing too. RFC
|
|
98
|
+
// 8785 has no reserved member names, and `JSON.parse` keeps `__proto__` as an
|
|
99
|
+
// ordinary own key. On a plain `{}`, `copy["__proto__"] = x` runs the setter
|
|
100
|
+
// inherited from `Object.prototype`: it replaces the copy's prototype and
|
|
101
|
+
// creates no key, so the member and everything under it vanished from the
|
|
102
|
+
// canonical bytes. Content there was covered by no hash and no signature, and
|
|
103
|
+
// this kernel disagreed with the Python and Go kernels on the same bytes
|
|
104
|
+
// (0.9.0; the same bug class as proof#31 and proof#34). With no prototype
|
|
105
|
+
// there is no setter, so every key, `__proto__` included, lands as an own data
|
|
106
|
+
// property. `canonicalize` reads the copy with `Object.keys` and `object[key]`
|
|
107
|
+
// and tests `toJSON instanceof Function`, none of which a prototype affects
|
|
108
|
+
// for JSON data, so no other input's output changes.
|
|
109
|
+
function emptyObject() {
|
|
110
|
+
return Object.create(null);
|
|
111
|
+
}
|
|
112
|
+
function boundedInput(value, max) {
|
|
113
|
+
const root = resolveToJSON(value, max);
|
|
114
|
+
if (root === OVER_DEEP || root === null || typeof root !== "object")
|
|
115
|
+
return root;
|
|
116
|
+
const copy = Array.isArray(root) ? [] : emptyObject();
|
|
117
|
+
const stack = [[root, copy, 1]];
|
|
62
118
|
while (stack.length > 0) {
|
|
63
|
-
const [
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
119
|
+
const [src, dst, depth] = stack.pop();
|
|
120
|
+
// `Object.entries` is what `canonicalize` walks for an object, and for an
|
|
121
|
+
// array it yields the same index keys its `reduce` visits.
|
|
122
|
+
for (const [key, rawChild] of Object.entries(src)) {
|
|
123
|
+
const child = resolveToJSON(rawChild, max);
|
|
124
|
+
if (child === OVER_DEEP)
|
|
125
|
+
return OVER_DEEP;
|
|
126
|
+
if (child === null || typeof child !== "object") {
|
|
127
|
+
dst[key] = child;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (depth + 1 > max)
|
|
131
|
+
return OVER_DEEP; // a container nested past the bound
|
|
132
|
+
const childCopy = Array.isArray(child) ? [] : emptyObject();
|
|
133
|
+
dst[key] = childCopy;
|
|
134
|
+
stack.push([child, childCopy, depth + 1]);
|
|
135
|
+
}
|
|
71
136
|
}
|
|
72
|
-
return
|
|
137
|
+
return copy;
|
|
138
|
+
}
|
|
139
|
+
// Test whether `value` nests JSON containers deeper than `max` levels. Same walk
|
|
140
|
+
// `jcs` runs, so the predicate and the serialization agree by construction.
|
|
141
|
+
// Callers holding a value with a `toJSON` pay for it once per call: this runs it,
|
|
142
|
+
// and so does the `jcs` that usually follows. Within `jcs` itself it runs once.
|
|
143
|
+
export function exceedsDepth(value, max) {
|
|
144
|
+
return boundedInput(value, max) === OVER_DEEP;
|
|
73
145
|
}
|
|
74
146
|
// RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
|
|
75
147
|
// JS implementation; correctness is pinned by the conformance vectors.
|
|
@@ -79,6 +151,11 @@ export function exceedsDepth(value, max) {
|
|
|
79
151
|
// input, so neither can overflow, and a too-deep input is a caller/input error
|
|
80
152
|
// that the callers bucket as malformed (same contract as a malformed key).
|
|
81
153
|
//
|
|
154
|
+
// Both of them then see `boundedInput`'s output, never the caller's value. That
|
|
155
|
+
// is what makes the two checks below true of what actually gets serialized: the
|
|
156
|
+
// bound holds against an unstable `toJSON`, and a lone surrogate behind a
|
|
157
|
+
// `toJSON` is seen rather than emitted (proof#71 and its review).
|
|
158
|
+
//
|
|
82
159
|
// Lone (unpaired) UTF-16 surrogates are REJECTED, never passed through: RFC
|
|
83
160
|
// 8785 requires well-formed UTF-8 output, JS strings can carry lone
|
|
84
161
|
// surrogates that `canonicalize` would emit as-is, and the sibling kernels
|
|
@@ -86,11 +163,12 @@ export function exceedsDepth(value, max) {
|
|
|
86
163
|
// them). Reject-only is the one behavior all three kernels can share —
|
|
87
164
|
// pinned here and by the corpus lone-surrogate negative.
|
|
88
165
|
export function jcs(value) {
|
|
89
|
-
|
|
166
|
+
const input = boundedInput(value, MAX_CANONICAL_DEPTH);
|
|
167
|
+
if (input === OVER_DEEP) {
|
|
90
168
|
throw new Error(`jcs: input nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`);
|
|
91
169
|
}
|
|
92
|
-
rejectLoneSurrogates(
|
|
93
|
-
const canonical = canonicalize(
|
|
170
|
+
rejectLoneSurrogates(input);
|
|
171
|
+
const canonical = canonicalize(input);
|
|
94
172
|
if (typeof canonical !== "string") {
|
|
95
173
|
throw new Error("jcs: canonicalize returned a non-string (input not JSON-serializable?)");
|
|
96
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli.ts
CHANGED
|
@@ -730,16 +730,27 @@ function reportEvidence(
|
|
|
730
730
|
io.out("");
|
|
731
731
|
io.out(paint(" Events", DIM));
|
|
732
732
|
for (const ev of result.events) {
|
|
733
|
+
// `~` is "not determined", and the words after it must say only that.
|
|
734
|
+
// This read "record withheld", which is a statement about the PRODUCER:
|
|
735
|
+
// it says the customer chose minimum disclosure and kept these bytes
|
|
736
|
+
// back, which is legitimate and expected. An event whose envelope was
|
|
737
|
+
// never an envelope produces the same `null` on this dimension, and
|
|
738
|
+
// there the sentence is simply untrue: the record may be sitting in the
|
|
739
|
+
// bundle, and nothing about the producer has been established either
|
|
740
|
+
// way. A renderer cannot tell the two apart from a tri-state, so it
|
|
741
|
+
// states what the verifier did and leaves WHY to the error lines below,
|
|
742
|
+
// which now name the malformed envelope instead of a signature check
|
|
743
|
+
// that never ran (proof#76).
|
|
733
744
|
const binding =
|
|
734
745
|
ev.payloadBindingOk === null
|
|
735
|
-
? paint("~ record
|
|
746
|
+
? paint("~ record not checked", Y)
|
|
736
747
|
: `record ${mark(ev.payloadBindingOk)}`;
|
|
737
748
|
// Show the content (logs) segment only when content was disclosed and
|
|
738
749
|
// evaluated (non-null) — undisclosed content is the default and would just
|
|
739
750
|
// be noise on every line.
|
|
740
751
|
const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
|
|
741
752
|
io.out(
|
|
742
|
-
` ${mark(ev.ok)} ${shortId(ev.eventId)}
|
|
753
|
+
` ${mark(ev.ok)} ${shortId(ev.eventId)} envelope ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`,
|
|
743
754
|
);
|
|
744
755
|
for (const e of ev.errors) io.out(paint(` - ${e}`, R));
|
|
745
756
|
}
|
package/src/evidence.ts
CHANGED
|
@@ -675,6 +675,20 @@ function stringifyErr(e: unknown): string {
|
|
|
675
675
|
return e instanceof Error ? e.message : String(e);
|
|
676
676
|
}
|
|
677
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Spell a value inside a finding the way the Python kernel spells it.
|
|
680
|
+
*
|
|
681
|
+
* `JSON.stringify(undefined)` is `undefined` (the value, not a string), so an
|
|
682
|
+
* absent field interpolates as the word `undefined` where Python writes `null`.
|
|
683
|
+
* One word, and a real difference in what an auditor reads: someone comparing
|
|
684
|
+
* the two CLIs on one bundle sees two different sentences for one fact. Both
|
|
685
|
+
* kernels route every quoted value through this spelling, and the cross-kernel
|
|
686
|
+
* anchor-trace leg byte-compares them.
|
|
687
|
+
*/
|
|
688
|
+
function jsonStr(value: unknown): string {
|
|
689
|
+
return JSON.stringify(value === undefined ? null : value) ?? "null";
|
|
690
|
+
}
|
|
691
|
+
|
|
678
692
|
// Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
|
|
679
693
|
//
|
|
680
694
|
// Returns a structured per-checkpoint + per-event result and a recomputed
|
|
@@ -876,7 +890,7 @@ async function resolveCheckpointKey(
|
|
|
876
890
|
if (isV1) {
|
|
877
891
|
if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
|
|
878
892
|
errors.push(
|
|
879
|
-
"sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref)
|
|
893
|
+
"sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) -- " +
|
|
880
894
|
"one bundle speaks one vocabulary",
|
|
881
895
|
);
|
|
882
896
|
return { key: null, anchorRef: null, errors };
|
|
@@ -893,7 +907,7 @@ async function resolveCheckpointKey(
|
|
|
893
907
|
|
|
894
908
|
if (cp.tx_id !== undefined) {
|
|
895
909
|
errors.push(
|
|
896
|
-
"sanning.anchor.trace/v2 checkpoint carries legacy tx_id
|
|
910
|
+
"sanning.anchor.trace/v2 checkpoint carries legacy tx_id -- one bundle speaks one vocabulary",
|
|
897
911
|
);
|
|
898
912
|
return { key: null, anchorRef: null, errors };
|
|
899
913
|
}
|
|
@@ -903,7 +917,7 @@ async function resolveCheckpointKey(
|
|
|
903
917
|
}
|
|
904
918
|
if (recordBytes === undefined) {
|
|
905
919
|
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
906
|
-
errors.push("checkpoint_ref cannot be verified
|
|
920
|
+
errors.push("checkpoint_ref cannot be verified -- record_bytes missing or not hex");
|
|
907
921
|
return { key: null, anchorRef: null, errors };
|
|
908
922
|
}
|
|
909
923
|
const derived = await sha256Hex(recordBytes);
|
|
@@ -991,7 +1005,29 @@ async function verifyAnchorTrace(
|
|
|
991
1005
|
const byKey = new Map<string, AnyTraceCheckpoint>();
|
|
992
1006
|
const anchorByKey = new Map<string, AnchorRef | null>();
|
|
993
1007
|
const checkpointResults: CheckpointResult[] = [];
|
|
994
|
-
for (const
|
|
1008
|
+
for (const rawCp of body.checkpoints as unknown[]) {
|
|
1009
|
+
// `checkpoints[]` is attacker-shaped, and a verifier must never raise on
|
|
1010
|
+
// adversarial input: a null entry used to reach `cp.tx_id` and hand the
|
|
1011
|
+
// caller a TypeError instead of a verdict, which an auditor cannot tell
|
|
1012
|
+
// apart from a broken tool. Graded and reported as "not a checkpoint"
|
|
1013
|
+
// rather than coerced into an empty one: probing the envelope layer with
|
|
1014
|
+
// nothing asks it a question about an object that was never an envelope.
|
|
1015
|
+
// Same treatment, same words, as the §5.3 path's `events[] entry is not an
|
|
1016
|
+
// object`.
|
|
1017
|
+
if (rawCp === null || typeof rawCp !== "object" || Array.isArray(rawCp)) {
|
|
1018
|
+
checkpointResults.push({
|
|
1019
|
+
checkpointRef: "",
|
|
1020
|
+
anchorRef: null,
|
|
1021
|
+
envelopeOk: false,
|
|
1022
|
+
merkleRootOk: false,
|
|
1023
|
+
onChainOk: null,
|
|
1024
|
+
onChain: null,
|
|
1025
|
+
ok: false,
|
|
1026
|
+
errors: ["checkpoints[] entry is not an object"],
|
|
1027
|
+
});
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
const cp = rawCp as AnyTraceCheckpoint;
|
|
995
1031
|
const cpErrors: string[] = [];
|
|
996
1032
|
let envelopeOk = false;
|
|
997
1033
|
let merkleRootOk = false;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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(" / ")}
|
|
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
|
|
2239
|
-
//
|
|
2240
|
-
//
|
|
2241
|
-
// `undefined` when
|
|
2347
|
+
// The events a bundle carries, unwrapping any evidence-export wrapper(s) to
|
|
2348
|
+
// the inline source bundle — mirroring how verifyEvidenceBundle recurses into
|
|
2349
|
+
// `source_bundle`, so the events line up with its results.
|
|
2350
|
+
// `undefined` when the bundle carries no events this reader understands, which
|
|
2351
|
+
// a caller must distinguish from a body that has events and no links.
|
|
2352
|
+
//
|
|
2353
|
+
// BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
|
|
2354
|
+
// `sanning.stamp.evidence/v1` is the hand-over pack, and it presents the same
|
|
2355
|
+
// event shape.
|
|
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
|
-
|
|
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,100 @@ export const ACCEPTED_SPEC_MAJORS: readonly string[] = Object.freeze([
|
|
|
57
57
|
// library's own frames-per-level. Mirrors the Python kernel's MAX_CANONICAL_DEPTH.
|
|
58
58
|
export const MAX_CANONICAL_DEPTH = 128;
|
|
59
59
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
// Returned by `boundedInput` in place of a value that nests past the bound.
|
|
61
|
+
// A symbol, so no input can forge it.
|
|
62
|
+
const OVER_DEEP = Symbol("canonicalization input nests past the bound");
|
|
63
|
+
|
|
64
|
+
// The serializer's own test (`object.toJSON instanceof Function`), on an object.
|
|
65
|
+
function hasToJSON(node: unknown): boolean {
|
|
66
|
+
return (
|
|
67
|
+
node !== null &&
|
|
68
|
+
typeof node === "object" &&
|
|
69
|
+
(node as { toJSON?: unknown }).toJSON instanceof Function
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Follow `node.toJSON()` until the value has none, which is what the serializer
|
|
74
|
+
// does (`serialize(object.toJSON())`: in place, at the same depth). Bounded,
|
|
75
|
+
// because the serializer burns a stack frame per hop: a `toJSON` that keeps
|
|
76
|
+
// returning another `toJSON` object is over-deep, not something to follow
|
|
77
|
+
// forever.
|
|
78
|
+
function resolveToJSON(node: unknown, max: number): unknown {
|
|
79
|
+
let resolved = node;
|
|
80
|
+
for (let hops = 0; hasToJSON(resolved); hops++) {
|
|
81
|
+
if (hops >= max) return OVER_DEEP;
|
|
82
|
+
resolved = (resolved as { toJSON: () => unknown }).toJSON();
|
|
83
|
+
}
|
|
84
|
+
return resolved;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Bound nesting AND resolve `toJSON`, in one pass, returning the value the rest
|
|
88
|
+
// of `jcs` must use: a structure with every `toJSON` already resolved, so
|
|
89
|
+
// `canonicalize` never calls one itself. `OVER_DEEP` if any container nests
|
|
90
|
+
// deeper than `max` levels. The root container is depth 1; each nested
|
|
91
|
+
// array/object is one deeper; scalars have no children and never increase depth.
|
|
92
|
+
//
|
|
93
|
+
// Deliberately NOT recursive: this runs on already-parsed, possibly-hostile
|
|
94
|
+
// input, so it must never itself overflow the call stack — it walks an explicit
|
|
95
|
+
// heap stack instead.
|
|
96
|
+
//
|
|
97
|
+
// It returns a resolved COPY rather than checking the caller's value in place,
|
|
98
|
+
// and that is the load-bearing part. `canonicalize` serializes `object.toJSON()`
|
|
99
|
+
// IN PLACE OF the object and recurses into the result, so a guard that only
|
|
100
|
+
// LOOKS at what `toJSON` returns and then lets the serializer ask again calls it
|
|
101
|
+
// twice, and the two answers need not match: an unstable `toJSON` hands the
|
|
102
|
+
// guard a shallow value and the serializer a deep one, which is the overflow the
|
|
103
|
+
// bound exists to prevent, reached through the bound (proof#71 review). Copying
|
|
104
|
+
// what was measured is what makes check and use the same value. It also settles
|
|
105
|
+
// a getter, which is read once here and never re-read.
|
|
106
|
+
//
|
|
107
|
+
// Every object copy has a NULL prototype, and that is load-bearing too. RFC
|
|
108
|
+
// 8785 has no reserved member names, and `JSON.parse` keeps `__proto__` as an
|
|
109
|
+
// ordinary own key. On a plain `{}`, `copy["__proto__"] = x` runs the setter
|
|
110
|
+
// inherited from `Object.prototype`: it replaces the copy's prototype and
|
|
111
|
+
// creates no key, so the member and everything under it vanished from the
|
|
112
|
+
// canonical bytes. Content there was covered by no hash and no signature, and
|
|
113
|
+
// this kernel disagreed with the Python and Go kernels on the same bytes
|
|
114
|
+
// (0.9.0; the same bug class as proof#31 and proof#34). With no prototype
|
|
115
|
+
// there is no setter, so every key, `__proto__` included, lands as an own data
|
|
116
|
+
// property. `canonicalize` reads the copy with `Object.keys` and `object[key]`
|
|
117
|
+
// and tests `toJSON instanceof Function`, none of which a prototype affects
|
|
118
|
+
// for JSON data, so no other input's output changes.
|
|
119
|
+
function emptyObject(): Record<string, unknown> {
|
|
120
|
+
return Object.create(null) as Record<string, unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function boundedInput(value: unknown, max: number): unknown {
|
|
124
|
+
const root = resolveToJSON(value, max);
|
|
125
|
+
if (root === OVER_DEEP || root === null || typeof root !== "object") return root;
|
|
126
|
+
const copy: Record<string, unknown> | unknown[] = Array.isArray(root) ? [] : emptyObject();
|
|
127
|
+
const stack: Array<[object, Record<string, unknown> | unknown[], number]> = [[root, copy, 1]];
|
|
68
128
|
while (stack.length > 0) {
|
|
69
|
-
const [
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
129
|
+
const [src, dst, depth] = stack.pop()!;
|
|
130
|
+
// `Object.entries` is what `canonicalize` walks for an object, and for an
|
|
131
|
+
// array it yields the same index keys its `reduce` visits.
|
|
132
|
+
for (const [key, rawChild] of Object.entries(src)) {
|
|
133
|
+
const child = resolveToJSON(rawChild, max);
|
|
134
|
+
if (child === OVER_DEEP) return OVER_DEEP;
|
|
135
|
+
if (child === null || typeof child !== "object") {
|
|
136
|
+
(dst as Record<string, unknown>)[key] = child;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (depth + 1 > max) return OVER_DEEP; // a container nested past the bound
|
|
140
|
+
const childCopy: Record<string, unknown> | unknown[] = Array.isArray(child) ? [] : emptyObject();
|
|
141
|
+
(dst as Record<string, unknown>)[key] = childCopy;
|
|
142
|
+
stack.push([child, childCopy, depth + 1]);
|
|
143
|
+
}
|
|
74
144
|
}
|
|
75
|
-
return
|
|
145
|
+
return copy;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Test whether `value` nests JSON containers deeper than `max` levels. Same walk
|
|
149
|
+
// `jcs` runs, so the predicate and the serialization agree by construction.
|
|
150
|
+
// Callers holding a value with a `toJSON` pay for it once per call: this runs it,
|
|
151
|
+
// and so does the `jcs` that usually follows. Within `jcs` itself it runs once.
|
|
152
|
+
export function exceedsDepth(value: unknown, max: number): boolean {
|
|
153
|
+
return boundedInput(value, max) === OVER_DEEP;
|
|
76
154
|
}
|
|
77
155
|
|
|
78
156
|
// RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
|
|
@@ -83,6 +161,11 @@ export function exceedsDepth(value: unknown, max: number): boolean {
|
|
|
83
161
|
// input, so neither can overflow, and a too-deep input is a caller/input error
|
|
84
162
|
// that the callers bucket as malformed (same contract as a malformed key).
|
|
85
163
|
//
|
|
164
|
+
// Both of them then see `boundedInput`'s output, never the caller's value. That
|
|
165
|
+
// is what makes the two checks below true of what actually gets serialized: the
|
|
166
|
+
// bound holds against an unstable `toJSON`, and a lone surrogate behind a
|
|
167
|
+
// `toJSON` is seen rather than emitted (proof#71 and its review).
|
|
168
|
+
//
|
|
86
169
|
// Lone (unpaired) UTF-16 surrogates are REJECTED, never passed through: RFC
|
|
87
170
|
// 8785 requires well-formed UTF-8 output, JS strings can carry lone
|
|
88
171
|
// surrogates that `canonicalize` would emit as-is, and the sibling kernels
|
|
@@ -90,13 +173,14 @@ export function exceedsDepth(value: unknown, max: number): boolean {
|
|
|
90
173
|
// them). Reject-only is the one behavior all three kernels can share —
|
|
91
174
|
// pinned here and by the corpus lone-surrogate negative.
|
|
92
175
|
export function jcs(value: unknown): string {
|
|
93
|
-
|
|
176
|
+
const input = boundedInput(value, MAX_CANONICAL_DEPTH);
|
|
177
|
+
if (input === OVER_DEEP) {
|
|
94
178
|
throw new Error(
|
|
95
179
|
`jcs: input nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`,
|
|
96
180
|
);
|
|
97
181
|
}
|
|
98
|
-
rejectLoneSurrogates(
|
|
99
|
-
const canonical = canonicalize(
|
|
182
|
+
rejectLoneSurrogates(input);
|
|
183
|
+
const canonical = canonicalize(input);
|
|
100
184
|
if (typeof canonical !== "string") {
|
|
101
185
|
throw new Error("jcs: canonicalize returned a non-string (input not JSON-serializable?)");
|
|
102
186
|
}
|