@sanning/proof 0.7.0 → 0.8.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/dist/chain.js +48 -10
- package/dist/cli.js +25 -0
- package/dist/evidence.d.ts +50 -0
- package/dist/evidence.js +442 -91
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/chain.ts +48 -8
- package/src/cli.ts +29 -0
- package/src/evidence.ts +590 -97
- package/src/index.ts +4 -0
- package/vectors/README.md +18 -0
- package/vectors/stamp-on-chain.json +229 -0
package/dist/evidence.js
CHANGED
|
@@ -61,6 +61,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
|
61
61
|
* unrecoverable once witnessed.
|
|
62
62
|
*/
|
|
63
63
|
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
64
|
+
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
|
|
64
65
|
/**
|
|
65
66
|
* The version string INSIDE a witnessed stamp body — a different string from
|
|
66
67
|
* the `body_type` above, and the two are easy to confuse.
|
|
@@ -193,7 +194,11 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
193
194
|
// wrapper is authentic — and for this body that IS "Sanning signed it", since
|
|
194
195
|
// a stamp carries no second signature of its own inside the bundle.
|
|
195
196
|
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
196
|
-
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
197
|
+
return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
|
|
198
|
+
}
|
|
199
|
+
// The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
|
|
200
|
+
if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
201
|
+
return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
|
|
197
202
|
}
|
|
198
203
|
if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
199
204
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
@@ -523,75 +528,10 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
523
528
|
evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
|
|
524
529
|
}
|
|
525
530
|
}
|
|
526
|
-
// Content (raw-log) binding —
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
// envelope it falls back to the envelope's own committed hashes
|
|
531
|
-
// (contentHashes — NOT verifyEnvelope's path, which reads payload/event_type
|
|
532
|
-
// and yields nothing for a sanning.events/v1 envelope). Disclosed bytes come
|
|
533
|
-
// from the signed in-body `ev.content` FIRST, then the out-of-band
|
|
534
|
-
// options.content side input. Undetermined (null) when nothing is disclosed
|
|
535
|
-
// or nothing is committed; false ONLY on a real mismatch or an in-body vs
|
|
536
|
-
// side-input disagreement — mirroring payloadBindingOk's never-fail-on-absent
|
|
537
|
-
// discipline.
|
|
538
|
-
let contentOk = null;
|
|
539
|
-
{
|
|
540
|
-
const committed = [];
|
|
541
|
-
let recordContentHash;
|
|
542
|
-
if (recordBytes !== undefined) {
|
|
543
|
-
try {
|
|
544
|
-
const record = JSON.parse(new TextDecoder().decode(recordBytes));
|
|
545
|
-
const ch = record.event?.content_hash;
|
|
546
|
-
if (typeof ch === "string" && ch)
|
|
547
|
-
recordContentHash = ch.toLowerCase();
|
|
548
|
-
}
|
|
549
|
-
catch {
|
|
550
|
-
// record not JSON — the payload binding already reflects that; content
|
|
551
|
-
// stays undetermined rather than throwing.
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
if (recordContentHash !== undefined) {
|
|
555
|
-
committed.push(recordContentHash);
|
|
556
|
-
}
|
|
557
|
-
else if (ev.envelope && typeof ev.envelope === "object") {
|
|
558
|
-
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
559
|
-
// which throws on a null/non-object envelope — a verifier must never
|
|
560
|
-
// raise on adversarial input (such an event already fails via envelopeOk).
|
|
561
|
-
for (const c of contentHashes(ev.envelope))
|
|
562
|
-
committed.push(c.hash.toLowerCase());
|
|
563
|
-
}
|
|
564
|
-
// Disclosed bytes, precedence: in-body (signed) → side input. A string
|
|
565
|
-
// side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
|
|
566
|
-
// selector so `disclosedContent` hands a consumer the very bytes bound
|
|
567
|
-
// here — one decode, not two that can drift apart.
|
|
568
|
-
const selection = selectDisclosedBytes(ev, eventId, options.content);
|
|
569
|
-
const { inBody, side } = selection;
|
|
570
|
-
evErrors.push(...selection.errors);
|
|
571
|
-
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
572
|
-
// Two disclosures that disagree: a genuine conflict, not undetermined.
|
|
573
|
-
contentOk = false;
|
|
574
|
-
evErrors.push("disclosed content disagreement: in-body events[].content and the supplied content differ");
|
|
575
|
-
}
|
|
576
|
-
else {
|
|
577
|
-
const disclosed = inBody ?? side;
|
|
578
|
-
if (disclosed === undefined || committed.length === 0) {
|
|
579
|
-
contentOk = null;
|
|
580
|
-
if (disclosed !== undefined && committed.length === 0) {
|
|
581
|
-
evErrors.push("disclosed content present but no committed content_hash to bind to " +
|
|
582
|
-
"(record withheld or record has no event.content_hash) — undetermined");
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
else {
|
|
586
|
-
const got = await sha256Hex(disclosed);
|
|
587
|
-
contentOk = committed.includes(got);
|
|
588
|
-
if (!contentOk) {
|
|
589
|
-
evErrors.push("disclosed content does not match the committed content_hash: " +
|
|
590
|
-
`sha256(disclosed)=${got} committed=${committed.join("|")}`);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
}
|
|
531
|
+
// Content (raw-log) binding — shared with every other event-carrying body.
|
|
532
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
|
|
533
|
+
const contentOk = contentRes.contentOk;
|
|
534
|
+
evErrors.push(...contentRes.errors);
|
|
595
535
|
eventResults.push({
|
|
596
536
|
eventId,
|
|
597
537
|
envelopeOk,
|
|
@@ -748,6 +688,72 @@ function isNonNegInt(n) {
|
|
|
748
688
|
* verifier that stops at the first one turns a second fix into a second
|
|
749
689
|
* round-trip.
|
|
750
690
|
*/
|
|
691
|
+
// ===========================================================================
|
|
692
|
+
// Disclosed-content binding, shared by every body that carries events.
|
|
693
|
+
//
|
|
694
|
+
// Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
|
|
695
|
+
// rawLog -> content_hash link is the one step the chain
|
|
696
|
+
// (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
|
|
697
|
+
// not otherwise cover, and a second copy of it is a second place for the
|
|
698
|
+
// never-fail-on-absent discipline to drift. The rules are unchanged from the
|
|
699
|
+
// anchor-trace path this came from: undetermined (null) when nothing is
|
|
700
|
+
// disclosed or nothing is committed; false ONLY on a real mismatch or an
|
|
701
|
+
// in-body vs side-input disagreement.
|
|
702
|
+
// ===========================================================================
|
|
703
|
+
async function verifyDisclosedContent(ev, eventId, recordBytes, sideContent) {
|
|
704
|
+
const evErrors = [];
|
|
705
|
+
let contentOk = null;
|
|
706
|
+
const committed = [];
|
|
707
|
+
let recordContentHash;
|
|
708
|
+
if (recordBytes !== undefined) {
|
|
709
|
+
try {
|
|
710
|
+
const record = JSON.parse(new TextDecoder().decode(recordBytes));
|
|
711
|
+
const ch = record.event?.content_hash;
|
|
712
|
+
if (typeof ch === "string" && ch)
|
|
713
|
+
recordContentHash = ch.toLowerCase();
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
// record not JSON — the payload binding already reflects that; content
|
|
717
|
+
// stays undetermined rather than throwing.
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (recordContentHash !== undefined) {
|
|
721
|
+
committed.push(recordContentHash);
|
|
722
|
+
}
|
|
723
|
+
else if (ev.envelope && typeof ev.envelope === "object") {
|
|
724
|
+
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
725
|
+
// which throws on a null/non-object envelope — a verifier must never raise
|
|
726
|
+
// on adversarial input.
|
|
727
|
+
for (const c of contentHashes(ev.envelope))
|
|
728
|
+
committed.push(c.hash.toLowerCase());
|
|
729
|
+
}
|
|
730
|
+
const selection = selectDisclosedBytes(ev, eventId, sideContent);
|
|
731
|
+
const { inBody, side } = selection;
|
|
732
|
+
evErrors.push(...selection.errors);
|
|
733
|
+
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
734
|
+
contentOk = false;
|
|
735
|
+
evErrors.push("disclosed content disagreement: in-body events[].content and the supplied content differ");
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
const disclosed = inBody ?? side;
|
|
739
|
+
if (disclosed === undefined || committed.length === 0) {
|
|
740
|
+
contentOk = null;
|
|
741
|
+
if (disclosed !== undefined && committed.length === 0) {
|
|
742
|
+
evErrors.push("disclosed content present but no committed content_hash to bind to " +
|
|
743
|
+
"(record withheld or record has no event.content_hash) — undetermined");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
const got = await sha256Hex(disclosed);
|
|
748
|
+
contentOk = committed.includes(got);
|
|
749
|
+
if (!contentOk) {
|
|
750
|
+
evErrors.push("disclosed content does not match the committed content_hash: " +
|
|
751
|
+
`sha256(disclosed)=${got} committed=${committed.join("|")}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return { contentOk, errors: evErrors };
|
|
756
|
+
}
|
|
751
757
|
async function verifyStampEntry(raw, seen) {
|
|
752
758
|
const errors = [];
|
|
753
759
|
const base = {
|
|
@@ -762,18 +768,20 @@ async function verifyStampEntry(raw, seen) {
|
|
|
762
768
|
subtreesOk: false,
|
|
763
769
|
subtrees: [],
|
|
764
770
|
anchorRef: null,
|
|
771
|
+
onChain: null,
|
|
772
|
+
onChainOk: null,
|
|
765
773
|
ok: false,
|
|
766
774
|
errors,
|
|
767
775
|
};
|
|
768
776
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
769
777
|
errors.push("stamp entry is not an object");
|
|
770
|
-
return base;
|
|
778
|
+
return { result: base };
|
|
771
779
|
}
|
|
772
780
|
const e = raw;
|
|
773
781
|
// --- the witnessed bytes, which are the only authority in this body -------
|
|
774
782
|
if (!isLowerHex(e.stamp_bytes)) {
|
|
775
783
|
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
776
|
-
return base;
|
|
784
|
+
return { result: base };
|
|
777
785
|
}
|
|
778
786
|
let bytes;
|
|
779
787
|
try {
|
|
@@ -781,8 +789,11 @@ async function verifyStampEntry(raw, seen) {
|
|
|
781
789
|
}
|
|
782
790
|
catch (err) {
|
|
783
791
|
errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
|
|
784
|
-
return base;
|
|
792
|
+
return { result: base };
|
|
785
793
|
}
|
|
794
|
+
// Gated by `isLowerHex` above, so this is already the exact lowercase hex of
|
|
795
|
+
// the exact witnessed bytes — §5.2.2's "one authority in this body".
|
|
796
|
+
const witnessedHex = e.stamp_bytes;
|
|
786
797
|
// --- the derived identity -------------------------------------------------
|
|
787
798
|
const derived = await sha256Hex(bytes);
|
|
788
799
|
if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
|
|
@@ -812,7 +823,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
812
823
|
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
813
824
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
814
825
|
errors.push("stamp body is not a JSON object");
|
|
815
|
-
return base;
|
|
826
|
+
return { result: base, witnessedHex };
|
|
816
827
|
}
|
|
817
828
|
body = parsed;
|
|
818
829
|
// Re-canonicalize and compare. The plane replays these bytes verbatim on
|
|
@@ -821,12 +832,12 @@ async function verifyStampEntry(raw, seen) {
|
|
|
821
832
|
// of the stamp disagree with the one that was witnessed.
|
|
822
833
|
if (jcs(body) !== new TextDecoder().decode(bytes)) {
|
|
823
834
|
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
824
|
-
return base;
|
|
835
|
+
return { result: base, witnessedHex };
|
|
825
836
|
}
|
|
826
837
|
}
|
|
827
838
|
catch (err) {
|
|
828
839
|
errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
|
|
829
|
-
return base;
|
|
840
|
+
return { result: base, witnessedHex };
|
|
830
841
|
}
|
|
831
842
|
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
832
843
|
// A `sanning.stamp.trace/v1` body whose inner version is something else is
|
|
@@ -835,7 +846,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
835
846
|
// how a reader half-parses a shape it does not know.
|
|
836
847
|
errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
837
848
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
|
|
838
|
-
return base;
|
|
849
|
+
return { result: base, witnessedHex };
|
|
839
850
|
}
|
|
840
851
|
base.stampVersion = body.stamp_version;
|
|
841
852
|
base.bodyOk = true;
|
|
@@ -866,7 +877,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
866
877
|
base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
|
|
867
878
|
base.ok =
|
|
868
879
|
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
869
|
-
return base;
|
|
880
|
+
return { result: base, witnessedHex };
|
|
870
881
|
}
|
|
871
882
|
/**
|
|
872
883
|
* The range arithmetic (§5.2), and the reason it is a hard check.
|
|
@@ -942,16 +953,27 @@ function checkSubtrees(body, out, errors) {
|
|
|
942
953
|
}
|
|
943
954
|
return true;
|
|
944
955
|
}
|
|
945
|
-
async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
956
|
+
async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, options) {
|
|
946
957
|
const body = b.body;
|
|
947
958
|
if (!Array.isArray(body?.stamps)) {
|
|
948
959
|
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
|
|
949
960
|
}
|
|
950
961
|
const seen = new Set();
|
|
951
962
|
const stamps = [];
|
|
963
|
+
const witnessed = [];
|
|
952
964
|
for (const entry of body.stamps) {
|
|
953
|
-
|
|
965
|
+
const outcome = await verifyStampEntry(entry, seen);
|
|
966
|
+
stamps.push(outcome.result);
|
|
967
|
+
witnessed.push(outcome.witnessedHex);
|
|
954
968
|
}
|
|
969
|
+
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
970
|
+
//
|
|
971
|
+
// The SAME stamps[] array §5.3 carries, so it gets the same capability. The
|
|
972
|
+
// alternative — a stamp that can be re-fetched inside one body and not the
|
|
973
|
+
// other — asks every consumer to remember which wrapper it is holding before
|
|
974
|
+
// it can trust `onChain: null`, and that is exactly the kind of per-body
|
|
975
|
+
// exception a reader eventually gets wrong.
|
|
976
|
+
const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
|
|
955
977
|
// --- Rollup ----------------------------------------------------------------
|
|
956
978
|
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
957
979
|
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
@@ -966,15 +988,23 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
|
966
988
|
status = "partial";
|
|
967
989
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
968
990
|
}
|
|
969
|
-
else if (anyUnwitnessed) {
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
// our coverage and never an accusation about their evidence. It is also not
|
|
973
|
-
// `verified`, because for a stamp the witness is the entire independent
|
|
974
|
-
// part — unlike an anchor-trace checkpoint, which still carries a producer
|
|
975
|
-
// signature and an inclusion proof that stand on their own offline.
|
|
991
|
+
else if (gatewayUnavailable || anyUnwitnessed) {
|
|
992
|
+
// Both hold the bundle at `partial`, and both get said — see §5.3's rollup
|
|
993
|
+
// for why one note is not enough when there are two different gaps.
|
|
976
994
|
status = "partial";
|
|
977
|
-
|
|
995
|
+
if (gatewayUnavailable) {
|
|
996
|
+
errors.push("one or more stamps could not be re-fetched from the supplied gateways");
|
|
997
|
+
}
|
|
998
|
+
if (anyUnwitnessed) {
|
|
999
|
+
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1000
|
+
// sealed and unwitnessed: `retained-by-sanning`, which is a statement
|
|
1001
|
+
// about our coverage and never an accusation about their evidence. It is
|
|
1002
|
+
// also not `verified`, because for a stamp the witness is the entire
|
|
1003
|
+
// independent part — unlike an anchor-trace checkpoint, which still
|
|
1004
|
+
// carries a producer signature and an inclusion proof that stand on
|
|
1005
|
+
// their own offline.
|
|
1006
|
+
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
1007
|
+
}
|
|
978
1008
|
}
|
|
979
1009
|
else {
|
|
980
1010
|
status = "verified";
|
|
@@ -985,7 +1015,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
|
985
1015
|
bodyHashOk: true,
|
|
986
1016
|
signatureOk: true,
|
|
987
1017
|
bodyType: STAMP_TRACE_BODY_TYPE,
|
|
988
|
-
onChainChecked
|
|
1018
|
+
onChainChecked,
|
|
989
1019
|
checkpoints: [],
|
|
990
1020
|
events: [],
|
|
991
1021
|
assertedStatus,
|
|
@@ -993,6 +1023,285 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
|
993
1023
|
errors,
|
|
994
1024
|
};
|
|
995
1025
|
}
|
|
1026
|
+
// ===========================================================================
|
|
1027
|
+
// `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
|
|
1028
|
+
//
|
|
1029
|
+
// The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
|
|
1030
|
+
// the sub-tree partition check). The event half reuses the same primitives the
|
|
1031
|
+
// anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
|
|
1032
|
+
// `verifyDisclosedContent` — because a second copy of Merkle verification is a
|
|
1033
|
+
// second place for it to drift.
|
|
1034
|
+
//
|
|
1035
|
+
// What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
|
|
1036
|
+
// to a stamps[] entry and walks its audit path to the root committed INSIDE
|
|
1037
|
+
// that stamp's witnessed bytes. There is no `merkle_root` field on the event to
|
|
1038
|
+
// read instead — repeating the root beside its own authoritative bytes would be
|
|
1039
|
+
// two answers to one question inside one signature (§5.1.2).
|
|
1040
|
+
// ===========================================================================
|
|
1041
|
+
async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options) {
|
|
1042
|
+
const body = b.body;
|
|
1043
|
+
if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
|
|
1044
|
+
return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
|
|
1045
|
+
}
|
|
1046
|
+
// --- The stamp half: §5.2's rules, §5.2's code ----------------------------
|
|
1047
|
+
const seen = new Set();
|
|
1048
|
+
const stamps = [];
|
|
1049
|
+
const witnessed = [];
|
|
1050
|
+
for (const entry of body.stamps) {
|
|
1051
|
+
const outcome = await verifyStampEntry(entry, seen);
|
|
1052
|
+
stamps.push(outcome.result);
|
|
1053
|
+
witnessed.push(outcome.witnessedHex);
|
|
1054
|
+
}
|
|
1055
|
+
// The join table. ONLY a stamp whose derived ref and witnessed bytes both
|
|
1056
|
+
// checked out is joinable: a root read out of bytes that failed their own
|
|
1057
|
+
// verification is not a root anyone should walk a proof to, and admitting one
|
|
1058
|
+
// would let a broken stamp lend its authority to an event.
|
|
1059
|
+
const byRef = new Map();
|
|
1060
|
+
for (const st of stamps) {
|
|
1061
|
+
if (st.stampRef && st.stampRefOk && st.bodyOk)
|
|
1062
|
+
byRef.set(st.stampRef, st);
|
|
1063
|
+
}
|
|
1064
|
+
// --- The event half -------------------------------------------------------
|
|
1065
|
+
const eventResults = [];
|
|
1066
|
+
for (const rawEv of body.events) {
|
|
1067
|
+
const evErrors = [];
|
|
1068
|
+
// A verifier must never RAISE on adversarial input — a caller handed a
|
|
1069
|
+
// stack trace instead of a verdict cannot tell a malformed pack from a
|
|
1070
|
+
// broken tool. `events[]` is attacker-shaped, so a null/non-object entry
|
|
1071
|
+
// is coerced to an empty event and fails through the normal path, exactly
|
|
1072
|
+
// as the Python kernel does. Found by adversarial review, not by the
|
|
1073
|
+
// cross-kernel leg — whose cases did not include one, and now do.
|
|
1074
|
+
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
1075
|
+
// NOT an event. Graded and reported as such rather than coerced to an
|
|
1076
|
+
// empty one: probing verifyEnvelope with nothing asks the envelope layer
|
|
1077
|
+
// a question about an object that was never an envelope, and the two
|
|
1078
|
+
// kernels answer it differently (undetermined vs failed) — a real
|
|
1079
|
+
// divergence, but not this body's to resolve. Reported separately.
|
|
1080
|
+
eventResults.push({
|
|
1081
|
+
eventId: "<unknown>",
|
|
1082
|
+
envelopeOk: false,
|
|
1083
|
+
payloadBindingOk: null,
|
|
1084
|
+
inclusionOk: false,
|
|
1085
|
+
checkpointBound: false,
|
|
1086
|
+
contentOk: null,
|
|
1087
|
+
ok: false,
|
|
1088
|
+
errors: ["events[] entry is not an object"],
|
|
1089
|
+
});
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
const ev = rawEv;
|
|
1093
|
+
const eventId = getEventId(ev.envelope);
|
|
1094
|
+
let envelopeOk = false;
|
|
1095
|
+
let payloadBindingOk = null;
|
|
1096
|
+
let recordBytes;
|
|
1097
|
+
try {
|
|
1098
|
+
if (typeof ev.record_bytes === "string")
|
|
1099
|
+
recordBytes = hexToBytes(ev.record_bytes);
|
|
1100
|
+
}
|
|
1101
|
+
catch (e) {
|
|
1102
|
+
evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
|
|
1103
|
+
}
|
|
1104
|
+
try {
|
|
1105
|
+
const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
|
|
1106
|
+
envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
|
|
1107
|
+
payloadBindingOk = res.payloadHashOk;
|
|
1108
|
+
if (!res.signatureOk)
|
|
1109
|
+
evErrors.push("event envelope signature failed");
|
|
1110
|
+
if (res.payloadHashOk === false)
|
|
1111
|
+
evErrors.push("event record does not bind to payload_hash");
|
|
1112
|
+
}
|
|
1113
|
+
catch (e) {
|
|
1114
|
+
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
1115
|
+
}
|
|
1116
|
+
// --- The join (§5.3.3 rule 1) -------------------------------------------
|
|
1117
|
+
const incl = ev.inclusion;
|
|
1118
|
+
let st;
|
|
1119
|
+
if (!incl || typeof incl !== "object") {
|
|
1120
|
+
evErrors.push("event missing inclusion proof");
|
|
1121
|
+
}
|
|
1122
|
+
else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
|
|
1123
|
+
// One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
|
|
1124
|
+
// join key inside this body is a mixed bundle, and resolving it anyway is
|
|
1125
|
+
// how an event silently binds to something nobody meant (§5.1.2).
|
|
1126
|
+
evErrors.push(`event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`);
|
|
1127
|
+
}
|
|
1128
|
+
else if (typeof incl.stamp_ref !== "string") {
|
|
1129
|
+
evErrors.push("event inclusion missing stamp_ref");
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
st = byRef.get(incl.stamp_ref);
|
|
1133
|
+
if (st === undefined) {
|
|
1134
|
+
// Hard reject, never undetermined: without a resolved stamp the
|
|
1135
|
+
// inclusion proof has no root to check against at all.
|
|
1136
|
+
evErrors.push(`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const stampBound = st !== undefined;
|
|
1140
|
+
let inclusionOk = false;
|
|
1141
|
+
if (incl && stampBound && st) {
|
|
1142
|
+
// Rule 3: the tree walked must be the tree the stamp committed to. A
|
|
1143
|
+
// proof walked against a different leaf_count is a proof about a
|
|
1144
|
+
// different tree, and RFC 9162's path shape depends on the size.
|
|
1145
|
+
if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
|
|
1146
|
+
evErrors.push(`inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
|
|
1147
|
+
`leaf_count ${st.leafCount}`);
|
|
1148
|
+
}
|
|
1149
|
+
else {
|
|
1150
|
+
try {
|
|
1151
|
+
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(st.merkleRoot ?? ""));
|
|
1152
|
+
if (!inclusionOk) {
|
|
1153
|
+
evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
|
|
1154
|
+
}
|
|
1155
|
+
// Defense in depth, as §5.1: the claimed leaf MUST be this event's
|
|
1156
|
+
// own signed envelope bytes, or a valid proof for an UNRELATED leaf
|
|
1157
|
+
// could be smuggled in.
|
|
1158
|
+
const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
|
|
1159
|
+
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
1160
|
+
inclusionOk = false;
|
|
1161
|
+
evErrors.push("inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))");
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
catch (e) {
|
|
1165
|
+
evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
|
|
1170
|
+
const contentOk = contentRes.contentOk;
|
|
1171
|
+
evErrors.push(...contentRes.errors);
|
|
1172
|
+
eventResults.push({
|
|
1173
|
+
eventId,
|
|
1174
|
+
envelopeOk,
|
|
1175
|
+
payloadBindingOk,
|
|
1176
|
+
inclusionOk,
|
|
1177
|
+
// Named for §5.1's join; here it reports whether `stamp_ref` resolved.
|
|
1178
|
+
checkpointBound: stampBound,
|
|
1179
|
+
contentOk,
|
|
1180
|
+
ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
|
|
1181
|
+
errors: evErrors,
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
1185
|
+
const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
|
|
1186
|
+
// --- Rollup ---------------------------------------------------------------
|
|
1187
|
+
// Rule 4: a declared event MUST be checked. `events[]` present and unverified
|
|
1188
|
+
// is the false green this body exists to prevent, so an event that did not
|
|
1189
|
+
// verify fails the bundle — it is never rolled up as verified.
|
|
1190
|
+
const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
|
|
1191
|
+
const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
|
|
1192
|
+
let status;
|
|
1193
|
+
if (anyHardFailure) {
|
|
1194
|
+
status = "failed";
|
|
1195
|
+
}
|
|
1196
|
+
else if (stamps.length === 0 && eventResults.length === 0) {
|
|
1197
|
+
status = "partial";
|
|
1198
|
+
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
1199
|
+
}
|
|
1200
|
+
else if (gatewayUnavailable || anyUnwitnessed) {
|
|
1201
|
+
// Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
|
|
1202
|
+
// witnessed and a witness we could not reach are different gaps with
|
|
1203
|
+
// different fixes — one waits for the interval, the other for the network —
|
|
1204
|
+
// and an auditor told only about the second would read the first as fine.
|
|
1205
|
+
// The order is fixed so the two kernels emit the same list.
|
|
1206
|
+
status = "partial";
|
|
1207
|
+
if (gatewayUnavailable) {
|
|
1208
|
+
errors.push("one or more stamps could not be re-fetched from the supplied gateways");
|
|
1209
|
+
}
|
|
1210
|
+
if (anyUnwitnessed) {
|
|
1211
|
+
// §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
|
|
1212
|
+
// the independent part, so the events resting on it inherit `partial`.
|
|
1213
|
+
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
status = "verified";
|
|
1218
|
+
}
|
|
1219
|
+
return {
|
|
1220
|
+
status,
|
|
1221
|
+
specVersionOk,
|
|
1222
|
+
bodyHashOk: true,
|
|
1223
|
+
signatureOk: true,
|
|
1224
|
+
bodyType: STAMP_EVIDENCE_BODY_TYPE,
|
|
1225
|
+
onChainChecked,
|
|
1226
|
+
checkpoints: [],
|
|
1227
|
+
events: eventResults,
|
|
1228
|
+
assertedStatus,
|
|
1229
|
+
stamps,
|
|
1230
|
+
errors,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
|
|
1235
|
+
*
|
|
1236
|
+
* ── Why the results land on `stamps[]` and `checkpoints` stays empty ──
|
|
1237
|
+
*
|
|
1238
|
+
* A stamp body carries no checkpoints. Synthesising `CheckpointResult`s to
|
|
1239
|
+
* carry these outcomes would make a stamp indistinguishable from a checkpoint
|
|
1240
|
+
* in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
|
|
1241
|
+
* and §5.3.1 refused twice, most sharply in "a Sanning attestation
|
|
1242
|
+
* indistinguishable on the wire from a producer's". They are different
|
|
1243
|
+
* artifacts with different signers and different meanings when absent, and a
|
|
1244
|
+
* reader must never have to guess which one it is holding. So the stamp
|
|
1245
|
+
* results go on the stamps, and `checkpoints: []` keeps saying the true thing:
|
|
1246
|
+
* this body has none.
|
|
1247
|
+
*
|
|
1248
|
+
* ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
|
|
1249
|
+
*
|
|
1250
|
+
* The anchor-trace path synthesises an all-`unreachable` result for a
|
|
1251
|
+
* checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
|
|
1252
|
+
*
|
|
1253
|
+
* It would be untrue: nothing was asked of any gateway, so reporting that
|
|
1254
|
+
* every gateway failed to answer invents a network event. "Not checked" and
|
|
1255
|
+
* "we asked and nobody answered" are different facts, and collapsing them is
|
|
1256
|
+
* precisely the defect that had a surface reporting an unreachable gateway
|
|
1257
|
+
* when it had contacted none.
|
|
1258
|
+
*
|
|
1259
|
+
* And it would be load-bearing in the wrong direction. Over there the
|
|
1260
|
+
* synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
|
|
1261
|
+
* run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
|
|
1262
|
+
* `partial` on its own, with a better error. Copying the synthesis would add
|
|
1263
|
+
* nothing to the status and would silently move the CLI exit code from 0 to 3
|
|
1264
|
+
* for an unwitnessed stamp — changing what an auditor's script does, based on
|
|
1265
|
+
* nothing but whether the caller happened to pass a gateway list.
|
|
1266
|
+
*/
|
|
1267
|
+
async function refetchStamps(stamps, witnessed, options, errors) {
|
|
1268
|
+
const gateways = options.gateways ?? [];
|
|
1269
|
+
const onChainChecked = gateways.length > 0;
|
|
1270
|
+
if (!onChainChecked)
|
|
1271
|
+
return { onChainChecked, gatewayUnavailable: false };
|
|
1272
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1273
|
+
if (typeof fetchImpl !== "function") {
|
|
1274
|
+
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
1275
|
+
return { onChainChecked, gatewayUnavailable: true };
|
|
1276
|
+
}
|
|
1277
|
+
let gatewayUnavailable = false;
|
|
1278
|
+
for (let i = 0; i < stamps.length; i++) {
|
|
1279
|
+
const st = stamps[i];
|
|
1280
|
+
const witnessedHex = witnessed[i];
|
|
1281
|
+
// No witness named, or bytes we could not read: nothing to re-fetch, and
|
|
1282
|
+
// nothing a gateway could confirm or deny. Left as `onChain: null`.
|
|
1283
|
+
if (st.anchorRef === null || witnessedHex === undefined)
|
|
1284
|
+
continue;
|
|
1285
|
+
const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
|
|
1286
|
+
st.onChain = outcome.onChain;
|
|
1287
|
+
st.onChainOk = outcome.onChain.onChainOk;
|
|
1288
|
+
if (outcome.unavailable)
|
|
1289
|
+
gatewayUnavailable = true;
|
|
1290
|
+
if (outcome.error)
|
|
1291
|
+
st.errors.push(outcome.error);
|
|
1292
|
+
// Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
|
|
1293
|
+
// (false) fails the stamp — the chain does not hold what the bundle says
|
|
1294
|
+
// was witnessed. UNREACHABLE (null) leaves an offline-sound stamp ok and
|
|
1295
|
+
// downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
|
|
1296
|
+
// changes nothing that was not already true offline.
|
|
1297
|
+
//
|
|
1298
|
+
// `st.ok` is kept and narrowed rather than recomputed: its offline form
|
|
1299
|
+
// includes `errors.length === 0`, and re-evaluating that AFTER pushing an
|
|
1300
|
+
// unreachable note would fail a stamp for a gateway being down.
|
|
1301
|
+
st.ok = st.ok && st.onChainOk !== false;
|
|
1302
|
+
}
|
|
1303
|
+
return { onChainChecked, gatewayUnavailable };
|
|
1304
|
+
}
|
|
996
1305
|
// How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
|
|
997
1306
|
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
998
1307
|
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
@@ -1605,6 +1914,10 @@ function summarizeExport(status, source, attestations) {
|
|
|
1605
1914
|
async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
1606
1915
|
let expectedHex;
|
|
1607
1916
|
try {
|
|
1917
|
+
// A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
|
|
1918
|
+
// bundle carries the envelope as a JSON object, not as bytes, so what was
|
|
1919
|
+
// uploaded has to be recomputed from it. See `refetchStamp` for the case
|
|
1920
|
+
// where this step is not merely unnecessary but WRONG.
|
|
1608
1921
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
1609
1922
|
}
|
|
1610
1923
|
catch {
|
|
@@ -1612,6 +1925,41 @@ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
|
1612
1925
|
// (we can never confirm bytes we cannot compute).
|
|
1613
1926
|
expectedHex = undefined;
|
|
1614
1927
|
}
|
|
1928
|
+
return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
|
|
1929
|
+
}
|
|
1930
|
+
// Re-fetch a STAMP's witnessed bytes (evidence-bundle.md §5.2.2 / §5.3.2).
|
|
1931
|
+
//
|
|
1932
|
+
// ── The one step this path must NOT copy from the checkpoint path ──
|
|
1933
|
+
//
|
|
1934
|
+
// `refetchCheckpoint` recomputes `JCS(envelope)` because a checkpoint's
|
|
1935
|
+
// witnessed bytes are not in the bundle — only the object they were derived
|
|
1936
|
+
// from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
|
|
1937
|
+
// "lowercase hex of the exact JCS bytes that were handed to the witness", and
|
|
1938
|
+
// §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
|
|
1939
|
+
// are already the bytes, already hex, already lowercase (`isLowerHex` gated
|
|
1940
|
+
// them), and `stamp_ref` is their SHA-256.
|
|
1941
|
+
//
|
|
1942
|
+
// So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
|
|
1943
|
+
// defect, not a redundancy: it would compare the chain against bytes NOBODY
|
|
1944
|
+
// SIGNED. On a canonical body the two agree and the bug hides; on a body that
|
|
1945
|
+
// is not canonical — the exact case §5.2.2 makes the kernel check for, because
|
|
1946
|
+
// the plane replays these bytes as text precisely so a normalizing serializer
|
|
1947
|
+
// cannot alter them — a re-canonicalizing verifier reports `mismatch` against
|
|
1948
|
+
// a chain that faithfully holds what it was given. A false accusation, from
|
|
1949
|
+
// the verifier, about untampered evidence.
|
|
1950
|
+
//
|
|
1951
|
+
// The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
|
|
1952
|
+
// `stamp_ref`, threaded through rather than re-read from the body, so the bytes
|
|
1953
|
+
// graded and the bytes compared cannot drift apart.
|
|
1954
|
+
async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
|
|
1955
|
+
return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
|
|
1956
|
+
}
|
|
1957
|
+
// The part both paths share: probe every gateway, roll the outcomes up under
|
|
1958
|
+
// the ONE rule (`rollupOnChain`), and phrase the finding. Extracted rather than
|
|
1959
|
+
// copied — a second rollup rule is a second place for "mismatch beats confirm
|
|
1960
|
+
// beats unreachable" to drift, and the drifted copy is the one nobody reads.
|
|
1961
|
+
// The subject only ever selects wording; it never changes a verdict.
|
|
1962
|
+
async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchImpl) {
|
|
1615
1963
|
const perGateway = [];
|
|
1616
1964
|
for (const gw of gateways) {
|
|
1617
1965
|
perGateway.push({
|
|
@@ -1622,10 +1970,13 @@ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
|
1622
1970
|
const onChain = rollupOnChain(perGateway);
|
|
1623
1971
|
let error;
|
|
1624
1972
|
if (onChain.rollup === "mismatch") {
|
|
1625
|
-
error =
|
|
1973
|
+
error =
|
|
1974
|
+
subject === "checkpoint"
|
|
1975
|
+
? `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`
|
|
1976
|
+
: `on-chain bytes at ${anchorRef.id} do not match the stamp's witnessed bytes`;
|
|
1626
1977
|
}
|
|
1627
1978
|
else if (onChain.rollup === "unreachable") {
|
|
1628
|
-
error =
|
|
1979
|
+
error = `${subject} ${anchorRef.id} unreachable on all gateways`;
|
|
1629
1980
|
}
|
|
1630
1981
|
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
1631
1982
|
}
|