@sanning/proof 0.12.0 → 0.14.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.
Files changed (59) hide show
  1. package/README.md +25 -12
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +353 -0
  6. package/dist/case.d.ts +217 -26
  7. package/dist/case.js +535 -124
  8. package/dist/cli.d.ts +64 -8
  9. package/dist/cli.js +799 -219
  10. package/dist/compose.js +6 -1
  11. package/dist/crypto.d.ts +1 -1
  12. package/dist/crypto.js +5 -1
  13. package/dist/declaration.d.ts +58 -52
  14. package/dist/declaration.js +256 -33
  15. package/dist/evidence.d.ts +38 -0
  16. package/dist/evidence.js +238 -22
  17. package/dist/exit-codes.d.ts +17 -0
  18. package/dist/exit-codes.js +50 -0
  19. package/dist/file-match.d.ts +105 -0
  20. package/dist/file-match.js +374 -0
  21. package/dist/grading.d.ts +28 -13
  22. package/dist/grading.js +90 -31
  23. package/dist/index.d.ts +14 -7
  24. package/dist/index.js +20 -6
  25. package/dist/message-parts.d.ts +18 -10
  26. package/dist/message-parts.js +25 -15
  27. package/dist/types.d.ts +1 -0
  28. package/dist/verifier.d.ts +63 -4
  29. package/dist/verifier.js +223 -15
  30. package/dist/witness-cache.d.ts +85 -0
  31. package/dist/witness-cache.js +264 -0
  32. package/dist/zip-reader.d.ts +50 -0
  33. package/dist/zip-reader.js +220 -0
  34. package/package.json +2 -2
  35. package/src/agent-proof.ts +4 -4
  36. package/src/case-file.ts +422 -0
  37. package/src/case.ts +671 -131
  38. package/src/cli.ts +932 -217
  39. package/src/compose.ts +8 -1
  40. package/src/crypto.ts +5 -1
  41. package/src/declaration.ts +248 -52
  42. package/src/evidence.ts +257 -23
  43. package/src/exit-codes.ts +54 -0
  44. package/src/file-match.ts +461 -0
  45. package/src/grading.ts +109 -52
  46. package/src/index.ts +101 -2
  47. package/src/message-parts.ts +25 -15
  48. package/src/types.ts +5 -0
  49. package/src/verifier.ts +225 -16
  50. package/src/witness-cache.ts +306 -0
  51. package/src/zip-reader.ts +229 -0
  52. package/vectors/README.md +66 -8
  53. package/vectors/case-file-index.json +668 -0
  54. package/vectors/case-split.json +218 -9
  55. package/vectors/file-match.json +1808 -0
  56. package/vectors/jcs-reader.json +82 -3
  57. package/vectors/pack-grading.json +68 -4
  58. package/vectors/stamp-on-chain.json +20 -5
  59. package/vectors/witness-cache.json +1304 -0
package/dist/evidence.js CHANGED
@@ -21,13 +21,15 @@ import { deriveOperatorAddress, ed25519Verify, hexToBytes,
21
21
  // body: two copies of one rule is how two kernels drift apart one edit at a
22
22
  // time, which is the whole defect class this fix is about.
23
23
  isHexField, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
24
+ import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
24
25
  import { leafHash, verifyInclusion } from "./merkle.js";
25
- import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, jcs, jcsParse, verifyEnvelope, } from "./verifier.js";
26
+ import { DuplicateKeyError, MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, isRfc3339UtcInstant, jcs, jcsParse, verifyEnvelope, } from "./verifier.js";
26
27
  import { inBodyContentOffered, sideContentEntry, } from "./side-content.js";
27
28
  // Only the evidence-bundle major(s) this kernel verifies. A new major is a
28
29
  // deliberate one-entry addition HERE — same fail-closed discipline as the
29
30
  // envelope verifier's ACCEPTED_SPEC_MAJORS.
30
- const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
31
+ /** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
32
+ export const ACCEPTED_EVIDENCE_MAJORS = Object.freeze(["sanning.evidence/v1"]);
31
33
  // The body_type this module knows how to verify structurally. An unknown
32
34
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
33
35
  // still verify), but its body cannot be re-derived, so the per-body verdict is
@@ -106,6 +108,41 @@ export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
106
108
  * signature is the §5.1.2 mixed-vocabulary reject, one layer down.
107
109
  */
