@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/dist/index.js
CHANGED
|
@@ -26,7 +26,31 @@ export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyI
|
|
|
26
26
|
// issuer-composed sanning.evidence.export/v1 body (specs/evidence-export.md) to
|
|
27
27
|
// verifyExportBody (embedded RSA-PSS operator attestations + a recomputed §4
|
|
28
28
|
// verdict object with per-gateway on-chain outcomes).
|
|
29
|
-
|
|
29
|
+
//
|
|
30
|
+
// `disclosedContent` is the INGEST half of the same path (evidence-bundle
|
|
31
|
+
// §5.1). A received bundle carries its disclosed raw bytes as lowercase hex, so
|
|
32
|
+
// materializing them was, until now, every consumer re-implementing the
|
|
33
|
+
// encoding convention — and a consumer that copied the field VERBATIM wrote
|
|
34
|
+
// files that failed verification on genuine evidence. This hands back DECODED
|
|
35
|
+
// bytes, and only for events whose verification bound them to the committed
|
|
36
|
+
// content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
|
|
37
|
+
export { ANCHOR_TRACE_BODY_TYPE, ANCHOR_TRACE_BODY_TYPE_V1, ANCHOR_TRACE_BODY_TYPES, EXPORT_BODY_TYPE, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
|
|
38
|
+
// Pack grading (src/grading.ts): what a verification SURFACE may claim about a
|
|
39
|
+
// pack — the signed bundle PLUS whatever disclosed bytes travelled beside it.
|
|
40
|
+
//
|
|
41
|
+
// The rule is already normative in evidence-bundle.md §5.1 (absent disclosure is
|
|
42
|
+
// undetermined, not failed) and §6 (no status ever means "safe"); what the
|
|
43
|
+
// surfaces added on top was a sharper word for undetermined and an account of
|
|
44
|
+
// what the pack failed to disclose. It lived twice, in two repos and two
|
|
45
|
+
// languages, and the two derivations had already diverged once. It lives here.
|
|
46
|
+
//
|
|
47
|
+
// Pure — data in, data out, no I/O, so the kernel's scope boundary
|
|
48
|
+
// (specs/architecture.md §3) is unchanged. `vectors/pack-grading.json` is the
|
|
49
|
+
// conformance statement of the rule and ships in the published tarball, so a
|
|
50
|
+
// consumer that cannot yet import `gradePack` can vendor the table, hold its own
|
|
51
|
+
// derivation to it, and diff its copy against ours. Additive — no existing
|
|
52
|
+
// export changes.
|
|
53
|
+
export { accountDisclosure, completenessNotes, gradeCauses, gradePack, renderCause, signedLeafCount, } from "./grading.js";
|
|
30
54
|
// The `proof export` composer (specs/evidence-export.md §5, phase 2): produce a
|
|
31
55
|
// signed, offline-verifiable sanning.evidence.export/v1 artifact from a source
|
|
32
56
|
// anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** A caller-built map of disclosed bytes, in either shape it can take. */
|
|
2
|
+
export type SideContentMap = Readonly<Record<string, Uint8Array | string | undefined>> | ReadonlyMap<string, Uint8Array | string | undefined>;
|
|
3
|
+
/** One event's entry, and — separately — whether an entry existed at all. */
|
|
4
|
+
export interface SideEntry {
|
|
5
|
+
/** An entry EXISTS for this id. Never inferred from the value. */
|
|
6
|
+
offered: boolean;
|
|
7
|
+
/** What it held, read without ever invoking an accessor. */
|
|
8
|
+
value: Uint8Array | string | undefined;
|
|
9
|
+
}
|
|
10
|
+
/** Look up one event's out-of-band disclosure. The rule, stated once. */
|
|
11
|
+
export declare function sideContentEntry(content: SideContentMap | null | undefined, eventId: string): SideEntry;
|
|
12
|
+
/** Whether the map carries a usable entry for this id. The same rule. */
|
|
13
|
+
export declare function hasSideContent(content: SideContentMap | null | undefined, eventId: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The ids the map actually carries — the same rule applied to every key, so a
|
|
16
|
+
* key that is not an entry cannot be reported as surplus disclosure either.
|
|
17
|
+
*/
|
|
18
|
+
export declare function sideContentKeys(content: SideContentMap | null | undefined): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Whether an event's IN-BODY `events[].content` field is a disclosure.
|
|
21
|
+
*
|
|
22
|
+
* The other half of the same question, and it broke the same way. `content`
|
|
23
|
+
* is an OPTIONAL field, so `null` is how a producer's serializer spells its
|
|
24
|
+
* absence — Go's `json.Marshal` on a nil pointer without `omitempty`, Python's
|
|
25
|
+
* `json.dumps(None)`. A bare `!== undefined` marks such an event OFFERED while
|
|
26
|
+
* no bytes are selected (only a `string` decodes), and the kernel then splits
|
|
27
|
+
* against itself: `verifyEvidenceBundle` reports `contentOk: null` and rolls up
|
|
28
|
+
* VERIFIED, while `disclosedContent` reports FAILED — "content was disclosed
|
|
29
|
+
* for this event but is not a decodable lowercase-hex string". One kernel, one
|
|
30
|
+
* bundle, two verdicts, reachable from any conforming producer.
|
|
31
|
+
*
|
|
32
|
+
* Non-nullish and not a string — a number, an object, a hex string that is not
|
|
33
|
+
* hex — remains a real, unusable disclosure and stays a finding, exactly as on
|
|
34
|
+
* the side-input path.
|
|
35
|
+
*/
|
|
36
|
+
export declare function inBodyContentOffered(content: unknown): boolean;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ONE answer to "did the holder actually supply bytes for this event_id?"
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS A MODULE AND NOT TWO PRIVATE HELPERS.
|
|
4
|
+
// The kernel asks that question in two places — the verifier, deciding whether
|
|
5
|
+
// an event's content was offered at all (`evidence.ts`), and the grader,
|
|
6
|
+
// accounting which records a pack disclosed (`grading.ts`). It had two answers.
|
|
7
|
+
// On one and the same content map the grader said PRESENT while the verifier
|
|
8
|
+
// said nothing was offered, so `gradePack` returned `incomplete` with ZERO
|
|
9
|
+
// causes: a surface saying "incomplete" and unable to say why. That is exactly
|
|
10
|
+
// the outcome `grading.ts`'s own comment claims was removed, reappearing
|
|
11
|
+
// because the fix landed on one of the two definitions.
|
|
12
|
+
//
|
|
13
|
+
// Two implementations of one verification rule is the defect `grading.ts` was
|
|
14
|
+
// extracted into the kernel to end. A rule stated once cannot drift; a rule
|
|
15
|
+
// stated twice already has.
|
|
16
|
+
//
|
|
17
|
+
// ── THE RULE ────────────────────────────────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// An entry exists for `eventId` when the map carries an OWN, ENUMERABLE, DATA
|
|
20
|
+
// property (or `Map` key) holding something other than `null`/`undefined`.
|
|
21
|
+
// This is the STRICTER of the two definitions that were in the kernel — the
|
|
22
|
+
// verifier's — and it is stricter on purpose. Every clause was paid for:
|
|
23
|
+
//
|
|
24
|
+
// own — an `event_id` is PRODUCER-CHOSEN, and every key on
|
|
25
|
+
// `Object.prototype` is a legal one: `constructor`,
|
|
26
|
+
// `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`,
|
|
27
|
+
// `propertyIsEnumerable`, `toLocaleString`, `__proto__`. An
|
|
28
|
+
// indexed lookup walks the prototype chain and resolves every
|
|
29
|
+
// one of them, so absent evidence read as good evidence.
|
|
30
|
+
// enumerable — what a caller means by "the map I built" is what
|
|
31
|
+
// `Object.keys` shows; nothing hidden behind it counts.
|
|
32
|
+
// data — an accessor is NEVER invoked. A getter can throw, and this
|
|
33
|
+
// kernel's standing rule is that a verifier does not raise on
|
|
34
|
+
// adversarial input. Not hypothetical: a plain-object map that
|
|
35
|
+
// has taken a `__proto__` write carries `Uint8Array.prototype`
|
|
36
|
+
// as its prototype, and reading `byteLength`, `length`,
|
|
37
|
+
// `buffer` or `byteOffset` off a plain object through it throws
|
|
38
|
+
// a TypeError. Reading a descriptor cannot.
|
|
39
|
+
// non-nullish — `{ [id]: undefined }` is how `Object.fromEntries` and friends
|
|
40
|
+
// spell "no entry", and `new Map([[id, undefined]])` is the
|
|
41
|
+
// same statement in the other shape. Anything else non-nullish
|
|
42
|
+
// — a number, an object, a string that is not hex — IS a real,
|
|
43
|
+
// unusable disclosure and stays a finding.
|
|
44
|
+
//
|
|
45
|
+
// `offered` is what separates "nothing was disclosed" (undetermined) from
|
|
46
|
+
// "something was disclosed and is unusable" (a finding), and absent disclosure
|
|
47
|
+
// is undetermined, not failed (evidence-bundle.md §5.1). So it is returned
|
|
48
|
+
// SEPARATELY from the value and is never inferred from it: `value !== undefined`
|
|
49
|
+
// is the conflation this module exists to remove, in both directions.
|
|
50
|
+
const ABSENT = { offered: false, value: undefined };
|
|
51
|
+
/** Look up one event's out-of-band disclosure. The rule, stated once. */
|
|
52
|
+
export function sideContentEntry(content, eventId) {
|
|
53
|
+
if (content === undefined || content === null)
|
|
54
|
+
return ABSENT;
|
|
55
|
+
if (content instanceof Map) {
|
|
56
|
+
const value = content.get(eventId);
|
|
57
|
+
return value === undefined || value === null ? ABSENT : { offered: true, value };
|
|
58
|
+
}
|
|
59
|
+
const desc = Object.getOwnPropertyDescriptor(content, eventId);
|
|
60
|
+
if (desc === undefined || !desc.enumerable || !("value" in desc))
|
|
61
|
+
return ABSENT;
|
|
62
|
+
const value = desc.value;
|
|
63
|
+
return value === undefined || value === null ? ABSENT : { offered: true, value };
|
|
64
|
+
}
|
|
65
|
+
/** Whether the map carries a usable entry for this id. The same rule. */
|
|
66
|
+
export function hasSideContent(content, eventId) {
|
|
67
|
+
return sideContentEntry(content, eventId).offered;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The ids the map actually carries — the same rule applied to every key, so a
|
|
71
|
+
* key that is not an entry cannot be reported as surplus disclosure either.
|
|
72
|
+
*/
|
|
73
|
+
export function sideContentKeys(content) {
|
|
74
|
+
if (content === undefined || content === null)
|
|
75
|
+
return [];
|
|
76
|
+
const keys = content instanceof Map ? [...content.keys()] : Object.keys(content);
|
|
77
|
+
return keys.filter((id) => hasSideContent(content, id));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether an event's IN-BODY `events[].content` field is a disclosure.
|
|
81
|
+
*
|
|
82
|
+
* The other half of the same question, and it broke the same way. `content`
|
|
83
|
+
* is an OPTIONAL field, so `null` is how a producer's serializer spells its
|
|
84
|
+
* absence — Go's `json.Marshal` on a nil pointer without `omitempty`, Python's
|
|
85
|
+
* `json.dumps(None)`. A bare `!== undefined` marks such an event OFFERED while
|
|
86
|
+
* no bytes are selected (only a `string` decodes), and the kernel then splits
|
|
87
|
+
* against itself: `verifyEvidenceBundle` reports `contentOk: null` and rolls up
|
|
88
|
+
* VERIFIED, while `disclosedContent` reports FAILED — "content was disclosed
|
|
89
|
+
* for this event but is not a decodable lowercase-hex string". One kernel, one
|
|
90
|
+
* bundle, two verdicts, reachable from any conforming producer.
|
|
91
|
+
*
|
|
92
|
+
* Non-nullish and not a string — a number, an object, a hex string that is not
|
|
93
|
+
* hex — remains a real, unusable disclosure and stays a finding, exactly as on
|
|
94
|
+
* the side-input path.
|
|
95
|
+
*/
|
|
96
|
+
export function inBodyContentOffered(content) {
|
|
97
|
+
return content !== undefined && content !== null;
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"dist",
|
|
22
|
+
"vectors",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
package/src/cli.ts
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
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
16
|
|
|
17
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
18
18
|
import { realpathSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
|
|
21
22
|
import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
|
|
@@ -72,8 +73,11 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
72
73
|
io.out("@sanning/proof verify CLI");
|
|
73
74
|
return EXIT_VERIFIED;
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
// `bundle` is the customer-facing verb (proof#19: one artifact, one word).
|
|
77
|
+
// `export` is kept working as a QUIET alias — it shipped and is published, so
|
|
78
|
+
// it is not broken abruptly; it is simply no longer taught anywhere.
|
|
79
|
+
if (command === "bundle" || command === "export") {
|
|
80
|
+
return runBundle(rest, io);
|
|
77
81
|
}
|
|
78
82
|
if (command !== "verify") {
|
|
79
83
|
io.err(`unknown command: ${command}`);
|
|
@@ -81,7 +85,7 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
81
85
|
return EXIT_MALFORMED;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
// Pull the optional `--logs <
|
|
88
|
+
// Pull the optional `--logs <path>` flag out of the args; the rest are
|
|
85
89
|
// positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
|
|
86
90
|
// content verification (the evidence-bundle path only).
|
|
87
91
|
let logsPath: string | undefined;
|
|
@@ -91,7 +95,7 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
91
95
|
if (a === "--logs") {
|
|
92
96
|
const next = rest[i + 1];
|
|
93
97
|
if (next === undefined) {
|
|
94
|
-
io.err("verify: --logs requires a file
|
|
98
|
+
io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
|
|
95
99
|
printUsage(io);
|
|
96
100
|
return EXIT_MALFORMED;
|
|
97
101
|
}
|
|
@@ -113,31 +117,13 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
113
117
|
.map((g) => g.trim())
|
|
114
118
|
.filter((g) => g.length > 0);
|
|
115
119
|
|
|
116
|
-
let
|
|
120
|
+
let logs: LoadedLogs | undefined;
|
|
117
121
|
if (logsPath !== undefined) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
}
|
|
122
|
+
logs = await loadLogs(logsPath, io);
|
|
123
|
+
if (logs === undefined) return EXIT_MALFORMED;
|
|
124
|
+
for (const n of logs.notes) io.err(`note: ${n}`);
|
|
140
125
|
}
|
|
126
|
+
const content = logs?.content;
|
|
141
127
|
|
|
142
128
|
let raw: string;
|
|
143
129
|
try {
|
|
@@ -181,10 +167,15 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
181
167
|
...(content ? { content } : {}),
|
|
182
168
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
183
169
|
});
|
|
184
|
-
|
|
170
|
+
const exit = reportEvidence(result, bundlePath, gateways, io);
|
|
171
|
+
// Coverage is reported only once the bundle parsed far enough to have events
|
|
172
|
+
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
173
|
+
if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
|
|
174
|
+
return exit;
|
|
185
175
|
}
|
|
186
176
|
|
|
187
|
-
// `proof
|
|
177
|
+
// `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
|
|
178
|
+
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
188
179
|
//
|
|
189
180
|
// Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from a
|
|
190
181
|
// source sanning.anchor.trace/v1 bundle + operator attestation records + an exporter
|
|
@@ -195,7 +186,7 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
195
186
|
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
196
187
|
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
197
188
|
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
198
|
-
async function
|
|
189
|
+
async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
199
190
|
let attestationsPath: string | undefined;
|
|
200
191
|
let keyPath: string | undefined;
|
|
201
192
|
let outPath: string | undefined;
|
|
@@ -306,7 +297,7 @@ async function runExport(rest: string[], io: Cli): Promise<number> {
|
|
|
306
297
|
|
|
307
298
|
function usageExport(io: Cli): number {
|
|
308
299
|
io.out(
|
|
309
|
-
"Usage: npx @sanning/proof
|
|
300
|
+
"Usage: npx @sanning/proof bundle <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]",
|
|
310
301
|
);
|
|
311
302
|
io.out("");
|
|
312
303
|
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from an");
|
|
@@ -386,10 +377,252 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
|
|
|
386
377
|
return seed.toLowerCase();
|
|
387
378
|
}
|
|
388
379
|
|
|
389
|
-
//
|
|
380
|
+
// ─── the `--logs` side input ────────────────────────────────────────────────
|
|
381
|
+
//
|
|
382
|
+
// Two shapes, one flag (proof#27):
|
|
383
|
+
//
|
|
384
|
+
// DIRECTORY `logs/<event_id>.json`, each file's RAW BYTES being the
|
|
385
|
+
// disclosure. This is the layout `materializeBundle` writes
|
|
386
|
+
// (bundle.json + logs/) and the layout the verify page hashes,
|
|
387
|
+
// so a pack verifies with NO side-input construction step.
|
|
388
|
+
// JSON MAP `{ event_id: <even-length lowercase hex | utf8 text> }`, the
|
|
389
|
+
// original form. Unchanged.
|
|
390
|
+
//
|
|
391
|
+
// **Why the directory form exists at all.** Handing a pack to the CLI used to
|
|
392
|
+
// require the caller to read `logs/` and hex-encode it into a map, and that
|
|
393
|
+
// read-and-encode step has been got wrong twice in shipped code: claims-demo#9
|
|
394
|
+
// wrote the hex TEXT into the log files (SHA-256 of hex text is not SHA-256 of
|
|
395
|
+
// the bytes it encodes, so genuine evidence failed), and a runbook handed the
|
|
396
|
+
// directory straight to `--logs` and got EISDIR, exit 2. The directory form
|
|
397
|
+
// removes the encoding decision from the caller entirely — bytes in, bytes
|
|
398
|
+
// hashed — so that whole class stops being reachable from this direction.
|
|
399
|
+
//
|
|
400
|
+
// **Nothing is re-encoded, re-parsed or re-serialised on the way in.** A log
|
|
401
|
+
// file's bytes are passed to the verifier as a `Uint8Array`, which the verifier
|
|
402
|
+
// uses as-is; only the map form's *strings* go through `decodeLogValue`. That
|
|
403
|
+
// is the invariant this feature exists to hold, and it is what makes the CLI
|
|
404
|
+
// and the verify page agree about one pack.
|
|
405
|
+
interface LoadedLogs {
|
|
406
|
+
// Keyed by event id. A `Uint8Array` is raw bytes (directory form); a `string`
|
|
407
|
+
// is lowercase hex (map form). Those are exactly the two the verifier
|
|
408
|
+
// distinguishes.
|
|
409
|
+
//
|
|
410
|
+
// A `Map`, NOT a plain object, and that is load-bearing rather than stylistic.
|
|
411
|
+
// An event id is producer-chosen — here it arrives as a FILE NAME or a JSON
|
|
412
|
+
// key — and `content["__proto__"] = <bytes>` on an object literal does not
|
|
413
|
+
// store anything: it REPLACES the map's prototype with the Uint8Array. The
|
|
414
|
+
// disclosed bytes are then silently lost (the holder disclosed them and we
|
|
415
|
+
// report the record undisclosed), and every later lookup on that map resolves
|
|
416
|
+
// through `Uint8Array.prototype`, so an event named `slice` or `set` finds an
|
|
417
|
+
// inherited function and an event named `byteLength` or `buffer` THROWS a
|
|
418
|
+
// TypeError out of the verifier. A Map keys on the string itself and has none
|
|
419
|
+
// of that.
|
|
420
|
+
content: Map<string, Uint8Array | string>;
|
|
421
|
+
// Event ids the side input actually supplied — for the post-verify coverage
|
|
422
|
+
// cross-check, which needs to know what was offered, not just what bound.
|
|
423
|
+
suppliedIds: string[];
|
|
424
|
+
// Advisory lines about the INPUT ITSELF (skipped entries, a directory that
|
|
425
|
+
// disclosed nothing). None of them moves a verdict or an exit code.
|
|
426
|
+
notes: string[];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// The suffix the directory form reads, matched exactly — lowercase, which is
|
|
430
|
+
// what `materializeBundle` writes. Anything else is skipped and NAMED rather
|
|
431
|
+
// than guessed at, so a `.JSON` or extensionless file shows up as a note
|
|
432
|
+
// instead of a mysteriously undisclosed event.
|
|
433
|
+
const LOG_SUFFIX = ".json";
|
|
434
|
+
|
|
435
|
+
// Which shape was passed is decided by `stat`, never by the extension: a pack's
|
|
436
|
+
// log directory can be called anything, and `logs.json` is a legal directory
|
|
437
|
+
// name. `stat` follows symlinks, so a symlinked logs directory works.
|
|
438
|
+
async function loadLogs(logsPath: string, io: Cli): Promise<LoadedLogs | undefined> {
|
|
439
|
+
let isDirectory: boolean;
|
|
440
|
+
try {
|
|
441
|
+
isDirectory = (await stat(logsPath)).isDirectory();
|
|
442
|
+
} catch (e) {
|
|
443
|
+
io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// The JSON-map form, unchanged: `{ event_id: <hex | utf8 text> }`.
|
|
450
|
+
async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | undefined> {
|
|
451
|
+
let logsRaw: string;
|
|
452
|
+
try {
|
|
453
|
+
logsRaw = await readFile(logsPath, "utf8");
|
|
454
|
+
} catch (e) {
|
|
455
|
+
io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
let logsParsed: unknown;
|
|
459
|
+
try {
|
|
460
|
+
logsParsed = JSON.parse(logsRaw);
|
|
461
|
+
} catch (e) {
|
|
462
|
+
io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
|
|
466
|
+
io.err(
|
|
467
|
+
`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes, ` +
|
|
468
|
+
`or a directory of <event_id>${LOG_SUFFIX} files`,
|
|
469
|
+
);
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
const content = new Map<string, Uint8Array | string>();
|
|
473
|
+
const suppliedIds: string[] = [];
|
|
474
|
+
for (const [k, v] of Object.entries(logsParsed as Record<string, unknown>)) {
|
|
475
|
+
if (typeof v !== "string") continue;
|
|
476
|
+
content.set(k, decodeLogValue(v));
|
|
477
|
+
suppliedIds.push(k);
|
|
478
|
+
}
|
|
479
|
+
return { content, suppliedIds, notes: [] };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// The directory form. Flat, one file per event, `<event_id>.json`.
|
|
483
|
+
//
|
|
484
|
+
// The decisions this pins, none of which is a verification failure — an absent
|
|
485
|
+
// or unusable disclosure is UNDISCLOSED, and absence is not evidence of
|
|
486
|
+
// tampering (evidence-bundle.md §5.1):
|
|
487
|
+
//
|
|
488
|
+
// - a file whose name is not a known event id → loaded, binds to nothing,
|
|
489
|
+
// named in a post-verify note (silence is how a typo'd filename becomes a
|
|
490
|
+
// mysteriously undisclosed event)
|
|
491
|
+
// - an event with no corresponding file → undetermined (`~`), noted
|
|
492
|
+
// - an empty directory → valid; discloses nothing,
|
|
493
|
+
// noted, exit code unchanged
|
|
494
|
+
// - a subdirectory → skipped, never recursed,
|
|
495
|
+
// noted (this is the "you pointed at the pack root, not its logs/" case)
|
|
496
|
+
// - anything not named `<event_id>.json`, and anything that is not a regular
|
|
497
|
+
// file → skipped, noted
|
|
498
|
+
//
|
|
499
|
+
// The one hard stop is a file we SELECTED but cannot read: we were pointed at
|
|
500
|
+
// it, so a permissions error must not read as minimal disclosure. That exits 2.
|
|
501
|
+
async function loadLogsDirectory(dirPath: string, io: Cli): Promise<LoadedLogs | undefined> {
|
|
502
|
+
let names: string[];
|
|
503
|
+
try {
|
|
504
|
+
names = await readdir(dirPath);
|
|
505
|
+
} catch (e) {
|
|
506
|
+
io.err(`cannot read --logs directory ${dirPath}: ${errMsg(e)}`);
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const content = new Map<string, Uint8Array | string>();
|
|
511
|
+
const suppliedIds: string[] = [];
|
|
512
|
+
const skippedDirs: string[] = [];
|
|
513
|
+
const skippedNames: string[] = [];
|
|
514
|
+
|
|
515
|
+
for (const name of [...names].sort()) {
|
|
516
|
+
const p = join(dirPath, name);
|
|
517
|
+
let isDirectory: boolean;
|
|
518
|
+
let isFile: boolean;
|
|
519
|
+
try {
|
|
520
|
+
// `stat`, not the dirent kind: a symlinked log file is a log file, and a
|
|
521
|
+
// symlink to a directory is a directory.
|
|
522
|
+
const s = await stat(p);
|
|
523
|
+
isDirectory = s.isDirectory();
|
|
524
|
+
isFile = s.isFile();
|
|
525
|
+
} catch (e) {
|
|
526
|
+
io.err(`cannot read --logs entry ${p}: ${errMsg(e)}`);
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
if (isDirectory) {
|
|
530
|
+
skippedDirs.push(name);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
// Not a regular file (device, fifo, socket), or not named
|
|
534
|
+
// `<event_id>.json` — including a file named exactly `.json`, whose event
|
|
535
|
+
// id would be the empty string.
|
|
536
|
+
if (!isFile || !name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
|
|
537
|
+
skippedNames.push(name);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const eventId = name.slice(0, -LOG_SUFFIX.length);
|
|
541
|
+
try {
|
|
542
|
+
// No encoding argument: bytes, exactly as they sit on disk. Copied out of
|
|
543
|
+
// Buffer's shared pool so the array handed to the verifier owns its own
|
|
544
|
+
// memory rather than a window onto a reused block.
|
|
545
|
+
content.set(eventId, new Uint8Array(await readFile(p)));
|
|
546
|
+
} catch (e) {
|
|
547
|
+
io.err(`cannot read --logs file ${p}: ${errMsg(e)}`);
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
suppliedIds.push(eventId);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const notes: string[] = [];
|
|
554
|
+
if (skippedDirs.length > 0) {
|
|
555
|
+
notes.push(
|
|
556
|
+
`--logs ${dirPath}: skipped ${count(skippedDirs.length, "subdirectory", "subdirectories")} ` +
|
|
557
|
+
`(${sample(skippedDirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
|
|
558
|
+
`and does not recurse`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (skippedNames.length > 0) {
|
|
562
|
+
notes.push(
|
|
563
|
+
`--logs ${dirPath}: skipped ${count(skippedNames.length, "entry", "entries")} ` +
|
|
564
|
+
`not named <event_id>${LOG_SUFFIX} (${sample(skippedNames)})`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (suppliedIds.length === 0) {
|
|
568
|
+
notes.push(
|
|
569
|
+
`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files) — every event ` +
|
|
570
|
+
`stays undisclosed, which is undetermined, not failed`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return { content, suppliedIds, notes };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// What the side input covered, reported once the bundle's own event ids are
|
|
577
|
+
// known. Both directions are ADVISORY — neither moves the verdict or the exit
|
|
578
|
+
// code — and both exist because the silent version of each has already cost
|
|
579
|
+
// somebody an afternoon:
|
|
580
|
+
//
|
|
581
|
+
// - an entry matching no event binds to nothing. A typo'd id, or a directory
|
|
582
|
+
// scoped to a different bundle, otherwise looks identical to minimal
|
|
583
|
+
// disclosure.
|
|
584
|
+
// - an event whose content stayed undetermined disclosed no usable bytes.
|
|
585
|
+
// That is UNDISCLOSED, never a failure (evidence-bundle.md §5.1): absence
|
|
586
|
+
// of a disclosure is not evidence of tampering.
|
|
587
|
+
function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io: Cli): void {
|
|
588
|
+
const eventIds = new Set(result.events.map((e) => e.eventId));
|
|
589
|
+
const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id));
|
|
590
|
+
if (unmatched.length > 0) {
|
|
591
|
+
io.err(
|
|
592
|
+
`note: --logs supplied ${count(unmatched.length, "entry", "entries")} matching no event ` +
|
|
593
|
+
`in the bundle, ignored: ${sample(unmatched)}`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const undetermined = result.events.filter((e) => e.contentOk === null);
|
|
597
|
+
if (undetermined.length > 0) {
|
|
598
|
+
io.err(
|
|
599
|
+
`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
|
|
600
|
+
`content — no bytes disclosed, or no committed content_hash to bind them to. ` +
|
|
601
|
+
`Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function errMsg(e: unknown): string {
|
|
607
|
+
return e instanceof Error ? e.message : String(e);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function count(n: number, one: string, many: string): string {
|
|
611
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// First few names, quoted, with a tail count — enough to recognise the mistake
|
|
615
|
+
// without pasting a whole directory into the terminal.
|
|
616
|
+
function sample(names: string[], max = 3): string {
|
|
617
|
+
const shown = names.slice(0, max).map((n) => JSON.stringify(n)).join(", ");
|
|
618
|
+
return names.length > max ? `${shown}, +${names.length - max} more` : shown;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// The --logs MAP lane decides each disclosed value's encoding: a clean,
|
|
390
622
|
// even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
|
|
391
623
|
// (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
|
|
392
|
-
// raw bytes" — the human-facing encoding choice is made here.
|
|
624
|
+
// raw bytes" — the human-facing encoding choice is made here. The DIRECTORY
|
|
625
|
+
// lane never reaches this: its bytes are already bytes.)
|
|
393
626
|
function decodeLogValue(v: string): Uint8Array | string {
|
|
394
627
|
if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v)) return v; // hex passthrough
|
|
395
628
|
return utf8(v); // raw utf8 bytes
|
|
@@ -399,18 +632,29 @@ function printUsage(io: Cli): void {
|
|
|
399
632
|
io.out("Usage: npx @sanning/proof <command> [args]");
|
|
400
633
|
io.out("");
|
|
401
634
|
io.out("Commands:");
|
|
402
|
-
io.out(" verify <bundle.json> [gateways] [--logs <
|
|
403
|
-
io.out("
|
|
635
|
+
io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
|
|
636
|
+
io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
|
|
404
637
|
io.out("");
|
|
405
638
|
io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
|
|
406
639
|
io.out("sanning.evidence.export/v1 attested export, or a sanning.agent.proof/v1");
|
|
407
640
|
io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
|
|
408
641
|
io.out("to also re-fetch each checkpoint on-chain.");
|
|
409
642
|
io.out("");
|
|
410
|
-
io.out("--logs <
|
|
411
|
-
io.out(" content_hash.
|
|
412
|
-
io.out("
|
|
413
|
-
io.out("
|
|
643
|
+
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
644
|
+
io.out(" content_hash. Either shape works, and which one you");
|
|
645
|
+
io.out(" passed is decided by stat(), not by the extension:");
|
|
646
|
+
io.out("");
|
|
647
|
+
io.out(" a DIRECTORY of <event_id>.json files — the file's");
|
|
648
|
+
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
649
|
+
io.out(" This is a materialized pack's logs/ directory:");
|
|
650
|
+
io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
651
|
+
io.out("");
|
|
652
|
+
io.out(" a JSON FILE mapping event_id → bytes (an");
|
|
653
|
+
io.out(" even-length lowercase-hex value ⇒ hex, else utf8).");
|
|
654
|
+
io.out("");
|
|
655
|
+
io.out(" In-body events[].content is verified automatically");
|
|
656
|
+
io.out(" and takes precedence. An event with no disclosed");
|
|
657
|
+
io.out(" bytes is undetermined (~), never a failure.");
|
|
414
658
|
io.out("");
|
|
415
659
|
io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
|
|
416
660
|
}
|
|
@@ -439,7 +683,7 @@ function reportEvidence(
|
|
|
439
683
|
for (const cp of result.checkpoints) {
|
|
440
684
|
const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
|
|
441
685
|
io.out(
|
|
442
|
-
` ${mark(cp.ok)} ${shortTx(cp.
|
|
686
|
+
` ${mark(cp.ok)} ${shortTx(cp.checkpointRef)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`,
|
|
443
687
|
);
|
|
444
688
|
for (const e of cp.errors) io.out(paint(` - ${e}`, R));
|
|
445
689
|
}
|
package/src/compose.ts
CHANGED
|
@@ -94,7 +94,7 @@ export function attestationSignatureToHex(signature: string): string {
|
|
|
94
94
|
|
|
95
95
|
// Normalize one attestation record for embedding: deep-clone (never mutate the
|
|
96
96
|
// caller's input) and transcode its signature to lowercase hex. Everything else
|
|
97
|
-
// — payload, public_key, signature_alg,
|
|
97
|
+
// — payload, public_key, signature_alg, checkpoint_ref — rides verbatim so the
|
|
98
98
|
// operator's RSA-PSS signature over JCS(payload) remains valid.
|
|
99
99
|
function normalizeAttestation(rec: AttestationRecord): AttestationRecord {
|
|
100
100
|
const cloned = JSON.parse(JSON.stringify(rec)) as AttestationRecord;
|