burnledger 0.9.1 → 0.10.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.
Files changed (60) hide show
  1. package/dist/cjs/audit-pack.d.ts +201 -0
  2. package/dist/cjs/audit-pack.d.ts.map +1 -0
  3. package/dist/cjs/audit-pack.js +867 -0
  4. package/dist/cjs/audit-pack.js.map +1 -0
  5. package/dist/cjs/client.d.ts +41 -1
  6. package/dist/cjs/client.d.ts.map +1 -1
  7. package/dist/cjs/client.js +58 -0
  8. package/dist/cjs/client.js.map +1 -1
  9. package/dist/cjs/index.d.ts +46 -2
  10. package/dist/cjs/index.d.ts.map +1 -1
  11. package/dist/cjs/index.js +69 -2
  12. package/dist/cjs/index.js.map +1 -1
  13. package/dist/cjs/models.d.ts +28 -2
  14. package/dist/cjs/models.d.ts.map +1 -1
  15. package/dist/cjs/models.js +19 -1
  16. package/dist/cjs/models.js.map +1 -1
  17. package/dist/cjs/run-record.d.ts +130 -0
  18. package/dist/cjs/run-record.d.ts.map +1 -0
  19. package/dist/cjs/run-record.js +272 -0
  20. package/dist/cjs/run-record.js.map +1 -0
  21. package/dist/cjs/verify.d.ts +58 -0
  22. package/dist/cjs/verify.d.ts.map +1 -1
  23. package/dist/cjs/verify.js +207 -5
  24. package/dist/cjs/verify.js.map +1 -1
  25. package/dist/esm/audit-pack.d.ts +201 -0
  26. package/dist/esm/audit-pack.d.ts.map +1 -0
  27. package/dist/esm/audit-pack.js +858 -0
  28. package/dist/esm/audit-pack.js.map +1 -0
  29. package/dist/esm/cli.d.ts +33 -0
  30. package/dist/esm/cli.d.ts.map +1 -1
  31. package/dist/esm/cli.js +225 -5
  32. package/dist/esm/cli.js.map +1 -1
  33. package/dist/esm/client.d.ts +41 -1
  34. package/dist/esm/client.d.ts.map +1 -1
  35. package/dist/esm/client.js +59 -1
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/index.d.ts +46 -2
  38. package/dist/esm/index.d.ts.map +1 -1
  39. package/dist/esm/index.js +52 -1
  40. package/dist/esm/index.js.map +1 -1
  41. package/dist/esm/models.d.ts +28 -2
  42. package/dist/esm/models.d.ts.map +1 -1
  43. package/dist/esm/models.js +18 -1
  44. package/dist/esm/models.js.map +1 -1
  45. package/dist/esm/run-record.d.ts +130 -0
  46. package/dist/esm/run-record.d.ts.map +1 -0
  47. package/dist/esm/run-record.js +262 -0
  48. package/dist/esm/run-record.js.map +1 -0
  49. package/dist/esm/verify.d.ts +58 -0
  50. package/dist/esm/verify.d.ts.map +1 -1
  51. package/dist/esm/verify.js +200 -8
  52. package/dist/esm/verify.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/audit-pack.ts +1067 -0
  55. package/src/cli.ts +234 -5
  56. package/src/client.ts +82 -1
  57. package/src/index.ts +117 -1
  58. package/src/models.ts +47 -3
  59. package/src/run-record.ts +371 -0
  60. package/src/verify.ts +228 -8