108
110
  export const STAMP_BODY_VERSION = "sanning.stamp/v1";
111
+ /**
112
+ * A surface MUST NOT present a wrapper's `co_signatures` as evidence
113
+ * (evidence-bundle.md §2, decided by Phil 2026-09-24 on proof#148): nothing
114
+ * in either kernel verifies the field, and it sits outside the wrapper's
115
+ * signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
116
+ * append it with no key at all. This is the one sentence both kernels attach
117
+ * as a note, never a verdict, never an exit code.
118
+ */
119
+ export const CO_SIGNATURES_NOTE = "unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
120
+ /**
121
+ * True iff the wrapper's reserved `co_signatures` field carries at least one
122
+ * entry.
123
+ *
124
+ * Absence and an empty array read the same way here: the field asserts
125
+ * nothing about a second signer until it holds one, and evidence-bundle.md's
126
+ * own carve-out language for the analogous envelope field treats "absent"
127
+ * and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
128
+ * value (malformed shape, but the field is reserved, not yet specified)
129
+ * still counts as present: it is not the well-formed absence either rule
130
+ * describes.
131
+ */
132
+ export function coSignaturesPresent(b) {
133
+ if (b === null || typeof b !== "object" || Array.isArray(b))
134
+ return false;
135
+ const value = b.co_signatures;
136
+ if (!("co_signatures" in b) || value === null || value === undefined) {
137
+ return false;
138
+ }
139
+ if (Array.isArray(value))
140
+ return value.length > 0;
141
+ return true;
142
+ }
143
+ function coSignaturesNote(bundle) {
144
+ return coSignaturesPresent(bundle) ? CO_SIGNATURES_NOTE : undefined;
145
+ }
109
146
  function isEvidenceMajorAccepted(specVersion) {
110
147
  if (typeof specVersion !== "string" || specVersion === "")
111
148
  return false;
@@ -129,6 +166,8 @@ function isEvidenceMajorAccepted(specVersion) {
129
166
  * (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
130
167
  * broke its own body shape read as a forger.
131
168
  */
169
+ /** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
170
+ export const GENERATED_AT_MALFORMED = "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle §2)";
132
171
  function malformed(error, specVersionOk = false, wrapperOk = false) {
133
172
  return {
134
173
  status: "malformed",
@@ -148,7 +187,9 @@ function malformed(error, specVersionOk = false, wrapperOk = false) {
148
187
  * the Python kernel writes too. Anything outside this set comes from the host:
149
188
  * a JSON parser, a codec, a crypto library. See `thrownDetail`.
150
189
  */
151
- const KERNEL_WORDING = ["hexToBytes: "];
190
+ // `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
191
+ // `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
192
+ const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
152
193
  /**
153
194
  * The detail a finding may append about a caught exception.
154
195
  *
@@ -165,6 +206,29 @@ const KERNEL_WORDING = ["hexToBytes: "];
165
206
  * is the host's parse offset, which no reader can act on and which was never
166
207
  * the same in the two kernels.
167
208
  */
209
+ /**
210
+ * The kernel's `duplicate key "<name>"` when a record repeats a member name,
211
+ * else `undefined` (proof#144; `_record_duplicate_key` in the Python twin).
212
+ *
213
+ * ONLY A DUPLICATE, and not every unreadable record. Every reader below this
214
+ * one that parses a record treats an unreadable one as absent, so the content
215
+ * binding reads undetermined and the pack said "1 record discloses no bytes"
216
+ * about a record whose bytes it had read. A record that repeats a name is
217
+ * a defect in the artifact, and a defect this kernel can see fails, the rule
218
+ * `event_bytes` that are not hex already follow.
219
+ */
220
+ function recordDuplicateKey(eventBytes) {
221
+ if (eventBytes === undefined)
222
+ return undefined;
223
+ try {
224
+ jcsParse(eventBytes);
225
+ }
226
+ catch (e) {
227
+ if (e instanceof DuplicateKeyError)
228
+ return e.message;
229
+ }
230
+ return undefined;
231
+ }
168
232
  function thrownDetail(e) {
169
233
  const text = e instanceof Error ? e.message : String(e);
170
234
  return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
@@ -235,7 +299,17 @@ function jsonStr(value) {
235
299
  // (`assertedStatus`) but NEVER trusted — the displayed status is the one this
236
300
  // function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
237
301
  export async function verifyEvidenceBundle(bundle, options = {}) {
238
- return verifyBundle(bundle, options, false);
302
+ // The ONE place a present `co_signatures` becomes a note, so every caller
303
+ // (including `case.ts`, which calls this function directly per pack) gets
304
+ // it, however the wrapper otherwise verifies. Never touches `status` or
305
+ // the exit code: a bundle that would otherwise verify still verifies, note
306
+ // or not (proof#148).
307
+ const result = await verifyBundle(bundle, options, false);
308
+ const note = coSignaturesNote(bundle);
309
+ if (note !== undefined) {
310
+ result.notes = [...(result.notes ?? []), note];
311
+ }
312
+ return result;
239
313
  }
240
314
  // `exportSource`: read the bundle as an export's source (evidence-export.md
241
315
  // §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
@@ -254,12 +328,16 @@ async function verifyBundle(bundle, options, exportSource) {
254
328
  const b = bundle;
255
329
  const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
256
330
  if (!specVersionOk) {
257
- return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version)}`, false);
331
+ // `?? null`: an ABSENT field is spelled `null`, as the Python twin's
332
+ // `_json_str` spells it. `JSON.stringify(undefined)` is `undefined`, and
333
+ // the two kernels printed two different sentences for one bundle until
334
+ // `verify --json` compared them byte for byte (0.13.0).
335
+ return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
258
336
  }
259
337
  if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
260
338
  // The reference verifier implements ed25519 only (evidence-bundle §2 +
261
339
  // decision #2); other algs are a deliberate future addition.
262
- return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg)}`, true);
340
+ return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
263
341
  }
264
342
  if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
265
343
  return malformed("evidence bundle is missing public_key/signature", true);
@@ -308,7 +386,7 @@ async function verifyBundle(bundle, options, exportSource) {
308
386
  if (keyFormOk && signatureFormOk) {
309
387
  try {
310
388
  const { signature: _sig, co_signatures: _co, ...bundleForSig } = b;
311
- signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
389
+ signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
312
390
  if (!signatureOk)
313
391
  errors.push("wrapper Ed25519 signature verification failed");
314
392
  }
@@ -333,6 +411,16 @@ async function verifyBundle(bundle, options, exportSource) {
333
411
  errors,
334
412
  };
335
413
  }
414
+ // --- The wrapper's own time (evidence-bundle §2, A31) ---------------------
415
+ // `generated_at` is copied into a recomputed verdict's `as_of` (the export
416
+ // reading) and printed beside every verdict, so a present value that is not
417
+ // the RFC 3339 form `signed_at` already pins is a wrapper this kernel cannot
418
+ // render, whatever its signature. It is read only once the wrapper holds: a
419
+ // broken wrapper is `failed` above, as it was. Absence is unchanged.
420
+ if (Object.prototype.hasOwnProperty.call(b, "generated_at") &&
421
+ !isRfc3339UtcInstant(b.generated_at)) {
422
+ return malformed(GENERATED_AT_MALFORMED, specVersionOk, true);
423
+ }
336
424
  // --- Body dispatch ---------------------------------------------------------
337
425
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
338
426
  // Read as an export's source (evidence-export.md §2.4), the only body this
@@ -524,7 +612,7 @@ function parseAnchorRef(raw, errors) {
524
612
  }
525
613
  const o = raw;
526
614
  if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
527
- errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
615
+ errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind ?? null)}`);
528
616
  return { ref: null, malformed: true };
529
617
  }
530
618
  if (typeof o.id !== "string" || o.id === "") {
@@ -761,6 +849,11 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
761
849
  eventBytesMalformed = true;
762
850
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
763
851
  }
852
+ const duplicate = recordDuplicateKey(eventBytes);
853
+ if (duplicate !== undefined) {
854
+ eventBytesMalformed = true;
855
+ evErrors.push(`event record is not JSON: ${duplicate}`);
856
+ }
764
857
  try {
765
858
  const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
766
859
  const hexError = envelopeHexError("event", ev.envelope);
@@ -1076,21 +1169,62 @@ function isNonNegInt(n) {
1076
1169
  async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
1077
1170
  const evErrors = [];
1078
1171
  let contentOk = null;
1172
+ // WHICH hash in the record is decided by the record's STRUCTURE
1173
+ // (evidence-bundle §5.1 v1.19, A20). A record `envelope-spec.md` §4.1
1174
+ // governs (`isHandoffNamespace`) commits to its message through
1175
+ // `event.message_hash`, SHA-256 of `JCS(message object)`, and has no
1176
+ // `content_hash`, so until A20 its disclosure bound to nothing and an honest
1177
+ // hand-off pack could never grade `verified`. For such a record the committed
1178
+ // hash is `event.message_hash` and nothing else: not a `content_hash` it also
1179
+ // carries, and not the envelope's. It binds only when the record is a
1180
+ // CONFORMANT hand-off record under the key that signed it
1181
+ // (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
1182
+ // hex. Anything short of that commits nothing this reader will bind to, so
1183
+ // the reading is undetermined and never true: a record dressed as a hand-off
1184
+ // record gains no binding from the dress, and a record outside §4.1 keeps its
1185
+ // `content_hash` exactly as before, whatever `message_hash` field it writes.
1186
+ // The Python twin is `evidence._evaluate_content`.
1079
1187
  const committed = [];
1080
1188
  let recordContentHash;
1189
+ let governed = false;
1190
+ let handoffHash;
1081
1191
  if (eventBytes !== undefined) {
1082
1192
  try {
1083
- const record = jcsParse(eventBytes);
1084
- const ch = record.event?.content_hash;
1193
+ const parsed = jcsParse(eventBytes);
1194
+ const record = parsed;
1195
+ const ch = record?.event?.content_hash;
1085
1196
  if (typeof ch === "string" && ch)
1086
1197
  recordContentHash = ch.toLowerCase();
1198
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
1199
+ const envelope = ev.envelope;
1200
+ const held = {
1201
+ publicKey: envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
1202
+ ? envelope.public_key
1203
+ : undefined,
1204
+ record: parsed,
1205
+ ref: "",
1206
+ };
1207
+ if (isHandoffNamespace(held)) {
1208
+ governed = true;
1209
+ const event = parsed.event;
1210
+ const mh = event !== null && typeof event === "object" && !Array.isArray(event)
1211
+ ? event.message_hash
1212
+ : undefined;
1213
+ if (isConformantHandoffRecord(held) && isLowerHex(mh, 64))
1214
+ handoffHash = mh;
1215
+ }
1216
+ }
1087
1217
  }
1088
1218
  catch {
1089
1219
  // record not JSON — the payload binding already reflects that; content
1090
1220
  // stays undetermined rather than throwing.
1091
1221
  }
1092
1222
  }
1093
- if (recordContentHash !== undefined) {
1223
+ if (governed) {
1224
+ if (handoffHash !== undefined)
1225
+ committed.push(handoffHash);
1226
+ }
1227
+ else if (recordContentHash !== undefined) {
1094
1228
  committed.push(recordContentHash);
1095
1229
  }
1096
1230
  else if (ev.envelope && typeof ev.envelope === "object") {
@@ -1112,15 +1246,20 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
1112
1246
  if (disclosed === undefined || committed.length === 0) {
1113
1247
  contentOk = null;
1114
1248
  if (disclosed !== undefined && committed.length === 0) {
1115
- evErrors.push("disclosed content present but no committed content_hash to bind to " +
1116
- "(record withheld or record has no event.content_hash) -- undetermined");
1249
+ evErrors.push(governed
1250
+ ? "disclosed content present but this hand-off record commits no " +
1251
+ "message_hash a reader can bind to (not a conformant hand-off " +
1252
+ "record, or message_hash is not 64 lowercase hex) -- undetermined"
1253
+ : "disclosed content present but no committed content_hash to bind to " +
1254
+ "(record withheld or record has no event.content_hash) -- undetermined");
1117
1255
  }
1118
1256
  }
1119
1257
  else {
1120
1258
  const got = await sha256Hex(disclosed);
1121
1259
  contentOk = committed.includes(got);
1122
1260
  if (!contentOk) {
1123
- evErrors.push("disclosed content does not match the committed content_hash: " +
1261
+ const fieldName = governed ? "message_hash" : "content_hash";
1262
+ evErrors.push(`disclosed content does not match the committed ${fieldName}: ` +
1124
1263
  `sha256(disclosed)=${got} committed=${committed.join("|")}`);
1125
1264
  }
1126
1265
  }
@@ -1217,7 +1356,7 @@ async function verifyStampEntry(raw, seen) {
1217
1356
  // two answers to one question inside one signature. The body_type is the
1218
1357
  // reader's dispatch key and it said v1; honouring the other spelling is
1219
1358
  // how a reader half-parses a shape it does not know.
1220
- errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
1359
+ errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
1221
1360
  `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
1222
1361
  return { result: base, witnessedHex };
1223
1362
  }
@@ -1228,6 +1367,11 @@ async function verifyStampEntry(raw, seen) {
1228
1367
  errors.push(`stamp body ${f} is missing or not a non-empty string`);
1229
1368
  base.bodyOk = false;
1230
1369
  }
1370
+ else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
1371
+ // §5.2 (A31): the promise window is the form `signed_at` pins.
1372
+ errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
1373
+ base.bodyOk = false;
1374
+ }
1231
1375
  }
