@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.
@@ -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
- function stringifyErr(e: unknown): string {
55
- return e instanceof Error ? e.message : String(e);
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("agent proof bundle is not a JSON object");
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
- `agent proof bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`,
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
- return fail(`unsupported agent-proof spec_version: ${JSON.stringify(b.spec_version)}`);
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("agent proof bundle missing checkpoint_envelope");
103
+ return fail("bundle missing checkpoint_envelope");
102
104
  }
103
105
  if (b.leaf === null || typeof b.leaf !== "object") {
104
- return fail("agent proof bundle missing leaf");
106
+ return fail("bundle missing leaf");
105
107
  }
106
108
  if (!Array.isArray(b.audit_path)) {
107
- return fail("agent proof bundle missing audit_path[]");
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
- if (typeof payload.leaf_count === "number") leafCount = payload.leaf_count;
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 missing leaf_count");
132
- } catch (e) {
133
- errors.push(`checkpoint verify error: ${stringifyErr(e)}`);
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
- const lh = await leafHash(utf8(jcs(b.leaf)));
141
- inclusionOk = await verifyInclusion(
142
- lh,
143
- b.leaf_index,
144
- leafCount,
145
- b.audit_path.map((h) => hexToBytes(h)),
146
- hexToBytes(merkleRoot),
147
- );
148
- if (!inclusionOk) errors.push("RFC 9162 inclusion proof did not reconstruct merkle_root");
149
- } catch (e) {
150
- errors.push(`inclusion verify error: ${stringifyErr(e)}`);
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
- leafIndex: typeof b.leaf_index === "number" ? b.leaf_index : -1,
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 (e) {
204
- return { ok: false, unavailable: false, error: `cannot canonicalize checkpoint envelope: ${stringifyErr(e)}` };
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; only the files under
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; `case_index_names_absent`
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: string }[],
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
- const here = new Map<string, string>();
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
- if (typeof path === "string" && typeof digest === "string" && !here.has(path)) {
318
- here.set(path, digest);
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 (