@sanning/proof 0.5.0 → 0.6.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/cli.js +212 -37
- package/dist/compose.js +1 -1
- package/dist/evidence.d.ts +77 -7
- package/dist/evidence.js +284 -66
- package/dist/grading.d.ts +160 -0
- package/dist/grading.js +469 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +17 -1
- package/dist/side-content.d.ts +36 -0
- package/dist/side-content.js +98 -0
- package/package.json +2 -1
- package/src/cli.ts +275 -35
- package/src/compose.ts +1 -1
- package/src/evidence.ts +394 -80
- package/src/grading.ts +623 -0
- package/src/index.ts +37 -0
- package/src/side-content.ts +119 -0
- package/vectors/README.md +66 -0
- package/vectors/pack-grading.json +136 -0
package/README.md
CHANGED
|
@@ -92,23 +92,48 @@ A bundle a producer built with disclosure carries the bytes **in the signed body
|
|
|
92
92
|
npx @sanning/proof verify trace-bundle.json # in-body content verified inline
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
If the logs travel **alongside** a minimal bundle instead, pass them with `--logs
|
|
95
|
+
If the logs travel **alongside** a minimal bundle instead, pass them with `--logs`. It takes either shape below, and which one you passed is decided by `stat`, never by the extension.
|
|
96
|
+
|
|
97
|
+
**A materialized pack — a DIRECTORY.** `materializeBundle` (in [`@sanning/anchor`](https://github.com/sanning-io/anchor)) writes `bundle.json` + `logs/<event_id>.json`, and each file's **raw bytes are the disclosure**. Point `--logs` at that directory and the pack verifies with nothing of ours in the path:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx @sanning/proof verify pack/bundle.json --logs pack/logs
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Nothing is decoded, re-parsed or re-serialised on the way in: the bytes on disk are the bytes hashed against the committed `content_hash` — the same bytes a verify page hashes from the same files. That is the whole point of the directory form. The alternative — reading the directory and hex-encoding it into a map yourself — is a step that has been got wrong twice in shipped code, once by writing the hex *text* into the log files, so `SHA-256(file)` was the hash of the encoding rather than of the bytes and genuine, untampered evidence failed.
|
|
104
|
+
|
|
105
|
+
**A side input — a JSON FILE** mapping `event_id` → bytes, where an even-length lowercase-hex value is read as hex and anything else as UTF-8 text:
|
|
96
106
|
|
|
97
107
|
```bash
|
|
98
108
|
npx @sanning/proof verify trace-bundle.json --logs logs.json
|
|
99
109
|
```
|
|
100
110
|
|
|
111
|
+
Emit hex, not text, into that map: a UTF-8 decode is lossy for a non-text payload, and a log line that happens to *look* like hex would be decoded into something the producer never wrote. `logsSideInput` in `@sanning/anchor` builds the map correctly.
|
|
112
|
+
|
|
113
|
+
The directory form's edge cases, **none of which is a verification failure** — an absent disclosure is not evidence of tampering:
|
|
114
|
+
|
|
115
|
+
| Situation | What happens |
|
|
116
|
+
|---|---|
|
|
117
|
+
| A record with no file | That event's content is **undetermined** (`~`) and reported as undisclosed. Exit code unchanged. |
|
|
118
|
+
| An empty directory | Valid. Discloses nothing, says so, exit code unchanged. |
|
|
119
|
+
| A file whose name is no event id | Read, binds to nothing, and is **named** in a note — a typo'd filename is otherwise indistinguishable from minimal disclosure. |
|
|
120
|
+
| A subdirectory | Skipped, never recursed: the layout is flat. Noted. |
|
|
121
|
+
| Anything not named `<event_id>.json`, or not a regular file | Skipped, noted. |
|
|
122
|
+
| A file we selected but cannot read | **Exit 2.** We were pointed at it, so a permissions or broken-link problem must not read as minimal disclosure. |
|
|
123
|
+
|
|
101
124
|
Either way each event gains a `contentOk`: **`true`** match · **`false`** mismatch (the event *and* the rollup fail, exit `1`) · **`null`** undetermined — nothing disclosed, or no committed `content_hash` (a withheld record); never a failure, mirroring the payload-binding rule. The CLI prints a `logs ✓/✗/~` mark per event and a `logs: N/M disclosed verified` line.
|
|
102
125
|
|
|
103
|
-
Programmatically the same side input is `content`, keyed by `event_id` — a `Uint8Array` is used as-is, a `string` is hex
|
|
126
|
+
Programmatically the same side input is `content`, keyed by `event_id` — a `Uint8Array` is used as-is, a `string` is hex. **Prefer a `Map`:**
|
|
104
127
|
|
|
105
128
|
```ts
|
|
106
129
|
const result = await verifyEvidenceBundle(bundle, {
|
|
107
|
-
content:
|
|
130
|
+
content: new Map([[eventId, rawLogBytes]]), // Uint8Array, or a hex string
|
|
108
131
|
});
|
|
109
132
|
result.events[0].contentOk; // true | false | null
|
|
110
133
|
```
|
|
111
134
|
|
|
135
|
+
A plain object still works and is fully guarded on the way in. But **an `event_id` is producer-chosen**, and on a plain object the names that live on `Object.prototype` are not free: `content["__proto__"] = bytes` does not store anything — it replaces the object's prototype, and the bytes you meant to disclose are lost before the verifier ever sees them. If you are building the map from names you did not choose — a directory listing, a JSON key, an id off the wire — use a `Map` (or `Object.create(null)`). It is also what `disclosedContent` hands back, so its output feeds straight back in.
|
|
136
|
+
|
|
112
137
|
In-body `events[].content` (signed) takes precedence over the side input; if both are present for an event and disagree, the event fails.
|
|
113
138
|
|
|
114
139
|
### …and get those raw logs back out (ingest)
|
|
@@ -161,6 +186,7 @@ const ok = await verifyInclusion(
|
|
|
161
186
|
| `verifyEnvelope(env, optionsOrContentHash?)` | The three load-bearing checks (spec-version registry, payload-hash recompute, Ed25519 over the signed scope) + optional content bind. Pass `{ payloadBytes }` for external-commitment envelopes. Returns `VerificationResult`. |
|
|
162
187
|
| `verifyEvidenceBundle(bundle, { gateways?, fetchImpl? })` | Verify a signed `sanning.evidence/v1` / `sanning.anchor.trace/v1` bundle: wrapper signature + `body_hash` + every checkpoint + every event's inclusion, offline (optional on-chain re-fetch). Returns `EvidenceBundleResult`. Powers the `npx @sanning/proof verify` CLI. |
|
|
163
188
|
| `disclosedContent(bundle, { gateways?, content?, fetchImpl? })` | **Ingest:** verify a bundle, then hand back the DECODED raw bytes (`Uint8Array`) of every event whose disclosure bound to its committed `content_hash` — so materializing a received pack never means re-implementing the hex convention. Returns `DisclosedContentResult` (`content` map + per-event `verified` / `not_disclosed` / `undetermined` / `failed`, plus the full `verification`). |
|
|
189
|
+
| `gradePack(result, account, notes)` + `accountDisclosure(bundle, content)` / `completenessNotes(bundle)` / `renderCause(cause)` | **Pack grading:** what a SURFACE may claim about a pack — the bundle plus whatever disclosed bytes travelled with it. Three states, never two: `verified` only when every record's own bytes bound to its own commitment and nothing is missing, unreadable, surplus or omitted; `failed` on a proven mismatch; `incomplete` when a check could not be run. Absent evidence is UNKNOWN, never an accusation. Pure — no I/O; transport stays yours. Conformance table: [`vectors/pack-grading.json`](https://github.com/sanning-io/proof/blob/main/ts/vectors/pack-grading.json), shipped in the tarball. |
|
|
164
190
|
| `verifyAgentProofBundle(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
|
|
165
191
|
| `contentHashes(env)` | The content hash(es) an envelope commits to, by event type — the reverse-provenance join keys. |
|
|
166
192
|
| `specVersionSupported(v)` | Fail-closed accept-check: `sanning.agent/v1` and additive minors (`sanning.agent/v1.<n>`). Unknown majors/profiles are rejected. |
|
|
@@ -177,12 +203,13 @@ The primary signature covers `JCS(envelope minus signature minus co_signatures)`
|
|
|
177
203
|
|
|
178
204
|
## Why you can trust it (without trusting us)
|
|
179
205
|
|
|
180
|
-
Three **independent implementations** of this kernel exist — this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and the MIT-licensed Go reference, vendored into this repo under [`cross-kernel/vendor-agent/`](https://github.com/sanning-io/proof/tree/main/cross-kernel/vendor-agent) so anyone can build and run it. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against the shared [`test-vectors` corpus](https://github.com/sanning-io/proof/tree/main/test-vectors) (per-file SHA-256 pins; current cut tag `test-vectors-
|
|
206
|
+
Three **independent implementations** of this kernel exist — this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and the MIT-licensed Go reference, vendored into this repo under [`cross-kernel/vendor-agent/`](https://github.com/sanning-io/proof/tree/main/cross-kernel/vendor-agent) so anyone can build and run it. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against the shared [`test-vectors` corpus](https://github.com/sanning-io/proof/tree/main/test-vectors) (per-file SHA-256 pins; current cut tag `test-vectors-v3.0`), and the [cross-kernel agreement gate](https://github.com/sanning-io/proof/tree/main/cross-kernel) re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
|
|
181
207
|
|
|
182
208
|
That mutual gate is the point: anyone can verify these provenance records with **no Sanning code in the trust path** — write your own verifier against the [public specs](https://github.com/sanning-io/proof/tree/main/specs) and the corpus will tell you if it's conformant.
|
|
183
209
|
|
|
184
210
|
## What this package deliberately does NOT do
|
|
185
211
|
|
|
212
|
+
- **No I/O, ever.** Including in `gradePack`: it takes an account of what was disclosed, it does not go and read a directory. How a pack reached you is yours.
|
|
186
213
|
- **Single-envelope scope.** Chain walking (`previous_hash`), checkpoint reconciliation, and gateway/transport logic are consumer-layer concerns composed above the kernel.
|
|
187
214
|
- **Provenance is history, not endorsement.** A verified envelope proves *these bytes have this signed, anchored history* — never "safe," "approved," or "currently in production."
|
|
188
215
|
- **Key identity is out-of-band.** The kernel proves the signature matches `env.public_key`; binding that key to a real-world identity is yours to establish.
|
package/dist/cli.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
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";
|
|
20
21
|
import { composeExport } from "./compose.js";
|
|
@@ -68,7 +69,7 @@ export async function runCli(argv, io) {
|
|
|
68
69
|
printUsage(io);
|
|
69
70
|
return EXIT_MALFORMED;
|
|
70
71
|
}
|
|
71
|
-
// Pull the optional `--logs <
|
|
72
|
+
// Pull the optional `--logs <path>` flag out of the args; the rest are
|
|
72
73
|
// positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
|
|
73
74
|
// content verification (the evidence-bundle path only).
|
|
74
75
|
let logsPath;
|
|
@@ -78,7 +79,7 @@ export async function runCli(argv, io) {
|
|
|
78
79
|
if (a === "--logs") {
|
|
79
80
|
const next = rest[i + 1];
|
|
80
81
|
if (next === undefined) {
|
|
81
|
-
io.err("verify: --logs requires a file
|
|
82
|
+
io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
|
|
82
83
|
printUsage(io);
|
|
83
84
|
return EXIT_MALFORMED;
|
|
84
85
|
}
|
|
@@ -98,34 +99,15 @@ export async function runCli(argv, io) {
|
|
|
98
99
|
.split(",")
|
|
99
100
|
.map((g) => g.trim())
|
|
100
101
|
.filter((g) => g.length > 0);
|
|
101
|
-
let
|
|
102
|
+
let logs;
|
|
102
103
|
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`);
|
|
104
|
+
logs = await loadLogs(logsPath, io);
|
|
105
|
+
if (logs === undefined)
|
|
121
106
|
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
|
-
}
|
|
107
|
+
for (const n of logs.notes)
|
|
108
|
+
io.err(`note: ${n}`);
|
|
128
109
|
}
|
|
110
|
+
const content = logs?.content;
|
|
129
111
|
let raw;
|
|
130
112
|
try {
|
|
131
113
|
raw = await readFile(bundlePath, "utf8");
|
|
@@ -165,7 +147,12 @@ export async function runCli(argv, io) {
|
|
|
165
147
|
...(content ? { content } : {}),
|
|
166
148
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
167
149
|
});
|
|
168
|
-
|
|
150
|
+
const exit = reportEvidence(result, bundlePath, gateways, io);
|
|
151
|
+
// Coverage is reported only once the bundle parsed far enough to have events
|
|
152
|
+
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
153
|
+
if (logs && result.status !== "malformed")
|
|
154
|
+
reportLogsCoverage(logs, result, io);
|
|
155
|
+
return exit;
|
|
169
156
|
}
|
|
170
157
|
// `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
|
|
171
158
|
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
@@ -381,10 +368,187 @@ async function readExporterKey(path, io) {
|
|
|
381
368
|
}
|
|
382
369
|
return seed.toLowerCase();
|
|
383
370
|
}
|
|
384
|
-
// The
|
|
371
|
+
// The suffix the directory form reads, matched exactly — lowercase, which is
|
|
372
|
+
// what `materializeBundle` writes. Anything else is skipped and NAMED rather
|
|
373
|
+
// than guessed at, so a `.JSON` or extensionless file shows up as a note
|
|
374
|
+
// instead of a mysteriously undisclosed event.
|
|
375
|
+
const LOG_SUFFIX = ".json";
|
|
376
|
+
// Which shape was passed is decided by `stat`, never by the extension: a pack's
|
|
377
|
+
// log directory can be called anything, and `logs.json` is a legal directory
|
|
378
|
+
// name. `stat` follows symlinks, so a symlinked logs directory works.
|
|
379
|
+
async function loadLogs(logsPath, io) {
|
|
380
|
+
let isDirectory;
|
|
381
|
+
try {
|
|
382
|
+
isDirectory = (await stat(logsPath)).isDirectory();
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
|
|
389
|
+
}
|
|
390
|
+
// The JSON-map form, unchanged: `{ event_id: <hex | utf8 text> }`.
|
|
391
|
+
async function loadLogsMap(logsPath, io) {
|
|
392
|
+
let logsRaw;
|
|
393
|
+
try {
|
|
394
|
+
logsRaw = await readFile(logsPath, "utf8");
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
let logsParsed;
|
|
401
|
+
try {
|
|
402
|
+
logsParsed = JSON.parse(logsRaw);
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
|
|
409
|
+
io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes, ` +
|
|
410
|
+
`or a directory of <event_id>${LOG_SUFFIX} files`);
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
const content = new Map();
|
|
414
|
+
const suppliedIds = [];
|
|
415
|
+
for (const [k, v] of Object.entries(logsParsed)) {
|
|
416
|
+
if (typeof v !== "string")
|
|
417
|
+
continue;
|
|
418
|
+
content.set(k, decodeLogValue(v));
|
|
419
|
+
suppliedIds.push(k);
|
|
420
|
+
}
|
|
421
|
+
return { content, suppliedIds, notes: [] };
|
|
422
|
+
}
|
|
423
|
+
// The directory form. Flat, one file per event, `<event_id>.json`.
|
|
424
|
+
//
|
|
425
|
+
// The decisions this pins, none of which is a verification failure — an absent
|
|
426
|
+
// or unusable disclosure is UNDISCLOSED, and absence is not evidence of
|
|
427
|
+
// tampering (evidence-bundle.md §5.1):
|
|
428
|
+
//
|
|
429
|
+
// - a file whose name is not a known event id → loaded, binds to nothing,
|
|
430
|
+
// named in a post-verify note (silence is how a typo'd filename becomes a
|
|
431
|
+
// mysteriously undisclosed event)
|
|
432
|
+
// - an event with no corresponding file → undetermined (`~`), noted
|
|
433
|
+
// - an empty directory → valid; discloses nothing,
|
|
434
|
+
// noted, exit code unchanged
|
|
435
|
+
// - a subdirectory → skipped, never recursed,
|
|
436
|
+
// noted (this is the "you pointed at the pack root, not its logs/" case)
|
|
437
|
+
// - anything not named `<event_id>.json`, and anything that is not a regular
|
|
438
|
+
// file → skipped, noted
|
|
439
|
+
//
|
|
440
|
+
// The one hard stop is a file we SELECTED but cannot read: we were pointed at
|
|
441
|
+
// it, so a permissions error must not read as minimal disclosure. That exits 2.
|
|
442
|
+
async function loadLogsDirectory(dirPath, io) {
|
|
443
|
+
let names;
|
|
444
|
+
try {
|
|
445
|
+
names = await readdir(dirPath);
|
|
446
|
+
}
|
|
447
|
+
catch (e) {
|
|
448
|
+
io.err(`cannot read --logs directory ${dirPath}: ${errMsg(e)}`);
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
const content = new Map();
|
|
452
|
+
const suppliedIds = [];
|
|
453
|
+
const skippedDirs = [];
|
|
454
|
+
const skippedNames = [];
|
|
455
|
+
for (const name of [...names].sort()) {
|
|
456
|
+
const p = join(dirPath, name);
|
|
457
|
+
let isDirectory;
|
|
458
|
+
let isFile;
|
|
459
|
+
try {
|
|
460
|
+
// `stat`, not the dirent kind: a symlinked log file is a log file, and a
|
|
461
|
+
// symlink to a directory is a directory.
|
|
462
|
+
const s = await stat(p);
|
|
463
|
+
isDirectory = s.isDirectory();
|
|
464
|
+
isFile = s.isFile();
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
io.err(`cannot read --logs entry ${p}: ${errMsg(e)}`);
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
if (isDirectory) {
|
|
471
|
+
skippedDirs.push(name);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
// Not a regular file (device, fifo, socket), or not named
|
|
475
|
+
// `<event_id>.json` — including a file named exactly `.json`, whose event
|
|
476
|
+
// id would be the empty string.
|
|
477
|
+
if (!isFile || !name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
|
|
478
|
+
skippedNames.push(name);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const eventId = name.slice(0, -LOG_SUFFIX.length);
|
|
482
|
+
try {
|
|
483
|
+
// No encoding argument: bytes, exactly as they sit on disk. Copied out of
|
|
484
|
+
// Buffer's shared pool so the array handed to the verifier owns its own
|
|
485
|
+
// memory rather than a window onto a reused block.
|
|
486
|
+
content.set(eventId, new Uint8Array(await readFile(p)));
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
io.err(`cannot read --logs file ${p}: ${errMsg(e)}`);
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
suppliedIds.push(eventId);
|
|
493
|
+
}
|
|
494
|
+
const notes = [];
|
|
495
|
+
if (skippedDirs.length > 0) {
|
|
496
|
+
notes.push(`--logs ${dirPath}: skipped ${count(skippedDirs.length, "subdirectory", "subdirectories")} ` +
|
|
497
|
+
`(${sample(skippedDirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
|
|
498
|
+
`and does not recurse`);
|
|
499
|
+
}
|
|
500
|
+
if (skippedNames.length > 0) {
|
|
501
|
+
notes.push(`--logs ${dirPath}: skipped ${count(skippedNames.length, "entry", "entries")} ` +
|
|
502
|
+
`not named <event_id>${LOG_SUFFIX} (${sample(skippedNames)})`);
|
|
503
|
+
}
|
|
504
|
+
if (suppliedIds.length === 0) {
|
|
505
|
+
notes.push(`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files) — every event ` +
|
|
506
|
+
`stays undisclosed, which is undetermined, not failed`);
|
|
507
|
+
}
|
|
508
|
+
return { content, suppliedIds, notes };
|
|
509
|
+
}
|
|
510
|
+
// What the side input covered, reported once the bundle's own event ids are
|
|
511
|
+
// known. Both directions are ADVISORY — neither moves the verdict or the exit
|
|
512
|
+
// code — and both exist because the silent version of each has already cost
|
|
513
|
+
// somebody an afternoon:
|
|
514
|
+
//
|
|
515
|
+
// - an entry matching no event binds to nothing. A typo'd id, or a directory
|
|
516
|
+
// scoped to a different bundle, otherwise looks identical to minimal
|
|
517
|
+
// disclosure.
|
|
518
|
+
// - an event whose content stayed undetermined disclosed no usable bytes.
|
|
519
|
+
// That is UNDISCLOSED, never a failure (evidence-bundle.md §5.1): absence
|
|
520
|
+
// of a disclosure is not evidence of tampering.
|
|
521
|
+
function reportLogsCoverage(logs, result, io) {
|
|
522
|
+
const eventIds = new Set(result.events.map((e) => e.eventId));
|
|
523
|
+
const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id));
|
|
524
|
+
if (unmatched.length > 0) {
|
|
525
|
+
io.err(`note: --logs supplied ${count(unmatched.length, "entry", "entries")} matching no event ` +
|
|
526
|
+
`in the bundle, ignored: ${sample(unmatched)}`);
|
|
527
|
+
}
|
|
528
|
+
const undetermined = result.events.filter((e) => e.contentOk === null);
|
|
529
|
+
if (undetermined.length > 0) {
|
|
530
|
+
io.err(`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
|
|
531
|
+
`content — no bytes disclosed, or no committed content_hash to bind them to. ` +
|
|
532
|
+
`Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function errMsg(e) {
|
|
536
|
+
return e instanceof Error ? e.message : String(e);
|
|
537
|
+
}
|
|
538
|
+
function count(n, one, many) {
|
|
539
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
540
|
+
}
|
|
541
|
+
// First few names, quoted, with a tail count — enough to recognise the mistake
|
|
542
|
+
// without pasting a whole directory into the terminal.
|
|
543
|
+
function sample(names, max = 3) {
|
|
544
|
+
const shown = names.slice(0, max).map((n) => JSON.stringify(n)).join(", ");
|
|
545
|
+
return names.length > max ? `${shown}, +${names.length - max} more` : shown;
|
|
546
|
+
}
|
|
547
|
+
// The --logs MAP lane decides each disclosed value's encoding: a clean,
|
|
385
548
|
// even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
|
|
386
549
|
// (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
|
|
387
|
-
// raw bytes" — the human-facing encoding choice is made here.
|
|
550
|
+
// raw bytes" — the human-facing encoding choice is made here. The DIRECTORY
|
|
551
|
+
// lane never reaches this: its bytes are already bytes.)
|
|
388
552
|
function decodeLogValue(v) {
|
|
389
553
|
if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v))
|
|
390
554
|
return v; // hex passthrough
|
|
@@ -394,7 +558,7 @@ function printUsage(io) {
|
|
|
394
558
|
io.out("Usage: npx @sanning/proof <command> [args]");
|
|
395
559
|
io.out("");
|
|
396
560
|
io.out("Commands:");
|
|
397
|
-
io.out(" verify <bundle.json> [gateways] [--logs <
|
|
561
|
+
io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
|
|
398
562
|
io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
|
|
399
563
|
io.out("");
|
|
400
564
|
io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
|
|
@@ -402,10 +566,21 @@ function printUsage(io) {
|
|
|
402
566
|
io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
|
|
403
567
|
io.out("to also re-fetch each checkpoint on-chain.");
|
|
404
568
|
io.out("");
|
|
405
|
-
io.out("--logs <
|
|
406
|
-
io.out(" content_hash.
|
|
407
|
-
io.out("
|
|
408
|
-
io.out("
|
|
569
|
+
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
570
|
+
io.out(" content_hash. Either shape works, and which one you");
|
|
571
|
+
io.out(" passed is decided by stat(), not by the extension:");
|
|
572
|
+
io.out("");
|
|
573
|
+
io.out(" a DIRECTORY of <event_id>.json files — the file's");
|
|
574
|
+
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
575
|
+
io.out(" This is a materialized pack's logs/ directory:");
|
|
576
|
+
io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
577
|
+
io.out("");
|
|
578
|
+
io.out(" a JSON FILE mapping event_id → bytes (an");
|
|
579
|
+
io.out(" even-length lowercase-hex value ⇒ hex, else utf8).");
|
|
580
|
+
io.out("");
|
|
581
|
+
io.out(" In-body events[].content is verified automatically");
|
|
582
|
+
io.out(" and takes precedence. An event with no disclosed");
|
|
583
|
+
io.out(" bytes is undetermined (~), never a failure.");
|
|
409
584
|
io.out("");
|
|
410
585
|
io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
|
|
411
586
|
}
|
|
@@ -424,7 +599,7 @@ function reportEvidence(result, path, gateways, io) {
|
|
|
424
599
|
io.out(paint(" Checkpoints", DIM));
|
|
425
600
|
for (const cp of result.checkpoints) {
|
|
426
601
|
const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
|
|
427
|
-
io.out(` ${mark(cp.ok)} ${shortTx(cp.
|
|
602
|
+
io.out(` ${mark(cp.ok)} ${shortTx(cp.checkpointRef)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`);
|
|
428
603
|
for (const e of cp.errors)
|
|
429
604
|
io.out(paint(` - ${e}`, R));
|
|
430
605
|
}
|
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,5 +1,16 @@
|
|
|
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";
|
|
4
15
|
export interface EvidenceIssuer {
|
|
5
16
|
kind: string;
|
|
@@ -15,7 +26,42 @@ export interface EvidenceVerdict {
|
|
|
15
26
|
as_of?: string;
|
|
16
27
|
[k: string]: unknown;
|
|
17
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Where a commitment was witnessed (evidence-bundle.md §5.1.1).
|
|
31
|
+
*
|
|
32
|
+
* The same three-part shape the control plane indexes (`kind` / one promoted
|
|
33
|
+
* `id` / a typed remainder), so the signed body, the index row and the SDK
|
|
34
|
+
* artifact stop being three vocabularies for one fact.
|
|
35
|
+
*
|
|
36
|
+
* NOT ONE FIELD HERE NAMES A CHAIN. Arweave's chunk proof, data_path, offsets
|
|
37
|
+
* and packing live in `ref`, opaque to every reader above this seam. A field
|
|
38
|
+
* that would need renaming for a second witness belongs in `ref` by definition.
|
|
39
|
+
*/
|
|
40
|
+
export interface AnchorRef {
|
|
41
|
+
kind: string;
|
|
42
|
+
id: string;
|
|
43
|
+
ref?: Record<string, unknown>;
|
|
44
|
+
}
|
|
18
45
|
export interface TraceCheckpoint {
|
|
46
|
+
/**
|
|
47
|
+
* The checkpoint's identity and the join key, DERIVED: hex SHA-256 of the
|
|
48
|
+
* decoded `record_bytes`. Recomputed by the verifier — in v1 this was an
|
|
49
|
+
* asserted label matched against another asserted label, so a producer could
|
|
50
|
+
* relabel a checkpoint and the bundle still verified.
|
|
51
|
+
*/
|
|
52
|
+
checkpoint_ref: string;
|
|
53
|
+
/**
|
|
54
|
+
* Where it was witnessed, IF IT HAS BEEN. Optional, and absent is honest:
|
|
55
|
+
* under DL-52 a checkpoint is routinely placed before it is witnessed, and a
|
|
56
|
+
* required field that must be filled dishonestly is a defect in the format.
|
|
57
|
+
*/
|
|
58
|
+
anchor_ref?: AnchorRef;
|
|
59
|
+
envelope: Envelope;
|
|
60
|
+
record_bytes: string;
|
|
61
|
+
merkle_root: string;
|
|
62
|
+
}
|
|
63
|
+
/** The frozen `sanning.anchor.trace/v1` checkpoint (§5.1.3). Read, never written. */
|
|
64
|
+
export interface TraceCheckpointV1 {
|
|
19
65
|
tx_id: string;
|
|
20
66
|
envelope: Envelope;
|
|
21
67
|
record_bytes: string;
|
|
@@ -26,7 +72,8 @@ export interface TraceInclusion {
|
|
|
26
72
|
leaf_index: number;
|
|
27
73
|
leaf_count: number;
|
|
28
74
|
audit_path: string[];
|
|
29
|
-
checkpoint_tx_id
|
|
75
|
+
/** Resolves against `checkpoints[].checkpoint_ref`. v1 spells this `checkpoint_tx_id`. */
|
|
76
|
+
checkpoint_ref: string;
|
|
30
77
|
}
|
|
31
78
|
export interface TraceEvent {
|
|
32
79
|
envelope: Envelope;
|
|
@@ -66,7 +113,25 @@ export interface OnChainResult {
|
|
|
66
113
|
perGateway: PerGatewayOutcome[];
|
|
67
114
|
}
|
|
68
115
|
export interface CheckpointResult {
|
|
69
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The checkpoint's join key AS THE BUNDLE SPELLS IT — the derived
|
|
118
|
+
* `checkpoint_ref` for a v2 body, the legacy `tx_id` for a frozen v1 body.
|
|
119
|
+
* Never re-derived across forms: a v1 bundle's key is its `tx_id`, because
|
|
120
|
+
* that is what its own events resolve against and what an attestation
|
|
121
|
+
* written against it carries.
|
|
122
|
+
*/
|
|
123
|
+
checkpointRef: string;
|
|
124
|
+
/**
|
|
125
|
+
* Where this checkpoint was witnessed, normalised to one shape.
|
|
126
|
+
*
|
|
127
|
+
* `null` when a v2 checkpoint carries no `anchor_ref` — placed but not yet
|
|
128
|
+
* witnessed, which is a normal state and NOT a deficiency. For a frozen v1
|
|
129
|
+
* checkpoint this is synthesised as `{ kind: "arweave", id: tx_id }`, which
|
|
130
|
+
* is exactly what v1's spec said the field was ("the Arweave TX the
|
|
131
|
+
* checkpoint envelope was anchored as"). The synthesis is reader-side only
|
|
132
|
+
* and never re-enters a signed artifact or a verdict.
|
|
133
|
+
*/
|
|
134
|
+
anchorRef: AnchorRef | null;
|
|
70
135
|
envelopeOk: boolean;
|
|
71
136
|
merkleRootOk: boolean;
|
|
72
137
|
onChainOk: boolean | null;
|
|
@@ -98,7 +163,12 @@ export interface EvidenceBundleResult {
|
|
|
98
163
|
errors: string[];
|
|
99
164
|
}
|
|
100
165
|
export interface AttestationRecord {
|
|
101
|
-
|
|
166
|
+
/**
|
|
167
|
+
* The source checkpoint this attestation binds to, spelled in the SOURCE
|
|
168
|
+
* bundle's vocabulary: `checkpoint_ref` for a v2 source, the legacy `tx_id`
|
|
169
|
+
* for a frozen v1 one. Not re-derived across forms.
|
|
170
|
+
*/
|
|
171
|
+
checkpoint_ref: string;
|
|
102
172
|
payload: AttestationPayload;
|
|
103
173
|
signature_alg: string;
|
|
104
174
|
public_key: {
|
|
@@ -158,7 +228,7 @@ export interface VerdictAttestation {
|
|
|
158
228
|
subject_ref_ok: boolean | null;
|
|
159
229
|
}
|
|
160
230
|
export interface VerdictCheckpoint {
|
|
161
|
-
|
|
231
|
+
checkpoint_ref: string;
|
|
162
232
|
merkle_root_ok: boolean;
|
|
163
233
|
on_chain: VerdictOnChain | null;
|
|
164
234
|
attestations: VerdictAttestation[];
|
|
@@ -179,7 +249,7 @@ export interface VerdictObject {
|
|
|
179
249
|
[k: string]: unknown;
|
|
180
250
|
}
|
|
181
251
|
export interface AttestationResult {
|
|
182
|
-
|
|
252
|
+
checkpointRef: string;
|
|
183
253
|
operator: string;
|
|
184
254
|
gateway: string | null;
|
|
185
255
|
signatureOk: boolean;
|
|
@@ -201,7 +271,7 @@ export interface ExportResult {
|
|
|
201
271
|
}
|
|
202
272
|
export interface VerifyEvidenceOptions {
|
|
203
273
|
gateways?: string[];
|
|
204
|
-
content?: Record<string, Uint8Array | string>;
|
|
274
|
+
content?: Record<string, Uint8Array | string> | ReadonlyMap<string, Uint8Array | string>;
|
|
205
275
|
fetchImpl?: typeof fetch;
|
|
206
276
|
}
|
|
207
277
|
export declare function verifyEvidenceBundle(bundle: unknown, options?: VerifyEvidenceOptions): Promise<EvidenceBundleResult>;
|