@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/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 {
|
|
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
|
|
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 =
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1922
|
-
const
|
|
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:
|
|
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(
|
|
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";
|
package/dist/evidence.d.ts
CHANGED
|
@@ -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 {
|