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
package/src/cli.ts CHANGED
@@ -4,10 +4,12 @@
4
4
 
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { realpathSync } from "node:fs";
7
+ import { basename } from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { nodeCrypto } from "./crypto-node.js";
9
10
  import { CERTIFICATE_REVOKED, VerificationError } from "./errors.js";
10
11
  import {
12
+ bytesToHex,
11
13
  formatTimestamp,
12
14
  issuingImage,
13
15
  publicKeyFromHex,
@@ -27,6 +29,8 @@ import {
27
29
  } from "./keys.js";
28
30
  import { ANCHOR_INCONSISTENT, checkStatementAnchor, type AnchorResult } from "./anchor.js";
29
31
  import { unwrapStatusStatement } from "./status-document.js";
32
+ import { chainAuditPackPages, verifyAuditPack, type AuditPackPage, type AuditPackResult } from "./audit-pack.js";
33
+ import { checkRunAgainstRecords, type RunCheckRecord, type RunRecord } from "./run-record.js";
30
34
  import type { AuthorizationPolicy, PublicKeyInfo } from "./verify.js";
31
35
 
32
36
  // Exported from here too: tests/cli-revocation-unknown.test.ts reads it as the
@@ -40,23 +44,26 @@ type Cert = Record<string, unknown>;
40
44
  type StatusStatement = Record<string, unknown>;
41
45
  const USAGE =
42
46
  "Usage: burnledger check --cert <file> --keys <file> [--online --api-url <url>]\n" +
43
- " [[--require-certified | --require-certified-system <system_id>...] --customer-key-id <id>...]\n";
47
+ " [[--require-certified | --require-certified-system <system_id>...] --customer-key-id <id>...]\n" +
48
+ " burnledger check --pack <file> [--pack <file>...] [--keys <file>] [--online --api-url <url>]\n";
44
49
 
45
50
  function die(msg: string): never { process.stderr.write(msg); process.exit(2); }
46
51
 
47
52
  function parseArgs(argv: string[]): {
48
- certPath: string; keysPath: string; online: boolean; apiUrl: string;
53
+ certPath: string; packPaths: string[]; keysPath: string; online: boolean; apiUrl: string;
49
54
  policy: AuthorizationPolicy | undefined;
50
55
  } {
51
56
  const args = argv.slice(2);
52
57
  if (args[0] !== "check") die(USAGE);
53
58
  let certPath = "", keysPath = "", online = false, apiUrl = "";
54
59
  let requireCertified = false;
60
+ const packPaths: string[] = [];
55
61
  const systems: string[] = [];
56
62
  const keyIds: string[] = [];
57
63
  for (let i = 1; i < args.length; i++) {
58
64
  const a = args[i]!;
59
65
  if (a === "--cert") { certPath = args[++i] ?? die("--cert requires a value\n"); }
66
+ else if (a === "--pack") { packPaths.push(args[++i] ?? die("--pack requires a value\n")); }
60
67
  else if (a === "--keys") { keysPath = args[++i] ?? die("--keys requires a value\n"); }
61
68
  else if (a === "--online") { online = true; }
62
69
  else if (a === "--api-url") { apiUrl = args[++i] ?? die("--api-url requires a value\n"); }
@@ -73,7 +80,17 @@ function parseArgs(argv: string[]): {
73
80
  else if (a === "--customer-key-id") { keyIds.push(args[++i] ?? die("--customer-key-id requires a value\n")); }
74
81
  else die(`Unknown argument: ${a}\n`);
75
82
  }
76
- if (!certPath || !keysPath) die("Both --cert and --keys are required.\n");
83
+ // One record or a pack of them, never both: the two have different verdicts,
84
+ // different exit codes and different output, and a run that printed one
85
+ // after the other would leave a script reading the wrong first line.
86
+ if (packPaths.length > 0 && certPath) die("--pack and --cert are alternatives; use one\n");
87
+ if (packPaths.length === 0 && (!certPath || !keysPath)) die("Both --cert and --keys are required.\n");
88
+ // The authorization policy is a question about one record's systems. Over a
89
+ // pack it would need a per-record answer the pack output has no line for,
90
+ // and refusing it is better than silently ignoring a flag that says "fail".
91
+ if (packPaths.length > 0 && (requireCertified || systems.length > 0 || keyIds.length > 0)) {
92
+ die("--require-certified, --require-certified-system and --customer-key-id are for a single --cert, not --pack\n");
93
+ }
77
94
  // Every system, or these systems: asking for both is a mistake to name, not
78
95
  // one to resolve silently in either direction.
79
96
  if (requireCertified && systems.length > 0) {
@@ -96,7 +113,7 @@ function parseArgs(argv: string[]): {
96
113
  }
97
114
  const policy: AuthorizationPolicy | undefined =
98
115
  keyIds.length > 0 ? { ...(systems.length > 0 ? { systems } : {}), customerKeyIds: keyIds } : undefined;
99
- return { certPath, keysPath, online, apiUrl, policy };
116
+ return { certPath, packPaths, keysPath, online, apiUrl, policy };
100
117
  }
101
118
 
102
119
  /** Flag first, then environment — the precedence docs/cli.md promises and the
@@ -321,7 +338,7 @@ const REVOCATION_UNKNOWN_ONLINE =
321
338
  * enclave measurement was unattributable when the signature covers it.
322
339
  * scripts/check-pcr0-signed-parity.py now pins this list to the Go function.
323
340
  */
324
- const ENCLAVE_PCR0_SIGNED_IN: readonly string[] = Object.freeze(["5.0", "6.0", "7.0", "8.0", "9.0"]);
341
+ const ENCLAVE_PCR0_SIGNED_IN: readonly string[] = Object.freeze(["5.0", "6.0", "7.0", "8.0", "9.0", "10.0"]);
325
342
 
326
343
  function signatureCoversEnclavePcr0(version: string): boolean {
327
344
  return ENCLAVE_PCR0_SIGNED_IN.includes(version);
@@ -616,6 +633,217 @@ function pushStatementEvidence(L: string[], status: StatusStatement | undefined)
616
633
  }
617
634
  }
618
635
 
636
+ /** One page's block, laid out as docs/audit-pack.md specifies. Every line is
637
+ * evidence the verdict rests on, and the two that qualify it — where the keys
638
+ * came from, and whether another page exists — are printed on a VERIFIED page
639
+ * too, because that word says nothing about either. Exported for testing:
640
+ * docs/cli.md pins two of these blocks byte for byte. */
641
+ export function formatAuditPackOutput(
642
+ fileName: string, result: AuditPackResult, opts: { keysFile?: string; online: boolean },
643
+ ): string {
644
+ const name = basename(fileName);
645
+ if (result.verdict === "UNREADABLE") return `Audit pack ${name}: UNREADABLE (${result.reason})\n`;
646
+ // Every readable result carries a header; the scanner refuses a page
647
+ // without one before a verdict is reached.
648
+ const header = result.header!;
649
+ const L: string[] = [];
650
+ L.push(`Audit pack ${name}: ${result.verdict}`);
651
+ L.push(` Format: ${header.format}`);
652
+ L.push(` Generated: ${header.generatedAt}`);
653
+ L.push(` Records: ${result.records - result.failed.length} verified, ${result.failed.length} refused`);
654
+ L.push(` Not yet in the log: ${result.notIncluded}`);
655
+ L.push(` Labeled revoked by the issuer: ${result.revoked} (unsigned; re-run with --online to evidence)`);
656
+ if (opts.online) {
657
+ L.push(` Revoked by signed statement: ${result.statusRevoked} of ${result.statusChecked} checked`);
658
+ if (result.statusRevoked > 0) {
659
+ L.push(" Revoked:");
660
+ for (const id of result.revokedIds) L.push(` ${id}`);
661
+ }
662
+ }
663
+ L.push(
664
+ result.keysFromPack
665
+ ? " Keys: from the pack itself (trust on first use; pass --keys to pin them)"
666
+ : ` Keys: from ${basename(opts.keysFile ?? "")}`,
667
+ );
668
+ const treeSize = header.logHead?.tree_size ?? 0;
669
+ switch (result.logHead) {
670
+ case "VALID":
671
+ L.push(` Log head: tree size ${treeSize}, signature VALID under ${result.logHeadKeyId}`);
672
+ break;
673
+ case "ABSENT":
674
+ L.push(" Log head: absent");
675
+ break;
676
+ case "INVALID_SIGNATURE":
677
+ L.push(` Log head: tree size ${treeSize}, signature INVALID_SIGNATURE (no key in use signed it)`);
678
+ break;
679
+ case "UNREADABLE":
680
+ L.push(" Log head: unreadable");
681
+ break;
682
+ }
683
+ L.push(` Digest: sha256:${result.digest}`);
684
+ if (result.run !== null) {
685
+ let inclusion = "not yet in the log";
686
+ if (result.runInclusion === "VALID") {
687
+ // VALID is only reached through a proof, whose entry_index the
688
+ // inclusion check read as an exact integer.
689
+ inclusion = `in the log at index ${String(result.run.transparency!.entry_index)}`;
690
+ } else if (result.runInclusion !== "NOT_INCLUDED") {
691
+ inclusion = `inclusion ${result.runInclusion}`;
692
+ }
693
+ L.push(` Run record: ${result.runResult} under ${result.run.issuer.keyId}, ${inclusion}`);
694
+ }
695
+ switch (result.footerStatement) {
696
+ case "VALID":
697
+ // VALID is only reached through a footer that carries signed fields; the
698
+ // instant is printed as the file wrote it, as the Go CLI prints it.
699
+ L.push(
700
+ ` Footer: signed under ${result.footerKeyId} at ${result.footer!.signed!.statement_issued_at} ` +
701
+ "(count and digest attested by the issuer)",
702
+ );
703
+ break;
704
+ case "UNSIGNED":
705
+ L.push(" Footer: unsigned (count and digest are the issuer's unsigned word)");
706
+ break;
707
+ default:
708
+ L.push(` Footer: ${result.footerStatement}`);
709
+ }
710
+ if (result.footer === null) {
711
+ L.push(" Next page: unknown (no footer)");
712
+ } else if (result.footer.nextCursor === null) {
713
+ L.push(" Next page: none");
714
+ } else {
715
+ L.push(` Next page: ${result.footer.nextCursor} (more pages exist; this file alone is not the whole answer)`);
716
+ }
717
+ if (result.failed.length > 0) {
718
+ L.push(" Refused:");
719
+ for (const f of result.failed) {
720
+ L.push(f.certificateId === "" ? ` #${f.index}: ${f.reason}` : ` #${f.index} ${f.certificateId}: ${f.reason}`);
721
+ }
722
+ }
723
+ if (result.verdict !== "VERIFIED") L.push(` Findings: ${result.reason}`);
724
+ return L.join("\n") + "\n";
725
+ }
726
+
727
+ /** The chain line printed after several pages. Exported for testing. */
728
+ export function formatAuditPackChain(chain: ReturnType<typeof chainAuditPackPages>): string {
729
+ if (!chain.complete) return `Pages: INCOMPLETE: ${chain.reason}\n`;
730
+ const names = chain.order.map((p) => p.name).join(", ");
731
+ return `Pages: ${chain.order.length} files chain into one complete answer: ${names}\n`;
732
+ }
733
+
734
+ /** What reproducing a run record's roots needs, collected across every page
735
+ * given: the subject hash and certificate id of every record the pages carry,
736
+ * and the record itself. A set of pages that carry different run records is
737
+ * not one run and is refused. Mirrors runAggregate in cmd/cli/auditpack.go. */
738
+ export interface RunAggregate {
739
+ record: RunRecord | null;
740
+ conflict: boolean;
741
+ records: RunCheckRecord[];
742
+ }
743
+
744
+ export function aggregateRun(agg: RunAggregate, result: AuditPackResult): void {
745
+ if (result.run === null) return;
746
+ if (agg.record === null) agg.record = result.run;
747
+ else if (agg.record.runId !== result.run.runId) agg.conflict = true;
748
+ for (let i = 0; i < result.runRecordIds.length; i++) {
749
+ agg.records.push({ certificateId: result.runRecordIds[i]!, subjectHash: result.runSubjects[i]! });
750
+ }
751
+ }
752
+
753
+ /** The run block that follows the pages, laid out as docs/cli.md pins it. ok
754
+ * is false when the run's roots could not be reproduced from a complete chain,
755
+ * or the pages disagree about which run they are. A shortfall against the
756
+ * declared list and a close after the deadline are reported, not refused:
757
+ * they are findings about the cycle, which is the auditor's question, not
758
+ * about the evidence. Mirrors formatRunCheck in cmd/cli/auditpack.go. */
759
+ export async function formatRunCheck(
760
+ agg: RunAggregate, complete: boolean,
761
+ ): Promise<{ output: string; ok: boolean }> {
762
+ if (agg.conflict) return { output: "Run: INVALID: the pages carry records of different runs\n", ok: false };
763
+ const r = agg.record!;
764
+ const L: string[] = [];
765
+ L.push(`Run ${r.runId}: ${r.runKind}`);
766
+ L.push(` Opened: ${r.openedAt}`);
767
+ const late = r.closedAt > r.notAfter ? " (AFTER the declared deadline)" : "";
768
+ L.push(` Closed: ${r.closedAt}, deadline ${r.notAfter}${late}`);
769
+ L.push(
770
+ ` Declared list: ${r.listCount} entries, sha256:${bytesToHex(r.listDigest)} ` +
771
+ "(the customer's assertion; the enclave never sees the list)",
772
+ );
773
+ L.push(` Issued under the run: ${r.recordsCount} records over ${r.subjectsCount} distinct subjects`);
774
+ if (!complete) {
775
+ L.push(" Roots: NOT CHECKED (the pages do not chain into the whole run; every page is needed to reproduce the roots)");
776
+ return { output: L.join("\n") + "\n", ok: false };
777
+ }
778
+ const check = await checkRunAgainstRecords(nodeCrypto, r, agg.records);
779
+ const ok = check.recordsMatch && check.subjectsMatch;
780
+ L.push(
781
+ ok
782
+ ? ` Roots: REPRODUCED from the ${agg.records.length} records in these pages`
783
+ : ` Roots: MISMATCH: the ${agg.records.length} records in these pages do not reproduce the record's roots`,
784
+ );
785
+ if (check.listShortfall > 0) {
786
+ L.push(` Shortfall: ${check.listShortfall} listed subjects have no record in this run`);
787
+ } else if (check.listShortfall < 0) {
788
+ L.push(` Shortfall: none; ${-check.listShortfall} more subjects than the list declared`);
789
+ } else {
790
+ L.push(" Shortfall: none; every listed subject has a record");
791
+ }
792
+ return { output: L.join("\n") + "\n", ok };
793
+ }
794
+
795
+ /** `--pack`: each file verified on its own, then the set checked to chain.
796
+ * Exit 0 only when every page VERIFIED and the chain is complete — a single
797
+ * page whose footer names a next cursor is not the whole answer. An UNREADABLE
798
+ * page ends the run at 2, as an unreadable --cert does: no record was checked,
799
+ * and a page with no header has nothing to chain by. Mirrors runPackCheck in
800
+ * cmd/cli/auditpack.go. */
801
+ async function mainPack(args: ReturnType<typeof parseArgs>): Promise<void> {
802
+ let keys: Map<string, PublicKeyInfo> | null = null;
803
+ if (args.keysPath) keys = (await loadKeys(args.keysPath)).keys;
804
+
805
+ // Step 8 per record, with the same absence of credentials as a single
806
+ // record. A statement that could not be had leaves that record at
807
+ // VALID_REVOCATION_UNKNOWN, as offline; the reader is told on stderr.
808
+ const status = args.online
809
+ ? async (certId: string): Promise<StatusStatement | null> => {
810
+ try {
811
+ return await fetchStatusStatement(args.apiUrl, certId);
812
+ } catch (e) {
813
+ process.stderr.write(
814
+ `warning: signed status statement unavailable for ${certId}: ${e instanceof Error ? e.message : String(e)}\n`,
815
+ );
816
+ return null;
817
+ }
818
+ }
819
+ : undefined;
820
+
821
+ const now = new Date();
822
+ const pages: AuditPackPage[] = [];
823
+ const run: RunAggregate = { record: null, conflict: false, records: [] };
824
+ let refused = false;
825
+ for (const path of args.packPaths) {
826
+ const result = await verifyAuditPack(nodeCrypto, await readFile(path), keys, now, { status });
827
+ process.stdout.write(formatAuditPackOutput(path, result, { keysFile: args.keysPath, online: args.online }));
828
+ if (result.verdict === "UNREADABLE" || result.header === null) process.exit(2);
829
+ if (result.verdict !== "VERIFIED") refused = true;
830
+ pages.push({
831
+ name: basename(path),
832
+ cursor: result.header.cursor,
833
+ nextCursor: result.footer?.nextCursor ?? null,
834
+ });
835
+ aggregateRun(run, result);
836
+ }
837
+ const chain = chainAuditPackPages(pages);
838
+ if (args.packPaths.length > 1) process.stdout.write(formatAuditPackChain(chain));
839
+ if (run.record !== null) {
840
+ const { output, ok } = await formatRunCheck(run, chain.complete);
841
+ process.stdout.write(output);
842
+ if (!ok) refused = true;
843
+ }
844
+ process.exit(refused || !chain.complete ? 1 : 0);
845
+ }
846
+
619
847
  /** Accept the raw API response too: GET /v1/certificates/{id} nests the
620
848
  * signed certificate as data.certificate. Exported for testing. */
621
849
  export function unwrapCertificate(parsed: Cert): Cert {
@@ -632,6 +860,7 @@ export function unwrapCertificate(parsed: Cert): Cert {
632
860
 
633
861
  async function main(): Promise<void> {
634
862
  const args = parseArgs(process.argv);
863
+ if (args.packPaths.length > 0) return mainPack(args);
635
864
  const parsed: unknown = JSON.parse(await readFile(args.certPath, "utf-8"));
636
865
  // No record to give a verdict about. Go's loader refuses the same file at
637
866
  // unmarshal and exits 2, as this does.
package/src/client.ts CHANGED
@@ -19,6 +19,8 @@ import type {
19
19
  LogEntry,
20
20
  Profile,
21
21
  RevocationStatus,
22
+ Run,
23
+ RunKind,
22
24
  SignedTreeHead,
23
25
  System,
24
26
  SystemHealth,
@@ -40,6 +42,7 @@ import {
40
42
  parseLogEntry,
41
43
  parseProfile,
42
44
  parseRevocationStatus,
45
+ parseRun,
43
46
  parseSignedTreeHead,
44
47
  parseSystem,
45
48
  parseSystemHealth,
@@ -191,6 +194,9 @@ export class BurnLedger {
191
194
  proofMode?: string;
192
195
  expiresIn?: string;
193
196
  webhookUrl?: string;
197
+ /** Issue the record under this OPEN run (ADR-031). The run must be this
198
+ * team's and its declared systems must be exactly systemIds. */
199
+ runId?: string;
194
200
  },
195
201
  ): Promise<Attestation> {
196
202
  const body: Record<string, unknown> = {
@@ -200,6 +206,7 @@ export class BurnLedger {
200
206
  };
201
207
  if (opts?.systemIds !== undefined) body.system_ids = opts.systemIds;
202
208
  if (opts?.webhookUrl !== undefined) body.webhook_url = opts.webhookUrl;
209
+ if (opts?.runId !== undefined) body.run_id = opts.runId;
203
210
 
204
211
  const data = await this.transport.request("POST", "/v1/attestations", {
205
212
  json: body,
@@ -267,13 +274,16 @@ export class BurnLedger {
267
274
  systemIds: string[];
268
275
  proofMode?: string;
269
276
  expiresIn?: string;
277
+ /** Issue every record under this OPEN run (ADR-031). */
278
+ runId?: string;
270
279
  }): Promise<BatchAttestationResponse> {
271
- const body = {
280
+ const body: Record<string, unknown> = {
272
281
  subject_identifiers: opts.subjectIdentifiers,
273
282
  system_ids: opts.systemIds,
274
283
  proof_mode: opts.proofMode ?? "count",
275
284
  expires_in: opts.expiresIn ?? "72h",
276
285
  };
286
+ if (opts.runId !== undefined) body.run_id = opts.runId;
277
287
  const data = await this.transport.request("POST", "/v1/attestations/batch", {
278
288
  json: body,
279
289
  });
@@ -377,6 +387,77 @@ export class BurnLedger {
377
387
  return this.transport.requestBytes("GET", path);
378
388
  }
379
389
 
390
+ /** One page of an audit pack (docs/audit-pack.md): the export's records as
391
+ * signed documents, with the issuer's keys and log head, framed by a header
392
+ * and a footer. The footer's next_cursor names the next page; pass it back
393
+ * as `cursor` and keep each page as its own file — pages chain by their
394
+ * cursors, not by their bytes. */
395
+ async exportAuditPack(opts?: {
396
+ status?: "ACTIVE" | "REVOKED";
397
+ issuedAfter?: string;
398
+ issuedBefore?: string;
399
+ /** Only the records issued under this run (ADR-031); a closed run's pack
400
+ * carries the signed run record in its header. */
401
+ runId?: string;
402
+ cursor?: string;
403
+ }): Promise<Uint8Array> {
404
+ const params: Record<string, string> = {};
405
+ if (opts?.status !== undefined) params.status = opts.status;
406
+ if (opts?.issuedAfter !== undefined) params.issued_after = opts.issuedAfter;
407
+ if (opts?.issuedBefore !== undefined) params.issued_before = opts.issuedBefore;
408
+ if (opts?.runId !== undefined) params.run_id = opts.runId;
409
+ if (opts?.cursor !== undefined) params.cursor = opts.cursor;
410
+
411
+ const qs = new URLSearchParams(params).toString();
412
+ const path = qs ? `/v1/certificates/audit-pack?${qs}` : "/v1/certificates/audit-pack";
413
+ return this.transport.requestBytes("GET", path);
414
+ }
415
+
416
+ // --- Runs (ADR-031) ---
417
+
418
+ /** Open a bulk run with the customer's declaration: what the input list was
419
+ * (its SHA-256 over the canonical form and entry count), the deadline, and
420
+ * the systems every record in the run will cover. The enclave never sees the
421
+ * list; the digest and count are signed into the run record as declared. */
422
+ async openRun(opts: {
423
+ runKind: RunKind;
424
+ /** 64 hex characters. */
425
+ listDigest: string;
426
+ listCount: number;
427
+ notAfter: Date | string;
428
+ systemIds: string[];
429
+ }): Promise<Run> {
430
+ const body = {
431
+ run_kind: opts.runKind,
432
+ list_digest: opts.listDigest,
433
+ list_count: opts.listCount,
434
+ not_after: opts.notAfter instanceof Date ? opts.notAfter.toISOString() : opts.notAfter,
435
+ system_ids: opts.systemIds,
436
+ };
437
+ const data = await this.transport.request("POST", "/v1/runs", { json: body });
438
+ return parseRun(data as Raw);
439
+ }
440
+
441
+ /** Close a run: the enclave signs the run record over what it issued under
442
+ * it and the record is appended to the transparency log. 409 (ConflictError)
443
+ * when the run is not OPEN — including a run an enclave restart forgot,
444
+ * which is marked LOST and can never be closed. */
445
+ async closeRun(runId: string): Promise<Run> {
446
+ const data = await this.transport.request("POST", `/v1/runs/${runId}/close`);
447
+ return parseRun(data as Raw);
448
+ }
449
+
450
+ async getRun(runId: string): Promise<Run> {
451
+ const data = await this.transport.request("GET", `/v1/runs/${runId}`);
452
+ return parseRun(data as Raw);
453
+ }
454
+
455
+ listRuns(opts?: { limit?: number }): Paginator<Run> {
456
+ return new Paginator(this.transport, "/v1/runs", parseRun, {
457
+ params: { limit: opts?.limit ?? 25 },
458
+ });
459
+ }
460
+
380
461
  // --- Webhooks ---
381
462
 
382
463
  async registerWebhook(opts: { url: string }): Promise<Webhook> {
package/src/index.ts CHANGED
@@ -48,6 +48,9 @@ export type {
48
48
  ConnectorType,
49
49
  HashScope,
50
50
  LogEntryType,
51
+ Run,
52
+ RunKind,
53
+ RunStatus,
51
54
  VerificationResult,
52
55
  TransparencyResult,
53
56
  ApiKeyRole,
@@ -84,7 +87,7 @@ export type {
84
87
 
85
88
  // Raw-JSON → model parsers, for consumers that fetch API responses outside
86
89
  // the client (the dashboard's paginated list hooks) but render SDK types.
87
- export { parseSystem, parseCertificateResponse, parseWebhook } from "./models.js";
90
+ export { parseSystem, parseCertificateResponse, parseRun, parseWebhook } from "./models.js";
88
91
 
89
92
  export { Paginator } from "./pagination.js";
90
93
 
@@ -170,6 +173,119 @@ export function verifyConsistency(
170
173
 
171
174
  export { verifyWebhookSignature, isTimestampFresh } from "./webhooks.js";
172
175
 
176
+ /**
177
+ * Audit pack verification (docs/audit-pack.md). Node only: a pack is a file an
178
+ * auditor holds, and the browser verifier reads one record at a time.
179
+ */
180
+ export {
181
+ AUDIT_PACK_FORMAT,
182
+ PAYLOAD_TYPE_AUDIT_PACK_FOOTER,
183
+ auditPackFooterStatement,
184
+ buildAuditPackFooterPayload,
185
+ chainAuditPackPages,
186
+ } from "./audit-pack.js";
187
+ export type {
188
+ AuditPackFilters,
189
+ AuditPackFooter,
190
+ AuditPackFooterResult,
191
+ AuditPackFooterSigned,
192
+ AuditPackFooterStatement,
193
+ AuditPackHeader,
194
+ AuditPackLogHead,
195
+ AuditPackOptions,
196
+ AuditPackPage,
197
+ AuditPackRecordFailure,
198
+ AuditPackResult,
199
+ AuditPackVerdict,
200
+ SignedTreeHeadDocument,
201
+ } from "./audit-pack.js";
202
+ import {
203
+ verifyAuditPack as _verifyAuditPack,
204
+ verifyAuditPackFooter as _verifyAuditPackFooter,
205
+ } from "./audit-pack.js";
206
+ import type {
207
+ AuditPackFooterResult,
208
+ AuditPackFooterStatement,
209
+ AuditPackOptions,
210
+ AuditPackResult,
211
+ } from "./audit-pack.js";
212
+
213
+ /** Verify one audit pack page offline, as `burnledger check --pack` does.
214
+ * `keys` null uses the pack's own issuer_keys (trust on first use, and the
215
+ * result's keysFromPack says so); a supplied key set outranks them. */
216
+ export function verifyAuditPack(
217
+ source: string | Uint8Array,
218
+ keys: Map<string, PublicKeyInfo> | null,
219
+ now?: Date,
220
+ options?: AuditPackOptions,
221
+ ): Promise<AuditPackResult> {
222
+ return _verifyAuditPack(nodeCrypto, source, keys, now ?? new Date(), options);
223
+ }
224
+
225
+ /** Verify a footer statement — rebuilt from a page's header and footer by
226
+ * auditPackFooterStatement — under a key set. verifyAuditPack already does
227
+ * this for every signed footer it reads; this is for a reader holding the
228
+ * statement on its own. */
229
+ export function verifyAuditPackFooter(
230
+ statement: AuditPackFooterStatement,
231
+ keys: Map<string, PublicKeyInfo>,
232
+ ): Promise<AuditPackFooterResult> {
233
+ return _verifyAuditPackFooter(nodeCrypto, statement, keys);
234
+ }
235
+
236
+ /**
237
+ * The run record (docs/run-record.md, ADR-031): the enclave's signed word on
238
+ * one bulk run, checked beside the run's records. Node only, as the audit pack
239
+ * verifier that carries it is.
240
+ */
241
+ export {
242
+ PAYLOAD_TYPE_RUN_RECORD,
243
+ buildRunRecordPayload,
244
+ parseRunRecordDocument,
245
+ } from "./run-record.js";
246
+ export type { RunCheck, RunCheckRecord, RunRecord, RunRecordResult } from "./run-record.js";
247
+ import {
248
+ checkRunAgainstRecords as _checkRunAgainstRecords,
249
+ runRecordHash as _runRecordHash,
250
+ runRoots as _runRoots,
251
+ verifyRunRecord as _verifyRunRecord,
252
+ verifyRunRecordInclusion as _verifyRunRecordInclusion,
253
+ } from "./run-record.js";
254
+ import type { RunCheck, RunCheckRecord, RunRecord, RunRecordResult } from "./run-record.js";
255
+
256
+ /** Verify a run record's signature under a key set: the shape, the key's
257
+ * authority at the record's close instant, then the signature. */
258
+ export function verifyRunRecord(record: RunRecord, keys: Map<string, PublicKeyInfo>): Promise<RunRecordResult> {
259
+ return _verifyRunRecord(nodeCrypto, record, keys);
260
+ }
261
+
262
+ /** What the run record's transparency leaf commits to: SHA-256 of its payload. */
263
+ export function runRecordHash(record: RunRecord): Promise<Uint8Array> {
264
+ return _runRecordHash(nodeCrypto, record);
265
+ }
266
+
267
+ /** Verify the run record's own inclusion proof under the key that signed it,
268
+ * as core.TransparencyResult names the outcome; NOT_INCLUDED without one. */
269
+ export function verifyRunRecordInclusion(record: RunRecord, key: PublicKeyInfo): Promise<string> {
270
+ return _verifyRunRecordInclusion(nodeCrypto, record, key);
271
+ }
272
+
273
+ /** The roots a run record commits to, reproduced from subject hashes and
274
+ * certificate ids in any order: subjects de-duplicated, both leaf sets sorted. */
275
+ export function runRoots(
276
+ subjects: Uint8Array[],
277
+ certificateIds: string[],
278
+ ): Promise<{ subjectsRoot: Uint8Array; subjectsCount: number; recordsRoot: Uint8Array; recordsCount: number }> {
279
+ return _runRoots(nodeCrypto, subjects, certificateIds);
280
+ }
281
+
282
+ /** Hold a run record beside every record of the run: whether the roots
283
+ * reproduce, the shortfall against the declared list, whether it closed in
284
+ * time, and how many records are labelled with another run. */
285
+ export function checkRunAgainstRecords(record: RunRecord, records: RunCheckRecord[]): Promise<RunCheck> {
286
+ return _checkRunAgainstRecords(nodeCrypto, record, records);
287
+ }
288
+
173
289
  /**
174
290
  * Client-side connection-config sealing.
175
291
  *
package/src/models.ts CHANGED
@@ -73,7 +73,9 @@ export type ConnectorType =
73
73
  | "bigquery"
74
74
  | "azure_blob";
75
75
  export type HashScope = "full" | "existence";
76
- export type LogEntryType = "CERTIFICATE" | "REVOCATION";
76
+ export type LogEntryType = "CERTIFICATE" | "REVOCATION" | "RUN_RECORD";
77
+ export type RunKind = "drop_cycle" | "bulk_erasure";
78
+ export type RunStatus = "OPEN" | "CLOSED" | "LOST";
77
79
 
78
80
  /** Result of offline certificate signature verification. */
79
81
  export type VerificationResult = "VALID" | "INVALID";
@@ -190,11 +192,35 @@ export interface SignedTreeHead {
190
192
  export interface LogEntry {
191
193
  readonly index: number;
192
194
  readonly entryType: LogEntryType;
193
- readonly certificateId: string;
195
+ /** Present on CERTIFICATE and REVOCATION leaves. */
196
+ readonly certificateId: string | undefined;
197
+ /** Present on RUN_RECORD leaves (ADR-031). */
198
+ readonly runId: string | undefined;
194
199
  readonly hash: string;
195
200
  readonly appendedAt: Date;
196
201
  }
197
202
 
203
+ /** One bulk run (ADR-031, docs/run-record.md): the customer's declaration as
204
+ * opened, and once CLOSED the enclave-signed run record under `record`, with
205
+ * its transparency proof merged in when a signed tree head covers its leaf.
206
+ * The record is kept raw so it can be handed straight to verifyRunRecord. */
207
+ export interface Run {
208
+ readonly id: string;
209
+ readonly teamId: string;
210
+ readonly runKind: RunKind;
211
+ /** SHA-256 of the input list's canonical form, 64 hex characters. */
212
+ readonly listDigest: string;
213
+ readonly listCount: number;
214
+ readonly notAfter: Date;
215
+ readonly systemIds: readonly string[];
216
+ readonly status: RunStatus;
217
+ readonly openedAt: Date;
218
+ readonly closedAt: Date | undefined;
219
+ /** The run record's leaf in the transparency log, once appended. */
220
+ readonly logIndex: number | undefined;
221
+ readonly record: Record<string, unknown> | undefined;
222
+ }
223
+
198
224
  export interface InclusionProof {
199
225
  readonly index: number;
200
226
  readonly treeSize: number;
@@ -486,12 +512,30 @@ export function parseLogEntry(d: Raw): LogEntry {
486
512
  return {
487
513
  index: d.index,
488
514
  entryType: d.entry_type as LogEntryType,
489
- certificateId: d.certificate_id,
515
+ certificateId: d.certificate_id ?? undefined,
516
+ runId: d.run_id ?? undefined,
490
517
  hash: d.hash,
491
518
  appendedAt: parseDt(d.appended_at),
492
519
  };
493
520
  }
494
521
 
522
+ export function parseRun(d: Raw): Run {
523
+ return {
524
+ id: d.id,
525
+ teamId: d.team_id,
526
+ runKind: d.run_kind as RunKind,
527
+ listDigest: d.list_digest,
528
+ listCount: d.list_count,
529
+ notAfter: parseDt(d.not_after),
530
+ systemIds: d.system_ids ?? [],
531
+ status: d.status as RunStatus,
532
+ openedAt: parseDt(d.opened_at),
533
+ closedAt: parseDtOpt(d.closed_at),
534
+ logIndex: d.log_index ?? undefined,
535
+ record: d.record ?? undefined,
536
+ };
537
+ }
538
+
495
539
  export function parseInclusionProof(d: Raw): InclusionProof {
496
540
  return {
497
541
  index: d.index,