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