@sanning/proof 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/evidence.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Evidence-bundle verification (sanning-io/proof specs/evidence-bundle.md).
2
2
  //
3
3
  // A `sanning.evidence/v1` bundle is a SIGNED wrapper around a producer-specific
4
- // `body`. This module verifies the `sanning.anchor.trace/v1` body — the
4
+ // `body`. This module verifies the `sanning.anchor.trace/v3` body, the
5
5
  // @sanning/anchor SDK trace: a self-verifying serialization of InclusionReceipt[]
6
6
  // (per-event signed envelopes + their checkpoints + RFC 9162 inclusion proofs).
7
7
  //
@@ -15,9 +15,14 @@
15
15
  //
16
16
  // Scope: this verifies a SINGLE evidence bundle. Chain walking over
17
17
  // `previous_hash` across successive exports is consumer-layer logic above it.
18
- import { deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
18
+ import { deriveOperatorAddress, ed25519Verify, hexToBytes,
19
+ // The one hex-field test, which lives in `crypto.ts` beside the one hex
20
+ // reader (proof#146). It was a second copy here until then, with the same
21
+ // body: two copies of one rule is how two kernels drift apart one edit at a
22
+ // time, which is the whole defect class this fix is about.
23
+ isHexField, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
19
24
  import { leafHash, verifyInclusion } from "./merkle.js";
20
- import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, jcs, verifyEnvelope, } from "./verifier.js";
25
+ import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, jcs, jcsParse, verifyEnvelope, } from "./verifier.js";
21
26
  import { inBodyContentOffered, sideContentEntry, } from "./side-content.js";
22
27
  // Only the evidence-bundle major(s) this kernel verifies. A new major is a
23
28
  // deliberate one-entry addition HERE — same fail-closed discipline as the
@@ -27,33 +32,60 @@ const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
27
32
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
28
33
  // still verify), but its body cannot be re-derived, so the per-body verdict is
29
34
  // "undetermined" rather than "verified".
30
- export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
35
+ //
36
+ // The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
37
+ // `event_bytes` and moved every body that carries it to a new major. This
38
+ // kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
39
+ // `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
40
+ // types here and answer `partial`. DL-73 releases evidence made before the cut
41
+ // from any obligation to keep verifying, so there is no second field name to
42
+ // read and no dual-accept path.
43
+ export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
31
44
  /**
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.
45
+ * The name `event_bytes` replaced. Under the new majors a body entry that still
46
+ * carries it is a hard reject, never an ignored unknown field: ignored, it
47
+ * would read as a withheld record and the event would verify with nothing
48
+ * checked, the fail-open the new majors exist to prevent (governance.md §8,
49
+ * W-3).
38
50
  */
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
- ];
51
+ export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
52
+ /**
53
+ * Name an envelope the accept-set refused. Without this a bundle whose
54
+ * envelopes carry a retired profile major (a half-migrated producer still
55
+ * writing `sanning.events/v1`) fails with no reason at all. Spelled with
56
+ * `JSON.stringify` so both kernels write the same sentence.
57
+ */
58
+ function specVersionFinding(what, envelope, res) {
59
+ if (!isJsonObject(envelope) || res.specVersionOk)
60
+ return null;
61
+ if (!Object.prototype.hasOwnProperty.call(envelope, "spec_version")) {
62
+ return `${what} envelope has no spec_version`;
63
+ }
64
+ const sv = envelope.spec_version;
65
+ return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
66
+ }
67
+ /**
68
+ * The reject for an entry that still carries `record_bytes`. One sentence,
69
+ * spelled the same in both kernels, so a negative vector can assert it.
70
+ */
71
+ function legacyEventBytesError(entry, what, bodyType) {
72
+ if (!Object.prototype.hasOwnProperty.call(entry, LEGACY_EVENT_BYTES_FIELD))
73
+ return null;
74
+ return (`${what} carries ${LEGACY_EVENT_BYTES_FIELD} in a ${bodyType} body ` +
75
+ "(renamed event_bytes) -- one bundle speaks one vocabulary");
76
+ }
45
77
  // The issuer-composed attested-evidence-export body (specs/evidence-export.md
46
78
  // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
47
79
  // carries a cached verdict + the inline source bundle + embedded RSA-PSS
48
80
  // operator attestations. verifyEvidenceBundle dispatches on it to
49
81
  // verifyExportBody (§5).
50
- export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
82
+ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
51
83
  /**
52
84
  * The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
53
85
  *
54
86
  * Its own `body_type` rather than a widened anchor-trace body, and the reason
55
87
  * 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 —
88
+ * `sanning.events/v2` envelope, and a stamp has no producer signature at all:
57
89
  * Sanning places it, under DL-52, for a customer who holds no chain identity.
58
90
  * The two rejected options were making `envelope` optional and synthesising an
59
91
  * envelope that Sanning signs; the second would have made a Sanning
@@ -61,7 +93,7 @@ export const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
61
93
  * unrecoverable once witnessed.
62
94
  */
63
95
  export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
