@sanning/proof 0.10.0 → 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
@@ -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
@@ -113,12 +118,23 @@ function isEvidenceMajorAccepted(specVersion) {
113
118
  return minor.length > 0 && /^[0-9]+$/.test(minor);
114
119
  });
115
120
  }
116
- 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) {
117
133
  return {
118
134
  status: "malformed",
119
135
  specVersionOk,
120
- bodyHashOk: false,
121
- signatureOk: false,
136
+ bodyHashOk: wrapperOk,
137
+ signatureOk: wrapperOk,
122
138
  bodyType: null,
123
139
  onChainChecked: false,
124
140
  checkpoints: [],
@@ -127,8 +143,77 @@ function malformed(error, specVersionOk = false) {
127
143
  errors: [error],
128
144
  };
129
145
  }
130
- function stringifyErr(e) {
131
- 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;
132
217
  }
133
218
  /**
134
219
  * Spell a value inside a finding the way the Python kernel spells it.
@@ -189,26 +274,47 @@ async function verifyBundle(bundle, options, exportSource) {
189
274
  }
190
275
  // --- Step 2: verify the wrapper signature + recompute body_hash ------------
191
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.
192
285
  let bodyHashOk = false;
193
- try {
194
- const recomputed = await sha256Hex(utf8(jcs(b.body)));
195
- bodyHashOk = recomputed === b.body_hash;
196
- if (!bodyHashOk) {
197
- errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
198
- }
286
+ if (!isHexField(b.body_hash, 64, true)) {
287
+ errors.push("wrapper body_hash is not 64 lowercase hex characters");
199
288
  }
200
- catch (e) {
201
- 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
+ }
202
300
  }
203
301
  let signatureOk = false;
204
- try {
205
- const { signature: _sig, co_signatures: _co, ...bundleForSig } = b;
206
- signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
207
- if (!signatureOk)
208
- errors.push("wrapper Ed25519 signature verification failed");
209
- }
210
- catch (e) {
211
- 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
+ }
212
318
  }
213
319
  const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
214
320
  // A broken wrapper (bad signature or tampered body) is a hard failure — the
@@ -345,31 +451,39 @@ async function indexSourceCheckpoints(bundle) {
345
451
  */
346
452
  async function resolveCheckpointKey(cp, eventBytes) {
347
453
  const errors = [];
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 };
348
457
  if (cp.tx_id !== undefined) {
349
458
  errors.push(`${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`);
350
- return { key: null, anchorRef: null, errors };
459
+ return unexamined;
351
460
  }
352
461
  const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
353
462
  if (legacy !== null) {
354
463
  errors.push(legacy);
355
- return { key: null, anchorRef: null, errors };
464
+ return unexamined;
356
465
  }
357
466
  if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
358
467
  errors.push("checkpoint missing checkpoint_ref");
359
- return { key: null, anchorRef: null, errors };
468
+ return unexamined;
360
469
  }
361
470
  if (eventBytes === undefined) {
362
471
  // Without the bytes the key cannot be recomputed, so it cannot be trusted.
363
472
  errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
364
- return { key: null, anchorRef: null, errors };
473
+ return unexamined;
365
474
  }
366
475
  const derived = await sha256Hex(eventBytes);
367
476
  if (derived !== cp.checkpoint_ref.toLowerCase()) {
368
477
  errors.push(`checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`);
369
- return { key: null, anchorRef: null, errors };
478
+ return unexamined;
370
479
  }
371
- const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
372
- 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
+ };
373
487
  }
374
488
  /**
375
489
  * Validate an optional `anchor_ref` (§5.1.1).
@@ -388,39 +502,54 @@ async function resolveCheckpointKey(cp, eventBytes) {
388
502
  * So this validates the SHAPE and never the vocabulary. A malformed
389
503
  * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
390
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.
391
516
  */
392
517
  function parseAnchorRef(raw, errors) {
393
- if (raw === undefined || raw === null)
394
- return null; // placed, not yet witnessed
518
+ if (raw === undefined || raw === null) {
519
+ return { ref: null, malformed: false }; // placed, not yet witnessed
520
+ }
395
521
  if (typeof raw !== "object" || Array.isArray(raw)) {
396
522
  errors.push("anchor_ref must be an object");
397
- return null;
523
+ return { ref: null, malformed: true };
398
524
  }
399
525
  const o = raw;
400
526
  if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
401
527
  errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
402
- return null;
528
+ return { ref: null, malformed: true };
403
529
  }
