@sanning/proof 0.16.0 → 0.18.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.
@@ -60,6 +60,26 @@ export const NOT_READ_REASONS = [
60
60
  "handoff_record_malformed",
61
61
  "message_withheld",
62
62
  ];
63
+ /**
64
+ * Why a file was named in the report and never hashed (5.7.5). A skipped file
65
+ * is listed, never dropped: every file a command found is matched, unmatched or
66
+ * skipped, so "no match" is never read as "no file was there".
67
+ */
68
+ export const SKIPPED_REASONS = [
69
+ "symbolic_link",
70
+ "not_regular",
71
+ "unreadable",
72
+ "too_large",
73
+ "refused",
74
+ ];
75
+ const SKIPPED_TEXT = {
76
+ symbolic_link: "a symbolic link: not followed, not read",
77
+ not_regular: "not a regular file: not read",
78
+ unreadable: "could not be read",
79
+ too_large: "past the 1 GiB read from folders in one run: not read",
80
+ // Followed by the reader's own words, `renderSkipped`.
81
+ refused: "a .zip entry the reader refused",
82
+ };
63
83
  const REASON_TEXT = {
64
84
  no_records: "this bundle carries no records this reading can read",
65
85
  record_unreadable: "its record bytes cannot be read",
@@ -124,17 +144,17 @@ async function messageOf(ev, eventId, messageHash, content) {
124
144
  }
125
145
  return ABSENT;
126
146
  }
