@sanning/proof 0.6.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/README.md +1 -1
- package/dist/chain.d.ts +99 -0
- package/dist/chain.js +389 -0
- package/dist/cli.js +42 -2
- package/dist/evidence.d.ts +148 -0
- package/dist/evidence.js +603 -70
- package/dist/index.d.ts +4 -2
- package/dist/index.js +22 -1
- package/dist/verifier.js +46 -7
- package/package.json +1 -1
- package/src/chain.ts +500 -0
- package/src/cli.ts +50 -1
- package/src/evidence.ts +813 -74
- package/src/index.ts +46 -0
- package/src/verifier.ts +49 -7
- package/vectors/chain-reading.json +293 -0
package/src/evidence.ts
CHANGED
|
@@ -73,6 +73,34 @@ export const ANCHOR_TRACE_BODY_TYPES = [
|
|
|
73
73
|
// verifyExportBody (§5).
|
|
74
74
|
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
|
|
78
|
+
*
|
|
79
|
+
* Its own `body_type` rather than a widened anchor-trace body, and the reason
|
|
80
|
+
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
81
|
+
* `sanning.events/v1` envelope, and a stamp has no producer signature at all —
|
|
82
|
+
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
83
|
+
* The two rejected options were making `envelope` optional and synthesising an
|
|
84
|
+
* envelope that Sanning signs; the second would have made a Sanning
|
|
85
|
+
* attestation indistinguishable ON THE WIRE from a producer's, which is
|
|
86
|
+
* unrecoverable once witnessed.
|
|
87
|
+
*/
|
|
88
|
+
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
89
|
+
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The version string INSIDE a witnessed stamp body — a different string from
|
|
93
|
+
* the `body_type` above, and the two are easy to confuse.
|
|
94
|
+
*
|
|
95
|
+
* `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
|
|
96
|
+
* dispatch key. `sanning.stamp/v1` is the control plane's own version of the
|
|
97
|
+
* bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
|
|
98
|
+
* `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
|
|
99
|
+
* version to exactly this value: two answers to one question inside one
|
|
100
|
+
* signature is the §5.1.2 mixed-vocabulary reject, one layer down.
|
|
101
|
+
*/
|
|
102
|
+
export const STAMP_BODY_VERSION = "sanning.stamp/v1";
|
|
103
|
+
|
|
76
104
|
// ---- Wire types (evidence-bundle.md §2 + §5.1) ------------------------------
|
|
77
105
|
|
|
78
106
|
export interface EvidenceIssuer {
|
|
@@ -160,6 +188,95 @@ export interface AnchorTraceBody {
|
|
|
160
188
|
events: TraceEvent[];
|
|
161
189
|
}
|
|
162
190
|
|
|
191
|
+
// ---- `sanning.stamp.trace/v1` wire types (evidence-bundle.md §5.2) ---------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One sub-tree of a stamp, as the WITNESSED bytes carry it.
|
|
195
|
+
*
|
|
196
|
+
* Keyed in the body by a blinded reference (an HMAC under a per-org secret we
|
|
197
|
+
* never publish), so a reader learns that the org runs N agents and their
|
|
198
|
+
* relative volumes and learns no name. The RANGE is deliberately in the clear:
|
|
199
|
+
* it is what makes a later per-agent slice claim checkable by a third party
|
|
200
|
+
* without Sanning, and hiding it would put us back in the trust path.
|
|
201
|
+
*/
|
|
202
|
+
export interface StampSubtree {
|
|
203
|
+
first_leaf: number;
|
|
204
|
+
leaf_count: number;
|
|
205
|
+
/** Hex RFC-9162 root over this sub-tree's leaves alone. */
|
|
206
|
+
root: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The stamp body as the control plane witnesses it
|
|
211
|
+
* (`control-plane/src/stamp-tree.ts`, `StampBody`).
|
|
212
|
+
*
|
|
213
|
+
* Never read from the bundle directly — it is parsed out of `stamp_bytes`,
|
|
214
|
+
* which is the only authority. Present as a type so the parse has a shape.
|
|
215
|
+
*/
|
|
216
|
+
export interface StampBody {
|
|
217
|
+
stamp_version: string;
|
|
218
|
+
/** The org, BLINDED. Not an org id, and not reversible without the secret. */
|
|
219
|
+
org_ref: string;
|
|
220
|
+
interval_start: string;
|
|
221
|
+
interval_end: string;
|
|
222
|
+
merkle_root: string;
|
|
223
|
+
leaf_count: number;
|
|
224
|
+
subtrees: Record<string, StampSubtree>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** One entry of a `sanning.stamp.trace/v1` body's `stamps[]` (§5.2). */
|
|
228
|
+
export interface StampEntry {
|
|
229
|
+
/**
|
|
230
|
+
* The stamp's identity within this bundle, DERIVED: hex SHA-256 of the
|
|
231
|
+
* DECODED `stamp_bytes`, not of the hex text. A verifier MUST recompute it.
|
|
232
|
+
*/
|
|
233
|
+
stamp_ref: string;
|
|
234
|
+
/** Lowercase hex of the exact JCS bytes that were handed to the witness. */
|
|
235
|
+
stamp_bytes: string;
|
|
236
|
+
/**
|
|
237
|
+
* Where the stamp was witnessed, IF IT HAS BEEN (§5.1.1). Absent is honest:
|
|
238
|
+
* a sealed-but-unconfirmed stamp is witnessed by nobody, and an emitter that
|
|
239
|
+
* filled this in from a submission receipt would be claiming a chain
|
|
240
|
+
* position that does not exist yet.
|
|
241
|
+
*/
|
|
242
|
+
anchor_ref?: AnchorRef;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface StampTraceBody {
|
|
246
|
+
stamps: StampEntry[];
|
|
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
|
+
|
|
163
280
|
export interface EvidenceBundle {
|
|
164
281
|
spec_version: string;
|
|
165
282
|
body_type: string;
|
|
@@ -265,6 +382,56 @@ export interface EventResult {
|
|
|
265
382
|
errors: string[];
|
|
266
383
|
}
|
|
267
384
|
|
|
385
|
+
/** One sub-tree's recomputed result (§5.2). */
|
|
386
|
+
export interface StampSubtreeResult {
|
|
387
|
+
/** The blinded key the body spells it under. Meaningless without the org's secret. */
|
|
388
|
+
ref: string;
|
|
389
|
+
firstLeaf: number;
|
|
390
|
+
leafCount: number;
|
|
391
|
+
root: string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** One `stamps[]` entry's result (§5.2). */
|
|
395
|
+
export interface StampResult {
|
|
396
|
+
/** The stamp's derived identity, as the bundle spells it. "" when unusable. */
|
|
397
|
+
stampRef: string;
|
|
398
|
+
/** The asserted `stamp_ref` equals SHA-256 of the decoded `stamp_bytes`. */
|
|
399
|
+
stampRefOk: boolean;
|
|
400
|
+
/**
|
|
401
|
+
* The witnessed bytes decoded, parsed, and re-canonicalized to themselves.
|
|
402
|
+
*
|
|
403
|
+
* The last clause is not pedantry. The plane stores these bytes as TEXT and
|
|
404
|
+
* replays them verbatim precisely because a round-trip through a normalizing
|
|
405
|
+
* serializer is not guaranteed to reproduce what was signed; a body that is
|
|
406
|
+
* not JCS-canonical is one that every re-deriving reader hashes differently.
|
|
407
|
+
*/
|
|
408
|
+
bodyOk: boolean;
|
|
409
|
+
stampVersion: string | null;
|
|
410
|
+
intervalStart: string | null;
|
|
411
|
+
intervalEnd: string | null;
|
|
412
|
+
merkleRoot: string | null;
|
|
413
|
+
leafCount: number | null;
|
|
414
|
+
/**
|
|
415
|
+
* Every sub-tree range accounts for exactly one leaf of the stamp: the
|
|
416
|
+
* ranges sorted by `first_leaf` tile `[0, leaf_count)` with no gap and no
|
|
417
|
+
* overlap, and their counts sum to `leaf_count`.
|
|
418
|
+
*/
|
|
419
|
+
subtreesOk: boolean;
|
|
420
|
+
subtrees: StampSubtreeResult[];
|
|
421
|
+
/**
|
|
422
|
+
* Where it was witnessed, or `null` for a stamp nothing has witnessed yet.
|
|
423
|
+
*
|
|
424
|
+
* `null` is NOT a failure and never an accusation — it is the honest state
|
|
425
|
+
* of every stamp for the first minutes of its life and the permanent state
|
|
426
|
+
* of every stamp when no witness is configured. It does hold the bundle at
|
|
427
|
+
* `partial`: a stamp is Sanning's own assertion until something independent
|
|
428
|
+
* carries it.
|
|
429
|
+
*/
|
|
430
|
+
anchorRef: AnchorRef | null;
|
|
431
|
+
ok: boolean;
|
|
432
|
+
errors: string[];
|
|
433
|
+
}
|
|
434
|
+
|
|
268
435
|
export interface EvidenceBundleResult {
|
|
269
436
|
status: EvidenceStatus;
|
|
270
437
|
// The wrapper: accepted major, body_hash recompute, Ed25519 wrapper signature.
|
|
@@ -284,6 +451,12 @@ export interface EvidenceBundleResult {
|
|
|
284
451
|
// above are the recomputed SOURCE-bundle results in that case. Absent for a
|
|
285
452
|
// plain anchor-trace bundle — additive, backward-compatible.
|
|
286
453
|
export?: ExportResult;
|
|
454
|
+
// Present ONLY for a `sanning.stamp.trace/v1` body (evidence-bundle.md §5.2):
|
|
455
|
+
// the per-stamp recomputed results. `checkpoints`/`events` stay empty for
|
|
456
|
+
// such a bundle — a stamp body carries neither, and reporting an empty list
|
|
457
|
+
// as "nothing was wrong" is the exact confusion `partial` exists to prevent.
|
|
458
|
+
// Absent for every other body — additive, backward-compatible.
|
|
459
|
+
stamps?: StampResult[];
|
|
287
460
|
errors: string[];
|
|
288
461
|
}
|
|
289
462
|
|
|
@@ -572,6 +745,18 @@ export async function verifyEvidenceBundle(
|
|
|
572
745
|
return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
|
|
573
746
|
}
|
|
574
747
|
|
|
748
|
+
// The Sanning-signed interval stamp body (evidence-bundle.md §5.2). The
|
|
749
|
+
// wrapper is authentic — and for this body that IS "Sanning signed it", since
|
|
750
|
+
// a stamp carries no second signature of its own inside the bundle.
|
|
751
|
+
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
752
|
+
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
753
|
+
}
|
|
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
|
+
|
|
575
760
|
if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
576
761
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
577
762
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
@@ -943,79 +1128,10 @@ async function verifyAnchorTrace(
|
|
|
943
1128
|
}
|
|
944
1129
|
}
|
|
945
1130
|
|
|
946
|
-
// Content (raw-log) binding —
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
// envelope it falls back to the envelope's own committed hashes
|
|
951
|
-
// (contentHashes — NOT verifyEnvelope's path, which reads payload/event_type
|
|
952
|
-
// and yields nothing for a sanning.events/v1 envelope). Disclosed bytes come
|
|
953
|
-
// from the signed in-body `ev.content` FIRST, then the out-of-band
|
|
954
|
-
// options.content side input. Undetermined (null) when nothing is disclosed
|
|
955
|
-
// or nothing is committed; false ONLY on a real mismatch or an in-body vs
|
|
956
|
-
// side-input disagreement — mirroring payloadBindingOk's never-fail-on-absent
|
|
957
|
-
// discipline.
|
|
958
|
-
let contentOk: boolean | null = null;
|
|
959
|
-
{
|
|
960
|
-
const committed: string[] = [];
|
|
961
|
-
let recordContentHash: string | undefined;
|
|
962
|
-
if (recordBytes !== undefined) {
|
|
963
|
-
try {
|
|
964
|
-
const record = JSON.parse(new TextDecoder().decode(recordBytes)) as {
|
|
965
|
-
event?: { content_hash?: unknown };
|
|
966
|
-
};
|
|
967
|
-
const ch = record.event?.content_hash;
|
|
968
|
-
if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
|
|
969
|
-
} catch {
|
|
970
|
-
// record not JSON — the payload binding already reflects that; content
|
|
971
|
-
// stays undetermined rather than throwing.
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
if (recordContentHash !== undefined) {
|
|
975
|
-
committed.push(recordContentHash);
|
|
976
|
-
} else if (ev.envelope && typeof ev.envelope === "object") {
|
|
977
|
-
// Promoted fallback. Guarded: contentHashes() dereferences env.payload,
|
|
978
|
-
// which throws on a null/non-object envelope — a verifier must never
|
|
979
|
-
// raise on adversarial input (such an event already fails via envelopeOk).
|
|
980
|
-
for (const c of contentHashes(ev.envelope)) committed.push(c.hash.toLowerCase());
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// Disclosed bytes, precedence: in-body (signed) → side input. A string
|
|
984
|
-
// side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
|
|
985
|
-
// selector so `disclosedContent` hands a consumer the very bytes bound
|
|
986
|
-
// here — one decode, not two that can drift apart.
|
|
987
|
-
const selection = selectDisclosedBytes(ev, eventId, options.content);
|
|
988
|
-
const { inBody, side } = selection;
|
|
989
|
-
evErrors.push(...selection.errors);
|
|
990
|
-
|
|
991
|
-
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
992
|
-
// Two disclosures that disagree: a genuine conflict, not undetermined.
|
|
993
|
-
contentOk = false;
|
|
994
|
-
evErrors.push(
|
|
995
|
-
"disclosed content disagreement: in-body events[].content and the supplied content differ",
|
|
996
|
-
);
|
|
997
|
-
} else {
|
|
998
|
-
const disclosed = inBody ?? side;
|
|
999
|
-
if (disclosed === undefined || committed.length === 0) {
|
|
1000
|
-
contentOk = null;
|
|
1001
|
-
if (disclosed !== undefined && committed.length === 0) {
|
|
1002
|
-
evErrors.push(
|
|
1003
|
-
"disclosed content present but no committed content_hash to bind to " +
|
|
1004
|
-
"(record withheld or record has no event.content_hash) — undetermined",
|
|
1005
|
-
);
|
|
1006
|
-
}
|
|
1007
|
-
} else {
|
|
1008
|
-
const got = await sha256Hex(disclosed);
|
|
1009
|
-
contentOk = committed.includes(got);
|
|
1010
|
-
if (!contentOk) {
|
|
1011
|
-
evErrors.push(
|
|
1012
|
-
"disclosed content does not match the committed content_hash: " +
|
|
1013
|
-
`sha256(disclosed)=${got} committed=${committed.join("|")}`,
|
|
1014
|
-
);
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
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);
|
|
1019
1135
|
|
|
1020
1136
|
eventResults.push({
|
|
1021
1137
|
eventId,
|
|
@@ -1117,6 +1233,623 @@ async function verifyAnchorTrace(
|
|
|
1117
1233
|
};
|
|
1118
1234
|
}
|
|
1119
1235
|
|
|
1236
|
+
// ===========================================================================
|
|
1237
|
+
// `sanning.stamp.trace/v1` — the Sanning-signed interval stamp (§5.2, DL-58)
|
|
1238
|
+
//
|
|
1239
|
+
// ── What this can prove, and what it deliberately does not claim ──
|
|
1240
|
+
//
|
|
1241
|
+
// A stamp is not a producer artifact. Under DL-52 the customer signs an
|
|
1242
|
+
// envelope and POSTs it; the commitment is witnessed later, on an interval, by
|
|
1243
|
+
// a stamp the customer never sees and holds no key for. So there is no
|
|
1244
|
+
// producer signature inside this body to check, and — this is the part worth
|
|
1245
|
+
// being precise about — there is no SECOND Sanning signature either. The
|
|
1246
|
+
// control plane stores the stamp's canonical bytes and hands them to a witness
|
|
1247
|
+
// as a signed chain data item; the signature lives on the chain, not in the
|
|
1248
|
+
// `stamps` table and not in this body.
|
|
1249
|
+
//
|
|
1250
|
+
// What "Sanning signed this" means for a stamp bundle is therefore exactly the
|
|
1251
|
+
// WRAPPER signature, verified generically before this function is reached. And
|
|
1252
|
+
// what makes the stamp evidence rather than an assertion is the witness, which
|
|
1253
|
+
// is on a chain and not checkable offline. Hence the rollup below: a stamp we
|
|
1254
|
+
// can read but nobody has witnessed holds the bundle at `partial`.
|
|
1255
|
+
//
|
|
1256
|
+
// So the checkable surface here is the stamp's INTERNAL consistency against
|
|
1257
|
+
// the one authority in the bundle — the witnessed bytes:
|
|
1258
|
+
//
|
|
1259
|
+
// 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
|
|
1260
|
+
// 2. Those bytes parse and re-canonicalize to themselves under JCS.
|
|
1261
|
+
// 3. Their `stamp_version` is the one this body_type pins.
|
|
1262
|
+
// 4. Every sub-tree range accounts for exactly one leaf of the stamp.
|
|
1263
|
+
//
|
|
1264
|
+
// (4) is the one that carries customer weight. R-8's whole per-agent story is
|
|
1265
|
+
// "prove this agent was under evidence over window W", answered by a
|
|
1266
|
+
// contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
|
|
1267
|
+
// not tile `[0, leaf_count)` describe a partition that does not exist, so a
|
|
1268
|
+
// slice claim resting on them is unfounded — and unlike a bad signature,
|
|
1269
|
+
// nothing else in the stack would ever notice.
|
|
1270
|
+
// ===========================================================================
|
|
1271
|
+
|
|
1272
|
+
/** Lowercase hex, even length, non-empty. The family's serialization rule. */
|
|
1273
|
+
function isLowerHex(s: unknown, nibbles?: number): s is string {
|
|
1274
|
+
if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0) return false;
|
|
1275
|
+
if (nibbles !== undefined && s.length !== nibbles) return false;
|
|
1276
|
+
return /^[0-9a-f]+$/.test(s);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function isNonNegInt(n: unknown): n is number {
|
|
1280
|
+
return typeof n === "number" && Number.isInteger(n) && n >= 0;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Parse and check one stamp's witnessed bytes.
|
|
1285
|
+
*
|
|
1286
|
+
* Errors accumulate rather than short-circuit wherever a later check is still
|
|
1287
|
+
* meaningful, so a bundle with two independent defects reports two — a
|
|
1288
|
+
* verifier that stops at the first one turns a second fix into a second
|
|
1289
|
+
* round-trip.
|
|
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
|
+
|
|
1368
|
+
async function verifyStampEntry(
|
|
1369
|
+
raw: unknown,
|
|
1370
|
+
seen: Set<string>,
|
|
1371
|
+
): Promise<StampResult> {
|
|
1372
|
+
const errors: string[] = [];
|
|
1373
|
+
const base: StampResult = {
|
|
1374
|
+
stampRef: "",
|
|
1375
|
+
stampRefOk: false,
|
|
1376
|
+
bodyOk: false,
|
|
1377
|
+
stampVersion: null,
|
|
1378
|
+
intervalStart: null,
|
|
1379
|
+
intervalEnd: null,
|
|
1380
|
+
merkleRoot: null,
|
|
1381
|
+
leafCount: null,
|
|
1382
|
+
subtreesOk: false,
|
|
1383
|
+
subtrees: [],
|
|
1384
|
+
anchorRef: null,
|
|
1385
|
+
ok: false,
|
|
1386
|
+
errors,
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1390
|
+
errors.push("stamp entry is not an object");
|
|
1391
|
+
return base;
|
|
1392
|
+
}
|
|
1393
|
+
const e = raw as Partial<StampEntry> & Record<string, unknown>;
|
|
1394
|
+
|
|
1395
|
+
// --- the witnessed bytes, which are the only authority in this body -------
|
|
1396
|
+
if (!isLowerHex(e.stamp_bytes)) {
|
|
1397
|
+
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
1398
|
+
return base;
|
|
1399
|
+
}
|
|
1400
|
+
let bytes: Uint8Array;
|
|
1401
|
+
try {
|
|
1402
|
+
bytes = hexToBytes(e.stamp_bytes);
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
|
|
1405
|
+
return base;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// --- the derived identity -------------------------------------------------
|
|
1409
|
+
const derived = await sha256Hex(bytes);
|
|
1410
|
+
if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
|
|
1411
|
+
errors.push("stamp entry missing stamp_ref");
|
|
1412
|
+
} else if (e.stamp_ref.toLowerCase() !== derived) {
|
|
1413
|
+
// The bytes were edited and the label was not — or the label was edited
|
|
1414
|
+
// and the bytes were not. Either way the bundle names a stamp it is not
|
|
1415
|
+
// carrying, and quoting that name to a witness would resolve to something
|
|
1416
|
+
// else entirely.
|
|
1417
|
+
errors.push(
|
|
1418
|
+
`stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`,
|
|
1419
|
+
);
|
|
1420
|
+
} else {
|
|
1421
|
+
base.stampRefOk = true;
|
|
1422
|
+
base.stampRef = derived;
|
|
1423
|
+
if (seen.has(derived)) {
|
|
1424
|
+
errors.push(`duplicate stamp_ref ${derived}`);
|
|
1425
|
+
base.stampRefOk = false;
|
|
1426
|
+
} else {
|
|
1427
|
+
seen.add(derived);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// --- the body itself ------------------------------------------------------
|
|
1432
|
+
let body: StampBody;
|
|
1433
|
+
try {
|
|
1434
|
+
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
1435
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1436
|
+
errors.push("stamp body is not a JSON object");
|
|
1437
|
+
return base;
|
|
1438
|
+
}
|
|
1439
|
+
body = parsed as StampBody;
|
|
1440
|
+
// Re-canonicalize and compare. The plane replays these bytes verbatim on
|
|
1441
|
+
// every submit attempt for exactly this reason: a non-canonical body makes
|
|
1442
|
+
// a retry a DIFFERENT submission, and makes every independent re-derivation
|
|
1443
|
+
// of the stamp disagree with the one that was witnessed.
|
|
1444
|
+
if (jcs(body as unknown as Record<string, unknown>) !== new TextDecoder().decode(bytes)) {
|
|
1445
|
+
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
1446
|
+
return base;
|
|
1447
|
+
}
|
|
1448
|
+
} catch (err) {
|
|
1449
|
+
errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
|
|
1450
|
+
return base;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
1454
|
+
// A `sanning.stamp.trace/v1` body whose inner version is something else is
|
|
1455
|
+
// two answers to one question inside one signature. The body_type is the
|
|
1456
|
+
// reader's dispatch key and it said v1; honouring the other spelling is
|
|
1457
|
+
// how a reader half-parses a shape it does not know.
|
|
1458
|
+
errors.push(
|
|
1459
|
+
`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
1460
|
+
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
|
|
1461
|
+
);
|
|
1462
|
+
return base;
|
|
1463
|
+
}
|
|
1464
|
+
base.stampVersion = body.stamp_version;
|
|
1465
|
+
base.bodyOk = true;
|
|
1466
|
+
|
|
1467
|
+
for (const f of ["org_ref", "interval_start", "interval_end"] as const) {
|
|
1468
|
+
if (typeof body[f] !== "string" || body[f] === "") {
|
|
1469
|
+
errors.push(`stamp body ${f} is missing or not a non-empty string`);
|
|
1470
|
+
base.bodyOk = false;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
|
|
1474
|
+
base.intervalEnd = typeof body.interval_end === "string" ? body.interval_end : null;
|
|
1475
|
+
|
|
1476
|
+
if (!isLowerHex(body.merkle_root, 64)) {
|
|
1477
|
+
errors.push("stamp body merkle_root is not 64 lowercase hex characters");
|
|
1478
|
+
base.bodyOk = false;
|
|
1479
|
+
} else {
|
|
1480
|
+
base.merkleRoot = body.merkle_root;
|
|
1481
|
+
}
|
|
1482
|
+
if (!isNonNegInt(body.leaf_count)) {
|
|
1483
|
+
errors.push("stamp body leaf_count is not a non-negative integer");
|
|
1484
|
+
base.bodyOk = false;
|
|
1485
|
+
} else {
|
|
1486
|
+
base.leafCount = body.leaf_count;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// --- the sub-tree ranges --------------------------------------------------
|
|
1490
|
+
base.subtreesOk = checkSubtrees(body, base, errors);
|
|
1491
|
+
|
|
1492
|
+
base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
|
|
1493
|
+
|
|
1494
|
+
base.ok =
|
|
1495
|
+
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
1496
|
+
return base;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* The range arithmetic (§5.2), and the reason it is a hard check.
|
|
1501
|
+
*
|
|
1502
|
+
* `buildStampTree` orders a stamp's leaves agent-major, which is the only
|
|
1503
|
+
* reason `first_leaf` + `leaf_count` can describe an agent's slice at all: the
|
|
1504
|
+
* ranges partition `[0, leaf_count)` exactly, by construction. A body whose
|
|
1505
|
+
* ranges do not is either not a stamp this stack built or one built wrong, and
|
|
1506
|
+
* in both cases the per-agent claim the ranges exist to support is unfounded.
|
|
1507
|
+
*
|
|
1508
|
+
* The sum is checked BEFORE the tiling and reports its own message, so a body
|
|
1509
|
+
* that is merely short does not get filed under a gap it does not have.
|
|
1510
|
+
*/
|
|
1511
|
+
function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boolean {
|
|
1512
|
+
const raw = body.subtrees;
|
|
1513
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1514
|
+
errors.push("stamp body subtrees is not an object");
|
|
1515
|
+
return false;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
let shapeOk = true;
|
|
1519
|
+
const subtrees: StampSubtreeResult[] = [];
|
|
1520
|
+
for (const [ref, v] of Object.entries(raw)) {
|
|
1521
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
1522
|
+
errors.push(`sub-tree ${ref} is not an object`);
|
|
1523
|
+
shapeOk = false;
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
const s = v as Partial<StampSubtree>;
|
|
1527
|
+
if (!isNonNegInt(s.first_leaf)) {
|
|
1528
|
+
errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
|
|
1529
|
+
shapeOk = false;
|
|
1530
|
+
}
|
|
1531
|
+
// A zero-leaf sub-tree is not a slice of anything, and admitting one lets
|
|
1532
|
+
// an arbitrary number of empty ranges sit inside a tiling that still adds
|
|
1533
|
+
// up.
|
|
1534
|
+
if (!isNonNegInt(s.leaf_count) || s.leaf_count === 0) {
|
|
1535
|
+
errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
|
|
1536
|
+
shapeOk = false;
|
|
1537
|
+
}
|
|
1538
|
+
if (!isLowerHex(s.root, 64)) {
|
|
1539
|
+
errors.push(`sub-tree ${ref} root is not 64 lowercase hex characters`);
|
|
1540
|
+
shapeOk = false;
|
|
1541
|
+
}
|
|
1542
|
+
if (!shapeOk) continue;
|
|
1543
|
+
subtrees.push({
|
|
1544
|
+
ref,
|
|
1545
|
+
firstLeaf: s.first_leaf as number,
|
|
1546
|
+
leafCount: s.leaf_count as number,
|
|
1547
|
+
root: s.root as string,
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
subtrees.sort((a, b) => a.firstLeaf - b.firstLeaf);
|
|
1551
|
+
out.subtrees = subtrees;
|
|
1552
|
+
if (!shapeOk) return false;
|
|
1553
|
+
|
|
1554
|
+
const total = body.leaf_count;
|
|
1555
|
+
if (!isNonNegInt(total)) return false; // already reported by the caller
|
|
1556
|
+
|
|
1557
|
+
const sum = subtrees.reduce((n, s) => n + s.leafCount, 0);
|
|
1558
|
+
if (sum !== total) {
|
|
1559
|
+
errors.push(
|
|
1560
|
+
`sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`,
|
|
1561
|
+
);
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
let next = 0;
|
|
1566
|
+
for (const s of subtrees) {
|
|
1567
|
+
if (s.firstLeaf !== next) {
|
|
1568
|
+
errors.push(
|
|
1569
|
+
`sub-tree ranges do not tile [0, ${total}): expected a range at leaf ` +
|
|
1570
|
+
`${next}, found one at ${s.firstLeaf}`,
|
|
1571
|
+
);
|
|
1572
|
+
return false;
|
|
1573
|
+
}
|
|
1574
|
+
next += s.leafCount;
|
|
1575
|
+
}
|
|
1576
|
+
return true;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
async function verifyStampTrace(
|
|
1580
|
+
b: EvidenceBundle,
|
|
1581
|
+
specVersionOk: boolean,
|
|
1582
|
+
assertedStatus: string | null,
|
|
1583
|
+
errors: string[],
|
|
1584
|
+
): Promise<EvidenceBundleResult> {
|
|
1585
|
+
const body = b.body as StampTraceBody;
|
|
1586
|
+
if (!Array.isArray(body?.stamps)) {
|
|
1587
|
+
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const seen = new Set<string>();
|
|
1591
|
+
const stamps: StampResult[] = [];
|
|
1592
|
+
for (const entry of body.stamps) {
|
|
1593
|
+
stamps.push(await verifyStampEntry(entry, seen));
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// --- Rollup ----------------------------------------------------------------
|
|
1597
|
+
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
1598
|
+
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
1599
|
+
|
|
1600
|
+
let status: EvidenceStatus;
|
|
1601
|
+
if (anyHardFailure) {
|
|
1602
|
+
status = "failed";
|
|
1603
|
+
} else if (stamps.length === 0) {
|
|
1604
|
+
// A bundle asserting nothing. Not a failure — nothing here is wrong — but
|
|
1605
|
+
// it must never read `verified`, which a caller would take as a witness it
|
|
1606
|
+
// does not have.
|
|
1607
|
+
status = "partial";
|
|
1608
|
+
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
1609
|
+
} else if (anyUnwitnessed) {
|
|
1610
|
+
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1611
|
+
// sealed and unwitnessed: `retained-by-sanning`, which is a statement about
|
|
1612
|
+
// our coverage and never an accusation about their evidence. It is also not
|
|
1613
|
+
// `verified`, because for a stamp the witness is the entire independent
|
|
1614
|
+
// part — unlike an anchor-trace checkpoint, which still carries a producer
|
|
1615
|
+
// signature and an inclusion proof that stand on their own offline.
|
|
1616
|
+
status = "partial";
|
|
1617
|
+
errors.push(
|
|
1618
|
+
"one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
|
|
1619
|
+
);
|
|
1620
|
+
} else {
|
|
1621
|
+
status = "verified";
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
return {
|
|
1625
|
+
status,
|
|
1626
|
+
specVersionOk,
|
|
1627
|
+
bodyHashOk: true,
|
|
1628
|
+
signatureOk: true,
|
|
1629
|
+
bodyType: STAMP_TRACE_BODY_TYPE,
|
|
1630
|
+
onChainChecked: false,
|
|
1631
|
+
checkpoints: [],
|
|
1632
|
+
events: [],
|
|
1633
|
+
assertedStatus,
|
|
1634
|
+
stamps,
|
|
1635
|
+
errors,
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
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
|
+
|
|
1120
1853
|
// ===========================================================================
|
|
1121
1854
|
// Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
|
|
1122
1855
|
//
|
|
@@ -1306,7 +2039,13 @@ export async function disclosedContent(
|
|
|
1306
2039
|
// wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
|
|
1307
2040
|
// recurses into `source_bundle`, so the events line up with its results.
|
|
1308
2041
|
// `undefined` when there is no anchor-trace body to materialize from.
|
|
1309
|
-
|
|
2042
|
+
//
|
|
2043
|
+
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
2044
|
+
// again: two answers to "which events is this bundle actually about" inside one
|
|
2045
|
+
// kernel is two implementations of one rule, and the one that drifted would be
|
|
2046
|
+
// the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
|
|
2047
|
+
// same posture as `side-content.ts`, internal and shared.
|
|
2048
|
+
export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
|
|
1310
2049
|
let cur: unknown = bundle;
|
|
1311
2050
|
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
1312
2051
|
if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
|