@sanning/proof 0.13.0 → 0.15.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/dist/evidence.js CHANGED
@@ -23,7 +23,7 @@ import { deriveOperatorAddress, ed25519Verify, hexToBytes,
23
23
  isHexField, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
24
24
  import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
25
25
  import { leafHash, verifyInclusion } from "./merkle.js";
26
- import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, jcs, jcsParse, verifyEnvelope, } from "./verifier.js";
26
+ import { DuplicateKeyError, MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, isRfc3339UtcInstant, jcs, jcsParse, verifyEnvelope, } from "./verifier.js";
27
27
  import { inBodyContentOffered, sideContentEntry, } from "./side-content.js";
28
28
  // Only the evidence-bundle major(s) this kernel verifies. A new major is a
29
29
  // deliberate one-entry addition HERE — same fail-closed discipline as the
@@ -108,6 +108,41 @@ export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
108
108
  * signature is the §5.1.2 mixed-vocabulary reject, one layer down.
109
109
  */
110
110
  export const STAMP_BODY_VERSION = "sanning.stamp/v1";
111
+ /**
112
+ * A surface MUST NOT present a wrapper's `co_signatures` as evidence
113
+ * (evidence-bundle.md §2, decided by Phil 2026-09-24 on proof#148): nothing
114
+ * in either kernel verifies the field, and it sits outside the wrapper's
115
+ * signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
116
+ * append it with no key at all. This is the one sentence both kernels attach
117
+ * as a note, never a verdict, never an exit code.
118
+ */
119
+ export const CO_SIGNATURES_NOTE = "unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
120
+ /**
121
+ * True iff the wrapper's reserved `co_signatures` field carries at least one
122
+ * entry.
123
+ *
124
+ * Absence and an empty array read the same way here: the field asserts
125
+ * nothing about a second signer until it holds one, and evidence-bundle.md's
126
+ * own carve-out language for the analogous envelope field treats "absent"
127
+ * and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
128
+ * value (malformed shape, but the field is reserved, not yet specified)
129
+ * still counts as present: it is not the well-formed absence either rule
130
+ * describes.
131
+ */
132
+ export function coSignaturesPresent(b) {
133
+ if (b === null || typeof b !== "object" || Array.isArray(b))
134
+ return false;
135
+ const value = b.co_signatures;
136
+ if (!("co_signatures" in b) || value === null || value === undefined) {
137
+ return false;
138
+ }
139
+ if (Array.isArray(value))
140
+ return value.length > 0;
141
+ return true;
142
+ }
143
+ function coSignaturesNote(bundle) {
144
+ return coSignaturesPresent(bundle) ? CO_SIGNATURES_NOTE : undefined;
145
+ }
111
146
  function isEvidenceMajorAccepted(specVersion) {
112
147
  if (typeof specVersion !== "string" || specVersion === "")
113
148
  return false;
@@ -131,6 +166,8 @@ function isEvidenceMajorAccepted(specVersion) {
131
166
  * (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
132
167
  * broke its own body shape read as a forger.
133
168
  */
169
+ /** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
170
+ export const GENERATED_AT_MALFORMED = "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle §2)";
134
171
  function malformed(error, specVersionOk = false, wrapperOk = false) {
135
172
  return {
136
173
  status: "malformed",
@@ -150,7 +187,9 @@ function malformed(error, specVersionOk = false, wrapperOk = false) {
150
187
  * the Python kernel writes too. Anything outside this set comes from the host:
151
188
  * a JSON parser, a codec, a crypto library. See `thrownDetail`.
152
189
  */
153
- const KERNEL_WORDING = ["hexToBytes: "];
190
+ // `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
191
+ // `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
192
+ const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
154
193
  /**
155
194
  * The detail a finding may append about a caught exception.
156
195
  *
@@ -167,6 +206,29 @@ const KERNEL_WORDING = ["hexToBytes: "];
167
206
  * is the host's parse offset, which no reader can act on and which was never
168
207
  * the same in the two kernels.
169
208
  */
209
+ /**
210
+ * The kernel's `duplicate key "<name>"` when a record repeats a member name,
211
+ * else `undefined` (proof#144; `_record_duplicate_key` in the Python twin).
212
+ *
213
+ * ONLY A DUPLICATE, and not every unreadable record. Every reader below this
214
+ * one that parses a record treats an unreadable one as absent, so the content
215
+ * binding reads undetermined and the pack said "1 record discloses no bytes"
216
+ * about a record whose bytes it had read. A record that repeats a name is
217
+ * a defect in the artifact, and a defect this kernel can see fails, the rule
218
+ * `event_bytes` that are not hex already follow.
219
+ */
220
+ function recordDuplicateKey(eventBytes) {
221
+ if (eventBytes === undefined)
222
+ return undefined;
223
+ try {
224
+ jcsParse(eventBytes);
225
+ }
226
+ catch (e) {
227
+ if (e instanceof DuplicateKeyError)
228
+ return e.message;
229
+ }
230
+ return undefined;
231
+ }
170
232
  function thrownDetail(e) {
171
233
  const text = e instanceof Error ? e.message : String(e);
172
234
  return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
@@ -237,7 +299,17 @@ function jsonStr(value) {
237
299
  // (`assertedStatus`) but NEVER trusted — the displayed status is the one this
238
300
  // function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
239
301
  export async function verifyEvidenceBundle(bundle, options = {}) {
240
- return verifyBundle(bundle, options, false);
302
+ // The ONE place a present `co_signatures` becomes a note, so every caller
303
+ // (including `case.ts`, which calls this function directly per pack) gets
304
+ // it, however the wrapper otherwise verifies. Never touches `status` or
305
+ // the exit code: a bundle that would otherwise verify still verifies, note
306
+ // or not (proof#148).
307
+ const result = await verifyBundle(bundle, options, false);
308
+ const note = coSignaturesNote(bundle);
309
+ if (note !== undefined) {
310
+ result.notes = [...(result.notes ?? []), note];
311
+ }
312
+ return result;
241
313
  }
242
314
  // `exportSource`: read the bundle as an export's source (evidence-export.md
243
315
  // §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
@@ -339,6 +411,16 @@ async function verifyBundle(bundle, options, exportSource) {
339
411
  errors,
340
412
  };
341
413
  }
414
+ // --- The wrapper's own time (evidence-bundle §2, A31) ---------------------
415
+ // `generated_at` is copied into a recomputed verdict's `as_of` (the export
416
+ // reading) and printed beside every verdict, so a present value that is not
417
+ // the RFC 3339 form `signed_at` already pins is a wrapper this kernel cannot
418
+ // render, whatever its signature. It is read only once the wrapper holds: a
419
+ // broken wrapper is `failed` above, as it was. Absence is unchanged.
420
+ if (Object.prototype.hasOwnProperty.call(b, "generated_at") &&
421
+ !isRfc3339UtcInstant(b.generated_at)) {
422
+ return malformed(GENERATED_AT_MALFORMED, specVersionOk, true);
423
+ }
342
424
  // --- Body dispatch ---------------------------------------------------------
343
425
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
344
426
  // Read as an export's source (evidence-export.md §2.4), the only body this
@@ -767,6 +849,11 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
767
849
  eventBytesMalformed = true;
768
850
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
769
851
  }
852
+ const duplicate = recordDuplicateKey(eventBytes);
853
+ if (duplicate !== undefined) {
854
+ eventBytesMalformed = true;
855
+ evErrors.push(`event record is not JSON: ${duplicate}`);
856
+ }
770
857
  try {
771
858
  const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
772
859
  const hexError = envelopeHexError("event", ev.envelope);
@@ -1280,6 +1367,11 @@ async function verifyStampEntry(raw, seen) {
1280
1367
  errors.push(`stamp body ${f} is missing or not a non-empty string`);
1281
1368
  base.bodyOk = false;
1282
1369
  }
1370
+ else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
1371
+ // §5.2 (A31): the promise window is the form `signed_at` pins.
1372
+ errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
1373
+ base.bodyOk = false;
1374
+ }
1283
1375
  }
1284
1376
  base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
1285
1377
  base.intervalEnd = typeof body.interval_end === "string" ? body.interval_end : null;
@@ -1553,6 +1645,11 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1553
1645
  eventBytesMalformed = true;
1554
1646
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1555
1647
  }
1648
+ const duplicate = recordDuplicateKey(eventBytes);
1649
+ if (duplicate !== undefined) {
1650
+ eventBytesMalformed = true;
1651
+ evErrors.push(`event record is not JSON: ${duplicate}`);
1652
+ }
1556
1653
  try {
1557
1654
  const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
1558
1655
  const hexError = envelopeHexError("event", ev.envelope);
@@ -2220,6 +2317,10 @@ function payloadFieldError(payload) {
2220
2317
  }
2221
2318
  if (!ok)
2222
2319
  return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
2320
+ if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
2321
+ // evidence-export §3.1 (v3.4): the form `signed_at` pins.
2322
+ return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
2323
+ }
2223
2324
  }
