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