404
530
  if (typeof o.id !== "string" || o.id === "") {
405
531
  errors.push("anchor_ref.id is missing or empty");
406
- return null;
532
+ return { ref: null, malformed: true };
407
533
  }
408
534
  const ref = o.ref;
409
535
  if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
410
536
  errors.push("anchor_ref.ref must be an object when present");
411
- return null;
537
+ return { ref: null, malformed: true };
412
538
  }
413
539
  return {
414
- kind: o.kind,
415
- id: o.id,
416
- ...(ref === undefined ? {} : { ref: ref }),
540
+ ref: {
541
+ kind: o.kind,
542
+ id: o.id,
543
+ ...(ref === undefined ? {} : { ref: ref }),
544
+ },
545
+ malformed: false,
417
546
  };
418
547
  }
419
548
  async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options) {
420
549
  const body = b.body;
421
550
  const bodyType = ANCHOR_TRACE_BODY_TYPE;
422
551
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
423
- return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
552
+ return malformed(`${bodyType} body must have checkpoints[] and events[]`, true, true);
424
553
  }
425
554
  const gateways = options.gateways ?? [];
426
555
  const onChainChecked = gateways.length > 0;
@@ -435,6 +564,11 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
435
564
  const byKey = new Map();
436
565
  const anchorByKey = new Map();
437
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();
438
572
  for (const rawCp of body.checkpoints) {
439
573
  // `checkpoints[]` is attacker-shaped, and a verifier must never raise on
440
574
  // adversarial input: a null entry used to reach `cp.tx_id` and hand the
@@ -469,7 +603,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
469
603
  eventBytes = hexToBytes(cp.event_bytes);
470
604
  }
471
605
  catch (e) {
472
- cpErrors.push(`checkpoint event_bytes is not hex: ${stringifyErr(e)}`);
606
+ cpErrors.push(`checkpoint event_bytes is not hex${thrownDetail(e)}`);
473
607
  }
474
608
  const keyed = await resolveCheckpointKey(cp, eventBytes);
475
609
  cpErrors.push(...keyed.errors);
@@ -500,12 +634,16 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
500
634
  // A checkpoint must fully bind: signature + the committed record matching
501
635
  // payload_hash. An undetermined binding (no record) is not enough to
502
636
  // anchor the events' inclusion claims to a trustworthy root.
503
- envelopeOk = res.ok && res.payloadHashOk === true;
637
+ const hexError = envelopeHexError("checkpoint", cp.envelope);
638
+ envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
504
639
  // See the event loop below: a degenerate envelope is a malformed input,
505
640
  // not a failed check (proof#76).
506
641
  if (!isJsonObject(cp.envelope)) {
507
642
  cpErrors.push("checkpoint envelope is not a JSON object");
508
643
  }
644
+ else if (hexError !== null) {
645
+ cpErrors.push(hexError);
646
+ }
509
647
  else if (!res.signatureOk) {
510
648
  cpErrors.push("checkpoint envelope signature failed");
511
649
  }
@@ -531,13 +669,13 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
531
669
  }
532
670
  }
533
671
  catch (e) {
534
- cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
672
+ cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
535
673
  }
536
674
  // The committed record's merkle_root must equal the checkpoint's claimed
537
675
  // merkle_root (recompute, don't trust the bundle's top-level claim).
538
676
  if (eventBytes !== undefined) {
539
677
  try {
540
- const record = JSON.parse(new TextDecoder().decode(eventBytes));
678
+ const record = jcsParse(eventBytes);
541
679
  const committedRoot = record.event?.merkle_root;
542
680
  merkleRootOk = typeof committedRoot === "string" && committedRoot === cp.merkle_root;
543
681
  if (!merkleRootOk) {
@@ -546,7 +684,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
546
684
  }
547
685
  }
548
686
  catch (e) {
549
- cpErrors.push(`checkpoint record is not JSON: ${stringifyErr(e)}`);
687
+ cpErrors.push(`checkpoint record is not JSON${thrownDetail(e)}`);
550
688
  }
551
689
  }