@@ -0,0 +1,867 @@
1
+ "use strict";
2
+ /** Audit pack verification (docs/audit-pack.md).
3
+ *
4
+ * A port of core/auditpack.go and core/auditpack_footer.go, verdict for
5
+ * verdict and reason string for reason string. Every record inside a pack is
6
+ * verified exactly as `burnledger check --cert` verifies one, through the same
7
+ * verifyCertificateWithStatus and verifyTransparency. What this file adds is
8
+ * the framing — header, footer, digest, cursor chain — that lets a reader tell
9
+ * a whole page from a subset and a whole set of pages from a partial one, and
10
+ * the footer's signed statement, which is the issuer's word on the count and
11
+ * digest rather than the host's.
12
+ *
13
+ * The single-record verifier reports a refusal by THROWING a VerificationError
14
+ * whose message is prose, where Go returns a named verdict. A pack result is
15
+ * read beside the Go one — testdata/audit_pack/index.json pins the two to the
16
+ * same strings — so the throw is mapped back to Go's name here rather than
17
+ * leaking one SDK's wording into a cross-language contract.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.PAYLOAD_TYPE_AUDIT_PACK_FOOTER = exports.AUDIT_PACK_FORMAT = void 0;
21
+ exports.auditPackDigest = auditPackDigest;
22
+ exports.verifyAuditPack = verifyAuditPack;
23
+ exports.buildAuditPackFooterPayload = buildAuditPackFooterPayload;
24
+ exports.verifyAuditPackFooter = verifyAuditPackFooter;
25
+ exports.auditPackFooterStatement = auditPackFooterStatement;
26
+ exports.chainAuditPackPages = chainAuditPackPages;
27
+ const errors_js_1 = require("./errors.js");
28
+ const verify_js_1 = require("./verify.js");
29
+ const run_record_js_1 = require("./run-record.js");
30
+ exports.AUDIT_PACK_FORMAT = "burnledger.audit_pack.v1";
31
+ /** Mirrors core.auditPackMaxLine: a record is a few kilobytes, and a line past
32
+ * sixteen megabytes is not one this reader should buffer. */
33
+ const MAX_LINE_BYTES = 16 << 20;
34
+ const NOT_AUDIT_PACK = "not an audit pack: the first line is not an audit pack header";
35
+ exports.PAYLOAD_TYPE_AUDIT_PACK_FOOTER = "burnledger.audit_pack_footer.v1";
36
+ function isObject(v) {
37
+ return v !== null && typeof v === "object" && !Array.isArray(v);
38
+ }
39
+ function message(e) {
40
+ return e instanceof Error ? e.message : String(e);
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // Go verdict names for this SDK's refusals
44
+ // ---------------------------------------------------------------------------
45
+ /** VerificationError message prefixes → core.VerificationResult, in the order
46
+ * verifyCertificate and verifyStatusStatement raise them. A message none of
47
+ * these match came from a field this SDK could not READ (decodeFixed,
48
+ * requireUint, formatTimestamp, objectAt), which is where Go's json.Unmarshal
49
+ * refuses the document before any verdict — so it is reported the way Go
50
+ * reports that, as an unreadable certificate document. */
51
+ const OFFLINE_VERDICTS = [
52
+ ["unknown issuer key:", "UNKNOWN_KEY"],
53
+ ["issuer key is compromised:", "KEY_COMPROMISED"],
54
+ ["issuer key was not valid when it signed:", "KEY_OUTSIDE_VALIDITY"],
55
+ ["issuer key has an unusable status", "UNKNOWN_KEY"],
56
+ ["certificate signature is invalid", "INVALID_CERTIFICATE_SIGNATURE"],
57
+ ["attestation signature is invalid", "INVALID_ATTESTATION_SIGNATURE"],
58
+ ["certificate attests no systems", "INCOMPLETE_VERIFICATION"],
59
+ ["malformed systems:", "MALFORMED_SYSTEMS"],
60
+ ["data still present:", "DATA_STILL_PRESENT"],
61
+ ["incomplete verification:", "INCOMPLETE_VERIFICATION"],
62
+ ["status statement signed by unknown key:", "UNKNOWN_KEY"],
63
+ ["status statement key is compromised:", "KEY_COMPROMISED"],
64
+ ["status statement key was not valid when it signed:", "KEY_OUTSIDE_VALIDITY"],
65
+ ["status statement key has an unusable status", "UNKNOWN_KEY"],
66
+ ["status statement signature is invalid", "INVALID_STATUS_SIGNATURE"],
67
+ ["status statement is about a different certificate", "STATUS_STATEMENT_MISMATCH"],
68
+ // Go's buildCertificateStatusPayload failing is INVALID_STATUS_SIGNATURE;
69
+ // here the same fields are refused by decodeFixed under a "status." path.
70
+ ["status.", "INVALID_STATUS_SIGNATURE"],
71
+ ];
72
+ const OFFLINE_CODES = new Map([
73
+ [errors_js_1.CERTIFICATE_REVOKED, "REVOKED"],
74
+ [errors_js_1.UNSUPPORTED_FORMAT_VERSION, "UNKNOWN_FORMAT_VERSION"],
75
+ [errors_js_1.UNSUPPORTED_ALGORITHM, "UNSUPPORTED_ALGORITHM"],
76
+ ]);
77
+ function offlineVerdict(e) {
78
+ const coded = e.code === undefined ? undefined : OFFLINE_CODES.get(e.code);
79
+ if (coded !== undefined)
80
+ return coded;
81
+ for (const [prefix, verdict] of OFFLINE_VERDICTS) {
82
+ if (e.message.startsWith(prefix))
83
+ return verdict;
84
+ }
85
+ return null;
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Framing: the strict line scanner (core.ScanAuditPack)
89
+ // ---------------------------------------------------------------------------
90
+ const decoder = new TextDecoder();
91
+ const encoder = new TextEncoder();
92
+ /** Trailing "\r" and "\n" stripped, as bytes.TrimRight(line, "\r\n") does. */
93
+ function trimLineEnd(line) {
94
+ let end = line.length;
95
+ while (end > 0 && (line[end - 1] === 0x0a || line[end - 1] === 0x0d))
96
+ end--;
97
+ return line.subarray(0, end);
98
+ }
99
+ function isBlank(line) {
100
+ for (const b of line) {
101
+ if (b !== 0x20 && b !== 0x09 && b !== 0x0a && b !== 0x0d && b !== 0x0b && b !== 0x0c)
102
+ return false;
103
+ }
104
+ return true;
105
+ }
106
+ function stringOrNull(v, field) {
107
+ if (v === null || v === undefined)
108
+ return null;
109
+ if (typeof v !== "string")
110
+ throw new Error(`${field} is not a string`);
111
+ return v;
112
+ }
113
+ function optionalString(v, field) {
114
+ if (v === null || v === undefined)
115
+ return undefined;
116
+ if (typeof v !== "string")
117
+ throw new Error(`${field} is not a string`);
118
+ return v;
119
+ }
120
+ function requireInt(v, field) {
121
+ // Go's int and uint64 refuse a fraction or, for uint64, a negative.
122
+ if (typeof v !== "number" || !Number.isSafeInteger(v))
123
+ throw new Error(`${field} is not an integer`);
124
+ return v;
125
+ }
126
+ /** The type checks encoding/json applies unmarshalling core.AuditPackHeader.
127
+ * Anything it would refuse is refused here with the same outcome: the whole
128
+ * page is UNREADABLE. Value checks (hex lengths, the format string) come after,
129
+ * as they do in Go. */
130
+ function parseHeader(doc) {
131
+ const format = optionalString(doc.format, "format") ?? "";
132
+ // time.Time refuses anything that is not RFC 3339, and reads null or an
133
+ // absent key as the zero time.
134
+ const generatedAt = (0, verify_js_1.formatTimestamp)(optionalString(doc.generated_at, "generated_at"), "generated_at");
135
+ const rawFilters = doc.filters;
136
+ const filters = { status: null, issuedAfter: null, issuedBefore: null, runId: null };
137
+ if (rawFilters !== undefined && rawFilters !== null) {
138
+ if (!isObject(rawFilters))
139
+ throw new Error("filters is not an object");
140
+ filters.status = stringOrNull(rawFilters.status, "filters.status");
141
+ filters.issuedAfter = stringOrNull(rawFilters.issued_after, "filters.issued_after");
142
+ filters.issuedBefore = stringOrNull(rawFilters.issued_before, "filters.issued_before");
143
+ filters.runId = stringOrNull(rawFilters.run_id, "filters.run_id");
144
+ }
145
+ const cursor = stringOrNull(doc.cursor, "cursor");
146
+ let issuerKeys = [];
147
+ const rawKeys = doc.issuer_keys;
148
+ if (rawKeys !== undefined && rawKeys !== null) {
149
+ if (!isObject(rawKeys))
150
+ throw new Error("issuer_keys is not an object");
151
+ if (rawKeys.keys !== undefined && rawKeys.keys !== null) {
152
+ if (!Array.isArray(rawKeys.keys))
153
+ throw new Error("issuer_keys.keys is not an array");
154
+ issuerKeys = rawKeys.keys.map((k, i) => {
155
+ if (!isObject(k))
156
+ throw new Error(`issuer_keys.keys[${i}] is not an object`);
157
+ return k;
158
+ });
159
+ }
160
+ }
161
+ const header = {
162
+ format,
163
+ generatedAt,
164
+ filters,
165
+ cursor,
166
+ issuerKeys,
167
+ };
168
+ const rawHead = doc.log_head;
169
+ if (rawHead !== undefined && rawHead !== null) {
170
+ if (!isObject(rawHead))
171
+ throw new Error("log_head is not an object");
172
+ const head = {
173
+ tree_size: requireInt(rawHead.tree_size ?? 0, "log_head.tree_size"),
174
+ root_hash: optionalString(rawHead.root_hash, "log_head.root_hash") ?? "",
175
+ timestamp: optionalString(rawHead.timestamp, "log_head.timestamp") ?? "",
176
+ signature: optionalString(rawHead.signature, "log_head.signature") ?? "",
177
+ };
178
+ if (head.tree_size < 0)
179
+ throw new Error("log_head.tree_size is negative");
180
+ const logId = optionalString(rawHead.log_id, "log_head.log_id");
181
+ if (logId !== undefined)
182
+ head.log_id = logId;
183
+ header.logHead = head;
184
+ }
185
+ // Kept as it arrived, as Go's json.RawMessage does; the verifier parses it.
186
+ // A present key that is not an object — null included — is refused as
187
+ // core.ParseRunRecordDocument refuses it.
188
+ const rawRun = doc.run_record;
189
+ if (rawRun !== undefined) {
190
+ if (!isObject(rawRun))
191
+ throw new Error("run_record: run record: not a JSON object");
192
+ header.runRecord = rawRun;
193
+ }
194
+ return header;
195
+ }
196
+ /** The type checks encoding/json applies unmarshalling core.AuditPackFooter,
197
+ * `signed` included: a null or absent `signed` is an unsigned footer, and a
198
+ * field of the wrong JSON type makes the page UNREADABLE. Whether the hex and
199
+ * the timestamp READ is the statement rebuild's question, as in Go. */
200
+ function parseFooter(doc) {
201
+ const footer = {
202
+ recordCount: requireInt(doc.record_count ?? 0, "record_count"),
203
+ nextCursor: stringOrNull(doc.next_cursor, "next_cursor"),
204
+ recordsSha256: optionalString(doc.records_sha256, "records_sha256") ?? "",
205
+ };
206
+ const raw = doc.signed;
207
+ if (raw === undefined || raw === null)
208
+ return footer;
209
+ if (!isObject(raw))
210
+ throw new Error("signed is not an object");
211
+ const signed = {
212
+ statement_issued_at: optionalString(raw.statement_issued_at, "signed.statement_issued_at") ?? "",
213
+ sth_tree_size: requireInt(raw.sth_tree_size ?? 0, "signed.sth_tree_size"),
214
+ sth_root_hash: optionalString(raw.sth_root_hash, "signed.sth_root_hash") ?? "",
215
+ signature: optionalString(raw.signature, "signed.signature") ?? "",
216
+ key_id: optionalString(raw.key_id, "signed.key_id") ?? "",
217
+ };
218
+ if (signed.sth_tree_size < 0)
219
+ throw new Error("signed.sth_tree_size is negative");
220
+ footer.signed = signed;
221
+ return footer;
222
+ }
223
+ /** Reads one page. The rules are strict because the file is evidence: the
224
+ * header is the first non-empty line, the footer when present is the last, a
225
+ * second header is refused (two pages concatenated as bytes are not one page),
226
+ * and any line after a footer is refused. Throws an Error whose message is the
227
+ * pack's UNREADABLE reason. */
228
+ async function scanAuditPack(crypto, source, cb) {
229
+ let header = null;
230
+ let footer = null;
231
+ let records = 0;
232
+ const digested = [];
233
+ let at = 0;
234
+ while (at < source.length) {
235
+ const nl = source.indexOf(0x0a, at);
236
+ const end = nl < 0 ? source.length : nl + 1;
237
+ const line = source.subarray(at, end);
238
+ at = end;
239
+ if (line.length > MAX_LINE_BYTES)
240
+ throw new Error(`audit pack: a line exceeds ${MAX_LINE_BYTES} bytes`);
241
+ const trimmed = trimLineEnd(line);
242
+ if (isBlank(trimmed))
243
+ continue;
244
+ if (footer !== null)
245
+ throw new Error("audit pack: a line follows the footer; the footer must be last");
246
+ // encoding/json unmarshals `null` into a struct as a no-op, so a bare null
247
+ // line is a record with nothing in it rather than a line that is not JSON.
248
+ let parsed;
249
+ try {
250
+ parsed = JSON.parse(decoder.decode(trimmed));
251
+ }
252
+ catch {
253
+ parsed = undefined;
254
+ }
255
+ const readable = parsed === null ||
256
+ (isObject(parsed) && isMarker(parsed._audit_pack_header) && isMarker(parsed._audit_pack_footer));
257
+ if (!readable) {
258
+ if (header === null)
259
+ throw new Error(NOT_AUDIT_PACK);
260
+ throw new Error(`audit pack: record ${records + 1} is not a JSON object`);
261
+ }
262
+ const doc = parsed === null ? {} : parsed;
263
+ if (header === null) {
264
+ if (doc._audit_pack_header !== true)
265
+ throw new Error(NOT_AUDIT_PACK);
266
+ try {
267
+ header = parseHeader(doc);
268
+ }
269
+ catch (e) {
270
+ throw new Error(`audit pack: header: ${message(e)}`);
271
+ }
272
+ if (header.format !== exports.AUDIT_PACK_FORMAT) {
273
+ throw new Error(`audit pack: format ${JSON.stringify(header.format)} is not ${exports.AUDIT_PACK_FORMAT}`);
274
+ }
275
+ await cb.onHeader(header);
276
+ continue;
277
+ }
278
+ if (doc._audit_pack_header === true) {
279
+ throw new Error("audit pack: a second header; concatenate pages by their records, not their bytes");
280
+ }
281
+ if (doc._audit_pack_footer === true) {
282
+ try {
283
+ footer = parseFooter(doc);
284
+ }
285
+ catch (e) {
286
+ throw new Error(`audit pack: footer: ${message(e)}`);
287
+ }
288
+ continue;
289
+ }
290
+ digested.push(trimmed);
291
+ records++;
292
+ await cb.onRecord(records, trimmed);
293
+ }
294
+ if (header === null)
295
+ throw new Error(NOT_AUDIT_PACK);
296
+ return { header, footer, records, digest: await auditPackDigest(crypto, digested) };
297
+ }
298
+ /** A marker is a bool or absent; anything else fails Go's probe unmarshal. */
299
+ function isMarker(v) {
300
+ return v === undefined || typeof v === "boolean";
301
+ }
302
+ /** The one definition of the footer's records_sha256: SHA-256 over every
303
+ * record line followed by a newline, in file order, with a trailing carriage
304
+ * return stripped. Mirrors core.AuditPackDigest. */
305
+ async function auditPackDigest(crypto, lines) {
306
+ let total = 0;
307
+ const trimmed = lines.map((l) => {
308
+ const t = trimLineEnd(l);
309
+ total += t.length + 1;
310
+ return t;
311
+ });
312
+ const joined = new Uint8Array(total);
313
+ let at = 0;
314
+ for (const t of trimmed) {
315
+ joined.set(t, at);
316
+ at += t.length;
317
+ joined[at++] = 0x0a;
318
+ }
319
+ return (0, verify_js_1.bytesToHex)(await crypto.sha256(joined));
320
+ }
321
+ // ---------------------------------------------------------------------------
322
+ // Verification (core.VerifyAuditPackWith)
323
+ // ---------------------------------------------------------------------------
324
+ /** The pack's own issuer_keys as a key set, under the checks cli.ts loadKeys
325
+ * applies to a --keys file: the key parses, a declared key_id is
326
+ * sha256(public_key), a compromised key names its date, and a duplicate does
327
+ * not contradict itself. The map is keyed on the DERIVED id so a lying key_id
328
+ * cannot shadow a genuine one. Throws where Go's KeyListDocument.PublicKeys
329
+ * returns an error. */
330
+ async function keysFromEntries(crypto, entries) {
331
+ const map = new Map();
332
+ for (const e of entries) {
333
+ if (e.key_status === "compromised" && (e.compromised_from === undefined || e.compromised_from === null)) {
334
+ throw new Error(`key ${e.key_id ?? "?"}: key_status is compromised but compromised_from is absent`);
335
+ }
336
+ let pki;
337
+ try {
338
+ pki = await (0, verify_js_1.publicKeyFromHex)(crypto, e.public_key, {
339
+ keyStatus: e.key_status,
340
+ notBefore: e.not_before,
341
+ notAfter: e.not_after ?? undefined,
342
+ compromisedFrom: e.compromised_from ?? undefined,
343
+ });
344
+ }
345
+ catch (err) {
346
+ throw new Error(`key ${e.key_id ?? "?"}: invalid public_key: ${message(err)}`);
347
+ }
348
+ if (e.key_id !== undefined && e.key_id !== pki.keyId) {
349
+ throw new Error(`key_id ${e.key_id} does not match sha256(public_key) (${pki.keyId})`);
350
+ }
351
+ const existing = map.get(pki.keyId);
352
+ if (existing !== undefined && existing.keyStatus !== pki.keyStatus) {
353
+ throw new Error(`conflicting key_status for duplicate key ${pki.keyId}`);
354
+ }
355
+ map.set(pki.keyId, pki);
356
+ }
357
+ return map;
358
+ }
359
+ const HEX_32 = /^[0-9a-fA-F]{64}$/;
360
+ const HEX_64 = /^[0-9a-fA-F]{128}$/;
361
+ /** Mirrors core.verifyAuditPackLogHead. A head names no key id, so every key
362
+ * usable at the head's own timestamp is tried; the one that verifies is
363
+ * reported so the reader knows which authority the head rests on. Returns
364
+ * UNREADABLE where Go's SignedTreeHeadDocument.SignedTreeHead refuses the
365
+ * document: a short or long hex value, or a timestamp that is not RFC 3339. */
366
+ async function verifyLogHead(crypto, head, keys) {
367
+ if (!HEX_32.test(head.root_hash) || !HEX_64.test(head.signature)) {
368
+ return { result: "UNREADABLE", keyId: "" };
369
+ }
370
+ let anchor;
371
+ let payload;
372
+ try {
373
+ anchor = Math.floor(Date.parse((0, verify_js_1.formatTimestamp)(head.timestamp, "log_head.timestamp")) / 1000);
374
+ payload = (0, verify_js_1.buildTreeHeadPayload)({ ...head });
375
+ }
376
+ catch (e) {
377
+ if (e instanceof errors_js_1.VerificationError)
378
+ return { result: "UNREADABLE", keyId: "" };
379
+ throw e;
380
+ }
381
+ const signature = (0, verify_js_1.hexToBytes)(head.signature);
382
+ for (const [id, key] of keys) {
383
+ if (!(0, verify_js_1.keyIsUsable)((0, verify_js_1.evaluateKey)(key, anchor)))
384
+ continue;
385
+ if (await crypto.ed25519Verify(key.keyBytes, payload, signature))
386
+ return { result: "VALID", keyId: id };
387
+ }
388
+ return { result: "INVALID_SIGNATURE", keyId: "" };
389
+ }
390
+ /** The rule a record must meet to count as verified — the one cli.ts's exit
391
+ * code applies to a single record. The two soft key verdicts are refused here
392
+ * as there: a pack an auditor relies on is exactly the place a "needs a human"
393
+ * verdict must not pass as a number. */
394
+ function recordAccepted(offline, transparency) {
395
+ if (offline !== "VALID" && offline !== "VALID_REVOCATION_UNKNOWN")
396
+ return false;
397
+ return transparency === "VALID" || transparency === "NOT_INCLUDED";
398
+ }
399
+ /** Verify one page offline, exactly as core.VerifyAuditPackWith does.
400
+ *
401
+ * `keys` null means the pack's own issuer_keys are used and the result says so
402
+ * (trust on first use). A supplied map — even an empty one — outranks them.
403
+ * `now` is the reader's clock, used to read a status statement's window.
404
+ */
405
+ async function verifyAuditPack(crypto, source, keys, now, opts = {}) {
406
+ const res = {
407
+ verdict: "UNREADABLE",
408
+ reason: "",
409
+ header: null,
410
+ footer: null,
411
+ records: 0,
412
+ revoked: 0,
413
+ notIncluded: 0,
414
+ failed: [],
415
+ logHead: "ABSENT",
416
+ logHeadKeyId: "",
417
+ keysFromPack: false,
418
+ digest: "",
419
+ statusChecked: 0,
420
+ statusRevoked: 0,
421
+ revokedIds: [],
422
+ footerStatement: "UNSIGNED",
423
+ footerKeyId: "",
424
+ run: null,
425
+ runResult: "",
426
+ runInclusion: "",
427
+ runSubjects: [],
428
+ runRecordIds: [],
429
+ runMislabelled: 0,
430
+ };
431
+ let keySet = keys;
432
+ let headSize = null;
433
+ const bytes = typeof source === "string" ? encoder.encode(source) : source;
434
+ let scan;
435
+ try {
436
+ scan = await scanAuditPack(crypto, bytes, {
437
+ async onHeader(h) {
438
+ if (keySet === null) {
439
+ if (h.issuerKeys.length === 0) {
440
+ throw new Error("audit pack: the pack carries no issuer keys and none were supplied");
441
+ }
442
+ try {
443
+ keySet = await keysFromEntries(crypto, h.issuerKeys);
444
+ }
445
+ catch (e) {
446
+ throw new Error(`audit pack: issuer_keys: ${message(e)}`);
447
+ }
448
+ res.keysFromPack = true;
449
+ }
450
+ if (h.logHead !== undefined) {
451
+ const { result, keyId } = await verifyLogHead(crypto, h.logHead, keySet);
452
+ res.logHead = result;
453
+ res.logHeadKeyId = keyId;
454
+ if (result === "VALID")
455
+ headSize = h.logHead.tree_size;
456
+ }
457
+ if (h.runRecord !== undefined) {
458
+ let rec;
459
+ try {
460
+ rec = (0, run_record_js_1.parseRunRecordDocument)(h.runRecord);
461
+ }
462
+ catch (e) {
463
+ if (!(e instanceof errors_js_1.VerificationError))
464
+ throw e;
465
+ throw new Error(`audit pack: run_record: ${e.message}`);
466
+ }
467
+ res.run = rec;
468
+ res.runResult = await (0, run_record_js_1.verifyRunRecord)(crypto, rec, keySet);
469
+ res.runInclusion = "NOT_INCLUDED";
470
+ const key = keySet.get(rec.issuer.keyId);
471
+ if (key !== undefined)
472
+ res.runInclusion = await (0, run_record_js_1.verifyRunRecordInclusion)(crypto, rec, key);
473
+ }
474
+ },
475
+ async onRecord(index, line) {
476
+ // The scanner delivers the header before any record, and onHeader
477
+ // either resolved a key set or threw.
478
+ if (keySet === null)
479
+ throw new Error("audit pack: record before header");
480
+ res.records = index;
481
+ const f = await verifyRecord(crypto, index, line, keySet, headSize, now, opts, res);
482
+ if (f !== null)
483
+ res.failed.push(f);
484
+ },
485
+ });
486
+ }
487
+ catch (e) {
488
+ res.verdict = "UNREADABLE";
489
+ res.reason = message(e);
490
+ return res;
491
+ }
492
+ res.header = scan.header;
493
+ res.footer = scan.footer;
494
+ res.digest = scan.digest;
495
+ const findings = [];
496
+ if (res.failed.length > 0)
497
+ findings.push(`${res.failed.length} of ${res.records} records refused`);
498
+ if (res.run !== null) {
499
+ if (res.runResult !== "VALID") {
500
+ findings.push(`the run record does not verify: ${res.runResult}`);
501
+ }
502
+ else if (res.runInclusion !== "VALID" && res.runInclusion !== "NOT_INCLUDED") {
503
+ findings.push(`the run record's inclusion proof does not verify: ${res.runInclusion}`);
504
+ }
505
+ const filtered = scan.header.filters.runId;
506
+ if (filtered !== null && filtered !== res.run.runId) {
507
+ findings.push(`the run record is for run ${res.run.runId}, not the run the pack was filtered by`);
508
+ }
509
+ }
510
+ if (res.logHead === "INVALID_SIGNATURE") {
511
+ findings.push("the log head's signature does not verify under any supplied key");
512
+ }
513
+ else if (res.logHead === "UNREADABLE") {
514
+ findings.push("the log head cannot be read");
515
+ }
516
+ let invalid = findings.length > 0;
517
+ const incomplete = [];
518
+ if (scan.footer === null) {
519
+ incomplete.push("no footer: the page is truncated");
520
+ }
521
+ else if (scan.footer.recordCount !== scan.records) {
522
+ incomplete.push(`the footer says ${scan.footer.recordCount} records and the file carries ${scan.records}`);
523
+ }
524
+ else if (scan.footer.recordsSha256 !== scan.digest) {
525
+ incomplete.push("the record lines do not reproduce the footer's records_sha256");
526
+ }
527
+ // The footer's own statement, checked whether or not the lines agree with
528
+ // it: a signed footer the lines contradict is still evidence of what the
529
+ // issuer attested, and a reader is told both things.
530
+ if (scan.footer?.signed !== undefined) {
531
+ // onHeader resolved a key set or threw, and the scanner delivered the
532
+ // header before the footer.
533
+ const { result, keyId } = await verifyFooter(crypto, scan.header, scan.footer, keySet);
534
+ res.footerStatement = result;
535
+ res.footerKeyId = keyId;
536
+ if (result !== "VALID") {
537
+ findings.push(`the footer's signature does not verify: ${result}`);
538
+ invalid = true;
539
+ }
540
+ }
541
+ findings.push(...incomplete);
542
+ res.verdict = invalid ? "INVALID" : findings.length > 0 ? "INCOMPLETE" : "VERIFIED";
543
+ res.reason = findings.join("; ");
544
+ return res;
545
+ }
546
+ // ---------------------------------------------------------------------------
547
+ // The signed footer (core/auditpack_footer.go)
548
+ // ---------------------------------------------------------------------------
549
+ /** The canonical signing payload of a footer statement. Optional fields are
550
+ * omitted rather than encoded as null, as in every other statement: a page
551
+ * with no filter must sign the same bytes a verifier rebuilds from it. Mirrors
552
+ * core.BuildAuditPackFooterPayload. */
553
+ function buildAuditPackFooterPayload(s) {
554
+ const payload = { payload_type: exports.PAYLOAD_TYPE_AUDIT_PACK_FOOTER };
555
+ if (s.cursor !== null)
556
+ payload.cursor = s.cursor;
557
+ if (s.filters.issuedAfter !== null)
558
+ payload.issued_after = s.filters.issuedAfter;
559
+ if (s.filters.issuedBefore !== null)
560
+ payload.issued_before = s.filters.issuedBefore;
561
+ if (s.nextCursor !== null)
562
+ payload.next_cursor = s.nextCursor;
563
+ payload.record_count = s.recordCount;
564
+ payload.records_sha256 = (0, verify_js_1.bytesToHex)(s.recordsSha256);
565
+ if (s.filters.runId !== null)
566
+ payload.run_id = s.filters.runId;
567
+ payload.statement_issued_at = (0, verify_js_1.formatTimestamp)(s.statementIssuedAt, "statement_issued_at");
568
+ if (s.filters.status !== null)
569
+ payload.status = s.filters.status;
570
+ payload.sth_root_hash = (0, verify_js_1.bytesToHex)(s.sthRootHash);
571
+ payload.sth_tree_size = s.sthTreeSize;
572
+ return (0, verify_js_1.canonicalJson)(payload);
573
+ }
574
+ /** Checks a footer statement against the reader's keys, in the order every
575
+ * other verifier uses: the key's authority first, then the signature. Mirrors
576
+ * core.VerifyAuditPackFooter. A statement whose payload cannot be built is
577
+ * INVALID_SIGNATURE, as there. */
578
+ async function verifyAuditPackFooter(crypto, s, keys) {
579
+ const key = keys.get(s.keyId);
580
+ if (key === undefined)
581
+ return "UNKNOWN_KEY";
582
+ let anchor;
583
+ let payload;
584
+ try {
585
+ anchor = Math.floor(Date.parse((0, verify_js_1.formatTimestamp)(s.statementIssuedAt, "statement_issued_at")) / 1000);
586
+ payload = buildAuditPackFooterPayload(s);
587
+ }
588
+ catch (e) {
589
+ if (e instanceof errors_js_1.VerificationError)
590
+ return "INVALID_SIGNATURE";
591
+ throw e;
592
+ }
593
+ if (!(0, verify_js_1.keyIsUsable)((0, verify_js_1.evaluateKey)(key, anchor)))
594
+ return "KEY_UNUSABLE";
595
+ if (!(await crypto.ed25519Verify(key.keyBytes, payload, s.signature)))
596
+ return "INVALID_SIGNATURE";
597
+ return "VALID";
598
+ }
599
+ /** Rebuilds the signed statement from a page's header and footer, as
600
+ * core.AuditPackFooter.Statement does. Throws for an unsigned footer, and for
601
+ * signed fields that do not parse, rather than returning a statement that
602
+ * would verify against nothing. */
603
+ function auditPackFooterStatement(header, footer) {
604
+ const signed = footer.signed;
605
+ if (signed === undefined)
606
+ throw new Error("audit pack: the footer carries no signed statement");
607
+ if (!HEX_32.test(footer.recordsSha256))
608
+ throw new Error("audit pack: footer: records_sha256 is not 32 bytes of hex");
609
+ if (!HEX_32.test(signed.sth_root_hash))
610
+ throw new Error("audit pack: footer: sth_root_hash is not 32 bytes of hex");
611
+ if (!HEX_64.test(signed.signature))
612
+ throw new Error("audit pack: footer: signature is not 64 bytes of hex");
613
+ return {
614
+ filters: header.filters,
615
+ cursor: header.cursor,
616
+ nextCursor: footer.nextCursor,
617
+ recordCount: footer.recordCount,
618
+ recordsSha256: (0, verify_js_1.hexToBytes)(footer.recordsSha256),
619
+ sthTreeSize: signed.sth_tree_size,
620
+ sthRootHash: (0, verify_js_1.hexToBytes)(signed.sth_root_hash),
621
+ statementIssuedAt: (0, verify_js_1.formatTimestamp)(signed.statement_issued_at, "statement_issued_at"),
622
+ signature: (0, verify_js_1.hexToBytes)(signed.signature),
623
+ keyId: signed.key_id,
624
+ };
625
+ }
626
+ /** Mirrors core.verifyAuditPackFooter. Signed fields that do not parse are
627
+ * INVALID_SIGNATURE: the issuer wrote something a reader cannot check, which
628
+ * is a refusal, not an absence. After a VALID signature the signed anchor must
629
+ * be the header's own log head, when the header has a readable one: a footer
630
+ * signed against a different head is a footer for a different page. */
631
+ async function verifyFooter(crypto, header, footer, keys) {
632
+ let stmt;
633
+ try {
634
+ stmt = auditPackFooterStatement(header, footer);
635
+ }
636
+ catch (e) {
637
+ if (e instanceof Error)
638
+ return { result: "INVALID_SIGNATURE", keyId: "" };
639
+ throw e;
640
+ }
641
+ const result = await verifyAuditPackFooter(crypto, stmt, keys);
642
+ if (result !== "VALID")
643
+ return { result, keyId: "" };
644
+ const head = header.logHead;
645
+ if (head !== undefined && logHeadReadable(head)) {
646
+ if (head.tree_size !== stmt.sthTreeSize || head.root_hash.toLowerCase() !== (0, verify_js_1.bytesToHex)(stmt.sthRootHash)) {
647
+ return { result: "INVALID_SIGNATURE", keyId: "" };
648
+ }
649
+ }
650
+ return { result: "VALID", keyId: stmt.keyId };
651
+ }
652
+ /** Whether Go's SignedTreeHeadDocument.SignedTreeHead would parse the head:
653
+ * the hex lengths and an RFC 3339 timestamp. An unreadable head cannot anchor
654
+ * anything, so the footer is not compared with it. */
655
+ function logHeadReadable(head) {
656
+ if (!HEX_32.test(head.root_hash) || !HEX_64.test(head.signature))
657
+ return false;
658
+ try {
659
+ (0, verify_js_1.formatTimestamp)(head.timestamp, "log_head.timestamp");
660
+ return true;
661
+ }
662
+ catch (e) {
663
+ if (e instanceof errors_js_1.VerificationError)
664
+ return false;
665
+ throw e;
666
+ }
667
+ }
668
+ /** One record through the single-record verifier; why it was refused, or null.
669
+ * Mirrors core.verifyAuditPackRecord, including what is counted before a
670
+ * refusal: the REVOKED label, a statement obtained, a proof absent. */
671
+ async function verifyRecord(crypto, index, line, keys, headSize, now, opts, res) {
672
+ const refused = (certificateId, offline, transparency, reason) => ({ index, certificateId, offline, transparency, reason });
673
+ // The scanner already parsed this line once, to classify it; parsing again
674
+ // here keeps the verifier's reading of the envelope beside the checks that
675
+ // use it, as Go's does. The line is what the scanner accepted, so this
676
+ // cannot fail on shape — only a null line yields an empty envelope.
677
+ const rec = (JSON.parse(decoder.decode(line)) ?? {});
678
+ const envelopeId = typeof rec.id === "string" ? rec.id : "";
679
+ if (rec.status === "REVOKED")
680
+ res.revoked++;
681
+ if (rec.certificate === undefined || rec.certificate === null) {
682
+ return refused(envelopeId, "", "", "the record carries no certificate document");
683
+ }
684
+ if (!isObject(rec.certificate)) {
685
+ return refused(envelopeId, "", "", "unreadable certificate document: certificate is not a JSON object");
686
+ }
687
+ const cert = rec.certificate;
688
+ const id = typeof cert.certificate_id === "string" ? cert.certificate_id : envelopeId;
689
+ if (res.run !== null) {
690
+ // What this page contributes to the run's roots, taken before the record
691
+ // is judged: a refused record was still presented as the run's, and the
692
+ // roots are what say whether it belongs. Go reads these fields at parse,
693
+ // so a record they cannot be read from is unreadable there too.
694
+ let subjectHash;
695
+ let canonicalId;
696
+ let runId;
697
+ try {
698
+ const subject = cert.subject;
699
+ if (!isObject(subject))
700
+ throw new errors_js_1.VerificationError("subject is not an object");
701
+ subjectHash = (0, verify_js_1.decodeFixed)(subject.identifier_hash, "subject.identifier_hash", 32);
702
+ canonicalId = (0, verify_js_1.formatUuid)((0, verify_js_1.parseUuid)(id));
703
+ if (cert.run_id != null) {
704
+ if (typeof cert.run_id !== "string")
705
+ throw new errors_js_1.VerificationError("run_id is not a string");
706
+ runId = (0, verify_js_1.formatUuid)((0, verify_js_1.parseUuid)(cert.run_id));
707
+ }
708
+ }
709
+ catch (e) {
710
+ if (!(e instanceof errors_js_1.VerificationError) && !(e instanceof TypeError))
711
+ throw e;
712
+ return refused(id, "", "", `unreadable certificate document: ${e.message}`);
713
+ }
714
+ res.runSubjects.push(subjectHash);
715
+ res.runRecordIds.push(canonicalId);
716
+ const version = typeof cert.certificate_format_version === "string" ? cert.certificate_format_version : "";
717
+ const mislabelled = (runId !== undefined && runId !== res.run.runId) ||
718
+ (runId === undefined && (0, verify_js_1.signatureCoversRunId)(version));
719
+ if (mislabelled) {
720
+ res.runMislabelled++;
721
+ return refused(id, "", "", "the record's signed run_id does not name the pack's run");
722
+ }
723
+ }
724
+ let status = null;
725
+ if (opts.status !== undefined) {
726
+ status = await opts.status(id);
727
+ if (status !== null)
728
+ res.statusChecked++;
729
+ }
730
+ let offline;
731
+ try {
732
+ offline = await offlineVerdictOf(crypto, cert, keys, status, now);
733
+ }
734
+ catch (e) {
735
+ if (!(e instanceof errors_js_1.VerificationError))
736
+ throw e;
737
+ return refused(id, "", "", `unreadable certificate document: ${e.message}`);
738
+ }
739
+ if (offline === "REVOKED" && status !== null) {
740
+ // Evidenced revocation: a fresh statement, verified under the reader's
741
+ // keys, said so. Recorded, and the record otherwise stands on the verdict
742
+ // it would have had without the statement.
743
+ res.statusRevoked++;
744
+ res.revokedIds.push(id);
745
+ try {
746
+ offline = await offlineVerdictOf(crypto, cert, keys, null, now);
747
+ }
748
+ catch (e) {
749
+ if (!(e instanceof errors_js_1.VerificationError))
750
+ throw e;
751
+ return refused(id, "", "", `unreadable certificate document: ${e.message}`);
752
+ }
753
+ }
754
+ const issuer = cert.issuer;
755
+ const issuerKeyId = isObject(issuer) && typeof issuer.key_id === "string" ? issuer.key_id : "";
756
+ let transparency = "NOT_INCLUDED";
757
+ if (keys.has(issuerKeyId)) {
758
+ try {
759
+ const t = await (0, verify_js_1.verifyTransparency)(crypto, cert, keys);
760
+ transparency = t === "INCLUDED" ? "VALID" : "NOT_INCLUDED";
761
+ }
762
+ catch (e) {
763
+ if (!(e instanceof errors_js_1.VerificationError))
764
+ throw e;
765
+ transparency = (0, verify_js_1.transparencyVerdictName)(e);
766
+ }
767
+ }
768
+ if (transparency === "NOT_INCLUDED")
769
+ res.notIncluded++;
770
+ if (!recordAccepted(offline, transparency)) {
771
+ return refused(id, offline, transparency, `${offline} / transparency ${transparency}`);
772
+ }
773
+ const includedAt = treeSizeOf(cert);
774
+ if (headSize !== null && includedAt !== null && includedAt > headSize) {
775
+ return refused(id, offline, transparency, `included at tree size ${includedAt}, past the pack's log head at ${headSize}`);
776
+ }
777
+ return null;
778
+ }
779
+ /** The single-record verdict under Go's name. Throws the VerificationError
780
+ * back when it is not one Go has a verdict for — a document this SDK could
781
+ * not read. */
782
+ async function offlineVerdictOf(crypto, cert, keys, status, now) {
783
+ try {
784
+ return await (0, verify_js_1.verifyCertificateWithStatus)(crypto, cert, keys, status, now);
785
+ }
786
+ catch (e) {
787
+ if (!(e instanceof errors_js_1.VerificationError))
788
+ throw e;
789
+ const verdict = offlineVerdict(e);
790
+ if (verdict === null)
791
+ throw e;
792
+ return verdict;
793
+ }
794
+ }
795
+ /** The signed tree size the record's proof sits at; null when it carries no
796
+ * proof. By the time this is read the proof has verified, so the field is the
797
+ * one the signature covers. */
798
+ function treeSizeOf(cert) {
799
+ const t = cert.transparency;
800
+ if (!isObject(t))
801
+ return null;
802
+ const sth = t.signed_tree_head;
803
+ if (!isObject(sth) || typeof sth.tree_size !== "number")
804
+ return null;
805
+ return sth.tree_size;
806
+ }
807
+ /** Orders a set of pages by their cursors and says whether they are one
808
+ * complete answer: exactly one first page, each next_cursor met by a page that
809
+ * resumes from it, every file used, and a last page with no next cursor. The
810
+ * returned order is as far as the chain could be followed. */
811
+ function chainAuditPackPages(pages) {
812
+ const byCursor = new Map();
813
+ const firsts = [];
814
+ for (const p of pages) {
815
+ if (p.cursor === null) {
816
+ firsts.push(p);
817
+ continue;
818
+ }
819
+ const list = byCursor.get(p.cursor);
820
+ if (list === undefined)
821
+ byCursor.set(p.cursor, [p]);
822
+ else
823
+ list.push(p);
824
+ }
825
+ if (firsts.length === 0) {
826
+ return { order: [], complete: false, reason: "no first page: every file resumes from a cursor" };
827
+ }
828
+ if (firsts.length > 1) {
829
+ return {
830
+ order: [],
831
+ complete: false,
832
+ reason: `${firsts.length} files are first pages (${firsts[0].name} and ${firsts[1].name}); a query has one`,
833
+ };
834
+ }
835
+ const order = [];
836
+ const used = new Set();
837
+ let cur = firsts[0];
838
+ for (;;) {
839
+ order.push(cur);
840
+ used.add(cur.name);
841
+ if (cur.nextCursor === null)
842
+ break;
843
+ const next = byCursor.get(cur.nextCursor) ?? [];
844
+ if (next.length === 0) {
845
+ return {
846
+ order, complete: false,
847
+ reason: `${cur.name} says more pages follow and no file resumes from its cursor`,
848
+ };
849
+ }
850
+ if (next.length > 1) {
851
+ return {
852
+ order, complete: false,
853
+ reason: `${next.length} files resume from the same cursor (${next[0].name} and ${next[1].name})`,
854
+ };
855
+ }
856
+ if (used.has(next[0].name)) {
857
+ return { order, complete: false, reason: `the cursors loop back to ${next[0].name}` };
858
+ }
859
+ cur = next[0];
860
+ }
861
+ for (const p of pages) {
862
+ if (!used.has(p.name))
863
+ return { order, complete: false, reason: `${p.name} is not part of the chain` };
864
+ }
865
+ return { order, complete: true, reason: "" };
866
+ }
867
+ //# sourceMappingURL=audit-pack.js.map