@sanning/proof 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chain.js +48 -10
- package/dist/evidence.d.ts +21 -0
- package/dist/evidence.js +266 -69
- 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/evidence.ts +332 -73
- package/src/index.ts +4 -0
package/dist/chain.js
CHANGED
|
@@ -143,7 +143,15 @@ function adapterLinkage(record) {
|
|
|
143
143
|
// here: nothing to resolve is a beginning.
|
|
144
144
|
const prev = str(fields.prev_event_id);
|
|
145
145
|
const seqRaw = fields.seq;
|
|
146
|
-
|
|
146
|
+
// SAFE integer, not merely integral. Beyond 2^53 a JSON number does not
|
|
147
|
+
// survive `JSON.parse` intact — `9007199254740993` arrives as
|
|
148
|
+
// `...992` — while Python's parser keeps it exactly, so the two kernels
|
|
149
|
+
// read DIFFERENT sequence numbers off identical bytes and report gaps of
|
|
150
|
+
// different widths. Measured at 2^53+1: a `chain-gap` count of
|
|
151
|
+
// 9007199254740990 here against 9007199254740992 there (proof#49). A value
|
|
152
|
+
// this kernel cannot represent is one it cannot read, which is the same
|
|
153
|
+
// answer it gives every other unreadable field: no sequence, take no part.
|
|
154
|
+
const seq = Number.isSafeInteger(seqRaw) ? seqRaw : null;
|
|
147
155
|
return { namespace, chainKey, prev, seq };
|
|
148
156
|
}
|
|
149
157
|
return null;
|
|
@@ -226,6 +234,20 @@ function byChainOrder(a, b) {
|
|
|
226
234
|
return a.seq - b.seq;
|
|
227
235
|
return a.order - b.order;
|
|
228
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* The smallest slot between the lowest and highest occupied ones that no record
|
|
239
|
+
* occupies, found by walking the SORTED occupants rather than the range they
|
|
240
|
+
* span. See the call site: the range's width is chosen by whoever wrote the
|
|
241
|
+
* bundle.
|
|
242
|
+
*/
|
|
243
|
+
function firstMissingSlot(present) {
|
|
244
|
+
const ordered = [...present].sort((a, b) => a - b);
|
|
245
|
+
for (let i = 0; i < ordered.length - 1; i++) {
|
|
246
|
+
if (ordered[i + 1] > ordered[i] + 1)
|
|
247
|
+
return ordered[i] + 1;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
229
251
|
function findingsFor(group) {
|
|
230
252
|
const { members, chainKey, source, publicKey } = group;
|
|
231
253
|
const of = members.length;
|
|
@@ -283,18 +305,23 @@ function findingsFor(group) {
|
|
|
283
305
|
const present = new Set(sequenced.map((m) => m.seq));
|
|
284
306
|
const low = Math.min(...present);
|
|
285
307
|
const high = Math.max(...present);
|
|
286
|
-
|
|
287
|
-
for (let s = low; s <=
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
308
|
+
// Counted, never walked. `seq` is attacker-chosen, so a bundle presenting
|
|
309
|
+
// two records at seq 0 and seq 1e15 made the old `for (let s = low; s <=
|
|
310
|
+
// high; s++)` spin for days — a verifier that HANGS on adversarial input is
|
|
311
|
+
// worse than one that raises, and this reader promises neither (proof#49).
|
|
312
|
+
// The present set is a subset of [low, high], so the absent slots are the
|
|
313
|
+
// width minus the occupants, and the first of them is found by walking the
|
|
314
|
+
// occupants in order.
|
|
315
|
+
const missingCount = high - low + 1 - present.size;
|
|
316
|
+
const firstMissing = missingCount > 0 ? firstMissingSlot(present) : null;
|
|
317
|
+
if (firstMissing !== null) {
|
|
318
|
+
const after = sequenced.filter((m) => m.seq > firstMissing).sort(byChainOrder)[0];
|
|
292
319
|
findings.push({
|
|
293
320
|
kind: "chain-gap",
|
|
294
321
|
...base,
|
|
295
|
-
count:
|
|
322
|
+
count: missingCount,
|
|
296
323
|
eventIds: after === undefined ? [] : [after.eventId],
|
|
297
|
-
firstMissingSeq:
|
|
324
|
+
firstMissingSeq: firstMissing,
|
|
298
325
|
});
|
|
299
326
|
}
|
|
300
327
|
}
|
|
@@ -311,7 +338,18 @@ function findingsFor(group) {
|
|
|
311
338
|
export async function readChains(bundle) {
|
|
312
339
|
const groups = await groupChains(bundle);
|
|
313
340
|
const order = (g) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
|
|
314
|
-
|
|
341
|
+
// Ordered by UTF-16 code unit, NOT `localeCompare`. A chain key is caller
|
|
342
|
+
// text, and `localeCompare` with no locale argument answers from the host's
|
|
343
|
+
// ICU collation: measured here, it puts "chain-2" AFTER "chain_1" where every
|
|
344
|
+
// code-wise comparison puts it before, and it can differ between two
|
|
345
|
+
// TypeScript installs, let alone between kernels. The Python reader sorts the
|
|
346
|
+
// same key encoded as `utf-16-be`, which is exactly this comparison — so the
|
|
347
|
+
// two kernels emit findings in one order (proof#49).
|
|
348
|
+
groups.sort((a, b) => {
|
|
349
|
+
const x = order(a);
|
|
350
|
+
const y = order(b);
|
|
351
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
352
|
+
});
|
|
315
353
|
return {
|
|
316
354
|
chains: groups.map((g) => ({
|
|
317
355
|
chainKey: g.chainKey,
|
package/dist/evidence.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export declare const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
|
25
25
|
* unrecoverable once witnessed.
|
|
26
26
|
*/
|
|
27
27
|
export declare const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
28
|
+
export declare const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
|
|
28
29
|
/**
|
|
29
30
|
* The version string INSIDE a witnessed stamp body — a different string from
|
|
30
31
|
* the `body_type` above, and the two are easy to confuse.
|
|
@@ -162,6 +163,26 @@ export interface StampEntry {
|
|
|
162
163
|
export interface StampTraceBody {
|
|
163
164
|
stamps: StampEntry[];
|
|
164
165
|
}
|
|
166
|
+
/** §5.3's inclusion: §5.1's shape with `stamp_ref` as the join key. */
|
|
167
|
+
export interface StampEvidenceInclusion {
|
|
168
|
+
leaf_hash: string;
|
|
169
|
+
leaf_index: number;
|
|
170
|
+
leaf_count: number;
|
|
171
|
+
audit_path: string[];
|
|
172
|
+
/** Resolves against `stamps[].stamp_ref` in the SAME body. */
|
|
173
|
+
stamp_ref: string;
|
|
174
|
+
}
|
|
175
|
+
export interface StampEvidenceEvent {
|
|
176
|
+
envelope: Envelope;
|
|
177
|
+
record_bytes?: string;
|
|
178
|
+
content?: string;
|
|
179
|
+
inclusion: StampEvidenceInclusion;
|
|
180
|
+
}
|
|
181
|
+
export interface StampEvidenceBody {
|
|
182
|
+
/** Byte-identical to §5.2.2 — one definition, one place to change. */
|
|
183
|
+
stamps: StampEntry[];
|
|
184
|
+
events: StampEvidenceEvent[];
|
|
185
|
+
}
|
|
165
186
|
export interface EvidenceBundle {
|
|
166
187
|
spec_version: string;
|
|
167
188
|
body_type: string;
|
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.
|
|
@@ -195,6 +196,10 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
195
196
|
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
196
197
|
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
197
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);
|
|
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
|
|
200
205
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
@@ -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 = {
|
|
@@ -993,6 +999,197 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
|
993
999
|
errors,
|
|
994
1000
|
};
|
|
995
1001
|
}
|
|
1002
|
+
// ===========================================================================
|
|
1003
|
+
// `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
|
|
1004
|
+
//
|
|
1005
|
+
// The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
|
|
1006
|
+
// the sub-tree partition check). The event half reuses the same primitives the
|
|
1007
|
+
// anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
|
|
1008
|
+
// `verifyDisclosedContent` — because a second copy of Merkle verification is a
|
|
1009
|
+
// second place for it to drift.
|
|
1010
|
+
//
|
|
1011
|
+
// What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
|
|
1012
|
+
// to a stamps[] entry and walks its audit path to the root committed INSIDE
|
|
1013
|
+
// that stamp's witnessed bytes. There is no `merkle_root` field on the event to
|
|
1014
|
+
// read instead — repeating the root beside its own authoritative bytes would be
|
|
1015
|
+
// two answers to one question inside one signature (§5.1.2).
|
|
1016
|
+
// ===========================================================================
|
|
1017
|
+
async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options) {
|
|
1018
|
+
const body = b.body;
|
|
1019
|
+
if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
|
|
1020
|
+
return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
|
|
1021
|
+
}
|
|
1022
|
+
// --- The stamp half: §5.2's rules, §5.2's code ----------------------------
|
|
1023
|
+
const seen = new Set();
|
|
1024
|
+
const stamps = [];
|
|
1025
|
+
for (const entry of body.stamps)
|
|
1026
|
+
stamps.push(await verifyStampEntry(entry, seen));
|
|
1027
|
+
// The join table. ONLY a stamp whose derived ref and witnessed bytes both
|
|
1028
|
+
// checked out is joinable: a root read out of bytes that failed their own
|
|
1029
|
+
// verification is not a root anyone should walk a proof to, and admitting one
|
|
1030
|
+
// would let a broken stamp lend its authority to an event.
|
|
1031
|
+
const byRef = new Map();
|
|
1032
|
+
for (const st of stamps) {
|
|
1033
|
+
if (st.stampRef && st.stampRefOk && st.bodyOk)
|
|
1034
|
+
byRef.set(st.stampRef, st);
|
|
1035
|
+
}
|
|
1036
|
+
// --- The event half -------------------------------------------------------
|
|
1037
|
+
const eventResults = [];
|
|
1038
|
+
for (const rawEv of body.events) {
|
|
1039
|
+
const evErrors = [];
|
|
1040
|
+
// A verifier must never RAISE on adversarial input — a caller handed a
|
|
1041
|
+
// stack trace instead of a verdict cannot tell a malformed pack from a
|
|
1042
|
+
// broken tool. `events[]` is attacker-shaped, so a null/non-object entry
|
|
1043
|
+
// is coerced to an empty event and fails through the normal path, exactly
|
|
1044
|
+
// as the Python kernel does. Found by adversarial review, not by the
|
|
1045
|
+
// cross-kernel leg — whose cases did not include one, and now do.
|
|
1046
|
+
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
1047
|
+
// NOT an event. Graded and reported as such rather than coerced to an
|
|
1048
|
+
// empty one: probing verifyEnvelope with nothing asks the envelope layer
|
|
1049
|
+
// a question about an object that was never an envelope, and the two
|
|
1050
|
+
// kernels answer it differently (undetermined vs failed) — a real
|
|
1051
|
+
// divergence, but not this body's to resolve. Reported separately.
|
|
1052
|
+
eventResults.push({
|
|
1053
|
+
eventId: "<unknown>",
|
|
1054
|
+
envelopeOk: false,
|
|
1055
|
+
payloadBindingOk: null,
|
|
1056
|
+
inclusionOk: false,
|
|
1057
|
+
checkpointBound: false,
|
|
1058
|
+
contentOk: null,
|
|
1059
|
+
ok: false,
|
|
1060
|
+
errors: ["events[] entry is not an object"],
|
|
1061
|
+
});
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
const ev = rawEv;
|
|
1065
|
+
const eventId = getEventId(ev.envelope);
|
|
1066
|
+
let envelopeOk = false;
|
|
1067
|
+
let payloadBindingOk = null;
|
|
1068
|
+
let recordBytes;
|
|
1069
|
+
try {
|
|
1070
|
+
if (typeof ev.record_bytes === "string")
|
|
1071
|
+
recordBytes = hexToBytes(ev.record_bytes);
|
|
1072
|
+
}
|
|
1073
|
+
catch (e) {
|
|
1074
|
+
evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
|
|
1078
|
+
envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
|
|
1079
|
+
payloadBindingOk = res.payloadHashOk;
|
|
1080
|
+
if (!res.signatureOk)
|
|
1081
|
+
evErrors.push("event envelope signature failed");
|
|
1082
|
+
if (res.payloadHashOk === false)
|
|
1083
|
+
evErrors.push("event record does not bind to payload_hash");
|
|
1084
|
+
}
|
|
1085
|
+
catch (e) {
|
|
1086
|
+
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
1087
|
+
}
|
|
1088
|
+
// --- The join (§5.3.3 rule 1) -------------------------------------------
|
|
1089
|
+
const incl = ev.inclusion;
|
|
1090
|
+
let st;
|
|
1091
|
+
if (!incl || typeof incl !== "object") {
|
|
1092
|
+
evErrors.push("event missing inclusion proof");
|
|
1093
|
+
}
|
|
1094
|
+
else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
|
|
1095
|
+
// One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
|
|
1096
|
+
// join key inside this body is a mixed bundle, and resolving it anyway is
|
|
1097
|
+
// how an event silently binds to something nobody meant (§5.1.2).
|
|
1098
|
+
evErrors.push(`event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`);
|
|
1099
|
+
}
|
|
1100
|
+
else if (typeof incl.stamp_ref !== "string") {
|
|
1101
|
+
evErrors.push("event inclusion missing stamp_ref");
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
st = byRef.get(incl.stamp_ref);
|
|
1105
|
+
if (st === undefined) {
|
|
1106
|
+
// Hard reject, never undetermined: without a resolved stamp the
|
|
1107
|
+
// inclusion proof has no root to check against at all.
|
|
1108
|
+
evErrors.push(`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
const stampBound = st !== undefined;
|
|
1112
|
+
let inclusionOk = false;
|
|
1113
|
+
if (incl && stampBound && st) {
|
|
1114
|
+
// Rule 3: the tree walked must be the tree the stamp committed to. A
|
|
1115
|
+
// proof walked against a different leaf_count is a proof about a
|
|
1116
|
+
// different tree, and RFC 9162's path shape depends on the size.
|
|
1117
|
+
if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
|
|
1118
|
+
evErrors.push(`inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
|
|
1119
|
+
`leaf_count ${st.leafCount}`);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
try {
|
|
1123
|
+
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(st.merkleRoot ?? ""));
|
|
1124
|
+
if (!inclusionOk) {
|
|
1125
|
+
evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
|
|
1126
|
+
}
|
|
1127
|
+
// Defense in depth, as §5.1: the claimed leaf MUST be this event's
|
|
1128
|
+
// own signed envelope bytes, or a valid proof for an UNRELATED leaf
|
|
1129
|
+
// could be smuggled in.
|
|
1130
|
+
const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
|
|
1131
|
+
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
1132
|
+
inclusionOk = false;
|
|
1133
|
+
evErrors.push("inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))");
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
catch (e) {
|
|
1137
|
+
evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
|
|
1142
|
+
const contentOk = contentRes.contentOk;
|
|
1143
|
+
evErrors.push(...contentRes.errors);
|
|
1144
|
+
eventResults.push({
|
|
1145
|
+
eventId,
|
|
1146
|
+
envelopeOk,
|
|
1147
|
+
payloadBindingOk,
|
|
1148
|
+
inclusionOk,
|
|
1149
|
+
// Named for §5.1's join; here it reports whether `stamp_ref` resolved.
|
|
1150
|
+
checkpointBound: stampBound,
|
|
1151
|
+
contentOk,
|
|
1152
|
+
ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
|
|
1153
|
+
errors: evErrors,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
// --- Rollup ---------------------------------------------------------------
|
|
1157
|
+
// Rule 4: a declared event MUST be checked. `events[]` present and unverified
|
|
1158
|
+
// is the false green this body exists to prevent, so an event that did not
|
|
1159
|
+
// verify fails the bundle — it is never rolled up as verified.
|
|
1160
|
+
const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
|
|
1161
|
+
const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
|
|
1162
|
+
let status;
|
|
1163
|
+
if (anyHardFailure) {
|
|
1164
|
+
status = "failed";
|
|
1165
|
+
}
|
|
1166
|
+
else if (stamps.length === 0 && eventResults.length === 0) {
|
|
1167
|
+
status = "partial";
|
|
1168
|
+
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
1169
|
+
}
|
|
1170
|
+
else if (anyUnwitnessed) {
|
|
1171
|
+
// §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
|
|
1172
|
+
// the independent part, so the events resting on it inherit `partial`.
|
|
1173
|
+
status = "partial";
|
|
1174
|
+
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
status = "verified";
|
|
1178
|
+
}
|
|
1179
|
+
return {
|
|
1180
|
+
status,
|
|
1181
|
+
specVersionOk,
|
|
1182
|
+
bodyHashOk: true,
|
|
1183
|
+
signatureOk: true,
|
|
1184
|
+
bodyType: STAMP_EVIDENCE_BODY_TYPE,
|
|
1185
|
+
onChainChecked: false,
|
|
1186
|
+
checkpoints: [],
|
|
1187
|
+
events: eventResults,
|
|
1188
|
+
assertedStatus,
|
|
1189
|
+
stamps,
|
|
1190
|
+
errors,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
996
1193
|
// How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
|
|
997
1194
|
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
998
1195
|
// depth bound already rejects a pathological bundle, but the unwrap must be
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyE
|
|
|
2
2
|
export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
|
|
3
3
|
export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
|
|
4
4
|
export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
|
|
5
|
-
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
6
|
-
export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, AnchorRef, StampBody, StampEntry, StampResult, StampSubtree, StampSubtreeResult, StampTraceBody, TraceCheckpoint, TraceCheckpointV1, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
|
|
5
|
+
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
6
|
+
export type { AnchorTraceBody, AttestationPayload, AttestationRecord, AttestationResult, CheckpointResult, ComposeVerdictResult, DisclosedContentResult, DisclosedEventContent, DisclosureStatus, EvidenceBundle, EvidenceBundleResult, EvidenceIssuer, EvidenceStatus, EvidenceVerdict, EventResult, ExportBody, ExportResult, OnChainOutcome, OnChainResult, PerGatewayOutcome, AnchorRef, StampBody, StampEntry, StampResult, StampSubtree, StampSubtreeResult, StampTraceBody, StampEvidenceBody, StampEvidenceEvent, StampEvidenceInclusion, TraceCheckpoint, TraceCheckpointV1, TraceEvent, TraceInclusion, VerdictAttestation, VerdictCheckpoint, VerdictEvent, VerdictObject, VerdictOnChain, VerdictOnChainGateway, VerifyEvidenceOptions, } from "./evidence.js";
|
|
7
7
|
export { accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
|
|
8
8
|
export type { CompletenessNote, DisclosureAccount, GradeInput, PackGrade, PackGradeCause, PackGradeCauseKind, PackVerdict, } from "./grading.js";
|
|
9
9
|
export { ADAPTER_CHAIN_NAMESPACES, CHAIN_READING_SCOPE, readChains, renderChainFinding, } from "./chain.js";
|
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyI
|
|
|
34
34
|
// files that failed verification on genuine evidence. This hands back DECODED
|
|
35
35
|
// bytes, and only for events whose verification bound them to the committed
|
|
36
36
|
// content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
|
|
37
|
-
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
37
|
+
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
38
38
|
// Pack grading (src/grading.ts): what a verification SURFACE may claim about a
|
|
39
39
|
// pack — the signed bundle PLUS whatever disclosed bytes travelled beside it.
|
|
40
40
|
//
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/chain.ts
CHANGED
|
@@ -236,7 +236,15 @@ function adapterLinkage(
|
|
|
236
236
|
// here: nothing to resolve is a beginning.
|
|
237
237
|
const prev = str(fields.prev_event_id);
|
|
238
238
|
const seqRaw = fields.seq;
|
|
239
|
-
|
|
239
|
+
// SAFE integer, not merely integral. Beyond 2^53 a JSON number does not
|
|
240
|
+
// survive `JSON.parse` intact — `9007199254740993` arrives as
|
|
241
|
+
// `...992` — while Python's parser keeps it exactly, so the two kernels
|
|
242
|
+
// read DIFFERENT sequence numbers off identical bytes and report gaps of
|
|
243
|
+
// different widths. Measured at 2^53+1: a `chain-gap` count of
|
|
244
|
+
// 9007199254740990 here against 9007199254740992 there (proof#49). A value
|
|
245
|
+
// this kernel cannot represent is one it cannot read, which is the same
|
|
246
|
+
// answer it gives every other unreadable field: no sequence, take no part.
|
|
247
|
+
const seq = Number.isSafeInteger(seqRaw) ? (seqRaw as number) : null;
|
|
240
248
|
return { namespace, chainKey, prev, seq };
|
|
241
249
|
}
|
|
242
250
|
return null;
|
|
@@ -322,6 +330,20 @@ function byChainOrder(a: Member, b: Member): number {
|
|
|
322
330
|
return a.order - b.order;
|
|
323
331
|
}
|
|
324
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The smallest slot between the lowest and highest occupied ones that no record
|
|
335
|
+
* occupies, found by walking the SORTED occupants rather than the range they
|
|
336
|
+
* span. See the call site: the range's width is chosen by whoever wrote the
|
|
337
|
+
* bundle.
|
|
338
|
+
*/
|
|
339
|
+
function firstMissingSlot(present: Set<number>): number | null {
|
|
340
|
+
const ordered = [...present].sort((a, b) => a - b);
|
|
341
|
+
for (let i = 0; i < ordered.length - 1; i++) {
|
|
342
|
+
if (ordered[i + 1]! > ordered[i]! + 1) return ordered[i]! + 1;
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
325
347
|
function findingsFor(group: Group): ChainFinding[] {
|
|
326
348
|
const { members, chainKey, source, publicKey } = group;
|
|
327
349
|
const of = members.length;
|
|
@@ -383,16 +405,23 @@ function findingsFor(group: Group): ChainFinding[] {
|
|
|
383
405
|
const present = new Set(sequenced.map((m) => m.seq as number));
|
|
384
406
|
const low = Math.min(...present);
|
|
385
407
|
const high = Math.max(...present);
|
|
386
|
-
|
|
387
|
-
for (let s = low; s <=
|
|
388
|
-
|
|
389
|
-
|
|
408
|
+
// Counted, never walked. `seq` is attacker-chosen, so a bundle presenting
|
|
409
|
+
// two records at seq 0 and seq 1e15 made the old `for (let s = low; s <=
|
|
410
|
+
// high; s++)` spin for days — a verifier that HANGS on adversarial input is
|
|
411
|
+
// worse than one that raises, and this reader promises neither (proof#49).
|
|
412
|
+
// The present set is a subset of [low, high], so the absent slots are the
|
|
413
|
+
// width minus the occupants, and the first of them is found by walking the
|
|
414
|
+
// occupants in order.
|
|
415
|
+
const missingCount = high - low + 1 - present.size;
|
|
416
|
+
const firstMissing = missingCount > 0 ? firstMissingSlot(present) : null;
|
|
417
|
+
if (firstMissing !== null) {
|
|
418
|
+
const after = sequenced.filter((m) => (m.seq as number) > firstMissing).sort(byChainOrder)[0];
|
|
390
419
|
findings.push({
|
|
391
420
|
kind: "chain-gap",
|
|
392
421
|
...base,
|
|
393
|
-
count:
|
|
422
|
+
count: missingCount,
|
|
394
423
|
eventIds: after === undefined ? [] : [after.eventId],
|
|
395
|
-
firstMissingSeq:
|
|
424
|
+
firstMissingSeq: firstMissing,
|
|
396
425
|
});
|
|
397
426
|
}
|
|
398
427
|
}
|
|
@@ -411,7 +440,18 @@ function findingsFor(group: Group): ChainFinding[] {
|
|
|
411
440
|
export async function readChains(bundle: unknown): Promise<ChainReading> {
|
|
412
441
|
const groups = await groupChains(bundle);
|
|
413
442
|
const order = (g: Group) => `${g.source} ${g.namespace ?? ""} ${g.chainKey}`;
|
|
414
|
-
|
|
443
|
+
// Ordered by UTF-16 code unit, NOT `localeCompare`. A chain key is caller
|
|
444
|
+
// text, and `localeCompare` with no locale argument answers from the host's
|
|
445
|
+
// ICU collation: measured here, it puts "chain-2" AFTER "chain_1" where every
|
|
446
|
+
// code-wise comparison puts it before, and it can differ between two
|
|
447
|
+
// TypeScript installs, let alone between kernels. The Python reader sorts the
|
|
448
|
+
// same key encoded as `utf-16-be`, which is exactly this comparison — so the
|
|
449
|
+
// two kernels emit findings in one order (proof#49).
|
|
450
|
+
groups.sort((a, b) => {
|
|
451
|
+
const x = order(a);
|
|
452
|
+
const y = order(b);
|
|
453
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
454
|
+
});
|
|
415
455
|
|
|
416
456
|
return {
|
|
417
457
|
chains: groups.map((g) => ({
|
package/src/evidence.ts
CHANGED
|
@@ -86,6 +86,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
|
86
86
|
* unrecoverable once witnessed.
|
|
87
87
|
*/
|
|
88
88
|
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
89
|
+
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
92
|
* The version string INSIDE a witnessed stamp body — a different string from
|
|
@@ -245,6 +246,37 @@ export interface StampTraceBody {
|
|
|
245
246
|
stamps: StampEntry[];
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
// ---- `sanning.stamp.evidence/v1` wire types (evidence-bundle.md §5.3) -----
|
|
250
|
+
//
|
|
251
|
+
// Both halves of a DL-52 record, bound: the producer signs the envelope, and
|
|
252
|
+
// Sanning signs and witnesses the stamp whose root commits to it. Neither
|
|
253
|
+
// earlier body joins them, and a field added to §5.2 could not — a conformant
|
|
254
|
+
// reader MUST ignore fields it does not know (envelope-spec.md §2), so the
|
|
255
|
+
// join has to live in a body_type readers DISPATCH on. See §5.3.1.
|
|
256
|
+
|
|
257
|
+
/** §5.3's inclusion: §5.1's shape with `stamp_ref` as the join key. */
|
|
258
|
+
export interface StampEvidenceInclusion {
|
|
259
|
+
leaf_hash: string; // hex
|
|
260
|
+
leaf_index: number;
|
|
261
|
+
leaf_count: number;
|
|
262
|
+
audit_path: string[]; // hex[]
|
|
263
|
+
/** Resolves against `stamps[].stamp_ref` in the SAME body. */
|
|
264
|
+
stamp_ref: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface StampEvidenceEvent {
|
|
268
|
+
envelope: Envelope;
|
|
269
|
+
record_bytes?: string; // hex of JCS(event record); omitted == withheld
|
|
270
|
+
content?: string; // opt-in disclosed raw bytes, lowercase hex
|
|
271
|
+
inclusion: StampEvidenceInclusion;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface StampEvidenceBody {
|
|
275
|
+
/** Byte-identical to §5.2.2 — one definition, one place to change. */
|
|
276
|
+
stamps: StampEntry[];
|
|
277
|
+
events: StampEvidenceEvent[];
|
|
278
|
+
}
|
|
279
|
+
|
|
248
280
|
export interface EvidenceBundle {
|
|
249
281
|
spec_version: string;
|
|
250
282
|
body_type: string;
|
|
@@ -720,6 +752,11 @@ export async function verifyEvidenceBundle(
|
|
|
720
752
|
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
721
753
|
}
|
|
722
754
|
|
|
755
|
+
// The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
|
|
756
|
+
if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
757
|
+
return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
|
|
758
|
+
}
|
|
759
|
+
|
|
723
760
|
if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
724
761
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
725
762
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
@@ -1091,79 +1128,10 @@ async function verifyAnchorTrace(
|
|
|
1091
1128
|
}
|
|
1092
1129
|
}
|
|
1093
1130
|
|
|
1094
|
-
// Content (raw-log) binding —
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
// envelope it falls back to the envelope's own committed hashes
|
|
1099
|
-
// (contentHashes — NOT verifyEnvelope's path, which reads payload/event_type
|
|
1100
|
-
// and yields nothing for a sanning.events/v1 envelope). Disclosed bytes come
|
|
1101
|
-
// from the signed in-body `ev.content` FIRST, then the out-of-band
|
|
1102
|
-
// options.content side input. Undetermined (null) when nothing is disclosed
|
|
1103
|
-
// or nothing is committed; false ONLY on a real mismatch or an in-body vs
|
|
1104
|
-
// side-input disagreement — mirroring payloadBindingOk's never-fail-on-absent
|
|
1105
|
-
// discipline.
|
|
1106
|
-
let contentOk: boolean | null = null;
|
|
1107
|
-
{
|
|
1108
|
-
const committed: string[] = [];
|
|
1109
|
-
let recordContentHash: string | undefined;
|
|
1110
|
-
if (recordBytes !== undefined) {
|
|
1111
|
-
try {
|
|
1112
|
-
const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
|
|
1113
|
-
event?: { content_hash?: unknown };
|
|
1114
|
-
};
|
|
1115
|
-
const ch = record.event?.content_hash;
|
|
1116
|
-
if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
|
|
1117
|
-
} catch {
|
|
1118
|
-
// record not JSON — the payload binding already reflects that; content
|
|
1119
|
-
// stays undetermined rather than throwing.
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
if (recordContentHash !== undefined) {
|
|
1123
|
-
committed.push(recordContentHash);
|
|
1124
|
-
} else if (ev.envelope && typeof ev.envelope === "object") {
|
|
1125
|
-
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
1126
|
-
// which throws on a null/non-object envelope — a verifier must never
|
|
1127
|
-
// raise on adversarial input (such an event already fails via envelopeOk).
|
|
1128
|
-
for (const c of contentHashes(ev.envelope)) committed.push(c.hash.toLowerCase());
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
// Disclosed bytes, precedence: in-body (signed) → side input. A string
|
|
1132
|
-
// side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
|
|
1133
|
-
// selector so `disclosedContent` hands a consumer the very bytes bound
|
|
1134
|
-
// here — one decode, not two that can drift apart.
|
|
1135
|
-
const selection = selectDisclosedBytes(ev, eventId, options.content);
|
|
1136
|
-
const { inBody, side } = selection;
|
|
1137
|
-
evErrors.push(...selection.errors);
|
|
1138
|
-
|
|
1139
|
-
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
1140
|
-
// Two disclosures that disagree: a genuine conflict, not undetermined.
|
|
1141
|
-
contentOk = false;
|
|
1142
|
-
evErrors.push(
|
|
1143
|
-
"disclosed content disagreement: in-body events[].content and the supplied content differ",
|
|
1144
|
-
);
|
|
1145
|
-
} else {
|
|
1146
|
-
const disclosed = inBody ?? side;
|
|
1147
|
-
if (disclosed === undefined || committed.length === 0) {
|
|
1148
|
-
contentOk = null;
|
|
1149
|
-
if (disclosed !== undefined && committed.length === 0) {
|
|
1150
|
-
evErrors.push(
|
|
1151
|
-
"disclosed content present but no committed content_hash to bind to " +
|
|
1152
|
-
"(record withheld or record has no event.content_hash) — undetermined",
|
|
1153
|
-
);
|
|
1154
|
-
}
|
|
1155
|
-
} else {
|
|
1156
|
-
const got = await sha256Hex(disclosed);
|
|
1157
|
-
contentOk = committed.includes(got);
|
|
1158
|
-
if (!contentOk) {
|
|
1159
|
-
evErrors.push(
|
|
1160
|
-
"disclosed content does not match the committed content_hash: " +
|
|
1161
|
-
`sha256(disclosed)=${got} committed=${committed.join("|")}`,
|
|
1162
|
-
);
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1131
|
+
// Content (raw-log) binding — shared with every other event-carrying body.
|
|
1132
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
|
|
1133
|
+
const contentOk = contentRes.contentOk;
|
|
1134
|
+
evErrors.push(...contentRes.errors);
|
|
1167
1135
|
|
|
1168
1136
|
eventResults.push({
|
|
1169
1137
|
eventId,
|
|
@@ -1320,6 +1288,83 @@ function isNonNegInt(n: unknown): n is number {
|
|
|
1320
1288
|
* verifier that stops at the first one turns a second fix into a second
|
|
1321
1289
|
* round-trip.
|
|
1322
1290
|
*/
|
|
1291
|
+
// ===========================================================================
|
|
1292
|
+
// Disclosed-content binding, shared by every body that carries events.
|
|
1293
|
+
//
|
|
1294
|
+
// Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
|
|
1295
|
+
// rawLog -> content_hash link is the one step the chain
|
|
1296
|
+
// (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
|
|
1297
|
+
// not otherwise cover, and a second copy of it is a second place for the
|
|
1298
|
+
// never-fail-on-absent discipline to drift. The rules are unchanged from the
|
|
1299
|
+
// anchor-trace path this came from: undetermined (null) when nothing is
|
|
1300
|
+
// disclosed or nothing is committed; false ONLY on a real mismatch or an
|
|
1301
|
+
// in-body vs side-input disagreement.
|
|
1302
|
+
// ===========================================================================
|
|
1303
|
+
async function verifyDisclosedContent(
|
|
1304
|
+
ev: { envelope?: unknown; content?: unknown },
|
|
1305
|
+
eventId: string,
|
|
1306
|
+
recordBytes: Uint8Array | undefined,
|
|
1307
|
+
sideContent: VerifyEvidenceOptions["content"],
|
|
1308
|
+
): Promise<{ contentOk: boolean | null; errors: string[] }> {
|
|
1309
|
+
const evErrors: string[] = [];
|
|
1310
|
+
let contentOk: boolean | null = null;
|
|
1311
|
+
|
|
1312
|
+
const committed: string[] = [];
|
|
1313
|
+
let recordContentHash: string | undefined;
|
|
1314
|
+
if (recordBytes !== undefined) {
|
|
1315
|
+
try {
|
|
1316
|
+
const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
|
|
1317
|
+
event?: { content_hash?: unknown };
|
|
1318
|
+
};
|
|
1319
|
+
const ch = record.event?.content_hash;
|
|
1320
|
+
if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
|
|
1321
|
+
} catch {
|
|
1322
|
+
// record not JSON — the payload binding already reflects that; content
|
|
1323
|
+
// stays undetermined rather than throwing.
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
if (recordContentHash !== undefined) {
|
|
1327
|
+
committed.push(recordContentHash);
|
|
1328
|
+
} else if (ev.envelope && typeof ev.envelope === "object") {
|
|
1329
|
+
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
1330
|
+
// which throws on a null/non-object envelope — a verifier must never raise
|
|
1331
|
+
// on adversarial input.
|
|
1332
|
+
for (const c of contentHashes(ev.envelope as Envelope)) committed.push(c.hash.toLowerCase());
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const selection = selectDisclosedBytes(ev as TraceEvent, eventId, sideContent);
|
|
1336
|
+
const { inBody, side } = selection;
|
|
1337
|
+
evErrors.push(...selection.errors);
|
|
1338
|
+
|
|
1339
|
+
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
1340
|
+
contentOk = false;
|
|
1341
|
+
evErrors.push(
|
|
1342
|
+
"disclosed content disagreement: in-body events[].content and the supplied content differ",
|
|
1343
|
+
);
|
|
1344
|
+
} else {
|
|
1345
|
+
const disclosed = inBody ?? side;
|
|
1346
|
+
if (disclosed === undefined || committed.length === 0) {
|
|
1347
|
+
contentOk = null;
|
|
1348
|
+
if (disclosed !== undefined && committed.length === 0) {
|
|
1349
|
+
evErrors.push(
|
|
1350
|
+
"disclosed content present but no committed content_hash to bind to " +
|
|
1351
|
+
"(record withheld or record has no event.content_hash) — undetermined",
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
} else {
|
|
1355
|
+
const got = await sha256Hex(disclosed);
|
|
1356
|
+
contentOk = committed.includes(got);
|
|
1357
|
+
if (!contentOk) {
|
|
1358
|
+
evErrors.push(
|
|
1359
|
+
"disclosed content does not match the committed content_hash: " +
|
|
1360
|
+
`sha256(disclosed)=${got} committed=${committed.join("|")}`,
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return { contentOk, errors: evErrors };
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1323
1368
|
async function verifyStampEntry(
|
|
1324
1369
|
raw: unknown,
|
|
1325
1370
|
seen: Set<string>,
|
|
@@ -1591,6 +1636,220 @@ async function verifyStampTrace(
|
|
|
1591
1636
|
};
|
|
1592
1637
|
}
|
|
1593
1638
|
|
|
1639
|
+
// ===========================================================================
|
|
1640
|
+
// `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
|
|
1641
|
+
//
|
|
1642
|
+
// The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
|
|
1643
|
+
// the sub-tree partition check). The event half reuses the same primitives the
|
|
1644
|
+
// anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
|
|
1645
|
+
// `verifyDisclosedContent` — because a second copy of Merkle verification is a
|
|
1646
|
+
// second place for it to drift.
|
|
1647
|
+
//
|
|
1648
|
+
// What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
|
|
1649
|
+
// to a stamps[] entry and walks its audit path to the root committed INSIDE
|
|
1650
|
+
// that stamp's witnessed bytes. There is no `merkle_root` field on the event to
|
|
1651
|
+
// read instead — repeating the root beside its own authoritative bytes would be
|
|
1652
|
+
// two answers to one question inside one signature (§5.1.2).
|
|
1653
|
+
// ===========================================================================
|
|
1654
|
+
async function verifyStampEvidence(
|
|
1655
|
+
b: EvidenceBundle,
|
|
1656
|
+
specVersionOk: boolean,
|
|
1657
|
+
assertedStatus: string | null,
|
|
1658
|
+
errors: string[],
|
|
1659
|
+
options: VerifyEvidenceOptions,
|
|
1660
|
+
): Promise<EvidenceBundleResult> {
|
|
1661
|
+
const body = b.body as StampEvidenceBody;
|
|
1662
|
+
if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
|
|
1663
|
+
return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// --- The stamp half: §5.2's rules, §5.2's code ----------------------------
|
|
1667
|
+
const seen = new Set<string>();
|
|
1668
|
+
const stamps: StampResult[] = [];
|
|
1669
|
+
for (const entry of body.stamps) stamps.push(await verifyStampEntry(entry, seen));
|
|
1670
|
+
|
|
1671
|
+
// The join table. ONLY a stamp whose derived ref and witnessed bytes both
|
|
1672
|
+
// checked out is joinable: a root read out of bytes that failed their own
|
|
1673
|
+
// verification is not a root anyone should walk a proof to, and admitting one
|
|
1674
|
+
// would let a broken stamp lend its authority to an event.
|
|
1675
|
+
const byRef = new Map<string, StampResult>();
|
|
1676
|
+
for (const st of stamps) {
|
|
1677
|
+
if (st.stampRef && st.stampRefOk && st.bodyOk) byRef.set(st.stampRef, st);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// --- The event half -------------------------------------------------------
|
|
1681
|
+
const eventResults: EventResult[] = [];
|
|
1682
|
+
for (const rawEv of body.events) {
|
|
1683
|
+
const evErrors: string[] = [];
|
|
1684
|
+
// A verifier must never RAISE on adversarial input — a caller handed a
|
|
1685
|
+
// stack trace instead of a verdict cannot tell a malformed pack from a
|
|
1686
|
+
// broken tool. `events[]` is attacker-shaped, so a null/non-object entry
|
|
1687
|
+
// is coerced to an empty event and fails through the normal path, exactly
|
|
1688
|
+
// as the Python kernel does. Found by adversarial review, not by the
|
|
1689
|
+
// cross-kernel leg — whose cases did not include one, and now do.
|
|
1690
|
+
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
1691
|
+
// NOT an event. Graded and reported as such rather than coerced to an
|
|
1692
|
+
// empty one: probing verifyEnvelope with nothing asks the envelope layer
|
|
1693
|
+
// a question about an object that was never an envelope, and the two
|
|
1694
|
+
// kernels answer it differently (undetermined vs failed) — a real
|
|
1695
|
+
// divergence, but not this body's to resolve. Reported separately.
|
|
1696
|
+
eventResults.push({
|
|
1697
|
+
eventId: "<unknown>",
|
|
1698
|
+
envelopeOk: false,
|
|
1699
|
+
payloadBindingOk: null,
|
|
1700
|
+
inclusionOk: false,
|
|
1701
|
+
checkpointBound: false,
|
|
1702
|
+
contentOk: null,
|
|
1703
|
+
ok: false,
|
|
1704
|
+
errors: ["events[] entry is not an object"],
|
|
1705
|
+
});
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
const ev = rawEv as StampEvidenceEvent;
|
|
1709
|
+
const eventId = getEventId(ev.envelope);
|
|
1710
|
+
let envelopeOk = false;
|
|
1711
|
+
let payloadBindingOk: boolean | null = null;
|
|
1712
|
+
|
|
1713
|
+
let recordBytes: Uint8Array | undefined;
|
|
1714
|
+
try {
|
|
1715
|
+
if (typeof ev.record_bytes === "string") recordBytes = hexToBytes(ev.record_bytes);
|
|
1716
|
+
} catch (e) {
|
|
1717
|
+
evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
|
|
1718
|
+
}
|
|
1719
|
+
try {
|
|
1720
|
+
const res = await verifyEnvelope(
|
|
1721
|
+
ev.envelope,
|
|
1722
|
+
recordBytes !== undefined ? { payloadBytes: recordBytes } : {},
|
|
1723
|
+
);
|
|
1724
|
+
envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
|
|
1725
|
+
payloadBindingOk = res.payloadHashOk;
|
|
1726
|
+
if (!res.signatureOk) evErrors.push("event envelope signature failed");
|
|
1727
|
+
if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
|
|
1728
|
+
} catch (e) {
|
|
1729
|
+
evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// --- The join (§5.3.3 rule 1) -------------------------------------------
|
|
1733
|
+
const incl = ev.inclusion as (StampEvidenceInclusion & Record<string, unknown>) | undefined;
|
|
1734
|
+
let st: StampResult | undefined;
|
|
1735
|
+
if (!incl || typeof incl !== "object") {
|
|
1736
|
+
evErrors.push("event missing inclusion proof");
|
|
1737
|
+
} else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
|
|
1738
|
+
// One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
|
|
1739
|
+
// join key inside this body is a mixed bundle, and resolving it anyway is
|
|
1740
|
+
// how an event silently binds to something nobody meant (§5.1.2).
|
|
1741
|
+
evErrors.push(
|
|
1742
|
+
`event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`,
|
|
1743
|
+
);
|
|
1744
|
+
} else if (typeof incl.stamp_ref !== "string") {
|
|
1745
|
+
evErrors.push("event inclusion missing stamp_ref");
|
|
1746
|
+
} else {
|
|
1747
|
+
st = byRef.get(incl.stamp_ref);
|
|
1748
|
+
if (st === undefined) {
|
|
1749
|
+
// Hard reject, never undetermined: without a resolved stamp the
|
|
1750
|
+
// inclusion proof has no root to check against at all.
|
|
1751
|
+
evErrors.push(
|
|
1752
|
+
`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`,
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
const stampBound = st !== undefined;
|
|
1757
|
+
|
|
1758
|
+
let inclusionOk = false;
|
|
1759
|
+
if (incl && stampBound && st) {
|
|
1760
|
+
// Rule 3: the tree walked must be the tree the stamp committed to. A
|
|
1761
|
+
// proof walked against a different leaf_count is a proof about a
|
|
1762
|
+
// different tree, and RFC 9162's path shape depends on the size.
|
|
1763
|
+
if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
|
|
1764
|
+
evErrors.push(
|
|
1765
|
+
`inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
|
|
1766
|
+
`leaf_count ${st.leafCount}`,
|
|
1767
|
+
);
|
|
1768
|
+
} else {
|
|
1769
|
+
try {
|
|
1770
|
+
inclusionOk = await verifyInclusion(
|
|
1771
|
+
hexToBytes(incl.leaf_hash),
|
|
1772
|
+
incl.leaf_index,
|
|
1773
|
+
incl.leaf_count,
|
|
1774
|
+
(incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
1775
|
+
hexToBytes(st.merkleRoot ?? ""),
|
|
1776
|
+
);
|
|
1777
|
+
if (!inclusionOk) {
|
|
1778
|
+
evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Defense in depth, as §5.1: the claimed leaf MUST be this event's
|
|
1782
|
+
// own signed envelope bytes, or a valid proof for an UNRELATED leaf
|
|
1783
|
+
// could be smuggled in.
|
|
1784
|
+
const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
|
|
1785
|
+
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
1786
|
+
inclusionOk = false;
|
|
1787
|
+
evErrors.push(
|
|
1788
|
+
"inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))",
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
} catch (e) {
|
|
1792
|
+
evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
|
|
1798
|
+
const contentOk = contentRes.contentOk;
|
|
1799
|
+
evErrors.push(...contentRes.errors);
|
|
1800
|
+
|
|
1801
|
+
eventResults.push({
|
|
1802
|
+
eventId,
|
|
1803
|
+
envelopeOk,
|
|
1804
|
+
payloadBindingOk,
|
|
1805
|
+
inclusionOk,
|
|
1806
|
+
// Named for §5.1's join; here it reports whether `stamp_ref` resolved.
|
|
1807
|
+
checkpointBound: stampBound,
|
|
1808
|
+
contentOk,
|
|
1809
|
+
ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
|
|
1810
|
+
errors: evErrors,
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// --- Rollup ---------------------------------------------------------------
|
|
1815
|
+
// Rule 4: a declared event MUST be checked. `events[]` present and unverified
|
|
1816
|
+
// is the false green this body exists to prevent, so an event that did not
|
|
1817
|
+
// verify fails the bundle — it is never rolled up as verified.
|
|
1818
|
+
const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
|
|
1819
|
+
const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
|
|
1820
|
+
|
|
1821
|
+
let status: EvidenceStatus;
|
|
1822
|
+
if (anyHardFailure) {
|
|
1823
|
+
status = "failed";
|
|
1824
|
+
} else if (stamps.length === 0 && eventResults.length === 0) {
|
|
1825
|
+
status = "partial";
|
|
1826
|
+
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
1827
|
+
} else if (anyUnwitnessed) {
|
|
1828
|
+
// §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
|
|
1829
|
+
// the independent part, so the events resting on it inherit `partial`.
|
|
1830
|
+
status = "partial";
|
|
1831
|
+
errors.push(
|
|
1832
|
+
"one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
|
|
1833
|
+
);
|
|
1834
|
+
} else {
|
|
1835
|
+
status = "verified";
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
return {
|
|
1839
|
+
status,
|
|
1840
|
+
specVersionOk,
|
|
1841
|
+
bodyHashOk: true,
|
|
1842
|
+
signatureOk: true,
|
|
1843
|
+
bodyType: STAMP_EVIDENCE_BODY_TYPE,
|
|
1844
|
+
onChainChecked: false,
|
|
1845
|
+
checkpoints: [],
|
|
1846
|
+
events: eventResults,
|
|
1847
|
+
assertedStatus,
|
|
1848
|
+
stamps,
|
|
1849
|
+
errors,
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1594
1853
|
// ===========================================================================
|
|
1595
1854
|
// Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
|
|
1596
1855
|
//
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ export {
|
|
|
66
66
|
EXPORT_BODY_TYPE,
|
|
67
67
|
STAMP_BODY_VERSION,
|
|
68
68
|
STAMP_TRACE_BODY_TYPE,
|
|
69
|
+
STAMP_EVIDENCE_BODY_TYPE,
|
|
69
70
|
disclosedContent,
|
|
70
71
|
recomputeExportVerdict,
|
|
71
72
|
verifyEvidenceBundle,
|
|
@@ -98,6 +99,9 @@ export type {
|
|
|
98
99
|
StampSubtree,
|
|
99
100
|
StampSubtreeResult,
|
|
100
101
|
StampTraceBody,
|
|
102
|
+
StampEvidenceBody,
|
|
103
|
+
StampEvidenceEvent,
|
|
104
|
+
StampEvidenceInclusion,
|
|
101
105
|
TraceCheckpoint,
|
|
102
106
|
TraceCheckpointV1,
|
|
103
107
|
TraceEvent,
|