552
690
  checkpointResults.push({
@@ -565,6 +703,9 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
565
703
  ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
566
704
  errors: cpErrors,
567
705
  });
706
+ if (keyed.anchorRefMalformed) {
707
+ malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
708
+ }
568
709
  }
569
710
  // --- Step 4: per-event ------------------------------------------------------
570
711
  const eventResults = [];
@@ -595,17 +736,36 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
595
736
  const legacyError = legacyEventBytesError(ev, "event", bodyType);
596
737
  if (legacyError !== null)
597
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.
598
754
  let eventBytes;
755
+ let eventBytesMalformed = false;
599
756
  try {
600
757
  if (typeof ev.event_bytes === "string")
601
758
  eventBytes = hexToBytes(ev.event_bytes);
602
759
  }
603
760
  catch (e) {
604
- evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
761
+ eventBytesMalformed = true;
762
+ evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
605
763
  }
606
764
  try {
607
765
  const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
608
- envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
766
+ const hexError = envelopeHexError("event", ev.envelope);
767
+ // ok tolerates an undetermined binding (withheld record)
768
+ envelopeOk = res.ok && hexError === null;
609
769
  payloadBindingOk = res.payloadHashOk;
610
770
  // P-10: report what could not be determined, never a fault that was never
611
771
  // observed. There is no envelope here, so no signature was checked and no
@@ -617,6 +777,9 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
617
777
  if (!isJsonObject(ev.envelope)) {
618
778
  evErrors.push("event envelope is not a JSON object");
619
779
  }
780
+ else if (hexError !== null) {
781
+ evErrors.push(hexError);
782
+ }
620
783
  else if (!res.signatureOk) {
621
784
  evErrors.push("event envelope signature failed");
622
785
  }
@@ -632,7 +795,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
632
795
  evErrors.push(specFinding);
633
796
  }
634
797
  catch (e) {
635
- evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
798
+ evErrors.push(`event envelope verify error${thrownDetail(e)}`);
636
799
  }
637
800
  // Bind the event to exactly one checkpoint in the bundle, then verify
638
801
  // RFC 9162 inclusion against THAT checkpoint's merkle_root.
@@ -686,7 +849,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
686
849
  }
687
850
  }
688
851
  catch (e) {
689
- evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
852
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
690
853
  }
691
854
  }
692
855
  // Content (raw-log) binding — shared with every other event-carrying body.
@@ -705,7 +868,14 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
705
868
  // present checkpoint, and its disclosed content did not MISMATCH. A
706
869
  // withheld record or undisclosed content (binding/content null) does not
707
870
  // fail it.
708
- ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false && legacyError === null,
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,
709
879
  errors: evErrors,
710
880
  });
711
881
  }
@@ -730,6 +900,20 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
730
900
  // the run to partial) and NEVER as a mismatch. Treating "we never
731
901
  // anchored it yet" as "the chain disagrees" would be the same false
