@sanning/proof 0.13.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.
package/dist/cli.js CHANGED
@@ -48,12 +48,15 @@ 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, WITNESS_NOT_LOOKED_UP, 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, checkWitnessCache, readWitnessCache, renderWitnessTime, } from "./witness-cache.js";
57
60
  import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
58
61
  import { completenessNotes, gradePack, renderCause } from "./grading.js";
59
62
  import { EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
@@ -199,6 +202,7 @@ async function runVerify(rest, io, sink) {
199
202
  // second positional argument until then, and a second positional is now a
200
203
  // usage error). The one positional is the bundle.
201
204
  let logsPath;
205
+ let filePaths;
202
206
  let gatewayArg;
203
207
  const positional = [];
204
208
  for (let i = 0; i < rest.length; i++) {
@@ -225,6 +229,17 @@ async function runVerify(rest, io, sink) {
225
229
  i++;
226
230
  continue;
227
231
  }
232
+ if (a === "--files") {
233
+ const taken = takeFilePaths(rest, i + 1);
234
+ if (taken.length === 0) {
235
+ io.err("verify: --files requires at least one file path");
236
+ printUsage(io);
237
+ return EXIT_MALFORMED;
238
+ }
239
+ filePaths = [...(filePaths ?? []), ...taken];
240
+ i += taken.length;
241
+ continue;
242
+ }
228
243
  positional.push(a);
229
244
  }
230
245
  const [bundlePath] = positional;
@@ -251,6 +266,15 @@ async function runVerify(rest, io, sink) {
251
266
  io.err(`note: ${n}`);
252
267
  }
253
268
  const content = logs?.content;
269
+ let offered;
270
+ if (filePaths !== undefined) {
271
+ const read = await readOfferedFiles(filePaths);
272
+ if (typeof read === "string") {
273
+ io.err(`verify: ${read}`);
274
+ return EXIT_MALFORMED;
275
+ }
276
+ offered = read;
277
+ }
254
278
  let raw;
255
279
  try {
256
280
  raw = new Uint8Array(await readFile(bundlePath));
@@ -270,6 +294,9 @@ async function runVerify(rest, io, sink) {
270
294
  if (e instanceof BundleReadError && e.tooDeep) {
271
295
  io.err(`${bundlePath} ${e.detail}; refusing to read it`);
272
296
  }
297
+ else if (e instanceof BundleReadError && e.duplicate) {
298
+ io.err(`${bundlePath} ${NOT_I_JSON}: ${e.detail}`);
299
+ }
273
300
  else {
274
301
  io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
275
302
  }
@@ -284,6 +311,9 @@ async function runVerify(rest, io, sink) {
284
311
  // has no such per-event content. Say so rather than silently ignoring it.
285
312
  io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
286
313
  }
314
+ if (offered) {
315
+ io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
316
+ }
287
317
  const result = await verifyAgentProof(parsed, {
288
318
  gateways,
289
319
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
@@ -318,12 +348,48 @@ async function runVerify(rest, io, sink) {
318
348
  chains = { unreadable: errMsg(e) };
319
349
  }
320
350
  const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
351
+ // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
352
+ // beside the verdict, never inside it, so `exit` is untouched.
353
+ if (offered !== undefined) {
354
+ const reading = await matchFiles(parsed, offered, content);
355
+ for (const line of renderFileMatch(reading))
356
+ io.out(line);
357
+ if (sink !== null && sink.data !== null)
358
+ sink.data.files = fileMatchToJson(reading);
359
+ }
321
360
  // Coverage is reported only once the bundle parsed far enough to have events
322
361
  // to compare against; over a MALFORMED bundle there is nothing to say.
323
362
  if (logs && result.status !== "malformed")
324
363
  reportLogsCoverage(logs, result, io);
325
364
  return exit;
326
365
  }
366
+ // The paths after `--files`: every argument up to the next option.
367
+ function takeFilePaths(args, start) {
368
+ const out = [];
369
+ for (const arg of args.slice(start)) {
370
+ if (arg.startsWith("--"))
371
+ break;
372
+ out.push(arg);
373
+ }
374
+ return out;
375
+ }
376
+ // Each `--files` path as `{ name, bytes }`, the name exactly as typed, or the
377
+ // one error line when a path is not a readable regular file. The line names the
378
+ // path and not the operating system's words, which differ by kernel.
379
+ async function readOfferedFiles(paths) {
380
+ const out = [];
381
+ for (const path of paths) {
382
+ try {
383
+ if (!(await stat(path)).isFile())
384
+ return `--files ${path} is not a readable file`;
385
+ out.push({ name: path, bytes: new Uint8Array(await readFile(path)) });
386
+ }
387
+ catch {
388
+ return `--files ${path} is not a readable file`;
389
+ }
390
+ }
391
+ return out;
392
+ }
327
393
  // `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
328
394
  //
329
395
  // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
@@ -514,9 +580,13 @@ async function readJson(path, io) {
514
580
  return undefined;
515
581
  }
516
582
  try {
517
- return JSON.parse(raw);
583
+ return parseRefusingDuplicates(raw);
518
584
  }
519
585
  catch (e) {
586
+ if (e instanceof DuplicateKeyError) {
587
+ io.err(`${path} ${NOT_I_JSON}: ${e.message}`);
588
+ return undefined;
589
+ }
520
590
  io.err(`${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
521
591
  return undefined;
522
592
  }
@@ -598,9 +668,15 @@ async function loadLogsMap(logsPath, io) {
598
668
  }
599
669
  let logsParsed;
600
670
  try {
601
- logsParsed = JSON.parse(logsRaw);
671
+ logsParsed = parseRefusingDuplicates(logsRaw);
602
672
  }
603
673
  catch (e) {
674
+ if (e instanceof DuplicateKeyError) {
675
+ // One event id named twice hands the reader two contents for one
676
+ // record, and the last one silently won (proof#144).
677
+ io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
678
+ return undefined;
679
+ }
604
680
  io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
605
681
  return undefined;
606
682
  }
@@ -775,10 +851,12 @@ function printUsage(io) {
775
851
  io.out(" (npx @sanning/proof <command> [args], without installing)");
776
852
  io.out("");
777
853
  io.out("Commands:");
778
- io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--json] Verify a bundle");
854
+ io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--files <path>...] [--json]");
855
+ io.out(" Verify a bundle");
779
856
  io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
780
857
  io.out(" Create an attested export");
781
- io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
858
+ io.out(" case <folder>... [--gateways <urls>] [--files <path>...] [--json]");
859
+ io.out(" Read several packs as one case");
782
860
  io.out(" (`case --help` for its exit codes)");
783
861
  io.out("");
784
862
  io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
@@ -791,6 +869,14 @@ function printUsage(io) {
791
869
  io.out("--json Print the verdict, the causes and the exit code as");
792
870
  io.out(" JSON, for a script, instead of the report.");
793
871
  io.out("");
872
+ io.out("--files <path>... Files handed in beside the pack, every path up to the");
873
+ io.out(" next option. Each is hashed and matched, by SHA-256");
874
+ io.out(" alone, against what the pack's signed records commit");
875
+ io.out(" to; the report lists the files the pack vouches for,");
876
+ io.out(" the files that match no record, and the commitments");
877
+ io.out(" no file was offered for. Names and folders are never");
878
+ io.out(" compared, and the verdict never moves.");
879
+ io.out("");
794
880
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
795
881
  io.out(" content_hash, or message_hash for a hand-off record.");
796
882
  io.out(" Either shape works, and which one you");
@@ -1234,17 +1320,26 @@ export class CaseInputError extends Error {
1234
1320
  /**
1235
1321
  * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1236
1322
  * 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.
1323
+ * which differ by kernel and are printed by `verify` only. `duplicate` marks the
1324
+ * one parse refusal whose `detail` is kernel wording, identical in both kernels
1325
+ * (`duplicate key "<name>"`, proof#144), so both commands print it.
1238
1326
  */
1239
1327
  export class BundleReadError extends Error {
1240
1328
  tooDeep;
1241
1329
  detail;
1242
- constructor(tooDeep, detail) {
1330
+ duplicate;
1331
+ constructor(tooDeep, detail, duplicate = false) {
1243
1332
  super(detail);
1244
1333
  this.tooDeep = tooDeep;
1245
1334
  this.detail = detail;
1335
+ this.duplicate = duplicate;
1246
1336
  }
1247
1337
  }
1338
+ // What both commands say about bytes that repeat a member name, before the
1339
+ // kernel's own `duplicate key "<name>"`. RFC 8259 JSON permits a repeat and
1340
+ // I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
1341
+ // would be the wrong sentence. `NOT_I_JSON` in the Python twin.
1342
+ export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
1248
1343
  // `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
1249
1344
  // `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
1250
1345
  const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
@@ -1253,7 +1348,8 @@ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
1253
1348
  * How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
1254
1349
  * kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
1255
1350
  * 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`.
1351
+ * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`,
1352
+ * refusing an object that repeats a member name (I-JSON, RFC 7493; proof#144).
1257
1353
  * Throws `BundleReadError`.
1258
1354
  */
1259
1355
  export function readBundleBytes(bytes) {
@@ -1262,9 +1358,11 @@ export function readBundleBytes(bytes) {
1262
1358
  throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
1263
1359
  }
1264
1360
  try {
1265
- return JSON.parse(text);
1361
+ return parseRefusingDuplicates(text);
1266
1362
  }
1267
1363
  catch (e) {
1364
+ if (e instanceof DuplicateKeyError)
1365
+ throw new BundleReadError(false, e.message, true);
1268
1366
  throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
1269
1367
  }
1270
1368
  }
@@ -1332,6 +1430,30 @@ export async function splitCase(tree, read) {
1332
1430
  }
1333
1431
  return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
1334
1432
  }
1433
+ /**
1434
+ * The bytes of the `witness-cache.json` beside a pack's `bundle.json`
1435
+ * (evidence-bundle 5.6.6, v1.25, A30), or `null`.
1436
+ *
1437
+ * The split rule is not changed for it: the file stays outside every pack,
1438
+ * listed as such, and is opened here and nowhere else. It is read only where it
1439
+ * is a regular file the split left outside every pack, so a file under another
1440
+ * pack's `logs/` is that pack's, and a link is never followed. A file that
1441
+ * cannot be read is no cache, never a refusal of the drop.
1442
+ */
1443
+ async function witnessCacheBytes(tree, split, pack) {
1444
+ const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
1445
+ const entry = tree.get(path);
1446
+ if (entry === undefined || !entry.regular || !split.outside.includes(path))
1447
+ return null;
1448
+ try {
1449
+ return await readFileEntry(entry, path);
1450
+ }
1451
+ catch (e) {
1452
+ if (e instanceof CaseInputError)
1453
+ return null;
1454
+ throw e;
1455
+ }
1456
+ }
1335
1457
  /** `verify`'s reader, with the refusal named by the pack's path. */
1336
1458
  export function loadBundle(pack) {
1337
1459
  try {
@@ -1341,6 +1463,9 @@ export function loadBundle(pack) {
1341
1463
  if (e instanceof BundleReadError && e.tooDeep) {
1342
1464
  throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
1343
1465
  }
1466
+ if (e instanceof BundleReadError && e.duplicate) {
1467
+ throw new CaseInputError(`${pack.bundlePath} ${NOT_I_JSON}: ${e.detail}`);
1468
+ }
1344
1469
  throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1345
1470
  }
1346
1471
  }
@@ -1543,7 +1668,7 @@ async function unzip(arg, prefix, tree, seen) {
1543
1668
  // 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
1544
1669
  export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
1545
1670
 
1546
- sanning-proof case <folder or case file>... [--gateways <url,url>] [--json]
1671
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
1547
1672
 
1548
1673
  <folder> a pack folder (bundle.json plus logs/), a folder that holds
1549
1674
  several packs, or an unzipped case file. Packs are found by the
@@ -1567,6 +1692,10 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
1567
1692
  --gateways comma-separated gateway URLs. Each pack's witnesses are then
1568
1693
  looked up on the public record, pack by pack, and reported on
1569
1694
  the witness line. The lookup never changes the verdict word.
1695
+ --files files handed in beside the packs, every path up to the next
1696
+ option, matched by SHA-256 alone against what every pack's
1697
+ signed records commit to. A file under a pack's logs/ is
1698
+ offered only when it is named here. The verdict never moves.
1570
1699
  --json the full reading as JSON: the verdict, every exception and note
1571
1700
  with its rendered line, every record, every contribution, the
1572
1701
  packs, the files outside them, the case file index comparison,
@@ -1615,6 +1744,19 @@ function sortKeys(v) {
1615
1744
  function jsonText(v) {
1616
1745
  return JSON.stringify(sortKeys(v), null, 2);
1617
1746
  }
1747
+ function witnessTimeRow(t) {
1748
+ return {
1749
+ stamp_ref: t.cached.stampRef,
1750
+ cached: { block_height: t.cached.blockHeight, block_time: t.cached.blockTime },
1751
+ outcome: t.outcome,
1752
+ answers: t.answers.map((a) => ({
1753
+ gateway: a.gateway,
1754
+ outcome: a.outcome,
1755
+ block_height: a.blockHeight,
1756
+ block_time: a.blockTime,
1757
+ })),
1758
+ };
1759
+ }
1618
1760
  // Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
1619
1761
  // case reading on purpose: `readCase` makes no network call, and neither does
1620
1762
  // the verify page's case reading, so the case verdict stays the one both
@@ -1624,7 +1766,7 @@ async function onChainHalf(packs, gateways, io) {
1624
1766
  return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
1625
1767
  const rows = [];
1626
1768
  const answered = new Set();
1627
- for (const [ref, bundle] of packs) {
1769
+ for (const [ref, bundle, cached] of packs) {
1628
1770
  const result = await verifyEvidenceBundle(bundle, {
1629
1771
  gateways,
1630
1772
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
@@ -1649,6 +1791,12 @@ async function onChainHalf(packs, gateways, io) {
1649
1791
  else
1650
1792
  row.not_checked++;
1651
1793
  }
1794
+ // The witness times the pack's witness-cache.json gives, looked up where
1795
+ // it gives any (5.6.6, v1.25, A30). A pack with no cache gets no key and
1796
+ // no request.
1797
+ const times = await checkWitnessCache(cached, gateways, io.fetchImpl ?? fetch);
1798
+ if (times.length > 0)
1799
+ row.witness_times = times.map(witnessTimeRow);
1652
1800
  rows.push(row);
1653
1801
  for (const w of witnesses) {
1654
1802
  for (const outcome of w.onChain?.perGateway ?? []) {
@@ -1657,8 +1805,14 @@ async function onChainHalf(packs, gateways, io) {
1657
1805
  }
1658
1806
  }
1659
1807
  }
1808
+ for (const t of times) {
1809
+ for (const a of t.answers) {
1810
+ if (a.outcome === "matches" || a.outcome === "differs")
1811
+ answered.add(a.gateway);
1812
+ }
1813
+ }
1660
1814
  }
1661
- const verdict = rows.some((r) => r.mismatched > 0)
1815
+ const verdict = rows.some((r) => r.mismatched > 0 || (r.witness_times ?? []).some((t) => t.outcome === "differs"))
1662
1816
  ? "failed"
1663
1817
  : rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
1664
1818
  ? "undetermined"
@@ -1677,20 +1831,63 @@ async function onChainHalf(packs, gateways, io) {
1677
1831
  * `not_checked` with the reason, and `failed` is `differs`. It never moves the
1678
1832
  * headline.
1679
1833
  */
1680
- export function witnessLine(onChain) {
1834
+ // Distinct `[blockHeight, blockTime]` pairs, in time order.
1835
+ function distinctTimes(times) {
1836
+ const seen = new Map();
1837
+ for (const [h, t] of times)
1838
+ seen.set(`${h}:${t}`, [h, t]);
1839
+ return [...seen.values()].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
1840
+ }
1841
+ // Only the keys that carry something: a line with no cached or recorded time
1842
+ // is the line 0.13.0 printed, field for field.
1843
+ function withTimes(line, cached, recorded) {
1844
+ return {
1845
+ ...line,
1846
+ ...(cached.length > 0 ? { cached } : {}),
1847
+ ...(recorded.length > 0 ? { recorded } : {}),
1848
+ };
1849
+ }
1850
+ /**
1851
+ * `cached` is every witness time the packs cache (5.6.6, v1.25, A30). Where no
1852
+ * lookup ran, each is printed labelled not checked. Where one ran, each time
1853
+ * the public record matched is printed as the record's, each time no lookup
1854
+ * settled stays labelled not checked, and a time the record contradicts is the
1855
+ * reason the line reads `differs`.
1856
+ */
1857
+ export function witnessLine(onChain, cached = []) {
1681
1858
  if (!onChain.checked) {
1682
- return {
1859
+ return withTimes({
1683
1860
  state: WITNESS_NOT_LOOKED_UP.state,
1684
1861
  reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
1685
1862
  services: [...WITNESS_NOT_LOOKED_UP.services],
1686
- };
1863
+ }, distinctTimes(cached.map((c) => [c.blockHeight, c.blockTime])), []);
1687
1864
  }
1688
1865
  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
1866
  const rows = onChain.packs;
1867
+ const times = rows.flatMap((r) => r.witness_times ?? []);
1868
+ const unsettled = distinctTimes(times
1869
+ .filter((t) => t.outcome === "not_checked")
1870
+ .map((t) => [t.cached.block_height, t.cached.block_time]));
1871
+ const recorded = distinctTimes(times
1872
+ .filter((t) => t.outcome === "matches")
1873
+ .flatMap((t) => t.answers.filter((a) => a.outcome === "matches"))
1874
+ .map((a) => [a.block_height, a.block_time]));
1875
+ const contradicted = [];
1876
+ for (const t of times) {
1877
+ if (t.outcome !== "differs")
1878
+ continue;
1879
+ const a = t.answers.find((x) => x.outcome === "differs");
1880
+ const reason = `a pack dates the witness ${renderWitnessTime(t.cached.block_height, t.cached.block_time)}` +
1881
+ `, and the public record dates it ${renderWitnessTime(a.block_height, a.block_time)}`;
1882
+ if (!contradicted.includes(reason))
1883
+ contradicted.push(reason);
1884
+ }
1885
+ if (onChain.verdict === "failed") {
1886
+ return withTimes({ state: "differs", reasons: contradicted, services }, unsettled, recorded);
1887
+ }
1888
+ if (onChain.verdict === "confirmed") {
1889
+ return withTimes({ state: "confirmed", reasons: [], services }, unsettled, recorded);
1890
+ }
1694
1891
  const reasons = [];
1695
1892
  if (rows.some((r) => r.witnesses === 0))
1696
1893
  reasons.push("a pack names no witness");
@@ -1701,7 +1898,7 @@ export function witnessLine(onChain) {
1701
1898
  if (rows.some((r) => r.not_checked > 0)) {
1702
1899
  reasons.push("no lookup is made for a witness kind a pack names");
1703
1900
  }
1704
- return { state: "not_checked", reasons, services };
1901
+ return withTimes({ state: "not_checked", reasons, services }, unsettled, recorded);
1705
1902
  }
1706
1903
  /**
1707
1904
  * The case verdict's code, moved only by an on-chain half that was asked for:
@@ -1859,6 +2056,7 @@ async function caseCommand(argv, io) {
1859
2056
  let asJson = false;
1860
2057
  let gateways = [];
1861
2058
  const paths = [];
2059
+ let filePaths;
1862
2060
  for (let i = 0; i < argv.length; i++) {
1863
2061
  const arg = argv[i];
1864
2062
  if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
@@ -1881,6 +2079,16 @@ async function caseCommand(argv, io) {
1881
2079
  .filter((g) => g.length > 0);
1882
2080
  i++;
1883
2081
  }
2082
+ else if (arg === "--files") {
2083
+ const taken = takeFilePaths(argv, i + 1);
2084
+ if (taken.length === 0) {
2085
+ io.err("case: --files requires at least one file path");
2086
+ outBlock(io, CASE_USAGE);
2087
+ return EXIT_MALFORMED;
2088
+ }
2089
+ filePaths = [...(filePaths ?? []), ...taken];
2090
+ i += taken.length;
2091
+ }
1884
2092
  else if (arg.startsWith("--")) {
1885
2093
  io.err(`case: unknown option ${arg}`);
1886
2094
  outBlock(io, CASE_USAGE);
@@ -1895,6 +2103,13 @@ async function caseCommand(argv, io) {
1895
2103
  outBlock(io, CASE_USAGE);
1896
2104
  return EXIT_MALFORMED;
1897
2105
  }
2106
+ let offered;
2107
+ if (filePaths !== undefined) {
2108
+ const read = await readOfferedFiles(filePaths);
2109
+ if (typeof read === "string")
2110
+ return caseError(new CaseInputError(read), asJson, io);
2111
+ offered = read;
2112
+ }
1898
2113
  let split;
1899
2114
  let tree;
1900
2115
  let bundles;
@@ -1918,20 +2133,26 @@ async function caseCommand(argv, io) {
1918
2133
  return caseError(e, asJson, io);
1919
2134
  throw e;
1920
2135
  }
1921
- const onChain = await onChainHalf(bundles.map(([p, bundle]) => [p.ref, bundle]), gateways, io);
1922
- const witness = witnessLine(onChain);
2136
+ const caches = [];
2137
+ for (const [p, bundle] of bundles) {
2138
+ caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
2139
+ }
2140
+ const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, io);
2141
+ const witness = witnessLine(onChain, caches.flat());
1923
2142
  const code = caseExitCode(reading.verdict, onChain.verdict);
1924
2143
  const nil = (v) => (v === undefined ? null : v);
1925
2144
  const data = {
1926
2145
  verdict: reading.verdict,
1927
2146
  title: titleOf(reading.verdict),
1928
- reliance: CASE_RELIANCE[reading.verdict],
2147
+ reliance: caseReliance(reading.verdict, reading.handoffRecords),
1929
2148
  exit_code: code,
1930
2149
  summary: renderCaseSummary(reading),
1931
2150
  witness: {
1932
2151
  state: witness.state,
1933
2152
  reasons: witness.reasons,
1934
2153
  services: witness.services,
2154
+ ...(witness.cached ? { cached: witness.cached } : {}),
2155
+ ...(witness.recorded ? { recorded: witness.recorded } : {}),
1935
2156
  rendered: renderWitnessLine(witness),
1936
2157
  },
1937
2158
  handoff_records: reading.handoffRecords,
@@ -1979,10 +2200,20 @@ async function caseCommand(argv, io) {
1979
2200
  case_file: caseFile,
1980
2201
  on_chain: onChain,
1981
2202
  };
2203
+ // Files handed in beside the packs (evidence-bundle.md 5.7, A29). A pack's
2204
+ // logs/ is its side content and is never an offered file; only the paths
2205
+ // after --files are. Nothing here moves `code`.
2206
+ let fileLines = [];
2207
+ const out = data;
2208
+ if (offered !== undefined) {
2209
+ const fileReading = await matchCaseFiles(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })), offered);
2210
+ out.files = fileMatchToJson(fileReading);
2211
+ fileLines = renderFileMatch(fileReading);
2212
+ }
1982
2213
  if (asJson)
1983
- outJson(io, jsonText(data));
2214
+ outJson(io, jsonText(out));
1984
2215
  else
1985
- for (const line of caseReport(data))
2216
+ for (const line of [...caseReport(data), ...fileLines])
1986
2217
  io.out(noLoneSurrogates(line));
1987
2218
  return code;
1988
2219
  }
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 {