@sanning/proof 0.4.1 → 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 +59 -4
- package/dist/cli.js +222 -43
- package/dist/compose.js +1 -1
- package/dist/evidence.d.ts +106 -7
- package/dist/evidence.js +446 -81
- package/dist/grading.d.ts +160 -0
- package/dist/grading.js +469 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +25 -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 +285 -41
- package/src/compose.ts +1 -1
- package/src/evidence.ts +647 -92
- package/src/grading.ts +623 -0
- package/src/index.ts +49 -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,25 +92,77 @@ 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
|
|
|
139
|
+
### …and get those raw logs back out (ingest)
|
|
140
|
+
|
|
141
|
+
`events[].content` rides as **lowercase hex** — JSON cannot carry arbitrary bytes. To *materialize* a received bundle's logs (serve them, write pack files, hand them on), use `disclosedContent` rather than decoding that field yourself:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { disclosedContent } from "@sanning/proof";
|
|
145
|
+
|
|
146
|
+
const result = await disclosedContent(bundle); // verifies, then decodes
|
|
147
|
+
|
|
148
|
+
for (const [eventId, bytes] of result.content) {
|
|
149
|
+
await writeFile(`logs/${eventId}.json`, bytes); // Uint8Array — write it as-is
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Verify-then-return:** `disclosedContent` runs the full `verifyEvidenceBundle` (same options) and hands back bytes **only** for events whose chain verified *and* whose disclosed bytes hash to the committed `content_hash`. There is no path through it that yields bytes that are not the committed ones — and `result.verification` carries the whole `EvidenceBundleResult`, so you verify once and render the verdict from the same call.
|
|
154
|
+
|
|
155
|
+
Every event also appears in `result.events`, in body order, with a status that never lets "nothing was disclosed" pass for "empty":
|
|
156
|
+
|
|
157
|
+
| `status` | `bytes` | Meaning |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `verified` | the bytes | Decoded **and** bound to the committed `content_hash` — safe to write as-is. May be zero-length: an empty payload is real, verifiable content. |
|
|
160
|
+
| `not_disclosed` | `null` | Minimal disclosure — the hash was committed, the bytes were not shared. Nothing to write, and not an error. |
|
|
161
|
+
| `undetermined` | `null` | Bytes were disclosed but there is nothing to bind them to (withheld record, or no committed `content_hash`). Withheld rather than returned. |
|
|
162
|
+
| `failed` | `null` | The event's own proof chain failed, or the disclosure did not bind or did not decode. `errors` says which. |
|
|
163
|
+
|
|
164
|
+
Bytes come back as `Uint8Array`, never a string: the spec commits to **raw bytes**, and a UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding such a string would change the bytes and break the hash. Decode to text yourself if you know your payload is text.
|
|
165
|
+
|
|
114
166
|
## Verify a Merkle inclusion proof
|
|
115
167
|
|
|
116
168
|
Agents close daily checkpoints over per-cycle leaves (RFC 9162, §2.1 domain separation — not the Bitcoin duplicate-leaf variant). Verify a leaf's inclusion against an anchored root:
|
|
@@ -133,6 +185,8 @@ const ok = await verifyInclusion(
|
|
|
133
185
|
|---|---|
|
|
134
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`. |
|
|
135
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. |
|
|
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. |
|
|
136
190
|
| `verifyAgentProofBundle(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
|
|
137
191
|
| `contentHashes(env)` | The content hash(es) an envelope commits to, by event type — the reverse-provenance join keys. |
|
|
138
192
|
| `specVersionSupported(v)` | Fail-closed accept-check: `sanning.agent/v1` and additive minors (`sanning.agent/v1.<n>`). Unknown majors/profiles are rejected. |
|
|
@@ -149,12 +203,13 @@ The primary signature covers `JCS(envelope minus signature minus co_signatures)`
|
|
|
149
203
|
|
|
150
204
|
## Why you can trust it (without trusting us)
|
|
151
205
|
|
|
152
|
-
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.
|
|
153
207
|
|
|
154
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.
|
|
155
209
|
|
|
156
210
|
## What this package deliberately does NOT do
|
|
157
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.
|
|
158
213
|
- **Single-envelope scope.** Chain walking (`previous_hash`), checkpoint reconciliation, and gateway/transport logic are consumer-layer concerns composed above the kernel.
|
|
159
214
|
- **Provenance is history, not endorsement.** A verified envelope proves *these bytes have this signed, anchored history* — never "safe," "approved," or "currently in production."
|
|
160
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";
|
|
@@ -57,15 +58,18 @@ export async function runCli(argv, io) {
|
|
|
57
58
|
io.out("@sanning/proof verify CLI");
|
|
58
59
|
return EXIT_VERIFIED;
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// `bundle` is the customer-facing verb (proof#19: one artifact, one word).
|
|
62
|
+
// `export` is kept working as a QUIET alias — it shipped and is published, so
|
|
63
|
+
// it is not broken abruptly; it is simply no longer taught anywhere.
|
|
64
|
+
if (command === "bundle" || command === "export") {
|
|
65
|
+
return runBundle(rest, io);
|
|
62
66
|
}
|
|
63
67
|
if (command !== "verify") {
|
|
64
68
|
io.err(`unknown command: ${command}`);
|
|
65
69
|
printUsage(io);
|
|
66
70
|
return EXIT_MALFORMED;
|
|
67
71
|
}
|
|
68
|
-
// Pull the optional `--logs <
|
|
72
|
+
// Pull the optional `--logs <path>` flag out of the args; the rest are
|
|
69
73
|
// positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
|
|
70
74
|
// content verification (the evidence-bundle path only).
|
|
71
75
|
let logsPath;
|
|
@@ -75,7 +79,7 @@ export async function runCli(argv, io) {
|
|
|
75
79
|
if (a === "--logs") {
|
|
76
80
|
const next = rest[i + 1];
|
|
77
81
|
if (next === undefined) {
|
|
78
|
-
io.err("verify: --logs requires a file
|
|
82
|
+
io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
|
|
79
83
|
printUsage(io);
|
|
80
84
|
return EXIT_MALFORMED;
|
|
81
85
|
}
|
|
@@ -95,34 +99,15 @@ export async function runCli(argv, io) {
|
|
|
95
99
|
.split(",")
|
|
96
100
|
.map((g) => g.trim())
|
|
97
101
|
.filter((g) => g.length > 0);
|
|
98
|
-
let
|
|
102
|
+
let logs;
|
|
99
103
|
if (logsPath !== undefined) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
logsRaw = await readFile(logsPath, "utf8");
|
|
103
|
-
}
|
|
104
|
-
catch (e) {
|
|
105
|
-
io.err(`cannot read --logs ${logsPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
106
|
-
return EXIT_MALFORMED;
|
|
107
|
-
}
|
|
108
|
-
let logsParsed;
|
|
109
|
-
try {
|
|
110
|
-
logsParsed = JSON.parse(logsRaw);
|
|
111
|
-
}
|
|
112
|
-
catch (e) {
|
|
113
|
-
io.err(`--logs ${logsPath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
114
|
-
return EXIT_MALFORMED;
|
|
115
|
-
}
|
|
116
|
-
if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
|
|
117
|
-
io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes`);
|
|
104
|
+
logs = await loadLogs(logsPath, io);
|
|
105
|
+
if (logs === undefined)
|
|
118
106
|
return EXIT_MALFORMED;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
for (const [k, v] of Object.entries(logsParsed)) {
|
|
122
|
-
if (typeof v === "string")
|
|
123
|
-
content[k] = decodeLogValue(v);
|
|
124
|
-
}
|
|
107
|
+
for (const n of logs.notes)
|
|
108
|
+
io.err(`note: ${n}`);
|
|
125
109
|
}
|
|
110
|
+
const content = logs?.content;
|
|
126
111
|
let raw;
|
|
127
112
|
try {
|
|
128
113
|
raw = await readFile(bundlePath, "utf8");
|
|
@@ -162,9 +147,15 @@ export async function runCli(argv, io) {
|
|
|
162
147
|
...(content ? { content } : {}),
|
|
163
148
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
164
149
|
});
|
|
165
|
-
|
|
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;
|
|
166
156
|
}
|
|
167
|
-
// `proof
|
|
157
|
+
// `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
|
|
158
|
+
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
168
159
|
//
|
|
169
160
|
// Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from a
|
|
170
161
|
// source sanning.anchor.trace/v1 bundle + operator attestation records + an exporter
|
|
@@ -175,7 +166,7 @@ export async function runCli(argv, io) {
|
|
|
175
166
|
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
176
167
|
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
177
168
|
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
178
|
-
async function
|
|
169
|
+
async function runBundle(rest, io) {
|
|
179
170
|
let attestationsPath;
|
|
180
171
|
let keyPath;
|
|
181
172
|
let outPath;
|
|
@@ -296,7 +287,7 @@ async function runExport(rest, io) {
|
|
|
296
287
|
return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
|
|
297
288
|
}
|
|
298
289
|
function usageExport(io) {
|
|
299
|
-
io.out("Usage: npx @sanning/proof
|
|
290
|
+
io.out("Usage: npx @sanning/proof bundle <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]");
|
|
300
291
|
io.out("");
|
|
301
292
|
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from an");
|
|
302
293
|
io.out("sanning.anchor.trace/v1 source bundle + operator attestation records + an exporter");
|
|
@@ -377,10 +368,187 @@ async function readExporterKey(path, io) {
|
|
|
377
368
|
}
|
|
378
369
|
return seed.toLowerCase();
|
|
379
370
|
}
|
|
380
|
-
// 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,
|
|
381
548
|
// even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
|
|
382
549
|
// (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
|
|
383
|
-
// 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.)
|
|
384
552
|
function decodeLogValue(v) {
|
|
385
553
|
if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v))
|
|
386
554
|
return v; // hex passthrough
|
|
@@ -390,18 +558,29 @@ function printUsage(io) {
|
|
|
390
558
|
io.out("Usage: npx @sanning/proof <command> [args]");
|
|
391
559
|
io.out("");
|
|
392
560
|
io.out("Commands:");
|
|
393
|
-
io.out(" verify <bundle.json> [gateways] [--logs <
|
|
394
|
-
io.out("
|
|
561
|
+
io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
|
|
562
|
+
io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
|
|
395
563
|
io.out("");
|
|
396
564
|
io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
|
|
397
565
|
io.out("sanning.evidence.export/v1 attested export, or a sanning.agent.proof/v1");
|
|
398
566
|
io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
|
|
399
567
|
io.out("to also re-fetch each checkpoint on-chain.");
|
|
400
568
|
io.out("");
|
|
401
|
-
io.out("--logs <
|
|
402
|
-
io.out(" content_hash.
|
|
403
|
-
io.out("
|
|
404
|
-
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.");
|
|
405
584
|
io.out("");
|
|
406
585
|
io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
|
|
407
586
|
}
|
|
@@ -420,7 +599,7 @@ function reportEvidence(result, path, gateways, io) {
|
|
|
420
599
|
io.out(paint(" Checkpoints", DIM));
|
|
421
600
|
for (const cp of result.checkpoints) {
|
|
422
601
|
const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
|
|
423
|
-
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}`);
|
|
424
603
|
for (const e of cp.errors)
|
|
425
604
|
io.out(paint(` - ${e}`, R));
|
|
426
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,10 +271,39 @@ 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>;
|
|
278
|
+
export type DisclosureStatus = "verified" | "not_disclosed" | "undetermined" | "failed";
|
|
279
|
+
export interface DisclosedEventContent {
|
|
280
|
+
eventId: string;
|
|
281
|
+
status: DisclosureStatus;
|
|
282
|
+
bytes: Uint8Array | null;
|
|
283
|
+
errors: string[];
|
|
284
|
+
}
|
|
285
|
+
export interface DisclosedContentResult {
|
|
286
|
+
status: EvidenceStatus;
|
|
287
|
+
verification: EvidenceBundleResult;
|
|
288
|
+
events: DisclosedEventContent[];
|
|
289
|
+
content: Map<string, Uint8Array>;
|
|
290
|
+
errors: string[];
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
|
|
294
|
+
*
|
|
295
|
+
* Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
|
|
296
|
+
* (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
|
|
297
|
+
* bytes are returned ONLY when that verification bound them to the committed
|
|
298
|
+
* `content_hash`. There is no path through this function that yields bytes
|
|
299
|
+
* whose SHA-256 is not the commitment.
|
|
300
|
+
*
|
|
301
|
+
* Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
|
|
302
|
+
* UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
|
|
303
|
+
* such a string would produce different bytes and a different hash. Callers who
|
|
304
|
+
* know their payload is text decode it themselves and own that assumption.
|
|
305
|
+
*/
|
|
306
|
+
export declare function disclosedContent(bundle: unknown, options?: VerifyEvidenceOptions): Promise<DisclosedContentResult>;
|
|
208
307
|
export interface ComposeVerdictResult {
|
|
209
308
|
verdict: VerdictObject;
|
|
210
309
|
sourceStatus: EvidenceStatus;
|