732
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
+ }
733
917
  if (cpr.anchorRef === null) {
734
918
  gatewayUnavailable = true;
735
919
  cpr.onChain = {
@@ -896,7 +1080,7 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
896
1080
  let recordContentHash;
897
1081
  if (eventBytes !== undefined) {
898
1082
  try {
899
- const record = JSON.parse(new TextDecoder().decode(eventBytes));
1083
+ const record = jcsParse(eventBytes);
900
1084
  const ch = record.event?.content_hash;
901
1085
  if (typeof ch === "string" && ch)
902
1086
  recordContentHash = ch.toLowerCase();
@@ -977,7 +1161,7 @@ async function verifyStampEntry(raw, seen) {
977
1161
  bytes = hexToBytes(e.stamp_bytes);
978
1162
  }
979
1163
  catch (err) {
980
- errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
1164
+ errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
981
1165
  return { result: base };
982
1166
  }
983
1167
  // Gated by `isLowerHex` above, so this is already the exact lowercase hex of
@@ -1009,7 +1193,7 @@ async function verifyStampEntry(raw, seen) {
1009
1193
  // --- the body itself ------------------------------------------------------
1010
1194
  let body;
1011
1195
  try {
1012
- const parsed = JSON.parse(new TextDecoder().decode(bytes));
1196
+ const parsed = jcsParse(bytes);
1013
1197
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1014
1198
  errors.push("stamp body is not a JSON object");
1015
1199
  return { result: base, witnessedHex };
@@ -1019,13 +1203,13 @@ async function verifyStampEntry(raw, seen) {
1019
1203
  // every submit attempt for exactly this reason: a non-canonical body makes
1020
1204
  // a retry a DIFFERENT submission, and makes every independent re-derivation
1021
1205
  // of the stamp disagree with the one that was witnessed.
1022
- if (jcs(body) !== new TextDecoder().decode(bytes)) {
1206
+ if (jcs(body) !== decodeUtf8Strict(bytes)) {
1023
1207
  errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
1024
1208
  return { result: base, witnessedHex };
1025
1209
  }
1026
1210
  }
1027
1211
  catch (err) {
1028
- errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
1212
+ errors.push(`stamp body is not JSON${thrownDetail(err)}`);
1029
1213
  return { result: base, witnessedHex };
1030
1214
  }
1031
1215
  if (body.stamp_version !== STAMP_BODY_VERSION) {
@@ -1063,7 +1247,10 @@ async function verifyStampEntry(raw, seen) {
1063
1247
  }
1064
1248
  // --- the sub-tree ranges --------------------------------------------------
1065
1249
  base.subtreesOk = checkSubtrees(body, base, errors);
1066
- 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;
1067
1254
  base.ok =
1068
1255
  base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
1069
1256
  return { result: base, witnessedHex };
@@ -1145,7 +1332,7 @@ function checkSubtrees(body, out, errors) {
1145
1332
  async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, options) {
1146
1333
  const body = b.body;
1147
1334
  if (!Array.isArray(body?.stamps)) {
1148
- return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
1335
+ return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true, true);
1149
1336
  }
1150
1337
  const seen = new Set();
1151
1338
  const stamps = [];
@@ -1232,7 +1419,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1232
1419
  async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options) {
1233
1420
  const body = b.body;
1234
1421
  if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
1235
- 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);
1236
1423
  }
1237
1424
  // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
1238
1425
  const seen = new Set();
@@ -1289,17 +1476,36 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1289
1476
  const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
1290
1477
  if (legacyError !== null)
1291
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.
1292
1494
  let eventBytes;
1495
+ let eventBytesMalformed = false;
1293
1496
  try {
1294
1497
  if (typeof ev.event_bytes === "string")
1295
1498
  eventBytes = hexToBytes(ev.event_bytes);
1296
1499
  }
1297
1500
  catch (e) {
1298
- evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
1501
+ eventBytesMalformed = true;
1502
+ evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1299
1503
  }
1300
1504
  try {
1301
1505
  const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
1302
- envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
1506
+ const hexError = envelopeHexError("event", ev.envelope);
1507
+ // tolerates an undetermined binding (withheld record)
1508
+ envelopeOk = res.ok && hexError === null;
1303
1509
  payloadBindingOk = res.payloadHashOk;
1304
1510
  // P-10: report what could not be determined, never a fault that was never
1305
1511
  // observed. There is no envelope here, so no signature was checked and no
@@ -1311,6 +1517,9 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1311
1517
  if (!isJsonObject(ev.envelope)) {
1312
1518
  evErrors.push("event envelope is not a JSON object");
1313
1519
  }
1520
+ else if (hexError !== null) {
1521
+ evErrors.push(hexError);
1522
+ }
1314
1523
  else if (!res.signatureOk) {
1315
1524
  evErrors.push("event envelope signature failed");
1316
1525
  }
@@ -1326,7 +1535,7 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1326
1535
  evErrors.push(specFinding);
1327
1536
  }
1328
1537
  catch (e) {
1329
- evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1538
+ evErrors.push(`event envelope verify error${thrownDetail(e)}`);
1330
1539
  }
1331
1540
  // --- The join (§5.3.3 rule 1) -------------------------------------------
1332
1541
  const incl = ev.inclusion;
@@ -1377,7 +1586,7 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1377
1586
  }
1378
1587
  }
1379
1588
  catch (e) {
1380
- evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
1589
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
1381
1590
  }
1382
1591
  }
1383
1592
  }
@@ -1392,7 +1601,14 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1392
1601
  // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
1393
1602
  checkpointBound: stampBound,
1394
1603
  contentOk,
1395
- ok: envelopeOk && inclusionOk && stampBound && contentOk !== false && legacyError === null,
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,
1396
1612
  errors: evErrors,
1397
1613
  });