2224
2325
  return null;
2225
2326
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Files handed in beside a pack, matched against what its records signed (A29).
3
+ *
4
+ * The TypeScript half of `src/sanning_proof/file_match.py`. Both kernels are
5
+ * held to one table, `vectors/file-match.json`, so neither can pass by agreeing
6
+ * with the other, and `cross-kernel/run_file_match.sh` holds the two readings,
7
+ * and the two commands' `--files` output, to the same bytes.
8
+ *
9
+ * WHAT THIS ANSWERS (evidence-bundle.md 5.7 v1.24, proof#92, decided by Phil
10
+ * 2026-09-23 as option 2). A reader holds a pack and some files beside it and
11
+ * asks whether the pack vouches for them. Each offered file is hashed, SHA-256
12
+ * over its exact bytes, and the digest is looked for among the commitments the
13
+ * pack's SIGNED records make. The reading says which offered files a record
14
+ * commits to (naming the record, its signer and its `signed_at`), which match
15
+ * no record, and which commitments had no file offered.
16
+ *
17
+ * THE RULES THAT CARRY THE WEIGHT:
18
+ *
19
+ * - By hash, and by nothing else. A file's name is carried through as the
20
+ * caller's label and is never compared with an `event_id`, a part's name or
21
+ * a `logs/` path. There is no layout convention.
22
+ * - Unmatched is reported, never guessed.
23
+ * - Only what a record's own key signed: a verified envelope whose
24
+ * `event_bytes` bind to its `payload_hash`, or a hand-off message whose
25
+ * bytes bind to that record's `message_hash`. No wrapper field is read.
26
+ * - The verdict does not move. A match proves that a signed record in the
27
+ * pack committed to those exact bytes, and nothing more (`FILE_MATCH_SCOPE`).
28
+ *
29
+ * WHAT COUNTS AS A COMMITMENT, in record order and then part order:
30
+ *
31
+ * 1. `record`: a record outside envelope-spec.md 4.1 commits through
32
+ * `event.content_hash`, 64 hex characters in either case.
33
+ * 2. `file_part`: a record 4.1 governs commits to its MESSAGE through
34
+ * `event.message_hash` (5.1, A20), never through a `content_hash` it also
35
+ * writes. Where the message's JCS bytes are in hand (in the body, or in
36
+ * the side content) and bind to it, each file part of that message (A18)
37
+ * commits by its `content_hash`.
38
+ *
39
+ * Side content is read for ONE thing, to find a hand-off message that binds to
40
+ * a signed `message_hash`. It is never an offered file.
41
+ *
42
+ * Pure: a bundle, the files and the optional side content in, a reading out.
43
+ */
44
+ import type { SideContentMap } from "./side-content.js";
45
+ /** The one sentence every surface prints with the reading (5.7). */
46
+ export declare const FILE_MATCH_SCOPE: string;
47
+ /** Why a record's commitments could not be read, in the order they are tested. */
48
+ export declare const NOT_READ_REASONS: readonly ["no_records", "record_unreadable", "record_not_verified", "record_withheld", "no_content_hash", "handoff_record_malformed", "message_withheld"];
49
+ export type NotReadReason = (typeof NOT_READ_REASONS)[number];
50
+ /** One commitment a signed record makes to a file's bytes. */
51
+ export interface FileCommitment {
52
+ packRef: string | null;
53
+ eventId: string | null;
54
+ kind: "record" | "file_part";
55
+ partIndex: number | null;
56
+ contentHash: string;
57
+ signer: string;
58
+ signedAt: string | null;
59
+ }
60
+ export interface MatchedFile {
61
+ name: string;
62
+ sha256: string;
63
+ records: FileCommitment[];
64
+ }
65
+ export interface UnmatchedFile {
66
+ name: string;
67
+ sha256: string;
68
+ }
69
+ export interface NotRead {
70
+ packRef: string | null;
71
+ eventId: string | null;
72
+ reason: NotReadReason;
73
+ }
74
+ export interface FileMatchReading {
75
+ offered: number;
76
+ matched: MatchedFile[];
77
+ unmatched: UnmatchedFile[];
78
+ notOffered: FileCommitment[];
79
+ notRead: NotRead[];
80
+ scope: string;
81
+ }
82
+ /** A file handed in beside a pack. `name` is a label and is never compared. */
83
+ export interface OfferedFile {
84
+ name: string;
85
+ bytes: Uint8Array;
86
+ }
87
+ /** One pack of a case, for `matchCaseFiles`. */
88
+ export interface FileMatchPack {
89
+ ref: string;
90
+ bundle: unknown;
91
+ content?: SideContentMap | null;
92
+ }
93
+ /**
94
+ * Match each offered file against the commitments one pack's signed records
95
+ * make. `content` is the side content handed in beside the pack, read only to
96
+ * find a hand-off message that binds to its record's `message_hash`; it is
97
+ * never an offered file.
98
+ */
99
+ export declare function matchFiles(bundle: unknown, files: readonly OfferedFile[], content?: SideContentMap | null): Promise<FileMatchReading>;
100
+ /** The same reading over every pack of a case, each commitment naming its pack's `ref`. */
101
+ export declare function matchCaseFiles(packs: readonly FileMatchPack[], files: readonly OfferedFile[]): Promise<FileMatchReading>;
102
+ /** The reading as the `files` member of `--json`, identical in both kernels. */
103
+ export declare function fileMatchToJson(reading: FileMatchReading): Record<string, unknown>;
104
+ /** The text report's lines for the reading, identical in both kernels. */
105
+ export declare function renderFileMatch(reading: FileMatchReading): string[];
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Files handed in beside a pack, matched against what its records signed (A29).
3
+ *
4
+ * The TypeScript half of `src/sanning_proof/file_match.py`. Both kernels are
5
+ * held to one table, `vectors/file-match.json`, so neither can pass by agreeing
6
+ * with the other, and `cross-kernel/run_file_match.sh` holds the two readings,
7
+ * and the two commands' `--files` output, to the same bytes.
8
+ *
9
+ * WHAT THIS ANSWERS (evidence-bundle.md 5.7 v1.24, proof#92, decided by Phil
10
+ * 2026-09-23 as option 2). A reader holds a pack and some files beside it and
11
+ * asks whether the pack vouches for them. Each offered file is hashed, SHA-256
12
+ * over its exact bytes, and the digest is looked for among the commitments the
13
+ * pack's SIGNED records make. The reading says which offered files a record
14
+ * commits to (naming the record, its signer and its `signed_at`), which match
15
+ * no record, and which commitments had no file offered.
16
+ *
17
+ * THE RULES THAT CARRY THE WEIGHT:
18
+ *
19
+ * - By hash, and by nothing else. A file's name is carried through as the
20
+ * caller's label and is never compared with an `event_id`, a part's name or
21
+ * a `logs/` path. There is no layout convention.
22
+ * - Unmatched is reported, never guessed.
23
+ * - Only what a record's own key signed: a verified envelope whose
24
+ * `event_bytes` bind to its `payload_hash`, or a hand-off message whose
25
+ * bytes bind to that record's `message_hash`. No wrapper field is read.
26
+ * - The verdict does not move. A match proves that a signed record in the
27
+ * pack committed to those exact bytes, and nothing more (`FILE_MATCH_SCOPE`).
28
+ *
29
+ * WHAT COUNTS AS A COMMITMENT, in record order and then part order:
30
+ *
31
+ * 1. `record`: a record outside envelope-spec.md 4.1 commits through
32
+ * `event.content_hash`, 64 hex characters in either case.
33
+ * 2. `file_part`: a record 4.1 governs commits to its MESSAGE through
34
+ * `event.message_hash` (5.1, A20), never through a `content_hash` it also
35
+ * writes. Where the message's JCS bytes are in hand (in the body, or in
36
+ * the side content) and bind to it, each file part of that message (A18)
37
+ * commits by its `content_hash`.
38
+ *
39
+ * Side content is read for ONE thing, to find a hand-off message that binds to
40
+ * a signed `message_hash`. It is never an offered file.
41
+ *
42
+ * Pure: a bundle, the files and the optional side content in, a reading out.
43
+ */
44
+ import { hexToBytes, isHexField, sha256Hex } from "./crypto.js";
45
+ import { resolveTraceEvents } from "./evidence.js";
46
+ import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
47
+ import { isFilePart } from "./message-parts.js";
48
+ import { sideContentEntry } from "./side-content.js";
49
+ import { jcsParse, verifyEnvelope } from "./verifier.js";
50
+ /** The one sentence every surface prints with the reading (5.7). */
51
+ export const FILE_MATCH_SCOPE = "A match proves that a signed record in the pack committed to these exact " +
52
+ "bytes, and nothing more.";
53
+ /** Why a record's commitments could not be read, in the order they are tested. */
54
+ export const NOT_READ_REASONS = [
55
+ "no_records",
56
+ "record_unreadable",
57
+ "record_not_verified",
58
+ "record_withheld",
59
+ "no_content_hash",
60
+ "handoff_record_malformed",
61
+ "message_withheld",
62
+ ];
63
+ const REASON_TEXT = {
64
+ no_records: "this bundle carries no records this reading can read",
65
+ record_unreadable: "its record bytes cannot be read",
66
+ record_not_verified: "its envelope does not verify",
67
+ record_withheld: "its record bytes are withheld",
68
+ no_content_hash: "it commits to no content_hash",
69
+ handoff_record_malformed: "it is a hand-off record that commits to no message a reader can bind to",
70
+ message_withheld: "its message is not in hand, so its file parts cannot be read",
71
+ };
72
+ // The accept-set minus the quarantined `sanning.mlflow/v1` (DL-63), the set
73
+ // both kernels read a case under.
74
+ const RECORD_PROFILES = ["sanning.agent/v1", "sanning.events/v2"];
75
+ function isObject(v) {
76
+ return v !== null && typeof v === "object" && !Array.isArray(v);
77
+ }
78
+ function strOrNull(v) {
79
+ return typeof v === "string" ? v : null;
80
+ }
81
+ function own(o, key) {
82
+ return Object.prototype.hasOwnProperty.call(o, key) ? o[key] : undefined;
83
+ }
84
+ // The envelope's key and signature in the hex charset at their lengths, the
85
+ // reading every body verifier applies inside a bundle (proof#142).
86
+ function envelopeSpelled(envelope) {
87
+ for (const [name, nibbles] of [
88
+ ["public_key", 64],
89
+ ["signature", 128],
90
+ ]) {
91
+ const value = own(envelope, name);
92
+ if (typeof value === "string" && !isHexField(value, nibbles))
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+ function decode(value) {
98
+ if (value instanceof Uint8Array)
99
+ return value;
100
+ if (typeof value === "string") {
101
+ try {
102
+ return hexToBytes(value);
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
108
+ return undefined;
109
+ }
110
+ const ABSENT = Symbol("absent");
111
+ async function messageOf(ev, eventId, messageHash, content) {
112
+ const candidates = [decode(own(ev, "content"))];
113
+ if (eventId !== null)
114
+ candidates.push(decode(sideContentEntry(content, eventId).value));
115
+ for (const raw of candidates) {
116
+ if (raw === undefined || (await sha256Hex(raw)) !== messageHash)
117
+ continue;
118
+ try {
119
+ return jcsParse(raw);
120
+ }
121
+ catch {
122
+ continue;
123
+ }
124
+ }
125
+ return ABSENT;
126
+ }
127
+ async function readPack(bundle, content, packRef) {
128
+ const commitments = [];
129
+ const notRead = [];
130
+ const events = resolveTraceEvents(bundle);
131
+ if (events === undefined) {
132
+ notRead.push({ packRef, eventId: null, reason: "no_records" });
133
+ return { commitments, notRead };
134
+ }
135
+ for (const ev of events) {
136
+ if (!isObject(ev)) {
137
+ notRead.push({ packRef, eventId: null, reason: "record_unreadable" });
138
+ continue;
139
+ }
140
+ const envelope = own(ev, "envelope");
141
+ const eventId = isObject(envelope) ? strOrNull(own(envelope, "event_id")) : null;
142
+ const rawBytes = own(ev, "event_bytes");
143
+ let eventBytes;
144
+ if (rawBytes !== undefined && rawBytes !== null) {
145
+ eventBytes = typeof rawBytes === "string" ? decode(rawBytes) : undefined;
146
+ if (eventBytes === undefined) {
147
+ notRead.push({ packRef, eventId, reason: "record_unreadable" });
148
+ continue;
149
+ }
150
+ }
151
+ const result = await verifyEnvelope(envelope, {
152
+ ...(eventBytes !== undefined ? { payloadBytes: eventBytes } : {}),
153
+ acceptedSpecMajors: RECORD_PROFILES,
154
+ });
155
+ if (!result.ok || !isObject(envelope) || !envelopeSpelled(envelope)) {
156
+ notRead.push({ packRef, eventId, reason: "record_not_verified" });
157
+ continue;
158
+ }
159
+ if (eventBytes === undefined) {
160
+ notRead.push({ packRef, eventId, reason: "record_withheld" });
161
+ continue;
162
+ }
163
+ if (result.payloadHashOk !== true) {
164
+ notRead.push({ packRef, eventId, reason: "record_not_verified" });
165
+ continue;
166
+ }
167
+ let record;
168
+ try {
169
+ record = jcsParse(eventBytes);
170
+ }
171
+ catch {
172
+ record = undefined;
173
+ }
174
+ if (!isObject(record)) {
175
+ notRead.push({ packRef, eventId, reason: "record_unreadable" });
176
+ continue;
177
+ }
178
+ const key = own(envelope, "public_key");
179
+ const signer = typeof key === "string" ? key : "";
180
+ const signedAt = strOrNull(own(envelope, "signed_at"));
181
+ const section = own(record, "event");
182
+ const event = isObject(section) ? section : {};
183
+ const held = {
184
+ publicKey: signer,
185
+ record: record,
186
+ ref: "",
187
+ };
188
+ if (isHandoffNamespace(held)) {
189
+ const messageHash = own(event, "message_hash");
190
+ if (!isConformantHandoffRecord(held) || !isHexField(messageHash, 64, true)) {
191
+ notRead.push({ packRef, eventId, reason: "handoff_record_malformed" });
192
+ continue;
193
+ }
194
+ const message = await messageOf(ev, eventId, messageHash, content);
195
+ if (message === ABSENT) {
196
+ notRead.push({ packRef, eventId, reason: "message_withheld" });
197
+ continue;
198
+ }
199
+ const parts = isObject(message) ? own(message, "parts") : undefined;
200
+ if (Array.isArray(parts)) {
201
+ parts.forEach((part, index) => {
202
+ if (!isFilePart(part))
203
+ return;
204
+ commitments.push({
205
+ packRef,
206
+ eventId,
207
+ kind: "file_part",
208
+ partIndex: index,
209
+ contentHash: (part.content_hash).toLowerCase(),
210
+ signer,
211
+ signedAt,
212
+ });
213
+ });
214
+ }
215
+ continue;
216
+ }
217
+ const contentHash = own(event, "content_hash");
218
+ if (!isHexField(contentHash, 64)) {
219
+ notRead.push({ packRef, eventId, reason: "no_content_hash" });
220
+ continue;
221
+ }
222
+ commitments.push({
223
+ packRef,
224
+ eventId,
225
+ kind: "record",
226
+ partIndex: null,
227
+ contentHash: contentHash.toLowerCase(),
228
+ signer,
229
+ signedAt,
230
+ });
231
+ }
232
+ return { commitments, notRead };
233
+ }
234
+ // A malformed file entry is the CALLER's mistake, not the evidence's, so it
235
+ // throws rather than reading as a file that matched nothing.
236
+ async function offeredDigests(files) {
237
+ const out = [];
238
+ for (const entry of files) {
239
+ if (!isObject(entry))
240
+ throw new TypeError("matchFiles: each file is an object {name, bytes}");
241
+ const { name, bytes } = entry;
242
+ if (typeof name !== "string")
243
+ throw new TypeError("matchFiles: a file's name is a string");
244
+ if (!(bytes instanceof Uint8Array)) {
245
+ throw new TypeError("matchFiles: a file's bytes are a Uint8Array");
246
+ }
247
+ out.push([name, await sha256Hex(bytes)]);
248
+ }
249
+ return out;
250
+ }
251
+ async function match(commitments, notRead, files) {
252
+ const offered = await offeredDigests(files);
253
+ const matched = [];
254
+ const unmatched = [];
255
+ const used = new Set();
256
+ for (const [name, digest] of offered) {
257
+ const hits = [];
258
+ commitments.forEach((c, i) => {
259
+ if (c.contentHash === digest)
260
+ hits.push(i);
261
+ });
262
+ if (hits.length > 0) {
263
+ for (const i of hits)
264
+ used.add(i);
265
+ matched.push({ name, sha256: digest, records: hits.map((i) => commitments[i]) });
266
+ }
267
+ else {
268
+ unmatched.push({ name, sha256: digest });
269
+ }
270
+ }
271
+ return {
272
+ offered: offered.length,
273
+ matched,
274
+ unmatched,
275
+ notOffered: commitments.filter((_, i) => !used.has(i)),
276
+ notRead,
277
+ scope: FILE_MATCH_SCOPE,
278
+ };
279
+ }
280
+ /**
281
+ * Match each offered file against the commitments one pack's signed records
282
+ * make. `content` is the side content handed in beside the pack, read only to
283
+ * find a hand-off message that binds to its record's `message_hash`; it is
284
+ * never an offered file.
285
+ */
286
+ export async function matchFiles(bundle, files, content) {
287
+ const { commitments, notRead } = await readPack(bundle, content, null);
288
+ return match(commitments, notRead, files);
289
+ }
290
+ /** The same reading over every pack of a case, each commitment naming its pack's `ref`. */
291
+ export async function matchCaseFiles(packs, files) {
292
+ const commitments = [];
293
+ const notRead = [];
294
+ for (const pack of packs) {
295
+ if (!isObject(pack))
296
+ throw new TypeError("matchCaseFiles: each pack is an object {ref, bundle}");
297
+ if (typeof pack.ref !== "string")
298
+ throw new TypeError("matchCaseFiles: a pack's ref is a string");
299
+ const r = await readPack(pack.bundle, pack.content, pack.ref);
300
+ commitments.push(...r.commitments);
301
+ notRead.push(...r.notRead);
302
+ }
303
+ return match(commitments, notRead, files);
304
+ }
305
+ function commitmentJson(c) {
306
+ return {
307
+ pack_ref: c.packRef,
308
+ event_id: c.eventId,
309
+ kind: c.kind,
310
+ part_index: c.partIndex,
311
+ content_hash: c.contentHash,
312
+ signer: c.signer,
313
+ signed_at: c.signedAt,
314
+ };
315
+ }
316
+ /** The reading as the `files` member of `--json`, identical in both kernels. */
317
+ export function fileMatchToJson(reading) {
318
+ return {
319
+ offered: reading.offered,
320
+ matched: reading.matched.map((m) => ({
321
+ name: m.name,
322
+ sha256: m.sha256,
323
+ records: m.records.map(commitmentJson),
324
+ })),
325
+ unmatched: reading.unmatched.map((u) => ({ name: u.name, sha256: u.sha256 })),
326
+ not_offered: reading.notOffered.map(commitmentJson),
327
+ not_read: reading.notRead.map((n) => ({
328
+ pack_ref: n.packRef,
329
+ event_id: n.eventId,
330
+ reason: n.reason,
331
+ rendered: renderNotRead(n),
332
+ })),
333
+ scope: reading.scope,
334
+ };
335
+ }
336
+ function recordName(eventId) {
337
+ return eventId !== null ? `record ${eventId}` : "a record with no event_id";
338
+ }
339
+ function renderCommitment(c) {
340
+ const what = c.kind === "record"
341
+ ? recordName(c.eventId)
342
+ : `file part ${c.partIndex} of the message in ${recordName(c.eventId)}`;
343
+ const where = c.packRef !== null ? ` in pack ${c.packRef}` : "";
344
+ const when = c.signedAt !== null ? c.signedAt : "no stated time";
345
+ return `${what}${where}, signed by ${c.signer}, signed_at ${when}`;
346
+ }
347
+ function renderNotRead(n) {
348
+ const where = n.packRef !== null ? ` in pack ${n.packRef}` : "";
349
+ if (n.reason === "no_records")
350
+ return `no record read${where}: ${REASON_TEXT[n.reason]}`;
351
+ return `${recordName(n.eventId)}${where} not read: ${REASON_TEXT[n.reason]}`;
352
+ }
353
+ /** The text report's lines for the reading, identical in both kernels. */
354
+ export function renderFileMatch(reading) {
355
+ const lines = [
356
+ `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`,
359
+ ];
360
+ for (const m of reading.matched) {
361
+ lines.push(` vouched for ${m.name} sha256 ${m.sha256}`);
362
+ for (const c of m.records)
363
+ lines.push(` by ${renderCommitment(c)}`);
364
+ }
365
+ for (const u of reading.unmatched)
366
+ 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)}`);
369
+ }
370
+ for (const n of reading.notRead)
371
+ lines.push(` not read ${renderNotRead(n)}`);
372
+ lines.push(` ${reading.scope}`);
373
+ return lines;
374
+ }