1232
1376
  base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
1233
1377
  base.intervalEnd = typeof body.interval_end === "string" ? body.interval_end : null;
@@ -1501,6 +1645,11 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1501
1645
  eventBytesMalformed = true;
1502
1646
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1503
1647
  }
1648
+ const duplicate = recordDuplicateKey(eventBytes);
1649
+ if (duplicate !== undefined) {
1650
+ eventBytesMalformed = true;
1651
+ evErrors.push(`event record is not JSON: ${duplicate}`);
1652
+ }
1504
1653
  try {
1505
1654
  const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
1506
1655
  const hexError = envelopeHexError("event", ev.envelope);
@@ -1702,8 +1851,8 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1702
1851
  * synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
1703
1852
  * run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
1704
1853
  * `partial` on its own, with a better error. Copying the synthesis would add
1705
- * nothing to the status and would silently move the CLI exit code from 0 to 3
1706
- * for an unwitnessed stamp — changing what an auditor's script does, based on
1854
+ * nothing to the status, which is `partial` either way, and would add an
1855
+ * unreachable-gateway note for a network event that never happened, based on
1707
1856
  * nothing but whether the caller happened to pass a gateway list.
1708
1857
  */
1709
1858
  async function refetchStamps(stamps, witnessed, options, errors) {
@@ -2016,8 +2165,11 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
2016
2165
  // step-5 erratum flagged in the slice report.
2017
2166
  let verdictAgreementOk = false;
2018
2167
  try {
2019
- const cachedProjection = jcs(projectVerdictForAgreement(body.kernel_verdict));
2020
- const freshProjection = jcs(projectVerdictForAgreement(verdict));
2168
+ const cached = projectVerdictForAgreement(body.kernel_verdict);
2169
+ const fresh = projectVerdictForAgreement(verdict);
2170
+ toleratePreA20Content(cached, fresh, sourceEventsGoverned(inline));
2171
+ const cachedProjection = jcs(cached);
2172
+ const freshProjection = jcs(fresh);
2021
2173
  verdictAgreementOk = cachedProjection === freshProjection;
2022
2174
  if (!verdictAgreementOk) {
2023
2175
  errors.push("recomputed verdict disagrees with the cached kernel_verdict " +
@@ -2046,8 +2198,8 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
2046
2198
  status = "failed";
2047
2199
  }
2048
2200
  else if (sourceResult.status === "partial") {
2049
- // Undetermined source (withheld record → exit 0; gateway-unreachable →
2050
- // exit 3 via the propagated "unreachable" note). Never a failure.
2201
+ // Undetermined source (a withheld record, or an unreachable gateway):
2202
+ // exit 3 either way since 0.13.0. Never a failure.
2051
2203
  status = "partial";
2052
2204
  }
2053
2205
  else {
@@ -2165,6 +2317,10 @@ function payloadFieldError(payload) {
2165
2317
  }
2166
2318
  if (!ok)
2167
2319
  return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
2320
+ if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
2321
+ // evidence-export §3.1 (v3.4): the form `signed_at` pins.
2322
+ return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
2323
+ }
2168
2324
  }
2169
2325
  return null;
2170
2326
  }
@@ -2193,7 +2349,7 @@ async function verifyAttestation(rec, sourceCheckpoints) {
2193
2349
  if (rec.signature_alg !== "rsa-pss-sha256") {
2194
2350
  return {
2195
2351
  result,
2196
- malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg)}`,
2352
+ malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg ?? null)}`,
2197
2353
  };
2198
2354
  }
2199
2355
  const pub = rec.public_key;
@@ -2421,6 +2577,66 @@ function projectVerdictForAgreement(v) {
2421
2577
  }),
