@sanning/proof 0.4.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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/agent-proof.d.ts +28 -0
- package/dist/agent-proof.js +188 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +569 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +124 -0
- package/dist/crypto.d.ts +16 -0
- package/dist/crypto.js +215 -0
- package/dist/evidence.d.ts +214 -0
- package/dist/evidence.js +1049 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +38 -0
- package/dist/merkle.d.ts +6 -0
- package/dist/merkle.js +114 -0
- package/dist/types.d.ts +34 -0
- package/dist/types.js +5 -0
- package/dist/verifier.d.ts +11 -0
- package/dist/verifier.js +321 -0
- package/package.json +59 -0
- package/src/agent-proof.ts +229 -0
- package/src/cli.ts +619 -0
- package/src/compose.ts +189 -0
- package/src/crypto.ts +243 -0
- package/src/evidence.ts +1491 -0
- package/src/index.ts +105 -0
- package/src/merkle.ts +125 -0
- package/src/types.ts +72 -0
- package/src/verifier.ts +340 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx @sanning/proof verify <bundle.json> [comma,sep,gateways]
|
|
3
|
+
//
|
|
4
|
+
// One turnkey verifier for both producer bundles in the stack:
|
|
5
|
+
// - sanning.evidence/v1 (body sanning.anchor.trace/v1) — the @ar.io/anchor SDK trace
|
|
6
|
+
// - sanning.agent.proof/v1 — the agent inclusion proof
|
|
7
|
+
// Dispatch is by sniffed spec_version; no flag tells the CLI which it is.
|
|
8
|
+
//
|
|
9
|
+
// Pinned exit codes (mirror `sanningd verify-status`):
|
|
10
|
+
// 0 verified
|
|
11
|
+
// 1 a real verification failure (bad signature / tamper / broken inclusion)
|
|
12
|
+
// 2 malformed bundle (unparseable, unknown spec major, body_hash mismatch)
|
|
13
|
+
// 3 gateway-unavailable when an on-chain re-fetch was requested
|
|
14
|
+
// `payloadHashOk === null` (a withheld record) is semantics-undetermined and
|
|
15
|
+
// surfaces as a per-event note — it does NOT fail the run.
|
|
16
|
+
|
|
17
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
18
|
+
import { realpathSync } from "node:fs";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
|
|
22
|
+
import type { AgentProofResult } from "./agent-proof.js";
|
|
23
|
+
import { composeExport } from "./compose.js";
|
|
24
|
+
import type { ComposeExportOptions } from "./compose.js";
|
|
25
|
+
import { utf8 } from "./crypto.js";
|
|
26
|
+
import { verifyEvidenceBundle } from "./evidence.js";
|
|
27
|
+
import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult, ExportResult } from "./evidence.js";
|
|
28
|
+
|
|
29
|
+
const EXIT_VERIFIED = 0;
|
|
30
|
+
const EXIT_FAILED = 1;
|
|
31
|
+
const EXIT_MALFORMED = 2;
|
|
32
|
+
const EXIT_GATEWAY_UNAVAILABLE = 3;
|
|
33
|
+
|
|
34
|
+
interface Cli {
|
|
35
|
+
out: (s: string) => void;
|
|
36
|
+
err: (s: string) => void;
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const G = "\x1b[32m";
|
|
41
|
+
const R = "\x1b[31m";
|
|
42
|
+
const Y = "\x1b[33m";
|
|
43
|
+
const DIM = "\x1b[2m";
|
|
44
|
+
const B = "\x1b[1m";
|
|
45
|
+
const X = "\x1b[0m";
|
|
46
|
+
|
|
47
|
+
function useColor(): boolean {
|
|
48
|
+
// Honor NO_COLOR; otherwise color only a TTY.
|
|
49
|
+
if (process.env.NO_COLOR) return false;
|
|
50
|
+
return Boolean(process.stdout.isTTY);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mark(ok: boolean | null): string {
|
|
54
|
+
const c = useColor();
|
|
55
|
+
if (ok === null) return c ? `${Y}~${X}` : "~";
|
|
56
|
+
if (ok) return c ? `${G}✓${X}` : "✓";
|
|
57
|
+
return c ? `${R}✗${X}` : "✗";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function paint(s: string, c: string): string {
|
|
61
|
+
return useColor() ? `${c}${s}${X}` : s;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
65
|
+
const [command, ...rest] = argv;
|
|
66
|
+
|
|
67
|
+
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
68
|
+
printUsage(io);
|
|
69
|
+
return command === undefined ? EXIT_MALFORMED : EXIT_VERIFIED;
|
|
70
|
+
}
|
|
71
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
72
|
+
io.out("@sanning/proof verify CLI");
|
|
73
|
+
return EXIT_VERIFIED;
|
|
74
|
+
}
|
|
75
|
+
if (command === "export") {
|
|
76
|
+
return runExport(rest, io);
|
|
77
|
+
}
|
|
78
|
+
if (command !== "verify") {
|
|
79
|
+
io.err(`unknown command: ${command}`);
|
|
80
|
+
printUsage(io);
|
|
81
|
+
return EXIT_MALFORMED;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Pull the optional `--logs <file>` flag out of the args; the rest are
|
|
85
|
+
// positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
|
|
86
|
+
// content verification (the evidence-bundle path only).
|
|
87
|
+
let logsPath: string | undefined;
|
|
88
|
+
const positional: string[] = [];
|
|
89
|
+
for (let i = 0; i < rest.length; i++) {
|
|
90
|
+
const a = rest[i]!;
|
|
91
|
+
if (a === "--logs") {
|
|
92
|
+
const next = rest[i + 1];
|
|
93
|
+
if (next === undefined) {
|
|
94
|
+
io.err("verify: --logs requires a file path");
|
|
95
|
+
printUsage(io);
|
|
96
|
+
return EXIT_MALFORMED;
|
|
97
|
+
}
|
|
98
|
+
logsPath = next;
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
positional.push(a);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const [bundlePath, gatewayArg] = positional;
|
|
106
|
+
if (!bundlePath) {
|
|
107
|
+
io.err("verify: a bundle file path is required");
|
|
108
|
+
printUsage(io);
|
|
109
|
+
return EXIT_MALFORMED;
|
|
110
|
+
}
|
|
111
|
+
const gateways = (gatewayArg ?? "")
|
|
112
|
+
.split(",")
|
|
113
|
+
.map((g) => g.trim())
|
|
114
|
+
.filter((g) => g.length > 0);
|
|
115
|
+
|
|
116
|
+
let content: Record<string, Uint8Array | string> | undefined;
|
|
117
|
+
if (logsPath !== undefined) {
|
|
118
|
+
let logsRaw: string;
|
|
119
|
+
try {
|
|
120
|
+
logsRaw = await readFile(logsPath, "utf8");
|
|
121
|
+
} catch (e) {
|
|
122
|
+
io.err(`cannot read --logs ${logsPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
123
|
+
return EXIT_MALFORMED;
|
|
124
|
+
}
|
|
125
|
+
let logsParsed: unknown;
|
|
126
|
+
try {
|
|
127
|
+
logsParsed = JSON.parse(logsRaw);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
io.err(`--logs ${logsPath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
130
|
+
return EXIT_MALFORMED;
|
|
131
|
+
}
|
|
132
|
+
if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
|
|
133
|
+
io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes`);
|
|
134
|
+
return EXIT_MALFORMED;
|
|
135
|
+
}
|
|
136
|
+
content = {};
|
|
137
|
+
for (const [k, v] of Object.entries(logsParsed as Record<string, unknown>)) {
|
|
138
|
+
if (typeof v === "string") content[k] = decodeLogValue(v);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let raw: string;
|
|
143
|
+
try {
|
|
144
|
+
raw = await readFile(bundlePath, "utf8");
|
|
145
|
+
} catch (e) {
|
|
146
|
+
io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
147
|
+
return EXIT_MALFORMED;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let parsed: unknown;
|
|
151
|
+
try {
|
|
152
|
+
parsed = JSON.parse(raw);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
155
|
+
return EXIT_MALFORMED;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const specVersion =
|
|
159
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
160
|
+
? (parsed as { spec_version?: unknown }).spec_version
|
|
161
|
+
: undefined;
|
|
162
|
+
|
|
163
|
+
if (isAgentProofSpec(specVersion)) {
|
|
164
|
+
if (content) {
|
|
165
|
+
// --logs binds raw logs to anchor-trace events; an agent inclusion proof
|
|
166
|
+
// has no such per-event content. Say so rather than silently ignoring it.
|
|
167
|
+
io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
168
|
+
}
|
|
169
|
+
const result = await verifyAgentProofBundle(parsed, {
|
|
170
|
+
gateways,
|
|
171
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
172
|
+
});
|
|
173
|
+
return reportAgentProof(result, bundlePath, gateways, io);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Default: treat anything else as an evidence bundle. verifyEvidenceBundle
|
|
177
|
+
// rejects an unknown major as "malformed" so a genuinely unknown shape still
|
|
178
|
+
// exits 2 with a clear message.
|
|
179
|
+
const result = await verifyEvidenceBundle(parsed, {
|
|
180
|
+
gateways,
|
|
181
|
+
...(content ? { content } : {}),
|
|
182
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
183
|
+
});
|
|
184
|
+
return reportEvidence(result, bundlePath, gateways, io);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// `proof export <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
|
|
188
|
+
//
|
|
189
|
+
// Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from a
|
|
190
|
+
// source sanning.anchor.trace/v1 bundle + operator attestation records + an exporter
|
|
191
|
+
// Ed25519 key (evidence-export.md §5, composer). Writes the export to `-o <out>`
|
|
192
|
+
// (or stdout when omitted, so it can be piped straight into `proof verify`).
|
|
193
|
+
//
|
|
194
|
+
// Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
|
|
195
|
+
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
196
|
+
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
197
|
+
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
198
|
+
async function runExport(rest: string[], io: Cli): Promise<number> {
|
|
199
|
+
let attestationsPath: string | undefined;
|
|
200
|
+
let keyPath: string | undefined;
|
|
201
|
+
let outPath: string | undefined;
|
|
202
|
+
const opts: ComposeExportOptions = {};
|
|
203
|
+
const positional: string[] = [];
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < rest.length; i++) {
|
|
206
|
+
const a = rest[i]!;
|
|
207
|
+
const takeNext = (flag: string): string | undefined => {
|
|
208
|
+
const next = rest[i + 1];
|
|
209
|
+
if (next === undefined) {
|
|
210
|
+
io.err(`export: ${flag} requires a value`);
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
i++;
|
|
214
|
+
return next;
|
|
215
|
+
};
|
|
216
|
+
if (a === "--attestations" || a === "-a") {
|
|
217
|
+
const v = takeNext(a);
|
|
218
|
+
if (v === undefined) return usageExport(io);
|
|
219
|
+
attestationsPath = v;
|
|
220
|
+
} else if (a === "--key" || a === "-k") {
|
|
221
|
+
const v = takeNext(a);
|
|
222
|
+
if (v === undefined) return usageExport(io);
|
|
223
|
+
keyPath = v;
|
|
224
|
+
} else if (a === "--out" || a === "-o") {
|
|
225
|
+
const v = takeNext(a);
|
|
226
|
+
if (v === undefined) return usageExport(io);
|
|
227
|
+
outPath = v;
|
|
228
|
+
} else if (a === "--issuer-id") {
|
|
229
|
+
const v = takeNext(a);
|
|
230
|
+
if (v === undefined) return usageExport(io);
|
|
231
|
+
opts.issuerId = v;
|
|
232
|
+
} else if (a === "--gateway") {
|
|
233
|
+
const v = takeNext(a);
|
|
234
|
+
if (v === undefined) return usageExport(io);
|
|
235
|
+
opts.gateway = v;
|
|
236
|
+
} else if (a === "--previous-hash") {
|
|
237
|
+
const v = takeNext(a);
|
|
238
|
+
if (v === undefined) return usageExport(io);
|
|
239
|
+
opts.previousHash = v;
|
|
240
|
+
} else if (a === "--generated-at") {
|
|
241
|
+
const v = takeNext(a);
|
|
242
|
+
if (v === undefined) return usageExport(io);
|
|
243
|
+
opts.generatedAt = v;
|
|
244
|
+
} else {
|
|
245
|
+
positional.push(a);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const [bundlePath] = positional;
|
|
250
|
+
if (!bundlePath) {
|
|
251
|
+
io.err("export: a source bundle file path is required");
|
|
252
|
+
return usageExport(io);
|
|
253
|
+
}
|
|
254
|
+
if (!attestationsPath) {
|
|
255
|
+
io.err("export: --attestations <file> is required");
|
|
256
|
+
return usageExport(io);
|
|
257
|
+
}
|
|
258
|
+
if (!keyPath) {
|
|
259
|
+
io.err("export: --key <file> is required");
|
|
260
|
+
return usageExport(io);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const sourceBundle = await readJson<EvidenceBundle>(bundlePath, io);
|
|
264
|
+
if (sourceBundle === undefined) return EXIT_MALFORMED;
|
|
265
|
+
const attestationsRaw = await readJson<unknown>(attestationsPath, io);
|
|
266
|
+
if (attestationsRaw === undefined) return EXIT_MALFORMED;
|
|
267
|
+
const attestations = coerceAttestations(attestationsRaw);
|
|
268
|
+
if (attestations === undefined) {
|
|
269
|
+
io.err(`export: ${attestationsPath} must be a JSON array of attestation records ` +
|
|
270
|
+
`(or an object with an "attestations" array)`);
|
|
271
|
+
return EXIT_MALFORMED;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const privateKey = await readExporterKey(keyPath, io);
|
|
275
|
+
if (privateKey === undefined) return EXIT_MALFORMED;
|
|
276
|
+
|
|
277
|
+
let composed;
|
|
278
|
+
try {
|
|
279
|
+
composed = await composeExport(sourceBundle, attestations, { privateKey }, opts);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
io.err(`export: cannot compose — ${e instanceof Error ? e.message : String(e)}`);
|
|
282
|
+
return EXIT_MALFORMED;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The composer refuses to hand back a green artifact over failing evidence: a
|
|
286
|
+
// FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
|
|
287
|
+
// still writes the artifact so the caller can inspect it.
|
|
288
|
+
const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
|
|
289
|
+
if (outPath) {
|
|
290
|
+
try {
|
|
291
|
+
await writeFile(outPath, serialized, "utf8");
|
|
292
|
+
} catch (e) {
|
|
293
|
+
io.err(`export: cannot write ${outPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
294
|
+
return EXIT_MALFORMED;
|
|
295
|
+
}
|
|
296
|
+
io.out(
|
|
297
|
+
`${paint("exported", composed.status === "failed" ? R : G)} ${outPath} ` +
|
|
298
|
+
`status ${composed.status} ` +
|
|
299
|
+
`attestations ${composed.boundAttestations}/${attestations.length} bound`,
|
|
300
|
+
);
|
|
301
|
+
} else {
|
|
302
|
+
io.out(serialized.replace(/\n$/, ""));
|
|
303
|
+
}
|
|
304
|
+
return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function usageExport(io: Cli): number {
|
|
308
|
+
io.out(
|
|
309
|
+
"Usage: npx @sanning/proof export <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]",
|
|
310
|
+
);
|
|
311
|
+
io.out("");
|
|
312
|
+
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from an");
|
|
313
|
+
io.out("sanning.anchor.trace/v1 source bundle + operator attestation records + an exporter");
|
|
314
|
+
io.out("Ed25519 key. The output verifies with `proof verify <out.json>`.");
|
|
315
|
+
io.out("");
|
|
316
|
+
io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
|
|
317
|
+
io.out("--key <file> Exporter Ed25519 key: a 32-byte hex seed, or JSON");
|
|
318
|
+
io.out(' {"privateKey":"<hex>"} / {"seed":"<hex>"}.');
|
|
319
|
+
io.out("-o, --out <file> Write the export here (default: stdout).");
|
|
320
|
+
io.out("--issuer-id <id> Wrapper issuer.issuer_id (default sanning-verify).");
|
|
321
|
+
io.out("--gateway <url> Named delivery surface (not trusted).");
|
|
322
|
+
io.out("--previous-hash <h> Custody pointer (default GENESIS).");
|
|
323
|
+
io.out("--generated-at <ts> RFC 3339 compose time (default now).");
|
|
324
|
+
return EXIT_MALFORMED;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Read + parse a JSON file; report a clear error and return undefined on any
|
|
328
|
+
// failure (mirrors the verify path's read/parse error handling → exit 2).
|
|
329
|
+
async function readJson<T>(path: string, io: Cli): Promise<T | undefined> {
|
|
330
|
+
let raw: string;
|
|
331
|
+
try {
|
|
332
|
+
raw = await readFile(path, "utf8");
|
|
333
|
+
} catch (e) {
|
|
334
|
+
io.err(`cannot read ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
return JSON.parse(raw) as T;
|
|
339
|
+
} catch (e) {
|
|
340
|
+
io.err(`${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Accept either a bare array of attestation records or an object wrapping one
|
|
346
|
+
// under `attestations`. Returns undefined for anything else.
|
|
347
|
+
function coerceAttestations(raw: unknown): AttestationRecord[] | undefined {
|
|
348
|
+
if (Array.isArray(raw)) return raw as AttestationRecord[];
|
|
349
|
+
if (raw !== null && typeof raw === "object") {
|
|
350
|
+
const inner = (raw as { attestations?: unknown }).attestations;
|
|
351
|
+
if (Array.isArray(inner)) return inner as AttestationRecord[];
|
|
352
|
+
}
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Read the exporter Ed25519 key: a file that is either a bare 32-byte hex seed
|
|
357
|
+
// (whitespace-trimmed) or a JSON object carrying it under `privateKey` / `seed` /
|
|
358
|
+
// `private_key`. Validates the 64-hex-char (32-byte) shape.
|
|
359
|
+
async function readExporterKey(path: string, io: Cli): Promise<string | undefined> {
|
|
360
|
+
let raw: string;
|
|
361
|
+
try {
|
|
362
|
+
raw = (await readFile(path, "utf8")).trim();
|
|
363
|
+
} catch (e) {
|
|
364
|
+
io.err(`export: cannot read --key ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
let seed = raw;
|
|
368
|
+
if (raw.startsWith("{")) {
|
|
369
|
+
try {
|
|
370
|
+
const obj = JSON.parse(raw) as Record<string, unknown>;
|
|
371
|
+
const found = obj.privateKey ?? obj.seed ?? obj.private_key;
|
|
372
|
+
if (typeof found !== "string") {
|
|
373
|
+
io.err(`export: --key ${path} JSON needs a string "privateKey"/"seed" field`);
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
seed = found.trim();
|
|
377
|
+
} catch (e) {
|
|
378
|
+
io.err(`export: --key ${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (!/^[0-9a-fA-F]{64}$/.test(seed)) {
|
|
383
|
+
io.err(`export: --key must be a 32-byte (64 hex char) Ed25519 seed`);
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
return seed.toLowerCase();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The --logs side-input lane decides each disclosed value's encoding: a clean,
|
|
390
|
+
// even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
|
|
391
|
+
// (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
|
|
392
|
+
// raw bytes" — the human-facing encoding choice is made here.)
|
|
393
|
+
function decodeLogValue(v: string): Uint8Array | string {
|
|
394
|
+
if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v)) return v; // hex passthrough
|
|
395
|
+
return utf8(v); // raw utf8 bytes
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function printUsage(io: Cli): void {
|
|
399
|
+
io.out("Usage: npx @sanning/proof <command> [args]");
|
|
400
|
+
io.out("");
|
|
401
|
+
io.out("Commands:");
|
|
402
|
+
io.out(" verify <bundle.json> [gateways] [--logs <logs.json>] Verify a bundle/export");
|
|
403
|
+
io.out(" export <source.json> --attestations <f> --key <f> [-o] Compose an attested export");
|
|
404
|
+
io.out("");
|
|
405
|
+
io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
|
|
406
|
+
io.out("sanning.evidence.export/v1 attested export, or a sanning.agent.proof/v1");
|
|
407
|
+
io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
|
|
408
|
+
io.out("to also re-fetch each checkpoint on-chain.");
|
|
409
|
+
io.out("");
|
|
410
|
+
io.out("--logs <logs.json> Bind disclosed raw logs to each event's committed");
|
|
411
|
+
io.out(" content_hash. JSON object mapping event_id → bytes");
|
|
412
|
+
io.out(" (even-length lowercase hex ⇒ hex, else utf8 text).");
|
|
413
|
+
io.out(" In-body events[].content is verified automatically.");
|
|
414
|
+
io.out("");
|
|
415
|
+
io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function reportEvidence(
|
|
419
|
+
result: EvidenceBundleResult,
|
|
420
|
+
path: string,
|
|
421
|
+
gateways: string[],
|
|
422
|
+
io: Cli,
|
|
423
|
+
): number {
|
|
424
|
+
if (result.status === "malformed") {
|
|
425
|
+
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
426
|
+
for (const e of result.errors) io.err(` - ${e}`);
|
|
427
|
+
return EXIT_MALFORMED;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
io.out(paint("Evidence bundle", B) + ` ${path}`);
|
|
431
|
+
io.out(` body_type ${result.bodyType ?? "<unknown>"}`);
|
|
432
|
+
io.out(
|
|
433
|
+
` wrapper sig ${mark(result.signatureOk)} body_hash ${mark(result.bodyHashOk)} spec ${mark(result.specVersionOk)}`,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
if (result.checkpoints.length > 0) {
|
|
437
|
+
io.out("");
|
|
438
|
+
io.out(paint(" Checkpoints", DIM));
|
|
439
|
+
for (const cp of result.checkpoints) {
|
|
440
|
+
const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
|
|
441
|
+
io.out(
|
|
442
|
+
` ${mark(cp.ok)} ${shortTx(cp.txId)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`,
|
|
443
|
+
);
|
|
444
|
+
for (const e of cp.errors) io.out(paint(` - ${e}`, R));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (result.events.length > 0) {
|
|
449
|
+
io.out("");
|
|
450
|
+
io.out(paint(" Events", DIM));
|
|
451
|
+
for (const ev of result.events) {
|
|
452
|
+
const binding =
|
|
453
|
+
ev.payloadBindingOk === null
|
|
454
|
+
? paint("~ record withheld", Y)
|
|
455
|
+
: `record ${mark(ev.payloadBindingOk)}`;
|
|
456
|
+
// Show the content (logs) segment only when content was disclosed and
|
|
457
|
+
// evaluated (non-null) — undisclosed content is the default and would just
|
|
458
|
+
// be noise on every line.
|
|
459
|
+
const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
|
|
460
|
+
io.out(
|
|
461
|
+
` ${mark(ev.ok)} ${shortId(ev.eventId)} sig ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`,
|
|
462
|
+
);
|
|
463
|
+
for (const e of ev.errors) io.out(paint(` - ${e}`, R));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (result.export) {
|
|
468
|
+
reportExport(result.export, io);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
io.out("");
|
|
472
|
+
printRollup(result.status, result.assertedStatus, gateways, result.onChainChecked, io);
|
|
473
|
+
for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
|
|
474
|
+
|
|
475
|
+
const disclosed = result.events.filter((e) => e.contentOk !== null);
|
|
476
|
+
if (disclosed.length > 0) {
|
|
477
|
+
const verified = disclosed.filter((e) => e.contentOk === true).length;
|
|
478
|
+
io.out(paint(` logs: ${verified}/${disclosed.length} disclosed verified`, DIM));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return mapStatusToExit(result.status, result.errors);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Render the export-specific block (evidence-export.md §5): source-bundle
|
|
485
|
+
// linkage, cached-vs-recomputed verdict agreement, and each embedded operator
|
|
486
|
+
// attestation (RSA-PSS sig · operator-address binding · data_hash binding).
|
|
487
|
+
function reportExport(exp: ExportResult, io: Cli): void {
|
|
488
|
+
io.out("");
|
|
489
|
+
io.out(paint(" Attested export", DIM));
|
|
490
|
+
io.out(
|
|
491
|
+
` source linkage ${mark(exp.sourceLinkageOk)} verdict agreement ${mark(exp.verdictAgreementOk)}`,
|
|
492
|
+
);
|
|
493
|
+
if (exp.attestations.length > 0) {
|
|
494
|
+
io.out(paint(" Operator attestations", DIM));
|
|
495
|
+
for (const a of exp.attestations) {
|
|
496
|
+
const subj = a.subjectRefOk === null ? "" : ` subject ${mark(a.subjectRefOk)}`;
|
|
497
|
+
io.out(
|
|
498
|
+
` ${mark(a.ok)} ${shortId(a.operator)} sig ${mark(a.signatureOk)}` +
|
|
499
|
+
` operator ${mark(a.operatorAddressBound)} data_hash ${mark(a.dataHashBound)}${subj}`,
|
|
500
|
+
);
|
|
501
|
+
for (const e of a.errors) io.out(paint(` - ${e}`, R));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function reportAgentProof(
|
|
507
|
+
result: AgentProofResult,
|
|
508
|
+
path: string,
|
|
509
|
+
gateways: string[],
|
|
510
|
+
io: Cli,
|
|
511
|
+
): number {
|
|
512
|
+
if (result.status === "malformed") {
|
|
513
|
+
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
514
|
+
for (const e of result.errors) io.err(` - ${e}`);
|
|
515
|
+
return EXIT_MALFORMED;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
io.out(paint("Agent inclusion proof", B) + ` ${path}`);
|
|
519
|
+
io.out(` spec_version sanning.agent.proof/v1`);
|
|
520
|
+
io.out(` checkpoint ${shortTx(result.checkpointTxId ?? "")} envelope ${mark(result.checkpointOk)}`);
|
|
521
|
+
const onChain = result.onChainOk === null ? "" : ` on-chain ${mark(result.onChainOk)}`;
|
|
522
|
+
io.out(
|
|
523
|
+
` leaf index ${result.leafIndex} / ${result.leafCount ?? "?"} inclusion ${mark(result.inclusionOk)}${onChain}`,
|
|
524
|
+
);
|
|
525
|
+
io.out("");
|
|
526
|
+
printRollup(result.status, null, gateways, result.onChainChecked, io);
|
|
527
|
+
for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
|
|
528
|
+
|
|
529
|
+
return mapStatusToExit(result.status, result.errors);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function printRollup(
|
|
533
|
+
status: string,
|
|
534
|
+
asserted: string | null,
|
|
535
|
+
gateways: string[],
|
|
536
|
+
onChainChecked: boolean,
|
|
537
|
+
io: Cli,
|
|
538
|
+
): void {
|
|
539
|
+
const scope = onChainChecked
|
|
540
|
+
? `verified offline + on-chain against ${gateways.length} gateway(s)`
|
|
541
|
+
: "verified offline (no gateway re-fetch requested)";
|
|
542
|
+
let head: string;
|
|
543
|
+
switch (status) {
|
|
544
|
+
case "verified":
|
|
545
|
+
head = paint("VERIFIED", G);
|
|
546
|
+
break;
|
|
547
|
+
case "partial":
|
|
548
|
+
head = paint("PARTIAL", Y);
|
|
549
|
+
break;
|
|
550
|
+
case "failed":
|
|
551
|
+
head = paint("FAILED", R);
|
|
552
|
+
break;
|
|
553
|
+
default:
|
|
554
|
+
head = paint(status.toUpperCase(), R);
|
|
555
|
+
}
|
|
556
|
+
io.out(`${head} ${paint(scope, DIM)}`);
|
|
557
|
+
if (asserted && asserted !== status) {
|
|
558
|
+
io.out(
|
|
559
|
+
paint(
|
|
560
|
+
` (producer asserted "${asserted}"; recomputed verdict is "${status}" — recomputed wins)`,
|
|
561
|
+
DIM,
|
|
562
|
+
),
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function mapStatusToExit(status: string, errors: string[]): number {
|
|
568
|
+
if (status === "failed") return EXIT_FAILED;
|
|
569
|
+
if (status === "malformed") return EXIT_MALFORMED;
|
|
570
|
+
// "partial" caused specifically by an unreachable gateway — or an export
|
|
571
|
+
// source_bundle_ref whose bytes are unavailable offline (evidence-export.md
|
|
572
|
+
// §5 step 3) — maps to exit 3 (undetermined, network-dependent).
|
|
573
|
+
if (
|
|
574
|
+
status === "partial" &&
|
|
575
|
+
errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))
|
|
576
|
+
) {
|
|
577
|
+
return EXIT_GATEWAY_UNAVAILABLE;
|
|
578
|
+
}
|
|
579
|
+
// "verified" → 0. "partial" from a withheld record (semantics-undetermined,
|
|
580
|
+
// not a failure) also exits 0 — the bundle is cryptographically sound.
|
|
581
|
+
return EXIT_VERIFIED;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function shortTx(tx: string): string {
|
|
585
|
+
if (tx.length <= 16) return tx;
|
|
586
|
+
return `${tx.slice(0, 8)}…${tx.slice(-4)}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function shortId(id: string): string {
|
|
590
|
+
if (id.length <= 12) return id;
|
|
591
|
+
return `${id.slice(0, 8)}…`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Entry point when run as a binary (not when imported by tests).
|
|
595
|
+
const isMain = (() => {
|
|
596
|
+
try {
|
|
597
|
+
if (!process.argv[1]) return false;
|
|
598
|
+
// realpath BOTH sides: when run via a bin symlink (npx / node_modules/.bin/proof
|
|
599
|
+
// -> dist/cli.js), process.argv[1] is the symlink and import.meta.url is the target,
|
|
600
|
+
// so a naive compare skips main(). Resolving both to the real path makes them match.
|
|
601
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
602
|
+
} catch {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
})();
|
|
606
|
+
|
|
607
|
+
if (isMain) {
|
|
608
|
+
runCli(process.argv.slice(2), {
|
|
609
|
+
out: (s) => process.stdout.write(s + "\n"),
|
|
610
|
+
err: (s) => process.stderr.write(s + "\n"),
|
|
611
|
+
})
|
|
612
|
+
.then((code) => {
|
|
613
|
+
process.exitCode = code;
|
|
614
|
+
})
|
|
615
|
+
.catch((e) => {
|
|
616
|
+
process.stderr.write(`fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
|
|
617
|
+
process.exitCode = EXIT_MALFORMED;
|
|
618
|
+
});
|
|
619
|
+
}
|