@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/src/evidence.ts CHANGED
@@ -20,6 +20,11 @@ import {
20
20
  deriveOperatorAddress,
21
21
  ed25519Verify,
22
22
  hexToBytes,
23
+ // The one hex-field test, which lives in `crypto.ts` beside the one hex
24
+ // reader (proof#146). It was a second copy here until then, with the same
25
+ // body: two copies of one rule is how two kernels drift apart one edit at a
26
+ // time, which is the whole defect class this fix is about.
27
+ isHexField,
23
28
  sha256Hex,
24
29
  utf8,
25
30
  verifyRsaPssSha256,
@@ -30,6 +35,7 @@ import {
30
35
  contentHashes,
31
36
  exceedsDepth,
32
37
  jcs,
38
+ jcsParse,
33
39
  verifyEnvelope,
34
40
  } from "./verifier.js";
35
41
  import {
@@ -673,12 +679,23 @@ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
673
679
  });
674
680
  }
675
681
 
676
- function malformed(error: string, specVersionOk = false): EvidenceBundleResult {
682
+ /**
683
+ * A bundle this kernel cannot read.
684
+ *
685
+ * `wrapperOk` is true only where the wrapper signature and `body_hash` were
686
+ * CHECKED and held before the body was found unreadable. It is then reported
687
+ * as held, because it did: `signatureOk` and `bodyHashOk` are statements about
688
+ * the wrapper, and a body this kernel cannot parse does not unsign it. The
689
+ * case reader decides whose pack a bundle is on exactly those two fields
690
+ * (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
691
+ * broke its own body shape read as a forger.
692
+ */
693
+ function malformed(error: string, specVersionOk = false, wrapperOk = false): EvidenceBundleResult {
677
694
  return {
678
695
  status: "malformed",
679
696
  specVersionOk,
680
- bodyHashOk: false,
681
- signatureOk: false,
697
+ bodyHashOk: wrapperOk,
698
+ signatureOk: wrapperOk,
682
699
  bodyType: null,
683
700
  onChainChecked: false,
684
701
  checkpoints: [],
@@ -688,8 +705,79 @@ function malformed(error: string, specVersionOk = false): EvidenceBundleResult {
688
705
  };
689
706
  }
690
707
 
691
- function stringifyErr(e: unknown): string {
692
- return e instanceof Error ? e.message : String(e);
708
+ /**
709
+ * The prefixes of the exception text this kernel writes ITSELF, in the words
710
+ * the Python kernel writes too. Anything outside this set comes from the host:
711
+ * a JSON parser, a codec, a crypto library. See `thrownDetail`.
712
+ */
713
+ const KERNEL_WORDING = ["hexToBytes: "];
714
+
715
+ /**
716
+ * The detail a finding may append about a caught exception.
717
+ *
718
+ * THE RULE: a finding carries the text of a caught exception only where this
719
+ * kernel wrote that text. A finding travels into a case reading through `case`
720
+ * `reasons`, and both kernels promise one reading and one rendered sentence for
721
+ * one set of bytes; a host JSON parser says `Unexpected token 'h', "hello" is
722
+ * not valid JSON` here and `Expecting value: line 1 column 1 (char 0)` there,
723
+ * for one pack anyone can build (gate 4 on proof#140, finding 1).
724
+ *
725
+ * Returns `": <text>"` for this kernel's own wording and the empty string for
726
+ * anything else. The label at the call site already names the check that
727
+ * failed and both kernels agree on it character for character; what is dropped
728
+ * is the host's parse offset, which no reader can act on and which was never
729
+ * the same in the two kernels.
730
+ */
731
+ function thrownDetail(e: unknown): string {
732
+ const text = e instanceof Error ? e.message : String(e);
733
+ return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
734
+ }
735
+
736
+ /**
737
+ * Decode `bytes` as UTF-8 the way every other kernel does: a byte sequence that
738
+ * is not valid UTF-8 THROWS, and a leading byte-order mark is kept rather than
739
+ * eaten.
740
+ *
741
+ * ONE CALLER LEFT, and it is not a parse. `jcsParse` (`verifier.ts`) reads
742
+ * every set of signed bytes in this kernel now; this function survives only for
743
+ * the canonicality comparison on the stamp path, which needs the DECODED TEXT
744
+ * of bytes `jcsParse` has already accepted, to compare against `jcs(body)`. It
745
+ * performs exactly the decode `jcsParse` performs, so the comparison cannot
746
+ * read the bytes differently from the parse that produced `body`.
747
+ *
748
+ * `new TextDecoder()` alone is neither strict nor BOM-preserving: it replaces
749
+ * every invalid sequence with U+FFFD and strips a BOM. A stamp body carrying
750
+ * one raw `0x80` inside a string value read `verified` here and `failed` there
751
+ * because of it (gate 4 on proof#140). The symmetric claim the first version of
752
+ * this comment made, that "Python's `json.loads` raises on both", is FALSE and
753
+ * is what hid gate 5's defect for a round: `json.loads(<bytes>)` strips a BOM
754
+ * too. Python's strict reader is `jcs_loads`, and the rule both kernels hold to
755
+ * is stated once, in `jcsParse`.
756
+ */
757
+ function decodeUtf8Strict(bytes: Uint8Array): string {
758
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
759
+ }
760
+
761
+ /**
762
+ * Name an envelope `public_key` or `signature` spelled outside the hex charset
763
+ * or at the wrong length (proof#142). This kernel's envelope layer already
764
+ * fails such an envelope, and the Python one's decoder accepts ASCII
765
+ * whitespace (proof#135), so both bundle verifiers name it here, in the same
766
+ * words, and the two kernels give one reading. Only a STRING is checked: a
767
+ * missing or non-string field is the envelope layer's to fail, and it does.
768
+ */
769
+ function envelopeHexError(what: string, envelope: unknown): string | null {
770
+ if (!isJsonObject(envelope)) return null;
771
+ for (const [name, nibbles] of [
772
+ ["public_key", 64],
773
+ ["signature", 128],
774
+ ] as const) {
775
+ const value = envelope[name];
776
+ if (typeof value === "string" && !isHexField(value, nibbles)) {
777
+ return `${what} envelope ${name} is not ${nibbles} hex characters`;
778
+ }
779
+ }
780
+ return null;
693
781
  }
694
782
 
695
783
  /**
@@ -764,26 +852,44 @@ async function verifyBundle(
764
852
  // --- Step 2: verify the wrapper signature + recompute body_hash ------------
765
853
  const errors: string[] = [];
766
854
 
855
+ // Every wrapper hex field is read STRICTLY, before any decoder sees it
856
+ // (proof#142). The Python decoder skipped ASCII whitespace, and the wrapper
857
+ // `signature` sits outside the signed scope, so one space in it, added by
858
+ // anyone and with no key, made a pack verify there and fail here. The same
859
+ // named failure in both kernels, for each field: `public_key` and
860
+ // `signature` are 64 and 128 hex characters in either case (proof#135,
861
+ // option B), and `body_hash` is 64 LOWER-case hex characters, the form it is
862
+ // recomputed in and compared against.
767
863
  let bodyHashOk = false;
768
- try {
769
- const recomputed = await sha256Hex(utf8(jcs(b.body)));
770
- bodyHashOk = recomputed === b.body_hash;
771
- if (!bodyHashOk) {
772
- errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
864
+ if (!isHexField(b.body_hash, 64, true)) {
865
+ errors.push("wrapper body_hash is not 64 lowercase hex characters");
866
+ } else {
867
+ try {
868
+ const recomputed = await sha256Hex(utf8(jcs(b.body)));
869
+ bodyHashOk = recomputed === b.body_hash;
870
+ if (!bodyHashOk) {
871
+ errors.push(`body_hash mismatch: bundle=${b.body_hash} recomputed=${recomputed}`);
872
+ }
873
+ } catch (e) {
874
+ errors.push(`body canonicalization failed${thrownDetail(e)}`);
773
875
  }
774
- } catch (e) {
775
- errors.push(`body canonicalization failed: ${stringifyErr(e)}`);
776
876
  }
777
877
 
778
878
  let signatureOk = false;
779
- try {
780
- const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
781
- co_signatures?: unknown;
782
- };
783
- signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
784
- if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
785
- } catch (e) {
786
- errors.push(`wrapper signature verification error: ${stringifyErr(e)}`);
879
+ const keyFormOk = isHexField(b.public_key, 64);
880
+ const signatureFormOk = isHexField(b.signature, 128);
881
+ if (!keyFormOk) errors.push("wrapper public_key is not 64 hex characters");
882
+ if (!signatureFormOk) errors.push("wrapper signature is not 128 hex characters");
883
+ if (keyFormOk && signatureFormOk) {
884
+ try {
885
+ const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
886
+ co_signatures?: unknown;
887
+ };
888
+ signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
889
+ if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
890
+ } catch (e) {
891
+ errors.push(`wrapper signature verification error${thrownDetail(e)}`);
892
+ }
787
893
  }
788
894
 
789
895
  const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
@@ -939,39 +1045,52 @@ type AnyTraceCheckpoint = Partial<TraceCheckpoint> & { tx_id?: unknown };
939
1045
  async function resolveCheckpointKey(
940
1046
  cp: AnyTraceCheckpoint,
941
1047
  eventBytes: Uint8Array | undefined,
942
- ): Promise<{ key: string | null; anchorRef: AnchorRef | null; errors: string[] }> {
1048
+ ): Promise<{
1049
+ key: string | null;
1050
+ anchorRef: AnchorRef | null;
1051
+ anchorRefMalformed: boolean;
1052
+ errors: string[];
1053
+ }> {
943
1054
  const errors: string[] = [];
1055
+ // Every early return below leaves before `anchor_ref` is ever looked at, so
1056
+ // nothing there is "malformed": it is unexamined, which is not the same claim.
1057
+ const unexamined = { key: null, anchorRef: null, anchorRefMalformed: false, errors };
944
1058
 
945
1059
  if (cp.tx_id !== undefined) {
946
1060
  errors.push(
947
1061
  `${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`,
948
1062
  );
949
- return { key: null, anchorRef: null, errors };
1063
+ return unexamined;
950
1064
  }
951
1065
  const legacy = legacyEventBytesError(cp, "checkpoint", ANCHOR_TRACE_BODY_TYPE);
952
1066
  if (legacy !== null) {
953
1067
  errors.push(legacy);
954
- return { key: null, anchorRef: null, errors };
1068
+ return unexamined;
955
1069
  }
956
1070
  if (typeof cp.checkpoint_ref !== "string" || cp.checkpoint_ref === "") {
957
1071
  errors.push("checkpoint missing checkpoint_ref");
958
- return { key: null, anchorRef: null, errors };
1072
+ return unexamined;
959
1073
  }
960
1074
  if (eventBytes === undefined) {
961
1075
  // Without the bytes the key cannot be recomputed, so it cannot be trusted.
962
1076
  errors.push("checkpoint_ref cannot be verified -- event_bytes missing or not hex");
963
- return { key: null, anchorRef: null, errors };
1077
+ return unexamined;
964
1078
  }
965
1079
  const derived = await sha256Hex(eventBytes);
966
1080
  if (derived !== cp.checkpoint_ref.toLowerCase()) {
967
1081
  errors.push(
968
1082
  `checkpoint_ref does not match SHA-256(event_bytes): claimed=${cp.checkpoint_ref} derived=${derived}`,
969
1083
  );
970
- return { key: null, anchorRef: null, errors };
1084
+ return unexamined;
971
1085
  }
972
1086
 
973
- const anchorRef = parseAnchorRef(cp.anchor_ref, errors);
974
- return { key: derived, anchorRef, errors };
1087
+ const parsed = parseAnchorRef(cp.anchor_ref, errors);
1088
+ return {
1089
+ key: derived,
1090
+ anchorRef: parsed.ref,
1091
+ anchorRefMalformed: parsed.malformed,
1092
+ errors,
1093
+ };
975
1094
  }
976
1095
 
977
1096
  /**
@@ -991,31 +1110,50 @@ async function resolveCheckpointKey(
991
1110
  * So this validates the SHAPE and never the vocabulary. A malformed
992
1111
  * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
993
1112
  * the reader, and only the first is the bundle's fault.
1113
+ *
1114
+ * ── Absent and malformed are not the same null (proof#120) ──
1115
+ *
1116
+ * Both leave no `AnchorRef` to re-fetch, and both used to be reported the same
1117
+ * way downstream: an all-`unreachable` on-chain result, although no gateway was
1118
+ * asked in either case. For the ABSENT case that is at least the note
1119
+ * `evidence-bundle.md` §5.1.1 reserves for "a checkpoint with no `anchor_ref`".
1120
+ * For the MALFORMED case it is a manufactured outage: the bundle named a
1121
+ * witness, the name was unreadable, and the reader was told the public record
1122
+ * could not be reached. So the two come back as different facts, and `malformed`
1123
+ * carries the difference.
994
1124
  */
995
- function parseAnchorRef(raw: unknown, errors: string[]): AnchorRef | null {
996
- if (raw === undefined || raw === null) return null; // placed, not yet witnessed
1125
+ function parseAnchorRef(
1126
+ raw: unknown,
1127
+ errors: string[],
1128
+ ): { ref: AnchorRef | null; malformed: boolean } {
1129
+ if (raw === undefined || raw === null) {
1130
+ return { ref: null, malformed: false }; // placed, not yet witnessed
1131
+ }
997
1132
  if (typeof raw !== "object" || Array.isArray(raw)) {
998
1133
  errors.push("anchor_ref must be an object");
999
- return null;
1134
+ return { ref: null, malformed: true };
1000
1135
  }
1001
1136
  const o = raw as Record<string, unknown>;
1002
1137
  if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
1003
1138
  errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
1004
- return null;
1139
+ return { ref: null, malformed: true };
1005
1140
  }
1006
1141
  if (typeof o.id !== "string" || o.id === "") {
1007
1142
  errors.push("anchor_ref.id is missing or empty");
1008
- return null;
1143
+ return { ref: null, malformed: true };
1009
1144
  }
1010
1145
  const ref = o.ref;
1011
1146
  if (ref !== undefined && (typeof ref !== "object" || ref === null || Array.isArray(ref))) {
1012
1147
  errors.push("anchor_ref.ref must be an object when present");
1013
- return null;
1148
+ return { ref: null, malformed: true };
1014
1149
  }
1015
1150
  return {
1016
- kind: o.kind,
1017
- id: o.id,
1018
- ...(ref === undefined ? {} : { ref: ref as Record<string, unknown> }),
1151
+ ref: {
1152
+ kind: o.kind,
1153
+ id: o.id,
1154
+ ...(ref === undefined ? {} : { ref: ref as Record<string, unknown> }),
1155
+ },
1156
+ malformed: false,
1019
1157
  };
1020
1158
  }
1021
1159
 
@@ -1029,7 +1167,7 @@ async function verifyAnchorTrace(
1029
1167
  const body = b.body as AnchorTraceBody;
1030
1168
  const bodyType = ANCHOR_TRACE_BODY_TYPE;
1031
1169
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
1032
- return malformed(`${bodyType} body must have checkpoints[] and events[]`, true);
1170
+ return malformed(`${bodyType} body must have checkpoints[] and events[]`, true, true);
1033
1171
  }
1034
1172
 
1035
1173
  const gateways = options.gateways ?? [];
@@ -1046,6 +1184,11 @@ async function verifyAnchorTrace(
1046
1184
  const byKey = new Map<string, AnyTraceCheckpoint>();
1047
1185
  const anchorByKey = new Map<string, AnchorRef | null>();
1048
1186
  const checkpointResults: CheckpointResult[] = [];
1187
+ // proof#120. Kept beside the results rather than on them: "the bundle named
1188
+ // a witness we could not read" is an input defect the checkpoint's own
1189
+ // `errors` already states, not a new dimension of the published verdict, and
1190
+ // `CheckpointResult` is a shape downstream surfaces render.
1191
+ const malformedAnchorRef = new Set<CheckpointResult>();
1049
1192
  for (const rawCp of body.checkpoints as unknown[]) {
1050
1193
  // `checkpoints[]` is attacker-shaped, and a verifier must never raise on
1051
1194
  // adversarial input: a null entry used to reach `cp.tx_id` and hand the
@@ -1079,7 +1222,7 @@ async function verifyAnchorTrace(
1079
1222
  try {
1080
1223
  if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
1081
1224
  } catch (e) {
1082
- cpErrors.push(`checkpoint event_bytes is not hex: ${stringifyErr(e)}`);
1225
+ cpErrors.push(`checkpoint event_bytes is not hex${thrownDetail(e)}`);
1083
1226
  }
1084
1227
 
1085
1228
  const keyed = await resolveCheckpointKey(cp, eventBytes);
@@ -1114,11 +1257,14 @@ async function verifyAnchorTrace(
1114
1257
  // A checkpoint must fully bind: signature + the committed record matching
1115
1258
  // payload_hash. An undetermined binding (no record) is not enough to
1116
1259
  // anchor the events' inclusion claims to a trustworthy root.
1117
- envelopeOk = res.ok && res.payloadHashOk === true;
1260
+ const hexError = envelopeHexError("checkpoint", cp.envelope);
1261
+ envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
1118
1262
  // See the event loop below: a degenerate envelope is a malformed input,
1119
1263
  // not a failed check (proof#76).
1120
1264
  if (!isJsonObject(cp.envelope)) {
1121
1265
  cpErrors.push("checkpoint envelope is not a JSON object");
1266
+ } else if (hexError !== null) {
1267
+ cpErrors.push(hexError);
1122
1268
  } else if (!res.signatureOk) {
1123
1269
  cpErrors.push("checkpoint envelope signature failed");
1124
1270
  }
@@ -1141,14 +1287,14 @@ async function verifyAnchorTrace(
1141
1287
  );
1142
1288
  }
1143
1289
  } catch (e) {
1144
- cpErrors.push(`checkpoint envelope verify error: ${stringifyErr(e)}`);
1290
+ cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
1145
1291
  }
1146
1292
 
1147
1293
  // The committed record's merkle_root must equal the checkpoint's claimed
1148
1294
  // merkle_root (recompute, don't trust the bundle's top-level claim).
1149
1295
  if (eventBytes !== undefined) {
1150
1296
  try {
1151
- const record = JSON.parse(new TextDecoder().decode(eventBytes)) as {
1297
+ const record = jcsParse(eventBytes) as {
1152
1298
  event?: { merkle_root?: unknown };
1153
1299
  };
1154
1300
  const committedRoot = record.event?.merkle_root;
@@ -1160,7 +1306,7 @@ async function verifyAnchorTrace(
1160
1306
  );
1161
1307
  }
1162
1308
  } catch (e) {
1163
- cpErrors.push(`checkpoint record is not JSON: ${stringifyErr(e)}`);
1309
+ cpErrors.push(`checkpoint record is not JSON${thrownDetail(e)}`);
1164
1310
  }
1165
1311
  }
1166
1312
 
@@ -1180,6 +1326,9 @@ async function verifyAnchorTrace(
1180
1326
  ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
1181
1327
  errors: cpErrors,
1182
1328
  });
1329
+ if (keyed.anchorRefMalformed) {
1330
+ malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
1331
+ }
1183
1332
  }
1184
1333
 
1185
1334
  // --- Step 4: per-event ------------------------------------------------------
@@ -1212,18 +1361,37 @@ async function verifyAnchorTrace(
1212
1361
  const legacyError = legacyEventBytesError(ev, "event", bodyType);
1213
1362
  if (legacyError !== null) evErrors.push(legacyError);
1214
1363
 
1364
+ // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
1365
+ //
1366
+ // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
1367
+ // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
1368
+ // line down. So an event carrying garbage in place of its record used to
1369
+ // reach the same tri-state as an event carrying nothing: `payloadHashOk`
1370
+ // null, `ok` true, a leading tick on the line, and a `verified` rollup over
1371
+ // a record nobody could read. The only thing wrong with the pack was the
1372
+ // bytes, and it was the one thing the verdict did not say.
1373
+ //
1374
+ // A record the producer chose not to disclose is the customer exercising
1375
+ // minimum disclosure. A record that is present and unreadable is a defect
1376
+ // in the artifact, and this kernel's standing rule (the same one
1377
+ // `checkpoints[]` already applies through `keyed.errors`) is that a defect
1378
+ // we can see fails.
1215
1379
  let eventBytes: Uint8Array | undefined;
1380
+ let eventBytesMalformed = false;
1216
1381
  try {
1217
1382
  if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
1218
1383
  } catch (e) {
1219
- evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
1384
+ eventBytesMalformed = true;
1385
+ evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1220
1386
  }
1221
1387
  try {
1222
1388
  const res = await verifyEnvelope(
1223
1389
  ev.envelope,
1224
1390
  eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1225
1391
  );
1226
- envelopeOk = res.ok; // ok tolerates an undetermined binding (withheld record)
1392
+ const hexError = envelopeHexError("event", ev.envelope);
1393
+ // ok tolerates an undetermined binding (withheld record)
1394
+ envelopeOk = res.ok && hexError === null;
1227
1395
  payloadBindingOk = res.payloadHashOk;
1228
1396
  // P-10: report what could not be determined, never a fault that was never
1229
1397
  // observed. There is no envelope here, so no signature was checked and no
@@ -1234,6 +1402,8 @@ async function verifyAnchorTrace(
1234
1402
  // the producer at all (proof#76).
1235
1403
  if (!isJsonObject(ev.envelope)) {
1236
1404
  evErrors.push("event envelope is not a JSON object");
1405
+ } else if (hexError !== null) {
1406
+ evErrors.push(hexError);
1237
1407
  } else if (!res.signatureOk) {
1238
1408
  evErrors.push("event envelope signature failed");
1239
1409
  }
@@ -1244,7 +1414,7 @@ async function verifyAnchorTrace(
1244
1414
  const specFinding = specVersionFinding("event", ev.envelope, res);
1245
1415
  if (specFinding !== null) evErrors.push(specFinding);
1246
1416
  } catch (e) {
1247
- evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
1417
+ evErrors.push(`event envelope verify error${thrownDetail(e)}`);
1248
1418
  }
1249
1419
 
1250
1420
  // Bind the event to exactly one checkpoint in the bundle, then verify
@@ -1307,7 +1477,7 @@ async function verifyAnchorTrace(
1307
1477
  );
1308
1478
  }
1309
1479
  } catch (e) {
1310
- evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
1480
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
1311
1481
  }
1312
1482
  }
1313
1483
 
@@ -1328,7 +1498,15 @@ async function verifyAnchorTrace(
1328
1498
  // present checkpoint, and its disclosed content did not MISMATCH. A
1329
1499
  // withheld record or undisclosed content (binding/content null) does not
1330
1500
  // fail it.
1331
- ok: envelopeOk && inclusionOk && checkpointBound && contentOk !== false && legacyError === null,
1501
+ // `!eventBytesMalformed`: present-and-unreadable record bytes are a
1502
+ // defect in the artifact, never a disclosure choice (proof#85).
1503
+ ok:
1504
+ envelopeOk &&
1505
+ inclusionOk &&
1506
+ checkpointBound &&
1507
+ contentOk !== false &&
1508
+ legacyError === null &&
1509
+ !eventBytesMalformed,
1332
1510
  errors: evErrors,
1333
1511
  });
1334
1512
  }
@@ -1352,6 +1530,20 @@ async function verifyAnchorTrace(
1352
1530
  // the run to partial) and NEVER as a mismatch. Treating "we never
1353
1531
  // anchored it yet" as "the chain disagrees" would be the same false
1354
1532
  // accusation this whole change is removing.
1533
+ // proof#120. An `anchor_ref` that failed structural validation is not
1534
+ // an absent one. Nothing was fetched, so the witness dimension is NOT
1535
+ // CHECKED — `onChain: null`, exactly as when the caller supplied no
1536
+ // adapter — and it must not raise `gatewayUnavailable`, which is what
1537
+ // puts "could not be re-fetched from the supplied gateways" on the
1538
+ // bundle. Reporting an outage nobody observed is a claim about the
1539
+ // public record made out of a defect in the artifact, and verify-page
1540
+ // renders it word for word. The run still fails: the structural error
1541
+ // is already in `cpr.errors` and already in `ok` above.
1542
+ if (cpr.anchorRef === null && malformedAnchorRef.has(cpr)) {
1543
+ cpr.onChain = null;
1544
+ cpr.onChainOk = null;
1545
+ continue;
1546
+ }
1355
1547
  if (cpr.anchorRef === null) {
1356
1548
  gatewayUnavailable = true;
1357
1549
  cpr.onChain = {
@@ -1523,7 +1715,7 @@ async function verifyDisclosedContent(
1523
1715
  let recordContentHash: string | undefined;
1524
1716
  if (eventBytes !== undefined) {
1525
1717
  try {
1526
- const record = JSON.parse(new TextDecoder().decode(eventBytes)) as {
1718
+ const record = jcsParse(eventBytes) as {
1527
1719
  event?: { content_hash?: unknown };
1528
1720
  };
1529
1721
  const ch = record.event?.content_hash;
@@ -1632,7 +1824,7 @@ async function verifyStampEntry(
1632
1824
  try {
1633
1825
  bytes = hexToBytes(e.stamp_bytes);
1634
1826
  } catch (err) {
1635
- errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
1827
+ errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
1636
1828
  return { result: base };
1637
1829
  }
1638
1830
  // Gated by `isLowerHex` above, so this is already the exact lowercase hex of
@@ -1665,7 +1857,7 @@ async function verifyStampEntry(
1665
1857
  // --- the body itself ------------------------------------------------------
1666
1858
  let body: StampBody;
1667
1859
  try {
1668
- const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
1860
+ const parsed: unknown = jcsParse(bytes);
1669
1861
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1670
1862
  errors.push("stamp body is not a JSON object");
1671
1863
  return { result: base, witnessedHex };
@@ -1675,12 +1867,12 @@ async function verifyStampEntry(
1675
1867
  // every submit attempt for exactly this reason: a non-canonical body makes
1676
1868
  // a retry a DIFFERENT submission, and makes every independent re-derivation
1677
1869
  // of the stamp disagree with the one that was witnessed.
1678
- if (jcs(body as unknown as Record<string, unknown>) !== new TextDecoder().decode(bytes)) {
1870
+ if (jcs(body as unknown as Record<string, unknown>) !== decodeUtf8Strict(bytes)) {
1679
1871
  errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
1680
1872
  return { result: base, witnessedHex };
1681
1873
  }
1682
1874
  } catch (err) {
1683
- errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
1875
+ errors.push(`stamp body is not JSON${thrownDetail(err)}`);
1684
1876
  return { result: base, witnessedHex };
1685
1877
  }
1686
1878
 
@@ -1723,7 +1915,10 @@ async function verifyStampEntry(
1723
1915
  // --- the sub-tree ranges --------------------------------------------------
1724
1916
  base.subtreesOk = checkSubtrees(body, base, errors);
1725
1917
 
1726
- base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
1918
+ // A stamp needs no malformed/absent split: the §5.2 re-fetch path already
1919
+ // leaves `onChain` null whenever there is no `AnchorRef`, so it never
1920
+ // synthesised the outage proof#120 is about.
1921
+ base.anchorRef = parseAnchorRef(e.anchor_ref, errors).ref;
1727
1922
 
1728
1923
  base.ok =
1729
1924
  base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
@@ -1819,7 +2014,7 @@ async function verifyStampTrace(
1819
2014
  ): Promise<EvidenceBundleResult> {
1820
2015
  const body = b.body as StampTraceBody;
1821
2016
  if (!Array.isArray(body?.stamps)) {
1822
- return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
2017
+ return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true, true);
1823
2018
  }
1824
2019
 
1825
2020
  const seen = new Set<string>();
@@ -1922,7 +2117,11 @@ async function verifyStampEvidence(
1922
2117
  ): Promise<EvidenceBundleResult> {
1923
2118
  const body = b.body as StampEvidenceBody;
1924
2119
  if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
1925
- return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true);
2120
+ return malformed(
2121
+ `${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`,
2122
+ true,
2123
+ true,
2124
+ );
1926
2125
  }
1927
2126
 
1928
2127
  // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
@@ -1982,18 +2181,37 @@ async function verifyStampEvidence(
1982
2181
  const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
1983
2182
  if (legacyError !== null) evErrors.push(legacyError);
1984
2183
 
2184
+ // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
2185
+ //
2186
+ // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
2187
+ // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
2188
+ // line down. So an event carrying garbage in place of its record used to
2189
+ // reach the same tri-state as an event carrying nothing: `payloadHashOk`
2190
+ // null, `ok` true, a leading tick on the line, and a `verified` rollup over
2191
+ // a record nobody could read. The only thing wrong with the pack was the
2192
+ // bytes, and it was the one thing the verdict did not say.
2193
+ //
2194
+ // A record the producer chose not to disclose is the customer exercising
2195
+ // minimum disclosure. A record that is present and unreadable is a defect
2196
+ // in the artifact, and this kernel's standing rule (the same one
2197
+ // `checkpoints[]` already applies through `keyed.errors`) is that a defect
2198
+ // we can see fails.
1985
2199
  let eventBytes: Uint8Array | undefined;
2200
+ let eventBytesMalformed = false;
1986
2201
  try {
1987
2202
  if (typeof ev.event_bytes === "string") eventBytes = hexToBytes(ev.event_bytes);
1988
2203
  } catch (e) {
1989
- evErrors.push(`event event_bytes is not hex: ${stringifyErr(e)}`);
2204
+ eventBytesMalformed = true;
2205
+ evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1990
2206
  }
1991
2207
  try {
1992
2208
  const res = await verifyEnvelope(
1993
2209
  ev.envelope,
1994
2210
  eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1995
2211
  );
1996
- envelopeOk = res.ok; // tolerates an undetermined binding (withheld record)
2212
+ const hexError = envelopeHexError("event", ev.envelope);
2213
+ // tolerates an undetermined binding (withheld record)
2214
+ envelopeOk = res.ok && hexError === null;
1997
2215
  payloadBindingOk = res.payloadHashOk;
1998
2216
  // P-10: report what could not be determined, never a fault that was never
1999
2217
  // observed. There is no envelope here, so no signature was checked and no
@@ -2004,6 +2222,8 @@ async function verifyStampEvidence(
2004
2222
  // the producer at all (proof#76).
2005
2223
  if (!isJsonObject(ev.envelope)) {
2006
2224
  evErrors.push("event envelope is not a JSON object");
2225
+ } else if (hexError !== null) {
2226
+ evErrors.push(hexError);
2007
2227
  } else if (!res.signatureOk) {
2008
2228
  evErrors.push("event envelope signature failed");
2009
2229
  }
@@ -2014,7 +2234,7 @@ async function verifyStampEvidence(
2014
2234
  const specFinding = specVersionFinding("event", ev.envelope, res);
2015
2235
  if (specFinding !== null) evErrors.push(specFinding);
2016
2236
  } catch (e) {
2017
- evErrors.push(`event envelope verify error: ${stringifyErr(e)}`);
2237
+ evErrors.push(`event envelope verify error${thrownDetail(e)}`);
2018
2238
  }
2019
2239
 
2020
2240
  // --- The join (§5.3.3 rule 1) -------------------------------------------
@@ -2077,7 +2297,7 @@ async function verifyStampEvidence(
2077
2297
  );
2078
2298
  }
2079
2299
  } catch (e) {
2080
- evErrors.push(`inclusion verify error: ${stringifyErr(e)}`);
2300
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
2081
2301
  }
2082
2302
  }
2083
2303
  }
@@ -2094,7 +2314,15 @@ async function verifyStampEvidence(
2094
2314
  // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
2095
2315
  checkpointBound: stampBound,
2096
2316
  contentOk,
2097
- ok: envelopeOk && inclusionOk && stampBound && contentOk !== false && legacyError === null,
2317
+ // `!eventBytesMalformed`: present-and-unreadable record bytes are a
2318
+ // defect in the artifact, never a disclosure choice (proof#85).
2319
+ ok:
2320
+ envelopeOk &&
2321
+ inclusionOk &&
2322
+ stampBound &&
2323
+ contentOk !== false &&
2324
+ legacyError === null &&
2325
+ !eventBytesMalformed,
2098
2326
  errors: evErrors,
2099
2327
  });
2100
2328
  }
@@ -2574,7 +2802,7 @@ async function verifyExportBody(
2574
2802
  );
2575
2803
  }
2576
2804
  } catch (e) {
2577
- errors.push(`source_bundle canonicalization failed: ${stringifyErr(e)}`);
2805
+ errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
2578
2806
  }
2579
2807
 
2580
2808
  // --- Step 4: recompute the source verdict ---------------------------------
@@ -2622,7 +2850,7 @@ async function verifyExportBody(
2622
2850
  );
2623
2851
  }
2624
2852
  } catch (e) {
2625
- errors.push(`verdict agreement comparison failed: ${stringifyErr(e)}`);
2853
+ errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
2626
2854
  }
2627
2855
 
2628
2856
  // --- Step 7 already folded in by the source recompute ---------------------
@@ -2867,13 +3095,20 @@ async function verifyAttestation(
2867
3095
  try {
2868
3096
  payloadBytes = utf8(jcs(payload));
2869
3097
  } catch (e) {
2870
- return { result, malformed: `attestation payload canonicalization failed: ${stringifyErr(e)}` };
3098
+ return { result, malformed: `attestation payload canonicalization failed${thrownDetail(e)}` };
2871
3099
  }
2872
3100
  try {
2873
3101
  result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
2874
3102
  if (!result.signatureOk) errs.push("attestation RSA-PSS signature verification failed");
2875
- } catch (e) {
2876
- return { result, malformed: `attestation ${stringifyErr(e)}` };
3103
+ } catch {
3104
+ // A FIXED sentence, not the library's. This kernel throws
3105
+ // `verifyRsaPssSha256: malformed signature hex: ...` and the Python kernel
3106
+ // raises `malformed signature hex: ...`, so quoting either one split the
3107
+ // two kernels on a finding a holder can put in an export body it signs,
3108
+ // and an export pack's findings reach a case reading through `reasons`
3109
+ // (gate 4 on proof#140, finding 1). Callers of `verifyRsaPssSha256` still
3110
+ // get this kernel's own words.
3111
+ return { result, malformed: "attestation signature or key is malformed" };
2877
3112
  }
2878
3113
 
2879
3114
  // (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
@@ -2886,7 +3121,7 @@ async function verifyAttestation(
2886
3121
  );
2887
3122
  }
2888
3123
  } catch (e) {
2889
- return { result, malformed: `attestation operator key: ${stringifyErr(e)}` };
3124
+ return { result, malformed: `attestation operator key${thrownDetail(e)}` };
2890
3125
  }
2891
3126
 
2892
3127
  // (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
@@ -2911,7 +3146,7 @@ async function verifyAttestation(
2911
3146
  );
2912
3147
  }
2913
3148
  } catch (e) {
2914
- errs.push(`cannot recompute checkpoint content hash: ${stringifyErr(e)}`);
3149
+ errs.push(`cannot recompute checkpoint content hash${thrownDetail(e)}`);
2915
3150
  }
2916
3151
  }
2917
3152
 
@@ -3278,7 +3513,7 @@ async function probeGateway(
3278
3513
  // the load-bearing check.
3279
3514
  if (toHex(onChainBytes) === expectedHex) return "confirm";
3280
3515
  try {
3281
- const parsed = JSON.parse(new TextDecoder().decode(onChainBytes));
3516
+ const parsed = jcsParse(onChainBytes);
3282
3517
  if (toHex(utf8(jcs(parsed))) === expectedHex) return "confirm";
3283
3518
  } catch {
3284
3519
  // not JSON / not parseable — a genuine mismatch
@@ -3344,7 +3579,7 @@ function selectDisclosedBytes(
3344
3579
  try {
3345
3580
  inBody = hexToBytes(ev.content);
3346
3581
  } catch (e) {
3347
- errors.push(`event content is not hex: ${stringifyErr(e)}`);
3582
+ errors.push(`event content is not hex${thrownDetail(e)}`);
3348
3583
  }
3349
3584
  }
3350
3585
  let side: Uint8Array | undefined;
@@ -3355,7 +3590,7 @@ function selectDisclosedBytes(
3355
3590
  try {
3356
3591
  side = hexToBytes(sideEntry.value);
3357
3592
  } catch (e) {
3358
- errors.push(`supplied content for ${eventId} is not hex: ${stringifyErr(e)}`);
3593
+ errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
3359
3594
  }
3360
3595
  }
3361
3596
  // `offered` comes from the SAME test that produced the value, never from