127
- async function readPack(bundle, content, packRef) {
147
+ async function readPack(bundle, content, packRef, packIndex) {
128
148
  const commitments = [];
129
149
  const notRead = [];
130
150
  const events = resolveTraceEvents(bundle);
131
151
  if (events === undefined) {
132
- notRead.push({ packRef, eventId: null, reason: "no_records" });
152
+ notRead.push({ packRef, eventId: null, reason: "no_records", packIndex, recordIndex: null });
133
153
  return { commitments, notRead };
134
154
  }
135
- for (const ev of events) {
155
+ for (const [recordIndex, ev] of events.entries()) {
136
156
  if (!isObject(ev)) {
137
- notRead.push({ packRef, eventId: null, reason: "record_unreadable" });
157
+ notRead.push({ packRef, eventId: null, reason: "record_unreadable", packIndex, recordIndex });
138
158
  continue;
139
159
  }
140
160
  const envelope = own(ev, "envelope");
@@ -144,7 +164,7 @@ async function readPack(bundle, content, packRef) {
144
164
  if (rawBytes !== undefined && rawBytes !== null) {
145
165
  eventBytes = typeof rawBytes === "string" ? decode(rawBytes) : undefined;
146
166
  if (eventBytes === undefined) {
147
- notRead.push({ packRef, eventId, reason: "record_unreadable" });
167
+ notRead.push({ packRef, eventId, reason: "record_unreadable", packIndex, recordIndex });
148
168
  continue;
149
169
  }
150
170
  }
@@ -153,15 +173,15 @@ async function readPack(bundle, content, packRef) {
153
173
  acceptedSpecMajors: RECORD_PROFILES,
154
174
  });
155
175
  if (!result.ok || !isObject(envelope) || !envelopeSpelled(envelope)) {
156
- notRead.push({ packRef, eventId, reason: "record_not_verified" });
176
+ notRead.push({ packRef, eventId, reason: "record_not_verified", packIndex, recordIndex });
157
177
  continue;
158
178
  }
159
179
  if (eventBytes === undefined) {
160
- notRead.push({ packRef, eventId, reason: "record_withheld" });
180
+ notRead.push({ packRef, eventId, reason: "record_withheld", packIndex, recordIndex });
161
181
  continue;
162
182
  }
163
183
  if (result.payloadHashOk !== true) {
164
- notRead.push({ packRef, eventId, reason: "record_not_verified" });
184
+ notRead.push({ packRef, eventId, reason: "record_not_verified", packIndex, recordIndex });
165
185
  continue;
166
186
  }
167
187
  let record;
@@ -172,7 +192,7 @@ async function readPack(bundle, content, packRef) {
172
192
  record = undefined;
173
193
  }
174
194
  if (!isObject(record)) {
175
- notRead.push({ packRef, eventId, reason: "record_unreadable" });
195
+ notRead.push({ packRef, eventId, reason: "record_unreadable", packIndex, recordIndex });
176
196
  continue;
177
197
  }
178
198
  const key = own(envelope, "public_key");
@@ -188,12 +208,12 @@ async function readPack(bundle, content, packRef) {
188
208
  if (isHandoffNamespace(held)) {
189
209
  const messageHash = own(event, "message_hash");
190
210
  if (!isConformantHandoffRecord(held) || !isHexField(messageHash, 64, true)) {
191
- notRead.push({ packRef, eventId, reason: "handoff_record_malformed" });
211
+ notRead.push({ packRef, eventId, reason: "handoff_record_malformed", packIndex, recordIndex });
192
212
  continue;
193
213
  }
194
214
  const message = await messageOf(ev, eventId, messageHash, content);
195
215
  if (message === ABSENT) {
196
- notRead.push({ packRef, eventId, reason: "message_withheld" });
216
+ notRead.push({ packRef, eventId, reason: "message_withheld", packIndex, recordIndex });
197
217
  continue;
198
218
  }
199
219
  const parts = isObject(message) ? own(message, "parts") : undefined;
@@ -209,6 +229,8 @@ async function readPack(bundle, content, packRef) {
209
229
  contentHash: (part.content_hash).toLowerCase(),
210
230
  signer,
211
231
  signedAt,
232
+ packIndex,
233
+ recordIndex,
212
234
  });
213
235
  });
214
236
  }
@@ -216,7 +238,7 @@ async function readPack(bundle, content, packRef) {
216
238
  }
217
239
  const contentHash = own(event, "content_hash");
218
240
  if (!isHexField(contentHash, 64)) {
219
- notRead.push({ packRef, eventId, reason: "no_content_hash" });
241
+ notRead.push({ packRef, eventId, reason: "no_content_hash", packIndex, recordIndex });
220
242
  continue;
221
243
  }
222
244
  commitments.push({
@@ -227,6 +249,8 @@ async function readPack(bundle, content, packRef) {
227
249
  contentHash: contentHash.toLowerCase(),
228
250
  signer,
229
251
  signedAt,
252
+ packIndex,
253
+ recordIndex,
230
254
  });
231
255
  }
232
256
  return { commitments, notRead };
@@ -238,9 +262,16 @@ async function offeredDigests(files) {
238
262
  for (const entry of files) {
239
263
  if (!isObject(entry))
240
264
  throw new TypeError("matchFiles: each file is an object {name, bytes}");
241
- const { name, bytes } = entry;
265
+ const { name, bytes, sha256 } = entry;
242
266
  if (typeof name !== "string")
243
267
  throw new TypeError("matchFiles: a file's name is a string");
268
+ if (sha256 !== undefined && sha256 !== null) {
269
+ if ((bytes !== undefined && bytes !== null) || !isHexField(sha256, 64, true)) {
270
+ throw new TypeError("matchFiles: a file is {name, bytes} or {name, sha256}, and a sha256 is 64 lowercase hex characters");
271
+ }
272
+ out.push([name, sha256]);
273
+ continue;
274
+ }
244
275
  if (!(bytes instanceof Uint8Array)) {
245
276
  throw new TypeError("matchFiles: a file's bytes are a Uint8Array");
246
277
  }
@@ -275,6 +306,7 @@ async function match(commitments, notRead, files) {
275
306
  notOffered: commitments.filter((_, i) => !used.has(i)),
276
307
  notRead,
277
308
  scope: FILE_MATCH_SCOPE,
309
+ skipped: [],
278
310
  };
279
311
  }
280
312
  /**
@@ -284,19 +316,19 @@ async function match(commitments, notRead, files) {
284
316
  * never an offered file.
285
317
  */
286
318
  export async function matchFiles(bundle, files, content) {
287
- const { commitments, notRead } = await readPack(bundle, content, null);
319
+ const { commitments, notRead } = await readPack(bundle, content, null, null);
288
320
  return match(commitments, notRead, files);
289
321
  }
290
322
  /** The same reading over every pack of a case, each commitment naming its pack's `ref`. */
291
323
  export async function matchCaseFiles(packs, files) {
292
324
  const commitments = [];
293
325
  const notRead = [];
294
- for (const pack of packs) {
326
+ for (const [packIndex, pack] of packs.entries()) {
295
327
  if (!isObject(pack))
296
328
  throw new TypeError("matchCaseFiles: each pack is an object {ref, bundle}");
297
329
  if (typeof pack.ref !== "string")
298
330
  throw new TypeError("matchCaseFiles: a pack's ref is a string");
299
- const r = await readPack(pack.bundle, pack.content, pack.ref);
331
+ const r = await readPack(pack.bundle, pack.content, pack.ref, packIndex);
300
332
  commitments.push(...r.commitments);
301
333
  notRead.push(...r.notRead);
302
334
  }
@@ -305,7 +337,9 @@ export async function matchCaseFiles(packs, files) {
305
337
  function commitmentJson(c) {
306
338
  return {
307
339
  pack_ref: c.packRef,
340
+ pack_index: c.packIndex,
308
341
  event_id: c.eventId,
342
+ record_index: c.recordIndex,
309
343
  kind: c.kind,
310
344
  part_index: c.partIndex,
311
345
  content_hash: c.contentHash,
@@ -315,6 +349,7 @@ function commitmentJson(c) {
315
349
  }
316
350
  /** The reading as the `files` member of `--json`, identical in both kernels. */
317
351
  export function fileMatchToJson(reading) {
352
+ const skipped = reading.skipped ?? [];
318
353
  return {
319
354
  offered: reading.offered,
320
355
  matched: reading.matched.map((m) => ({
@@ -326,13 +361,32 @@ export function fileMatchToJson(reading) {
326
361
  not_offered: reading.notOffered.map(commitmentJson),
327
362
  not_read: reading.notRead.map((n) => ({
328
363
  pack_ref: n.packRef,
364
+ pack_index: n.packIndex,
329
365
  event_id: n.eventId,
366
+ record_index: n.recordIndex,
330
367
  reason: n.reason,
331
368
  rendered: renderNotRead(n),
332
369
  })),
370
+ // Present only when something was skipped, so a reading with no skipped
371
+ // file is the object it was before a folder could be offered (A40).
372
+ ...(skipped.length > 0
373
+ ? {
374
+ skipped: skipped.map((k) => ({
375
+ name: k.name,
376
+ reason: k.reason,
377
+ ...(k.detail !== undefined ? { detail: k.detail } : {}),
378
+ rendered: renderSkipped(k),
379
+ })),
380
+ }
381
+ : {}),
333
382
  scope: reading.scope,
334
383
  };
335
384
  }
385
+ function renderSkipped(k) {
386
+ if (k.detail !== undefined)
387
+ return `${k.name} (${SKIPPED_TEXT[k.reason]}: ${k.detail}; not read)`;
388
+ return `${k.name} (${SKIPPED_TEXT[k.reason]})`;
389
+ }
336
390
  function recordName(eventId) {
337
391
  return eventId !== null ? `record ${eventId}` : "a record with no event_id";
338
392
  }
@@ -350,12 +404,41 @@ function renderNotRead(n) {
350
404
  return `no record read${where}: ${REASON_TEXT[n.reason]}`;
351
405
  return `${recordName(n.eventId)}${where} not read: ${REASON_TEXT[n.reason]}`;
352
406
  }
353
- /** The text report's lines for the reading, identical in both kernels. */
354
- export function renderFileMatch(reading) {
407
+ /**
408
+ * The commitments no offered file matched and no disclosed log answers.
409
+ *
410
+ * `contents` is each pack's side content by `pack_ref`. A record whose own pack
411
+ * discloses bytes that hash to its `content_hash` is accounted for by the pack,
412
+ * and is not returned. Only the text report's count reads this; the reading, and
413
+ * `--json`, still list every commitment no file matched.
414
+ */
415
+ export async function notAccountedFor(reading, contents) {
416
+ const out = [];
417
+ for (const c of reading.notOffered) {
418
+ const held = c.packRef !== null ? contents.get(c.packRef) : undefined;
419
+ const raw = c.kind === "record" && held !== undefined && held !== null && c.eventId !== null
420
+ ? decode(sideContentEntry(held, c.eventId).value)
421
+ : undefined;
422
+ if (raw !== undefined && (await sha256Hex(raw)) === c.contentHash)
423
+ continue;
424
+ out.push(c);
425
+ }
426
+ return out;
427
+ }
428
+ /**
429
+ * The text report's lines for the reading, identical in both kernels.
430
+ *
431
+ * By default every commitment no file matched is a line. `notAmong` is the case
432
+ * command's report when no `--files` was given: one count line for the recorded
433
+ * files no loose file matched, and no line for each (A41).
434
+ */
435
+ export function renderFileMatch(reading, notAmong) {
436
+ const skipped = reading.skipped ?? [];
355
437
  const lines = [
356
438
  `files ${reading.offered} offered: ${reading.matched.length} vouched for, ` +
357
- `${reading.unmatched.length} match no record; ` +
358
- `${reading.notOffered.length} commitment(s) had no file offered`,
439
+ `${reading.unmatched.length} match no record` +
440
+ (notAmong === undefined ? `; ${reading.notOffered.length} commitment(s) had no file offered` : "") +
441
+ (skipped.length > 0 ? `; ${skipped.length} skipped, not read` : ""),
359
442
  ];
360
443
  for (const m of reading.matched) {
361
444
  lines.push(` vouched for ${m.name} sha256 ${m.sha256}`);
@@ -364,8 +447,16 @@ export function renderFileMatch(reading) {
364
447
  }
365
448
  for (const u of reading.unmatched)
366
449
  lines.push(` no match ${u.name} sha256 ${u.sha256}`);
367
- for (const c of reading.notOffered) {
368
- lines.push(` not offered ${c.contentHash}, ${renderCommitment(c)}`);
450
+ for (const k of skipped)
451
+ lines.push(` skipped ${renderSkipped(k)}`);
452
+ if (notAmong === undefined) {
453
+ for (const c of reading.notOffered) {
454
+ lines.push(` not offered ${c.contentHash}, ${renderCommitment(c)}`);
455
+ }
456
+ }
457
+ else if (notAmong > 0) {
458
+ lines.push(` ${notAmong} recorded commitment(s) matched no loose file and no log in their own pack ` +
459
+ "(--json lists every commitment no file matched, these included)");
369
460
  }
370
461
  for (const n of reading.notRead)
371
462
  lines.push(` not read ${renderNotRead(n)}`);
package/dist/grading.d.ts CHANGED
@@ -145,6 +145,13 @@ export interface PackGrade {
145
145
  * bundle, precisely so the transport can say things the map cannot.
146
146
  */
147
147
  export declare function accountDisclosure(bundle: unknown, content: SideContentMap): DisclosureAccount;
148
+ /**
149
+ * A JSON number read by its value: an integer within 2**53 - 1, or null.
150
+ * `Number.isSafeInteger`, mirrored by `_js_safe_integer` in
151
+ * `src/sanning_proof/chain.py`, so `2.0` is 2 in both kernels and
152
+ * `9007199254740993`, which this parser rounds, is unreadable in both.
153
+ */
154
+ export declare function safeInteger(value: unknown): number | null;
148
155
  /** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
149
156
  export declare function signedLeafCount(checkpoint: {
150
157
  event_bytes?: unknown;
package/dist/grading.js CHANGED
@@ -323,6 +323,16 @@ content) {
323
323
  const extra = sideContentKeys(content).filter((id) => !accounted.has(id));
324
324
  return { expected, present, missing, unreadable, extra };
325
325
  }
326
+ /**
327
+ * A JSON number read by its value: an integer within 2**53 - 1, or null.
328
+ * `Number.isSafeInteger`, mirrored by `_js_safe_integer` in
329
+ * `src/sanning_proof/chain.py`, so `2.0` is 2 in both kernels and
330
+ * `9007199254740993`, which this parser rounds, is unreadable in both.
331
+ */
332
+ export function safeInteger(value) {
333
+ // `+ 0` folds -0 into 0, which Python's `int(-0.0)` already is.
334
+ return typeof value === "number" && Number.isSafeInteger(value) ? value + 0 : null;
335
+ }
326
336
  /** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
327
337
  export function signedLeafCount(checkpoint) {
328
338
  const hex = checkpoint?.event_bytes;
@@ -336,8 +346,10 @@ export function signedLeafCount(checkpoint) {
336
346
  // `BOM + JCS(checkpoint)` yielded a signed leaf count here and unreadable
337
347
  // there, and that count decides a completeness note (proof#140 gate 5).
338
348
  const record = jcsParse(hexToBytes(hex));
339
- const n = record?.event?.leaf_count;
340
- return Number.isInteger(n) ? n : null;
349
+ // Read BY VALUE, the model JCS signs: a record spelling `3.0`, `3e0` or
350
+ // `30e-1` commits to 3, and since A38 (proof#197) this count decides a
351
+ // verdict, not only a note. Past 2**53 - 1 it is unreadable, as in Python.
352
+ return safeInteger(record?.event?.leaf_count);
341
353
  }
342
354
  catch {
343
355
  return null;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyEnvelope, } from "./verifier.js";
2
- export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
2
+ export { bytesToHex, deriveOperatorAddress, ed25519Refusal, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
3
3
  export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
4
4
  export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
5
5
  export { ACCEPTED_EVIDENCE_MAJORS, ANCHOR_TRACE_BODY_TYPE, CO_SIGNATURES_NOTE, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, coSignaturesPresent, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
@@ -8,16 +8,16 @@ export { PACK_GRADE_CAUSE_KINDS, PACK_VERDICTS, accountDisclosure, completenessN
8
8
  export type { CompletenessNote, DisclosureAccount, GradeInput, PackGrade, PackGradeCause, PackGradeCauseKind, PackVerdict, } from "./grading.js";
9
9
  export { DECLARATION_CONTENT_KINDS, DECLARATION_DEVIATION_KINDS, DECLARATION_NOTE_KINDS, DECLARATION_ROLES, DECLARATION_VERDICTS, DECLARATION_VERSION, DECLARATION_VERSION_V2, DECLARATION_VERSIONS, checkDeclaration, declarationClaims, packRecords, renderContentFinding, renderDeclarationSummary, renderDeviation, renderNote, } from "./declaration.js";
10
10
  export type { DeclarationCheck, DeclarationClaims, DeclarationContentFinding, DeclarationContentKind, DeclarationDeviation, DeclarationDeviationKind, DeclarationNote, DeclarationNoteKind, DeclarationRole, DeclarationVerdict, PackRecord, } from "./declaration.js";
11
- export { CASE_BODY_TYPES, CASE_EXCEPTION_KINDS, CASE_QUARANTINED_PROFILES, CASE_RECORD_PROFILES, CASE_NOTE_KINDS, CASE_RELIANCE, CASE_RELIANCE_HANDOFF_CLAUSE, CASE_VERDICTS, CONTENT_WITHHELD_BY_DECLARATION, CO_SIGNATURES_PRESENT, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, WITNESS_STATES, WITNESS_TIME_CACHED, WITNESS_TIME_RECORDED, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
11
+ export { CASE_BODY_TYPES, CASE_EXCEPTION_KINDS, CASE_QUARANTINED_PROFILES, CASE_RECORD_PROFILES, CASE_RECORD_STATUSES, CASE_NOTE_KINDS, CASE_RELIANCE, CASE_RELIANCE_HANDOFF_CLAUSE, CASE_VERDICTS, CONTENT_WITHHELD_BY_DECLARATION, CO_SIGNATURES_PRESENT, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, WITNESS_STATES, WITNESS_TIME_CACHED, WITNESS_TIME_RECORDED, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
12
12
  export { MAX_BLOCK_TIME, MAX_WITNESS_CACHE_DEPTH, WITNESS_CACHE_NAME, WITNESS_CACHE_VERSION, checkWitnessCache, formatBlockTime, readWitnessCache, renderWitnessTime, witnessTimeQuery, } from "./witness-cache.js";
13
13
  export type { CachedWitnessTime, WitnessTimeAnswer, WitnessTimeCheck } from "./witness-cache.js";
14
14
  export { lookupPublicRecord, onChainHalf, witnessLine } from "./witness-lookup.js";
15
15
  export type { OnChainHalf, OnChainRow, PublicRecordPack, PublicRecordResult, PublicRecordState, WitnessLookupOptions, WitnessTimeRow, } from "./witness-lookup.js";
16
- export type { CaseException, CaseExceptionKind, CaseNote, CaseNoteKind, CasePack, CasePackReading, CaseReading, CaseRecordReading, CaseVerdict, WitnessLine, WitnessState, } from "./case.js";
16
+ export type { CaseException, CaseExceptionKind, CaseNote, CaseNoteKind, CasePack, CasePackReading, CaseReading, CaseRecordReading, CaseRecordStatus, CaseVerdict, WitnessLine, WitnessState, } from "./case.js";
17
17
  export { CASE_FILE_FINDING_KINDS, CASE_FILE_INDEX_NAME, CASE_FILE_VERSION, HOW_TO_VERIFY_NAME, REPORT_README_FIRST_LINE, howToVerifyText, isPackRef, readCaseFileIndex, renderCaseFileFinding, } from "./case-file.js";
18
18
  export type { CaseFileFinding, CaseFileFindingKind, CaseFileIndexReading, } from "./case-file.js";
19
- export { FILE_MATCH_SCOPE, NOT_READ_REASONS, fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch, } from "./file-match.js";
20
- export type { FileCommitment, FileMatchPack, FileMatchReading, MatchedFile, NotRead, NotReadReason, OfferedFile, UnmatchedFile, } from "./file-match.js";
19
+ export { FILE_MATCH_SCOPE, NOT_READ_REASONS, SKIPPED_REASONS, fileMatchToJson, matchCaseFiles, matchFiles, notAccountedFor, renderFileMatch, } from "./file-match.js";
20
+ export type { FileCommitment, FileMatchPack, FileMatchReading, MatchedFile, NotRead, NotReadReason, OfferedFile, SkippedFile, SkippedReason, UnmatchedFile, } from "./file-match.js";
21
21
  export { ADAPTER_CHAIN_NAMESPACES, CHAIN_READING_SCOPE, readChains, renderChainFinding, } from "./chain.js";
22
22
  export type { ChainFinding, ChainFindingKind, ChainReading, ChainSource, ChainSummary, } from "./chain.js";
23
23
  export { EXIT_CODES, EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_MIXED, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  // mutable accept-set would be a runtime-widenable kernel. Additive — no
19
19
  // existing export changes.
20
20
  export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyEnvelope, } from "./verifier.js";
21
- export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
21
+ export { bytesToHex, deriveOperatorAddress, ed25519Refusal, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
22
22
  export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
23
23
  // Evidence-bundle verification (specs/evidence-bundle.md): the signed
24
24
  // sanning.evidence/v1 wrapper carrying the sanning.anchor.trace/v3 body. The CLI
@@ -85,7 +85,7 @@ export { DECLARATION_CONTENT_KINDS, DECLARATION_DEVIATION_KINDS, DECLARATION_NOT
85
85
  //
86
86
  // Pure: data in, data out, no I/O. Additive: a new module, no existing export
87
87
  // changes, and no new verdict word anywhere.
88
- export { CASE_BODY_TYPES, CASE_EXCEPTION_KINDS, CASE_QUARANTINED_PROFILES, CASE_RECORD_PROFILES, CASE_NOTE_KINDS, CASE_RELIANCE, CASE_RELIANCE_HANDOFF_CLAUSE, CASE_VERDICTS, CONTENT_WITHHELD_BY_DECLARATION, CO_SIGNATURES_PRESENT, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, WITNESS_STATES, WITNESS_TIME_CACHED, WITNESS_TIME_RECORDED, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
88
+ export { CASE_BODY_TYPES, CASE_EXCEPTION_KINDS, CASE_QUARANTINED_PROFILES, CASE_RECORD_PROFILES, CASE_RECORD_STATUSES, CASE_NOTE_KINDS, CASE_RELIANCE, CASE_RELIANCE_HANDOFF_CLAUSE, CASE_VERDICTS, CONTENT_WITHHELD_BY_DECLARATION, CO_SIGNATURES_PRESENT, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, WITNESS_STATES, WITNESS_TIME_CACHED, WITNESS_TIME_RECORDED, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
89
89
  // The witnessed-time cache (evidence-bundle 5.6.6, v1.25, A30): read it, look it
90
90
  // up, print it. Never an input to a verdict.
91
91
  export { MAX_BLOCK_TIME, MAX_WITNESS_CACHE_DEPTH, WITNESS_CACHE_NAME, WITNESS_CACHE_VERSION, checkWitnessCache, formatBlockTime, readWitnessCache, renderWitnessTime, witnessTimeQuery, } from "./witness-cache.js";
@@ -111,7 +111,7 @@ export { CASE_FILE_FINDING_KINDS, CASE_FILE_INDEX_NAME, CASE_FILE_VERSION, HOW_T
111
111
  // Files handed in beside a pack (src/file-match.ts, evidence-bundle.md §5.7,
112
112
  // A29, proof#92): each file's SHA-256 matched against the commitments the
113
113
  // pack's SIGNED records make, by hash alone. Never moves a verdict.
114
- export { FILE_MATCH_SCOPE, NOT_READ_REASONS, fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch, } from "./file-match.js";
114
+ export { FILE_MATCH_SCOPE, NOT_READ_REASONS, SKIPPED_REASONS, fileMatchToJson, matchCaseFiles, matchFiles, notAccountedFor, renderFileMatch, } from "./file-match.js";
115
115
  // Chain reading (src/chain.ts): following the pointer every sanning.events/v2
116
116
  // record carries to the record before it, over the records ONE bundle presents.
117
117
  //
package/dist/merkle.js CHANGED
@@ -66,26 +66,32 @@ export async function verifyInclusion(leaf, leafIndex, totalLeaves, auditPath, e
66
66
  }
67
67
  if (totalLeaves === 1)
68
68
  return auditPath.length === 0 && bytesEqual(leaf, expectedRoot);
69
+ // Arithmetic, not bitwise: JavaScript's `&` and `>>=` truncate to 32 bits,
70
+ // so a tree past 2**31 leaves walked a different tree here than in the
71
+ // Python kernel, whose integers are unbounded. `% 2` and a halving floor
72
+ // are exact for every safe integer, and the stated index and count reach
73
+ // this walk as safe integers.
69
74
  let fn = leafIndex;
70
75
  let sn = totalLeaves - 1;
71
76
  let r = leaf;
77
+ const half = (v) => Math.floor(v / 2);
72
78
  for (const p of auditPath) {
73
79
  if (sn === 0)
74
80
  return false; // path longer than the tree depth — malformed
75
- if ((fn & 1) === 1 || fn === sn) {
81
+ if (fn % 2 === 1 || fn === sn) {
76
82
  r = await nodeHash(p, r);
77
- if ((fn & 1) === 0) {
78
- while ((fn & 1) === 0 && fn !== 0) {
79
- fn >>= 1;
80
- sn >>= 1;
83
+ if (fn % 2 === 0) {
84
+ while (fn % 2 === 0 && fn !== 0) {
85
+ fn = half(fn);
86
+ sn = half(sn);
81
87
  }
82
88
  }
83
89
  }
84
90
  else {
85
91
  r = await nodeHash(r, p);
86
92
  }
87
- fn >>= 1;
88
- sn >>= 1;
93
+ fn = half(fn);
94
+ sn = half(sn);
89
95
  }
90
96
  return sn === 0 && bytesEqual(r, expectedRoot);
91
97
  }
package/dist/verifier.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // Conformance is enforced by test/conformance.test.ts, which runs every
8
8
  // corpus test vector through this code and asserts byte-for-byte agreement.
9
9
  import canonicalize from "canonicalize";
10
- import { ed25519Verify, isHexField, sha256Hex, utf8 } from "./crypto.js";
10
+ import { SIGNATURE_FAILED, ed25519Refusal, isHexField, sha256Hex, utf8 } from "./crypto.js";
11
11
  // Fail-closed accepted-profile registry (envelope-spec §2): exactly the
12
12
  // accepted profile majors, nothing else. Minors within an accepted
13
13
  // major ("sanning.agent/v1.<minor>") are additive and tolerated — matching the Go
@@ -690,7 +690,7 @@ export async function verifyEnvelope(env, options) {
690
690
  try {
691
691
  return await verifyEnvelopeChecked(env, opts);
692
692
  }
693
- catch (e) {
693
+ catch {
694
694
  return {
695
695
  ok: false,
696
696
  specVersionOk: false,
@@ -700,7 +700,7 @@ export async function verifyEnvelope(env, options) {
700
700
  signatureOk: false,
701
701
  contentHashOk: opts.expectedContentHash === undefined ? null : false,
702
702
  contentRole: null,
703
- errors: [`envelope could not be read: ${stringifyErr(e)}`],
703
+ errors: ["envelope could not be read"],
704
704
  };
705
705
  }
706
706
  }
@@ -737,8 +737,13 @@ async function verifyEnvelopeChecked(env, opts) {
737
737
  };
738
738
  }
739
739
  const specVersionOk = specVersionSupported(env.spec_version, opts.acceptedSpecMajors);
740
- if (!specVersionOk)
741
- errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
740
+ if (!specVersionOk) {
741
+ // Absent or null reads "missing", as in the Python kernel. Everything else
742
+ // is quoted the way both kernels quote a value in a finding.
743
+ errors.push(env.spec_version === undefined || env.spec_version === null
744
+ ? "missing spec_version"
745
+ : `unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
746
+ }
742
747
  // Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
743
748
  // or `environment` that is not what §2 says fails the verdict. There is no
744
749
  // result field for it, so the verdict shape is unchanged for every envelope
@@ -766,8 +771,8 @@ async function verifyEnvelopeChecked(env, opts) {
766
771
  // line, or an ANSI escape that paints `VERIFIED` green, into what the
767
772
  // verifier prints. No other field in this file echoes its value, the Python
768
773
  // twin names the same failure without one, and the value is in the envelope
769
- // the auditor already holds. The RECOMPUTED digest stays: this kernel
770
- // produced it, so it is 64 characters of lowercase hex by construction.
774
+ // the auditor already holds. Neither is the recomputed digest: the reason is
775
+ // the Python kernel's sentence, word for word (proof#150).
771
776
  let payloadHashOk;
772
777
  if (typeof env.payload_hash !== "string") {
773
778
  payloadHashOk = false;
@@ -779,23 +784,23 @@ async function verifyEnvelopeChecked(env, opts) {
779
784
  try {
780
785
  const recomputed = await sha256Hex(utf8(jcs(env.payload)));
781
786
  checks.push(recomputed === env.payload_hash);
782
- if (recomputed !== env.payload_hash) {
783
- errors.push(`payload_hash mismatch: the envelope's payload_hash is not SHA-256(JCS(payload)), recomputed=${recomputed}`);
784
- }
785
787
  }
786
- catch (e) {
788
+ catch {
787
789
  checks.push(false);
788
- errors.push(`payload canonicalization failed: ${stringifyErr(e)}`);
790
+ errors.push("payload is not JCS-canonicalizable");
789
791
  }
790
792
  }
791
793
  if (opts.payloadBytes !== undefined) {
792
794
  const external = await sha256Hex(opts.payloadBytes);
793
795
  checks.push(external === env.payload_hash);
794
- if (external !== env.payload_hash) {
795
- errors.push(`payload_hash does not match the committed bytes: bytes=${external}`);
796
- }
797
796
  }
798
797
  payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
798
+ // One sentence for a mismatch against the inline payload, the supplied
799
+ // bytes or both, and none when the payload could not be canonicalized: the
800
+ // Python kernel's rule, word for word (proof#150).
801
+ if (payloadHashOk === false && !errors.some((e) => e.startsWith("payload is not"))) {
802
+ errors.push("payload_hash does not match the committed bytes");
803
+ }
799
804
  }
800
805
  // Check 2 — Signature Confirmed: Ed25519 over the signed scope, which is
801
806
  // JCS(envelope minus `signature` minus `co_signatures`) per envelope-spec §2.
@@ -803,15 +808,22 @@ async function verifyEnvelopeChecked(env, opts) {
803
808
  // invalidating the primary signature; the field is reserved/default-absent.
804
809
  // The corpus has no co-signed vectors, so this strip is pinned by an explicit
805
810
  // unit test rather than by conformance.
811
+ // The refusal names the clause of the Ed25519 rule a signature breaks
812
+ // (envelope-spec §7, A44), so a forged shape reads differently from a
813
+ // corrupted byte; an equation that does not hold reads the bare sentence.
806
814
  let signatureOk = false;
807
815
  try {
808
816
  const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
809
- signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
810
- if (!signatureOk)
811
- errors.push("Ed25519 signature verification failed");
817
+ const refusal = await ed25519Refusal(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
818
+ signatureOk = refusal === null;
819
+ if (refusal !== null)
820
+ errors.push(refusal);
812
821
  }
813
- catch (e) {
814
- errors.push(`signature verification error: ${stringifyErr(e)}`);
822
+ catch {
823
+ // The sentence the Python kernel writes for the same failure. What was
824
+ // thrown is the host's wording (`jcs: input nesting exceeds ...`), and a
825
+ // public result carries the kernel's own words only (proof#150).
826
+ errors.push(SIGNATURE_FAILED);
815
827
  }
816
828
  // Check 3 (optional) — Content Bind: the user's bytes match a hash this
817
829
  // envelope commits to. The tag got us here; only this proves the bytes match.
@@ -865,6 +877,3 @@ async function verifyEnvelopeChecked(env, opts) {
865
877
  errors,
866
878
  };
867
879
  }
868
- function stringifyErr(e) {
869
- return e instanceof Error ? e.message : String(e);
870
- }
@@ -27,6 +27,13 @@
27
27
  export declare const MAX_ZIP_ENTRIES = 50000;
28
28
  /** More uncompressed bytes than this, across every entry, refuse the archive. */
29
29
  export declare const MAX_ZIP_BYTES: number;
30
+ /**
31
+ * An archive file larger than this is refused before it is read: the
32
+ * uncompressed cap, and room for the headers, the central directory and the
33
+ * deflate framing of an archive that holds that much. The caller checks the
34
+ * size on disk, so the archive is never read whole to find out it is too big.
35
+ */
36
+ export declare const MAX_ZIP_ARCHIVE_BYTES: number;
30
37
  /** A name nested deeper than this many folders refuses the archive. */
31
38
  export declare const MAX_ZIP_DEPTH = 32;
32
39
  /** The archive cannot be read as a case file. `message` is the whole report, identical in both kernels. */
@@ -29,6 +29,13 @@ import { utf8Strict } from "./verifier.js";
29
29
  export const MAX_ZIP_ENTRIES = 50_000;
30
30
  /** More uncompressed bytes than this, across every entry, refuse the archive. */
31
31
  export const MAX_ZIP_BYTES = 2 ** 30;
32
+ /**
33
+ * An archive file larger than this is refused before it is read: the
34
+ * uncompressed cap, and room for the headers, the central directory and the
35
+ * deflate framing of an archive that holds that much. The caller checks the
36
+ * size on disk, so the archive is never read whole to find out it is too big.
37
+ */
38
+ export const MAX_ZIP_ARCHIVE_BYTES = MAX_ZIP_BYTES + 256 * 2 ** 20;
32
39
  /** A name nested deeper than this many folders refuses the archive. */
33
40
  export const MAX_ZIP_DEPTH = 32;
34
41
  const EOCD = 0x06054b50;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.16.0",
3
+ "version": "0.18.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 and sanning.events/v2. Conformance-gated byte-for-byte against the test-vectors corpus.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "prepublishOnly": "npm run build"
31
31
  },
32
32
  "dependencies": {
33
- "@noble/ed25519": "^2.1.0",
33
+ "@noble/ed25519": "^3.2.0",
34
34
  "canonicalize": "^2.0.0"
35
35
  },
36
36
  "devDependencies": {