@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/file-match.ts
CHANGED
|
@@ -70,6 +70,30 @@ export const NOT_READ_REASONS = [
|
|
|
70
70
|
|
|
71
71
|
export type NotReadReason = (typeof NOT_READ_REASONS)[number];
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Why a file was named in the report and never hashed (5.7.5). A skipped file
|
|
75
|
+
* is listed, never dropped: every file a command found is matched, unmatched or
|
|
76
|
+
* skipped, so "no match" is never read as "no file was there".
|
|
77
|
+
*/
|
|
78
|
+
export const SKIPPED_REASONS = [
|
|
79
|
+
"symbolic_link",
|
|
80
|
+
"not_regular",
|
|
81
|
+
"unreadable",
|
|
82
|
+
"too_large",
|
|
83
|
+
"refused",
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
86
|
+
export type SkippedReason = (typeof SKIPPED_REASONS)[number];
|
|
87
|
+
|
|
88
|
+
const SKIPPED_TEXT: Record<SkippedReason, string> = {
|
|
89
|
+
symbolic_link: "a symbolic link: not followed, not read",
|
|
90
|
+
not_regular: "not a regular file: not read",
|
|
91
|
+
unreadable: "could not be read",
|
|
92
|
+
too_large: "past the 1 GiB read from folders in one run: not read",
|
|
93
|
+
// Followed by the reader's own words, `renderSkipped`.
|
|
94
|
+
refused: "a .zip entry the reader refused",
|
|
95
|
+
};
|
|
96
|
+
|
|
73
97
|
const REASON_TEXT: Record<NotReadReason, string> = {
|
|
74
98
|
no_records: "this bundle carries no records this reading can read",
|
|
75
99
|
record_unreadable: "its record bytes cannot be read",
|
|
@@ -94,6 +118,14 @@ export interface FileCommitment {
|
|
|
94
118
|
contentHash: string;
|
|
95
119
|
signer: string;
|
|
96
120
|
signedAt: string | null;
|
|
121
|
+
/**
|
|
122
|
+
* The pack, by its position in the packs `matchCaseFiles` was handed (the
|
|
123
|
+
* same position the case reading's `packIndex` names), or `null` from
|
|
124
|
+
* `matchFiles`, which reads one pack (A43).
|
|
125
|
+
*/
|
|
126
|
+
packIndex: number | null;
|
|
127
|
+
/** The record, by its position in the events the pack presents (A43). */
|
|
128
|
+
recordIndex: number;
|
|
97
129
|
}
|
|
98
130
|
|
|
99
131
|
export interface MatchedFile {
|
|
@@ -107,10 +139,25 @@ export interface UnmatchedFile {
|
|
|
107
139
|
sha256: string;
|
|
108
140
|
}
|
|
109
141
|
|
|
142
|
+
/** A file a command found and did not hash, named with the reason. */
|
|
143
|
+
export interface SkippedFile {
|
|
144
|
+
name: string;
|
|
145
|
+
reason: SkippedReason;
|
|
146
|
+
/** The reader's own words, for a `.zip` entry it refused (`refused`). */
|
|
147
|
+
detail?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
110
150
|
export interface NotRead {
|
|
111
151
|
packRef: string | null;
|
|
112
152
|
eventId: string | null;
|
|
113
153
|
reason: NotReadReason;
|
|
154
|
+
/** As on `FileCommitment` (A43). */
|
|
155
|
+
packIndex: number | null;
|
|
156
|
+
/**
|
|
157
|
+
* As on `FileCommitment`, or `null` where the pack presents no events at all
|
|
158
|
+
* (`no_records`).
|
|
159
|
+
*/
|
|
160
|
+
recordIndex: number | null;
|
|
114
161
|
}
|
|
115
162
|
|
|
116
163
|
export interface FileMatchReading {
|
|
@@ -120,13 +167,22 @@ export interface FileMatchReading {
|
|
|
120
167
|
notOffered: FileCommitment[];
|
|
121
168
|
notRead: NotRead[];
|
|
122
169
|
scope: string;
|
|
170
|
+
/**
|
|
171
|
+
* Files found and not hashed. The matching functions leave it empty: it is
|
|
172
|
+
* the caller's, because only a caller that reads a folder knows a link.
|
|
173
|
+
*/
|
|
174
|
+
skipped?: SkippedFile[];
|
|
123
175
|
}
|
|
124
176
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
177
|
+
/**
|
|
178
|
+
* A file handed in beside a pack. `name` is a label and is never compared. The
|
|
179
|
+
* file is its `bytes`, or its `sha256` (64 lowercase hex characters) where the
|
|
180
|
+
* caller has already hashed it: a command streams a file of any size rather
|
|
181
|
+
* than hold it.
|
|
182
|
+
*/
|
|
183
|
+
export type OfferedFile =
|
|
184
|
+
| { name: string; bytes: Uint8Array; sha256?: undefined }
|
|
185
|
+
| { name: string; sha256: string; bytes?: undefined };
|
|
130
186
|
|
|
131
187
|
/** One pack of a case, for `matchCaseFiles`. */
|
|
132
188
|
export interface FileMatchPack {
|
|
@@ -197,17 +253,18 @@ async function readPack(
|
|
|
197
253
|
bundle: unknown,
|
|
198
254
|
content: SideContentMap | null | undefined,
|
|
199
255
|
packRef: string | null,
|
|
256
|
+
packIndex: number | null,
|
|
200
257
|
): Promise<{ commitments: FileCommitment[]; notRead: NotRead[] }> {
|
|
201
258
|
const commitments: FileCommitment[] = [];
|
|
202
259
|
const notRead: NotRead[] = [];
|
|
203
260
|
const events = resolveTraceEvents(bundle) as unknown[] | undefined;
|
|
204
261
|
if (events === undefined) {
|
|
205
|
-
notRead.push({ packRef, eventId: null, reason: "no_records" });
|
|
262
|
+
notRead.push({ packRef, eventId: null, reason: "no_records", packIndex, recordIndex: null });
|
|
206
263
|
return { commitments, notRead };
|
|
207
264
|
}
|
|
208
|
-
for (const ev of events) {
|
|
265
|
+
for (const [recordIndex, ev] of events.entries()) {
|
|
209
266
|
if (!isObject(ev)) {
|
|
210
|
-
notRead.push({ packRef, eventId: null, reason: "record_unreadable" });
|
|
267
|
+
notRead.push({ packRef, eventId: null, reason: "record_unreadable", packIndex, recordIndex });
|
|
211
268
|
continue;
|
|
212
269
|
}
|
|
213
270
|
const envelope = own(ev, "envelope");
|
|
@@ -218,7 +275,7 @@ async function readPack(
|
|
|
218
275
|
if (rawBytes !== undefined && rawBytes !== null) {
|
|
219
276
|
eventBytes = typeof rawBytes === "string" ? decode(rawBytes) : undefined;
|
|
220
277
|
if (eventBytes === undefined) {
|
|
221
|
-
notRead.push({ packRef, eventId, reason: "record_unreadable" });
|
|
278
|
+
notRead.push({ packRef, eventId, reason: "record_unreadable", packIndex, recordIndex });
|
|
222
279
|
continue;
|
|
223
280
|
}
|
|
224
281
|
}
|
|
@@ -228,15 +285,15 @@ async function readPack(
|
|
|
228
285
|
acceptedSpecMajors: RECORD_PROFILES,
|
|
229
286
|
});
|
|
230
287
|
if (!result.ok || !isObject(envelope) || !envelopeSpelled(envelope)) {
|
|
231
|
-
notRead.push({ packRef, eventId, reason: "record_not_verified" });
|
|
288
|
+
notRead.push({ packRef, eventId, reason: "record_not_verified", packIndex, recordIndex });
|
|
232
289
|
continue;
|
|
233
290
|
}
|
|
234
291
|
if (eventBytes === undefined) {
|
|
235
|
-
notRead.push({ packRef, eventId, reason: "record_withheld" });
|
|
292
|
+
notRead.push({ packRef, eventId, reason: "record_withheld", packIndex, recordIndex });
|
|
236
293
|
continue;
|
|
237
294
|
}
|
|
238
295
|
if (result.payloadHashOk !== true) {
|
|
239
|
-
notRead.push({ packRef, eventId, reason: "record_not_verified" });
|
|
296
|
+
notRead.push({ packRef, eventId, reason: "record_not_verified", packIndex, recordIndex });
|
|
240
297
|
continue;
|
|
241
298
|
}
|
|
242
299
|
let record: unknown;
|
|
@@ -246,7 +303,7 @@ async function readPack(
|
|
|
246
303
|
record = undefined;
|
|
247
304
|
}
|
|
248
305
|
if (!isObject(record)) {
|
|
249
|
-
notRead.push({ packRef, eventId, reason: "record_unreadable" });
|
|
306
|
+
notRead.push({ packRef, eventId, reason: "record_unreadable", packIndex, recordIndex });
|
|
250
307
|
continue;
|
|
251
308
|
}
|
|
252
309
|
|
|
@@ -264,12 +321,12 @@ async function readPack(
|
|
|
264
321
|
if (isHandoffNamespace(held)) {
|
|
265
322
|
const messageHash = own(event, "message_hash");
|
|
266
323
|
if (!isConformantHandoffRecord(held) || !isHexField(messageHash, 64, true)) {
|
|
267
|
-
notRead.push({ packRef, eventId, reason: "handoff_record_malformed" });
|
|
324
|
+
notRead.push({ packRef, eventId, reason: "handoff_record_malformed", packIndex, recordIndex });
|
|
268
325
|
continue;
|
|
269
326
|
}
|
|
270
327
|
const message = await messageOf(ev, eventId, messageHash, content);
|
|
271
328
|
if (message === ABSENT) {
|
|
272
|
-
notRead.push({ packRef, eventId, reason: "message_withheld" });
|
|
329
|
+
notRead.push({ packRef, eventId, reason: "message_withheld", packIndex, recordIndex });
|
|
273
330
|
continue;
|
|
274
331
|
}
|
|
275
332
|
const parts = isObject(message) ? own(message, "parts") : undefined;
|
|
@@ -284,6 +341,8 @@ async function readPack(
|
|
|
284
341
|
contentHash: ((part as { content_hash: string }).content_hash).toLowerCase(),
|
|
285
342
|
signer,
|
|
286
343
|
signedAt,
|
|
344
|
+
packIndex,
|
|
345
|
+
recordIndex,
|
|
287
346
|
});
|
|
288
347
|
});
|
|
289
348
|
}
|
|
@@ -292,7 +351,7 @@ async function readPack(
|
|
|
292
351
|
|
|
293
352
|
const contentHash = own(event, "content_hash");
|
|
294
353
|
if (!isHexField(contentHash, 64)) {
|
|
295
|
-
notRead.push({ packRef, eventId, reason: "no_content_hash" });
|
|
354
|
+
notRead.push({ packRef, eventId, reason: "no_content_hash", packIndex, recordIndex });
|
|
296
355
|
continue;
|
|
297
356
|
}
|
|
298
357
|
commitments.push({
|
|
@@ -303,6 +362,8 @@ async function readPack(
|
|
|
303
362
|
contentHash: contentHash.toLowerCase(),
|
|
304
363
|
signer,
|
|
305
364
|
signedAt,
|
|
365
|
+
packIndex,
|
|
366
|
+
recordIndex,
|
|
306
367
|
});
|
|
307
368
|
}
|
|
308
369
|
return { commitments, notRead };
|
|
@@ -314,8 +375,17 @@ async function offeredDigests(files: readonly OfferedFile[]): Promise<[string, s
|
|
|
314
375
|
const out: [string, string][] = [];
|
|
315
376
|
for (const entry of files) {
|
|
316
377
|
if (!isObject(entry)) throw new TypeError("matchFiles: each file is an object {name, bytes}");
|
|
317
|
-
const { name, bytes } = entry as { name?: unknown; bytes?: unknown };
|
|
378
|
+
const { name, bytes, sha256 } = entry as { name?: unknown; bytes?: unknown; sha256?: unknown };
|
|
318
379
|
if (typeof name !== "string") throw new TypeError("matchFiles: a file's name is a string");
|
|
380
|
+
if (sha256 !== undefined && sha256 !== null) {
|
|
381
|
+
if ((bytes !== undefined && bytes !== null) || !isHexField(sha256, 64, true)) {
|
|
382
|
+
throw new TypeError(
|
|
383
|
+
"matchFiles: a file is {name, bytes} or {name, sha256}, and a sha256 is 64 lowercase hex characters",
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
out.push([name, sha256]);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
319
389
|
if (!(bytes instanceof Uint8Array)) {
|
|
320
390
|
throw new TypeError("matchFiles: a file's bytes are a Uint8Array");
|
|
321
391
|
}
|
|
@@ -352,6 +422,7 @@ async function match(
|
|
|
352
422
|
notOffered: commitments.filter((_, i) => !used.has(i)),
|
|
353
423
|
notRead,
|
|
354
424
|
scope: FILE_MATCH_SCOPE,
|
|
425
|
+
skipped: [],
|
|
355
426
|
};
|
|
356
427
|
}
|
|
357
428
|
|
|
@@ -366,7 +437,7 @@ export async function matchFiles(
|
|
|
366
437
|
files: readonly OfferedFile[],
|
|
367
438
|
content?: SideContentMap | null,
|
|
368
439
|
): Promise<FileMatchReading> {
|
|
369
|
-
const { commitments, notRead } = await readPack(bundle, content, null);
|
|
440
|
+
const { commitments, notRead } = await readPack(bundle, content, null, null);
|
|
370
441
|
return match(commitments, notRead, files);
|
|
371
442
|
}
|
|
372
443
|
|
|
@@ -377,10 +448,10 @@ export async function matchCaseFiles(
|
|
|
377
448
|
): Promise<FileMatchReading> {
|
|
378
449
|
const commitments: FileCommitment[] = [];
|
|
379
450
|
const notRead: NotRead[] = [];
|
|
380
|
-
for (const pack of packs) {
|
|
451
|
+
for (const [packIndex, pack] of packs.entries()) {
|
|
381
452
|
if (!isObject(pack)) throw new TypeError("matchCaseFiles: each pack is an object {ref, bundle}");
|
|
382
453
|
if (typeof pack.ref !== "string") throw new TypeError("matchCaseFiles: a pack's ref is a string");
|
|
383
|
-
const r = await readPack(pack.bundle, pack.content, pack.ref);
|
|
454
|
+
const r = await readPack(pack.bundle, pack.content, pack.ref, packIndex);
|
|
384
455
|
commitments.push(...r.commitments);
|
|
385
456
|
notRead.push(...r.notRead);
|
|
386
457
|
}
|
|
@@ -390,7 +461,9 @@ export async function matchCaseFiles(
|
|
|
390
461
|
function commitmentJson(c: FileCommitment): Record<string, unknown> {
|
|
391
462
|
return {
|
|
392
463
|
pack_ref: c.packRef,
|
|
464
|
+
pack_index: c.packIndex,
|
|
393
465
|
event_id: c.eventId,
|
|
466
|
+
record_index: c.recordIndex,
|
|
394
467
|
kind: c.kind,
|
|
395
468
|
part_index: c.partIndex,
|
|
396
469
|
content_hash: c.contentHash,
|
|
@@ -401,6 +474,7 @@ function commitmentJson(c: FileCommitment): Record<string, unknown> {
|
|
|
401
474
|
|
|
402
475
|
/** The reading as the `files` member of `--json`, identical in both kernels. */
|
|
403
476
|
export function fileMatchToJson(reading: FileMatchReading): Record<string, unknown> {
|
|
477
|
+
const skipped = reading.skipped ?? [];
|
|
404
478
|
return {
|
|
405
479
|
offered: reading.offered,
|
|
406
480
|
matched: reading.matched.map((m) => ({
|
|
@@ -412,14 +486,33 @@ export function fileMatchToJson(reading: FileMatchReading): Record<string, unkno
|
|
|
412
486
|
not_offered: reading.notOffered.map(commitmentJson),
|
|
413
487
|
not_read: reading.notRead.map((n) => ({
|
|
414
488
|
pack_ref: n.packRef,
|
|
489
|
+
pack_index: n.packIndex,
|
|
415
490
|
event_id: n.eventId,
|
|
491
|
+
record_index: n.recordIndex,
|
|
416
492
|
reason: n.reason,
|
|
417
493
|
rendered: renderNotRead(n),
|
|
418
494
|
})),
|
|
495
|
+
// Present only when something was skipped, so a reading with no skipped
|
|
496
|
+
// file is the object it was before a folder could be offered (A40).
|
|
497
|
+
...(skipped.length > 0
|
|
498
|
+
? {
|
|
499
|
+
skipped: skipped.map((k) => ({
|
|
500
|
+
name: k.name,
|
|
501
|
+
reason: k.reason,
|
|
502
|
+
...(k.detail !== undefined ? { detail: k.detail } : {}),
|
|
503
|
+
rendered: renderSkipped(k),
|
|
504
|
+
})),
|
|
505
|
+
}
|
|
506
|
+
: {}),
|
|
419
507
|
scope: reading.scope,
|
|
420
508
|
};
|
|
421
509
|
}
|
|
422
510
|
|
|
511
|
+
function renderSkipped(k: SkippedFile): string {
|
|
512
|
+
if (k.detail !== undefined) return `${k.name} (${SKIPPED_TEXT[k.reason]}: ${k.detail}; not read)`;
|
|
513
|
+
return `${k.name} (${SKIPPED_TEXT[k.reason]})`;
|
|
514
|
+
}
|
|
515
|
+
|
|
423
516
|
function recordName(eventId: string | null): string {
|
|
424
517
|
return eventId !== null ? `record ${eventId}` : "a record with no event_id";
|
|
425
518
|
}
|
|
@@ -440,20 +533,61 @@ function renderNotRead(n: NotRead): string {
|
|
|
440
533
|
return `${recordName(n.eventId)}${where} not read: ${REASON_TEXT[n.reason]}`;
|
|
441
534
|
}
|
|
442
535
|
|
|
443
|
-
/**
|
|
444
|
-
|
|
536
|
+
/**
|
|
537
|
+
* The commitments no offered file matched and no disclosed log answers.
|
|
538
|
+
*
|
|
539
|
+
* `contents` is each pack's side content by `pack_ref`. A record whose own pack
|
|
540
|
+
* discloses bytes that hash to its `content_hash` is accounted for by the pack,
|
|
541
|
+
* and is not returned. Only the text report's count reads this; the reading, and
|
|
542
|
+
* `--json`, still list every commitment no file matched.
|
|
543
|
+
*/
|
|
544
|
+
export async function notAccountedFor(
|
|
545
|
+
reading: FileMatchReading,
|
|
546
|
+
contents: ReadonlyMap<string, SideContentMap | null | undefined>,
|
|
547
|
+
): Promise<FileCommitment[]> {
|
|
548
|
+
const out: FileCommitment[] = [];
|
|
549
|
+
for (const c of reading.notOffered) {
|
|
550
|
+
const held = c.packRef !== null ? contents.get(c.packRef) : undefined;
|
|
551
|
+
const raw =
|
|
552
|
+
c.kind === "record" && held !== undefined && held !== null && c.eventId !== null
|
|
553
|
+
? decode(sideContentEntry(held, c.eventId).value)
|
|
554
|
+
: undefined;
|
|
555
|
+
if (raw !== undefined && (await sha256Hex(raw)) === c.contentHash) continue;
|
|
556
|
+
out.push(c);
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The text report's lines for the reading, identical in both kernels.
|
|
563
|
+
*
|
|
564
|
+
* By default every commitment no file matched is a line. `notAmong` is the case
|
|
565
|
+
* command's report when no `--files` was given: one count line for the recorded
|
|
566
|
+
* files no loose file matched, and no line for each (A41).
|
|
567
|
+
*/
|
|
568
|
+
export function renderFileMatch(reading: FileMatchReading, notAmong?: number): string[] {
|
|
569
|
+
const skipped = reading.skipped ?? [];
|
|
445
570
|
const lines = [
|
|
446
571
|
`files ${reading.offered} offered: ${reading.matched.length} vouched for, ` +
|
|
447
|
-
`${reading.unmatched.length} match no record
|
|
448
|
-
|
|
572
|
+
`${reading.unmatched.length} match no record` +
|
|
573
|
+
(notAmong === undefined ? `; ${reading.notOffered.length} commitment(s) had no file offered` : "") +
|
|
574
|
+
(skipped.length > 0 ? `; ${skipped.length} skipped, not read` : ""),
|
|
449
575
|
];
|
|
450
576
|
for (const m of reading.matched) {
|
|
451
577
|
lines.push(` vouched for ${m.name} sha256 ${m.sha256}`);
|
|
452
578
|
for (const c of m.records) lines.push(` by ${renderCommitment(c)}`);
|
|
453
579
|
}
|
|
454
580
|
for (const u of reading.unmatched) lines.push(` no match ${u.name} sha256 ${u.sha256}`);
|
|
455
|
-
for (const
|
|
456
|
-
|
|
581
|
+
for (const k of skipped) lines.push(` skipped ${renderSkipped(k)}`);
|
|
582
|
+
if (notAmong === undefined) {
|
|
583
|
+
for (const c of reading.notOffered) {
|
|
584
|
+
lines.push(` not offered ${c.contentHash}, ${renderCommitment(c)}`);
|
|
585
|
+
}
|
|
586
|
+
} else if (notAmong > 0) {
|
|
587
|
+
lines.push(
|
|
588
|
+
` ${notAmong} recorded commitment(s) matched no loose file and no log in their own pack ` +
|
|
589
|
+
"(--json lists every commitment no file matched, these included)",
|
|
590
|
+
);
|
|
457
591
|
}
|
|
458
592
|
for (const n of reading.notRead) lines.push(` not read ${renderNotRead(n)}`);
|
|
459
593
|
lines.push(` ${reading.scope}`);
|
package/src/grading.ts
CHANGED
|
@@ -476,6 +476,17 @@ export function accountDisclosure(
|
|
|
476
476
|
return { expected, present, missing, unreadable, extra };
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
/**
|
|
480
|
+
* A JSON number read by its value: an integer within 2**53 - 1, or null.
|
|
481
|
+
* `Number.isSafeInteger`, mirrored by `_js_safe_integer` in
|
|
482
|
+
* `src/sanning_proof/chain.py`, so `2.0` is 2 in both kernels and
|
|
483
|
+
* `9007199254740993`, which this parser rounds, is unreadable in both.
|
|
484
|
+
*/
|
|
485
|
+
export function safeInteger(value: unknown): number | null {
|
|
486
|
+
// `+ 0` folds -0 into 0, which Python's `int(-0.0)` already is.
|
|
487
|
+
return typeof value === "number" && Number.isSafeInteger(value) ? value + 0 : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
479
490
|
/** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
|
|
480
491
|
export function signedLeafCount(checkpoint: { event_bytes?: unknown } | null | undefined): number | null {
|
|
481
492
|
const hex = checkpoint?.event_bytes;
|
|
@@ -490,8 +501,10 @@ export function signedLeafCount(checkpoint: { event_bytes?: unknown } | null | u
|
|
|
490
501
|
const record = jcsParse(hexToBytes(hex)) as {
|
|
491
502
|
event?: { leaf_count?: unknown };
|
|
492
503
|
};
|
|
493
|
-
|
|
494
|
-
|
|
504
|
+
// Read BY VALUE, the model JCS signs: a record spelling `3.0`, `3e0` or
|
|
505
|
+
// `30e-1` commits to 3, and since A38 (proof#197) this count decides a
|
|
506
|
+
// verdict, not only a note. Past 2**53 - 1 it is unreadable, as in Python.
|
|
507
|
+
return safeInteger(record?.event?.leaf_count);
|
|
495
508
|
} catch {
|
|
496
509
|
return null;
|
|
497
510
|
}
|
package/src/index.ts
CHANGED
|
@@ -219,6 +219,7 @@ export {
|
|
|
219
219
|
CASE_EXCEPTION_KINDS,
|
|
220
220
|
CASE_QUARANTINED_PROFILES,
|
|
221
221
|
CASE_RECORD_PROFILES,
|
|
222
|
+
CASE_RECORD_STATUSES,
|
|
222
223
|
CASE_NOTE_KINDS,
|
|
223
224
|
CASE_RELIANCE,
|
|
224
225
|
CASE_RELIANCE_HANDOFF_CLAUSE,
|
|
@@ -285,6 +286,7 @@ export type {
|
|
|
285
286
|
CasePackReading,
|
|
286
287
|
CaseReading,
|
|
287
288
|
CaseRecordReading,
|
|
289
|
+
CaseRecordStatus,
|
|
288
290
|
CaseVerdict,
|
|
289
291
|
WitnessLine,
|
|
290
292
|
WitnessState,
|
|
@@ -316,9 +318,11 @@ export type {
|
|
|
316
318
|
export {
|
|
317
319
|
FILE_MATCH_SCOPE,
|
|
318
320
|
NOT_READ_REASONS,
|
|
321
|
+
SKIPPED_REASONS,
|
|
319
322
|
fileMatchToJson,
|
|
320
323
|
matchCaseFiles,
|
|
321
324
|
matchFiles,
|
|
325
|
+
notAccountedFor,
|
|
322
326
|
renderFileMatch,
|
|
323
327
|
} from "./file-match.js";
|
|
324
328
|
export type {
|
|
@@ -329,6 +333,8 @@ export type {
|
|
|
329
333
|
NotRead,
|
|
330
334
|
NotReadReason,
|
|
331
335
|
OfferedFile,
|
|
336
|
+
SkippedFile,
|
|
337
|
+
SkippedReason,
|
|
332
338
|
UnmatchedFile,
|
|
333
339
|
} from "./file-match.js";
|
|
334
340
|
|
package/src/merkle.ts
CHANGED
|
@@ -78,25 +78,31 @@ export async function verifyInclusion(
|
|
|
78
78
|
}
|
|
79
79
|
if (totalLeaves === 1) return auditPath.length === 0 && bytesEqual(leaf, expectedRoot);
|
|
80
80
|
|
|
81
|
+
// Arithmetic, not bitwise: JavaScript's `&` and `>>=` truncate to 32 bits,
|
|
82
|
+
// so a tree past 2**31 leaves walked a different tree here than in the
|
|
83
|
+
// Python kernel, whose integers are unbounded. `% 2` and a halving floor
|
|
84
|
+
// are exact for every safe integer, and the stated index and count reach
|
|
85
|
+
// this walk as safe integers.
|
|
81
86
|
let fn = leafIndex;
|
|
82
87
|
let sn = totalLeaves - 1;
|
|
83
88
|
let r = leaf;
|
|
89
|
+
const half = (v: number): number => Math.floor(v / 2);
|
|
84
90
|
|
|
85
91
|
for (const p of auditPath) {
|
|
86
92
|
if (sn === 0) return false; // path longer than the tree depth — malformed
|
|
87
|
-
if (
|
|
93
|
+
if (fn % 2 === 1 || fn === sn) {
|
|
88
94
|
r = await nodeHash(p, r);
|
|
89
|
-
if (
|
|
90
|
-
while (
|
|
91
|
-
fn
|
|
92
|
-
sn
|
|
95
|
+
if (fn % 2 === 0) {
|
|
96
|
+
while (fn % 2 === 0 && fn !== 0) {
|
|
97
|
+
fn = half(fn);
|
|
98
|
+
sn = half(sn);
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
101
|
} else {
|
|
96
102
|
r = await nodeHash(r, p);
|
|
97
103
|
}
|
|
98
|
-
fn
|
|
99
|
-
sn
|
|
104
|
+
fn = half(fn);
|
|
105
|
+
sn = half(sn);
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
return sn === 0 && bytesEqual(r, expectedRoot);
|
package/src/verifier.ts
CHANGED
|
@@ -708,7 +708,7 @@ export async function verifyEnvelope(
|
|
|
708
708
|
// exotic object.
|
|
709
709
|
try {
|
|
710
710
|
return await verifyEnvelopeChecked(env, opts);
|
|
711
|
-
} catch
|
|
711
|
+
} catch {
|
|
712
712
|
return {
|
|
713
713
|
ok: false,
|
|
714
714
|
specVersionOk: false,
|
|
@@ -718,7 +718,7 @@ export async function verifyEnvelope(
|
|
|
718
718
|
signatureOk: false,
|
|
719
719
|
contentHashOk: opts.expectedContentHash === undefined ? null : false,
|
|
720
720
|
contentRole: null,
|
|
721
|
-
errors: [
|
|
721
|
+
errors: ["envelope could not be read"],
|
|
722
722
|
};
|
|
723
723
|
}
|
|
724
724
|
}
|
|
@@ -761,7 +761,15 @@ async function verifyEnvelopeChecked(
|
|
|
761
761
|
}
|
|
762
762
|
|
|
763
763
|
const specVersionOk = specVersionSupported(env.spec_version, opts.acceptedSpecMajors);
|
|
764
|
-
if (!specVersionOk)
|
|
764
|
+
if (!specVersionOk) {
|
|
765
|
+
// Absent or null reads "missing", as in the Python kernel. Everything else
|
|
766
|
+
// is quoted the way both kernels quote a value in a finding.
|
|
767
|
+
errors.push(
|
|
768
|
+
env.spec_version === undefined || env.spec_version === null
|
|
769
|
+
? "missing spec_version"
|
|
770
|
+
: `unsupported spec_version: ${JSON.stringify(env.spec_version)}`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
765
773
|
|
|
766
774
|
// Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
|
|
767
775
|
// or `environment` that is not what §2 says fails the verdict. There is no
|
|
@@ -791,8 +799,8 @@ async function verifyEnvelopeChecked(
|
|
|
791
799
|
// line, or an ANSI escape that paints `VERIFIED` green, into what the
|
|
792
800
|
// verifier prints. No other field in this file echoes its value, the Python
|
|
793
801
|
// twin names the same failure without one, and the value is in the envelope
|
|
794
|
-
// the auditor already holds.
|
|
795
|
-
//
|
|
802
|
+
// the auditor already holds. Neither is the recomputed digest: the reason is
|
|
803
|
+
// the Python kernel's sentence, word for word (proof#150).
|
|
796
804
|
let payloadHashOk: boolean | null;
|
|
797
805
|
if (typeof env.payload_hash !== "string") {
|
|
798
806
|
payloadHashOk = false;
|
|
@@ -803,26 +811,22 @@ async function verifyEnvelopeChecked(
|
|
|
803
811
|
try {
|
|
804
812
|
const recomputed = await sha256Hex(utf8(jcs(env.payload)));
|
|
805
813
|
checks.push(recomputed === env.payload_hash);
|
|
806
|
-
|
|
807
|
-
errors.push(
|
|
808
|
-
`payload_hash mismatch: the envelope's payload_hash is not SHA-256(JCS(payload)), recomputed=${recomputed}`,
|
|
809
|
-
);
|
|
810
|
-
}
|
|
811
|
-
} catch (e) {
|
|
814
|
+
} catch {
|
|
812
815
|
checks.push(false);
|
|
813
|
-
errors.push(
|
|
816
|
+
errors.push("payload is not JCS-canonicalizable");
|
|
814
817
|
}
|
|
815
818
|
}
|
|
816
819
|
if (opts.payloadBytes !== undefined) {
|
|
817
820
|
const external = await sha256Hex(opts.payloadBytes);
|
|
818
821
|
checks.push(external === env.payload_hash);
|
|
819
|
-
if (external !== env.payload_hash) {
|
|
820
|
-
errors.push(
|
|
821
|
-
`payload_hash does not match the committed bytes: bytes=${external}`,
|
|
822
|
-
);
|
|
823
|
-
}
|
|
824
822
|
}
|
|
825
823
|
payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
|
|
824
|
+
// One sentence for a mismatch against the inline payload, the supplied
|
|
825
|
+
// bytes or both, and none when the payload could not be canonicalized: the
|
|
826
|
+
// Python kernel's rule, word for word (proof#150).
|
|
827
|
+
if (payloadHashOk === false && !errors.some((e) => e.startsWith("payload is not"))) {
|
|
828
|
+
errors.push("payload_hash does not match the committed bytes");
|
|
829
|
+
}
|
|
826
830
|
}
|
|
827
831
|
|
|
828
832
|
// Check 2 — Signature Confirmed: Ed25519 over the signed scope, which is
|
|
@@ -836,8 +840,11 @@ async function verifyEnvelopeChecked(
|
|
|
836
840
|
const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
|
|
837
841
|
signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
|
|
838
842
|
if (!signatureOk) errors.push("Ed25519 signature verification failed");
|
|
839
|
-
} catch
|
|
840
|
-
|
|
843
|
+
} catch {
|
|
844
|
+
// The sentence the Python kernel writes for the same failure. What was
|
|
845
|
+
// thrown is the host's wording (`jcs: input nesting exceeds ...`), and a
|
|
846
|
+
// public result carries the kernel's own words only (proof#150).
|
|
847
|
+
errors.push("Ed25519 signature verification failed");
|
|
841
848
|
}
|
|
842
849
|
|
|
843
850
|
// Check 3 (optional) — Content Bind: the user's bytes match a hash this
|
|
@@ -895,6 +902,3 @@ async function verifyEnvelopeChecked(
|
|
|
895
902
|
};
|
|
896
903
|
}
|
|
897
904
|
|
|
898
|
-
function stringifyErr(e: unknown): string {
|
|
899
|
-
return e instanceof Error ? e.message : String(e);
|
|
900
|
-
}
|
package/src/zip-reader.ts
CHANGED
|
@@ -32,6 +32,13 @@ import { utf8Strict } from "./verifier.js";
|
|
|
32
32
|
export const MAX_ZIP_ENTRIES = 50_000;
|
|
33
33
|
/** More uncompressed bytes than this, across every entry, refuse the archive. */
|
|
34
34
|
export const MAX_ZIP_BYTES = 2 ** 30;
|
|
35
|
+
/**
|
|
36
|
+
* An archive file larger than this is refused before it is read: the
|
|
37
|
+
* uncompressed cap, and room for the headers, the central directory and the
|
|
38
|
+
* deflate framing of an archive that holds that much. The caller checks the
|
|
39
|
+
* size on disk, so the archive is never read whole to find out it is too big.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_ZIP_ARCHIVE_BYTES = MAX_ZIP_BYTES + 256 * 2 ** 20;
|
|
35
42
|
/** A name nested deeper than this many folders refuses the archive. */
|
|
36
43
|
export const MAX_ZIP_DEPTH = 32;
|
|
37
44
|
|
package/vectors/README.md
CHANGED
|
@@ -173,7 +173,11 @@ matched by its SHA-256 and by nothing else**, and **no match moves a verdict**:
|
|
|
173
173
|
a surface that matched by name, or took a hash from a field no record's key
|
|
174
174
|
signed, would vouch for a file the pack never committed to. Written by
|
|
175
175
|
`tools/gen-vectors/gen_file_match_vectors.py`, whose expectations are written
|
|
176
|
-
by hand from what it built.
|
|
176
|
+
by hand from what it built. Rule version 2 (A43, proof#204) adds `pack_index`
|
|
177
|
+
and `record_index` to every commitment and every `not_read` entry: the pack's
|
|
178
|
+
position among the row's packs in `mode: case` (`null` in `mode: pack`) and
|
|
179
|
+
the record's position among the events the pack presents, both read off the
|
|
180
|
+
packs the row names.
|
|
177
181
|
|
|
178
182
|
## `case-split.json`
|
|
179
183
|
|