@sanning/proof 0.15.0 → 0.17.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 +49 -4
- package/dist/agent-proof.js +50 -23
- package/dist/case-file.d.ts +24 -4
- package/dist/case-file.js +62 -5
- package/dist/case.d.ts +104 -8
- package/dist/case.js +195 -53
- package/dist/cli.d.ts +105 -2
- package/dist/cli.js +664 -123
- package/dist/compose.js +32 -0
- package/dist/declaration.d.ts +1 -1
- package/dist/declaration.js +182 -3
- package/dist/evidence.js +108 -38
- package/dist/file-match.d.ts +64 -5
- package/dist/file-match.js +113 -22
- package/dist/grading.d.ts +7 -0
- package/dist/grading.js +14 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/merkle.js +13 -7
- package/dist/verifier.js +24 -19
- package/dist/zip-reader.d.ts +7 -0
- package/dist/zip-reader.js +7 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +53 -27
- package/src/case-file.ts +61 -8
- package/src/case.ts +293 -64
- package/src/cli.ts +703 -132
- package/src/compose.ts +33 -0
- package/src/declaration.ts +186 -3
- package/src/evidence.ts +109 -43
- package/src/file-match.ts +160 -26
- package/src/grading.ts +15 -2
- package/src/index.ts +6 -0
- package/src/merkle.ts +13 -7
- package/src/verifier.ts +26 -22
- package/src/zip-reader.ts +7 -0
- package/vectors/README.md +5 -1
- package/vectors/case-file-index.json +88 -2
- package/vectors/file-match.json +135 -1
package/src/agent-proof.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// 5. (optional) Re-fetch checkpoint_tx_id from a gateway and byte-compare.
|
|
16
16
|
|
|
17
17
|
import { hexToBytes, utf8 } from "./crypto.js";
|
|
18
|
+
import { safeInteger } from "./grading.js";
|
|
18
19
|
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
19
20
|
import { MAX_CANONICAL_DEPTH, exceedsDepth, jcs, jcsParse, verifyEnvelope } from "./verifier.js";
|
|
20
21
|
import type { Envelope } from "./types.js";
|
|
@@ -51,9 +52,8 @@ export interface VerifyAgentProofOptions {
|
|
|
51
52
|
fetchImpl?: typeof fetch;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
55
|
+
/** The sentence `agent_proof.py` writes when the proof's own fields are not usable. */
|
|
56
|
+
const MALFORMED_PROOF = "merkle_root / audit_path / leaf_index malformed";
|
|
57
57
|
|
|
58
58
|
function toHex(bytes: Uint8Array): string {
|
|
59
59
|
let out = "";
|
|
@@ -83,28 +83,30 @@ export async function verifyAgentProof(
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
86
|
-
return fail("
|
|
86
|
+
return fail("bundle is not a JSON object");
|
|
87
87
|
}
|
|
88
88
|
// Bound nesting before any canonicalization (checkpoint envelope + Merkle
|
|
89
89
|
// leaves are canonicalized recursively) — a fixed shared limit keeps this a
|
|
90
90
|
// `malformed` verdict in every kernel. See MAX_CANONICAL_DEPTH.
|
|
91
91
|
if (exceedsDepth(bundle, MAX_CANONICAL_DEPTH)) {
|
|
92
92
|
return fail(
|
|
93
|
-
`
|
|
93
|
+
`bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`,
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
const b = bundle as AgentProofBundle;
|
|
97
97
|
if (!isAgentProofSpec(b.spec_version)) {
|
|
98
|
-
|
|
98
|
+
// An absent one reads `null`, as in `agent_proof.py` (`dict.get` gives
|
|
99
|
+
// None); `JSON.stringify(undefined)` would write the word `undefined`.
|
|
100
|
+
return fail(`unsupported bundle spec_version: ${JSON.stringify(b.spec_version ?? null)}`);
|
|
99
101
|
}
|
|
100
102
|
if (b.checkpoint_envelope === null || typeof b.checkpoint_envelope !== "object") {
|
|
101
|
-
return fail("
|
|
103
|
+
return fail("bundle missing checkpoint_envelope");
|
|
102
104
|
}
|
|
103
105
|
if (b.leaf === null || typeof b.leaf !== "object") {
|
|
104
|
-
return fail("
|
|
106
|
+
return fail("bundle missing leaf");
|
|
105
107
|
}
|
|
106
108
|
if (!Array.isArray(b.audit_path)) {
|
|
107
|
-
return fail(
|
|
109
|
+
return fail(MALFORMED_PROOF);
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
const errors: string[] = [];
|
|
@@ -126,28 +128,50 @@ export async function verifyAgentProof(
|
|
|
126
128
|
leaf_count?: unknown;
|
|
127
129
|
};
|
|
128
130
|
if (typeof payload.merkle_root === "string") merkleRoot = payload.merkle_root;
|
|
129
|
-
|
|
131
|
+
// `leaf_count` and `leaf_index` are read BY VALUE, the rule every
|
|
132
|
+
// inclusion reader in both kernels follows (evidence-bundle A38): `3.0` is
|
|
133
|
+
// 3, and a fraction, a value past 2**53 - 1, a string or a boolean is not
|
|
134
|
+
// an integer. `agent_proof.py` reads them through `_safe_integer`.
|
|
135
|
+
leafCount = safeInteger(payload.leaf_count) ?? undefined;
|
|
130
136
|
if (merkleRoot === undefined) errors.push("checkpoint payload missing merkle_root");
|
|
131
|
-
if (leafCount === undefined) errors.push("checkpoint payload
|
|
132
|
-
} catch
|
|
133
|
-
|
|
137
|
+
if (leafCount === undefined) errors.push("checkpoint payload leaf_count is not an integer");
|
|
138
|
+
} catch {
|
|
139
|
+
// The sentence `agent_proof.py` writes for the same failure. The thrown
|
|
140
|
+
// text is the host's, and a public result carries the kernel's words only
|
|
141
|
+
// (proof#150).
|
|
142
|
+
errors.push("checkpoint envelope verification failed");
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
// Steps 2-4: leaf hash, audit-path walk, root match.
|
|
137
146
|
let inclusionOk = false;
|
|
138
147
|
if (merkleRoot !== undefined && leafCount !== undefined) {
|
|
148
|
+
let lh: Uint8Array | undefined;
|
|
139
149
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
lh = await leafHash(utf8(jcs(b.leaf)));
|
|
151
|
+
} catch {
|
|
152
|
+
errors.push("leaf is not JCS-canonicalizable");
|
|
153
|
+
}
|
|
154
|
+
const leafIndex = safeInteger(b.leaf_index);
|
|
155
|
+
if (lh !== undefined && leafIndex === null) {
|
|
156
|
+
// `agent_proof.py` refuses a leaf_index that is not an integer with this
|
|
157
|
+
// sentence. Without the check a string, `null` or `1.5` reaches the walk
|
|
158
|
+
// and reads as a failed inclusion proof instead (proof#150).
|
|
159
|
+
errors.push(MALFORMED_PROOF);
|
|
160
|
+
} else if (lh !== undefined) {
|
|
161
|
+
try {
|
|
162
|
+
inclusionOk = await verifyInclusion(
|
|
163
|
+
lh,
|
|
164
|
+
leafIndex as number,
|
|
165
|
+
leafCount,
|
|
166
|
+
b.audit_path.map((h) => hexToBytes(h)),
|
|
167
|
+
hexToBytes(merkleRoot),
|
|
168
|
+
);
|
|
169
|
+
if (!inclusionOk) errors.push("audit_path does not reconstruct merkle_root");
|
|
170
|
+
} catch {
|
|
171
|
+
// `agent_proof.py`'s sentence for the same failure; the thrown text is
|
|
172
|
+
// the host's (proof#150).
|
|
173
|
+
errors.push(MALFORMED_PROOF);
|
|
174
|
+
}
|
|
151
175
|
}
|
|
152
176
|
}
|
|
153
177
|
|
|
@@ -184,7 +208,9 @@ export async function verifyAgentProof(
|
|
|
184
208
|
inclusionOk,
|
|
185
209
|
onChainOk,
|
|
186
210
|
onChainChecked,
|
|
187
|
-
|
|
211
|
+
// Read by value, as the walk above read it: a `2.5` or `1e+21` index is
|
|
212
|
+
// reported as no index, never printed beside a verdict that refused it.
|
|
213
|
+
leafIndex: safeInteger(b.leaf_index) ?? -1,
|
|
188
214
|
leafCount: leafCount ?? null,
|
|
189
215
|
checkpointTxId: typeof b.checkpoint_tx_id === "string" ? b.checkpoint_tx_id : null,
|
|
190
216
|
errors,
|
|
@@ -200,8 +226,8 @@ async function refetch(
|
|
|
200
226
|
let expectedHex: string;
|
|
201
227
|
try {
|
|
202
228
|
expectedHex = toHex(utf8(jcs(envelope)));
|
|
203
|
-
} catch
|
|
204
|
-
return { ok: false, unavailable: false, error:
|
|
229
|
+
} catch {
|
|
230
|
+
return { ok: false, unavailable: false, error: "checkpoint envelope is not JCS-canonicalizable" };
|
|
205
231
|
}
|
|
206
232
|
let anyReachable = false;
|
|
207
233
|
for (const gw of gateways) {
|
package/src/case-file.ts
CHANGED
|
@@ -58,6 +58,7 @@ export const CASE_FILE_FINDING_KINDS = [
|
|
|
58
58
|
"case_index_file_missing",
|
|
59
59
|
"case_index_file_unlisted",
|
|
60
60
|
"case_index_file_differs",
|
|
61
|
+
"case_index_file_too_large",
|
|
61
62
|
"case_index_names_absent",
|
|
62
63
|
] as const;
|
|
63
64
|
export type CaseFileFindingKind = (typeof CASE_FILE_FINDING_KINDS)[number];
|
|
@@ -275,17 +276,56 @@ function readIndex(raw: Uint8Array): ReadIndex {
|
|
|
275
276
|
return { caseId, packs: listedPacks, absent: listedAbsent, report };
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
/**
|
|
280
|
+
* The reading of an index the caller could not hold at all (it is over the
|
|
281
|
+
* run's whole-read total, or it cannot be read): one `case_index_unreadable`
|
|
282
|
+
* finding with the caller's `reason`, and no comparison. The index is never an
|
|
283
|
+
* input to the verdict, so a file that cannot be read keeps nothing from it.
|
|
284
|
+
*/
|
|
285
|
+
export function unreadableCaseFileIndex(reason: string): CaseFileIndexReading {
|
|
286
|
+
return {
|
|
287
|
+
index: "unreadable",
|
|
288
|
+
caseId: null,
|
|
289
|
+
findings: [{ kind: "case_index_unreadable", reason }],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Every path the index lists (each pack's files and `report`), or `null` where
|
|
295
|
+
* the index is unreadable. A caller that charges a budget for hashing charges
|
|
296
|
+
* these files first, so a file the index does not list, which needs no digest,
|
|
297
|
+
* cannot turn a real mismatch into `too_large`.
|
|
298
|
+
*/
|
|
299
|
+
export function caseFileListedPaths(index: Uint8Array): ReadonlySet<string> | null {
|
|
300
|
+
let read: ReadIndex;
|
|
301
|
+
try {
|
|
302
|
+
read = readIndex(index);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
if (!(e instanceof Unreadable)) throw e;
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const paths = new Set<string>();
|
|
308
|
+
for (const [, packFiles] of read.packs) for (const [path] of packFiles) paths.add(path);
|
|
309
|
+
for (const [path] of read.report) paths.add(path);
|
|
310
|
+
return paths;
|
|
311
|
+
}
|
|
312
|
+
|
|
278
313
|
/**
|
|
279
314
|
* Compare a case file's index with the folder it sits at the root of.
|
|
280
315
|
*
|
|
281
316
|
* `index` is the bytes of `contents.json`, or `null` where the folder holds
|
|
282
317
|
* none. `files` is every regular file in the folder, as `{ path, sha256 }`
|
|
283
|
-
* with `path` from the folder's root, `/`-separated
|
|
318
|
+
* with `path` from the folder's root, `/`-separated, or `{ path, too_large: true }`
|
|
319
|
+
* for a file the caller did not hash because it is over its cap (reported as
|
|
320
|
+
* `case_index_file_too_large` where the index lists it, never as a match or as
|
|
321
|
+
* absent); only the files under
|
|
284
322
|
* `packs/` and `report/` are compared, so a caller may list the rest or not. A
|
|
285
323
|
* folder with no index is a folder of packs, with no finding.
|
|
286
324
|
*
|
|
287
325
|
* Findings come in the order of `CASE_FILE_FINDING_KINDS`, and within a kind in
|
|
288
|
-
* the order of their `ref` or `path` by UTF-8 bytes
|
|
326
|
+
* the order of their `ref` or `path` by UTF-8 bytes (the order a caller charges
|
|
327
|
+
* its hashing budget in is another, UTF-16 code units: evidence-bundle 5.6.3);
|
|
328
|
+
* `case_index_names_absent`
|
|
289
329
|
* is in the index's own order, one per `absent[]` entry. An unreadable index is
|
|
290
330
|
* one finding and no other comparison runs.
|
|
291
331
|
*
|
|
@@ -293,7 +333,7 @@ function readIndex(raw: Uint8Array): ReadIndex {
|
|
|
293
333
|
*/
|
|
294
334
|
export function readCaseFileIndex(
|
|
295
335
|
index: Uint8Array | null,
|
|
296
|
-
files: readonly { path: string; sha256
|
|
336
|
+
files: readonly { path: string; sha256?: string; too_large?: boolean }[],
|
|
297
337
|
): CaseFileIndexReading {
|
|
298
338
|
if (index === null || index === undefined) {
|
|
299
339
|
return { index: "absent", caseId: null, findings: [] };
|
|
@@ -310,13 +350,17 @@ export function readCaseFileIndex(
|
|
|
310
350
|
};
|
|
311
351
|
}
|
|
312
352
|
|
|
313
|
-
|
|
353
|
+
// `null` is a file that is here and was too large to hash (a file entry of
|
|
354
|
+
// `{ path, too_large: true }`): neither a match nor absent, reported as
|
|
355
|
+
// neither.
|
|
356
|
+
const here = new Map<string, string | null>();
|
|
314
357
|
for (const entry of files) {
|
|
315
358
|
const path = isObject(entry) ? own(entry, "path") : undefined;
|
|
316
359
|
const digest = isObject(entry) ? own(entry, "sha256") : undefined;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
360
|
+
const tooLarge = isObject(entry) && own(entry, "too_large") === true;
|
|
361
|
+
if (typeof path !== "string" || here.has(path)) continue;
|
|
362
|
+
if (typeof digest === "string") here.set(path, digest);
|
|
363
|
+
else if (tooLarge) here.set(path, null);
|
|
320
364
|
}
|
|
321
365
|
|
|
322
366
|
const listedRefs = new Set(read.packs.map(([ref]) => ref));
|
|
@@ -365,7 +409,7 @@ export function readCaseFileIndex(
|
|
|
365
409
|
|
|
366
410
|
for (const path of listedPaths) {
|
|
367
411
|
const actual = here.get(path);
|
|
368
|
-
if (actual !== undefined && actual !== listed.get(path)) {
|
|
412
|
+
if (actual !== undefined && actual !== null && actual !== listed.get(path)) {
|
|
369
413
|
findings.push({
|
|
370
414
|
kind: "case_index_file_differs",
|
|
371
415
|
path,
|
|
@@ -375,6 +419,10 @@ export function readCaseFileIndex(
|
|
|
375
419
|
}
|
|
376
420
|
}
|
|
377
421
|
|
|
422
|
+
for (const path of listedPaths) {
|
|
423
|
+
if (here.get(path) === null) findings.push({ kind: "case_index_file_too_large", path });
|
|
424
|
+
}
|
|
425
|
+
|
|
378
426
|
for (const [reason, key] of read.absent) {
|
|
379
427
|
findings.push(
|
|
380
428
|
key === null
|
|
@@ -408,6 +456,11 @@ export function renderCaseFileFinding(finding: CaseFileFinding): string {
|
|
|
408
456
|
`the index lists ${finding.path} with SHA-256 ${finding.listedSha256}; ` +
|
|
409
457
|
`the file here hashes to ${finding.sha256}`
|
|
410
458
|
);
|
|
459
|
+
case "case_index_file_too_large":
|
|
460
|
+
return (
|
|
461
|
+
`the index lists ${finding.path}; hashing the file here would take case ` +
|
|
462
|
+
"past the 1 GiB it hashes in one run, so it was not compared"
|
|
463
|
+
);
|
|
411
464
|
case "case_index_names_absent":
|
|
412
465
|
if (finding.publicKey !== undefined) {
|
|
413
466
|
return (
|