@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/case-file.js +6 -2
- package/dist/case.d.ts +51 -7
- package/dist/case.js +81 -16
- package/dist/cli.d.ts +31 -8
- package/dist/cli.js +256 -25
- package/dist/compose.js +6 -1
- package/dist/evidence.d.ts +36 -0
- package/dist/evidence.js +104 -3
- package/dist/file-match.d.ts +105 -0
- package/dist/file-match.js +374 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +9 -2
- package/dist/message-parts.d.ts +12 -6
- package/dist/message-parts.js +18 -10
- package/dist/verifier.d.ts +54 -2
- package/dist/verifier.js +195 -3
- package/dist/witness-cache.d.ts +85 -0
- package/dist/witness-cache.js +264 -0
- package/package.json +1 -1
- package/src/case-file.ts +5 -2
- package/src/case.ts +101 -15
- package/src/cli.ts +297 -27
- package/src/compose.ts +8 -1
- package/src/evidence.ts +115 -2
- package/src/file-match.ts +461 -0
- package/src/index.ts +43 -0
- package/src/message-parts.ts +18 -10
- package/src/verifier.ts +190 -3
- package/src/witness-cache.ts +306 -0
- package/vectors/README.md +43 -3
- package/vectors/file-match.json +1808 -0
- package/vectors/jcs-reader.json +82 -3
- package/vectors/stamp-on-chain.json +18 -3
- package/vectors/witness-cache.json +1304 -0
package/src/cli.ts
CHANGED
|
@@ -57,10 +57,10 @@ import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js"
|
|
|
57
57
|
type ChainOutcome = ChainReading | { unreadable: string };
|
|
58
58
|
import type { ChainReading } from "./chain.js";
|
|
59
59
|
import {
|
|
60
|
-
CASE_RELIANCE,
|
|
61
60
|
VERDICT_DISPLAY_TITLES,
|
|
62
61
|
WITNESS_NOT_LOOKED_UP,
|
|
63
62
|
type WitnessLine,
|
|
63
|
+
caseReliance,
|
|
64
64
|
readCase,
|
|
65
65
|
renderCaseNote,
|
|
66
66
|
renderCaseSummary,
|
|
@@ -78,6 +78,18 @@ import { composeExport } from "./compose.js";
|
|
|
78
78
|
import type { ComposeExportOptions } from "./compose.js";
|
|
79
79
|
import { isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
80
80
|
import { verifyEvidenceBundle } from "./evidence.js";
|
|
81
|
+
import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
|
|
82
|
+
import { fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch } from "./file-match.js";
|
|
83
|
+
import type { OfferedFile } from "./file-match.js";
|
|
84
|
+
import {
|
|
85
|
+
type CachedWitnessTime,
|
|
86
|
+
type WitnessTimeAnswer,
|
|
87
|
+
type WitnessTimeCheck,
|
|
88
|
+
WITNESS_CACHE_NAME,
|
|
89
|
+
checkWitnessCache,
|
|
90
|
+
readWitnessCache,
|
|
91
|
+
renderWitnessTime,
|
|
92
|
+
} from "./witness-cache.js";
|
|
81
93
|
import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult, ExportResult } from "./evidence.js";
|
|
82
94
|
import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
|
|
83
95
|
import { completenessNotes, gradePack, renderCause } from "./grading.js";
|
|
@@ -276,6 +288,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
276
288
|
// second positional argument until then, and a second positional is now a
|
|
277
289
|
// usage error). The one positional is the bundle.
|
|
278
290
|
let logsPath: string | undefined;
|
|
291
|
+
let filePaths: string[] | undefined;
|
|
279
292
|
let gatewayArg: string | undefined;
|
|
280
293
|
const positional: string[] = [];
|
|
281
294
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -302,6 +315,17 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
302
315
|
i++;
|
|
303
316
|
continue;
|
|
304
317
|
}
|
|
318
|
+
if (a === "--files") {
|
|
319
|
+
const taken = takeFilePaths(rest, i + 1);
|
|
320
|
+
if (taken.length === 0) {
|
|
321
|
+
io.err("verify: --files requires at least one file path");
|
|
322
|
+
printUsage(io);
|
|
323
|
+
return EXIT_MALFORMED;
|
|
324
|
+
}
|
|
325
|
+
filePaths = [...(filePaths ?? []), ...taken];
|
|
326
|
+
i += taken.length;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
305
329
|
positional.push(a);
|
|
306
330
|
}
|
|
307
331
|
|
|
@@ -329,6 +353,16 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
329
353
|
}
|
|
330
354
|
const content = logs?.content;
|
|
331
355
|
|
|
356
|
+
let offered: OfferedFile[] | undefined;
|
|
357
|
+
if (filePaths !== undefined) {
|
|
358
|
+
const read = await readOfferedFiles(filePaths);
|
|
359
|
+
if (typeof read === "string") {
|
|
360
|
+
io.err(`verify: ${read}`);
|
|
361
|
+
return EXIT_MALFORMED;
|
|
362
|
+
}
|
|
363
|
+
offered = read;
|
|
364
|
+
}
|
|
365
|
+
|
|
332
366
|
let raw: Uint8Array;
|
|
333
367
|
try {
|
|
334
368
|
raw = new Uint8Array(await readFile(bundlePath));
|
|
@@ -346,6 +380,8 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
346
380
|
} catch (e) {
|
|
347
381
|
if (e instanceof BundleReadError && e.tooDeep) {
|
|
348
382
|
io.err(`${bundlePath} ${e.detail}; refusing to read it`);
|
|
383
|
+
} else if (e instanceof BundleReadError && e.duplicate) {
|
|
384
|
+
io.err(`${bundlePath} ${NOT_I_JSON}: ${e.detail}`);
|
|
349
385
|
} else {
|
|
350
386
|
io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
351
387
|
}
|
|
@@ -363,6 +399,9 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
363
399
|
// has no such per-event content. Say so rather than silently ignoring it.
|
|
364
400
|
io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
365
401
|
}
|
|
402
|
+
if (offered) {
|
|
403
|
+
io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
404
|
+
}
|
|
366
405
|
const result = await verifyAgentProof(parsed, {
|
|
367
406
|
gateways,
|
|
368
407
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
@@ -397,12 +436,45 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
397
436
|
chains = { unreadable: errMsg(e) };
|
|
398
437
|
}
|
|
399
438
|
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
|
|
439
|
+
// Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
|
|
440
|
+
// beside the verdict, never inside it, so `exit` is untouched.
|
|
441
|
+
if (offered !== undefined) {
|
|
442
|
+
const reading = await matchFiles(parsed, offered, content);
|
|
443
|
+
for (const line of renderFileMatch(reading)) io.out(line);
|
|
444
|
+
if (sink !== null && sink.data !== null) sink.data.files = fileMatchToJson(reading);
|
|
445
|
+
}
|
|
400
446
|
// Coverage is reported only once the bundle parsed far enough to have events
|
|
401
447
|
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
402
448
|
if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
|
|
403
449
|
return exit;
|
|
404
450
|
}
|
|
405
451
|
|
|
452
|
+
// The paths after `--files`: every argument up to the next option.
|
|
453
|
+
function takeFilePaths(args: readonly string[], start: number): string[] {
|
|
454
|
+
const out: string[] = [];
|
|
455
|
+
for (const arg of args.slice(start)) {
|
|
456
|
+
if (arg.startsWith("--")) break;
|
|
457
|
+
out.push(arg);
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Each `--files` path as `{ name, bytes }`, the name exactly as typed, or the
|
|
463
|
+
// one error line when a path is not a readable regular file. The line names the
|
|
464
|
+
// path and not the operating system's words, which differ by kernel.
|
|
465
|
+
async function readOfferedFiles(paths: readonly string[]): Promise<OfferedFile[] | string> {
|
|
466
|
+
const out: OfferedFile[] = [];
|
|
467
|
+
for (const path of paths) {
|
|
468
|
+
try {
|
|
469
|
+
if (!(await stat(path)).isFile()) return `--files ${path} is not a readable file`;
|
|
470
|
+
out.push({ name: path, bytes: new Uint8Array(await readFile(path)) });
|
|
471
|
+
} catch {
|
|
472
|
+
return `--files ${path} is not a readable file`;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
|
|
406
478
|
// `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
|
|
407
479
|
//
|
|
408
480
|
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
|
|
@@ -582,8 +654,12 @@ async function readJson<T>(path: string, io: Cli): Promise<T | undefined> {
|
|
|
582
654
|
return undefined;
|
|
583
655
|
}
|
|
584
656
|
try {
|
|
585
|
-
return
|
|
657
|
+
return parseRefusingDuplicates(raw) as T;
|
|
586
658
|
} catch (e) {
|
|
659
|
+
if (e instanceof DuplicateKeyError) {
|
|
660
|
+
io.err(`${path} ${NOT_I_JSON}: ${e.message}`);
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
587
663
|
io.err(`${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
588
664
|
return undefined;
|
|
589
665
|
}
|
|
@@ -713,8 +789,14 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
|
|
|
713
789
|
}
|
|
714
790
|
let logsParsed: unknown;
|
|
715
791
|
try {
|
|
716
|
-
logsParsed =
|
|
792
|
+
logsParsed = parseRefusingDuplicates(logsRaw);
|
|
717
793
|
} catch (e) {
|
|
794
|
+
if (e instanceof DuplicateKeyError) {
|
|
795
|
+
// One event id named twice hands the reader two contents for one
|
|
796
|
+
// record, and the last one silently won (proof#144).
|
|
797
|
+
io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
718
800
|
io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
|
|
719
801
|
return undefined;
|
|
720
802
|
}
|
|
@@ -906,10 +988,12 @@ function printUsage(io: Cli): void {
|
|
|
906
988
|
io.out(" (npx @sanning/proof <command> [args], without installing)");
|
|
907
989
|
io.out("");
|
|
908
990
|
io.out("Commands:");
|
|
909
|
-
io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--json]
|
|
991
|
+
io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--files <path>...] [--json]");
|
|
992
|
+
io.out(" Verify a bundle");
|
|
910
993
|
io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
|
|
911
994
|
io.out(" Create an attested export");
|
|
912
|
-
io.out(" case <folder>... [--gateways <urls>] [--json]
|
|
995
|
+
io.out(" case <folder>... [--gateways <urls>] [--files <path>...] [--json]");
|
|
996
|
+
io.out(" Read several packs as one case");
|
|
913
997
|
io.out(" (`case --help` for its exit codes)");
|
|
914
998
|
io.out("");
|
|
915
999
|
io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
|
|
@@ -922,6 +1006,14 @@ function printUsage(io: Cli): void {
|
|
|
922
1006
|
io.out("--json Print the verdict, the causes and the exit code as");
|
|
923
1007
|
io.out(" JSON, for a script, instead of the report.");
|
|
924
1008
|
io.out("");
|
|
1009
|
+
io.out("--files <path>... Files handed in beside the pack, every path up to the");
|
|
1010
|
+
io.out(" next option. Each is hashed and matched, by SHA-256");
|
|
1011
|
+
io.out(" alone, against what the pack's signed records commit");
|
|
1012
|
+
io.out(" to; the report lists the files the pack vouches for,");
|
|
1013
|
+
io.out(" the files that match no record, and the commitments");
|
|
1014
|
+
io.out(" no file was offered for. Names and folders are never");
|
|
1015
|
+
io.out(" compared, and the verdict never moves.");
|
|
1016
|
+
io.out("");
|
|
925
1017
|
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
926
1018
|
io.out(" content_hash, or message_hash for a hand-off record.");
|
|
927
1019
|
io.out(" Either shape works, and which one you");
|
|
@@ -1460,18 +1552,28 @@ export class CaseInputError extends Error {
|
|
|
1460
1552
|
/**
|
|
1461
1553
|
* `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
|
|
1462
1554
|
* the one refusal that is not a parse error; `detail` is the parser's own words,
|
|
1463
|
-
* which differ by kernel and are printed by `verify` only.
|
|
1555
|
+
* which differ by kernel and are printed by `verify` only. `duplicate` marks the
|
|
1556
|
+
* one parse refusal whose `detail` is kernel wording, identical in both kernels
|
|
1557
|
+
* (`duplicate key "<name>"`, proof#144), so both commands print it.
|
|
1464
1558
|
*/
|
|
1465
1559
|
export class BundleReadError extends Error {
|
|
1466
1560
|
readonly tooDeep: boolean;
|
|
1467
1561
|
readonly detail: string;
|
|
1468
|
-
|
|
1562
|
+
readonly duplicate: boolean;
|
|
1563
|
+
constructor(tooDeep: boolean, detail: string, duplicate = false) {
|
|
1469
1564
|
super(detail);
|
|
1470
1565
|
this.tooDeep = tooDeep;
|
|
1471
1566
|
this.detail = detail;
|
|
1567
|
+
this.duplicate = duplicate;
|
|
1472
1568
|
}
|
|
1473
1569
|
}
|
|
1474
1570
|
|
|
1571
|
+
// What both commands say about bytes that repeat a member name, before the
|
|
1572
|
+
// kernel's own `duplicate key "<name>"`. RFC 8259 JSON permits a repeat and
|
|
1573
|
+
// I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
|
|
1574
|
+
// would be the wrong sentence. `NOT_I_JSON` in the Python twin.
|
|
1575
|
+
export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
|
|
1576
|
+
|
|
1475
1577
|
// `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
|
|
1476
1578
|
// `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
|
|
1477
1579
|
const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
@@ -1481,7 +1583,8 @@ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
|
1481
1583
|
* How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
|
|
1482
1584
|
* kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
|
|
1483
1585
|
* sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
|
|
1484
|
-
* MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse
|
|
1586
|
+
* MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`,
|
|
1587
|
+
* refusing an object that repeats a member name (I-JSON, RFC 7493; proof#144).
|
|
1485
1588
|
* Throws `BundleReadError`.
|
|
1486
1589
|
*/
|
|
1487
1590
|
export function readBundleBytes(bytes: Uint8Array): unknown {
|
|
@@ -1490,8 +1593,9 @@ export function readBundleBytes(bytes: Uint8Array): unknown {
|
|
|
1490
1593
|
throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
|
|
1491
1594
|
}
|
|
1492
1595
|
try {
|
|
1493
|
-
return
|
|
1596
|
+
return parseRefusingDuplicates(text);
|
|
1494
1597
|
} catch (e) {
|
|
1598
|
+
if (e instanceof DuplicateKeyError) throw new BundleReadError(false, e.message, true);
|
|
1495
1599
|
throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
|
|
1496
1600
|
}
|
|
1497
1601
|
}
|
|
@@ -1596,6 +1700,32 @@ export async function splitCase(
|
|
|
1596
1700
|
return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
|
|
1597
1701
|
}
|
|
1598
1702
|
|
|
1703
|
+
/**
|
|
1704
|
+
* The bytes of the `witness-cache.json` beside a pack's `bundle.json`
|
|
1705
|
+
* (evidence-bundle 5.6.6, v1.25, A30), or `null`.
|
|
1706
|
+
*
|
|
1707
|
+
* The split rule is not changed for it: the file stays outside every pack,
|
|
1708
|
+
* listed as such, and is opened here and nowhere else. It is read only where it
|
|
1709
|
+
* is a regular file the split left outside every pack, so a file under another
|
|
1710
|
+
* pack's `logs/` is that pack's, and a link is never followed. A file that
|
|
1711
|
+
* cannot be read is no cache, never a refusal of the drop.
|
|
1712
|
+
*/
|
|
1713
|
+
async function witnessCacheBytes(
|
|
1714
|
+
tree: Map<string, TreeEntry>,
|
|
1715
|
+
split: SplitCase,
|
|
1716
|
+
pack: SplitPack,
|
|
1717
|
+
): Promise<Uint8Array | null> {
|
|
1718
|
+
const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
|
|
1719
|
+
const entry = tree.get(path);
|
|
1720
|
+
if (entry === undefined || !entry.regular || !split.outside.includes(path)) return null;
|
|
1721
|
+
try {
|
|
1722
|
+
return await readFileEntry(entry, path);
|
|
1723
|
+
} catch (e) {
|
|
1724
|
+
if (e instanceof CaseInputError) return null;
|
|
1725
|
+
throw e;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1599
1729
|
/** `verify`'s reader, with the refusal named by the pack's path. */
|
|
1600
1730
|
export function loadBundle(pack: SplitPack): unknown {
|
|
1601
1731
|
try {
|
|
@@ -1604,6 +1734,9 @@ export function loadBundle(pack: SplitPack): unknown {
|
|
|
1604
1734
|
if (e instanceof BundleReadError && e.tooDeep) {
|
|
1605
1735
|
throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
|
|
1606
1736
|
}
|
|
1737
|
+
if (e instanceof BundleReadError && e.duplicate) {
|
|
1738
|
+
throw new CaseInputError(`${pack.bundlePath} ${NOT_I_JSON}: ${e.detail}`);
|
|
1739
|
+
}
|
|
1607
1740
|
throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
|
|
1608
1741
|
}
|
|
1609
1742
|
}
|
|
@@ -1819,7 +1952,7 @@ async function unzip(
|
|
|
1819
1952
|
|
|
1820
1953
|
export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
|
|
1821
1954
|
|
|
1822
|
-
sanning-proof case <folder or case file>... [--gateways <url,url>] [--json]
|
|
1955
|
+
sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
|
|
1823
1956
|
|
|
1824
1957
|
<folder> a pack folder (bundle.json plus logs/), a folder that holds
|
|
1825
1958
|
several packs, or an unzipped case file. Packs are found by the
|
|
@@ -1843,6 +1976,10 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
|
|
|
1843
1976
|
--gateways comma-separated gateway URLs. Each pack's witnesses are then
|
|
1844
1977
|
looked up on the public record, pack by pack, and reported on
|
|
1845
1978
|
the witness line. The lookup never changes the verdict word.
|
|
1979
|
+
--files files handed in beside the packs, every path up to the next
|
|
1980
|
+
option, matched by SHA-256 alone against what every pack's
|
|
1981
|
+
signed records commit to. A file under a pack's logs/ is
|
|
1982
|
+
offered only when it is named here. The verdict never moves.
|
|
1846
1983
|
--json the full reading as JSON: the verdict, every exception and note
|
|
1847
1984
|
with its rendered line, every record, every contribution, the
|
|
1848
1985
|
packs, the files outside them, the case file index comparison,
|
|
@@ -1903,6 +2040,34 @@ interface OnChainRow {
|
|
|
1903
2040
|
mismatched: number;
|
|
1904
2041
|
unreachable: number;
|
|
1905
2042
|
not_checked: number;
|
|
2043
|
+
/** Present only where the pack caches a witness time (5.6.6, v1.25, A30). */
|
|
2044
|
+
witness_times?: WitnessTimeRow[];
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
interface WitnessTimeRow {
|
|
2048
|
+
stamp_ref: string;
|
|
2049
|
+
cached: { block_height: number; block_time: number };
|
|
2050
|
+
outcome: WitnessTimeCheck["outcome"];
|
|
2051
|
+
answers: {
|
|
2052
|
+
gateway: string;
|
|
2053
|
+
outcome: WitnessTimeAnswer["outcome"];
|
|
2054
|
+
block_height: number | null;
|
|
2055
|
+
block_time: number | null;
|
|
2056
|
+
}[];
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function witnessTimeRow(t: WitnessTimeCheck): WitnessTimeRow {
|
|
2060
|
+
return {
|
|
2061
|
+
stamp_ref: t.cached.stampRef,
|
|
2062
|
+
cached: { block_height: t.cached.blockHeight, block_time: t.cached.blockTime },
|
|
2063
|
+
outcome: t.outcome,
|
|
2064
|
+
answers: t.answers.map((a) => ({
|
|
2065
|
+
gateway: a.gateway,
|
|
2066
|
+
outcome: a.outcome,
|
|
2067
|
+
block_height: a.blockHeight,
|
|
2068
|
+
block_time: a.blockTime,
|
|
2069
|
+
})),
|
|
2070
|
+
};
|
|
1906
2071
|
}
|
|
1907
2072
|
|
|
1908
2073
|
interface OnChainHalf {
|
|
@@ -1919,14 +2084,14 @@ interface OnChainHalf {
|
|
|
1919
2084
|
// the verify page's case reading, so the case verdict stays the one both
|
|
1920
2085
|
// surfaces compute. This adds the half the reading names as not checked.
|
|
1921
2086
|
async function onChainHalf(
|
|
1922
|
-
packs: readonly [string, unknown][],
|
|
2087
|
+
packs: readonly [string, unknown, readonly CachedWitnessTime[]][],
|
|
1923
2088
|
gateways: string[],
|
|
1924
2089
|
io: Cli,
|
|
1925
2090
|
): Promise<OnChainHalf> {
|
|
1926
2091
|
if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
|
|
1927
2092
|
const rows: OnChainRow[] = [];
|
|
1928
2093
|
const answered = new Set<string>();
|
|
1929
|
-
for (const [ref, bundle] of packs) {
|
|
2094
|
+
for (const [ref, bundle, cached] of packs) {
|
|
1930
2095
|
const result = await verifyEvidenceBundle(bundle, {
|
|
1931
2096
|
gateways,
|
|
1932
2097
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
@@ -1947,6 +2112,11 @@ async function onChainHalf(
|
|
|
1947
2112
|
else if (rollup === "unreachable") row.unreachable++;
|
|
1948
2113
|
else row.not_checked++;
|
|
1949
2114
|
}
|
|
2115
|
+
// The witness times the pack's witness-cache.json gives, looked up where
|
|
2116
|
+
// it gives any (5.6.6, v1.25, A30). A pack with no cache gets no key and
|
|
2117
|
+
// no request.
|
|
2118
|
+
const times = await checkWitnessCache(cached, gateways, io.fetchImpl ?? fetch);
|
|
2119
|
+
if (times.length > 0) row.witness_times = times.map(witnessTimeRow);
|
|
1950
2120
|
rows.push(row);
|
|
1951
2121
|
for (const w of witnesses) {
|
|
1952
2122
|
for (const outcome of w.onChain?.perGateway ?? []) {
|
|
@@ -1955,8 +2125,15 @@ async function onChainHalf(
|
|
|
1955
2125
|
}
|
|
1956
2126
|
}
|
|
1957
2127
|
}
|
|
2128
|
+
for (const t of times) {
|
|
2129
|
+
for (const a of t.answers) {
|
|
2130
|
+
if (a.outcome === "matches" || a.outcome === "differs") answered.add(a.gateway);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
1958
2133
|
}
|
|
1959
|
-
const verdict = rows.some(
|
|
2134
|
+
const verdict = rows.some(
|
|
2135
|
+
(r) => r.mismatched > 0 || (r.witness_times ?? []).some((t) => t.outcome === "differs"),
|
|
2136
|
+
)
|
|
1960
2137
|
? "failed"
|
|
1961
2138
|
: rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
|
|
1962
2139
|
? "undetermined"
|
|
@@ -1976,18 +2153,75 @@ async function onChainHalf(
|
|
|
1976
2153
|
* `not_checked` with the reason, and `failed` is `differs`. It never moves the
|
|
1977
2154
|
* headline.
|
|
1978
2155
|
*/
|
|
1979
|
-
|
|
2156
|
+
// Distinct `[blockHeight, blockTime]` pairs, in time order.
|
|
2157
|
+
function distinctTimes(times: Iterable<[number, number]>): [number, number][] {
|
|
2158
|
+
const seen = new Map<string, [number, number]>();
|
|
2159
|
+
for (const [h, t] of times) seen.set(`${h}:${t}`, [h, t]);
|
|
2160
|
+
return [...seen.values()].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// Only the keys that carry something: a line with no cached or recorded time
|
|
2164
|
+
// is the line 0.13.0 printed, field for field.
|
|
2165
|
+
function withTimes(
|
|
2166
|
+
line: WitnessLine,
|
|
2167
|
+
cached: [number, number][],
|
|
2168
|
+
recorded: [number, number][],
|
|
2169
|
+
): WitnessLine {
|
|
2170
|
+
return {
|
|
2171
|
+
...line,
|
|
2172
|
+
...(cached.length > 0 ? { cached } : {}),
|
|
2173
|
+
...(recorded.length > 0 ? { recorded } : {}),
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* `cached` is every witness time the packs cache (5.6.6, v1.25, A30). Where no
|
|
2179
|
+
* lookup ran, each is printed labelled not checked. Where one ran, each time
|
|
2180
|
+
* the public record matched is printed as the record's, each time no lookup
|
|
2181
|
+
* settled stays labelled not checked, and a time the record contradicts is the
|
|
2182
|
+
* reason the line reads `differs`.
|
|
2183
|
+
*/
|
|
2184
|
+
export function witnessLine(onChain: OnChainHalf, cached: readonly CachedWitnessTime[] = []): WitnessLine {
|
|
1980
2185
|
if (!onChain.checked) {
|
|
1981
|
-
return
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2186
|
+
return withTimes(
|
|
2187
|
+
{
|
|
2188
|
+
state: WITNESS_NOT_LOOKED_UP.state,
|
|
2189
|
+
reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
|
|
2190
|
+
services: [...WITNESS_NOT_LOOKED_UP.services],
|
|
2191
|
+
},
|
|
2192
|
+
distinctTimes(cached.map((c): [number, number] => [c.blockHeight, c.blockTime])),
|
|
2193
|
+
[],
|
|
2194
|
+
);
|
|
1986
2195
|
}
|
|
1987
2196
|
const services = [...(onChain.answered ?? [])];
|
|
1988
|
-
if (onChain.verdict === "failed") return { state: "differs", reasons: [], services };
|
|
1989
|
-
if (onChain.verdict === "confirmed") return { state: "confirmed", reasons: [], services };
|
|
1990
2197
|
const rows = onChain.packs;
|
|
2198
|
+
const times = rows.flatMap((r) => r.witness_times ?? []);
|
|
2199
|
+
const unsettled = distinctTimes(
|
|
2200
|
+
times
|
|
2201
|
+
.filter((t) => t.outcome === "not_checked")
|
|
2202
|
+
.map((t): [number, number] => [t.cached.block_height, t.cached.block_time]),
|
|
2203
|
+
);
|
|
2204
|
+
const recorded = distinctTimes(
|
|
2205
|
+
times
|
|
2206
|
+
.filter((t) => t.outcome === "matches")
|
|
2207
|
+
.flatMap((t) => t.answers.filter((a) => a.outcome === "matches"))
|
|
2208
|
+
.map((a): [number, number] => [a.block_height as number, a.block_time as number]),
|
|
2209
|
+
);
|
|
2210
|
+
const contradicted: string[] = [];
|
|
2211
|
+
for (const t of times) {
|
|
2212
|
+
if (t.outcome !== "differs") continue;
|
|
2213
|
+
const a = t.answers.find((x) => x.outcome === "differs")!;
|
|
2214
|
+
const reason =
|
|
2215
|
+
`a pack dates the witness ${renderWitnessTime(t.cached.block_height, t.cached.block_time)}` +
|
|
2216
|
+
`, and the public record dates it ${renderWitnessTime(a.block_height as number, a.block_time as number)}`;
|
|
2217
|
+
if (!contradicted.includes(reason)) contradicted.push(reason);
|
|
2218
|
+
}
|
|
2219
|
+
if (onChain.verdict === "failed") {
|
|
2220
|
+
return withTimes({ state: "differs", reasons: contradicted, services }, unsettled, recorded);
|
|
2221
|
+
}
|
|
2222
|
+
if (onChain.verdict === "confirmed") {
|
|
2223
|
+
return withTimes({ state: "confirmed", reasons: [], services }, unsettled, recorded);
|
|
2224
|
+
}
|
|
1991
2225
|
const reasons: string[] = [];
|
|
1992
2226
|
if (rows.some((r) => r.witnesses === 0)) reasons.push("a pack names no witness");
|
|
1993
2227
|
if (rows.some((r) => r.unreachable > 0)) {
|
|
@@ -1999,7 +2233,7 @@ export function witnessLine(onChain: OnChainHalf): WitnessLine {
|
|
|
1999
2233
|
if (rows.some((r) => r.not_checked > 0)) {
|
|
2000
2234
|
reasons.push("no lookup is made for a witness kind a pack names");
|
|
2001
2235
|
}
|
|
2002
|
-
return { state: "not_checked", reasons, services };
|
|
2236
|
+
return withTimes({ state: "not_checked", reasons, services }, unsettled, recorded);
|
|
2003
2237
|
}
|
|
2004
2238
|
|
|
2005
2239
|
/**
|
|
@@ -2199,6 +2433,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2199
2433
|
let asJson = false;
|
|
2200
2434
|
let gateways: string[] = [];
|
|
2201
2435
|
const paths: string[] = [];
|
|
2436
|
+
let filePaths: string[] | undefined;
|
|
2202
2437
|
for (let i = 0; i < argv.length; i++) {
|
|
2203
2438
|
const arg = argv[i]!;
|
|
2204
2439
|
if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
|
|
@@ -2219,6 +2454,15 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2219
2454
|
.map((g) => g.trim())
|
|
2220
2455
|
.filter((g) => g.length > 0);
|
|
2221
2456
|
i++;
|
|
2457
|
+
} else if (arg === "--files") {
|
|
2458
|
+
const taken = takeFilePaths(argv, i + 1);
|
|
2459
|
+
if (taken.length === 0) {
|
|
2460
|
+
io.err("case: --files requires at least one file path");
|
|
2461
|
+
outBlock(io, CASE_USAGE);
|
|
2462
|
+
return EXIT_MALFORMED;
|
|
2463
|
+
}
|
|
2464
|
+
filePaths = [...(filePaths ?? []), ...taken];
|
|
2465
|
+
i += taken.length;
|
|
2222
2466
|
} else if (arg.startsWith("--")) {
|
|
2223
2467
|
io.err(`case: unknown option ${arg}`);
|
|
2224
2468
|
outBlock(io, CASE_USAGE);
|
|
@@ -2233,6 +2477,13 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2233
2477
|
return EXIT_MALFORMED;
|
|
2234
2478
|
}
|
|
2235
2479
|
|
|
2480
|
+
let offered: OfferedFile[] | undefined;
|
|
2481
|
+
if (filePaths !== undefined) {
|
|
2482
|
+
const read = await readOfferedFiles(filePaths);
|
|
2483
|
+
if (typeof read === "string") return caseError(new CaseInputError(read), asJson, io);
|
|
2484
|
+
offered = read;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2236
2487
|
let split;
|
|
2237
2488
|
let tree: Map<string, TreeEntry>;
|
|
2238
2489
|
let bundles: [SplitPack, unknown][];
|
|
@@ -2255,25 +2506,31 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2255
2506
|
if (e instanceof CaseInputError) return caseError(e, asJson, io);
|
|
2256
2507
|
throw e;
|
|
2257
2508
|
}
|
|
2509
|
+
const caches: CachedWitnessTime[][] = [];
|
|
2510
|
+
for (const [p, bundle] of bundles) {
|
|
2511
|
+
caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
|
|
2512
|
+
}
|
|
2258
2513
|
const onChain = await onChainHalf(
|
|
2259
|
-
bundles.map(([p, bundle]): [string, unknown] => [p.ref, bundle]),
|
|
2514
|
+
bundles.map(([p, bundle], i): [string, unknown, CachedWitnessTime[]] => [p.ref, bundle, caches[i]!]),
|
|
2260
2515
|
gateways,
|
|
2261
2516
|
io,
|
|
2262
2517
|
);
|
|
2263
|
-
const witness = witnessLine(onChain);
|
|
2518
|
+
const witness = witnessLine(onChain, caches.flat());
|
|
2264
2519
|
const code = caseExitCode(reading.verdict, onChain.verdict);
|
|
2265
2520
|
const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
|
|
2266
2521
|
|
|
2267
2522
|
const data = {
|
|
2268
2523
|
verdict: reading.verdict,
|
|
2269
2524
|
title: titleOf(reading.verdict),
|
|
2270
|
-
reliance:
|
|
2525
|
+
reliance: caseReliance(reading.verdict, reading.handoffRecords),
|
|
2271
2526
|
exit_code: code,
|
|
2272
2527
|
summary: renderCaseSummary(reading),
|
|
2273
2528
|
witness: {
|
|
2274
2529
|
state: witness.state,
|
|
2275
2530
|
reasons: witness.reasons,
|
|
2276
2531
|
services: witness.services,
|
|
2532
|
+
...(witness.cached ? { cached: witness.cached } : {}),
|
|
2533
|
+
...(witness.recorded ? { recorded: witness.recorded } : {}),
|
|
2277
2534
|
rendered: renderWitnessLine(witness),
|
|
2278
2535
|
},
|
|
2279
2536
|
handoff_records: reading.handoffRecords,
|
|
@@ -2321,8 +2578,21 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2321
2578
|
case_file: caseFile,
|
|
2322
2579
|
on_chain: onChain,
|
|
2323
2580
|
};
|
|
2324
|
-
|
|
2325
|
-
|
|
2581
|
+
// Files handed in beside the packs (evidence-bundle.md 5.7, A29). A pack's
|
|
2582
|
+
// logs/ is its side content and is never an offered file; only the paths
|
|
2583
|
+
// after --files are. Nothing here moves `code`.
|
|
2584
|
+
let fileLines: string[] = [];
|
|
2585
|
+
const out: Record<string, unknown> = data;
|
|
2586
|
+
if (offered !== undefined) {
|
|
2587
|
+
const fileReading = await matchCaseFiles(
|
|
2588
|
+
bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
|
|
2589
|
+
offered,
|
|
2590
|
+
);
|
|
2591
|
+
out.files = fileMatchToJson(fileReading);
|
|
2592
|
+
fileLines = renderFileMatch(fileReading);
|
|
2593
|
+
}
|
|
2594
|
+
if (asJson) outJson(io, jsonText(out));
|
|
2595
|
+
else for (const line of [...caseReport(data), ...fileLines]) io.out(noLoneSurrogates(line));
|
|
2326
2596
|
return code;
|
|
2327
2597
|
}
|
|
2328
2598
|
|
package/src/compose.ts
CHANGED
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
sha256Hex,
|
|
40
40
|
utf8,
|
|
41
41
|
} from "./crypto.js";
|
|
42
|
-
import { jcs } from "./verifier.js";
|
|
42
|
+
import { isRfc3339UtcInstant, jcs } from "./verifier.js";
|
|
43
43
|
|
|
44
44
|
// The exporter's Ed25519 wrapper-signing key. `privateKey` is a 32-byte seed as
|
|
45
45
|
// lowercase hex (the @noble/ed25519 / stack convention, e.g. the fixtures'
|
|
@@ -133,6 +133,13 @@ export async function composeExport(
|
|
|
133
133
|
throw new Error("composeExport: exporterKey.privateKey (32-byte hex seed) is required");
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
if (options.generatedAt !== undefined && !isRfc3339UtcInstant(options.generatedAt)) {
|
|
137
|
+
// The verifier reads this as `malformed generated_at` (evidence-bundle §2,
|
|
138
|
+
// A31), so composing one would sign an export this kernel rejects.
|
|
139
|
+
throw new Error(
|
|
140
|
+
"composeExport: generatedAt is not an RFC 3339 UTC date-time with Z naming a real date and time",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
136
143
|
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
137
144
|
const previousHash = options.previousHash ?? GENESIS;
|
|
138
145
|
const issuerId = options.issuerId ?? "sanning-verify";
|