64
- export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v1";
96
+ export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
65
97
  /**
66
98
  * The version string INSIDE a witnessed stamp body — a different string from
67
99
  * the `body_type` above, and the two are easy to confuse.
@@ -86,12 +118,23 @@ function isEvidenceMajorAccepted(specVersion) {
86
118
  return minor.length > 0 && /^[0-9]+$/.test(minor);
87
119
  });
88
120
  }
89
- function malformed(error, specVersionOk = false) {
121
+ /**
122
+ * A bundle this kernel cannot read.
123
+ *
124
+ * `wrapperOk` is true only where the wrapper signature and `body_hash` were
125
+ * CHECKED and held before the body was found unreadable. It is then reported
126
+ * as held, because it did: `signatureOk` and `bodyHashOk` are statements about
127
+ * the wrapper, and a body this kernel cannot parse does not unsign it. The
128
+ * case reader decides whose pack a bundle is on exactly those two fields
129
+ * (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
130
+ * broke its own body shape read as a forger.
131
+ */
132
+ function malformed(error, specVersionOk = false, wrapperOk = false) {
90
133
  return {
91
134
  status: "malformed",
92
135
  specVersionOk,
93
- bodyHashOk: false,
94
- signatureOk: false,
136
+ bodyHashOk: wrapperOk,
137
+ signatureOk: wrapperOk,
95
138
  bodyType: null,
96
139
  onChainChecked: false,
97
140
  checkpoints: [],
@@ -100,8 +143,77 @@ function malformed(error, specVersionOk = false) {
100
143
  errors: [error],
101
144
  };
102
145
  }
103
- function stringifyErr(e) {
104
- return e instanceof Error ? e.message : String(e);
146
+ /**
147
+ * The prefixes of the exception text this kernel writes ITSELF, in the words
148
+ * the Python kernel writes too. Anything outside this set comes from the host:
149
+ * a JSON parser, a codec, a crypto library. See `thrownDetail`.
150
+ */
151
+ const KERNEL_WORDING = ["hexToBytes: "];
152
+ /**
153
+ * The detail a finding may append about a caught exception.
154
+ *
155
+ * THE RULE: a finding carries the text of a caught exception only where this
156
+ * kernel wrote that text. A finding travels into a case reading through `case`
157
+ * `reasons`, and both kernels promise one reading and one rendered sentence for
158
+ * one set of bytes; a host JSON parser says `Unexpected token 'h', "hello" is
159
+ * not valid JSON` here and `Expecting value: line 1 column 1 (char 0)` there,
160
+ * for one pack anyone can build (gate 4 on proof#140, finding 1).
161
+ *
162
+ * Returns `": <text>"` for this kernel's own wording and the empty string for
163
+ * anything else. The label at the call site already names the check that
164
+ * failed and both kernels agree on it character for character; what is dropped
165
+ * is the host's parse offset, which no reader can act on and which was never
166
+ * the same in the two kernels.
167
+ */
168
+ function thrownDetail(e) {
169
+ const text = e instanceof Error ? e.message : String(e);
170
+ return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
171
+ }
172
+ /**
173
+ * Decode `bytes` as UTF-8 the way every other kernel does: a byte sequence that
174
+ * is not valid UTF-8 THROWS, and a leading byte-order mark is kept rather than
175
+ * eaten.
176
+ *
177
+ * ONE CALLER LEFT, and it is not a parse. `jcsParse` (`verifier.ts`) reads
178
+ * every set of signed bytes in this kernel now; this function survives only for
179
+ * the canonicality comparison on the stamp path, which needs the DECODED TEXT
180
+ * of bytes `jcsParse` has already accepted, to compare against `jcs(body)`. It
181
+ * performs exactly the decode `jcsParse` performs, so the comparison cannot
182
+ * read the bytes differently from the parse that produced `body`.
183
+ *
184
+ * `new TextDecoder()` alone is neither strict nor BOM-preserving: it replaces
185
+ * every invalid sequence with U+FFFD and strips a BOM. A stamp body carrying
186
+ * one raw `0x80` inside a string value read `verified` here and `failed` there
187
+ * because of it (gate 4 on proof#140). The symmetric claim the first version of
188
+ * this comment made, that "Python's `json.loads` raises on both", is FALSE and
189
+ * is what hid gate 5's defect for a round: `json.loads(<bytes>)` strips a BOM
190
+ * too. Python's strict reader is `jcs_loads`, and the rule both kernels hold to
191
+ * is stated once, in `jcsParse`.
192
+ */
193
+ function decodeUtf8Strict(bytes) {
194
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
195
+ }
196
+ /**
197
+ * Name an envelope `public_key` or `signature` spelled outside the hex charset
198
+ * or at the wrong length (proof#142). This kernel's envelope layer already
199
+ * fails such an envelope, and the Python one's decoder accepts ASCII
200
+ * whitespace (proof#135), so both bundle verifiers name it here, in the same
201
+ * words, and the two kernels give one reading. Only a STRING is checked: a
202
+ * missing or non-string field is the envelope layer's to fail, and it does.
203
+ */
204
+ function envelopeHexError(what, envelope) {
205
+ if (!isJsonObject(envelope))
206
+ return null;
207
+ for (const [name, nibbles] of [
208
+ ["public_key", 64],
209
+ ["signature", 128],
210
+ ]) {
211
+ const value = envelope[name];
212
+ if (typeof value === "string" && !isHexField(value, nibbles)) {
213
+ return `${what} envelope ${name} is not ${nibbles} hex characters`;
214
+ }
215
+ }
216
+ return null;
105
217
  }
106
218
  /**
107
219
  * Spell a value inside a finding the way the Python kernel spells it.
@@ -116,13 +228,18 @@ function stringifyErr(e) {
116
228
  function jsonStr(value) {
117
229
  return JSON.stringify(value === undefined ? null : value) ?? "null";
118
230
  }
119
- // Verify a `sanning.evidence/v1` bundle carrying a `sanning.anchor.trace/v1` body.
231
+ // Verify a `sanning.evidence/v1` bundle and dispatch on its `body_type`.
120
232
  //
121
233
  // Returns a structured per-checkpoint + per-event result and a recomputed
122
234
  // rollup `status`. The producer's asserted `verdict.status` is surfaced
123
235
  // (`assertedStatus`) but NEVER trusted — the displayed status is the one this
124
236
  // function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
125
237
  export async function verifyEvidenceBundle(bundle, options = {}) {
238
+ return verifyBundle(bundle, options, false);
239
+ }
240
+ // `exportSource`: read the bundle as an export's source (evidence-export.md
241
+ // §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
242
+ async function verifyBundle(bundle, options, exportSource) {
126
243
  // --- Step 1: parse + reject unknown spec_version major ---------------------
127
244
  if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
128
245
  return malformed("evidence bundle is not a JSON object");
@@ -157,26 +274,47 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
157
274
  }
158
275
  // --- Step 2: verify the wrapper signature + recompute body_hash ------------
159
276
  const errors = [];
277
+ // Every wrapper hex field is read STRICTLY, before any decoder sees it
278
+ // (proof#142). The Python decoder skipped ASCII whitespace, and the wrapper
279
+ // `signature` sits outside the signed scope, so one space in it, added by
280
+ // anyone and with no key, made a pack verify there and fail here. The same
281
+ // named failure in both kernels, for each field: `public_key` and
282
+ // `signature` are 64 and 128 hex characters in either case (proof#135,
283
+ // option B), and `body_hash` is 64 LOWER-case hex characters, the form it is
284
+ // recomputed in and compared against.
160
285
  let bodyHashOk = false;
161
- try {
162
- const recomputed = await sha256Hex(utf8(jcs(b.body)));
163
- bodyHashOk = recomputed === b.body_hash;
164
- if (!bodyHashOk) {
165
- errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
166
- }
286
+ if (!isHexField(b.body_hash, 64, true)) {
287
+ errors.push("wrapper body_hash is not 64 lowercase hex characters");
167
288
  }
168
- catch (e) {
169
- errors.push(`body canonicalization failed: ${stringifyErr(e)}`);
289
+ else {
290
+ try {
291
+ const recomputed = await sha256Hex(utf8(jcs(b.body)));
292
+ bodyHashOk = recomputed === b.body_hash;
293
+ if (!bodyHashOk) {
294
+ errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
295
+ }
296
+ }
297
+ catch (e) {
298
+ errors.push(`body canonicalization failed${thrownDetail(e)}`);
299
+ }
170
300
  }
171
301
  let signatureOk = false;
172
- try {
173
- const { signature: _sig, co_signatures: _co, ...bundleForSig } = b;
174
- signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
175
- if (!signatureOk)
176
- errors.push("wrapper Ed25519 signature verification failed");
177
- }
178
- catch (e) {
179
- errors.push(`wrapper signature verification error: ${stringifyErr(e)}`);
302
+ const keyFormOk = isHexField(b.public_key, 64);
303
+ const signatureFormOk = isHexField(b.signature, 128);
304
+ if (!keyFormOk)
305
+ errors.push("wrapper public_key is not 64 hex characters");
306
+ if (!signatureFormOk)
307
+ errors.push("wrapper signature is not 128 hex characters");
308
+ if (keyFormOk && signatureFormOk) {
309
+ try {
310
+ const { signature: _sig, co_signatures: _co, ...bundleForSig } = b;
311
+ signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
312
+ if (!signatureOk)
313
+ errors.push("wrapper Ed25519 signature verification failed");
314
+ }
315
+ catch (e) {
316
+ errors.push(`wrapper signature verification error${thrownDetail(e)}`);
317
+ }
180
318
  }
181
319
  const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
182
320
  // A broken wrapper (bad signature or tampered body) is a hard failure — the
@@ -197,6 +335,25 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
197
335
  }
198
336
  // --- Body dispatch ---------------------------------------------------------
199
337
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
338
+ // Read as an export's source (evidence-export.md §2.4), the only body this
339
+ // kernel recognises is sanning.anchor.trace/v3. Any other body, even one it
340
+ // verifies on its own, is undetermined here, after the wrapper checks above.
341
+ if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
342
+ errors.push(`body_type ${JSON.stringify(bodyType)} is not an export source ` +
343
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
344
+ return {
345
+ status: "partial",
346
+ specVersionOk,
347
+ bodyHashOk,
348
+ signatureOk,
349
+ bodyType,
350
+ onChainChecked: false,
351
+ checkpoints: [],
352
+ events: [],
353
+ assertedStatus,
354
+ errors,
355
+ };
356
+ }
200
357
  // Attested-evidence-export body (evidence-export.md §5). The wrapper is
201
358
  // authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
202
359
  // hand off to the export verifier for steps 3–9.
@@ -213,12 +370,12 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
213
370
  if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
214
371
  return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
215
372
  }
216
- if (bodyType !== ANCHOR_TRACE_BODY_TYPE && bodyType !== ANCHOR_TRACE_BODY_TYPE_V1) {
373
+ if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
217
374
  // The wrapper is authentic but we don't know this body's structure. Per
218
375
  // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
219
376
  // body verdict undetermined) — not a failure.
220
377
  errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
221
- `(only ${ANCHOR_TRACE_BODY_TYPES.join(", ")}); wrapper verified, body undetermined`);
378
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
222
379
  return {
223
380
  status: "partial",
224
381
  specVersionOk,
@@ -232,99 +389,101 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
232
389
  errors,
233
390
  };
234
391
  }
235
- return verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options);
392
+ return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
236
393
  }
237
394
  /**
238
- * Index a source bundle's checkpoints by the key its attestations will use.
395
+ * Recompute an export's source verdict (evidence-export.md §5 step 4).
239
396
  *
240
- * The attestation join key follows the SOURCE bundle's own vocabulary — a
241
- * v2 source is keyed by `checkpoint_ref`, a frozen v1 source by `tx_id`. The
242
- * alternative (always deriving a v2-style key) would silently un-resolve every
243
- * attestation ever written against a v1 export: `data_hash_bound` would go
244
- * false, the export would stop reaching `verified`, and nothing would say why.
397
+ * The only export source is `sanning.anchor.trace/v3` (§2.2, §2.4). Any other
398
+ * inline source body is unrecognised *as a source*, even one this kernel
399
+ * verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
400
+ * its source verdict undetermined, so the export cannot reach `verified`. The
401
+ * source wrapper is still checked first, because a forged source is a failure
402
+ * whatever its body.
403
+ */
404
+ async function verifyExportSource(source, options) {
405
+ return verifyBundle(source, options, true);
406
+ }
407
+ /**
408
+ * Index a source bundle's checkpoints by the key its attestations use: the
409
+ * derived `checkpoint_ref`. Only a `sanning.anchor.trace/v3` source has
410
+ * checkpoints an attestation can resolve (evidence-export.md §2.4).
245
411
  */
246
412
  async function indexSourceCheckpoints(bundle) {
247
413
  const out = new Map();
414
+ if (!isJsonObject(bundle) || bundle.body_type !== ANCHOR_TRACE_BODY_TYPE)
415
+ return out;
248
416
  const sb = bundle.body;
249
- const isV1 = bundle.body_type === ANCHOR_TRACE_BODY_TYPE_V1;
250
417
  if (!sb || typeof sb !== "object" || !Array.isArray(sb.checkpoints))
251
418
  return out;
252
419
  for (const cp of sb.checkpoints) {
253
420
  if (!cp || typeof cp !== "object")
254
421
  continue;
255
- let recordBytes;
422
+ let eventBytes;
256
423
  try {
257
- if (typeof cp.record_bytes === "string")
258
- recordBytes = hexToBytes(cp.record_bytes);
424
+ if (typeof cp.event_bytes === "string")
425
+ eventBytes = hexToBytes(cp.event_bytes);
259
426
  }
260
427
  catch {
261
- recordBytes = undefined;
428
+ eventBytes = undefined;
262
429
  }
263
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
430
+ const keyed = await resolveCheckpointKey(cp, eventBytes);
264
431
  if (keyed.key !== null && !out.has(keyed.key))
265
432
  out.set(keyed.key, cp);
266
433
  }
267
434
  return out;
268
435
  }
269
436
  /**
270
- * Resolve a checkpoint's join key and its witness reference, for whichever
271
- * body form we are reading.
437
+ * Resolve a checkpoint's join key and its witness reference.
272
438
  *
273
- * ── The rule that matters, and why it is a reject rather than a preference ──
439
+ * ── Why a retired spelling is a reject rather than a preference ──
274
440
  *
275
- * A checkpoint carrying BOTH vocabularies is two answers to one question
276
- * inside one signature. A reader that silently prefers one gives a different
277
- * answer from a reader that prefers the other, over the same signed bytes —
278
- * and both look like they verified. So a v2 body carrying `tx_id`, or a v1
279
- * body carrying `anchor_ref`/`checkpoint_ref`, is rejected rather than
280
- * resolved. This is cheap to enforce here and impossible to enforce later.
441
+ * A checkpoint carrying a retired spelling beside the current one is two
442
+ * answers to one question inside one signature. A reader that silently prefers
443
+ * one gives a different answer from a reader that prefers the other, over the
444
+ * same signed bytes, and both look like they verified. So `tx_id` and
445
+ * `record_bytes` are rejected rather than resolved.
281
446
  *
282
- * ── Why v2's key is recomputed ──
447
+ * ── Why the key is recomputed ──
283
448
  *
284
- * In v1 the key was an asserted label matched against another asserted label;
285
- * nothing tied either to the bytes, so relabelling a checkpoint and every
286
- * event pointing at it left a bundle that still verified. In v2 the key IS the
287
- * bytes, so the same relabel fails here. v1's behaviour is preserved exactly
288
- * as it was — deliberately, because those bundles are already signed and
289
- * re-interpreting them is not backwards compatibility.
449
+ * The key IS the bytes: relabelling a checkpoint and every event pointing at it
450
+ * fails here, because nothing is taken from the label.
290
451
  */
291
- async function resolveCheckpointKey(cp, recordBytes, isV1) {
452
+ async function resolveCheckpointKey(cp, eventBytes) {
292
453
  const errors = [];
293
- if (isV1) {
294
- if (cp.checkpoint_ref !== undefined || cp.anchor_ref !== undefined) {
295
- errors.push("sanning.anchor.trace/v1 checkpoint carries v2 fields (checkpoint_ref/anchor_ref) -- " +
296
- "one bundle speaks one vocabulary");
297
- return { key: null, anchorRef: null, errors };
298
- }
299
- if (typeof cp.tx_id !== "string" || cp.tx_id === "") {
300
- errors.push("checkpoint missing tx_id");
301
- return { key: null, anchorRef: null, errors };
302
- }
303
- // v1's tx_id IS an Arweave transaction — its own spec said so. Surfacing it
304
- // through the same shape as v2 gives ONE reader-side vocabulary without
305
- // touching a byte of the signed bundle.
306
- return { key: cp.tx_id, anchorRef: { kind: "arweave", id: cp.tx_id }, errors };
307
- }
454
+ // Every early return below leaves before `anchor_ref` is ever looked at, so
455
+ // nothing there is "malformed": it is unexamined, which is not the same claim.
456
+ const unexamined = { key: null, anchorRef: null, anchorRefMalformed: false, errors };
308
457
  if (cp.tx_id !== undefined) {
309
- errors.push("sanning.anchor.trace/v2 checkpoint carries legacy tx_id -- one bundle speaks one vocabulary");
310
- return { key: null, anchorRef: null, errors };
458
+ errors.push(`${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`);
459
+ return unexamined;
460
+ }
461
+ const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
462
+ if (legacy !== null) {
463
+ errors.push(legacy);
464
+ return unexamined;
311
465
  }
312
466
  if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
313
467
  errors.push("checkpoint missing checkpoint_ref");
314
- return { key: null, anchorRef: null, errors };
468
+ return unexamined;
315
469
  }
316
- if (recordBytes === undefined) {
470
+ if (eventBytes === undefined) {
317
471
  // Without the bytes the key cannot be recomputed, so it cannot be trusted.
318
- errors.push("checkpoint_ref cannot be verified -- record_bytes missing or not hex");
319
- return { key: null, anchorRef: null, errors };
472
+ errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
473
+ return unexamined;
320
474
  }
321
- const derived = await sha256Hex(recordBytes);
475
+ const derived = await sha256Hex(eventBytes);
322
476
  if (derived !== cp.checkpoint_ref.toLowerCase()) {
323
- errors.push(`checkpoint_ref does not match SHA-256(record_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
324
- return { key: null, anchorRef: null, errors };
477
+ errors.push(`checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
478
+ return unexamined;
325
479
  }
326
- const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
327
- return { key: derived, anchorRef, errors };
480
+ const parsed = parseAnchorRef(cp.anchor_ref, errors);
481
+ return {
482
+ key: derived,
483
+ anchorRef: parsed.ref,
484
+ anchorRefMalformed: parsed.malformed,
485
+ errors,
486
+ };
328
487
  }
329
488
  /**
330
489
  * Validate an optional `anchor_ref` (§5.1.1).
@@ -343,39 +502,54 @@ async function resolveCheckpointKey(cp, recordBytes, isV1) {
343
502
  * So this validates the SHAPE and never the vocabulary. A malformed
344
503
  * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
345
504
  * the reader, and only the first is the bundle's fault.
505
+ *
506
+ * ── Absent and malformed are not the same null (proof#120) ──
507
+ *
508
+ * Both leave no `AnchorRef` to re-fetch, and both used to be reported the same
509
+ * way downstream: an all-`unreachable` on-chain result, although no gateway was
510
+ * asked in either case. For the ABSENT case that is at least the note
511
+ * `evidence-bundle.md` §5.1.1 reserves for "a checkpoint with no `anchor_ref`".
512
+ * For the MALFORMED case it is a manufactured outage: the bundle named a
513
+ * witness, the name was unreadable, and the reader was told the public record
514
+ * could not be reached. So the two come back as different facts, and `malformed`
515
+ * carries the difference.
346
516
  */
347
517
  function parseAnchorRef(raw, errors) {
348
- if (raw === undefined || raw === null)
349
- return null; // placed, not yet witnessed
518
+ if (raw === undefined || raw === null) {
519
+ return { ref: null, malformed: false }; // placed, not yet witnessed
520
+ }
350
521
  if (typeof raw !== "object" || Array.isArray(raw)) {
351
522
  errors.push("anchor_ref must be an object");
352
- return null;
523
+ return { ref: null, malformed: true };
353
524
  }
354
525
  const o = raw;
355
526
  if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
356
527
  errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
357
- return null;
528
+ return { ref: null, malformed: true };
358
529
  }
359
530
  if (typeof o.id !== "string" || o.id === "") {
360
531
  errors.push("anchor_ref.id is missing or empty");
361
- return null;
532
+ return { ref: null, malformed: true };
362
533
  }
363
534
  const ref = o.ref;
364
535
  if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
365
536
  errors.push("anchor_ref.ref must be an object when present");
366
- return null;
537
+ return { ref: null, malformed: true };
367
538
  }
368
539
  return {
369
- kind: o.kind,
370
- id: o.id,
371
- ...(ref === undefined ? {} : { ref: ref }),
540
+ ref: {
541
+ kind: o.kind,
542
+ id: o.id,
543
+ ...(ref === undefined ? {} : { ref: ref }),
544
+ },
545
+ malformed: false,
372
546
  };
373
547
  }
374
- async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, errors, options) {
548
+ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options) {
375
549
  const body = b.body;
376
- const isV1 = bodyType === ANCHOR_TRACE_BODY_TYPE_V1;
550
+ const bodyType = ANCHOR_TRACE_BODY_TYPE;
377
551
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
378
- return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
552
+ return malformed(`${bodyType} body must have checkpoints[] and events[]`, true, true);
379
553
  }
380
554
  const gateways = options.gateways ?? [];
381
555
  const onChainChecked = gateways.length > 0;
@@ -390,6 +564,11 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
390
564
  const byKey = new Map();
391
565
  const anchorByKey = new Map();
392
566
  const checkpointResults = [];
567
+ // proof#120. Kept beside the results rather than on them: "the bundle named
568
+ // a witness we could not read" is an input defect the checkpoint's own
569
+ // `errors` already states, not a new dimension of the published verdict, and
570
+ // `CheckpointResult` is a shape downstream surfaces render.
571
+ const malformedAnchorRef = new Set();
393
572
  for (const rawCp of body.checkpoints) {
394
573
  // `checkpoints[]` is attacker-shaped, and a verifier must never raise on
395
574
  // adversarial input: a null entry used to reach `cp.tx_id` and hand the
@@ -416,17 +595,17 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
416
595
  const cpErrors = [];
417
596
  let envelopeOk = false;
418
597
  let merkleRootOk = false;
419
- // Decode record_bytes first: in v2 the join key is DERIVED from it, so it
598
+ // Decode event_bytes first: in v2 the join key is DERIVED from it, so it
420
599
  // is an input to identity, not just to the payload binding below.
421
- let recordBytes;
600
+ let eventBytes;
422
601
  try {
423
- if (typeof cp.record_bytes === "string")
424
- recordBytes = hexToBytes(cp.record_bytes);
602
+ if (typeof cp.event_bytes === "string")
603
+ eventBytes = hexToBytes(cp.event_bytes);
425
604
  }
426
605
  catch (e) {
427
- cpErrors.push(`checkpoint record_bytes is not hex: ${stringifyErr(e)}`);
606
+ cpErrors.push(`checkpoint event_bytes is not hex${thrownDetail(e)}`);
428
607
  }
429
- const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
608
+ const keyed = await resolveCheckpointKey(cp, eventBytes);
430
609
  cpErrors.push(...keyed.errors);
431
610
  // A duplicate key is a HARD failure, not a note. `byKey` below keeps the
432
611
  // FIRST occurrence, so a second checkpoint claiming the same key is
@@ -440,7 +619,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
440
619
  let duplicateKey = false;
441
620
  if (keyed.key !== null) {
442
621
  if (byKey.has(keyed.key)) {
443
- cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
622
+ cpErrors.push(`duplicate checkpoint checkpoint_ref ${keyed.key}`);
444
623
  duplicateKey = true;
445
624
  }
446
625
  else {
@@ -451,16 +630,20 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
451
630
  // Verify the checkpoint envelope against its committed record (external
452
631
  // commitment — the record is the payload these external envelopes bind to).
453
632
  try {
454
- const res = await verifyEnvelope(cp.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
633
+ const res = await verifyEnvelope(cp.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
455
634
  // A checkpoint must fully bind: signature + the committed record matching
456
635
  // payload_hash. An undetermined binding (no record) is not enough to
457
636
  // anchor the events' inclusion claims to a trustworthy root.
458
- envelopeOk = res.ok && res.payloadHashOk === true;
637
+ const hexError = envelopeHexError("checkpoint", cp.envelope);
638
+ envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
459
639
  // See the event loop below: a degenerate envelope is a malformed input,
460
640
  // not a failed check (proof#76).
461
641
  if (!isJsonObject(cp.envelope)) {
462
642
  cpErrors.push("checkpoint envelope is not a JSON object");
463
643
  }
644
+ else if (hexError !== null) {
645
+ cpErrors.push(hexError);
646
+ }
464
647
  else if (!res.signatureOk) {
465
648
  cpErrors.push("checkpoint envelope signature failed");
466
649
  }
@@ -471,25 +654,28 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
471
654
  for (const m of res.errors)
472
655
  if (m.startsWith("malformed "))
473
656
  cpErrors.push(`checkpoint envelope ${m}`);
657
+ const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
658
+ if (specFinding !== null)
659
+ cpErrors.push(specFinding);
474
660
  // WHY the binding is undetermined decides which sentence is true.
475
- // record_bytes really absent is one thing; record_bytes present and
661
+ // event_bytes really absent is one thing; event_bytes present and
476
662
  // unbindable (a degenerate envelope, unreadable hex) is another, and
477
663
  // naming the wrong cause sends a reader to look for a field that is
478
664
  // sitting right there (proof#76).
479
665
  if (res.payloadHashOk === null) {
480
- cpErrors.push(recordBytes === undefined
481
- ? "checkpoint record_bytes missing -- root untrusted"
666
+ cpErrors.push(eventBytes === undefined
667
+ ? "checkpoint event_bytes missing -- root untrusted"
482
668
  : "checkpoint record binding not checked: root untrusted");
483
669
  }
484
670
  }
485
671
  catch (e) {
486
- cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
672
+ cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
487
673
  }
488
674
  // The committed record's merkle_root must equal the checkpoint's claimed
489
675
  // merkle_root (recompute, don't trust the bundle's top-level claim).
490
- if (recordBytes !== undefined) {
676
+ if (eventBytes !== undefined) {
491
677
  try {
492
- const record = JSON.parse(new TextDecoder().decode(recordBytes));
678
+ const record = jcsParse(eventBytes);
493
679
  const committedRoot = record.event?.merkle_root;
494
680
  merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
495
681
  if (!merkleRootOk) {
@@ -498,7 +684,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
498
684
  }
499
685
  }
500
686
  catch (e) {
501
- cpErrors.push(`checkpoint record is not JSON: ${stringifyErr(e)}`);
687
+ cpErrors.push(`checkpoint record is not JSON${thrownDetail(e)}`);
502
688
  }
503
689
  }
504
690
  checkpointResults.push({
@@ -517,6 +703,9 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
517
703
  ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
518
704
  errors: cpErrors,
519
705
  });
706
+ if (keyed.anchorRefMalformed) {
707
+ malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
708
+ }
520
709
  }
521
710
  // --- Step 4: per-event ------------------------------------------------------
522
711
  const eventResults = [];
@@ -542,17 +731,41 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
542
731
  const eventId = getEventId(ev.envelope);
543
732
  let envelopeOk = false;
544
733
  let payloadBindingOk = null;
545
- let recordBytes;
734
+ // A retired field name under this major is a reject, never an ignored
735
+ // unknown field: ignored, it reads as a withheld record (W-3).
736
+ const legacyError = legacyEventBytesError(ev, "event", bodyType);
737
+ if (legacyError !== null)
738
+ evErrors.push(legacyError);
739
+ // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
740
+ //
741
+ // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
742
+ // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
743
+ // line down. So an event carrying garbage in place of its record used to
744
+ // reach the same tri-state as an event carrying nothing: `payloadHashOk`
745
+ // null, `ok` true, a leading tick on the line, and a `verified` rollup over
746
+ // a record nobody could read. The only thing wrong with the pack was the
747
+ // bytes, and it was the one thing the verdict did not say.
748
+ //
749
+ // A record the producer chose not to disclose is the customer exercising
750
+ // minimum disclosure. A record that is present and unreadable is a defect
751
+ // in the artifact, and this kernel's standing rule (the same one
752
+ // `checkpoints[]` already applies through `keyed.errors`) is that a defect
753
+ // we can see fails.
754
+ let eventBytes;
755
+ let eventBytesMalformed = false;
546
756
  try {
547
- if (typeof ev.record_bytes === "string")
548
- recordBytes = hexToBytes(ev.record_bytes);
757
+ if (typeof ev.event_bytes === "string")
758
+ eventBytes = hexToBytes(ev.event_bytes);
549
759
  }
550
760
  catch (e) {
551
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
761
+ eventBytesMalformed = true;
762
+ evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
552
763
  }
553
764
  try {
554
- const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
555
- envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
765
+ const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
766
+ const hexError = envelopeHexError("event", ev.envelope);
767
+ // ok tolerates an undetermined binding (withheld record)
768
+ envelopeOk = res.ok && hexError === null;
556
769
  payloadBindingOk = res.payloadHashOk;
557
770
  // P-10: report what could not be determined, never a fault that was never
558
771
  // observed. There is no envelope here, so no signature was checked and no
@@ -564,6 +777,9 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
564
777
  if (!isJsonObject(ev.envelope)) {
565
778
  evErrors.push("event envelope is not a JSON object");
566
779
  }
780
+ else if (hexError !== null) {
781
+ evErrors.push(hexError);
782
+ }
567
783
  else if (!res.signatureOk) {
568
784
  evErrors.push("event envelope signature failed");
569
785
  }
@@ -574,19 +790,21 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
574
790
  for (const m of res.errors)
575
791
  if (m.startsWith("malformed "))
576
792
  evErrors.push(`event envelope ${m}`);
793
+ const specFinding = specVersionFinding("event", ev.envelope, res);
794
+ if (specFinding !== null)
795
+ evErrors.push(specFinding);
577
796
  }
578
797
  catch (e) {
579
- evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
798
+ evErrors.push(`event envelope verify error${thrownDetail(e)}`);
580
799
  }
581
800
  // Bind the event to exactly one checkpoint in the bundle, then verify
582
801
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
583
802
  const incl = ev.inclusion;
584
- // The join key is spelled differently in the two forms and MUST NOT be read
585
- // from the other one: an inclusion carrying v1's spelling inside a v2 body
586
- // is a mixed bundle, and resolving it anyway is how an event silently binds
587
- // to a checkpoint nobody meant.
588
- const keyField = isV1 ? "checkpoint_tx_id" : "checkpoint_ref";
589
- const wrongField = isV1 ? "checkpoint_ref" : "checkpoint_tx_id";
803
+ // An inclusion carrying the retired `checkpoint_tx_id` spelling is a mixed
804
+ // bundle, and resolving it anyway is how an event silently binds to a
805
+ // checkpoint nobody meant.
806
+ const keyField = "checkpoint_ref";
807
+ const wrongField = "checkpoint_tx_id";
590
808
  const inclAsRecord = incl;
591
809
  const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
592
810
  const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
@@ -631,11 +849,11 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
631
849
  }
632
850
  }
633
851
  catch (e) {
634
- evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
852
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
635
853
  }
636
854
  }
637
855
  // Content (raw-log) binding — shared with every other event-carrying body.
638
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
856
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
639
857
  const contentOk = contentRes.contentOk;
640
858
  evErrors.push(...contentRes.errors);
641
859
  eventResults.push({
@@ -650,13 +868,22 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
650
868
  // present checkpoint, and its disclosed content did not MISMATCH. A
651
869
  // withheld record or undisclosed content (binding/content null) does not
652
870
  // fail it.
653
- ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false,
871
+ // `!eventBytesMalformed`: present-and-unreadable record bytes are a
872
+ // defect in the artifact, never a disclosure choice (proof#85).
873
+ ok: envelopeOk &&
874
+ inclusionOk &&
875
+ checkpointBound &&
876
+ contentOk !== false &&
877
+ legacyError === null &&
878
+ !eventBytesMalformed,
654
879
  errors: evErrors,
655
880
  });
656
881
  }
657
882
  // --- Step 5: optional on-chain re-fetch ------------------------------------
658
883
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
659
884
  let gatewayUnavailable = false;
885
+ let uncheckedKind = false;
886
+ let uncheckedId = false;
660
887
  if (onChainChecked) {
661
888
  if (typeof fetchImpl !== "function") {
662
889
  gatewayUnavailable = true;
@@ -673,6 +900,20 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
673
900
  // the run to partial) and NEVER as a mismatch. Treating "we never
674
901
  // anchored it yet" as "the chain disagrees" would be the same false
675
902
  // accusation this whole change is removing.
903
+ // proof#120. An `anchor_ref` that failed structural validation is not
904
+ // an absent one. Nothing was fetched, so the witness dimension is NOT
905
+ // CHECKED — `onChain: null`, exactly as when the caller supplied no
906
+ // adapter — and it must not raise `gatewayUnavailable`, which is what
907
+ // puts "could not be re-fetched from the supplied gateways" on the
908
+ // bundle. Reporting an outage nobody observed is a claim about the
909
+ // public record made out of a defect in the artifact, and verify-page
910
+ // renders it word for word. The run still fails: the structural error
911
+ // is already in `cpr.errors` and already in `ok` above.
912
+ if (cpr.anchorRef === null && malformedAnchorRef.has(cpr)) {
913
+ cpr.onChain = null;
914
+ cpr.onChainOk = null;
915
+ continue;
916
+ }
676
917
  if (cpr.anchorRef === null) {
677
918
  gatewayUnavailable = true;
678
919
  cpr.onChain = {
@@ -686,9 +927,13 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
686
927
  }
687
928
  const outcome = await refetchCheckpoint(cp, cpr.anchorRef, gateways, fetchImpl);
688
929
  cpr.onChain = outcome.onChain;
689
- cpr.onChainOk = outcome.onChain.onChainOk;
930
+ cpr.onChainOk = outcome.onChain?.onChainOk ?? null;
690
931
  if (outcome.unavailable)
691
932
  gatewayUnavailable = true;
933
+ if (outcome.unchecked === "kind")
934
+ uncheckedKind = true;
935
+ if (outcome.unchecked === "id")
936
+ uncheckedId = true;
692
937
  if (outcome.error)
693
938
  cpr.errors.push(outcome.error);
694
939
  // Fold the on-chain dimension in: a MISMATCH (false) fails the
@@ -719,12 +964,16 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
719
964
  if (anyHardFailure) {
720
965
  status = "failed";
721
966
  }
722
- else if (gatewayUnavailable) {
967
+ else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
723
968
  // Every offline check passed and nothing MISMATCHED, but a requested
724
969
  // on-chain confirmation could not be made — the bundle is offline-sound,
725
970
  // its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
726
971
  status = "partial";
727
- errors.push("one or more checkpoints could not be re-fetched from the supplied gateways");
972
+ pushRefetchGapNotes(errors, "checkpoints", {
973
+ unavailable: gatewayUnavailable,
974
+ kind: uncheckedKind,
975
+ id: uncheckedId,
976
+ });
728
977
  }
729
978
  else if (anyWithheldRecord) {
730
979
  // Everything that COULD be checked passed; some record bytes were withheld
@@ -815,7 +1064,7 @@ function isNonNegInt(n) {
815
1064
  // ===========================================================================
816
1065
  // Disclosed-content binding, shared by every body that carries events.
817
1066
  //
818
- // Extracted for `sanning.stamp.evidence/v1` (§5.3) rather than copied. The
1067
+ // Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
819
1068
  // rawLog -> content_hash link is the one step the chain
820
1069
  // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
821
1070
  // not otherwise cover, and a second copy of it is a second place for the
@@ -824,14 +1073,14 @@ function isNonNegInt(n) {
824
1073
  // disclosed or nothing is committed; false ONLY on a real mismatch or an
825
1074
  // in-body vs side-input disagreement.
826
1075
  // ===========================================================================
827
- async function verifyDisclosedContent(ev, eventId, recordBytes, sideContent) {
1076
+ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
828
1077
  const evErrors = [];
829
1078
  let contentOk = null;
830
1079
  const committed = [];
831
1080
  let recordContentHash;
832
- if (recordBytes !== undefined) {
1081
+ if (eventBytes !== undefined) {
833
1082
  try {
834
- const record = JSON.parse(new TextDecoder().decode(recordBytes));
1083
+ const record = jcsParse(eventBytes);
835
1084
  const ch = record.event?.content_hash;
836
1085
  if (typeof ch === "string" && ch)
837
1086
  recordContentHash = ch.toLowerCase();
@@ -912,7 +1161,7 @@ async function verifyStampEntry(raw, seen) {
912
1161
  bytes = hexToBytes(e.stamp_bytes);
913
1162
  }
914
1163
  catch (err) {
915
- errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
1164
+ errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
916
1165
  return { result: base };
917
1166
  }
918
1167
  // Gated by `isLowerHex` above, so this is already the exact lowercase hex of
@@ -944,7 +1193,7 @@ async function verifyStampEntry(raw, seen) {
944
1193
  // --- the body itself ------------------------------------------------------
945
1194
  let body;
946
1195
  try {
947
- const parsed = JSON.parse(new TextDecoder().decode(bytes));
1196
+ const parsed = jcsParse(bytes);
948
1197
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
949
1198
  errors.push("stamp body is not a JSON object");
950
1199
  return { result: base, witnessedHex };
@@ -954,13 +1203,13 @@ async function verifyStampEntry(raw, seen) {
954
1203
  // every submit attempt for exactly this reason: a non-canonical body makes
955
1204
  // a retry a DIFFERENT submission, and makes every independent re-derivation
956
1205
  // of the stamp disagree with the one that was witnessed.
957
- if (jcs(body) !== new TextDecoder().decode(bytes)) {
1206
+ if (jcs(body) !== decodeUtf8Strict(bytes)) {
958
1207
  errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
959
1208
  return { result: base, witnessedHex };
960
1209
  }
961
1210
  }
962
1211
  catch (err) {
963
- errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
1212
+ errors.push(`stamp body is not JSON${thrownDetail(err)}`);
964
1213
  return { result: base, witnessedHex };
965
1214
  }
966
1215
  if (body.stamp_version !== STAMP_BODY_VERSION) {
@@ -998,7 +1247,10 @@ async function verifyStampEntry(raw, seen) {
998
1247
  }
999
1248
  // --- the sub-tree ranges --------------------------------------------------
1000
1249
  base.subtreesOk = checkSubtrees(body, base, errors);
1001
- base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
1250
+ // A stamp needs no malformed/absent split: the §5.2 re-fetch path already
1251
+ // leaves `onChain` null whenever there is no `AnchorRef`, so it never
1252
+ // synthesised the outage proof#120 is about.
1253
+ base.anchorRef = parseAnchorRef(e.anchor_ref, errors).ref;
1002
1254
  base.ok =
1003
1255
  base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
1004
1256
  return { result: base, witnessedHex };
@@ -1080,7 +1332,7 @@ function checkSubtrees(body, out, errors) {
1080
1332
  async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, options) {
1081
1333
  const body = b.body;
1082
1334
  if (!Array.isArray(body?.stamps)) {
1083
- return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
1335
+ return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true, true);
1084
1336
  }
1085
1337
  const seen = new Set();
1086
1338
  const stamps = [];
@@ -1097,7 +1349,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1097
1349
  // other — asks every consumer to remember which wrapper it is holding before
1098
1350
  // it can trust `onChain: null`, and that is exactly the kind of per-body
1099
1351
  // exception a reader eventually gets wrong.
1100
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
1352
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
1101
1353
  // --- Rollup ----------------------------------------------------------------
1102
1354
  const anyHardFailure = stamps.some((s) => !s.ok);
1103
1355
  const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
@@ -1112,13 +1364,15 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1112
1364
  status = "partial";
1113
1365
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1114
1366
  }
1115
- else if (gatewayUnavailable || anyUnwitnessed) {
1367
+ else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
1116
1368
  // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
1117
1369
  // for why one note is not enough when there are two different gaps.
1118
1370
  status = "partial";
1119
- if (gatewayUnavailable) {
1120
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1121
- }
1371
+ pushRefetchGapNotes(errors, "stamps", {
1372
+ unavailable: gatewayUnavailable,
1373
+ kind: uncheckedKind,
1374
+ id: uncheckedId,
1375
+ });
1122
1376
  if (anyUnwitnessed) {
1123
1377
  // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1124
1378
  // sealed and unwitnessed: `retained-by-sanning`, which is a statement
@@ -1148,7 +1402,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1148
1402
  };
1149
1403
  }
1150
1404
  // ===========================================================================
1151
- // `sanning.stamp.evidence/v1` — the two halves, bound (evidence-bundle.md §5.3)
1405
+ // `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
1152
1406
  //
1153
1407
  // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1154
1408
  // the sub-tree partition check). The event half reuses the same primitives the
@@ -1165,7 +1419,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1165
1419
  async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options) {
1166
1420
  const body = b.body;
1167
1421
  if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
1168
- return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
1422
+ return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true, true);
1169
1423
  }
1170
1424
  // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
1171
1425
  const seen = new Set();
@@ -1217,17 +1471,41 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1217
1471
  const eventId = getEventId(ev.envelope);
1218
1472
  let envelopeOk = false;
1219
1473
  let payloadBindingOk = null;
1220
- let recordBytes;
1474
+ // A retired field name under this major is a reject, never an ignored
1475
+ // unknown field: ignored, it reads as a withheld record (W-3).
1476
+ const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
1477
+ if (legacyError !== null)
1478
+ evErrors.push(legacyError);
1479
+ // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
1480
+ //
1481
+ // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
1482
+ // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
1483
+ // line down. So an event carrying garbage in place of its record used to
1484
+ // reach the same tri-state as an event carrying nothing: `payloadHashOk`
1485
+ // null, `ok` true, a leading tick on the line, and a `verified` rollup over
1486
+ // a record nobody could read. The only thing wrong with the pack was the
1487
+ // bytes, and it was the one thing the verdict did not say.
1488
+ //
1489
+ // A record the producer chose not to disclose is the customer exercising
1490
+ // minimum disclosure. A record that is present and unreadable is a defect
1491
+ // in the artifact, and this kernel's standing rule (the same one
1492
+ // `checkpoints[]` already applies through `keyed.errors`) is that a defect
1493
+ // we can see fails.
1494
+ let eventBytes;
1495
+ let eventBytesMalformed = false;
1221
1496
  try {
1222
- if (typeof ev.record_bytes === "string")
1223
- recordBytes = hexToBytes(ev.record_bytes);
1497
+ if (typeof ev.event_bytes === "string")
1498
+ eventBytes = hexToBytes(ev.event_bytes);
1224
1499
  }
1225
1500
  catch (e) {
1226
- evErrors.push(`event record_bytes is not hex: ${stringifyErr(e)}`);
1501
+ eventBytesMalformed = true;
1502
+ evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1227
1503
  }
1228
1504
  try {
1229
- const res = await verifyEnvelope(ev.envelope, recordBytes !== undefined ? { payloadBytes: recordBytes } : {});
1230
- envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1505
+ const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
1506
+ const hexError = envelopeHexError("event", ev.envelope);
1507
+ // tolerates an undetermined binding (withheld record)
1508
+ envelopeOk = res.ok && hexError === null;
1231
1509
  payloadBindingOk = res.payloadHashOk;
1232
1510
  // P-10: report what could not be determined, never a fault that was never
1233
1511
  // observed. There is no envelope here, so no signature was checked and no
@@ -1239,6 +1517,9 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1239
1517
  if (!isJsonObject(ev.envelope)) {
1240
1518
  evErrors.push("event envelope is not a JSON object");
1241
1519
  }
1520
+ else if (hexError !== null) {
1521
+ evErrors.push(hexError);
1522
+ }
1242
1523
  else if (!res.signatureOk) {
1243
1524
  evErrors.push("event envelope signature failed");
1244
1525
  }
@@ -1249,9 +1530,12 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1249
1530
  for (const m of res.errors)
1250
1531
  if (m.startsWith("malformed "))
1251
1532
  evErrors.push(`event envelope ${m}`);
1533
+ const specFinding = specVersionFinding("event", ev.envelope, res);
1534
+ if (specFinding !== null)
1535
+ evErrors.push(specFinding);
1252
1536
  }
1253
1537
  catch (e) {
1254
- evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1538
+ evErrors.push(`event envelope verify error${thrownDetail(e)}`);
1255
1539
  }
1256
1540
  // --- The join (§5.3.3 rule 1) -------------------------------------------
1257
1541
  const incl = ev.inclusion;
@@ -1302,11 +1586,11 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1302
1586
  }
1303
1587
  }
1304
1588
  catch (e) {
1305
- evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
1589
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
1306
1590
  }
1307
1591
  }
1308
1592
  }
1309
- const contentRes = await verifyDisclosedContent(ev, eventId, recordBytes, options.content);
1593
+ const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
1310
1594
  const contentOk = contentRes.contentOk;
1311
1595
  evErrors.push(...contentRes.errors);
1312
1596
  eventResults.push({
@@ -1317,12 +1601,19 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1317
1601
  // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
1318
1602
  checkpointBound: stampBound,
1319
1603
  contentOk,
1320
- ok: envelopeOk && inclusionOk && stampBound && contentOk !== false,
1604
+ // `!eventBytesMalformed`: present-and-unreadable record bytes are a
1605
+ // defect in the artifact, never a disclosure choice (proof#85).
1606
+ ok: envelopeOk &&
1607
+ inclusionOk &&
1608
+ stampBound &&
1609
+ contentOk !== false &&
1610
+ legacyError === null &&
1611
+ !eventBytesMalformed,
1321
1612
  errors: evErrors,
1322
1613
  });
1323
1614
  }
1324
1615
  // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1325
- const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
1616
+ const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
1326
1617
  // --- Rollup ---------------------------------------------------------------
1327
1618
  // Rule 4: a declared event MUST be checked. `events[]` present and unverified
1328
1619
  // is the false green this body exists to prevent, so an event that did not
@@ -1337,16 +1628,27 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1337
1628
  status = "partial";
1338
1629
  errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
1339
1630
  }
1340
- else if (gatewayUnavailable || anyUnwitnessed) {
1341
- // Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
1342
- // witnessed and a witness we could not reach are different gaps with
1343
- // different fixes — one waits for the interval, the other for the network —
1344
- // and an auditor told only about the second would read the first as fine.
1345
- // The order is fixed so the two kernels emit the same list.
1631
+ else if (eventResults.length === 0 ||
1632
+ gatewayUnavailable ||
1633
+ uncheckedKind ||
1634
+ uncheckedId ||
1635
+ anyUnwitnessed) {
1636
+ // Each holds the bundle at `partial`, and EACH gets said. A pack with no
1637
+ // events, a stamp nobody witnessed and a witness we could not reach are
1638
+ // different gaps with different fixes, and an auditor told only about one
1639
+ // would read the others as fine. The order is fixed so the two kernels
1640
+ // emit the same list.
1346
1641
  status = "partial";
1347
- if (gatewayUnavailable) {
1348
- errors.push("one or more stamps could not be re-fetched from the supplied gateways");
1349
- }
1642
+ if (eventResults.length === 0) {
1643
+ // §5.3.2: an events-empty body is exactly a §5.2 stamp trace, and is
1644
+ // partial, never verified. It says nothing about any event.
1645
+ errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`);
1646
+ }
1647
+ pushRefetchGapNotes(errors, "stamps", {
1648
+ unavailable: gatewayUnavailable,
1649
+ kind: uncheckedKind,
1650
+ id: uncheckedId,
1651
+ });
1350
1652
  if (anyUnwitnessed) {
1351
1653
  // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1352
1654
  // the independent part, so the events resting on it inherit `partial`.
@@ -1407,14 +1709,17 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1407
1709
  async function refetchStamps(stamps, witnessed, options, errors) {
1408
1710
  const gateways = options.gateways ?? [];
1409
1711
  const onChainChecked = gateways.length > 0;
1712
+ const none = { gatewayUnavailable: false, uncheckedKind: false, uncheckedId: false };
1410
1713
  if (!onChainChecked)
1411
- return { onChainChecked, gatewayUnavailable: false };
1714
+ return { onChainChecked, ...none };
1412
1715
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1413
1716
  if (typeof fetchImpl !== "function") {
1414
1717
  errors.push("on-chain re-fetch requested but no fetch implementation is available");
1415
- return { onChainChecked, gatewayUnavailable: true };
1718
+ return { onChainChecked, ...none, gatewayUnavailable: true };
1416
1719
  }
1417
1720
  let gatewayUnavailable = false;
1721
+ let uncheckedKind = false;
1722
+ let uncheckedId = false;
1418
1723
  for (let i = 0; i < stamps.length; i++) {
1419
1724
  const st = stamps[i];
1420
1725
  const witnessedHex = witnessed[i];
@@ -1424,9 +1729,13 @@ async function refetchStamps(stamps, witnessed, options, errors) {
1424
1729
  continue;
1425
1730
  const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
1426
1731
  st.onChain = outcome.onChain;
1427
- st.onChainOk = outcome.onChain.onChainOk;
1732
+ st.onChainOk = outcome.onChain?.onChainOk ?? null;
1428
1733
  if (outcome.unavailable)
1429
1734
  gatewayUnavailable = true;
1735
+ if (outcome.unchecked === "kind")
1736
+ uncheckedKind = true;
1737
+ if (outcome.unchecked === "id")
1738
+ uncheckedId = true;
1430
1739
  if (outcome.error)
1431
1740
  st.errors.push(outcome.error);
1432
1741
  // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
@@ -1440,9 +1749,9 @@ async function refetchStamps(stamps, witnessed, options, errors) {
1440
1749
  // unreachable note would fail a stamp for a gateway being down.
1441
1750
  st.ok = st.ok && st.onChainOk !== false;
1442
1751
  }
1443
- return { onChainChecked, gatewayUnavailable };
1752
+ return { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId };
1444
1753
  }
1445
- // How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
1754
+ // How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
1446
1755
  // nested before this path gives up. verifyEvidenceBundle's canonicalization
1447
1756
  // depth bound already rejects a pathological bundle, but the unwrap must be
1448
1757
  // independently bounded rather than trusting that it ran first.
@@ -1475,7 +1784,7 @@ export async function disclosedContent(bundle, options = {}) {
1475
1784
  });
1476
1785
  const traceEvents = resolveTraceEvents(bundle);
1477
1786
  if (traceEvents === undefined) {
1478
- errors.push(`no ${[...ANCHOR_TRACE_BODY_TYPES, STAMP_EVIDENCE_BODY_TYPE].join(" / ")} ` +
1787
+ errors.push(`no ${ANCHOR_TRACE_BODY_TYPE} / ${STAMP_EVIDENCE_BODY_TYPE} ` +
1479
1788
  `body to materialize content from ` +
1480
1789
  `(body_type ${JSON.stringify(verification.bodyType)})`);
1481
1790
  return empty();
@@ -1562,7 +1871,7 @@ export async function disclosedContent(bundle, options = {}) {
1562
1871
  // a caller must distinguish from a body that has events and no links.
1563
1872
  //
1564
1873
  // BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
1565
- // `sanning.stamp.evidence/v1` is the hand-over pack, and it presents the same
1874
+ // `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
1566
1875
  // event shape.
1567
1876
  //
1568
1877
  // Exported for `chain.ts`, which needs the same descent and must not derive it
@@ -1578,15 +1887,14 @@ export function resolveTraceEvents(bundle) {
1578
1887
  const b = cur;
1579
1888
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
1580
1889
  // Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
1581
- // `sanning.stamp.evidence/v1` presents the SAME event shape (a
1582
- // producer-signed envelope, optional `record_bytes`, an inclusion proof),
1890
+ // `sanning.stamp.evidence/v2` presents the SAME event shape (a
1891
+ // producer-signed envelope, optional `event_bytes`, an inclusion proof),
1583
1892
  // and it is the HAND-OVER PACK: the only artifact an auditor is ever
1584
1893
  // handed. Dispatching on anchor-trace alone meant `readChains` reported
1585
1894
  // zero chains on every pack, however well-formed the links inside its
1586
1895
  // records were, so "is it in order?" read as NOT CHECKED on the one shape
1587
1896
  // that matters. The writers have been emitting those links the whole time.
1588
1897
  if (bodyType === ANCHOR_TRACE_BODY_TYPE ||
1589
- bodyType === ANCHOR_TRACE_BODY_TYPE_V1 ||
1590
1898
  bodyType === STAMP_EVIDENCE_BODY_TYPE) {
1591
1899
  const body = b.body;
1592
1900
  if (body === null || typeof body !== "object" || !Array.isArray(body.events))
@@ -1673,13 +1981,13 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
1673
1981
  }
1674
1982
  }
1675
1983
  catch (e) {
1676
- errors.push(`source_bundle canonicalization failed: ${stringifyErr(e)}`);
1984
+ errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
1677
1985
  }
1678
1986
  // --- Step 4: recompute the source verdict ---------------------------------
1679
1987
  // The existing evidence path over the inline source bundle — same primitives
1680
1988
  // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
1681
1989
  // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
1682
- const sourceResult = await verifyEvidenceBundle(inline, options);
1990
+ const sourceResult = await verifyExportSource(inline, options);
1683
1991
  // Attestation data_hash binding (§5 6c).
1684
1992
  const sourceCheckpoints = await indexSourceCheckpoints(inline);
1685
1993
  // --- Step 6: embedded attestation records ---------------------------------
@@ -1717,7 +2025,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
1717
2025
  }
1718
2026
  }
1719
2027
  catch (e) {
1720
- errors.push(`verdict agreement comparison failed: ${stringifyErr(e)}`);
2028
+ errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
1721
2029
  }
1722
2030
  // --- Step 7 already folded in by the source recompute ---------------------
1723
2031
  // (verifyEvidenceBundle merged per-gateway outcomes into sourceResult.status:
@@ -1772,7 +2080,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
1772
2080
  };
1773
2081
  }
1774
2082
  export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
1775
- const sourceResult = await verifyEvidenceBundle(sourceBundle, options);
2083
+ const sourceResult = await verifyExportSource(sourceBundle, options);
1776
2084
  // Attestation data_hash binding (§5 6c) — the SAME index construction as
1777
2085
  // verifyExportBody, deliberately shared rather than restated.
1778
2086
  const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
@@ -1812,6 +2120,54 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
1812
2120
  // field; otherwise a per-dimension AttestationResult whose `ok` gates on the
1813
2121
  // three binding checks (sig / operator-address / data_hash). subject_ref is
1814
2122
  // undetermined-tolerant and never gates.
2123
+ // The attestation payload fields evidence-export.md §3.1 marks Required, with
2124
+ // the type it gives each. A record whose payload lacks one, or carries another
2125
+ // type (null included), is malformed: §5 step 9 and §4.5 make "a missing
2126
+ // required field" an export the verifier cannot render (exit 2). §5 step 6
2127
+ // gates a WELL-FORMED record on signature, operator and data_hash only; these
2128
+ // checks decide whether the record is well-formed at all. `level` is the §3.1
2129
+ // enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
2130
+ // §3.1 clarification).
2131
+ const PAYLOAD_REQUIRED = [
2132
+ ["attested_at", "string"],
2133
+ ["tx_id", "string"],
2134
+ ["data_hash", "string"],
2135
+ ["data_size", "integer"],
2136
+ ["block_height", "integer"],
2137
+ ["block_timestamp", "integer"],
2138
+ ["operator", "string"],
2139
+ ["owner_address", "string"],
2140
+ ["gateway", "string"],
2141
+ ["signature_verified", "boolean"],
2142
+ ["level", "level"],
2143
+ ];
2144
+ function payloadFieldError(payload) {
2145
+ for (const [name, kind] of PAYLOAD_REQUIRED) {
2146
+ const v = Object.prototype.hasOwnProperty.call(payload, name) ? payload[name] : undefined;
2147
+ let ok;
2148
+ let want;
2149
+ if (kind === "string") {
2150
+ ok = typeof v === "string";
2151
+ want = "a string";
2152
+ }
2153
+ else if (kind === "boolean") {
2154
+ ok = typeof v === "boolean";
2155
+ want = "a boolean";
2156
+ }
2157
+ else if (kind === "integer") {
2158
+ // §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
2159
+ ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
2160
+ want = "a non-negative integer";
2161
+ }
2162
+ else {
2163
+ ok = v === 1 || v === 2 || v === 3;
2164
+ want = "1, 2 or 3";
2165
+ }
2166
+ if (!ok)
2167
+ return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
2168
+ }
2169
+ return null;
2170
+ }
1815
2171
  async function verifyAttestation(rec, sourceCheckpoints) {
1816
2172
  const errs = [];
1817
2173
  const result = {
@@ -1851,6 +2207,9 @@ async function verifyAttestation(rec, sourceCheckpoints) {
1851
2207
  if (typeof rec.signature !== "string") {
1852
2208
  return { result, malformed: "attestation record missing signature" };
1853
2209
  }
2210
+ const fieldError = payloadFieldError(payload);
2211
+ if (fieldError !== null)
2212
+ return { result, malformed: fieldError };
1854
2213
  const rsaKey = { kty: "RSA", n: pub.n, e: pub.e };
1855
2214
  result.operator = typeof payload.operator === "string" ? payload.operator : "";
1856
2215
  result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
@@ -1863,15 +2222,22 @@ async function verifyAttestation(rec, sourceCheckpoints) {
1863
2222
  payloadBytes = utf8(jcs(payload));
1864
2223
  }
1865
2224
  catch (e) {
1866
- return { result, malformed: `attestation payload canonicalization failed: ${stringifyErr(e)}` };
2225
+ return { result, malformed: `attestation payload canonicalization failed${thrownDetail(e)}` };
1867
2226
  }
1868
2227
  try {
1869
2228
  result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
1870
2229
  if (!result.signatureOk)
1871
2230
  errs.push("attestation RSA-PSS signature verification failed");
1872
2231
  }
1873
- catch (e) {
1874
- return { result, malformed: `attestation ${stringifyErr(e)}` };
2232
+ catch {
2233
+ // A FIXED sentence, not the library's. This kernel throws
2234
+ // `verifyRsaPssSha256: malformed signature hex: ...` and the Python kernel
2235
+ // raises `malformed signature hex: ...`, so quoting either one split the
2236
+ // two kernels on a finding a holder can put in an export body it signs,
2237
+ // and an export pack's findings reach a case reading through `reasons`
2238
+ // (gate 4 on proof#140, finding 1). Callers of `verifyRsaPssSha256` still
2239
+ // get this kernel's own words.
2240
+ return { result, malformed: "attestation signature or key is malformed" };
1875
2241
  }
1876
2242
  // (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
1877
2243
  try {
@@ -1882,7 +2248,7 @@ async function verifyAttestation(rec, sourceCheckpoints) {
1882
2248
  }
1883
2249
  }
1884
2250
  catch (e) {
1885
- return { result, malformed: `attestation operator key: ${stringifyErr(e)}` };
2251
+ return { result, malformed: `attestation operator key${thrownDetail(e)}` };
1886
2252
  }
1887
2253
  // (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
1888
2254
  // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
@@ -1904,7 +2270,7 @@ async function verifyAttestation(rec, sourceCheckpoints) {
1904
2270
  }
1905
2271
  }
1906
2272
  catch (e) {
1907
- errs.push(`cannot recompute checkpoint content hash: ${stringifyErr(e)}`);
2273
+ errs.push(`cannot recompute checkpoint content hash${thrownDetail(e)}`);
1908
2274
  }
1909
2275
  }
1910
2276
  // (d) subject_ref (§3.2) — well-formedness only (no side-input subject channel
@@ -2116,6 +2482,35 @@ async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
2116
2482
  // beats unreachable" to drift, and the drifted copy is the one nobody reads.
2117
2483
  // The subject only ever selects wording; it never changes a verdict.
2118
2484
  async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchImpl) {
2485
+ // Only a `kind` this kernel has a witness adapter for is re-fetched
2486
+ // (evidence-bundle.md §5.1: "where the verifier has an adapter for its
2487
+ // `kind`"; §5.1.1: an unrecognised kind MUST NOT fail verification). An `id`
2488
+ // of any other kind is not an Arweave id, so whatever a gateway serves at
2489
+ // that path says nothing about the witness, and comparing it would turn an
2490
+ // unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
2491
+ // run goes `partial`: the caller asked for the witness check and it was not
2492
+ // made.
2493
+ if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
2494
+ return {
2495
+ onChain: null,
2496
+ unavailable: false,
2497
+ unchecked: "kind",
2498
+ error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
2499
+ };
2500
+ }
2501
+ // The id becomes a URL path segment. Anything but a 43-character base64url
2502
+ // transaction id can leave the gateway's base path once URL parsing
2503
+ // normalises it (`../x`, `%2e%2e/x`), or drop part of the path (`a#b`,
2504
+ // `a?b`), and the two kernels would then request different URLs. Such an id
2505
+ // is never fetched and never failed: the witness is not checked.
2506
+ if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
2507
+ return {
2508
+ onChain: null,
2509
+ unavailable: false,
2510
+ unchecked: "id",
2511
+ error: "arweave witness id is not a 43-character base64url transaction id; the witness was not checked",
2512
+ };
2513
+ }
2119
2514
  const perGateway = [];
2120
2515
  for (const gw of gateways) {
2121
2516
  perGateway.push({
@@ -2134,7 +2529,28 @@ async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchI
2134
2529
  else if (onChain.rollup === "unreachable") {
2135
2530
  error = `${subject} ${anchorRef.id} unreachable on all gateways`;
2136
2531
  }
2137
- return { onChain, unavailable: onChain.rollup === "unreachable", error };
2532
+ return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
2533
+ }
2534
+ // The `anchor_ref.kind` values this kernel can re-fetch from a gateway
2535
+ // (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
2536
+ // left unchecked by `refetchAnchored`, never probed.
2537
+ const WITNESS_ADAPTER_KINDS = new Set(["arweave"]);
2538
+ // The only `arweave` ids the adapter looks up: a 43-character base64url
2539
+ // transaction or data-item id.
2540
+ const ARWEAVE_ID_RE = /^[A-Za-z0-9_-]{43}$/;
2541
+ // The bundle-level notes for a run where requested re-fetches were not made.
2542
+ // Each note keeps the phrase "could not be re-fetched", which is what both
2543
+ // CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
2544
+ function pushRefetchGapNotes(errors, subject, gaps) {
2545
+ if (gaps.unavailable) {
2546
+ errors.push(`one or more ${subject} could not be re-fetched from the supplied gateways`);
2547
+ }
2548
+ if (gaps.kind) {
2549
+ errors.push(`one or more ${subject} name a witness kind this verifier has no adapter for, so it could not be re-fetched`);
2550
+ }
2551
+ if (gaps.id) {
2552
+ errors.push(`one or more ${subject} name an Arweave witness id this verifier cannot look up, so it could not be re-fetched`);
2553
+ }
2138
2554
  }
2139
2555
  // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
2140
2556
  // (bytes disagree), or unreachable (no response / !ok / network error).
@@ -2165,7 +2581,7 @@ async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
2165
2581
  if (toHex(onChainBytes) === expectedHex)
2166
2582
  return "confirm";
2167
2583
  try {
2168
- const parsed = JSON.parse(new TextDecoder().decode(onChainBytes));
2584
+ const parsed = jcsParse(onChainBytes);
2169
2585
  if (toHex(utf8(jcs(parsed))) === expectedHex)
2170
2586
  return "confirm";
2171
2587
  }
@@ -2209,7 +2625,7 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
2209
2625
  inBody = hexToBytes(ev.content);
2210
2626
  }
2211
2627
  catch (e) {
2212
- errors.push(`event content is not hex: ${stringifyErr(e)}`);
2628
+ errors.push(`event content is not hex${thrownDetail(e)}`);
2213
2629
  }
2214
2630
  }
2215
2631
  let side;
@@ -2222,7 +2638,7 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
2222
2638
  side = hexToBytes(sideEntry.value);
2223
2639
  }
2224
2640
  catch (e) {
2225
- errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
2641
+ errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
2226
2642
  }
2227
2643
  }
2228
2644
  // `offered` comes from the SAME test that produced the value, never from