@sanning/proof 0.5.0 → 0.7.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 +31 -4
- package/dist/chain.d.ts +99 -0
- package/dist/chain.js +351 -0
- package/dist/cli.js +253 -38
- package/dist/compose.js +1 -1
- package/dist/evidence.d.ts +204 -7
- package/dist/evidence.js +621 -67
- package/dist/grading.d.ts +160 -0
- package/dist/grading.js +469 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +38 -1
- package/dist/side-content.d.ts +36 -0
- package/dist/side-content.js +98 -0
- package/dist/verifier.js +46 -7
- package/package.json +2 -1
- package/src/chain.ts +460 -0
- package/src/cli.ts +324 -35
- package/src/compose.ts +1 -1
- package/src/evidence.ts +875 -81
- package/src/grading.ts +623 -0
- package/src/index.ts +79 -0
- package/src/side-content.ts +119 -0
- package/src/verifier.ts +49 -7
- package/vectors/README.md +66 -0
- package/vectors/chain-reading.json +293 -0
- package/vectors/pack-grading.json +136 -0
package/dist/evidence.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
|
|
19
19
|
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
20
20
|
import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, jcs, verifyEnvelope, } from "./verifier.js";
|
|
21
|
+
import { inBodyContentOffered, sideContentEntry, } from "./side-content.js";
|
|
21
22
|
// Only the evidence-bundle major(s) this kernel verifies. A new major is a
|
|
22
23
|
// deliberate one-entry addition HERE — same fail-closed discipline as the
|
|
23
24
|
// envelope verifier's ACCEPTED_SPEC_MAJORS.
|
|
@@ -26,13 +27,52 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
|
|
|
26
27
|
// body_type is not a hard failure of the WRAPPER (the signature + body_hash
|
|
27
28
|
// still verify), but its body cannot be re-derived, so the per-body verdict is
|
|
28
29
|
// "undetermined" rather than "verified".
|
|
29
|
-
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/
|
|
30
|
+
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
|
|
31
|
+
/**
|
|
32
|
+
* The frozen pre-`anchor_ref` body (evidence-bundle.md §5.1.3).
|
|
33
|
+
*
|
|
34
|
+
* Accepted PERMANENTLY, not for a deprecation window. Every bundle emitted
|
|
35
|
+
* before 2026-09-03 carries this shape, and those bundles are evidence — a
|
|
36
|
+
* reader that stopped accepting them would destroy the record it exists to
|
|
37
|
+
* defend (DL-30). Nothing new emits it.
|
|
38
|
+
*/
|
|
39
|
+
export const ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
|
|
40
|
+
/** Every anchor-trace body this kernel verifies. Order is newest-first. */
|
|
41
|
+
export const ANCHOR_TRACE_BODY_TYPES = [
|
|
42
|
+
ANCHOR_TRACE_BODY_TYPE,
|
|
43
|
+
ANCHOR_TRACE_BODY_TYPE_V1,
|
|
44
|
+
];
|
|
30
45
|
// The issuer-composed attested-evidence-export body (specs/evidence-export.md
|
|
31
46
|
// §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
|
|
32
47
|
// carries a cached verdict + the inline source bundle + embedded RSA-PSS
|
|
33
48
|
// operator attestations. verifyEvidenceBundle dispatches on it to
|
|
34
49
|
// verifyExportBody (§5).
|
|
35
50
|
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
51
|
+
/**
|
|
52
|
+
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
|
|
53
|
+
*
|
|
54
|
+
* Its own `body_type` rather than a widened anchor-trace body, and the reason
|
|
55
|
+
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
56
|
+
* `sanning.events/v1` envelope, and a stamp has no producer signature at all —
|
|
57
|
+
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
58
|
+
* The two rejected options were making `envelope` optional and synthesising an
|
|
59
|
+
* envelope that Sanning signs; the second would have made a Sanning
|
|
60
|
+
* attestation indistinguishable ON THE WIRE from a producer's, which is
|
|
61
|
+
* unrecoverable once witnessed.
|
|
62
|
+
*/
|
|
63
|
+
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
64
|
+
/**
|
|
65
|
+
* The version string INSIDE a witnessed stamp body — a different string from
|
|
66
|
+
* the `body_type` above, and the two are easy to confuse.
|
|
67
|
+
*
|
|
68
|
+
* `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
|
|
69
|
+
* dispatch key. `sanning.stamp/v1` is the control plane's own version of the
|
|
70
|
+
* bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
|
|
71
|
+
* `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
|
|
72
|
+
* version to exactly this value: two answers to one question inside one
|
|
73
|
+
* signature is the §5.1.2 mixed-vocabulary reject, one layer down.
|
|
74
|
+
*/
|
|
75
|
+
export const STAMP_BODY_VERSION = "sanning.stamp/v1";
|
|
36
76
|
function isEvidenceMajorAccepted(specVersion) {
|
|
37
77
|
if (typeof specVersion !== "string" || specVersion === "")
|
|
38
78
|
return false;
|
|
@@ -149,12 +189,18 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
149
189
|
if (bodyType === EXPORT_BODY_TYPE) {
|
|
150
190
|
return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
|
|
151
191
|
}
|
|
152
|
-
|
|
192
|
+
// The Sanning-signed interval stamp body (evidence-bundle.md §5.2). The
|
|
193
|
+
// wrapper is authentic — and for this body that IS "Sanning signed it", since
|
|
194
|
+
// a stamp carries no second signature of its own inside the bundle.
|
|
195
|
+
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
196
|
+
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
197
|
+
}
|
|
198
|
+
if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
153
199
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
154
200
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
155
201
|
// body verdict undetermined) — not a failure.
|
|
156
202
|
errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
|
|
157
|
-
`(only ${
|
|
203
|
+
`(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`);
|
|
158
204
|
return {
|
|
159
205
|
status: "partial",
|
|
160
206
|
specVersionOk,
|
|
@@ -170,31 +216,168 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
170
216
|
}
|
|
171
217
|
return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
|
|
172
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Index a source bundle's checkpoints by the key its attestations will use.
|
|
221
|
+
*
|
|
222
|
+
* The attestation join key follows the SOURCE bundle's own vocabulary — a
|
|
223
|
+
* v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
|
|
224
|
+
* alternative (always deriving a v2-style key) would silently un-resolve every
|
|
225
|
+
* attestation ever written against a v1 export: `data_hash_bound` would go
|
|
226
|
+
* false, the export would stop reaching `verified`, and nothing would say why.
|
|
227
|
+
*/
|
|
228
|
+
async function indexSourceCheckpoints(bundle) {
|
|
229
|
+
const out = new Map();
|
|
230
|
+
const sb = bundle.body;
|
|
231
|
+
const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
|
|
232
|
+
if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints))
|
|
233
|
+
return out;
|
|
234
|
+
for (const cp of sb.checkpoints) {
|
|
235
|
+
if (!cp || typeof cp !== "object")
|
|
236
|
+
continue;
|
|
237
|
+
let recordBytes;
|
|
238
|
+
try {
|
|
239
|
+
if (typeof cp.record_bytes === "string")
|
|
240
|
+
recordBytes = hexToBytes(cp.record_bytes);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
recordBytes = undefined;
|
|
244
|
+
}
|
|
245
|
+
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
246
|
+
if (keyed.key !== null && !out.has(keyed.key))
|
|
247
|
+
out.set(keyed.key, cp);
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Resolve a checkpoint's join key and its witness reference, for whichever
|
|
253
|
+
* body form we are reading.
|
|
254
|
+
*
|
|
255
|
+
* ── The rule that matters, and why it is a reject rather than a preference ──
|
|
256
|
+
*
|
|
257
|
+
* A checkpoint carrying BOTH vocabularies is two answers to one question
|
|
258
|
+
* inside one signature. A reader that silently prefers one gives a different
|
|
259
|
+
* answer from a reader that prefers the other, over the same signed bytes —
|
|
260
|
+
* and both look like they verified. So a v2 body carrying `tx_id`, or a v1
|
|
261
|
+
* body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
|
|
262
|
+
* resolved. This is cheap to enforce here and impossible to enforce later.
|
|
263
|
+
*
|
|
264
|
+
* ── Why v2's key is recomputed ──
|
|
265
|
+
*
|
|
266
|
+
* In v1 the key was an asserted label matched against another asserted label;
|
|
267
|
+
* nothing tied either to the bytes, so relabelling a checkpoint and every
|
|
268
|
+
* event pointing at it left a bundle that still verified. In v2 the key IS the
|
|
269
|
+
* bytes, so the same relabel fails here. v1's behaviour is preserved exactly
|
|
270
|
+
* as it was — deliberately, because those bundles are already signed and
|
|
271
|
+
* re-interpreting them is not backwards compatibility.
|
|
272
|
+
*/
|
|
273
|
+
async function resolveCheckpointKey(cp, recordBytes, isV1) {
|
|
274
|
+
const errors = [];
|
|
275
|
+
if (isV1) {
|
|
276
|
+
if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
|
|
277
|
+
errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) — " +
|
|
278
|
+
"one bundle speaks one vocabulary");
|
|
279
|
+
return { key: null, anchorRef: null, errors };
|
|
280
|
+
}
|
|
281
|
+
if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
|
|
282
|
+
errors.push("checkpoint missing tx_id");
|
|
283
|
+
return { key: null, anchorRef: null, errors };
|
|
284
|
+
}
|
|
285
|
+
// v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
|
|
286
|
+
// through the same shape as v2 gives ONE reader-side vocabulary without
|
|
287
|
+
// touching a byte of the signed bundle.
|
|
288
|
+
return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
|
|
289
|
+
}
|
|
290
|
+
if (cp.tx_id !== undefined) {
|
|
291
|
+
errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id — one bundle speaks one vocabulary");
|
|
292
|
+
return { key: null, anchorRef: null, errors };
|
|
293
|
+
}
|
|
294
|
+
if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
|
|
295
|
+
errors.push("checkpoint missing checkpoint_ref");
|
|
296
|
+
return { key: null, anchorRef: null, errors };
|
|
297
|
+
}
|
|
298
|
+
if (recordBytes === undefined) {
|
|
299
|
+
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
300
|
+
errors.push("checkpoint_ref cannot be verified — record_bytes missing or not hex");
|
|
301
|
+
return { key: null, anchorRef: null, errors };
|
|
302
|
+
}
|
|
303
|
+
const derived = await sha256Hex(recordBytes);
|
|
304
|
+
if (derived !== cp.checkpoint_ref.toLowerCase()) {
|
|
305
|
+
errors.push(`checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
|
|
306
|
+
return { key: null, anchorRef: null, errors };
|
|
307
|
+
}
|
|
308
|
+
const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
|
|
309
|
+
return { key: derived, anchorRef, errors };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Validate an optional `anchor_ref` (§5.1.1).
|
|
313
|
+
*
|
|
314
|
+
* ── An unrecognised `kind` is NOT a failure, and that is the point ──
|
|
315
|
+
*
|
|
316
|
+
* The offline verdict — signatures, payload binding, Merkle inclusion — is
|
|
317
|
+
* computed with no witness access at all. A reader that cannot resolve a
|
|
318
|
+
* witness has therefore learned NOTHING about whether the evidence was
|
|
319
|
+
* altered, and must not say otherwise. The shipped issuer did the opposite:
|
|
320
|
+
* an unrecognised signature type returned `false` rather than `null`, mapped
|
|
321
|
+
* to `tampered`, and was cached as permanent — a false accusation, produced
|
|
322
|
+
* by a coverage gap, for a company whose product is telling people whether
|
|
323
|
+
* records were altered.
|
|
324
|
+
*
|
|
325
|
+
* So this validates the SHAPE and never the vocabulary. A malformed
|
|
326
|
+
* `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
|
|
327
|
+
* the reader, and only the first is the bundle's fault.
|
|
328
|
+
*/
|
|
329
|
+
function parseAnchorRef(raw, errors) {
|
|
330
|
+
if (raw === undefined || raw === null)
|
|
331
|
+
return null; // placed, not yet witnessed
|
|
332
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
333
|
+
errors.push("anchor_ref must be an object");
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const o = raw;
|
|
337
|
+
if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
|
|
338
|
+
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
if (typeof o.id !== "string" || o.id === "") {
|
|
342
|
+
errors.push("anchor_ref.id is missing or empty");
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const ref = o.ref;
|
|
346
|
+
if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
|
|
347
|
+
errors.push("anchor_ref.ref must be an object when present");
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
kind: o.kind,
|
|
352
|
+
id: o.id,
|
|
353
|
+
...(ref === undefined ? {} : { ref: ref }),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
173
356
|
async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options) {
|
|
174
357
|
const body = b.body;
|
|
358
|
+
const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
|
|
175
359
|
if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
|
|
176
|
-
return malformed(
|
|
360
|
+
return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
|
|
177
361
|
}
|
|
178
362
|
const gateways = options.gateways ?? [];
|
|
179
363
|
const onChainChecked = gateways.length > 0;
|
|
180
364
|
// --- Step 3: per-checkpoint -------------------------------------------------
|
|
181
|
-
|
|
365
|
+
//
|
|
366
|
+
// ── Why the join key is resolved BEFORE anything else ──
|
|
367
|
+
//
|
|
368
|
+
// `byKey` is what binds an event to the root its inclusion proof is checked
|
|
369
|
+
// against. Get it wrong and an event proves inclusion in a tree it was never
|
|
370
|
+
// in — the one failure in this file that produces a WRONG VERIFIED, not a
|
|
371
|
+
// failed verify. Everything below is downstream of it.
|
|
372
|
+
const byKey = new Map();
|
|
373
|
+
const anchorByKey = new Map();
|
|
182
374
|
const checkpointResults = [];
|
|
183
375
|
for (const cp of body.checkpoints) {
|
|
184
376
|
const cpErrors = [];
|
|
185
377
|
let envelopeOk = false;
|
|
186
378
|
let merkleRootOk = false;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
else if (byTxId.has(cp.tx_id)) {
|
|
191
|
-
cpErrors.push(`duplicate checkpoint tx_id ${cp.tx_id}`);
|
|
192
|
-
}
|
|
193
|
-
else {
|
|
194
|
-
byTxId.set(cp.tx_id, cp);
|
|
195
|
-
}
|
|
196
|
-
// Verify the checkpoint envelope against its committed record (external
|
|
197
|
-
// commitment — the record is the payload these external envelopes bind to).
|
|
379
|
+
// Decode record_bytes first: in v2 the join key is DERIVED from it, so it
|
|
380
|
+
// is an input to identity, not just to the payload binding below.
|
|
198
381
|
let recordBytes;
|
|
199
382
|
try {
|
|
200
383
|
if (typeof cp.record_bytes === "string")
|
|
@@ -203,6 +386,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
203
386
|
catch (e) {
|
|
204
387
|
cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
|
|
205
388
|
}
|
|
389
|
+
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
390
|
+
cpErrors.push(...keyed.errors);
|
|
391
|
+
if (keyed.key !== null) {
|
|
392
|
+
if (byKey.has(keyed.key)) {
|
|
393
|
+
cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
byKey.set(keyed.key, cp);
|
|
397
|
+
anchorByKey.set(keyed.key, keyed.anchorRef);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Verify the checkpoint envelope against its committed record (external
|
|
401
|
+
// commitment — the record is the payload these external envelopes bind to).
|
|
206
402
|
try {
|
|
207
403
|
const res = await verifyEnvelope(cp.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
|
|
208
404
|
// A checkpoint must fully bind: signature + the committed record matching
|
|
@@ -235,12 +431,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
235
431
|
}
|
|
236
432
|
}
|
|
237
433
|
checkpointResults.push({
|
|
238
|
-
|
|
434
|
+
checkpointRef: keyed.key ?? "",
|
|
435
|
+
anchorRef: keyed.anchorRef,
|
|
239
436
|
envelopeOk,
|
|
240
437
|
merkleRootOk,
|
|
241
438
|
onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
|
|
242
439
|
onChain: null, // per-gateway (§4.2), filled in step 5
|
|
243
|
-
|
|
440
|
+
// `keyed.errors` is load-bearing here, not decorative. A structurally
|
|
441
|
+
// invalid anchor_ref rides INSIDE the signature, so it is a defect we
|
|
442
|
+
// can see rather than a coverage gap we cannot — and this kernel's
|
|
443
|
+
// standing rule is that malformed is a failure while absent is
|
|
444
|
+
// undetermined (the same rule disclosed `content` follows). Without
|
|
445
|
+
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
446
|
+
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
|
|
244
447
|
errors: cpErrors,
|
|
245
448
|
});
|
|
246
449
|
}
|
|
@@ -274,18 +477,36 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
274
477
|
// Bind the event to exactly one checkpoint in the bundle, then verify
|
|
275
478
|
// RFC 9162 inclusion against THAT checkpoint's merkle_root.
|
|
276
479
|
const incl = ev.inclusion;
|
|
277
|
-
|
|
278
|
-
|
|
480
|
+
// The join key is spelled differently in the two forms and MUST NOT be read
|
|
481
|
+
// from the other one: an inclusion carrying v1's spelling inside a v2 body
|
|
482
|
+
// is a mixed bundle, and resolving it anyway is how an event silently binds
|
|
483
|
+
// to a checkpoint nobody meant.
|
|
484
|
+
const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
|
|
485
|
+
const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
|
|
486
|
+
const inclAsRecord = incl;
|
|
487
|
+
const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
|
|
488
|
+
const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
|
|
489
|
+
let cp;
|
|
279
490
|
if (!incl || typeof incl !== "object") {
|
|
280
491
|
evErrors.push("event missing inclusion proof");
|
|
281
492
|
}
|
|
282
|
-
else if (
|
|
283
|
-
evErrors.push(`event inclusion
|
|
493
|
+
else if (strayKey !== undefined) {
|
|
494
|
+
evErrors.push(`event inclusion carries ${wrongField} in a ${bodyType} body — one bundle speaks one vocabulary`);
|
|
495
|
+
}
|
|
496
|
+
else if (typeof rawKey !== "string") {
|
|
497
|
+
evErrors.push(`event inclusion missing ${keyField}`);
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
cp = byKey.get(rawKey);
|
|
501
|
+
if (cp === undefined) {
|
|
502
|
+
evErrors.push(`event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`);
|
|
503
|
+
}
|
|
284
504
|
}
|
|
505
|
+
const checkpointBound = cp !== undefined;
|
|
285
506
|
let inclusionOk = false;
|
|
286
507
|
if (incl && checkpointBound && cp) {
|
|
287
508
|
try {
|
|
288
|
-
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(cp.merkle_root));
|
|
509
|
+
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), incl.leaf_index, incl.leaf_count, (incl.audit_path ?? []).map((h) => hexToBytes(h)), hexToBytes(cp.merkle_root ?? ""));
|
|
289
510
|
if (!inclusionOk)
|
|
290
511
|
evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
291
512
|
// Defense in depth: the claimed leaf_hash MUST be the hash of the
|
|
@@ -397,10 +618,27 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
397
618
|
}
|
|
398
619
|
else {
|
|
399
620
|
for (const cpr of checkpointResults) {
|
|
400
|
-
const cp =
|
|
621
|
+
const cp = byKey.get(cpr.checkpointRef);
|
|
401
622
|
if (!cp)
|
|
402
623
|
continue;
|
|
403
|
-
|
|
624
|
+
// A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
|
|
625
|
+
// that is the normal state inside a stamp interval — placed, readable,
|
|
626
|
+
// not yet witnessed — so it reports as unreachable (which downgrades
|
|
627
|
+
// the run to partial) and NEVER as a mismatch. Treating "we never
|
|
628
|
+
// anchored it yet" as "the chain disagrees" would be the same false
|
|
629
|
+
// accusation this whole change is removing.
|
|
630
|
+
if (cpr.anchorRef === null) {
|
|
631
|
+
gatewayUnavailable = true;
|
|
632
|
+
cpr.onChain = {
|
|
633
|
+
perGateway: gateways.map((gw) => ({ gateway: gw, outcome: "unreachable" })),
|
|
634
|
+
rollup: "unreachable",
|
|
635
|
+
onChainOk: null,
|
|
636
|
+
};
|
|
637
|
+
cpr.onChainOk = null;
|
|
638
|
+
cpr.errors.push("checkpoint has no anchor_ref — not yet witnessed, nothing to re-fetch");
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
|
|
404
642
|
cpr.onChain = outcome.onChain;
|
|
405
643
|
cpr.onChainOk = outcome.onChain.onChainOk;
|
|
406
644
|
if (outcome.unavailable)
|
|
@@ -456,6 +694,305 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
456
694
|
errors,
|
|
457
695
|
};
|
|
458
696
|
}
|
|
697
|
+
// ===========================================================================
|
|
698
|
+
// `sanning.stamp.trace/v1` — the Sanning-signed interval stamp (§5.2, DL-58)
|
|
699
|
+
//
|
|
700
|
+
// ── What this can prove, and what it deliberately does not claim ──
|
|
701
|
+
//
|
|
702
|
+
// A stamp is not a producer artifact. Under DL-52 the customer signs an
|
|
703
|
+
// envelope and POSTs it; the commitment is witnessed later, on an interval, by
|
|
704
|
+
// a stamp the customer never sees and holds no key for. So there is no
|
|
705
|
+
// producer signature inside this body to check, and — this is the part worth
|
|
706
|
+
// being precise about — there is no SECOND Sanning signature either. The
|
|
707
|
+
// control plane stores the stamp's canonical bytes and hands them to a witness
|
|
708
|
+
// as a signed chain data item; the signature lives on the chain, not in the
|
|
709
|
+
// `stamps` table and not in this body.
|
|
710
|
+
//
|
|
711
|
+
// What "Sanning signed this" means for a stamp bundle is therefore exactly the
|
|
712
|
+
// WRAPPER signature, verified generically before this function is reached. And
|
|
713
|
+
// what makes the stamp evidence rather than an assertion is the witness, which
|
|
714
|
+
// is on a chain and not checkable offline. Hence the rollup below: a stamp we
|
|
715
|
+
// can read but nobody has witnessed holds the bundle at `partial`.
|
|
716
|
+
//
|
|
717
|
+
// So the checkable surface here is the stamp's INTERNAL consistency against
|
|
718
|
+
// the one authority in the bundle — the witnessed bytes:
|
|
719
|
+
//
|
|
720
|
+
// 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
|
|
721
|
+
// 2. Those bytes parse and re-canonicalize to themselves under JCS.
|
|
722
|
+
// 3. Their `stamp_version` is the one this body_type pins.
|
|
723
|
+
// 4. Every sub-tree range accounts for exactly one leaf of the stamp.
|
|
724
|
+
//
|
|
725
|
+
// (4) is the one that carries customer weight. R-8's whole per-agent story is
|
|
726
|
+
// "prove this agent was under evidence over window W", answered by a
|
|
727
|
+
// contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
|
|
728
|
+
// not tile `[0, leaf_count)` describe a partition that does not exist, so a
|
|
729
|
+
// slice claim resting on them is unfounded — and unlike a bad signature,
|
|
730
|
+
// nothing else in the stack would ever notice.
|
|
731
|
+
// ===========================================================================
|
|
732
|
+
/** Lowercase hex, even length, non-empty. The family's serialization rule. */
|
|
733
|
+
function isLowerHex(s, nibbles) {
|
|
734
|
+
if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0)
|
|
735
|
+
return false;
|
|
736
|
+
if (nibbles !== undefined && s.length !== nibbles)
|
|
737
|
+
return false;
|
|
738
|
+
return /^[0-9a-f]+$/.test(s);
|
|
739
|
+
}
|
|
740
|
+
function isNonNegInt(n) {
|
|
741
|
+
return typeof n === "number" && Number.isInteger(n) && n >= 0;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Parse and check one stamp's witnessed bytes.
|
|
745
|
+
*
|
|
746
|
+
* Errors accumulate rather than short-circuit wherever a later check is still
|
|
747
|
+
* meaningful, so a bundle with two independent defects reports two — a
|
|
748
|
+
* verifier that stops at the first one turns a second fix into a second
|
|
749
|
+
* round-trip.
|
|
750
|
+
*/
|
|
751
|
+
async function verifyStampEntry(raw, seen) {
|
|
752
|
+
const errors = [];
|
|
753
|
+
const base = {
|
|
754
|
+
stampRef: "",
|
|
755
|
+
stampRefOk: false,
|
|
756
|
+
bodyOk: false,
|
|
757
|
+
stampVersion: null,
|
|
758
|
+
intervalStart: null,
|
|
759
|
+
intervalEnd: null,
|
|
760
|
+
merkleRoot: null,
|
|
761
|
+
leafCount: null,
|
|
762
|
+
subtreesOk: false,
|
|
763
|
+
subtrees: [],
|
|
764
|
+
anchorRef: null,
|
|
765
|
+
ok: false,
|
|
766
|
+
errors,
|
|
767
|
+
};
|
|
768
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
769
|
+
errors.push("stamp entry is not an object");
|
|
770
|
+
return base;
|
|
771
|
+
}
|
|
772
|
+
const e = raw;
|
|
773
|
+
// --- the witnessed bytes, which are the only authority in this body -------
|
|
774
|
+
if (!isLowerHex(e.stamp_bytes)) {
|
|
775
|
+
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
776
|
+
return base;
|
|
777
|
+
}
|
|
778
|
+
let bytes;
|
|
779
|
+
try {
|
|
780
|
+
bytes = hexToBytes(e.stamp_bytes);
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
|
|
784
|
+
return base;
|
|
785
|
+
}
|
|
786
|
+
// --- the derived identity -------------------------------------------------
|
|
787
|
+
const derived = await sha256Hex(bytes);
|
|
788
|
+
if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
|
|
789
|
+
errors.push("stamp entry missing stamp_ref");
|
|
790
|
+
}
|
|
791
|
+
else if (e.stamp_ref.toLowerCase() !== derived) {
|
|
792
|
+
// The bytes were edited and the label was not — or the label was edited
|
|
793
|
+
// and the bytes were not. Either way the bundle names a stamp it is not
|
|
794
|
+
// carrying, and quoting that name to a witness would resolve to something
|
|
795
|
+
// else entirely.
|
|
796
|
+
errors.push(`stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`);
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
base.stampRefOk = true;
|
|
800
|
+
base.stampRef = derived;
|
|
801
|
+
if (seen.has(derived)) {
|
|
802
|
+
errors.push(`duplicate stamp_ref ${derived}`);
|
|
803
|
+
base.stampRefOk = false;
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
seen.add(derived);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// --- the body itself ------------------------------------------------------
|
|
810
|
+
let body;
|
|
811
|
+
try {
|
|
812
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
813
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
814
|
+
errors.push("stamp body is not a JSON object");
|
|
815
|
+
return base;
|
|
816
|
+
}
|
|
817
|
+
body = parsed;
|
|
818
|
+
// Re-canonicalize and compare. The plane replays these bytes verbatim on
|
|
819
|
+
// every submit attempt for exactly this reason: a non-canonical body makes
|
|
820
|
+
// a retry a DIFFERENT submission, and makes every independent re-derivation
|
|
821
|
+
// of the stamp disagree with the one that was witnessed.
|
|
822
|
+
if (jcs(body) !== new TextDecoder().decode(bytes)) {
|
|
823
|
+
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
824
|
+
return base;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
|
|
829
|
+
return base;
|
|
830
|
+
}
|
|
831
|
+
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
832
|
+
// A `sanning.stamp.trace/v1` body whose inner version is something else is
|
|
833
|
+
// two answers to one question inside one signature. The body_type is the
|
|
834
|
+
// reader's dispatch key and it said v1; honouring the other spelling is
|
|
835
|
+
// how a reader half-parses a shape it does not know.
|
|
836
|
+
errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
837
|
+
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
|
|
838
|
+
return base;
|
|
839
|
+
}
|
|
840
|
+
base.stampVersion = body.stamp_version;
|
|
841
|
+
base.bodyOk = true;
|
|
842
|
+
for (const f of ["org_ref", "interval_start", "interval_end"]) {
|
|
843
|
+
if (typeof body[f] !== "string" || body[f] === "") {
|
|
844
|
+
errors.push(`stamp body ${f} is missing or not a non-empty string`);
|
|
845
|
+
base.bodyOk = false;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
|
|
849
|
+
base.intervalEnd = typeof body.interval_end === "string" ? body.interval_end : null;
|
|
850
|
+
if (!isLowerHex(body.merkle_root, 64)) {
|
|
851
|
+
errors.push("stamp body merkle_root is not 64 lowercase hex characters");
|
|
852
|
+
base.bodyOk = false;
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
base.merkleRoot = body.merkle_root;
|
|
856
|
+
}
|
|
857
|
+
if (!isNonNegInt(body.leaf_count)) {
|
|
858
|
+
errors.push("stamp body leaf_count is not a non-negative integer");
|
|
859
|
+
base.bodyOk = false;
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
base.leafCount = body.leaf_count;
|
|
863
|
+
}
|
|
864
|
+
// --- the sub-tree ranges --------------------------------------------------
|
|
865
|
+
base.subtreesOk = checkSubtrees(body, base, errors);
|
|
866
|
+
base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
|
|
867
|
+
base.ok =
|
|
868
|
+
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
869
|
+
return base;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* The range arithmetic (§5.2), and the reason it is a hard check.
|
|
873
|
+
*
|
|
874
|
+
* `buildStampTree` orders a stamp's leaves agent-major, which is the only
|
|
875
|
+
* reason `first_leaf` + `leaf_count` can describe an agent's slice at all: the
|
|
876
|
+
* ranges partition `[0, leaf_count)` exactly, by construction. A body whose
|
|
877
|
+
* ranges do not is either not a stamp this stack built or one built wrong, and
|
|
878
|
+
* in both cases the per-agent claim the ranges exist to support is unfounded.
|
|
879
|
+
*
|
|
880
|
+
* The sum is checked BEFORE the tiling and reports its own message, so a body
|
|
881
|
+
* that is merely short does not get filed under a gap it does not have.
|
|
882
|
+
*/
|
|
883
|
+
function checkSubtrees(body, out, errors) {
|
|
884
|
+
const raw = body.subtrees;
|
|
885
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
886
|
+
errors.push("stamp body subtrees is not an object");
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
let shapeOk = true;
|
|
890
|
+
const subtrees = [];
|
|
891
|
+
for (const [ref, v] of Object.entries(raw)) {
|
|
892
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
893
|
+
errors.push(`sub-tree ${ref} is not an object`);
|
|
894
|
+
shapeOk = false;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
const s = v;
|
|
898
|
+
if (!isNonNegInt(s.first_leaf)) {
|
|
899
|
+
errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
|
|
900
|
+
shapeOk = false;
|
|
901
|
+
}
|
|
902
|
+
// A zero-leaf sub-tree is not a slice of anything, and admitting one lets
|
|
903
|
+
// an arbitrary number of empty ranges sit inside a tiling that still adds
|
|
904
|
+
// up.
|
|
905
|
+
if (!isNonNegInt(s.leaf_count) || s.leaf_count === 0) {
|
|
906
|
+
errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
|
|
907
|
+
shapeOk = false;
|
|
908
|
+
}
|
|
909
|
+
if (!isLowerHex(s.root, 64)) {
|
|
910
|
+
errors.push(`sub-tree ${ref} root is not 64 lowercase hex characters`);
|
|
911
|
+
shapeOk = false;
|
|
912
|
+
}
|
|
913
|
+
if (!shapeOk)
|
|
914
|
+
continue;
|
|
915
|
+
subtrees.push({
|
|
916
|
+
ref,
|
|
917
|
+
firstLeaf: s.first_leaf,
|
|
918
|
+
leafCount: s.leaf_count,
|
|
919
|
+
root: s.root,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
subtrees.sort((a, b) => a.firstLeaf - b.firstLeaf);
|
|
923
|
+
out.subtrees = subtrees;
|
|
924
|
+
if (!shapeOk)
|
|
925
|
+
return false;
|
|
926
|
+
const total = body.leaf_count;
|
|
927
|
+
if (!isNonNegInt(total))
|
|
928
|
+
return false; // already reported by the caller
|
|
929
|
+
const sum = subtrees.reduce((n, s) => n + s.leafCount, 0);
|
|
930
|
+
if (sum !== total) {
|
|
931
|
+
errors.push(`sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`);
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
let next = 0;
|
|
935
|
+
for (const s of subtrees) {
|
|
936
|
+
if (s.firstLeaf !== next) {
|
|
937
|
+
errors.push(`sub-tree ranges do not tile [0, ${total}): expected a range at leaf ` +
|
|
938
|
+
`${next}, found one at ${s.firstLeaf}`);
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
next += s.leafCount;
|
|
942
|
+
}
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
946
|
+
const body = b.body;
|
|
947
|
+
if (!Array.isArray(body?.stamps)) {
|
|
948
|
+
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
|
|
949
|
+
}
|
|
950
|
+
const seen = new Set();
|
|
951
|
+
const stamps = [];
|
|
952
|
+
for (const entry of body.stamps) {
|
|
953
|
+
stamps.push(await verifyStampEntry(entry, seen));
|
|
954
|
+
}
|
|
955
|
+
// --- Rollup ----------------------------------------------------------------
|
|
956
|
+
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
957
|
+
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
958
|
+
let status;
|
|
959
|
+
if (anyHardFailure) {
|
|
960
|
+
status = "failed";
|
|
961
|
+
}
|
|
962
|
+
else if (stamps.length === 0) {
|
|
963
|
+
// A bundle asserting nothing. Not a failure — nothing here is wrong — but
|
|
964
|
+
// it must never read `verified`, which a caller would take as a witness it
|
|
965
|
+
// does not have.
|
|
966
|
+
status = "partial";
|
|
967
|
+
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
968
|
+
}
|
|
969
|
+
else if (anyUnwitnessed) {
|
|
970
|
+
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
971
|
+
// sealed and unwitnessed: `retained-by-sanning`, which is a statement about
|
|
972
|
+
// our coverage and never an accusation about their evidence. It is also not
|
|
973
|
+
// `verified`, because for a stamp the witness is the entire independent
|
|
974
|
+
// part — unlike an anchor-trace checkpoint, which still carries a producer
|
|
975
|
+
// signature and an inclusion proof that stand on their own offline.
|
|
976
|
+
status = "partial";
|
|
977
|
+
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
status = "verified";
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
status,
|
|
984
|
+
specVersionOk,
|
|
985
|
+
bodyHashOk: true,
|
|
986
|
+
signatureOk: true,
|
|
987
|
+
bodyType: STAMP_TRACE_BODY_TYPE,
|
|
988
|
+
onChainChecked: false,
|
|
989
|
+
checkpoints: [],
|
|
990
|
+
events: [],
|
|
991
|
+
assertedStatus,
|
|
992
|
+
stamps,
|
|
993
|
+
errors,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
459
996
|
// How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
|
|
460
997
|
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
461
998
|
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
@@ -489,7 +1026,7 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
489
1026
|
});
|
|
490
1027
|
const traceEvents = resolveTraceEvents(bundle);
|
|
491
1028
|
if (traceEvents === undefined) {
|
|
492
|
-
errors.push(`no ${
|
|
1029
|
+
errors.push(`no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
|
|
493
1030
|
`(body_type ${JSON.stringify(verification.bodyType)})`);
|
|
494
1031
|
return empty();
|
|
495
1032
|
}
|
|
@@ -572,14 +1109,20 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
572
1109
|
// wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
|
|
573
1110
|
// recurses into `source_bundle`, so the events line up with its results.
|
|
574
1111
|
// `undefined` when there is no anchor-trace body to materialize from.
|
|
575
|
-
|
|
1112
|
+
//
|
|
1113
|
+
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
1114
|
+
// again: two answers to "which events is this bundle actually about" inside one
|
|
1115
|
+
// kernel is two implementations of one rule, and the one that drifted would be
|
|
1116
|
+
// the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
|
|
1117
|
+
// same posture as `side-content.ts`, internal and shared.
|
|
1118
|
+
export function resolveTraceEvents(bundle) {
|
|
576
1119
|
let cur = bundle;
|
|
577
1120
|
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
578
1121
|
if (cur === null || typeof cur !== "object" || Array.isArray(cur))
|
|
579
1122
|
return undefined;
|
|
580
1123
|
const b = cur;
|
|
581
1124
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
582
|
-
if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
|
|
1125
|
+
if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
583
1126
|
const body = b.body;
|
|
584
1127
|
if (body === null || typeof body !== "object" || !Array.isArray(body.events))
|
|
585
1128
|
return undefined;
|
|
@@ -672,15 +1215,8 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
672
1215
|
// (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
|
|
673
1216
|
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
674
1217
|
const sourceResult = await verifyEvidenceBundle(inline, options);
|
|
675
|
-
//
|
|
676
|
-
const sourceCheckpoints =
|
|
677
|
-
const sb = inline.body;
|
|
678
|
-
if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
|
|
679
|
-
for (const cp of sb.checkpoints) {
|
|
680
|
-
if (cp && typeof cp.tx_id === "string")
|
|
681
|
-
sourceCheckpoints.set(cp.tx_id, cp);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
1218
|
+
// Attestation data_hash binding (§5 6c).
|
|
1219
|
+
const sourceCheckpoints = await indexSourceCheckpoints(inline);
|
|
684
1220
|
// --- Step 6: embedded attestation records ---------------------------------
|
|
685
1221
|
const attestations = [];
|
|
686
1222
|
for (const rec of body.attestations) {
|
|
@@ -772,16 +1308,9 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
772
1308
|
}
|
|
773
1309
|
export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
|
|
774
1310
|
const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
const sourceCheckpoints =
|
|
778
|
-
const sb = sourceBundle.body;
|
|
779
|
-
if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
|
|
780
|
-
for (const cp of sb.checkpoints) {
|
|
781
|
-
if (cp && typeof cp.tx_id === "string")
|
|
782
|
-
sourceCheckpoints.set(cp.tx_id, cp);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
1311
|
+
// Attestation data_hash binding (§5 6c) — the SAME index construction as
|
|
1312
|
+
// verifyExportBody, deliberately shared rather than restated.
|
|
1313
|
+
const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
|
|
785
1314
|
const attestations = [];
|
|
786
1315
|
for (const rec of attestationRecords) {
|
|
787
1316
|
const r = await verifyAttestation(rec, sourceCheckpoints);
|
|
@@ -821,7 +1350,7 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
|
|
|
821
1350
|
async function verifyAttestation(rec, sourceCheckpoints) {
|
|
822
1351
|
const errs = [];
|
|
823
1352
|
const result = {
|
|
824
|
-
|
|
1353
|
+
checkpointRef: rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
|
|
825
1354
|
operator: "",
|
|
826
1355
|
gateway: null,
|
|
827
1356
|
signatureOk: false,
|
|
@@ -894,10 +1423,10 @@ async function verifyAttestation(rec, sourceCheckpoints) {
|
|
|
894
1423
|
// uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
|
|
895
1424
|
// against, §4.2), so the checkpoint's committed content hash is
|
|
896
1425
|
// SHA-256(JCS(checkpoint.envelope)).
|
|
897
|
-
const cp = sourceCheckpoints.get(result.
|
|
1426
|
+
const cp = sourceCheckpoints.get(result.checkpointRef);
|
|
898
1427
|
result.checkpointResolved = cp !== undefined;
|
|
899
1428
|
if (!cp) {
|
|
900
|
-
errs.push(`attestation
|
|
1429
|
+
errs.push(`attestation checkpoint_ref ${JSON.stringify(result.checkpointRef)} resolves to no source checkpoint`);
|
|
901
1430
|
}
|
|
902
1431
|
else {
|
|
903
1432
|
try {
|
|
@@ -949,7 +1478,7 @@ function evaluateSubjectRef(subjectRef, errs) {
|
|
|
949
1478
|
function buildVerdictObject(source, attestations) {
|
|
950
1479
|
const attByCheckpoint = new Map();
|
|
951
1480
|
for (const a of attestations) {
|
|
952
|
-
const list = attByCheckpoint.get(a.
|
|
1481
|
+
const list = attByCheckpoint.get(a.checkpointRef) ?? [];
|
|
953
1482
|
list.push({
|
|
954
1483
|
operator: a.operator,
|
|
955
1484
|
...(a.gateway ? { gateway: a.gateway } : {}),
|
|
@@ -959,7 +1488,7 @@ function buildVerdictObject(source, attestations) {
|
|
|
959
1488
|
...(a.level !== null ? { level: a.level } : {}),
|
|
960
1489
|
subject_ref_ok: a.subjectRefOk,
|
|
961
1490
|
});
|
|
962
|
-
attByCheckpoint.set(a.
|
|
1491
|
+
attByCheckpoint.set(a.checkpointRef, list);
|
|
963
1492
|
}
|
|
964
1493
|
const events = source.events.map((e) => ({
|
|
965
1494
|
event_id: e.eventId,
|
|
@@ -970,10 +1499,10 @@ function buildVerdictObject(source, attestations) {
|
|
|
970
1499
|
status: eventVerdictStatus(e),
|
|
971
1500
|
}));
|
|
972
1501
|
const checkpoints = source.checkpoints.map((c) => ({
|
|
973
|
-
|
|
1502
|
+
checkpoint_ref: c.checkpointRef,
|
|
974
1503
|
merkle_root_ok: c.merkleRootOk,
|
|
975
1504
|
on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
|
|
976
|
-
attestations: attByCheckpoint.get(c.
|
|
1505
|
+
attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
|
|
977
1506
|
}));
|
|
978
1507
|
let verified = 0;
|
|
979
1508
|
let failed = 0;
|
|
@@ -1044,7 +1573,7 @@ function projectVerdictForAgreement(v) {
|
|
|
1044
1573
|
const cp = (c ?? {});
|
|
1045
1574
|
const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
|
|
1046
1575
|
return {
|
|
1047
|
-
|
|
1576
|
+
checkpoint_ref: cp.checkpoint_ref ?? null,
|
|
1048
1577
|
merkle_root_ok: cp.merkle_root_ok ?? null,
|
|
1049
1578
|
// on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
|
|
1050
1579
|
attestations: atts.map((a) => {
|
|
@@ -1073,7 +1602,7 @@ function summarizeExport(status, source, attestations) {
|
|
|
1073
1602
|
// records a per-gateway array so a confirm/mismatch/unreachable MIX across
|
|
1074
1603
|
// gateways is preserved; the derived collapsed `onChainOk` reproduces the old
|
|
1075
1604
|
// single-boolean exactly for a single gateway.
|
|
1076
|
-
async function refetchCheckpoint(cp, gateways, fetchImpl) {
|
|
1605
|
+
async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
1077
1606
|
let expectedHex;
|
|
1078
1607
|
try {
|
|
1079
1608
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
@@ -1085,22 +1614,25 @@ async function refetchCheckpoint(cp, gateways, fetchImpl) {
|
|
|
1085
1614
|
}
|
|
1086
1615
|
const perGateway = [];
|
|
1087
1616
|
for (const gw of gateways) {
|
|
1088
|
-
perGateway.push({
|
|
1617
|
+
perGateway.push({
|
|
1618
|
+
gateway: gw,
|
|
1619
|
+
outcome: await probeGateway(anchorRef, gw, expectedHex, fetchImpl),
|
|
1620
|
+
});
|
|
1089
1621
|
}
|
|
1090
1622
|
const onChain = rollupOnChain(perGateway);
|
|
1091
1623
|
let error;
|
|
1092
1624
|
if (onChain.rollup === "mismatch") {
|
|
1093
|
-
error = `on-chain bytes at ${
|
|
1625
|
+
error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
|
|
1094
1626
|
}
|
|
1095
1627
|
else if (onChain.rollup === "unreachable") {
|
|
1096
|
-
error = `checkpoint ${
|
|
1628
|
+
error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
|
|
1097
1629
|
}
|
|
1098
1630
|
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
1099
1631
|
}
|
|
1100
1632
|
// One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
|
|
1101
1633
|
// (bytes disagree), or unreachable (no response / !ok / network error).
|
|
1102
|
-
async function probeGateway(
|
|
1103
|
-
const url = `${gateway.replace(/\/+$/, "")}/${
|
|
1634
|
+
async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
|
|
1635
|
+
const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
|
|
1104
1636
|
let resp;
|
|
1105
1637
|
try {
|
|
1106
1638
|
resp = await fetchImpl(url);
|
|
@@ -1154,6 +1686,14 @@ function getEventId(env) {
|
|
|
1154
1686
|
}
|
|
1155
1687
|
return "<unknown>";
|
|
1156
1688
|
}
|
|
1689
|
+
// The side-input presence rule lives in `side-content.ts`, stated ONCE.
|
|
1690
|
+
//
|
|
1691
|
+
// It used to live here as a private `lookupSideContent`, and `grading.ts`
|
|
1692
|
+
// carried a second, laxer answer to the same question (`hasEntry`: own +
|
|
1693
|
+
// enumerable, nothing more). On one and the same content map the grader said
|
|
1694
|
+
// PRESENT while this verifier said nothing was offered, and `gradePack` then
|
|
1695
|
+
// returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
|
|
1696
|
+
// and for what each clause of it cost.
|
|
1157
1697
|
function selectDisclosedBytes(ev, eventId, sideContent) {
|
|
1158
1698
|
const errors = [];
|
|
1159
1699
|
let inBody;
|
|
@@ -1166,19 +1706,33 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
|
|
|
1166
1706
|
}
|
|
1167
1707
|
}
|
|
1168
1708
|
let side;
|
|
1169
|
-
const
|
|
1170
|
-
if (
|
|
1171
|
-
side =
|
|
1709
|
+
const sideEntry = sideContentEntry(sideContent, eventId);
|
|
1710
|
+
if (sideEntry.value instanceof Uint8Array) {
|
|
1711
|
+
side = sideEntry.value;
|
|
1172
1712
|
}
|
|
1173
|
-
else if (typeof
|
|
1713
|
+
else if (typeof sideEntry.value === "string") {
|
|
1174
1714
|
try {
|
|
1175
|
-
side = hexToBytes(
|
|
1715
|
+
side = hexToBytes(sideEntry.value);
|
|
1176
1716
|
}
|
|
1177
1717
|
catch (e) {
|
|
1178
1718
|
errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
1179
1719
|
}
|
|
1180
1720
|
}
|
|
1181
|
-
|
|
1721
|
+
// `offered` comes from the SAME test that produced the value, never from
|
|
1722
|
+
// `value !== undefined` — that is the conflation this whole guard exists to
|
|
1723
|
+
// remove, and it was removed from ONE of the two operands. The left one
|
|
1724
|
+
// carried it unchanged: an in-body `"content": null` is `!== undefined`, so
|
|
1725
|
+
// the event was marked offered while no bytes were selected, and the kernel
|
|
1726
|
+
// split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
|
|
1727
|
+
// FAILED, on one bundle. `null` is how an optional field's absence
|
|
1728
|
+
// serializes; `inBodyContentOffered` states that once, beside the side-input
|
|
1729
|
+
// rule it mirrors.
|
|
1730
|
+
return {
|
|
1731
|
+
inBody,
|
|
1732
|
+
side,
|
|
1733
|
+
offered: inBodyContentOffered(ev.content) || sideEntry.offered,
|
|
1734
|
+
errors,
|
|
1735
|
+
};
|
|
1182
1736
|
}
|
|
1183
1737
|
function toHex(bytes) {
|
|
1184
1738
|
let out = "";
|