1398
1614
  }
@@ -1765,7 +1981,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
1765
1981
  }
1766
1982
  }
1767
1983
  catch (e) {
1768
- errors.push(`source_bundle canonicalization failed: ${stringifyErr(e)}`);
1984
+ errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
1769
1985
  }
1770
1986
  // --- Step 4: recompute the source verdict ---------------------------------
1771
1987
  // The existing evidence path over the inline source bundle — same primitives
@@ -1809,7 +2025,7 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
1809
2025
  }
1810
2026
  }
1811
2027
  catch (e) {
1812
- errors.push(`verdict agreement comparison failed: ${stringifyErr(e)}`);
2028
+ errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
1813
2029
  }
1814
2030
  // --- Step 7 already folded in by the source recompute ---------------------
1815
2031
  // (verifyEvidenceBundle merged per-gateway outcomes into sourceResult.status:
@@ -2006,15 +2222,22 @@ async function verifyAttestation(rec, sourceCheckpoints) {
2006
2222
  payloadBytes = utf8(jcs(payload));
2007
2223
  }
2008
2224
  catch (e) {
2009
- return { result, malformed: `attestation payload canonicalization failed: ${stringifyErr(e)}` };
2225
+ return { result, malformed: `attestation payload canonicalization failed${thrownDetail(e)}` };
2010
2226
  }
2011
2227
  try {
2012
2228
  result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
2013
2229
  if (!result.signatureOk)
2014
2230
  errs.push("attestation RSA-PSS signature verification failed");
2015
2231
  }
2016
- catch (e) {
2017
- 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" };
2018
2241
  }
2019
2242
  // (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
2020
2243
  try {
@@ -2025,7 +2248,7 @@ async function verifyAttestation(rec, sourceCheckpoints) {
2025
2248
  }
2026
2249
  }
2027
2250
  catch (e) {
2028
- return { result, malformed: `attestation operator key: ${stringifyErr(e)}` };
2251
+ return { result, malformed: `attestation operator key${thrownDetail(e)}` };
2029
2252
  }
2030
2253
  // (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
2031
2254
  // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
@@ -2047,7 +2270,7 @@ async function verifyAttestation(rec, sourceCheckpoints) {
2047
2270
  }
2048
2271
  }
2049
2272
  catch (e) {
2050
- errs.push(`cannot recompute checkpoint content hash: ${stringifyErr(e)}`);
2273
+ errs.push(`cannot recompute checkpoint content hash${thrownDetail(e)}`);
2051
2274
  }
2052
2275
  }
2053
2276
  // (d) subject_ref (§3.2) — well-formedness only (no side-input subject channel
@@ -2358,7 +2581,7 @@ async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
2358
2581
  if (toHex(onChainBytes) === expectedHex)
2359
2582
  return "confirm";
2360
2583
  try {
2361
- const parsed = JSON.parse(new TextDecoder().decode(onChainBytes));
2584
+ const parsed = jcsParse(onChainBytes);
2362
2585
  if (toHex(utf8(jcs(parsed))) === expectedHex)
2363
2586
  return "confirm";
2364
2587
  }
@@ -2402,7 +2625,7 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
2402
2625
  inBody = hexToBytes(ev.content);
2403
2626
  }
2404
2627
  catch (e) {
2405
- errors.push(`event content is not hex: ${stringifyErr(e)}`);
2628
+ errors.push(`event content is not hex${thrownDetail(e)}`);
2406
2629
  }
2407
2630
  }
2408
2631
  let side;
@@ -2415,7 +2638,7 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
2415
2638
  side = hexToBytes(sideEntry.value);
2416
2639
  }
2417
2640
  catch (e) {
2418
- errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
2641
+ errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
2419
2642
  }
2420
2643
  }
2421
2644
  // `offered` comes from the SAME test that produced the value, never from