2422
2578
  };
2423
2579
  }
2580
+ // Per event of an export's inline `sanning.anchor.trace/v3` source, in order:
2581
+ // whether `envelope-spec.md` §4.1 governs its record. Decoded the way the
2582
+ // content binding decodes it, so an event whose record is withheld, not hex or
2583
+ // not JCS is not governed here either. The Python twin is
2584
+ // `evidence._source_events_governed`.
2585
+ function sourceEventsGoverned(source) {
2586
+ if (source === null || typeof source !== "object" || Array.isArray(source))
2587
+ return [];
2588
+ const s = source;
2589
+ if (s.body_type !== ANCHOR_TRACE_BODY_TYPE)
2590
+ return [];
2591
+ const body = s.body;
2592
+ const events = body !== null && typeof body === "object" && !Array.isArray(body) ? body.events : undefined;
2593
+ if (!Array.isArray(events))
2594
+ return [];
2595
+ return events.map((raw) => {
2596
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
2597
+ return false;
2598
+ const ev = raw;
2599
+ if (typeof ev.event_bytes !== "string")
2600
+ return false;
2601
+ try {
2602
+ const parsed = jcsParse(hexToBytes(ev.event_bytes));
2603
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
2604
+ return false;
2605
+ const envelope = ev.envelope;
2606
+ const held = {
2607
+ publicKey: envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
2608
+ ? envelope.public_key
2609
+ : undefined,
2610
+ record: parsed,
2611
+ ref: "",
2612
+ };
2613
+ return isHandoffNamespace(held);
2614
+ }
2615
+ catch {
2616
+ return false;
2617
+ }
2618
+ });
2619
+ }
2620
+ // The one difference §5 step 5 does not read as a disagreement
2621
+ // (evidence-export.md, evidence-bundle.md v1.19, A20): a cached `content_ok` of
2622
+ // `null` against a recomputed `true`, on an event whose record §4.1 governs. A
2623
+ // kernel before A20 could not bind a hand-off record's disclosed message, so an
2624
+ // export it composed claims less than the recompute proves, and reading that
2625
+ // as tamper failed an honest export. Only that direction: a cached `true`
2626
+ // against a recomputed `false` or `null`, `null` against `false`, and any
2627
+ // difference on a record outside §4.1 still disagree. Edits `fresh` in place
2628
+ // to match `cached` on those events. The Python twin is
2629
+ // `evidence._tolerate_pre_a20_content`.
2630
+ function toleratePreA20Content(cached, fresh, governed) {
2631
+ const n = Math.min(cached.events.length, fresh.events.length);
2632
+ for (let i = 0; i < n; i++) {
2633
+ if (governed[i] === true &&
2634
+ cached.events[i].content_ok === null &&
2635
+ fresh.events[i].content_ok === true) {
2636
+ fresh.events[i].content_ok = null;
2637
+ }
2638
+ }
2639
+ }
2424
2640
  function summarizeExport(status, source, attestations) {
2425
2641
  const attOk = attestations.filter((a) => a.ok).length;
2426
2642
  return (`Export ${status}: source bundle ${source.status} ` +
@@ -0,0 +1,17 @@
1
+ export declare const EXIT_VERIFIED = 0;
2
+ export declare const EXIT_FAILED = 1;
3
+ export declare const EXIT_MALFORMED = 2;
4
+ export declare const EXIT_UNDETERMINED = 3;
5
+ export declare const EXIT_MIXED = 4;
6
+ /**
7
+ * The exit code of each of the eight verdict words (`evidence-bundle.md` §3).
8
+ * `undetermined` is the name of 3, and never a word here.
9
+ */
10
+ export declare const EXIT_CODES: Readonly<Record<string, number>>;
11
+ /** What every verb's help prints, identical in both commands. */
12
+ export declare const EXIT_CODES_HELP: string;
13
+ /**
14
+ * The exit code of a verdict word. A word outside the eight is a verdict
15
+ * nothing can render, so it exits 2, never 0.
16
+ */
17
+ export declare function exitCodeFor(verdict: unknown): number;
@@ -0,0 +1,50 @@
1
+ // One exit-code table for every verb (0.13.0).
2
+ //
3
+ // Both commands, `npx @sanning/proof` and `sanning-proof`, map a verdict word to
4
+ // an exit code through this one table, for `verify`, `case` and `export` alike,
5
+ // and every verb's `--help` prints `EXIT_CODES_HELP` byte for byte. The twin is
6
+ // `src/sanning_proof/exit_codes.py`; the specification is `evidence-export.md`
7
+ // §5.
8
+ //
9
+ // Until 0.13.0 `verify` exited 0 on `partial` unless a gateway was unreachable,
10
+ // while `case` exited 3 on it, so a CI gate reading `verify`'s exit code read a
11
+ // stamp witnessed by nobody as verified. Every word that means "could not be
12
+ // settled" now exits 3, which is the code's name: undetermined.
13
+ export const EXIT_VERIFIED = 0;
14
+ export const EXIT_FAILED = 1;
15
+ export const EXIT_MALFORMED = 2;
16
+ export const EXIT_UNDETERMINED = 3;
17
+ export const EXIT_MIXED = 4;
18
+ /**
19
+ * The exit code of each of the eight verdict words (`evidence-bundle.md` §3).
20
+ * `undetermined` is the name of 3, and never a word here.
21
+ */
22
+ export const EXIT_CODES = Object.freeze({
23
+ verified: EXIT_VERIFIED,
24
+ failed: EXIT_FAILED,
25
+ malformed: EXIT_MALFORMED,
26
+ partial: EXIT_UNDETERMINED,
27
+ pending: EXIT_UNDETERMINED,
28
+ incomplete: EXIT_UNDETERMINED,
29
+ not_found: EXIT_UNDETERMINED,
30
+ mixed: EXIT_MIXED,
31
+ });
32
+ /** What every verb's help prints, identical in both commands. */
33
+ export const EXIT_CODES_HELP = [
34
+ "Exit codes, one table for every verb:",
35
+ " 0 verified, or the export was written",
36
+ " 1 failed",
37
+ " 2 usage or malformed",
38
+ " 3 undetermined: partial, pending, incomplete or not_found",
39
+ " 4 mixed: more than one outcome, none failed; read the exceptions",
40
+ ].join("\n");
41
+ /**
42
+ * The exit code of a verdict word. A word outside the eight is a verdict
43
+ * nothing can render, so it exits 2, never 0.
44
+ */
45
+ export function exitCodeFor(verdict) {
46
+ if (typeof verdict === "string" && Object.prototype.hasOwnProperty.call(EXIT_CODES, verdict)) {
47
+ return EXIT_CODES[verdict];
48
+ }
49
+ return EXIT_MALFORMED;
50
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Files handed in beside a pack, matched against what its records signed (A29).
3
+ *
4
+ * The TypeScript half of `src/sanning_proof/file_match.py`. Both kernels are
5
+ * held to one table, `vectors/file-match.json`, so neither can pass by agreeing
6
+ * with the other, and `cross-kernel/run_file_match.sh` holds the two readings,
7
+ * and the two commands' `--files` output, to the same bytes.
8
+ *
9
+ * WHAT THIS ANSWERS (evidence-bundle.md 5.7 v1.24, proof#92, decided by Phil
10
+ * 2026-09-23 as option 2). A reader holds a pack and some files beside it and
11
+ * asks whether the pack vouches for them. Each offered file is hashed, SHA-256
12
+ * over its exact bytes, and the digest is looked for among the commitments the
13
+ * pack's SIGNED records make. The reading says which offered files a record
14
+ * commits to (naming the record, its signer and its `signed_at`), which match
15
+ * no record, and which commitments had no file offered.
16
+ *
17
+ * THE RULES THAT CARRY THE WEIGHT:
18
+ *
19
+ * - By hash, and by nothing else. A file's name is carried through as the
20
+ * caller's label and is never compared with an `event_id`, a part's name or
21
+ * a `logs/` path. There is no layout convention.
22
+ * - Unmatched is reported, never guessed.
23
+ * - Only what a record's own key signed: a verified envelope whose
24
+ * `event_bytes` bind to its `payload_hash`, or a hand-off message whose
25
+ * bytes bind to that record's `message_hash`. No wrapper field is read.
26
+ * - The verdict does not move. A match proves that a signed record in the
27
+ * pack committed to those exact bytes, and nothing more (`FILE_MATCH_SCOPE`).
28
+ *
29
+ * WHAT COUNTS AS A COMMITMENT, in record order and then part order:
30
+ *
31
+ * 1. `record`: a record outside envelope-spec.md 4.1 commits through
32
+ * `event.content_hash`, 64 hex characters in either case.
33
+ * 2. `file_part`: a record 4.1 governs commits to its MESSAGE through
34
+ * `event.message_hash` (5.1, A20), never through a `content_hash` it also
35
+ * writes. Where the message's JCS bytes are in hand (in the body, or in
36
+ * the side content) and bind to it, each file part of that message (A18)
37
+ * commits by its `content_hash`.
38
+ *
39
+ * Side content is read for ONE thing, to find a hand-off message that binds to
40
+ * a signed `message_hash`. It is never an offered file.
41
+ *
42
+ * Pure: a bundle, the files and the optional side content in, a reading out.
43
+ */
44
+ import type { SideContentMap } from "./side-content.js";
45
+ /** The one sentence every surface prints with the reading (5.7). */
46
+ export declare const FILE_MATCH_SCOPE: string;
47
+ /** Why a record's commitments could not be read, in the order they are tested. */
48
+ export declare const NOT_READ_REASONS: readonly ["no_records", "record_unreadable", "record_not_verified", "record_withheld", "no_content_hash", "handoff_record_malformed", "message_withheld"];
49
+ export type NotReadReason = (typeof NOT_READ_REASONS)[number];
50
+ /** One commitment a signed record makes to a file's bytes. */
51
+ export interface FileCommitment {
52
+ packRef: string | null;
53
+ eventId: string | null;
54
+ kind: "record" | "file_part";
55
+ partIndex: number | null;
56
+ contentHash: string;
57
+ signer: string;
58
+ signedAt: string | null;
59
+ }
60
+ export interface MatchedFile {
61
+ name: string;
62
+ sha256: string;
63
+ records: FileCommitment[];
64
+ }
65
+ export interface UnmatchedFile {
66
+ name: string;
67
+ sha256: string;
68
+ }
69
+ export interface NotRead {
70
+ packRef: string | null;
71
+ eventId: string | null;
72
+ reason: NotReadReason;
73
+ }
74
+ export interface FileMatchReading {
75
+ offered: number;
76
+ matched: MatchedFile[];
77
+ unmatched: UnmatchedFile[];
78
+ notOffered: FileCommitment[];
79
+ notRead: NotRead[];
80
+ scope: string;
81
+ }
82
+ /** A file handed in beside a pack. `name` is a label and is never compared. */
83
+ export interface OfferedFile {
84
+ name: string;
85
+ bytes: Uint8Array;
86
+ }
87
+ /** One pack of a case, for `matchCaseFiles`. */
88
+ export interface FileMatchPack {
89
+ ref: string;
90
+ bundle: unknown;
91
+ content?: SideContentMap | null;
92
+ }
93
+ /**
94
+ * Match each offered file against the commitments one pack's signed records
95
+ * make. `content` is the side content handed in beside the pack, read only to
96
+ * find a hand-off message that binds to its record's `message_hash`; it is
97
+ * never an offered file.
98
+ */
99
+ export declare function matchFiles(bundle: unknown, files: readonly OfferedFile[], content?: SideContentMap | null): Promise<FileMatchReading>;
100
+ /** The same reading over every pack of a case, each commitment naming its pack's `ref`. */
101
+ export declare function matchCaseFiles(packs: readonly FileMatchPack[], files: readonly OfferedFile[]): Promise<FileMatchReading>;
102
+ /** The reading as the `files` member of `--json`, identical in both kernels. */
103
+ export declare function fileMatchToJson(reading: FileMatchReading): Record<string, unknown>;
104
+ /** The text report's lines for the reading, identical in both kernels. */
105
+ export declare function renderFileMatch(reading: FileMatchReading): string[];