@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/case-file.js +6 -2
- package/dist/case.d.ts +51 -7
- package/dist/case.js +81 -16
- package/dist/cli.d.ts +8 -27
- package/dist/cli.js +184 -103
- 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 +8 -2
- package/dist/index.js +24 -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/dist/witness-lookup.d.ts +112 -0
- package/dist/witness-lookup.js +300 -0
- package/package.json +1 -1
- package/src/case-file.ts +5 -2
- package/src/case.ts +101 -15
- package/src/cli.ts +191 -123
- package/src/compose.ts +8 -1
- package/src/evidence.ts +115 -2
- package/src/file-match.ts +461 -0
- package/src/index.ts +68 -0
- package/src/message-parts.ts +18 -10
- package/src/verifier.ts +190 -3
- package/src/witness-cache.ts +306 -0
- package/src/witness-lookup.ts +428 -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,8 @@ 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
|
-
|
|
63
|
-
type WitnessLine,
|
|
61
|
+
caseReliance,
|
|
64
62
|
readCase,
|
|
65
63
|
renderCaseNote,
|
|
66
64
|
renderCaseSummary,
|
|
@@ -78,6 +76,20 @@ import { composeExport } from "./compose.js";
|
|
|
78
76
|
import type { ComposeExportOptions } from "./compose.js";
|
|
79
77
|
import { isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
80
78
|
import { verifyEvidenceBundle } from "./evidence.js";
|
|
79
|
+
import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
|
|
80
|
+
import { fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch } from "./file-match.js";
|
|
81
|
+
import type { OfferedFile } from "./file-match.js";
|
|
82
|
+
import {
|
|
83
|
+
type CachedWitnessTime,
|
|
84
|
+
WITNESS_CACHE_NAME,
|
|
85
|
+
readWitnessCache,
|
|
86
|
+
} from "./witness-cache.js";
|
|
87
|
+
// The public-record (witness) lookup this command runs with `--gateways`:
|
|
88
|
+
// moved to its own pure module (proof#200) so a consumer that is not this CLI,
|
|
89
|
+
// the verify page, can call the SAME implementation. `onChainHalf` and
|
|
90
|
+
// `witnessLine` are this command's own two building blocks, unchanged; see
|
|
91
|
+
// `./witness-lookup.ts` for the exported entry point, `lookupPublicRecord`.
|
|
92
|
+
import { type OnChainHalf, onChainHalf, witnessLine } from "./witness-lookup.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,
|
|
@@ -1896,111 +2033,6 @@ function jsonText(v: unknown): string {
|
|
|
1896
2033
|
return JSON.stringify(sortKeys(v), null, 2);
|
|
1897
2034
|
}
|
|
1898
2035
|
|
|
1899
|
-
interface OnChainRow {
|
|
1900
|
-
ref: string;
|
|
1901
|
-
witnesses: number;
|
|
1902
|
-
confirmed: number;
|
|
1903
|
-
mismatched: number;
|
|
1904
|
-
unreachable: number;
|
|
1905
|
-
not_checked: number;
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
interface OnChainHalf {
|
|
1909
|
-
/** Present only where a lookup ran: each gateway that answered, in the order given. */
|
|
1910
|
-
answered?: string[];
|
|
1911
|
-
checked: boolean;
|
|
1912
|
-
gateways: string[];
|
|
1913
|
-
packs: OnChainRow[];
|
|
1914
|
-
verdict: "not_checked" | "confirmed" | "failed" | "undetermined";
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
// Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
|
|
1918
|
-
// case reading on purpose: `readCase` makes no network call, and neither does
|
|
1919
|
-
// the verify page's case reading, so the case verdict stays the one both
|
|
1920
|
-
// surfaces compute. This adds the half the reading names as not checked.
|
|
1921
|
-
async function onChainHalf(
|
|
1922
|
-
packs: readonly [string, unknown][],
|
|
1923
|
-
gateways: string[],
|
|
1924
|
-
io: Cli,
|
|
1925
|
-
): Promise<OnChainHalf> {
|
|
1926
|
-
if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
|
|
1927
|
-
const rows: OnChainRow[] = [];
|
|
1928
|
-
const answered = new Set<string>();
|
|
1929
|
-
for (const [ref, bundle] of packs) {
|
|
1930
|
-
const result = await verifyEvidenceBundle(bundle, {
|
|
1931
|
-
gateways,
|
|
1932
|
-
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
1933
|
-
});
|
|
1934
|
-
const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
|
|
1935
|
-
const row: OnChainRow = {
|
|
1936
|
-
ref,
|
|
1937
|
-
witnesses: witnesses.length,
|
|
1938
|
-
confirmed: 0,
|
|
1939
|
-
mismatched: 0,
|
|
1940
|
-
unreachable: 0,
|
|
1941
|
-
not_checked: 0,
|
|
1942
|
-
};
|
|
1943
|
-
for (const w of witnesses) {
|
|
1944
|
-
const rollup = w.onChain?.rollup ?? null;
|
|
1945
|
-
if (rollup === "confirm") row.confirmed++;
|
|
1946
|
-
else if (rollup === "mismatch") row.mismatched++;
|
|
1947
|
-
else if (rollup === "unreachable") row.unreachable++;
|
|
1948
|
-
else row.not_checked++;
|
|
1949
|
-
}
|
|
1950
|
-
rows.push(row);
|
|
1951
|
-
for (const w of witnesses) {
|
|
1952
|
-
for (const outcome of w.onChain?.perGateway ?? []) {
|
|
1953
|
-
if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
|
|
1954
|
-
answered.add(outcome.gateway);
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
const verdict = rows.some((r) => r.mismatched > 0)
|
|
1960
|
-
? "failed"
|
|
1961
|
-
: rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
|
|
1962
|
-
? "undetermined"
|
|
1963
|
-
: "confirmed";
|
|
1964
|
-
return {
|
|
1965
|
-
answered: gateways.filter((g) => answered.has(g)),
|
|
1966
|
-
checked: true,
|
|
1967
|
-
gateways: [...gateways],
|
|
1968
|
-
packs: rows,
|
|
1969
|
-
verdict,
|
|
1970
|
-
};
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
/**
|
|
1974
|
-
* The witness line (evidence-bundle 5.5.6 v1.20, A24, D7) from the on-chain
|
|
1975
|
-
* half: `confirmed` is `confirmed`, `not_checked` and `undetermined` are
|
|
1976
|
-
* `not_checked` with the reason, and `failed` is `differs`. It never moves the
|
|
1977
|
-
* headline.
|
|
1978
|
-
*/
|
|
1979
|
-
export function witnessLine(onChain: OnChainHalf): WitnessLine {
|
|
1980
|
-
if (!onChain.checked) {
|
|
1981
|
-
return {
|
|
1982
|
-
state: WITNESS_NOT_LOOKED_UP.state,
|
|
1983
|
-
reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
|
|
1984
|
-
services: [...WITNESS_NOT_LOOKED_UP.services],
|
|
1985
|
-
};
|
|
1986
|
-
}
|
|
1987
|
-
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
|
-
const rows = onChain.packs;
|
|
1991
|
-
const reasons: string[] = [];
|
|
1992
|
-
if (rows.some((r) => r.witnesses === 0)) reasons.push("a pack names no witness");
|
|
1993
|
-
if (rows.some((r) => r.unreachable > 0)) {
|
|
1994
|
-
reasons.push(
|
|
1995
|
-
"a lookup did not settle: the service could not be reached, or it " +
|
|
1996
|
-
"holds no such witness yet",
|
|
1997
|
-
);
|
|
1998
|
-
}
|
|
1999
|
-
if (rows.some((r) => r.not_checked > 0)) {
|
|
2000
|
-
reasons.push("no lookup is made for a witness kind a pack names");
|
|
2001
|
-
}
|
|
2002
|
-
return { state: "not_checked", reasons, services };
|
|
2003
|
-
}
|
|
2004
2036
|
|
|
2005
2037
|
/**
|
|
2006
2038
|
* The case verdict's code, moved only by an on-chain half that was asked for:
|
|
@@ -2199,6 +2231,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2199
2231
|
let asJson = false;
|
|
2200
2232
|
let gateways: string[] = [];
|
|
2201
2233
|
const paths: string[] = [];
|
|
2234
|
+
let filePaths: string[] | undefined;
|
|
2202
2235
|
for (let i = 0; i < argv.length; i++) {
|
|
2203
2236
|
const arg = argv[i]!;
|
|
2204
2237
|
if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
|
|
@@ -2219,6 +2252,15 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2219
2252
|
.map((g) => g.trim())
|
|
2220
2253
|
.filter((g) => g.length > 0);
|
|
2221
2254
|
i++;
|
|
2255
|
+
} else if (arg === "--files") {
|
|
2256
|
+
const taken = takeFilePaths(argv, i + 1);
|
|
2257
|
+
if (taken.length === 0) {
|
|
2258
|
+
io.err("case: --files requires at least one file path");
|
|
2259
|
+
outBlock(io, CASE_USAGE);
|
|
2260
|
+
return EXIT_MALFORMED;
|
|
2261
|
+
}
|
|
2262
|
+
filePaths = [...(filePaths ?? []), ...taken];
|
|
2263
|
+
i += taken.length;
|
|
2222
2264
|
} else if (arg.startsWith("--")) {
|
|
2223
2265
|
io.err(`case: unknown option ${arg}`);
|
|
2224
2266
|
outBlock(io, CASE_USAGE);
|
|
@@ -2233,6 +2275,13 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2233
2275
|
return EXIT_MALFORMED;
|
|
2234
2276
|
}
|
|
2235
2277
|
|
|
2278
|
+
let offered: OfferedFile[] | undefined;
|
|
2279
|
+
if (filePaths !== undefined) {
|
|
2280
|
+
const read = await readOfferedFiles(filePaths);
|
|
2281
|
+
if (typeof read === "string") return caseError(new CaseInputError(read), asJson, io);
|
|
2282
|
+
offered = read;
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2236
2285
|
let split;
|
|
2237
2286
|
let tree: Map<string, TreeEntry>;
|
|
2238
2287
|
let bundles: [SplitPack, unknown][];
|
|
@@ -2255,25 +2304,31 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2255
2304
|
if (e instanceof CaseInputError) return caseError(e, asJson, io);
|
|
2256
2305
|
throw e;
|
|
2257
2306
|
}
|
|
2307
|
+
const caches: CachedWitnessTime[][] = [];
|
|
2308
|
+
for (const [p, bundle] of bundles) {
|
|
2309
|
+
caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
|
|
2310
|
+
}
|
|
2258
2311
|
const onChain = await onChainHalf(
|
|
2259
|
-
bundles.map(([p, bundle]): [string, unknown] => [p.ref, bundle]),
|
|
2312
|
+
bundles.map(([p, bundle], i): [string, unknown, CachedWitnessTime[]] => [p.ref, bundle, caches[i]!]),
|
|
2260
2313
|
gateways,
|
|
2261
|
-
io,
|
|
2314
|
+
{ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) },
|
|
2262
2315
|
);
|
|
2263
|
-
const witness = witnessLine(onChain);
|
|
2316
|
+
const witness = witnessLine(onChain, caches.flat());
|
|
2264
2317
|
const code = caseExitCode(reading.verdict, onChain.verdict);
|
|
2265
2318
|
const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
|
|
2266
2319
|
|
|
2267
2320
|
const data = {
|
|
2268
2321
|
verdict: reading.verdict,
|
|
2269
2322
|
title: titleOf(reading.verdict),
|
|
2270
|
-
reliance:
|
|
2323
|
+
reliance: caseReliance(reading.verdict, reading.handoffRecords),
|
|
2271
2324
|
exit_code: code,
|
|
2272
2325
|
summary: renderCaseSummary(reading),
|
|
2273
2326
|
witness: {
|
|
2274
2327
|
state: witness.state,
|
|
2275
2328
|
reasons: witness.reasons,
|
|
2276
2329
|
services: witness.services,
|
|
2330
|
+
...(witness.cached ? { cached: witness.cached } : {}),
|
|
2331
|
+
...(witness.recorded ? { recorded: witness.recorded } : {}),
|
|
2277
2332
|
rendered: renderWitnessLine(witness),
|
|
2278
2333
|
},
|
|
2279
2334
|
handoff_records: reading.handoffRecords,
|
|
@@ -2321,8 +2376,21 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2321
2376
|
case_file: caseFile,
|
|
2322
2377
|
on_chain: onChain,
|
|
2323
2378
|
};
|
|
2324
|
-
|
|
2325
|
-
|
|
2379
|
+
// Files handed in beside the packs (evidence-bundle.md 5.7, A29). A pack's
|
|
2380
|
+
// logs/ is its side content and is never an offered file; only the paths
|
|
2381
|
+
// after --files are. Nothing here moves `code`.
|
|
2382
|
+
let fileLines: string[] = [];
|
|
2383
|
+
const out: Record<string, unknown> = data;
|
|
2384
|
+
if (offered !== undefined) {
|
|
2385
|
+
const fileReading = await matchCaseFiles(
|
|
2386
|
+
bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
|
|
2387
|
+
offered,
|
|
2388
|
+
);
|
|
2389
|
+
out.files = fileMatchToJson(fileReading);
|
|
2390
|
+
fileLines = renderFileMatch(fileReading);
|
|
2391
|
+
}
|
|
2392
|
+
if (asJson) outJson(io, jsonText(out));
|
|
2393
|
+
else for (const line of [...caseReport(data), ...fileLines]) io.out(noLoneSurrogates(line));
|
|
2326
2394
|
return code;
|
|
2327
2395
|
}
|
|
2328
2396
|
|
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";
|