@sanning/proof 0.5.0 → 0.6.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/cli.js +212 -37
- package/dist/compose.js +1 -1
- package/dist/evidence.d.ts +77 -7
- package/dist/evidence.js +284 -66
- package/dist/grading.d.ts +160 -0
- package/dist/grading.js +469 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +17 -1
- package/dist/side-content.d.ts +36 -0
- package/dist/side-content.js +98 -0
- package/package.json +2 -1
- package/src/cli.ts +275 -35
- package/src/compose.ts +1 -1
- package/src/evidence.ts +394 -80
- package/src/grading.ts +623 -0
- package/src/index.ts +37 -0
- package/src/side-content.ts +119 -0
- package/vectors/README.md +66 -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,7 +27,21 @@ 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
|
|
@@ -149,12 +164,12 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
149
164
|
if (bodyType === EXPORT_BODY_TYPE) {
|
|
150
165
|
return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
|
|
151
166
|
}
|
|
152
|
-
if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
167
|
+
if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
153
168
|
// The wrapper is authentic but we don't know this body's structure. Per
|
|
154
169
|
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
155
170
|
// body verdict undetermined) — not a failure.
|
|
156
171
|
errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
|
|
157
|
-
`(only ${
|
|
172
|
+
`(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`);
|
|
158
173
|
return {
|
|
159
174
|
status: "partial",
|
|
160
175
|
specVersionOk,
|
|
@@ -170,31 +185,168 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
170
185
|
}
|
|
171
186
|
return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
|
|
172
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Index a source bundle's checkpoints by the key its attestations will use.
|
|
190
|
+
*
|
|
191
|
+
* The attestation join key follows the SOURCE bundle's own vocabulary — a
|
|
192
|
+
* v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
|
|
193
|
+
* alternative (always deriving a v2-style key) would silently un-resolve every
|
|
194
|
+
* attestation ever written against a v1 export: `data_hash_bound` would go
|
|
195
|
+
* false, the export would stop reaching `verified`, and nothing would say why.
|
|
196
|
+
*/
|
|
197
|
+
async function indexSourceCheckpoints(bundle) {
|
|
198
|
+
const out = new Map();
|
|
199
|
+
const sb = bundle.body;
|
|
200
|
+
const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
|
|
201
|
+
if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints))
|
|
202
|
+
return out;
|
|
203
|
+
for (const cp of sb.checkpoints) {
|
|
204
|
+
if (!cp || typeof cp !== "object")
|
|
205
|
+
continue;
|
|
206
|
+
let recordBytes;
|
|
207
|
+
try {
|
|
208
|
+
if (typeof cp.record_bytes === "string")
|
|
209
|
+
recordBytes = hexToBytes(cp.record_bytes);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
recordBytes = undefined;
|
|
213
|
+
}
|
|
214
|
+
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
215
|
+
if (keyed.key !== null && !out.has(keyed.key))
|
|
216
|
+
out.set(keyed.key, cp);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Resolve a checkpoint's join key and its witness reference, for whichever
|
|
222
|
+
* body form we are reading.
|
|
223
|
+
*
|
|
224
|
+
* ── The rule that matters, and why it is a reject rather than a preference ──
|
|
225
|
+
*
|
|
226
|
+
* A checkpoint carrying BOTH vocabularies is two answers to one question
|
|
227
|
+
* inside one signature. A reader that silently prefers one gives a different
|
|
228
|
+
* answer from a reader that prefers the other, over the same signed bytes —
|
|
229
|
+
* and both look like they verified. So a v2 body carrying `tx_id`, or a v1
|
|
230
|
+
* body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
|
|
231
|
+
* resolved. This is cheap to enforce here and impossible to enforce later.
|
|
232
|
+
*
|
|
233
|
+
* ── Why v2's key is recomputed ──
|
|
234
|
+
*
|
|
235
|
+
* In v1 the key was an asserted label matched against another asserted label;
|
|
236
|
+
* nothing tied either to the bytes, so relabelling a checkpoint and every
|
|
237
|
+
* event pointing at it left a bundle that still verified. In v2 the key IS the
|
|
238
|
+
* bytes, so the same relabel fails here. v1's behaviour is preserved exactly
|
|
239
|
+
* as it was — deliberately, because those bundles are already signed and
|
|
240
|
+
* re-interpreting them is not backwards compatibility.
|
|
241
|
+
*/
|
|
242
|
+
async function resolveCheckpointKey(cp, recordBytes, isV1) {
|
|
243
|
+
const errors = [];
|
|
244
|
+
if (isV1) {
|
|
245
|
+
if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
|
|
246
|
+
errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) — " +
|
|
247
|
+
"one bundle speaks one vocabulary");
|
|
248
|
+
return { key: null, anchorRef: null, errors };
|
|
249
|
+
}
|
|
250
|
+
if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
|
|
251
|
+
errors.push("checkpoint missing tx_id");
|
|
252
|
+
return { key: null, anchorRef: null, errors };
|
|
253
|
+
}
|
|
254
|
+
// v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
|
|
255
|
+
// through the same shape as v2 gives ONE reader-side vocabulary without
|
|
256
|
+
// touching a byte of the signed bundle.
|
|
257
|
+
return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
|
|
258
|
+
}
|
|
259
|
+
if (cp.tx_id !== undefined) {
|
|
260
|
+
errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id — one bundle speaks one vocabulary");
|
|
261
|
+
return { key: null, anchorRef: null, errors };
|
|
262
|
+
}
|
|
263
|
+
if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
|
|
264
|
+
errors.push("checkpoint missing checkpoint_ref");
|
|
265
|
+
return { key: null, anchorRef: null, errors };
|
|
266
|
+
}
|
|
267
|
+
if (recordBytes === undefined) {
|
|
268
|
+
// Without the bytes the key cannot be recomputed, so it cannot be trusted.
|
|
269
|
+
errors.push("checkpoint_ref cannot be verified — record_bytes missing or not hex");
|
|
270
|
+
return { key: null, anchorRef: null, errors };
|
|
271
|
+
}
|
|
272
|
+
const derived = await sha256Hex(recordBytes);
|
|
273
|
+
if (derived !== cp.checkpoint_ref.toLowerCase()) {
|
|
274
|
+
errors.push(`checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
|
|
275
|
+
return { key: null, anchorRef: null, errors };
|
|
276
|
+
}
|
|
277
|
+
const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
|
|
278
|
+
return { key: derived, anchorRef, errors };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Validate an optional `anchor_ref` (§5.1.1).
|
|
282
|
+
*
|
|
283
|
+
* ── An unrecognised `kind` is NOT a failure, and that is the point ──
|
|
284
|
+
*
|
|
285
|
+
* The offline verdict — signatures, payload binding, Merkle inclusion — is
|
|
286
|
+
* computed with no witness access at all. A reader that cannot resolve a
|
|
287
|
+
* witness has therefore learned NOTHING about whether the evidence was
|
|
288
|
+
* altered, and must not say otherwise. The shipped issuer did the opposite:
|
|
289
|
+
* an unrecognised signature type returned `false` rather than `null`, mapped
|
|
290
|
+
* to `tampered`, and was cached as permanent — a false accusation, produced
|
|
291
|
+
* by a coverage gap, for a company whose product is telling people whether
|
|
292
|
+
* records were altered.
|
|
293
|
+
*
|
|
294
|
+
* So this validates the SHAPE and never the vocabulary. A malformed
|
|
295
|
+
* `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
|
|
296
|
+
* the reader, and only the first is the bundle's fault.
|
|
297
|
+
*/
|
|
298
|
+
function parseAnchorRef(raw, errors) {
|
|
299
|
+
if (raw === undefined || raw === null)
|
|
300
|
+
return null; // placed, not yet witnessed
|
|
301
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
302
|
+
errors.push("anchor_ref must be an object");
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const o = raw;
|
|
306
|
+
if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
|
|
307
|
+
errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
if (typeof o.id !== "string" || o.id === "") {
|
|
311
|
+
errors.push("anchor_ref.id is missing or empty");
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
const ref = o.ref;
|
|
315
|
+
if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
|
|
316
|
+
errors.push("anchor_ref.ref must be an object when present");
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
kind: o.kind,
|
|
321
|
+
id: o.id,
|
|
322
|
+
...(ref === undefined ? {} : { ref: ref }),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
173
325
|
async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options) {
|
|
174
326
|
const body = b.body;
|
|
327
|
+
const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
|
|
175
328
|
if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
|
|
176
|
-
return malformed(
|
|
329
|
+
return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
|
|
177
330
|
}
|
|
178
331
|
const gateways = options.gateways ?? [];
|
|
179
332
|
const onChainChecked = gateways.length > 0;
|
|
180
333
|
// --- Step 3: per-checkpoint -------------------------------------------------
|
|
181
|
-
|
|
334
|
+
//
|
|
335
|
+
// ── Why the join key is resolved BEFORE anything else ──
|
|
336
|
+
//
|
|
337
|
+
// `byKey` is what binds an event to the root its inclusion proof is checked
|
|
338
|
+
// against. Get it wrong and an event proves inclusion in a tree it was never
|
|
339
|
+
// in — the one failure in this file that produces a WRONG VERIFIED, not a
|
|
340
|
+
// failed verify. Everything below is downstream of it.
|
|
341
|
+
const byKey = new Map();
|
|
342
|
+
const anchorByKey = new Map();
|
|
182
343
|
const checkpointResults = [];
|
|
183
344
|
for (const cp of body.checkpoints) {
|
|
184
345
|
const cpErrors = [];
|
|
185
346
|
let envelopeOk = false;
|
|
186
347
|
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).
|
|
348
|
+
// Decode record_bytes first: in v2 the join key is DERIVED from it, so it
|
|
349
|
+
// is an input to identity, not just to the payload binding below.
|
|
198
350
|
let recordBytes;
|
|
199
351
|
try {
|
|
200
352
|
if (typeof cp.record_bytes === "string")
|
|
@@ -203,6 +355,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
203
355
|
catch (e) {
|
|
204
356
|
cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
|
|
205
357
|
}
|
|
358
|
+
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
359
|
+
cpErrors.push(...keyed.errors);
|
|
360
|
+
if (keyed.key !== null) {
|
|
361
|
+
if (byKey.has(keyed.key)) {
|
|
362
|
+
cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
byKey.set(keyed.key, cp);
|
|
366
|
+
anchorByKey.set(keyed.key, keyed.anchorRef);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Verify the checkpoint envelope against its committed record (external
|
|
370
|
+
// commitment — the record is the payload these external envelopes bind to).
|
|
206
371
|
try {
|
|
207
372
|
const res = await verifyEnvelope(cp.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
|
|
208
373
|
// A checkpoint must fully bind: signature + the committed record matching
|
|
@@ -235,12 +400,19 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
235
400
|
}
|
|
236
401
|
}
|
|
237
402
|
checkpointResults.push({
|
|
238
|
-
|
|
403
|
+
checkpointRef: keyed.key ?? "",
|
|
404
|
+
anchorRef: keyed.anchorRef,
|
|
239
405
|
envelopeOk,
|
|
240
406
|
merkleRootOk,
|
|
241
407
|
onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
|
|
242
408
|
onChain: null, // per-gateway (§4.2), filled in step 5
|
|
243
|
-
|
|
409
|
+
// `keyed.errors` is load-bearing here, not decorative. A structurally
|
|
410
|
+
// invalid anchor_ref rides INSIDE the signature, so it is a defect we
|
|
411
|
+
// can see rather than a coverage gap we cannot — and this kernel's
|
|
412
|
+
// standing rule is that malformed is a failure while absent is
|
|
413
|
+
// undetermined (the same rule disclosed `content` follows). Without
|
|
414
|
+
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
415
|
+
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
|
|
244
416
|
errors: cpErrors,
|
|
245
417
|
});
|
|
246
418
|
}
|
|
@@ -274,18 +446,36 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
274
446
|
// Bind the event to exactly one checkpoint in the bundle, then verify
|
|
275
447
|
// RFC 9162 inclusion against THAT checkpoint's merkle_root.
|
|
276
448
|
const incl = ev.inclusion;
|
|
277
|
-
|
|
278
|
-
|
|
449
|
+
// The join key is spelled differently in the two forms and MUST NOT be read
|
|
450
|
+
// from the other one: an inclusion carrying v1's spelling inside a v2 body
|
|
451
|
+
// is a mixed bundle, and resolving it anyway is how an event silently binds
|
|
452
|
+
// to a checkpoint nobody meant.
|
|
453
|
+
const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
|
|
454
|
+
const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
|
|
455
|
+
const inclAsRecord = incl;
|
|
456
|
+
const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
|
|
457
|
+
const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
|
|
458
|
+
let cp;
|
|
279
459
|
if (!incl || typeof incl !== "object") {
|
|
280
460
|
evErrors.push("event missing inclusion proof");
|
|
281
461
|
}
|
|
282
|
-
else if (
|
|
283
|
-
evErrors.push(`event inclusion
|
|
462
|
+
else if (strayKey !== undefined) {
|
|
463
|
+
evErrors.push(`event inclusion carries ${wrongField} in a ${bodyType} body — one bundle speaks one vocabulary`);
|
|
284
464
|
}
|
|
465
|
+
else if (typeof rawKey !== "string") {
|
|
466
|
+
evErrors.push(`event inclusion missing ${keyField}`);
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
cp = byKey.get(rawKey);
|
|
470
|
+
if (cp === undefined) {
|
|
471
|
+
evErrors.push(`event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const checkpointBound = cp !== undefined;
|
|
285
475
|
let inclusionOk = false;
|
|
286
476
|
if (incl && checkpointBound && cp) {
|
|
287
477
|
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));
|
|
478
|
+
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
479
|
if (!inclusionOk)
|
|
290
480
|
evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
291
481
|
// Defense in depth: the claimed leaf_hash MUST be the hash of the
|
|
@@ -397,10 +587,27 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
397
587
|
}
|
|
398
588
|
else {
|
|
399
589
|
for (const cpr of checkpointResults) {
|
|
400
|
-
const cp =
|
|
590
|
+
const cp = byKey.get(cpr.checkpointRef);
|
|
401
591
|
if (!cp)
|
|
402
592
|
continue;
|
|
403
|
-
|
|
593
|
+
// A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
|
|
594
|
+
// that is the normal state inside a stamp interval — placed, readable,
|
|
595
|
+
// not yet witnessed — so it reports as unreachable (which downgrades
|
|
596
|
+
// the run to partial) and NEVER as a mismatch. Treating "we never
|
|
597
|
+
// anchored it yet" as "the chain disagrees" would be the same false
|
|
598
|
+
// accusation this whole change is removing.
|
|
599
|
+
if (cpr.anchorRef === null) {
|
|
600
|
+
gatewayUnavailable = true;
|
|
601
|
+
cpr.onChain = {
|
|
602
|
+
perGateway: gateways.map((gw) => ({ gateway: gw, outcome: "unreachable" })),
|
|
603
|
+
rollup: "unreachable",
|
|
604
|
+
onChainOk: null,
|
|
605
|
+
};
|
|
606
|
+
cpr.onChainOk = null;
|
|
607
|
+
cpr.errors.push("checkpoint has no anchor_ref — not yet witnessed, nothing to re-fetch");
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
|
|
404
611
|
cpr.onChain = outcome.onChain;
|
|
405
612
|
cpr.onChainOk = outcome.onChain.onChainOk;
|
|
406
613
|
if (outcome.unavailable)
|
|
@@ -489,7 +696,7 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
489
696
|
});
|
|
490
697
|
const traceEvents = resolveTraceEvents(bundle);
|
|
491
698
|
if (traceEvents === undefined) {
|
|
492
|
-
errors.push(`no ${
|
|
699
|
+
errors.push(`no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
|
|
493
700
|
`(body_type ${JSON.stringify(verification.bodyType)})`);
|
|
494
701
|
return empty();
|
|
495
702
|
}
|
|
@@ -579,7 +786,7 @@ function resolveTraceEvents(bundle) {
|
|
|
579
786
|
return undefined;
|
|
580
787
|
const b = cur;
|
|
581
788
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
582
|
-
if (bodyType === ANCHOR_TRACE_BODY_TYPE) {
|
|
789
|
+
if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
583
790
|
const body = b.body;
|
|
584
791
|
if (body === null || typeof body !== "object" || !Array.isArray(body.events))
|
|
585
792
|
return undefined;
|
|
@@ -672,15 +879,8 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
672
879
|
// (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
|
|
673
880
|
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
674
881
|
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
|
-
}
|
|
882
|
+
// Attestation data_hash binding (§5 6c).
|
|
883
|
+
const sourceCheckpoints = await indexSourceCheckpoints(inline);
|
|
684
884
|
// --- Step 6: embedded attestation records ---------------------------------
|
|
685
885
|
const attestations = [];
|
|
686
886
|
for (const rec of body.attestations) {
|
|
@@ -772,16 +972,9 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
772
972
|
}
|
|
773
973
|
export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
|
|
774
974
|
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
|
-
}
|
|
975
|
+
// Attestation data_hash binding (§5 6c) — the SAME index construction as
|
|
976
|
+
// verifyExportBody, deliberately shared rather than restated.
|
|
977
|
+
const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
|
|
785
978
|
const attestations = [];
|
|
786
979
|
for (const rec of attestationRecords) {
|
|
787
980
|
const r = await verifyAttestation(rec, sourceCheckpoints);
|
|
@@ -821,7 +1014,7 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
|
|
|
821
1014
|
async function verifyAttestation(rec, sourceCheckpoints) {
|
|
822
1015
|
const errs = [];
|
|
823
1016
|
const result = {
|
|
824
|
-
|
|
1017
|
+
checkpointRef: rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
|
|
825
1018
|
operator: "",
|
|
826
1019
|
gateway: null,
|
|
827
1020
|
signatureOk: false,
|
|
@@ -894,10 +1087,10 @@ async function verifyAttestation(rec, sourceCheckpoints) {
|
|
|
894
1087
|
// uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
|
|
895
1088
|
// against, §4.2), so the checkpoint's committed content hash is
|
|
896
1089
|
// SHA-256(JCS(checkpoint.envelope)).
|
|
897
|
-
const cp = sourceCheckpoints.get(result.
|
|
1090
|
+
const cp = sourceCheckpoints.get(result.checkpointRef);
|
|
898
1091
|
result.checkpointResolved = cp !== undefined;
|
|
899
1092
|
if (!cp) {
|
|
900
|
-
errs.push(`attestation
|
|
1093
|
+
errs.push(`attestation checkpoint_ref ${JSON.stringify(result.checkpointRef)} resolves to no source checkpoint`);
|
|
901
1094
|
}
|
|
902
1095
|
else {
|
|
903
1096
|
try {
|
|
@@ -949,7 +1142,7 @@ function evaluateSubjectRef(subjectRef, errs) {
|
|
|
949
1142
|
function buildVerdictObject(source, attestations) {
|
|
950
1143
|
const attByCheckpoint = new Map();
|
|
951
1144
|
for (const a of attestations) {
|
|
952
|
-
const list = attByCheckpoint.get(a.
|
|
1145
|
+
const list = attByCheckpoint.get(a.checkpointRef) ?? [];
|
|
953
1146
|
list.push({
|
|
954
1147
|
operator: a.operator,
|
|
955
1148
|
...(a.gateway ? { gateway: a.gateway } : {}),
|
|
@@ -959,7 +1152,7 @@ function buildVerdictObject(source, attestations) {
|
|
|
959
1152
|
...(a.level !== null ? { level: a.level } : {}),
|
|
960
1153
|
subject_ref_ok: a.subjectRefOk,
|
|
961
1154
|
});
|
|
962
|
-
attByCheckpoint.set(a.
|
|
1155
|
+
attByCheckpoint.set(a.checkpointRef, list);
|
|
963
1156
|
}
|
|
964
1157
|
const events = source.events.map((e) => ({
|
|
965
1158
|
event_id: e.eventId,
|
|
@@ -970,10 +1163,10 @@ function buildVerdictObject(source, attestations) {
|
|
|
970
1163
|
status: eventVerdictStatus(e),
|
|
971
1164
|
}));
|
|
972
1165
|
const checkpoints = source.checkpoints.map((c) => ({
|
|
973
|
-
|
|
1166
|
+
checkpoint_ref: c.checkpointRef,
|
|
974
1167
|
merkle_root_ok: c.merkleRootOk,
|
|
975
1168
|
on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
|
|
976
|
-
attestations: attByCheckpoint.get(c.
|
|
1169
|
+
attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
|
|
977
1170
|
}));
|
|
978
1171
|
let verified = 0;
|
|
979
1172
|
let failed = 0;
|
|
@@ -1044,7 +1237,7 @@ function projectVerdictForAgreement(v) {
|
|
|
1044
1237
|
const cp = (c ?? {});
|
|
1045
1238
|
const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
|
|
1046
1239
|
return {
|
|
1047
|
-
|
|
1240
|
+
checkpoint_ref: cp.checkpoint_ref ?? null,
|
|
1048
1241
|
merkle_root_ok: cp.merkle_root_ok ?? null,
|
|
1049
1242
|
// on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
|
|
1050
1243
|
attestations: atts.map((a) => {
|
|
@@ -1073,7 +1266,7 @@ function summarizeExport(status, source, attestations) {
|
|
|
1073
1266
|
// records a per-gateway array so a confirm/mismatch/unreachable MIX across
|
|
1074
1267
|
// gateways is preserved; the derived collapsed `onChainOk` reproduces the old
|
|
1075
1268
|
// single-boolean exactly for a single gateway.
|
|
1076
|
-
async function refetchCheckpoint(cp, gateways, fetchImpl) {
|
|
1269
|
+
async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
1077
1270
|
let expectedHex;
|
|
1078
1271
|
try {
|
|
1079
1272
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
@@ -1085,22 +1278,25 @@ async function refetchCheckpoint(cp, gateways, fetchImpl) {
|
|
|
1085
1278
|
}
|
|
1086
1279
|
const perGateway = [];
|
|
1087
1280
|
for (const gw of gateways) {
|
|
1088
|
-
perGateway.push({
|
|
1281
|
+
perGateway.push({
|
|
1282
|
+
gateway: gw,
|
|
1283
|
+
outcome: await probeGateway(anchorRef, gw, expectedHex, fetchImpl),
|
|
1284
|
+
});
|
|
1089
1285
|
}
|
|
1090
1286
|
const onChain = rollupOnChain(perGateway);
|
|
1091
1287
|
let error;
|
|
1092
1288
|
if (onChain.rollup === "mismatch") {
|
|
1093
|
-
error = `on-chain bytes at ${
|
|
1289
|
+
error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
|
|
1094
1290
|
}
|
|
1095
1291
|
else if (onChain.rollup === "unreachable") {
|
|
1096
|
-
error = `checkpoint ${
|
|
1292
|
+
error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
|
|
1097
1293
|
}
|
|
1098
1294
|
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
1099
1295
|
}
|
|
1100
1296
|
// One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
|
|
1101
1297
|
// (bytes disagree), or unreachable (no response / !ok / network error).
|
|
1102
|
-
async function probeGateway(
|
|
1103
|
-
const url = `${gateway.replace(/\/+$/, "")}/${
|
|
1298
|
+
async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
|
|
1299
|
+
const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
|
|
1104
1300
|
let resp;
|
|
1105
1301
|
try {
|
|
1106
1302
|
resp = await fetchImpl(url);
|
|
@@ -1154,6 +1350,14 @@ function getEventId(env) {
|
|
|
1154
1350
|
}
|
|
1155
1351
|
return "<unknown>";
|
|
1156
1352
|
}
|
|
1353
|
+
// The side-input presence rule lives in `side-content.ts`, stated ONCE.
|
|
1354
|
+
//
|
|
1355
|
+
// It used to live here as a private `lookupSideContent`, and `grading.ts`
|
|
1356
|
+
// carried a second, laxer answer to the same question (`hasEntry`: own +
|
|
1357
|
+
// enumerable, nothing more). On one and the same content map the grader said
|
|
1358
|
+
// PRESENT while this verifier said nothing was offered, and `gradePack` then
|
|
1359
|
+
// returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
|
|
1360
|
+
// and for what each clause of it cost.
|
|
1157
1361
|
function selectDisclosedBytes(ev, eventId, sideContent) {
|
|
1158
1362
|
const errors = [];
|
|
1159
1363
|
let inBody;
|
|
@@ -1166,19 +1370,33 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
|
|
|
1166
1370
|
}
|
|
1167
1371
|
}
|
|
1168
1372
|
let side;
|
|
1169
|
-
const
|
|
1170
|
-
if (
|
|
1171
|
-
side =
|
|
1373
|
+
const sideEntry = sideContentEntry(sideContent, eventId);
|
|
1374
|
+
if (sideEntry.value instanceof Uint8Array) {
|
|
1375
|
+
side = sideEntry.value;
|
|
1172
1376
|
}
|
|
1173
|
-
else if (typeof
|
|
1377
|
+
else if (typeof sideEntry.value === "string") {
|
|
1174
1378
|
try {
|
|
1175
|
-
side = hexToBytes(
|
|
1379
|
+
side = hexToBytes(sideEntry.value);
|
|
1176
1380
|
}
|
|
1177
1381
|
catch (e) {
|
|
1178
1382
|
errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
1179
1383
|
}
|
|
1180
1384
|
}
|
|
1181
|
-
|
|
1385
|
+
// `offered` comes from the SAME test that produced the value, never from
|
|
1386
|
+
// `value !== undefined` — that is the conflation this whole guard exists to
|
|
1387
|
+
// remove, and it was removed from ONE of the two operands. The left one
|
|
1388
|
+
// carried it unchanged: an in-body `"content": null` is `!== undefined`, so
|
|
1389
|
+
// the event was marked offered while no bytes were selected, and the kernel
|
|
1390
|
+
// split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
|
|
1391
|
+
// FAILED, on one bundle. `null` is how an optional field's absence
|
|
1392
|
+
// serializes; `inBodyContentOffered` states that once, beside the side-input
|
|
1393
|
+
// rule it mirrors.
|
|
1394
|
+
return {
|
|
1395
|
+
inBody,
|
|
1396
|
+
side,
|
|
1397
|
+
offered: inBodyContentOffered(ev.content) || sideEntry.offered,
|
|
1398
|
+
errors,
|
|
1399
|
+
};
|
|
1182
1400
|
}
|
|
1183
1401
|
function toHex(bytes) {
|
|
1184
1402
|
let out = "";
|