@sanning/proof 0.4.1 → 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 +59 -4
- package/dist/cli.js +222 -43
- package/dist/compose.js +1 -1
- package/dist/evidence.d.ts +106 -7
- package/dist/evidence.js +446 -81
- package/dist/grading.d.ts +160 -0
- package/dist/grading.js +469 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +25 -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 +285 -41
- package/src/compose.ts +1 -1
- package/src/evidence.ts +647 -92
- package/src/grading.ts +623 -0
- package/src/index.ts +49 -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`);
|
|
464
|
+
}
|
|
465
|
+
else if (typeof rawKey !== "string") {
|
|
466
|
+
evErrors.push(`event inclusion missing ${keyField}`);
|
|
284
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
|
|
@@ -341,29 +531,12 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
341
531
|
committed.push(c.hash.toLowerCase());
|
|
342
532
|
}
|
|
343
533
|
// Disclosed bytes, precedence: in-body (signed) → side input. A string
|
|
344
|
-
// side input is hex; a Uint8Array is used as-is.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
catch (e) {
|
|
351
|
-
evErrors.push(`event content is not hex: ${stringifyErr(e)}`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
let side;
|
|
355
|
-
const sideRaw = options.content?.[eventId];
|
|
356
|
-
if (sideRaw instanceof Uint8Array) {
|
|
357
|
-
side = sideRaw;
|
|
358
|
-
}
|
|
359
|
-
else if (typeof sideRaw === "string") {
|
|
360
|
-
try {
|
|
361
|
-
side = hexToBytes(sideRaw);
|
|
362
|
-
}
|
|
363
|
-
catch (e) {
|
|
364
|
-
evErrors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
534
|
+
// side input is hex; a Uint8Array is used as-is. Resolved by the SHARED
|
|
535
|
+
// selector so `disclosedContent` hands a consumer the very bytes bound
|
|
536
|
+
// here — one decode, not two that can drift apart.
|
|
537
|
+
const selection = selectDisclosedBytes(ev, eventId, options.content);
|
|
538
|
+
const { inBody, side } = selection;
|
|
539
|
+
evErrors.push(...selection.errors);
|
|
367
540
|
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
368
541
|
// Two disclosures that disagree: a genuine conflict, not undetermined.
|
|
369
542
|
contentOk = false;
|
|
@@ -414,10 +587,27 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
414
587
|
}
|
|
415
588
|
else {
|
|
416
589
|
for (const cpr of checkpointResults) {
|
|
417
|
-
const cp =
|
|
590
|
+
const cp = byKey.get(cpr.checkpointRef);
|
|
418
591
|
if (!cp)
|
|
419
592
|
continue;
|
|
420
|
-
|
|
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);
|
|
421
611
|
cpr.onChain = outcome.onChain;
|
|
422
612
|
cpr.onChainOk = outcome.onChain.onChainOk;
|
|
423
613
|
if (outcome.unavailable)
|
|
@@ -473,6 +663,144 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
473
663
|
errors,
|
|
474
664
|
};
|
|
475
665
|
}
|
|
666
|
+
// How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
|
|
667
|
+
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
668
|
+
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
669
|
+
// independently bounded rather than trusting that it ran first.
|
|
670
|
+
const MAX_EXPORT_UNWRAP = 4;
|
|
671
|
+
/**
|
|
672
|
+
* Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
|
|
673
|
+
*
|
|
674
|
+
* Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
|
|
675
|
+
* (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
|
|
676
|
+
* bytes are returned ONLY when that verification bound them to the committed
|
|
677
|
+
* `content_hash`. There is no path through this function that yields bytes
|
|
678
|
+
* whose SHA-256 is not the commitment.
|
|
679
|
+
*
|
|
680
|
+
* Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
|
|
681
|
+
* UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
|
|
682
|
+
* such a string would produce different bytes and a different hash. Callers who
|
|
683
|
+
* know their payload is text decode it themselves and own that assumption.
|
|
684
|
+
*/
|
|
685
|
+
export async function disclosedContent(bundle, options = {}) {
|
|
686
|
+
const verification = await verifyEvidenceBundle(bundle, options);
|
|
687
|
+
const events = [];
|
|
688
|
+
const content = new Map();
|
|
689
|
+
const errors = [];
|
|
690
|
+
const empty = () => ({
|
|
691
|
+
status: verification.status,
|
|
692
|
+
verification,
|
|
693
|
+
events: [],
|
|
694
|
+
content: new Map(),
|
|
695
|
+
errors: [...errors, ...verification.errors],
|
|
696
|
+
});
|
|
697
|
+
const traceEvents = resolveTraceEvents(bundle);
|
|
698
|
+
if (traceEvents === undefined) {
|
|
699
|
+
errors.push(`no ${ANCHOR_TRACE_BODY_TYPES.join(" / ")} body to materialize content from ` +
|
|
700
|
+
`(body_type ${JSON.stringify(verification.bodyType)})`);
|
|
701
|
+
return empty();
|
|
702
|
+
}
|
|
703
|
+
// Fail closed on any drift between the body we are decoding and the results
|
|
704
|
+
// we are trusting. Both must be the same events in the same order, or the
|
|
705
|
+
// "these bytes are the bytes that verified" guarantee is void — which is the
|
|
706
|
+
// only guarantee this function makes. A broken wrapper lands here too:
|
|
707
|
+
// verifyEvidenceBundle returns zero event results, so nothing is emitted.
|
|
708
|
+
if (traceEvents.length !== verification.events.length) {
|
|
709
|
+
errors.push("the bundle did not verify far enough to materialize content: " +
|
|
710
|
+
`${verification.events.length} verified event result(s) for ${traceEvents.length} body event(s)`);
|
|
711
|
+
return empty();
|
|
712
|
+
}
|
|
713
|
+
for (let i = 0; i < traceEvents.length; i++) {
|
|
714
|
+
const te = traceEvents[i];
|
|
715
|
+
const envelope = te !== null && typeof te === "object" ? te.envelope : undefined;
|
|
716
|
+
if (getEventId(envelope) !== verification.events[i].eventId) {
|
|
717
|
+
errors.push(`event alignment drift at index ${i}: the body event and its verification result ` +
|
|
718
|
+
"are not the same event; refusing to hand back any content");
|
|
719
|
+
return empty();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
for (let i = 0; i < traceEvents.length; i++) {
|
|
723
|
+
const ev = traceEvents[i];
|
|
724
|
+
const res = verification.events[i];
|
|
725
|
+
const selection = selectDisclosedBytes(ev, res.eventId, options.content);
|
|
726
|
+
const bytes = selection.inBody ?? selection.side;
|
|
727
|
+
const evErrors = [];
|
|
728
|
+
let status;
|
|
729
|
+
if (!res.ok || res.contentOk === false) {
|
|
730
|
+
// The event itself is not trustworthy (bad signature, broken inclusion,
|
|
731
|
+
// unbound checkpoint) or its disclosure contradicts the commitment.
|
|
732
|
+
status = "failed";
|
|
733
|
+
evErrors.push(res.contentOk === false
|
|
734
|
+
? "disclosed content does not bind to the committed content_hash — withheld"
|
|
735
|
+
: "the event's own proof chain did not verify — nothing here is safe to materialize");
|
|
736
|
+
evErrors.push(...res.errors);
|
|
737
|
+
}
|
|
738
|
+
else if (!selection.offered) {
|
|
739
|
+
// Minimal disclosure: the committed hash is there, the bytes are not.
|
|
740
|
+
// Distinct from empty bytes, and never an error.
|
|
741
|
+
status = "not_disclosed";
|
|
742
|
+
}
|
|
743
|
+
else if (bytes === undefined) {
|
|
744
|
+
status = "failed";
|
|
745
|
+
evErrors.push("content was disclosed for this event but is not a decodable lowercase-hex string");
|
|
746
|
+
evErrors.push(...selection.errors);
|
|
747
|
+
}
|
|
748
|
+
else if (res.contentOk !== true) {
|
|
749
|
+
// Disclosed and decodable, but there is no commitment to check it against
|
|
750
|
+
// (withheld record, or a record committing no content_hash). Unbound
|
|
751
|
+
// bytes are exactly what a consumer must not silently write out.
|
|
752
|
+
status = "undetermined";
|
|
753
|
+
evErrors.push("disclosed bytes are not bound to any committed content_hash — withheld rather than returned");
|
|
754
|
+
evErrors.push(...res.errors);
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
status = "verified";
|
|
758
|
+
}
|
|
759
|
+
const verified = status === "verified" && bytes !== undefined;
|
|
760
|
+
if (verified) {
|
|
761
|
+
if (content.has(res.eventId)) {
|
|
762
|
+
errors.push(`duplicate event_id ${JSON.stringify(res.eventId)}: the content map keeps the first ` +
|
|
763
|
+
"occurrence — read events[] for every event");
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
content.set(res.eventId, bytes);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
events.push({
|
|
770
|
+
eventId: res.eventId,
|
|
771
|
+
status,
|
|
772
|
+
bytes: verified ? bytes : null,
|
|
773
|
+
errors: evErrors,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
return { status: verification.status, verification, events, content, errors };
|
|
777
|
+
}
|
|
778
|
+
// The anchor-trace events a bundle carries, unwrapping any evidence-export
|
|
779
|
+
// wrapper(s) to the inline source bundle — mirroring how verifyEvidenceBundle
|
|
780
|
+
// recurses into `source_bundle`, so the events line up with its results.
|
|
781
|
+
// `undefined` when there is no anchor-trace body to materialize from.
|
|
782
|
+
function resolveTraceEvents(bundle) {
|
|
783
|
+
let cur = bundle;
|
|
784
|
+
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
785
|
+
if (cur === null || typeof cur !== "object" || Array.isArray(cur))
|
|
786
|
+
return undefined;
|
|
787
|
+
const b = cur;
|
|
788
|
+
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
789
|
+
if (bodyType === ANCHOR_TRACE_BODY_TYPE || bodyType === ANCHOR_TRACE_BODY_TYPE_V1) {
|
|
790
|
+
const body = b.body;
|
|
791
|
+
if (body === null || typeof body !== "object" || !Array.isArray(body.events))
|
|
792
|
+
return undefined;
|
|
793
|
+
return body.events;
|
|
794
|
+
}
|
|
795
|
+
if (bodyType !== EXPORT_BODY_TYPE)
|
|
796
|
+
return undefined;
|
|
797
|
+
const body = b.body;
|
|
798
|
+
if (body === null || typeof body !== "object")
|
|
799
|
+
return undefined;
|
|
800
|
+
cur = body.source_bundle;
|
|
801
|
+
}
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
476
804
|
// ===========================================================================
|
|
477
805
|
// Attested-evidence-export verification (evidence-export.md §5, steps 3–9).
|
|
478
806
|
// Reached from verifyEvidenceBundle AFTER the wrapper (Ed25519 issuer signature
|
|
@@ -551,15 +879,8 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
551
879
|
// (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
|
|
552
880
|
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
553
881
|
const sourceResult = await verifyEvidenceBundle(inline, options);
|
|
554
|
-
//
|
|
555
|
-
const sourceCheckpoints =
|
|
556
|
-
const sb = inline.body;
|
|
557
|
-
if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
|
|
558
|
-
for (const cp of sb.checkpoints) {
|
|
559
|
-
if (cp && typeof cp.tx_id === "string")
|
|
560
|
-
sourceCheckpoints.set(cp.tx_id, cp);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
882
|
+
// Attestation data_hash binding (§5 6c).
|
|
883
|
+
const sourceCheckpoints = await indexSourceCheckpoints(inline);
|
|
563
884
|
// --- Step 6: embedded attestation records ---------------------------------
|
|
564
885
|
const attestations = [];
|
|
565
886
|
for (const rec of body.attestations) {
|
|
@@ -651,16 +972,9 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
651
972
|
}
|
|
652
973
|
export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
|
|
653
974
|
const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
const sourceCheckpoints =
|
|
657
|
-
const sb = sourceBundle.body;
|
|
658
|
-
if (sb && typeof sb === "object" && Array.isArray(sb.checkpoints)) {
|
|
659
|
-
for (const cp of sb.checkpoints) {
|
|
660
|
-
if (cp && typeof cp.tx_id === "string")
|
|
661
|
-
sourceCheckpoints.set(cp.tx_id, cp);
|
|
662
|
-
}
|
|
663
|
-
}
|
|
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);
|
|
664
978
|
const attestations = [];
|
|
665
979
|
for (const rec of attestationRecords) {
|
|
666
980
|
const r = await verifyAttestation(rec, sourceCheckpoints);
|
|
@@ -700,7 +1014,7 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
|
|
|
700
1014
|
async function verifyAttestation(rec, sourceCheckpoints) {
|
|
701
1015
|
const errs = [];
|
|
702
1016
|
const result = {
|
|
703
|
-
|
|
1017
|
+
checkpointRef: rec && typeof rec.checkpoint_ref === "string" ? rec.checkpoint_ref : "",
|
|
704
1018
|
operator: "",
|
|
705
1019
|
gateway: null,
|
|
706
1020
|
signatureOk: false,
|
|
@@ -773,10 +1087,10 @@ async function verifyAttestation(rec, sourceCheckpoints) {
|
|
|
773
1087
|
// uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
|
|
774
1088
|
// against, §4.2), so the checkpoint's committed content hash is
|
|
775
1089
|
// SHA-256(JCS(checkpoint.envelope)).
|
|
776
|
-
const cp = sourceCheckpoints.get(result.
|
|
1090
|
+
const cp = sourceCheckpoints.get(result.checkpointRef);
|
|
777
1091
|
result.checkpointResolved = cp !== undefined;
|
|
778
1092
|
if (!cp) {
|
|
779
|
-
errs.push(`attestation
|
|
1093
|
+
errs.push(`attestation checkpoint_ref ${JSON.stringify(result.checkpointRef)} resolves to no source checkpoint`);
|
|
780
1094
|
}
|
|
781
1095
|
else {
|
|
782
1096
|
try {
|
|
@@ -828,7 +1142,7 @@ function evaluateSubjectRef(subjectRef, errs) {
|
|
|
828
1142
|
function buildVerdictObject(source, attestations) {
|
|
829
1143
|
const attByCheckpoint = new Map();
|
|
830
1144
|
for (const a of attestations) {
|
|
831
|
-
const list = attByCheckpoint.get(a.
|
|
1145
|
+
const list = attByCheckpoint.get(a.checkpointRef) ?? [];
|
|
832
1146
|
list.push({
|
|
833
1147
|
operator: a.operator,
|
|
834
1148
|
...(a.gateway ? { gateway: a.gateway } : {}),
|
|
@@ -838,7 +1152,7 @@ function buildVerdictObject(source, attestations) {
|
|
|
838
1152
|
...(a.level !== null ? { level: a.level } : {}),
|
|
839
1153
|
subject_ref_ok: a.subjectRefOk,
|
|
840
1154
|
});
|
|
841
|
-
attByCheckpoint.set(a.
|
|
1155
|
+
attByCheckpoint.set(a.checkpointRef, list);
|
|
842
1156
|
}
|
|
843
1157
|
const events = source.events.map((e) => ({
|
|
844
1158
|
event_id: e.eventId,
|
|
@@ -849,10 +1163,10 @@ function buildVerdictObject(source, attestations) {
|
|
|
849
1163
|
status: eventVerdictStatus(e),
|
|
850
1164
|
}));
|
|
851
1165
|
const checkpoints = source.checkpoints.map((c) => ({
|
|
852
|
-
|
|
1166
|
+
checkpoint_ref: c.checkpointRef,
|
|
853
1167
|
merkle_root_ok: c.merkleRootOk,
|
|
854
1168
|
on_chain: c.onChain ? toVerdictOnChain(c.onChain) : null,
|
|
855
|
-
attestations: attByCheckpoint.get(c.
|
|
1169
|
+
attestations: attByCheckpoint.get(c.checkpointRef) ?? [],
|
|
856
1170
|
}));
|
|
857
1171
|
let verified = 0;
|
|
858
1172
|
let failed = 0;
|
|
@@ -923,7 +1237,7 @@ function projectVerdictForAgreement(v) {
|
|
|
923
1237
|
const cp = (c ?? {});
|
|
924
1238
|
const atts = Array.isArray(cp.attestations) ? cp.attestations : [];
|
|
925
1239
|
return {
|
|
926
|
-
|
|
1240
|
+
checkpoint_ref: cp.checkpoint_ref ?? null,
|
|
927
1241
|
merkle_root_ok: cp.merkle_root_ok ?? null,
|
|
928
1242
|
// on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
|
|
929
1243
|
attestations: atts.map((a) => {
|
|
@@ -952,7 +1266,7 @@ function summarizeExport(status, source, attestations) {
|
|
|
952
1266
|
// records a per-gateway array so a confirm/mismatch/unreachable MIX across
|
|
953
1267
|
// gateways is preserved; the derived collapsed `onChainOk` reproduces the old
|
|
954
1268
|
// single-boolean exactly for a single gateway.
|
|
955
|
-
async function refetchCheckpoint(cp, gateways, fetchImpl) {
|
|
1269
|
+
async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
956
1270
|
let expectedHex;
|
|
957
1271
|
try {
|
|
958
1272
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
@@ -964,22 +1278,25 @@ async function refetchCheckpoint(cp, gateways, fetchImpl) {
|
|
|
964
1278
|
}
|
|
965
1279
|
const perGateway = [];
|
|
966
1280
|
for (const gw of gateways) {
|
|
967
|
-
perGateway.push({
|
|
1281
|
+
perGateway.push({
|
|
1282
|
+
gateway: gw,
|
|
1283
|
+
outcome: await probeGateway(anchorRef, gw, expectedHex, fetchImpl),
|
|
1284
|
+
});
|
|
968
1285
|
}
|
|
969
1286
|
const onChain = rollupOnChain(perGateway);
|
|
970
1287
|
let error;
|
|
971
1288
|
if (onChain.rollup === "mismatch") {
|
|
972
|
-
error = `on-chain bytes at ${
|
|
1289
|
+
error = `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`;
|
|
973
1290
|
}
|
|
974
1291
|
else if (onChain.rollup === "unreachable") {
|
|
975
|
-
error = `checkpoint ${
|
|
1292
|
+
error = `checkpoint ${anchorRef.id} unreachable on all gateways`;
|
|
976
1293
|
}
|
|
977
1294
|
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
978
1295
|
}
|
|
979
1296
|
// One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
|
|
980
1297
|
// (bytes disagree), or unreachable (no response / !ok / network error).
|
|
981
|
-
async function probeGateway(
|
|
982
|
-
const url = `${gateway.replace(/\/+$/, "")}/${
|
|
1298
|
+
async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
|
|
1299
|
+
const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
|
|
983
1300
|
let resp;
|
|
984
1301
|
try {
|
|
985
1302
|
resp = await fetchImpl(url);
|
|
@@ -1033,6 +1350,54 @@ function getEventId(env) {
|
|
|
1033
1350
|
}
|
|
1034
1351
|
return "<unknown>";
|
|
1035
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.
|
|
1361
|
+
function selectDisclosedBytes(ev, eventId, sideContent) {
|
|
1362
|
+
const errors = [];
|
|
1363
|
+
let inBody;
|
|
1364
|
+
if (typeof ev.content === "string") {
|
|
1365
|
+
try {
|
|
1366
|
+
inBody = hexToBytes(ev.content);
|
|
1367
|
+
}
|
|
1368
|
+
catch (e) {
|
|
1369
|
+
errors.push(`event content is not hex: ${stringifyErr(e)}`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
let side;
|
|
1373
|
+
const sideEntry = sideContentEntry(sideContent, eventId);
|
|
1374
|
+
if (sideEntry.value instanceof Uint8Array) {
|
|
1375
|
+
side = sideEntry.value;
|
|
1376
|
+
}
|
|
1377
|
+
else if (typeof sideEntry.value === "string") {
|
|
1378
|
+
try {
|
|
1379
|
+
side = hexToBytes(sideEntry.value);
|
|
1380
|
+
}
|
|
1381
|
+
catch (e) {
|
|
1382
|
+
errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
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
|
+
};
|
|
1400
|
+
}
|
|
1036
1401
|
function toHex(bytes) {
|
|
1037
1402
|
let out = "";
|
|
1038
1403
|
for (const x of bytes)
|