@sanning/proof 0.5.0 → 0.7.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/README.md +31 -4
- package/dist/chain.d.ts +99 -0
- package/dist/chain.js +351 -0
- package/dist/cli.js +253 -38
- package/dist/compose.js +1 -1
- package/dist/evidence.d.ts +204 -7
- package/dist/evidence.js +621 -67
- package/dist/grading.d.ts +160 -0
- package/dist/grading.js +469 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +38 -1
- package/dist/side-content.d.ts +36 -0
- package/dist/side-content.js +98 -0
- package/dist/verifier.js +46 -7
- package/package.json +2 -1
- package/src/chain.ts +460 -0
- package/src/cli.ts +324 -35
- package/src/compose.ts +1 -1
- package/src/evidence.ts +875 -81
- package/src/grading.ts +623 -0
- package/src/index.ts +79 -0
- package/src/side-content.ts +119 -0
- package/src/verifier.ts +49 -7
- package/vectors/README.md +66 -0
- package/vectors/chain-reading.json +293 -0
- package/vectors/pack-grading.json +136 -0
package/dist/cli.js
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
// 3 gateway-unavailable when an on-chain re-fetch was requested
|
|
14
14
|
// `payloadHashOk === null` (a withheld record) is semantics-undetermined and
|
|
15
15
|
// surfaces as a per-event note — it does NOT fail the run.
|
|
16
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
16
|
+
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
17
17
|
import { realpathSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
19
20
|
import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
|
|
21
|
+
import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
|
|
20
22
|
import { composeExport } from "./compose.js";
|
|
21
23
|
import { utf8 } from "./crypto.js";
|
|
22
24
|
import { verifyEvidenceBundle } from "./evidence.js";
|
|
@@ -68,7 +70,7 @@ export async function runCli(argv, io) {
|
|
|
68
70
|
printUsage(io);
|
|
69
71
|
return EXIT_MALFORMED;
|
|
70
72
|
}
|
|
71
|
-
// Pull the optional `--logs <
|
|
73
|
+
// Pull the optional `--logs <path>` flag out of the args; the rest are
|
|
72
74
|
// positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
|
|
73
75
|
// content verification (the evidence-bundle path only).
|
|
74
76
|
let logsPath;
|
|
@@ -78,7 +80,7 @@ export async function runCli(argv, io) {
|
|
|
78
80
|
if (a === "--logs") {
|
|
79
81
|
const next = rest[i + 1];
|
|
80
82
|
if (next === undefined) {
|
|
81
|
-
io.err("verify: --logs requires a file
|
|
83
|
+
io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
|
|
82
84
|
printUsage(io);
|
|
83
85
|
return EXIT_MALFORMED;
|
|
84
86
|
}
|
|
@@ -98,34 +100,15 @@ export async function runCli(argv, io) {
|
|
|
98
100
|
.split(",")
|
|
99
101
|
.map((g) => g.trim())
|
|
100
102
|
.filter((g) => g.length > 0);
|
|
101
|
-
let
|
|
103
|
+
let logs;
|
|
102
104
|
if (logsPath !== undefined) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
logsRaw = await readFile(logsPath, "utf8");
|
|
106
|
-
}
|
|
107
|
-
catch (e) {
|
|
108
|
-
io.err(`cannot read --logs ${logsPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
109
|
-
return EXIT_MALFORMED;
|
|
110
|
-
}
|
|
111
|
-
let logsParsed;
|
|
112
|
-
try {
|
|
113
|
-
logsParsed = JSON.parse(logsRaw);
|
|
114
|
-
}
|
|
115
|
-
catch (e) {
|
|
116
|
-
io.err(`--logs ${logsPath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
117
|
-
return EXIT_MALFORMED;
|
|
118
|
-
}
|
|
119
|
-
if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
|
|
120
|
-
io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes`);
|
|
105
|
+
logs = await loadLogs(logsPath, io);
|
|
106
|
+
if (logs === undefined)
|
|
121
107
|
return EXIT_MALFORMED;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
for (const [k, v] of Object.entries(logsParsed)) {
|
|
125
|
-
if (typeof v === "string")
|
|
126
|
-
content[k] = decodeLogValue(v);
|
|
127
|
-
}
|
|
108
|
+
for (const n of logs.notes)
|
|
109
|
+
io.err(`note: ${n}`);
|
|
128
110
|
}
|
|
111
|
+
const content = logs?.content;
|
|
129
112
|
let raw;
|
|
130
113
|
try {
|
|
131
114
|
raw = await readFile(bundlePath, "utf8");
|
|
@@ -165,7 +148,17 @@ export async function runCli(argv, io) {
|
|
|
165
148
|
...(content ? { content } : {}),
|
|
166
149
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
167
150
|
});
|
|
168
|
-
|
|
151
|
+
// Read the chains from the SAME parsed bundle the verifier saw. Findings are
|
|
152
|
+
// evidence about the records in front of us, not a verdict: they are printed
|
|
153
|
+
// and they never move `exit`, because a chain is not a completeness proof and
|
|
154
|
+
// an exit code is exactly the kind of thing people read as one.
|
|
155
|
+
const chains = await readChains(parsed);
|
|
156
|
+
const exit = reportEvidence(result, bundlePath, gateways, io, chains);
|
|
157
|
+
// Coverage is reported only once the bundle parsed far enough to have events
|
|
158
|
+
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
159
|
+
if (logs && result.status !== "malformed")
|
|
160
|
+
reportLogsCoverage(logs, result, io);
|
|
161
|
+
return exit;
|
|
169
162
|
}
|
|
170
163
|
// `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
|
|
171
164
|
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
@@ -381,10 +374,187 @@ async function readExporterKey(path, io) {
|
|
|
381
374
|
}
|
|
382
375
|
return seed.toLowerCase();
|
|
383
376
|
}
|
|
384
|
-
// The
|
|
377
|
+
// The suffix the directory form reads, matched exactly — lowercase, which is
|
|
378
|
+
// what `materializeBundle` writes. Anything else is skipped and NAMED rather
|
|
379
|
+
// than guessed at, so a `.JSON` or extensionless file shows up as a note
|
|
380
|
+
// instead of a mysteriously undisclosed event.
|
|
381
|
+
const LOG_SUFFIX = ".json";
|
|
382
|
+
// Which shape was passed is decided by `stat`, never by the extension: a pack's
|
|
383
|
+
// log directory can be called anything, and `logs.json` is a legal directory
|
|
384
|
+
// name. `stat` follows symlinks, so a symlinked logs directory works.
|
|
385
|
+
async function loadLogs(logsPath, io) {
|
|
386
|
+
let isDirectory;
|
|
387
|
+
try {
|
|
388
|
+
isDirectory = (await stat(logsPath)).isDirectory();
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
|
|
395
|
+
}
|
|
396
|
+
// The JSON-map form, unchanged: `{ event_id: <hex | utf8 text> }`.
|
|
397
|
+
async function loadLogsMap(logsPath, io) {
|
|
398
|
+
let logsRaw;
|
|
399
|
+
try {
|
|
400
|
+
logsRaw = await readFile(logsPath, "utf8");
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
let logsParsed;
|
|
407
|
+
try {
|
|
408
|
+
logsParsed = JSON.parse(logsRaw);
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
|
|
415
|
+
io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes, ` +
|
|
416
|
+
`or a directory of <event_id>${LOG_SUFFIX} files`);
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
const content = new Map();
|
|
420
|
+
const suppliedIds = [];
|
|
421
|
+
for (const [k, v] of Object.entries(logsParsed)) {
|
|
422
|
+
if (typeof v !== "string")
|
|
423
|
+
continue;
|
|
424
|
+
content.set(k, decodeLogValue(v));
|
|
425
|
+
suppliedIds.push(k);
|
|
426
|
+
}
|
|
427
|
+
return { content, suppliedIds, notes: [] };
|
|
428
|
+
}
|
|
429
|
+
// The directory form. Flat, one file per event, `<event_id>.json`.
|
|
430
|
+
//
|
|
431
|
+
// The decisions this pins, none of which is a verification failure — an absent
|
|
432
|
+
// or unusable disclosure is UNDISCLOSED, and absence is not evidence of
|
|
433
|
+
// tampering (evidence-bundle.md §5.1):
|
|
434
|
+
//
|
|
435
|
+
// - a file whose name is not a known event id → loaded, binds to nothing,
|
|
436
|
+
// named in a post-verify note (silence is how a typo'd filename becomes a
|
|
437
|
+
// mysteriously undisclosed event)
|
|
438
|
+
// - an event with no corresponding file → undetermined (`~`), noted
|
|
439
|
+
// - an empty directory → valid; discloses nothing,
|
|
440
|
+
// noted, exit code unchanged
|
|
441
|
+
// - a subdirectory → skipped, never recursed,
|
|
442
|
+
// noted (this is the "you pointed at the pack root, not its logs/" case)
|
|
443
|
+
// - anything not named `<event_id>.json`, and anything that is not a regular
|
|
444
|
+
// file → skipped, noted
|
|
445
|
+
//
|
|
446
|
+
// The one hard stop is a file we SELECTED but cannot read: we were pointed at
|
|
447
|
+
// it, so a permissions error must not read as minimal disclosure. That exits 2.
|
|
448
|
+
async function loadLogsDirectory(dirPath, io) {
|
|
449
|
+
let names;
|
|
450
|
+
try {
|
|
451
|
+
names = await readdir(dirPath);
|
|
452
|
+
}
|
|
453
|
+
catch (e) {
|
|
454
|
+
io.err(`cannot read --logs directory ${dirPath}: ${errMsg(e)}`);
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
const content = new Map();
|
|
458
|
+
const suppliedIds = [];
|
|
459
|
+
const skippedDirs = [];
|
|
460
|
+
const skippedNames = [];
|
|
461
|
+
for (const name of [...names].sort()) {
|
|
462
|
+
const p = join(dirPath, name);
|
|
463
|
+
let isDirectory;
|
|
464
|
+
let isFile;
|
|
465
|
+
try {
|
|
466
|
+
// `stat`, not the dirent kind: a symlinked log file is a log file, and a
|
|
467
|
+
// symlink to a directory is a directory.
|
|
468
|
+
const s = await stat(p);
|
|
469
|
+
isDirectory = s.isDirectory();
|
|
470
|
+
isFile = s.isFile();
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
io.err(`cannot read --logs entry ${p}: ${errMsg(e)}`);
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
if (isDirectory) {
|
|
477
|
+
skippedDirs.push(name);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
// Not a regular file (device, fifo, socket), or not named
|
|
481
|
+
// `<event_id>.json` — including a file named exactly `.json`, whose event
|
|
482
|
+
// id would be the empty string.
|
|
483
|
+
if (!isFile || !name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
|
|
484
|
+
skippedNames.push(name);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const eventId = name.slice(0, -LOG_SUFFIX.length);
|
|
488
|
+
try {
|
|
489
|
+
// No encoding argument: bytes, exactly as they sit on disk. Copied out of
|
|
490
|
+
// Buffer's shared pool so the array handed to the verifier owns its own
|
|
491
|
+
// memory rather than a window onto a reused block.
|
|
492
|
+
content.set(eventId, new Uint8Array(await readFile(p)));
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
io.err(`cannot read --logs file ${p}: ${errMsg(e)}`);
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
suppliedIds.push(eventId);
|
|
499
|
+
}
|
|
500
|
+
const notes = [];
|
|
501
|
+
if (skippedDirs.length > 0) {
|
|
502
|
+
notes.push(`--logs ${dirPath}: skipped ${count(skippedDirs.length, "subdirectory", "subdirectories")} ` +
|
|
503
|
+
`(${sample(skippedDirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
|
|
504
|
+
`and does not recurse`);
|
|
505
|
+
}
|
|
506
|
+
if (skippedNames.length > 0) {
|
|
507
|
+
notes.push(`--logs ${dirPath}: skipped ${count(skippedNames.length, "entry", "entries")} ` +
|
|
508
|
+
`not named <event_id>${LOG_SUFFIX} (${sample(skippedNames)})`);
|
|
509
|
+
}
|
|
510
|
+
if (suppliedIds.length === 0) {
|
|
511
|
+
notes.push(`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files) — every event ` +
|
|
512
|
+
`stays undisclosed, which is undetermined, not failed`);
|
|
513
|
+
}
|
|
514
|
+
return { content, suppliedIds, notes };
|
|
515
|
+
}
|
|
516
|
+
// What the side input covered, reported once the bundle's own event ids are
|
|
517
|
+
// known. Both directions are ADVISORY — neither moves the verdict or the exit
|
|
518
|
+
// code — and both exist because the silent version of each has already cost
|
|
519
|
+
// somebody an afternoon:
|
|
520
|
+
//
|
|
521
|
+
// - an entry matching no event binds to nothing. A typo'd id, or a directory
|
|
522
|
+
// scoped to a different bundle, otherwise looks identical to minimal
|
|
523
|
+
// disclosure.
|
|
524
|
+
// - an event whose content stayed undetermined disclosed no usable bytes.
|
|
525
|
+
// That is UNDISCLOSED, never a failure (evidence-bundle.md §5.1): absence
|
|
526
|
+
// of a disclosure is not evidence of tampering.
|
|
527
|
+
function reportLogsCoverage(logs, result, io) {
|
|
528
|
+
const eventIds = new Set(result.events.map((e) => e.eventId));
|
|
529
|
+
const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id));
|
|
530
|
+
if (unmatched.length > 0) {
|
|
531
|
+
io.err(`note: --logs supplied ${count(unmatched.length, "entry", "entries")} matching no event ` +
|
|
532
|
+
`in the bundle, ignored: ${sample(unmatched)}`);
|
|
533
|
+
}
|
|
534
|
+
const undetermined = result.events.filter((e) => e.contentOk === null);
|
|
535
|
+
if (undetermined.length > 0) {
|
|
536
|
+
io.err(`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
|
|
537
|
+
`content — no bytes disclosed, or no committed content_hash to bind them to. ` +
|
|
538
|
+
`Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function errMsg(e) {
|
|
542
|
+
return e instanceof Error ? e.message : String(e);
|
|
543
|
+
}
|
|
544
|
+
function count(n, one, many) {
|
|
545
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
546
|
+
}
|
|
547
|
+
// First few names, quoted, with a tail count — enough to recognise the mistake
|
|
548
|
+
// without pasting a whole directory into the terminal.
|
|
549
|
+
function sample(names, max = 3) {
|
|
550
|
+
const shown = names.slice(0, max).map((n) => JSON.stringify(n)).join(", ");
|
|
551
|
+
return names.length > max ? `${shown}, +${names.length - max} more` : shown;
|
|
552
|
+
}
|
|
553
|
+
// The --logs MAP lane decides each disclosed value's encoding: a clean,
|
|
385
554
|
// even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
|
|
386
555
|
// (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
|
|
387
|
-
// raw bytes" — the human-facing encoding choice is made here.
|
|
556
|
+
// raw bytes" — the human-facing encoding choice is made here. The DIRECTORY
|
|
557
|
+
// lane never reaches this: its bytes are already bytes.)
|
|
388
558
|
function decodeLogValue(v) {
|
|
389
559
|
if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v))
|
|
390
560
|
return v; // hex passthrough
|
|
@@ -394,7 +564,7 @@ function printUsage(io) {
|
|
|
394
564
|
io.out("Usage: npx @sanning/proof <command> [args]");
|
|
395
565
|
io.out("");
|
|
396
566
|
io.out("Commands:");
|
|
397
|
-
io.out(" verify <bundle.json> [gateways] [--logs <
|
|
567
|
+
io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
|
|
398
568
|
io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
|
|
399
569
|
io.out("");
|
|
400
570
|
io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
|
|
@@ -402,14 +572,25 @@ function printUsage(io) {
|
|
|
402
572
|
io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
|
|
403
573
|
io.out("to also re-fetch each checkpoint on-chain.");
|
|
404
574
|
io.out("");
|
|
405
|
-
io.out("--logs <
|
|
406
|
-
io.out(" content_hash.
|
|
407
|
-
io.out("
|
|
408
|
-
io.out("
|
|
575
|
+
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
576
|
+
io.out(" content_hash. Either shape works, and which one you");
|
|
577
|
+
io.out(" passed is decided by stat(), not by the extension:");
|
|
578
|
+
io.out("");
|
|
579
|
+
io.out(" a DIRECTORY of <event_id>.json files — the file's");
|
|
580
|
+
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
581
|
+
io.out(" This is a materialized pack's logs/ directory:");
|
|
582
|
+
io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
583
|
+
io.out("");
|
|
584
|
+
io.out(" a JSON FILE mapping event_id → bytes (an");
|
|
585
|
+
io.out(" even-length lowercase-hex value ⇒ hex, else utf8).");
|
|
586
|
+
io.out("");
|
|
587
|
+
io.out(" In-body events[].content is verified automatically");
|
|
588
|
+
io.out(" and takes precedence. An event with no disclosed");
|
|
589
|
+
io.out(" bytes is undetermined (~), never a failure.");
|
|
409
590
|
io.out("");
|
|
410
591
|
io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
|
|
411
592
|
}
|
|
412
|
-
function reportEvidence(result, path, gateways, io) {
|
|
593
|
+
function reportEvidence(result, path, gateways, io, chains) {
|
|
413
594
|
if (result.status === "malformed") {
|
|
414
595
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
415
596
|
for (const e of result.errors)
|
|
@@ -424,7 +605,7 @@ function reportEvidence(result, path, gateways, io) {
|
|
|
424
605
|
io.out(paint(" Checkpoints", DIM));
|
|
425
606
|
for (const cp of result.checkpoints) {
|
|
426
607
|
const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
|
|
427
|
-
io.out(` ${mark(cp.ok)} ${shortTx(cp.
|
|
608
|
+
io.out(` ${mark(cp.ok)} ${shortTx(cp.checkpointRef)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`);
|
|
428
609
|
for (const e of cp.errors)
|
|
429
610
|
io.out(paint(` - ${e}`, R));
|
|
430
611
|
}
|
|
@@ -445,6 +626,7 @@ function reportEvidence(result, path, gateways, io) {
|
|
|
445
626
|
io.out(paint(` - ${e}`, R));
|
|
446
627
|
}
|
|
447
628
|
}
|
|
629
|
+
reportChains(chains, io);
|
|
448
630
|
if (result.export) {
|
|
449
631
|
reportExport(result.export, io);
|
|
450
632
|
}
|
|
@@ -459,6 +641,39 @@ function reportEvidence(result, path, gateways, io) {
|
|
|
459
641
|
}
|
|
460
642
|
return mapStatusToExit(result.status, result.errors);
|
|
461
643
|
}
|
|
644
|
+
/**
|
|
645
|
+
* The chain block: what following each record's pointer to the record before it
|
|
646
|
+
* showed, over the records THIS bundle presents.
|
|
647
|
+
*
|
|
648
|
+
* Three deliberate choices, all of them about not overclaiming.
|
|
649
|
+
*
|
|
650
|
+
* 1. It prints the chains it READ, not just the findings. "3 chains, nothing to
|
|
651
|
+
* report" and silence are different statements, and a reader that quietly
|
|
652
|
+
* matched no records at all would produce the second one while covering
|
|
653
|
+
* nothing.
|
|
654
|
+
* 2. It prints the scope sentence whenever it read a chain, findings or not.
|
|
655
|
+
* "Delete an event and the next pointer dangles" is true; "so the bundle is
|
|
656
|
+
* complete" does not follow, and two customer-facing documents had already
|
|
657
|
+
* made that leap. The limit travels with the result or it gets dropped.
|
|
658
|
+
* 3. It does not touch the exit code. Findings are evidence; the exit code is
|
|
659
|
+
* the verdict surface, and the pinned 0/1/2/3 contract stays exactly what it
|
|
660
|
+
* was. A dangling link is something for a person to look at, not a machine
|
|
661
|
+
* decision this check is entitled to make.
|
|
662
|
+
*/
|
|
663
|
+
function reportChains(reading, io) {
|
|
664
|
+
if (reading.chains.length === 0)
|
|
665
|
+
return;
|
|
666
|
+
io.out("");
|
|
667
|
+
io.out(paint(" Event chains", DIM));
|
|
668
|
+
for (const c of reading.chains) {
|
|
669
|
+
const where = c.namespace === undefined ? "record" : `${c.namespace} metadata`;
|
|
670
|
+
io.out(paint(` ${c.chainKey} ${count(c.events, "record", "records")} present (linked in ${where})`, DIM));
|
|
671
|
+
}
|
|
672
|
+
for (const f of reading.findings) {
|
|
673
|
+
io.out(paint(` ! ${renderChainFinding(f, "this bundle")}`, Y));
|
|
674
|
+
}
|
|
675
|
+
io.out(paint(` ${CHAIN_READING_SCOPE}`, DIM));
|
|
676
|
+
}
|
|
462
677
|
// Render the export-specific block (evidence-export.md §5): source-bundle
|
|
463
678
|
// linkage, cached-vs-recomputed verdict agreement, and each embedded operator
|
|
464
679
|
// attestation (RSA-PSS sig · operator-address binding · data_hash binding).
|
package/dist/compose.js
CHANGED
|
@@ -41,7 +41,7 @@ export function attestationSignatureToHex(signature) {
|
|
|
41
41
|
}
|
|
42
42
|
// Normalize one attestation record for embedding: deep-clone (never mutate the
|
|
43
43
|
// caller's input) and transcode its signature to lowercase hex. Everything else
|
|
44
|
-
// — payload, public_key, signature_alg,
|
|
44
|
+
// — payload, public_key, signature_alg, checkpoint_ref — rides verbatim so the
|
|
45
45
|
// operator's RSA-PSS signature over JCS(payload) remains valid.
|
|
46
46
|
function normalizeAttestation(rec) {
|
|
47
47
|
const cloned = JSON.parse(JSON.stringify(rec));
|
package/dist/evidence.d.ts
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
import type { Envelope } from "./types.js";
|
|
2
|
-
export declare const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/
|
|
2
|
+
export declare const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
|
|
3
|
+
/**
|
|
4
|
+
* The frozen pre-`anchor_ref` body (evidence-bundle.md §5.1.3).
|
|
5
|
+
*
|
|
6
|
+
* Accepted PERMANENTLY, not for a deprecation window. Every bundle emitted
|
|
7
|
+
* before 2026-09-03 carries this shape, and those bundles are evidence — a
|
|
8
|
+
* reader that stopped accepting them would destroy the record it exists to
|
|
9
|
+
* defend (DL-30). Nothing new emits it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
|
|
12
|
+
/** Every anchor-trace body this kernel verifies. Order is newest-first. */
|
|
13
|
+
export declare const ANCHOR_TRACE_BODY_TYPES: readonly ["sanning.anchor.trace/v2", "sanning.anchor.trace/v1"];
|
|
3
14
|
export declare const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
|
|
15
|
+
/**
|
|
16
|
+
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
|
|
17
|
+
*
|
|
18
|
+
* Its own `body_type` rather than a widened anchor-trace body, and the reason
|
|
19
|
+
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
20
|
+
* `sanning.events/v1` envelope, and a stamp has no producer signature at all —
|
|
21
|
+
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
22
|
+
* The two rejected options were making `envelope` optional and synthesising an
|
|
23
|
+
* envelope that Sanning signs; the second would have made a Sanning
|
|
24
|
+
* attestation indistinguishable ON THE WIRE from a producer's, which is
|
|
25
|
+
* unrecoverable once witnessed.
|
|
26
|
+
*/
|
|
27
|
+
export declare const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
28
|
+
/**
|
|
29
|
+
* The version string INSIDE a witnessed stamp body — a different string from
|
|
30
|
+
* the `body_type` above, and the two are easy to confuse.
|
|
31
|
+
*
|
|
32
|
+
* `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
|
|
33
|
+
* dispatch key. `sanning.stamp/v1` is the control plane's own version of the
|
|
34
|
+
* bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
|
|
35
|
+
* `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
|
|
36
|
+
* version to exactly this value: two answers to one question inside one
|
|
37
|
+
* signature is the §5.1.2 mixed-vocabulary reject, one layer down.
|
|
38
|
+
*/
|
|
39
|
+
export declare const STAMP_BODY_VERSION = "sanning.stamp/v1";
|
|
4
40
|
export interface EvidenceIssuer {
|
|
5
41
|
kind: string;
|
|
6
42
|
tenant_id?: string;
|
|
@@ -15,7 +51,42 @@ export interface EvidenceVerdict {
|
|
|
15
51
|
as_of?: string;
|
|
16
52
|
[k: string]: unknown;
|
|
17
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Where a commitment was witnessed (evidence-bundle.md §5.1.1).
|
|
56
|
+
*
|
|
57
|
+
* The same three-part shape the control plane indexes (`kind` / one promoted
|
|
58
|
+
* `id` / a typed remainder), so the signed body, the index row and the SDK
|
|
59
|
+
* artifact stop being three vocabularies for one fact.
|
|
60
|
+
*
|
|
61
|
+
* NOT ONE FIELD HERE NAMES A CHAIN. Arweave's chunk proof, data_path, offsets
|
|
62
|
+
* and packing live in `ref`, opaque to every reader above this seam. A field
|
|
63
|
+
* that would need renaming for a second witness belongs in `ref` by definition.
|
|
64
|
+
*/
|
|
65
|
+
export interface AnchorRef {
|
|
66
|
+
kind: string;
|
|
67
|
+
id: string;
|
|
68
|
+
ref?: Record<string, unknown>;
|
|
69
|
+
}
|
|
18
70
|
export interface TraceCheckpoint {
|
|
71
|
+
/**
|
|
72
|
+
* The checkpoint's identity and the join key, DERIVED: hex SHA-256 of the
|
|
73
|
+
* decoded `record_bytes`. Recomputed by the verifier — in v1 this was an
|
|
74
|
+
* asserted label matched against another asserted label, so a producer could
|
|
75
|
+
* relabel a checkpoint and the bundle still verified.
|
|
76
|
+
*/
|
|
77
|
+
checkpoint_ref: string;
|
|
78
|
+
/**
|
|
79
|
+
* Where it was witnessed, IF IT HAS BEEN. Optional, and absent is honest:
|
|
80
|
+
* under DL-52 a checkpoint is routinely placed before it is witnessed, and a
|
|
81
|
+
* required field that must be filled dishonestly is a defect in the format.
|
|
82
|
+
*/
|
|
83
|
+
anchor_ref?: AnchorRef;
|
|
84
|
+
envelope: Envelope;
|
|
85
|
+
record_bytes: string;
|
|
86
|
+
merkle_root: string;
|
|
87
|
+
}
|
|
88
|
+
/** The frozen `sanning.anchor.trace/v1` checkpoint (§5.1.3). Read, never written. */
|
|
89
|
+
export interface TraceCheckpointV1 {
|
|
19
90
|
tx_id: string;
|
|
20
91
|
envelope: Envelope;
|
|
21
92
|
record_bytes: string;
|
|
@@ -26,7 +97,8 @@ export interface TraceInclusion {
|
|
|
26
97
|
leaf_index: number;
|
|
27
98
|
leaf_count: number;
|
|
28
99
|
audit_path: string[];
|
|
29
|
-
checkpoint_tx_id
|
|
100
|
+
/** Resolves against `checkpoints[].checkpoint_ref`. v1 spells this `checkpoint_tx_id`. */
|
|
101
|
+
checkpoint_ref: string;
|
|
30
102
|
}
|
|
31
103
|
export interface TraceEvent {
|
|
32
104
|
envelope: Envelope;
|
|
@@ -38,6 +110,58 @@ export interface AnchorTraceBody {
|
|
|
38
110
|
checkpoints: TraceCheckpoint[];
|
|
39
111
|
events: TraceEvent[];
|
|
40
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* One sub-tree of a stamp, as the WITNESSED bytes carry it.
|
|
115
|
+
*
|
|
116
|
+
* Keyed in the body by a blinded reference (an HMAC under a per-org secret we
|
|
117
|
+
* never publish), so a reader learns that the org runs N agents and their
|
|
118
|
+
* relative volumes and learns no name. The RANGE is deliberately in the clear:
|
|
119
|
+
* it is what makes a later per-agent slice claim checkable by a third party
|
|
120
|
+
* without Sanning, and hiding it would put us back in the trust path.
|
|
121
|
+
*/
|
|
122
|
+
export interface StampSubtree {
|
|
123
|
+
first_leaf: number;
|
|
124
|
+
leaf_count: number;
|
|
125
|
+
/** Hex RFC-9162 root over this sub-tree's leaves alone. */
|
|
126
|
+
root: string;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The stamp body as the control plane witnesses it
|
|
130
|
+
* (`control-plane/src/stamp-tree.ts`, `StampBody`).
|
|
131
|
+
*
|
|
132
|
+
* Never read from the bundle directly — it is parsed out of `stamp_bytes`,
|
|
133
|
+
* which is the only authority. Present as a type so the parse has a shape.
|
|
134
|
+
*/
|
|
135
|
+
export interface StampBody {
|
|
136
|
+
stamp_version: string;
|
|
137
|
+
/** The org, BLINDED. Not an org id, and not reversible without the secret. */
|
|
138
|
+
org_ref: string;
|
|
139
|
+
interval_start: string;
|
|
140
|
+
interval_end: string;
|
|
141
|
+
merkle_root: string;
|
|
142
|
+
leaf_count: number;
|
|
143
|
+
subtrees: Record<string, StampSubtree>;
|
|
144
|
+
}
|
|
145
|
+
/** One entry of a `sanning.stamp.trace/v1` body's `stamps[]` (§5.2). */
|
|
146
|
+
export interface StampEntry {
|
|
147
|
+
/**
|
|
148
|
+
* The stamp's identity within this bundle, DERIVED: hex SHA-256 of the
|
|
149
|
+
* DECODED `stamp_bytes`, not of the hex text. A verifier MUST recompute it.
|
|
150
|
+
*/
|
|
151
|
+
stamp_ref: string;
|
|
152
|
+
/** Lowercase hex of the exact JCS bytes that were handed to the witness. */
|
|
153
|
+
stamp_bytes: string;
|
|
154
|
+
/**
|
|
155
|
+
* Where the stamp was witnessed, IF IT HAS BEEN (§5.1.1). Absent is honest:
|
|
156
|
+
* a sealed-but-unconfirmed stamp is witnessed by nobody, and an emitter that
|
|
157
|
+
* filled this in from a submission receipt would be claiming a chain
|
|
158
|
+
* position that does not exist yet.
|
|
159
|
+
*/
|
|
160
|
+
anchor_ref?: AnchorRef;
|
|
161
|
+
}
|
|
162
|
+
export interface StampTraceBody {
|
|
163
|
+
stamps: StampEntry[];
|
|
164
|
+
}
|
|
41
165
|
export interface EvidenceBundle {
|
|
42
166
|
spec_version: string;
|
|
43
167
|
body_type: string;
|
|
@@ -66,7 +190,25 @@ export interface OnChainResult {
|
|
|
66
190
|
perGateway: PerGatewayOutcome[];
|
|
67
191
|
}
|
|
68
192
|
export interface CheckpointResult {
|
|
69
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The checkpoint's join key AS THE BUNDLE SPELLS IT — the derived
|
|
195
|
+
* `checkpoint_ref` for a v2 body, the legacy `tx_id` for a frozen v1 body.
|
|
196
|
+
* Never re-derived across forms: a v1 bundle's key is its `tx_id`, because
|
|
197
|
+
* that is what its own events resolve against and what an attestation
|
|
198
|
+
* written against it carries.
|
|
199
|
+
*/
|
|
200
|
+
checkpointRef: string;
|
|
201
|
+
/**
|
|
202
|
+
* Where this checkpoint was witnessed, normalised to one shape.
|
|
203
|
+
*
|
|
204
|
+
* `null` when a v2 checkpoint carries no `anchor_ref` — placed but not yet
|
|
205
|
+
* witnessed, which is a normal state and NOT a deficiency. For a frozen v1
|
|
206
|
+
* checkpoint this is synthesised as `{ kind: "arweave", id: tx_id }`, which
|
|
207
|
+
* is exactly what v1's spec said the field was ("the Arweave TX the
|
|
208
|
+
* checkpoint envelope was anchored as"). The synthesis is reader-side only
|
|
209
|
+
* and never re-enters a signed artifact or a verdict.
|
|
210
|
+
*/
|
|
211
|
+
anchorRef: AnchorRef | null;
|
|
70
212
|
envelopeOk: boolean;
|
|
71
213
|
merkleRootOk: boolean;
|
|
72
214
|
onChainOk: boolean | null;
|
|
@@ -84,6 +226,54 @@ export interface EventResult {
|
|
|
84
226
|
ok: boolean;
|
|
85
227
|
errors: string[];
|
|
86
228
|
}
|
|
229
|
+
/** One sub-tree's recomputed result (§5.2). */
|
|
230
|
+
export interface StampSubtreeResult {
|
|
231
|
+
/** The blinded key the body spells it under. Meaningless without the org's secret. */
|
|
232
|
+
ref: string;
|
|
233
|
+
firstLeaf: number;
|
|
234
|
+
leafCount: number;
|
|
235
|
+
root: string;
|
|
236
|
+
}
|
|
237
|
+
/** One `stamps[]` entry's result (§5.2). */
|
|
238
|
+
export interface StampResult {
|
|
239
|
+
/** The stamp's derived identity, as the bundle spells it. "" when unusable. */
|
|
240
|
+
stampRef: string;
|
|
241
|
+
/** The asserted `stamp_ref` equals SHA-256 of the decoded `stamp_bytes`. */
|
|
242
|
+
stampRefOk: boolean;
|
|
243
|
+
/**
|
|
244
|
+
* The witnessed bytes decoded, parsed, and re-canonicalized to themselves.
|
|
245
|
+
*
|
|
246
|
+
* The last clause is not pedantry. The plane stores these bytes as TEXT and
|
|
247
|
+
* replays them verbatim precisely because a round-trip through a normalizing
|
|
248
|
+
* serializer is not guaranteed to reproduce what was signed; a body that is
|
|
249
|
+
* not JCS-canonical is one that every re-deriving reader hashes differently.
|
|
250
|
+
*/
|
|
251
|
+
bodyOk: boolean;
|
|
252
|
+
stampVersion: string | null;
|
|
253
|
+
intervalStart: string | null;
|
|
254
|
+
intervalEnd: string | null;
|
|
255
|
+
merkleRoot: string | null;
|
|
256
|
+
leafCount: number | null;
|
|
257
|
+
/**
|
|
258
|
+
* Every sub-tree range accounts for exactly one leaf of the stamp: the
|
|
259
|
+
* ranges sorted by `first_leaf` tile `[0, leaf_count)` with no gap and no
|
|
260
|
+
* overlap, and their counts sum to `leaf_count`.
|
|
261
|
+
*/
|
|
262
|
+
subtreesOk: boolean;
|
|
263
|
+
subtrees: StampSubtreeResult[];
|
|
264
|
+
/**
|
|
265
|
+
* Where it was witnessed, or `null` for a stamp nothing has witnessed yet.
|
|
266
|
+
*
|
|
267
|
+
* `null` is NOT a failure and never an accusation — it is the honest state
|
|
268
|
+
* of every stamp for the first minutes of its life and the permanent state
|
|
269
|
+
* of every stamp when no witness is configured. It does hold the bundle at
|
|
270
|
+
* `partial`: a stamp is Sanning's own assertion until something independent
|
|
271
|
+
* carries it.
|
|
272
|
+
*/
|
|
273
|
+
anchorRef: AnchorRef | null;
|
|
274
|
+
ok: boolean;
|
|
275
|
+
errors: string[];
|
|
276
|
+
}
|
|
87
277
|
export interface EvidenceBundleResult {
|
|
88
278
|
status: EvidenceStatus;
|
|
89
279
|
specVersionOk: boolean;
|
|
@@ -95,10 +285,16 @@ export interface EvidenceBundleResult {
|
|
|
95
285
|
events: EventResult[];
|
|
96
286
|
assertedStatus: string | null;
|
|
97
287
|
export?: ExportResult;
|
|
288
|
+
stamps?: StampResult[];
|
|
98
289
|
errors: string[];
|
|
99
290
|
}
|
|
100
291
|
export interface AttestationRecord {
|
|
101
|
-
|
|
292
|
+
/**
|
|
293
|
+
* The source checkpoint this attestation binds to, spelled in the SOURCE
|
|
294
|
+
* bundle's vocabulary: `checkpoint_ref` for a v2 source, the legacy `tx_id`
|
|
295
|
+
* for a frozen v1 one. Not re-derived across forms.
|
|
296
|
+
*/
|
|
297
|
+
checkpoint_ref: string;
|
|
102
298
|
payload: AttestationPayload;
|
|
103
299
|
signature_alg: string;
|
|
104
300
|
public_key: {
|
|
@@ -158,7 +354,7 @@ export interface VerdictAttestation {
|
|
|
158
354
|
subject_ref_ok: boolean | null;
|
|
159
355
|
}
|
|
160
356
|
export interface VerdictCheckpoint {
|
|
161
|
-
|
|
357
|
+
checkpoint_ref: string;
|
|
162
358
|
merkle_root_ok: boolean;
|
|
163
359
|
on_chain: VerdictOnChain | null;
|
|
164
360
|
attestations: VerdictAttestation[];
|
|
@@ -179,7 +375,7 @@ export interface VerdictObject {
|
|
|
179
375
|
[k: string]: unknown;
|
|
180
376
|
}
|
|
181
377
|
export interface AttestationResult {
|
|
182
|
-
|
|
378
|
+
checkpointRef: string;
|
|
183
379
|
operator: string;
|
|
184
380
|
gateway: string | null;
|
|
185
381
|
signatureOk: boolean;
|
|
@@ -201,7 +397,7 @@ export interface ExportResult {
|
|
|
201
397
|
}
|
|
202
398
|
export interface VerifyEvidenceOptions {
|
|
203
399
|
gateways?: string[];
|
|
204
|
-
content?: Record<string, Uint8Array | string>;
|
|
400
|
+
content?: Record<string, Uint8Array | string> | ReadonlyMap<string, Uint8Array | string>;
|
|
205
401
|
fetchImpl?: typeof fetch;
|
|
206
402
|
}
|
|
207
403
|
export declare function verifyEvidenceBundle(bundle: unknown, options?: VerifyEvidenceOptions): Promise<EvidenceBundleResult>;
|
|
@@ -234,6 +430,7 @@ export interface DisclosedContentResult {
|
|
|
234
430
|
* know their payload is text decode it themselves and own that assumption.
|
|
235
431
|
*/
|
|
236
432
|
export declare function disclosedContent(bundle: unknown, options?: VerifyEvidenceOptions): Promise<DisclosedContentResult>;
|
|
433
|
+
export declare function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined;
|
|
237
434
|
export interface ComposeVerdictResult {
|
|
238
435
|
verdict: VerdictObject;
|
|
239
436
|
sourceStatus: EvidenceStatus;
|