@sanning/proof 0.13.0 → 0.15.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/cli.js CHANGED
@@ -48,12 +48,21 @@ import { basename, join, resolve } from "node:path";
48
48
  import { fileURLToPath } from "node:url";
49
49
  import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
50
50
  import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
51
- import { CASE_RELIANCE, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
51
+ import { VERDICT_DISPLAY_TITLES, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
52
52
  import { CASE_FILE_INDEX_NAME, jsonDepthExceeds, readCaseFileIndex, renderCaseFileFinding, } from "./case-file.js";
53
53
  import { ZipReadError, readZip } from "./zip-reader.js";
54
54
  import { composeExport } from "./compose.js";
55
55
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
56
56
  import { verifyEvidenceBundle } from "./evidence.js";
57
+ import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
58
+ import { fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch } from "./file-match.js";
59
+ import { WITNESS_CACHE_NAME, readWitnessCache, } from "./witness-cache.js";
60
+ // The public-record (witness) lookup this command runs with `--gateways`:
61
+ // moved to its own pure module (proof#200) so a consumer that is not this CLI,
62
+ // the verify page, can call the SAME implementation. `onChainHalf` and
63
+ // `witnessLine` are this command's own two building blocks, unchanged; see
64
+ // `./witness-lookup.ts` for the exported entry point, `lookupPublicRecord`.
65
+ import { onChainHalf, witnessLine } from "./witness-lookup.js";
57
66
  import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
58
67
  import { completenessNotes, gradePack, renderCause } from "./grading.js";
59
68
  import { EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
@@ -199,6 +208,7 @@ async function runVerify(rest, io, sink) {
199
208
  // second positional argument until then, and a second positional is now a
200
209
  // usage error). The one positional is the bundle.
201
210
  let logsPath;
211
+ let filePaths;
202
212
  let gatewayArg;
203
213
  const positional = [];
204
214
  for (let i = 0; i < rest.length; i++) {
@@ -225,6 +235,17 @@ async function runVerify(rest, io, sink) {
225
235
  i++;
226
236
  continue;
227
237
  }
238
+ if (a === "--files") {
239
+ const taken = takeFilePaths(rest, i + 1);
240
+ if (taken.length === 0) {
241
+ io.err("verify: --files requires at least one file path");
242
+ printUsage(io);
243
+ return EXIT_MALFORMED;
244
+ }
245
+ filePaths = [...(filePaths ?? []), ...taken];
246
+ i += taken.length;
247
+ continue;
248
+ }
228
249
  positional.push(a);
229
250
  }
230
251
  const [bundlePath] = positional;
@@ -251,6 +272,15 @@ async function runVerify(rest, io, sink) {
251
272
  io.err(`note: ${n}`);
252
273
  }
253
274
  const content = logs?.content;
275
+ let offered;
276
+ if (filePaths !== undefined) {
277
+ const read = await readOfferedFiles(filePaths);
278
+ if (typeof read === "string") {
279
+ io.err(`verify: ${read}`);
280
+ return EXIT_MALFORMED;
281
+ }
282
+ offered = read;
283
+ }
254
284
  let raw;
255
285
  try {
256
286
  raw = new Uint8Array(await readFile(bundlePath));
@@ -270,6 +300,9 @@ async function runVerify(rest, io, sink) {
270
300
  if (e instanceof BundleReadError && e.tooDeep) {
271
301
  io.err(`${bundlePath} ${e.detail}; refusing to read it`);
272
302
  }
303
+ else if (e instanceof BundleReadError && e.duplicate) {
304
+ io.err(`${bundlePath} ${NOT_I_JSON}: ${e.detail}`);
305
+ }
273
306
  else {
274
307
  io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
275
308
  }
@@ -284,6 +317,9 @@ async function runVerify(rest, io, sink) {
284
317
  // has no such per-event content. Say so rather than silently ignoring it.
285
318
  io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
286
319
  }
320
+ if (offered) {
321
+ io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
322
+ }
287
323
  const result = await verifyAgentProof(parsed, {
288
324
  gateways,
289
325
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
@@ -318,12 +354,48 @@ async function runVerify(rest, io, sink) {
318
354
  chains = { unreadable: errMsg(e) };
319
355
  }
320
356
  const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
357
+ // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
358
+ // beside the verdict, never inside it, so `exit` is untouched.
359
+ if (offered !== undefined) {
360
+ const reading = await matchFiles(parsed, offered, content);
361
+ for (const line of renderFileMatch(reading))
362
+ io.out(line);
363
+ if (sink !== null && sink.data !== null)
364
+ sink.data.files = fileMatchToJson(reading);
365
+ }
321
366
  // Coverage is reported only once the bundle parsed far enough to have events
322
367
  // to compare against; over a MALFORMED bundle there is nothing to say.
323
368
  if (logs && result.status !== "malformed")
324
369
  reportLogsCoverage(logs, result, io);
325
370
  return exit;
326
371
  }
372
+ // The paths after `--files`: every argument up to the next option.
373
+ function takeFilePaths(args, start) {
374
+ const out = [];
375
+ for (const arg of args.slice(start)) {
376
+ if (arg.startsWith("--"))
377
+ break;
378
+ out.push(arg);
379
+ }
380
+ return out;
381
+ }
382
+ // Each `--files` path as `{ name, bytes }`, the name exactly as typed, or the
383
+ // one error line when a path is not a readable regular file. The line names the
384
+ // path and not the operating system's words, which differ by kernel.
385
+ async function readOfferedFiles(paths) {
386
+ const out = [];
387
+ for (const path of paths) {
388
+ try {
389
+ if (!(await stat(path)).isFile())
390
+ return `--files ${path} is not a readable file`;
391
+ out.push({ name: path, bytes: new Uint8Array(await readFile(path)) });
392
+ }
393
+ catch {
394
+ return `--files ${path} is not a readable file`;
395
+ }
396
+ }
397
+ return out;
398
+ }
327
399
  // `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
328
400
  //
329
401
  // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
@@ -514,9 +586,13 @@ async function readJson(path, io) {
514
586
  return undefined;
515
587
  }
516
588
  try {
517
- return JSON.parse(raw);
589
+ return parseRefusingDuplicates(raw);
518
590
  }
519
591
  catch (e) {
592
+ if (e instanceof DuplicateKeyError) {
593
+ io.err(`${path} ${NOT_I_JSON}: ${e.message}`);
594
+ return undefined;
595
+ }
520
596
  io.err(`${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
521
597
  return undefined;
522
598
  }
@@ -598,9 +674,15 @@ async function loadLogsMap(logsPath, io) {
598
674
  }
599
675
  let logsParsed;
600
676
  try {
601
- logsParsed = JSON.parse(logsRaw);
677
+ logsParsed = parseRefusingDuplicates(logsRaw);
602
678
  }
603
679
  catch (e) {
680
+ if (e instanceof DuplicateKeyError) {
681
+ // One event id named twice hands the reader two contents for one
682
+ // record, and the last one silently won (proof#144).
683
+ io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
684
+ return undefined;
685
+ }
604
686
  io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
605
687
  return undefined;
606
688
  }
@@ -775,10 +857,12 @@ function printUsage(io) {
775
857
  io.out(" (npx @sanning/proof <command> [args], without installing)");
776
858
  io.out("");
777
859
  io.out("Commands:");
778
- io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--json] Verify a bundle");
860
+ io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--files <path>...] [--json]");
861
+ io.out(" Verify a bundle");
779
862
  io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
780
863
  io.out(" Create an attested export");
781
- io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
864
+ io.out(" case <folder>... [--gateways <urls>] [--files <path>...] [--json]");
865
+ io.out(" Read several packs as one case");
782
866
  io.out(" (`case --help` for its exit codes)");
783
867
  io.out("");
784
868
  io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
@@ -791,6 +875,14 @@ function printUsage(io) {
791
875
  io.out("--json Print the verdict, the causes and the exit code as");
792
876
  io.out(" JSON, for a script, instead of the report.");
793
877
  io.out("");
878
+ io.out("--files <path>... Files handed in beside the pack, every path up to the");
879
+ io.out(" next option. Each is hashed and matched, by SHA-256");
880
+ io.out(" alone, against what the pack's signed records commit");
881
+ io.out(" to; the report lists the files the pack vouches for,");
882
+ io.out(" the files that match no record, and the commitments");
883
+ io.out(" no file was offered for. Names and folders are never");
884
+ io.out(" compared, and the verdict never moves.");
885
+ io.out("");
794
886
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
795
887
  io.out(" content_hash, or message_hash for a hand-off record.");
796
888
  io.out(" Either shape works, and which one you");
@@ -1234,17 +1326,26 @@ export class CaseInputError extends Error {
1234
1326
  /**
1235
1327
  * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1236
1328
  * the one refusal that is not a parse error; `detail` is the parser's own words,
1237
- * which differ by kernel and are printed by `verify` only.
1329
+ * which differ by kernel and are printed by `verify` only. `duplicate` marks the
1330
+ * one parse refusal whose `detail` is kernel wording, identical in both kernels
1331
+ * (`duplicate key "<name>"`, proof#144), so both commands print it.
1238
1332
  */
1239
1333
  export class BundleReadError extends Error {
1240
1334
  tooDeep;
1241
1335
  detail;
1242
- constructor(tooDeep, detail) {
1336
+ duplicate;
1337
+ constructor(tooDeep, detail, duplicate = false) {
1243
1338
  super(detail);
1244
1339
  this.tooDeep = tooDeep;
1245
1340
  this.detail = detail;
1341
+ this.duplicate = duplicate;
1246
1342
  }
1247
1343
  }
1344
+ // What both commands say about bytes that repeat a member name, before the
1345
+ // kernel's own `duplicate key "<name>"`. RFC 8259 JSON permits a repeat and
1346
+ // I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
1347
+ // would be the wrong sentence. `NOT_I_JSON` in the Python twin.
1348
+ export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
1248
1349
  // `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
1249
1350
  // `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
1250
1351
  const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
@@ -1253,7 +1354,8 @@ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
1253
1354
  * How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
1254
1355
  * kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
1255
1356
  * sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
1256
- * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`.
1357
+ * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`,
1358
+ * refusing an object that repeats a member name (I-JSON, RFC 7493; proof#144).
1257
1359
  * Throws `BundleReadError`.
1258
1360
  */
1259
1361
  export function readBundleBytes(bytes) {
@@ -1262,9 +1364,11 @@ export function readBundleBytes(bytes) {
1262
1364
  throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
1263
1365
  }
1264
1366
  try {
1265
- return JSON.parse(text);
1367
+ return parseRefusingDuplicates(text);
1266
1368
  }
1267
1369
  catch (e) {
1370
+ if (e instanceof DuplicateKeyError)
1371
+ throw new BundleReadError(false, e.message, true);
1268
1372
  throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
1269
1373
  }
1270
1374
  }
@@ -1332,6 +1436,30 @@ export async function splitCase(tree, read) {
1332
1436
  }
1333
1437
  return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
1334
1438
  }
1439
+ /**
1440
+ * The bytes of the `witness-cache.json` beside a pack's `bundle.json`
1441
+ * (evidence-bundle 5.6.6, v1.25, A30), or `null`.
1442
+ *
1443
+ * The split rule is not changed for it: the file stays outside every pack,
1444
+ * listed as such, and is opened here and nowhere else. It is read only where it
1445
+ * is a regular file the split left outside every pack, so a file under another
1446
+ * pack's `logs/` is that pack's, and a link is never followed. A file that
1447
+ * cannot be read is no cache, never a refusal of the drop.
1448
+ */
1449
+ async function witnessCacheBytes(tree, split, pack) {
1450
+ const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
1451
+ const entry = tree.get(path);
1452
+ if (entry === undefined || !entry.regular || !split.outside.includes(path))
1453
+ return null;
1454
+ try {
1455
+ return await readFileEntry(entry, path);
1456
+ }
1457
+ catch (e) {
1458
+ if (e instanceof CaseInputError)
1459
+ return null;
1460
+ throw e;
1461
+ }
1462
+ }
1335
1463
  /** `verify`'s reader, with the refusal named by the pack's path. */
1336
1464
  export function loadBundle(pack) {
1337
1465
  try {
@@ -1341,6 +1469,9 @@ export function loadBundle(pack) {
1341
1469
  if (e instanceof BundleReadError && e.tooDeep) {
1342
1470
  throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
1343
1471
  }
1472
+ if (e instanceof BundleReadError && e.duplicate) {
1473
+ throw new CaseInputError(`${pack.bundlePath} ${NOT_I_JSON}: ${e.detail}`);
1474
+ }
1344
1475
  throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1345
1476
  }
1346
1477
  }
@@ -1543,7 +1674,7 @@ async function unzip(arg, prefix, tree, seen) {
1543
1674
  // 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
1544
1675
  export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
1545
1676
 
1546
- sanning-proof case <folder or case file>... [--gateways <url,url>] [--json]
1677
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
1547
1678
 
1548
1679
  <folder> a pack folder (bundle.json plus logs/), a folder that holds
1549
1680
  several packs, or an unzipped case file. Packs are found by the
@@ -1567,6 +1698,10 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
1567
1698
  --gateways comma-separated gateway URLs. Each pack's witnesses are then
1568
1699
  looked up on the public record, pack by pack, and reported on
1569
1700
  the witness line. The lookup never changes the verdict word.
1701
+ --files files handed in beside the packs, every path up to the next
1702
+ option, matched by SHA-256 alone against what every pack's
1703
+ signed records commit to. A file under a pack's logs/ is
1704
+ offered only when it is named here. The verdict never moves.
1570
1705
  --json the full reading as JSON: the verdict, every exception and note
1571
1706
  with its rendered line, every record, every contribution, the
1572
1707
  packs, the files outside them, the case file index comparison,
@@ -1615,94 +1750,6 @@ function sortKeys(v) {
1615
1750
  function jsonText(v) {
1616
1751
  return JSON.stringify(sortKeys(v), null, 2);
1617
1752
  }
1618
- // Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
1619
- // case reading on purpose: `readCase` makes no network call, and neither does
1620
- // the verify page's case reading, so the case verdict stays the one both
1621
- // surfaces compute. This adds the half the reading names as not checked.
1622
- async function onChainHalf(packs, gateways, io) {
1623
- if (gateways.length === 0)
1624
- return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
1625
- const rows = [];
1626
- const answered = new Set();
1627
- for (const [ref, bundle] of packs) {
1628
- const result = await verifyEvidenceBundle(bundle, {
1629
- gateways,
1630
- ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
1631
- });
1632
- const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
1633
- const row = {
1634
- ref,
1635
- witnesses: witnesses.length,
1636
- confirmed: 0,
1637
- mismatched: 0,
1638
- unreachable: 0,
1639
- not_checked: 0,
1640
- };
1641
- for (const w of witnesses) {
1642
- const rollup = w.onChain?.rollup ?? null;
1643
- if (rollup === "confirm")
1644
- row.confirmed++;
1645
- else if (rollup === "mismatch")
1646
- row.mismatched++;
1647
- else if (rollup === "unreachable")
1648
- row.unreachable++;
1649
- else
1650
- row.not_checked++;
1651
- }
1652
- rows.push(row);
1653
- for (const w of witnesses) {
1654
- for (const outcome of w.onChain?.perGateway ?? []) {
1655
- if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
1656
- answered.add(outcome.gateway);
1657
- }
1658
- }
1659
- }
1660
- }
1661
- const verdict = rows.some((r) => r.mismatched > 0)
1662
- ? "failed"
1663
- : rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
1664
- ? "undetermined"
1665
- : "confirmed";
1666
- return {
1667
- answered: gateways.filter((g) => answered.has(g)),
1668
- checked: true,
1669
- gateways: [...gateways],
1670
- packs: rows,
1671
- verdict,
1672
- };
1673
- }
1674
- /**
1675
- * The witness line (evidence-bundle 5.5.6 v1.20, A24, D7) from the on-chain
1676
- * half: `confirmed` is `confirmed`, `not_checked` and `undetermined` are
1677
- * `not_checked` with the reason, and `failed` is `differs`. It never moves the
1678
- * headline.
1679
- */
1680
- export function witnessLine(onChain) {
1681
- if (!onChain.checked) {
1682
- return {
1683
- state: WITNESS_NOT_LOOKED_UP.state,
1684
- reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
1685
- services: [...WITNESS_NOT_LOOKED_UP.services],
1686
- };
1687
- }
1688
- const services = [...(onChain.answered ?? [])];
1689
- if (onChain.verdict === "failed")
1690
- return { state: "differs", reasons: [], services };
1691
- if (onChain.verdict === "confirmed")
1692
- return { state: "confirmed", reasons: [], services };
1693
- const rows = onChain.packs;
1694
- const reasons = [];
1695
- if (rows.some((r) => r.witnesses === 0))
1696
- reasons.push("a pack names no witness");
1697
- if (rows.some((r) => r.unreachable > 0)) {
1698
- reasons.push("a lookup did not settle: the service could not be reached, or it " +
1699
- "holds no such witness yet");
1700
- }
1701
- if (rows.some((r) => r.not_checked > 0)) {
1702
- reasons.push("no lookup is made for a witness kind a pack names");
1703
- }
1704
- return { state: "not_checked", reasons, services };
1705
- }
1706
1753
  /**
1707
1754
  * The case verdict's code, moved only by an on-chain half that was asked for:
1708
1755
  * a witness that does not match is a failure whatever the reading said, and an
@@ -1859,6 +1906,7 @@ async function caseCommand(argv, io) {
1859
1906
  let asJson = false;
1860
1907
  let gateways = [];
1861
1908
  const paths = [];
1909
+ let filePaths;
1862
1910
  for (let i = 0; i < argv.length; i++) {
1863
1911
  const arg = argv[i];
1864
1912
  if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
@@ -1881,6 +1929,16 @@ async function caseCommand(argv, io) {
1881
1929
  .filter((g) => g.length > 0);
1882
1930
  i++;
1883
1931
  }
1932
+ else if (arg === "--files") {
1933
+ const taken = takeFilePaths(argv, i + 1);
1934
+ if (taken.length === 0) {
1935
+ io.err("case: --files requires at least one file path");
1936
+ outBlock(io, CASE_USAGE);
1937
+ return EXIT_MALFORMED;
1938
+ }
1939
+ filePaths = [...(filePaths ?? []), ...taken];
1940
+ i += taken.length;
1941
+ }
1884
1942
  else if (arg.startsWith("--")) {
1885
1943
  io.err(`case: unknown option ${arg}`);
1886
1944
  outBlock(io, CASE_USAGE);
@@ -1895,6 +1953,13 @@ async function caseCommand(argv, io) {
1895
1953
  outBlock(io, CASE_USAGE);
1896
1954
  return EXIT_MALFORMED;
1897
1955
  }
1956
+ let offered;
1957
+ if (filePaths !== undefined) {
1958
+ const read = await readOfferedFiles(filePaths);
1959
+ if (typeof read === "string")
1960
+ return caseError(new CaseInputError(read), asJson, io);
1961
+ offered = read;
1962
+ }
1898
1963
  let split;
1899
1964
  let tree;
1900
1965
  let bundles;
@@ -1918,20 +1983,26 @@ async function caseCommand(argv, io) {
1918
1983
  return caseError(e, asJson, io);
1919
1984
  throw e;
1920
1985
  }
1921
- const onChain = await onChainHalf(bundles.map(([p, bundle]) => [p.ref, bundle]), gateways, io);
1922
- const witness = witnessLine(onChain);
1986
+ const caches = [];
1987
+ for (const [p, bundle] of bundles) {
1988
+ caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
1989
+ }
1990
+ const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) });
1991
+ const witness = witnessLine(onChain, caches.flat());
1923
1992
  const code = caseExitCode(reading.verdict, onChain.verdict);
1924
1993
  const nil = (v) => (v === undefined ? null : v);
1925
1994
  const data = {
1926
1995
  verdict: reading.verdict,
1927
1996
  title: titleOf(reading.verdict),
1928
- reliance: CASE_RELIANCE[reading.verdict],
1997
+ reliance: caseReliance(reading.verdict, reading.handoffRecords),
1929
1998
  exit_code: code,
1930
1999
  summary: renderCaseSummary(reading),
1931
2000
  witness: {
1932
2001
  state: witness.state,
1933
2002
  reasons: witness.reasons,
1934
2003
  services: witness.services,
2004
+ ...(witness.cached ? { cached: witness.cached } : {}),
2005
+ ...(witness.recorded ? { recorded: witness.recorded } : {}),
1935
2006
  rendered: renderWitnessLine(witness),
1936
2007
  },
1937
2008
  handoff_records: reading.handoffRecords,
@@ -1979,10 +2050,20 @@ async function caseCommand(argv, io) {
1979
2050
  case_file: caseFile,
1980
2051
  on_chain: onChain,
1981
2052
  };
2053
+ // Files handed in beside the packs (evidence-bundle.md 5.7, A29). A pack's
2054
+ // logs/ is its side content and is never an offered file; only the paths
2055
+ // after --files are. Nothing here moves `code`.
2056
+ let fileLines = [];
2057
+ const out = data;
2058
+ if (offered !== undefined) {
2059
+ const fileReading = await matchCaseFiles(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })), offered);
2060
+ out.files = fileMatchToJson(fileReading);
2061
+ fileLines = renderFileMatch(fileReading);
2062
+ }
1982
2063
  if (asJson)
1983
- outJson(io, jsonText(data));
2064
+ outJson(io, jsonText(out));
1984
2065
  else
1985
- for (const line of caseReport(data))
2066
+ for (const line of [...caseReport(data), ...fileLines])
1986
2067
  io.out(noLoneSurrogates(line));
1987
2068
  return code;
1988
2069
  }
package/dist/compose.js CHANGED
@@ -22,7 +22,7 @@
22
22
  // does not invalidate the operator signature.
23
23
  import { EXPORT_BODY_TYPE, recomputeExportVerdict, } from "./evidence.js";
24
24
  import { base64UrlToBytes, bytesToHex, ed25519PublicKey, ed25519Sign, sha256Hex, utf8, } from "./crypto.js";
25
- import { jcs } from "./verifier.js";
25
+ import { isRfc3339UtcInstant, jcs } from "./verifier.js";
26
26
  const GENESIS = "GENESIS";
27
27
  const EXPORT_SCHEMA = EXPORT_BODY_TYPE;
28
28
  // Transcode an attestation `signature` to the lowercase hex §2.2 mandates. The
@@ -73,6 +73,11 @@ export async function composeExport(sourceBundle, attestations, exporterKey, opt
73
73
  if (typeof exporterKey?.privateKey !== "string" || exporterKey.privateKey.length === 0) {
74
74
  throw new Error("composeExport: exporterKey.privateKey (32-byte hex seed) is required");
75
75
  }
76
+ if (options.generatedAt !== undefined && !isRfc3339UtcInstant(options.generatedAt)) {
77
+ // The verifier reads this as `malformed generated_at` (evidence-bundle §2,
78
+ // A31), so composing one would sign an export this kernel rejects.
79
+ throw new Error("composeExport: generatedAt is not an RFC 3339 UTC date-time with Z naming a real date and time");
80
+ }
76
81
  const generatedAt = options.generatedAt ?? new Date().toISOString();
77
82
  const previousHash = options.previousHash ?? GENESIS;
78
83
  const issuerId = options.issuerId ?? "sanning-verify";
@@ -318,6 +318,7 @@ export interface EvidenceBundleResult {
318
318
  export?: ExportResult;
319
319
  stamps?: StampResult[];
320
320
  errors: string[];
321
+ notes?: string[];
321
322
  }
322
323
  export interface AttestationRecord {
323
324
  /** The source checkpoint's derived `checkpoint_ref` this attestation binds to. */
@@ -427,6 +428,41 @@ export interface VerifyEvidenceOptions {
427
428
  content?: Record<string, Uint8Array | string> | ReadonlyMap<string, Uint8Array | string>;
428
429
  fetchImpl?: typeof fetch;
429
430
  }
431
+ /**
432
+ * A surface MUST NOT present a wrapper's `co_signatures` as evidence
433
+ * (evidence-bundle.md §2, decided by Phil 2026-09-24 on proof#148): nothing
434
+ * in either kernel verifies the field, and it sits outside the wrapper's
435
+ * signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
436
+ * append it with no key at all. This is the one sentence both kernels attach
437
+ * as a note, never a verdict, never an exit code.
438
+ */
439
+ export declare const CO_SIGNATURES_NOTE = "unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
440
+ /**
441
+ * True iff the wrapper's reserved `co_signatures` field carries at least one
442
+ * entry.
443
+ *
444
+ * Absence and an empty array read the same way here: the field asserts
445
+ * nothing about a second signer until it holds one, and evidence-bundle.md's
446
+ * own carve-out language for the analogous envelope field treats "absent"
447
+ * and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
448
+ * value (malformed shape, but the field is reserved, not yet specified)
449
+ * still counts as present: it is not the well-formed absence either rule
450
+ * describes.
451
+ */
452
+ export declare function coSignaturesPresent(b: unknown): boolean;
453
+ /**
454
+ * A bundle this kernel cannot read.
455
+ *
456
+ * `wrapperOk` is true only where the wrapper signature and `body_hash` were
457
+ * CHECKED and held before the body was found unreadable. It is then reported
458
+ * as held, because it did: `signatureOk` and `bodyHashOk` are statements about
459
+ * the wrapper, and a body this kernel cannot parse does not unsign it. The
460
+ * case reader decides whose pack a bundle is on exactly those two fields
461
+ * (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
462
+ * broke its own body shape read as a forger.
463
+ */
464
+ /** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
465
+ export declare const GENERATED_AT_MALFORMED = "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle \u00A72)";
430
466
  export declare function verifyEvidenceBundle(bundle: unknown, options?: VerifyEvidenceOptions): Promise<EvidenceBundleResult>;
431
467
  export type DisclosureStatus = "verified" | "not_disclosed" | "undetermined" | "failed";
432
468
  export interface DisclosedEventContent {