burnledger 0.8.0 → 0.9.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 (124) hide show
  1. package/README.md +139 -2
  2. package/dist/cjs/anchor.d.ts +5 -3
  3. package/dist/cjs/anchor.d.ts.map +1 -1
  4. package/dist/cjs/anchor.js +10 -4
  5. package/dist/cjs/anchor.js.map +1 -1
  6. package/dist/cjs/client.d.ts +95 -0
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +133 -2
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/customer-keys.d.ts +146 -0
  11. package/dist/cjs/customer-keys.d.ts.map +1 -0
  12. package/dist/cjs/customer-keys.js +465 -0
  13. package/dist/cjs/customer-keys.js.map +1 -0
  14. package/dist/cjs/enclave-registration.d.ts +153 -0
  15. package/dist/cjs/enclave-registration.d.ts.map +1 -0
  16. package/dist/cjs/enclave-registration.js +275 -0
  17. package/dist/cjs/enclave-registration.js.map +1 -0
  18. package/dist/cjs/enclave-seal.d.ts +43 -0
  19. package/dist/cjs/enclave-seal.d.ts.map +1 -1
  20. package/dist/cjs/enclave-seal.js +62 -1
  21. package/dist/cjs/enclave-seal.js.map +1 -1
  22. package/dist/cjs/errors.d.ts +10 -0
  23. package/dist/cjs/errors.d.ts.map +1 -1
  24. package/dist/cjs/errors.js +11 -1
  25. package/dist/cjs/errors.js.map +1 -1
  26. package/dist/cjs/index.browser.d.ts +7 -3
  27. package/dist/cjs/index.browser.d.ts.map +1 -1
  28. package/dist/cjs/index.browser.js +6 -3
  29. package/dist/cjs/index.browser.js.map +1 -1
  30. package/dist/cjs/index.d.ts +23 -9
  31. package/dist/cjs/index.d.ts.map +1 -1
  32. package/dist/cjs/index.js +40 -6
  33. package/dist/cjs/index.js.map +1 -1
  34. package/dist/cjs/key-group.d.ts +80 -0
  35. package/dist/cjs/key-group.d.ts.map +1 -0
  36. package/dist/cjs/key-group.js +136 -0
  37. package/dist/cjs/key-group.js.map +1 -0
  38. package/dist/cjs/models.d.ts +60 -0
  39. package/dist/cjs/models.d.ts.map +1 -1
  40. package/dist/cjs/models.js +63 -0
  41. package/dist/cjs/models.js.map +1 -1
  42. package/dist/cjs/status-document.d.ts +25 -0
  43. package/dist/cjs/status-document.d.ts.map +1 -0
  44. package/dist/cjs/status-document.js +62 -0
  45. package/dist/cjs/status-document.js.map +1 -0
  46. package/dist/cjs/verify.d.ts +112 -3
  47. package/dist/cjs/verify.d.ts.map +1 -1
  48. package/dist/cjs/verify.js +393 -146
  49. package/dist/cjs/verify.js.map +1 -1
  50. package/dist/cjs/web-verifier.d.ts +23 -3
  51. package/dist/cjs/web-verifier.d.ts.map +1 -1
  52. package/dist/cjs/web-verifier.js +31 -5
  53. package/dist/cjs/web-verifier.js.map +1 -1
  54. package/dist/esm/anchor.d.ts +5 -3
  55. package/dist/esm/anchor.d.ts.map +1 -1
  56. package/dist/esm/anchor.js +10 -4
  57. package/dist/esm/anchor.js.map +1 -1
  58. package/dist/esm/cli.d.ts +13 -19
  59. package/dist/esm/cli.d.ts.map +1 -1
  60. package/dist/esm/cli.js +109 -81
  61. package/dist/esm/cli.js.map +1 -1
  62. package/dist/esm/client.d.ts +95 -0
  63. package/dist/esm/client.d.ts.map +1 -1
  64. package/dist/esm/client.js +133 -2
  65. package/dist/esm/client.js.map +1 -1
  66. package/dist/esm/customer-keys.d.ts +146 -0
  67. package/dist/esm/customer-keys.d.ts.map +1 -0
  68. package/dist/esm/customer-keys.js +450 -0
  69. package/dist/esm/customer-keys.js.map +1 -0
  70. package/dist/esm/enclave-registration.d.ts +153 -0
  71. package/dist/esm/enclave-registration.d.ts.map +1 -0
  72. package/dist/esm/enclave-registration.js +265 -0
  73. package/dist/esm/enclave-registration.js.map +1 -0
  74. package/dist/esm/enclave-seal.d.ts +43 -0
  75. package/dist/esm/enclave-seal.d.ts.map +1 -1
  76. package/dist/esm/enclave-seal.js +61 -1
  77. package/dist/esm/enclave-seal.js.map +1 -1
  78. package/dist/esm/errors.d.ts +10 -0
  79. package/dist/esm/errors.d.ts.map +1 -1
  80. package/dist/esm/errors.js +10 -0
  81. package/dist/esm/errors.js.map +1 -1
  82. package/dist/esm/index.browser.d.ts +7 -3
  83. package/dist/esm/index.browser.d.ts.map +1 -1
  84. package/dist/esm/index.browser.js +6 -3
  85. package/dist/esm/index.browser.js.map +1 -1
  86. package/dist/esm/index.d.ts +23 -9
  87. package/dist/esm/index.d.ts.map +1 -1
  88. package/dist/esm/index.js +18 -7
  89. package/dist/esm/index.js.map +1 -1
  90. package/dist/esm/key-group.d.ts +80 -0
  91. package/dist/esm/key-group.d.ts.map +1 -0
  92. package/dist/esm/key-group.js +130 -0
  93. package/dist/esm/key-group.js.map +1 -0
  94. package/dist/esm/models.d.ts +60 -0
  95. package/dist/esm/models.d.ts.map +1 -1
  96. package/dist/esm/models.js +59 -0
  97. package/dist/esm/models.js.map +1 -1
  98. package/dist/esm/status-document.d.ts +25 -0
  99. package/dist/esm/status-document.d.ts.map +1 -0
  100. package/dist/esm/status-document.js +59 -0
  101. package/dist/esm/status-document.js.map +1 -0
  102. package/dist/esm/verify.d.ts +112 -3
  103. package/dist/esm/verify.d.ts.map +1 -1
  104. package/dist/esm/verify.js +390 -147
  105. package/dist/esm/verify.js.map +1 -1
  106. package/dist/esm/web-verifier.d.ts +23 -3
  107. package/dist/esm/web-verifier.d.ts.map +1 -1
  108. package/dist/esm/web-verifier.js +27 -5
  109. package/dist/esm/web-verifier.js.map +1 -1
  110. package/package.json +1 -1
  111. package/src/anchor.ts +10 -4
  112. package/src/cli.ts +122 -79
  113. package/src/client.ts +198 -6
  114. package/src/customer-keys.ts +550 -0
  115. package/src/enclave-registration.ts +390 -0
  116. package/src/enclave-seal.ts +108 -1
  117. package/src/errors.ts +11 -0
  118. package/src/index.browser.ts +9 -3
  119. package/src/index.ts +48 -6
  120. package/src/key-group.ts +181 -0
  121. package/src/models.ts +131 -0
  122. package/src/status-document.ts +59 -0
  123. package/src/verify.ts +503 -152
  124. package/src/web-verifier.ts +36 -3
package/src/cli.ts CHANGED
@@ -9,7 +9,9 @@ import { nodeCrypto } from "./crypto-node.js";
9
9
  import { CERTIFICATE_REVOKED, VerificationError } from "./errors.js";
10
10
  import {
11
11
  formatTimestamp,
12
+ issuingImage,
12
13
  publicKeyFromHex,
14
+ registrationQualifier,
13
15
  verifyCertificateWithStatus,
14
16
  verifyTransparency,
15
17
  KNOWN_FORMAT_VERSIONS,
@@ -24,23 +26,34 @@ import {
24
26
  type KeyListEvidence,
25
27
  } from "./keys.js";
26
28
  import { ANCHOR_INCONSISTENT, checkStatementAnchor, type AnchorResult } from "./anchor.js";
27
- import type { PublicKeyInfo } from "./verify.js";
29
+ import { unwrapStatusStatement } from "./status-document.js";
30
+ import type { AuthorizationPolicy, PublicKeyInfo } from "./verify.js";
31
+
32
+ // Exported from here too: tests/cli-revocation-unknown.test.ts reads it as the
33
+ // CLI's own parser.
34
+ export { unwrapStatusStatement };
28
35
 
29
36
  type Cert = Record<string, unknown>;
30
37
  /** A published status statement, exactly as GET /v1/certificates/{id}/status
31
38
  * serves it: hex strings, not decoded bytes. verifyCertificateWithStatus reads
32
39
  * this shape, so nothing here re-encodes a field the signature covers. */
33
40
  type StatusStatement = Record<string, unknown>;
34
- const USAGE = "Usage: burnledger check --cert <file> --keys <file> [--online --api-url <url>]\n";
41
+ const USAGE =
42
+ "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";
35
44
 
36
45
  function die(msg: string): never { process.stderr.write(msg); process.exit(2); }
37
46
 
38
47
  function parseArgs(argv: string[]): {
39
48
  certPath: string; keysPath: string; online: boolean; apiUrl: string;
49
+ policy: AuthorizationPolicy | undefined;
40
50
  } {
41
51
  const args = argv.slice(2);
42
52
  if (args[0] !== "check") die(USAGE);
43
53
  let certPath = "", keysPath = "", online = false, apiUrl = "";
54
+ let requireCertified = false;
55
+ const systems: string[] = [];
56
+ const keyIds: string[] = [];
44
57
  for (let i = 1; i < args.length; i++) {
45
58
  const a = args[i]!;
46
59
  if (a === "--cert") { certPath = args[++i] ?? die("--cert requires a value\n"); }
@@ -51,9 +64,29 @@ function parseArgs(argv: string[]): {
51
64
  // buys nothing — but it is a published flag that scripts already pass, and
52
65
  // failing on an unknown flag would break them for no gain.
53
66
  else if (a === "--api-key") { void (args[++i] ?? die("--api-key requires a value\n")); }
67
+ // ADR-025. Opt-in: without these, authorization: none verifies as it always
68
+ // has. The Go CLI has no equivalent yet; these names are the proposal for it.
69
+ else if (a === "--require-certified") { requireCertified = true; }
70
+ else if (a === "--require-certified-system") {
71
+ systems.push(args[++i] ?? die("--require-certified-system requires a value\n"));
72
+ }
73
+ else if (a === "--customer-key-id") { keyIds.push(args[++i] ?? die("--customer-key-id requires a value\n")); }
54
74
  else die(`Unknown argument: ${a}\n`);
55
75
  }
56
76
  if (!certPath || !keysPath) die("Both --cert and --keys are required.\n");
77
+ // Every system, or these systems: asking for both is a mistake to name, not
78
+ // one to resolve silently in either direction.
79
+ if (requireCertified && systems.length > 0) {
80
+ die("--require-certified and --require-certified-system are alternatives; use one\n");
81
+ }
82
+ // Certified under a key id you did not name proves nothing to you: the relay
83
+ // can enroll a key group of its own, and the record would name that one.
84
+ if ((requireCertified || systems.length > 0) && keyIds.length === 0) {
85
+ die(
86
+ "--require-certified and --require-certified-system need --customer-key-id <id>: " +
87
+ "certified under a key you did not name proves nothing\n",
88
+ );
89
+ }
57
90
  if (online) {
58
91
  try {
59
92
  apiUrl = resolveApiBaseUrl(apiUrl, process.env);
@@ -61,7 +94,9 @@ function parseArgs(argv: string[]): {
61
94
  die((e as Error).message + "\n");
62
95
  }
63
96
  }
64
- return { certPath, keysPath, online, apiUrl };
97
+ const policy: AuthorizationPolicy | undefined =
98
+ keyIds.length > 0 ? { ...(systems.length > 0 ? { systems } : {}), customerKeyIds: keyIds } : undefined;
99
+ return { certPath, keysPath, online, apiUrl, policy };
65
100
  }
66
101
 
67
102
  /** Flag first, then environment — the precedence docs/cli.md promises and the
@@ -86,8 +121,8 @@ export function resolveApiBaseUrl(
86
121
  * carries a signed statement — key_id == sha256(public_key) makes every
87
122
  * identity self-checking — so an unsigned file is not refused. What changes is
88
123
  * what this CLI is entitled to say about the statuses and dates. Mirrors
89
- * loadKeysFromFile in cmd/cli/keys.go. */
90
- async function loadKeys(path: string): Promise<{ keys: Map<string, PublicKeyInfo>; keyList: KeyListEvidence }> {
124
+ * loadKeysFromFile in cmd/cli/keys.go. Exported for testing. */
125
+ export async function loadKeys(path: string): Promise<{ keys: Map<string, PublicKeyInfo>; keyList: KeyListEvidence }> {
91
126
  let entries: KeyEntry[];
92
127
  let keyList: KeyListEvidence;
93
128
  try {
@@ -154,59 +189,6 @@ async function fetchStatusStatement(apiUrl: string, certId: string): Promise<Sta
154
189
  return unwrapStatusStatement(await res.json());
155
190
  }
156
191
 
157
- /** The API wraps every response in {"data": ...}; a caller may hand over either
158
- * the envelope or the document inside it. Mirrors
159
- * core.ParseCertificateStatusDocument, so this CLI and the Go one cannot
160
- * disagree about which shape the endpoint returns. Exported for testing.
161
- *
162
- * A document that does not parse THROWS, and the caller turns that into a
163
- * warning and a VALID_REVOCATION_UNKNOWN verdict. That is deliberate: an answer
164
- * this build cannot read is silence about revocation, not a finding about the
165
- * certificate. Handing a half-read document to the verifier instead would
166
- * report "status statement signature is invalid" — a claim about the issuer —
167
- * when what actually happened is that something returned nonsense.
168
- *
169
- * `status` itself is NOT validated against a known set. A statement saying
170
- * SUSPENDED is a statement this build cannot read, and the verifier already has
171
- * the right answer for that: it authenticates the signature first, then reports
172
- * VALID_REVOCATION_UNKNOWN. Refusing it here would discard the statement before
173
- * anyone authenticated it. */
174
- export function unwrapStatusStatement(parsed: unknown): StatusStatement {
175
- if (parsed === null || typeof parsed !== "object") {
176
- throw new Error("status statement: response is not a JSON object");
177
- }
178
- let doc = parsed as StatusStatement;
179
- if (doc.certificate_id === undefined && doc.data !== null && typeof doc.data === "object") {
180
- doc = doc.data as StatusStatement;
181
- }
182
- for (const f of ["certificate_id", "status", "key_id"]) {
183
- if (typeof doc[f] !== "string" || doc[f] === "") throw new Error(`status statement: ${f} is missing`);
184
- }
185
- for (const f of ["statement_issued_at", "statement_expires_at"]) requireRfc3339(doc, f);
186
- if (doc.revoked_at != null) requireRfc3339(doc, "revoked_at");
187
- if (typeof doc.sth_tree_size !== "number") throw new Error("status statement: sth_tree_size is not a number");
188
- // Refuse a short or long value rather than letting it through: a truncated
189
- // signature or root hash that reaches the verifier is checked against bytes
190
- // nobody sent.
191
- requireHex(doc, "sth_root_hash", 32);
192
- requireHex(doc, "signature", 64);
193
- return doc;
194
- }
195
-
196
- function requireRfc3339(doc: StatusStatement, field: string): void {
197
- const v = doc[field];
198
- if (typeof v !== "string" || Number.isNaN(Date.parse(v))) {
199
- throw new Error(`status statement: ${field} is not RFC 3339`);
200
- }
201
- }
202
-
203
- function requireHex(doc: StatusStatement, field: string, bytes: number): void {
204
- const v = doc[field];
205
- if (typeof v !== "string" || !new RegExp(`^[0-9a-fA-F]{${bytes * 2}}$`).test(v)) {
206
- throw new Error(`status statement: ${field} is not ${bytes} bytes of hex`);
207
- }
208
- }
209
-
210
192
  /** The verdicts a reader can act on, and therefore the ones that exit 0.
211
193
  *
212
194
  * VALID_REVOCATION_UNKNOWN is here. It is the verdict every offline run now
@@ -295,9 +277,41 @@ function signatureCoversEnclavePcr0(version: string): boolean {
295
277
  return ENCLAVE_PCR0_SIGNED_IN.includes(version);
296
278
  }
297
279
 
280
+ /** Follows a system that attested zero records. The same words in all three CLIs. */
281
+ const MATCHED_NOTHING_NOTE =
282
+ " — the query matched nothing here, so this record is no evidence " +
283
+ "about this system; if it should hold the subject, its query is wrong";
284
+
285
+ /**
286
+ * Each system's own counts. The totals hide a system that matched nothing: 11
287
+ * records across two systems read the same whether both held some or one held
288
+ * all eleven and the other's query found nothing. A zero is a legitimate answer
289
+ * — the subject was simply not there — so it is stated, not refused. But a
290
+ * query that CANNOT match (a wrong column, a term query on an analyzed field)
291
+ * also reads zero, and then the record is no evidence about that system at
292
+ * all. Only the reader knows which, so the line says both.
293
+ */
294
+ function pushRecordCounts(L: string[], sys: Record<string, unknown>[]): void {
295
+ for (const s of sys) {
296
+ const attested = s.attested_count as number;
297
+ L.push(
298
+ ` Records [${s.system_name as string}]: ${attested} at attestation, ` +
299
+ `${s.verified_count as number} after deletion` +
300
+ (attested === 0 ? MATCHED_NOTHING_NOTE : ""),
301
+ );
302
+ }
303
+ }
304
+
298
305
  /**
299
- * Per system: did the enclave check it against a customer-signed registration,
300
- * and under which key?
306
+ * Per system: did the enclave check it against a registration, under which key,
307
+ * and what does the image that signed the record make of that?
308
+ *
309
+ * Printed for exactly the verdicts that say the record's own signature
310
+ * verified: VALID, VALID_REVOCATION_UNKNOWN, the two soft key verdicts and
311
+ * REVOKED, as in the Go and Python CLIs and the /verify page. The qualifier is
312
+ * read from issuer.enclave_pcr0, which that signature covers, and
313
+ * verifyCertificate checks it before any of those verdicts is reached. Beside
314
+ * INVALID the field is unverified bytes, so nothing is printed there.
301
315
  *
302
316
  * This line is the WHOLE POINT of the field. ADR-025 §2: the enclave cannot
303
317
  * refuse a second enrollment for a team — that needs durable state — so a parent
@@ -319,14 +333,11 @@ function pushRegistrationEvidence(L: string[], cert: Record<string, unknown>): v
319
333
  );
320
334
  return;
321
335
  }
336
+ const qualifier = registrationQualifier(issuingImage(cert));
322
337
  for (const sys of (cert.systems as Record<string, unknown>[]) ?? []) {
323
338
  const name = sys.system_name as string;
324
339
  if (sys.authorization === "certified") {
325
- L.push(
326
- ` Registration [${name}]: certified under ${sys.customer_key_id as string} — ` +
327
- "the enclave checked this system against a customer-signed registration. " +
328
- "If that key id is not yours, someone else authorized this verification.",
329
- );
340
+ L.push(` Registration [${name}]: certified under ${sys.customer_key_id as string} — ${qualifier}`);
330
341
  } else if (sys.authorization === "none") {
331
342
  L.push(
332
343
  ` Registration [${name}]: none — this verification was NOT checked against ` +
@@ -363,8 +374,9 @@ export function formatOutput(
363
374
  status: StatusStatement | undefined, online: boolean, anchor?: AnchorResult,
364
375
  keyList?: KeyListEvidence,
365
376
  ): string {
366
- const id = (cert.certificate_id as string).slice(0, 6);
367
- const sub = displayHash((cert.subject as Record<string, unknown>).identifier_hash);
377
+ // This line prints for records that FAILED verification too, so it must not
378
+ // assume the shape verification would have checked.
379
+ const id = typeof cert.certificate_id === "string" ? cert.certificate_id.slice(0, 6) : "unknown";
368
380
  const att = cert.attestation as Record<string, unknown>;
369
381
  const L: string[] = [];
370
382
 
@@ -382,22 +394,33 @@ export function formatOutput(
382
394
  return L.join("\n") + "\n";
383
395
  }
384
396
 
385
- // A soft key verdict is not a forgery: every signature verified, and what
386
- // could not be established is the key's authority. "INVALID" would send an
387
- // auditor hunting for tampering that is not there, and "VALID" — which this
388
- // CLI printed — claims a check that did not happen.
389
- const note = SOFT_KEY_VERDICTS[sigResult];
390
- if (note !== undefined) return `Verification record dc_${id}: ${sigResult} (${note})\n`;
391
397
  // Revocation is now a signed fact, so the reader gets the evidence with the
392
398
  // verdict: who said it, when, and which log entry it points at.
393
399
  if (sigResult === REVOKED) {
394
400
  L.push(`Verification record dc_${id}: INVALID (REVOKED)`);
401
+ pushRegistrationEvidence(L, cert);
395
402
  pushStatementEvidence(L, status);
396
403
  pushAnchorEvidence(L, anchor);
397
404
  return L.join("\n") + "\n";
398
405
  }
399
- if (!USABLE_VERDICTS.has(sigResult)) return `Verification record dc_${id}: INVALID (${sigResult})\n`;
406
+ const note = SOFT_KEY_VERDICTS[sigResult];
407
+ if (!USABLE_VERDICTS.has(sigResult) && note === undefined) {
408
+ return `Verification record dc_${id}: INVALID (${sigResult})\n`;
409
+ }
410
+ // A failed proof refuses the record, and it outranks a soft key verdict: a
411
+ // verdict that asks for a human must not hide one that refuses. The Python
412
+ // CLI and the /verify page already put it first; this CLI and Go printed
413
+ // the soft verdict instead.
400
414
  if (transparencyFailed) return `Verification record dc_${id}: INVALID (transparency: ${transparency})\n`;
415
+ // A soft key verdict is not a forgery: every signature verified, and what
416
+ // could not be established is the key's authority. "INVALID" would send an
417
+ // auditor hunting for tampering that is not there, and "VALID" — which this
418
+ // CLI printed — claims a check that did not happen.
419
+ if (note !== undefined) {
420
+ L.push(`Verification record dc_${id}: ${sigResult} (${note})`);
421
+ pushRegistrationEvidence(L, cert);
422
+ return L.join("\n") + "\n";
423
+ }
401
424
 
402
425
  // The two usable verdicts. VALID is sayable without a qualifier for the first
403
426
  // time: it is reached only when a fresh statement, signed by a key from the
@@ -408,6 +431,7 @@ export function formatOutput(
408
431
  ? `${VALID_REVOCATION_UNKNOWN} (${online ? REVOCATION_UNKNOWN_ONLINE : REVOCATION_UNKNOWN_OFFLINE})`
409
432
  : "VALID (a signed status statement says this record is live)";
410
433
  L.push(`Verification record dc_${id}: ${tag}`);
434
+ const sub = displayHash((cert.subject as Record<string, unknown>).identifier_hash);
411
435
  L.push(` Subject: sha256:${sub.slice(0, 6)}...`);
412
436
  L.push(` Format version: ${cert.certificate_format_version as string}`);
413
437
 
@@ -420,6 +444,7 @@ export function formatOutput(
420
444
  L.push(` Systems: ${sys.length} (${sys.map((s) => `${s.connector_type} [${s.hash_scope}]`).join(", ")})`);
421
445
  L.push(` Records before: ${sys.reduce((n, s) => n + (s.attested_count as number), 0)}`);
422
446
  L.push(` Records after: ${sys.reduce((n, s) => n + (s.verified_count as number), 0)}`);
447
+ pushRecordCounts(L, sys);
423
448
  L.push(` Committed: ${att.attested_at as string}`);
424
449
  L.push(` Verified: ${sys.reduce((t, s) => {
425
450
  const v = s.verified_at as string;
@@ -557,7 +582,13 @@ export function unwrapCertificate(parsed: Cert): Cert {
557
582
 
558
583
  async function main(): Promise<void> {
559
584
  const args = parseArgs(process.argv);
560
- const cert = unwrapCertificate(JSON.parse(await readFile(args.certPath, "utf-8")) as Cert);
585
+ const parsed: unknown = JSON.parse(await readFile(args.certPath, "utf-8"));
586
+ // No record to give a verdict about. Go's loader refuses the same file at
587
+ // unmarshal and exits 2, as this does.
588
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
589
+ die(`${args.certPath}: not a JSON object\n`);
590
+ }
591
+ const cert = unwrapCertificate(parsed as Cert);
561
592
  const { keys, keyList } = await loadKeys(args.keysPath);
562
593
 
563
594
  // Fetched first, because the verdict now depends on it. A status statement is
@@ -597,11 +628,19 @@ async function main(): Promise<void> {
597
628
  // and VALID_KEY_COMPROMISED_LATER are cases verifyCertificateWithStatus
598
629
  // deliberately refused to call VALID, and this is the tool an auditor runs in
599
630
  // a script — the one whose exit code gets trusted.
631
+ //
632
+ // Only a VerificationError is a verdict. Anything else is a defect in this
633
+ // CLI or the SDK under it; printing it as INVALID dressed a crash up as a
634
+ // finding about the record, so it propagates and exits 2 instead.
600
635
  let sigResult = "VALID";
601
- try { sigResult = await verifyCertificateWithStatus(nodeCrypto, cert, keys, status ?? null); }
636
+ try {
637
+ sigResult = await verifyCertificateWithStatus(nodeCrypto, cert, keys, status ?? null, undefined, {
638
+ requireAuthorization: args.policy,
639
+ });
640
+ }
602
641
  catch (e) {
603
- if (e instanceof VerificationError && e.code === CERTIFICATE_REVOKED) sigResult = REVOKED;
604
- else sigResult = e instanceof Error ? e.message : "unknown error";
642
+ if (!(e instanceof VerificationError)) throw e;
643
+ sigResult = e.code === CERTIFICATE_REVOKED ? REVOKED : e.message;
605
644
  }
606
645
 
607
646
  // NOT_AVAILABLE (no proof) is a legitimate result; only a thrown error is a
@@ -609,7 +648,11 @@ async function main(): Promise<void> {
609
648
  let transparency = "NOT_AVAILABLE";
610
649
  let transparencyFailed = false;
611
650
  try { transparency = await verifyTransparency(nodeCrypto, cert, keys); }
612
- catch (e) { transparency = e instanceof Error ? e.message : "unknown error"; transparencyFailed = true; }
651
+ catch (e) {
652
+ if (!(e instanceof VerificationError)) throw e;
653
+ transparency = e.message;
654
+ transparencyFailed = true;
655
+ }
613
656
 
614
657
  const valid = certVerdict(sigResult, transparencyFailed, anchor);
615
658
 
package/src/client.ts CHANGED
@@ -48,7 +48,14 @@ import {
48
48
  parseWebhookDelivery,
49
49
  parseWebhookRotateResponse,
50
50
  } from "./models.js";
51
+ import type { KeyEnrollmentResult, SystemRegistrationCertificate } from "./models.js";
51
52
  import { Paginator } from "./pagination.js";
53
+ // Types only: erased at build, so the browser entry that shares this module
54
+ // never loads node:crypto. The code behind them is imported lazily, on use.
55
+ import type { CustomerKeyGroup } from "./customer-keys.js";
56
+ import type { EnclavePinOptions, RegisteredSystem } from "./enclave-registration.js";
57
+ import type { EnclaveIdentity } from "./enclave-seal.js";
58
+ import type { KeyGroupSigner } from "./key-group.js";
52
59
 
53
60
  type Raw = Record<string, unknown>;
54
61
 
@@ -102,6 +109,13 @@ export class BurnLedger {
102
109
  connectionConfig?: Record<string, unknown>;
103
110
  dsn?: string;
104
111
  uri?: string;
112
+ /**
113
+ * The connection config already sealed to the enclave — by
114
+ * sealConnectionConfig, or by createRegisteredSystem, which does it for you.
115
+ * The API relays these bytes and cannot open them. Instead of
116
+ * connectionConfig/dsn/uri, never beside them.
117
+ */
118
+ sealedConnectionConfig?: Uint8Array;
105
119
  subjectQuery: string;
106
120
  hashScope?: string;
107
121
  /** The system holds data you treat as PHI. Makes the default proof mode
@@ -112,15 +126,19 @@ export class BurnLedger {
112
126
  maxBytes?: number;
113
127
  queryTimeout?: string;
114
128
  }): Promise<System> {
115
- const config = resolveConnectionConfig(
116
- opts.connectionConfig,
117
- opts.dsn,
118
- opts.uri,
119
- );
129
+ const sealed = opts.sealedConnectionConfig;
130
+ if (
131
+ sealed !== undefined &&
132
+ (opts.connectionConfig !== undefined || opts.dsn !== undefined || opts.uri !== undefined)
133
+ ) {
134
+ throw new Error("Cannot provide both sealedConnectionConfig and connectionConfig/dsn/uri.");
135
+ }
120
136
  const body = {
121
137
  name: opts.name,
122
138
  connector_type: opts.connectorType,
123
- connection_config: config,
139
+ ...(sealed === undefined
140
+ ? { connection_config: resolveConnectionConfig(opts.connectionConfig, opts.dsn, opts.uri) }
141
+ : { sealed_connection_config: bytesToBase64(sealed) }),
124
142
  subject_query: opts.subjectQuery,
125
143
  hash_scope: opts.hashScope ?? "existence",
126
144
  phi_in_scope: opts.phiInScope ?? false,
@@ -458,6 +476,172 @@ export class BurnLedger {
458
476
  await this.transport.request("DELETE", "/v1/me");
459
477
  }
460
478
 
479
+ // --- Customer key groups and enclave registration (ADR-025; Node only) ---
480
+ //
481
+ // These load node:crypto on first use, lazily, because this class is also the
482
+ // browser entry's. ADR-025 §2 makes a client the customer runs locally the
483
+ // only place enrolment can happen, so a browser has no business here anyway.
484
+ // `teamId` is the team every signed payload binds; it is also sent as
485
+ // asserted_team_id, so naming the wrong team is a 403 that says so rather than
486
+ // an enclave 401 that cannot.
487
+
488
+ /**
489
+ * Verify the enclave: `GET /v1/enclave/attestation` over a 32-byte challenge
490
+ * drawn here (or `pin.nonce`), checked against the PCR0 you pinned out of band.
491
+ * Returns the keys the document binds. Throws EnclaveAttestationError on
492
+ * anything short of a document from the pinned image answering this challenge.
493
+ * Pass the result as `enclave` to the calls below: configs are sealed to it,
494
+ * and every document they return must verify under its signing key.
495
+ */
496
+ async attestEnclaveIdentity(pin: EnclavePinOptions): Promise<EnclaveIdentity> {
497
+ const flow = await import("./enclave-registration.js");
498
+ const { nonce, params } = flow.attestationParams(pin.nonce);
499
+ const data = await this.transport.request("GET", "/v1/enclave/attestation", {
500
+ params,
501
+ authenticated: false,
502
+ });
503
+ return flow.identityFromResponse(data, { ...pin, nonce });
504
+ }
505
+
506
+ /**
507
+ * Enrol a key group for your team (ENROLL_KEY). Every member signs; the private
508
+ * keys stay with the signers. `notAfter` is the latest the authorization may
509
+ * end, signed by every member so a relayed copy of this request cannot renew
510
+ * it; it defaults to the enclave's 90-day maximum. Throws VerificationError
511
+ * unless both documents that come back verify under `enclave`'s signing key,
512
+ * name exactly this group — ADR-025 §2's one comparison — and your team, end
513
+ * exactly at `notAfter` and do not start after `now` (this machine's clock by default).
514
+ */
515
+ async enrollKeyGroup(opts: {
516
+ teamId: string;
517
+ group: CustomerKeyGroup;
518
+ signers: readonly KeyGroupSigner[];
519
+ enclave: EnclaveIdentity;
520
+ notAfter?: Date;
521
+ /** The time to judge the answer's not_before against. Defaults to now. */
522
+ now?: Date;
523
+ }): Promise<KeyEnrollmentResult> {
524
+ const flow = await import("./enclave-registration.js");
525
+ const { body, keyId, notAfter } = await flow.enrollBody(opts);
526
+ const data = await this.transport.request("POST", "/v1/enclave/keys", { json: body });
527
+ return flow.checkEnrollment(data, {
528
+ enclave: opts.enclave,
529
+ keyId,
530
+ prevKeyId: undefined,
531
+ teamId: opts.teamId,
532
+ notAfter,
533
+ now: opts.now,
534
+ });
535
+ }
536
+
537
+ /**
538
+ * Replace an enrolled group with another (ROTATE_KEY). The OUTGOING group
539
+ * signs at its threshold and EVERY incoming member signs, over the same
540
+ * request and `notAfter`. Systems registered under the old group keep working
541
+ * through the link this issues; nothing is re-registered. The answer is checked
542
+ * as `enrollKeyGroup`'s is, and must also link to the outgoing group.
543
+ */
544
+ async rotateKeyGroup(opts: {
545
+ teamId: string;
546
+ previousGroup: CustomerKeyGroup;
547
+ previousSigners: readonly KeyGroupSigner[];
548
+ nextGroup: CustomerKeyGroup;
549
+ nextSigners: readonly KeyGroupSigner[];
550
+ enclave: EnclaveIdentity;
551
+ notAfter?: Date;
552
+ /** The time to judge the answer's not_before against. Defaults to now. */
553
+ now?: Date;
554
+ }): Promise<KeyEnrollmentResult> {
555
+ const flow = await import("./enclave-registration.js");
556
+ const { body, prevKeyId, nextKeyId, notAfter } = await flow.rotateBody(opts);
557
+ const data = await this.transport.request("POST", "/v1/enclave/keys/rotate", { json: body });
558
+ return flow.checkEnrollment(data, {
559
+ enclave: opts.enclave,
560
+ keyId: nextKeyId,
561
+ prevKeyId,
562
+ teamId: opts.teamId,
563
+ notAfter,
564
+ now: opts.now,
565
+ });
566
+ }
567
+
568
+ /**
569
+ * Register an existing system under your key group (REGISTER_SYSTEM), signed at
570
+ * the group's threshold. `config` must be the exact bytes submitted when the
571
+ * system was created, and `queryTemplate` the system's subject query exactly.
572
+ * The registration that comes back must verify under `enclave`'s signing key.
573
+ */
574
+ async registerSystemWithKey(opts: {
575
+ teamId: string;
576
+ group: CustomerKeyGroup;
577
+ signers: readonly KeyGroupSigner[];
578
+ enclave: EnclaveIdentity;
579
+ systemId: string;
580
+ config: Uint8Array | string;
581
+ queryTemplate: string;
582
+ connectorType: string;
583
+ }): Promise<SystemRegistrationCertificate> {
584
+ const flow = await import("./enclave-registration.js");
585
+ const { body, keyId, configDigest } = await flow.registrationBody(opts);
586
+ const data = await this.transport.request("POST", "/v1/enclave/registrations", { json: body });
587
+ return flow.checkRegistration(data, {
588
+ enclave: opts.enclave,
589
+ keyId,
590
+ systemId: opts.systemId,
591
+ configDigest,
592
+ connectorType: opts.connectorType,
593
+ });
594
+ }
595
+
596
+ /**
597
+ * ADR-025's whole customer path for a new system: seal the config to `enclave`
598
+ * (from {@link attestEnclaveIdentity}), create the system with only the sealed
599
+ * bytes, and register it under your key group.
600
+ */
601
+ async createRegisteredSystem(opts: {
602
+ teamId: string;
603
+ group: CustomerKeyGroup;
604
+ signers: readonly KeyGroupSigner[];
605
+ enclave: EnclaveIdentity;
606
+ name: string;
607
+ connectorType: string;
608
+ connectionConfig?: Record<string, unknown>;
609
+ dsn?: string;
610
+ uri?: string;
611
+ subjectQuery: string;
612
+ hashScope?: string;
613
+ phiInScope?: boolean;
614
+ maxRecords?: number;
615
+ maxBytes?: number;
616
+ queryTimeout?: string;
617
+ }): Promise<RegisteredSystem> {
618
+ const flow = await import("./enclave-registration.js");
619
+ const { sealToKey } = await import("./enclave-seal.js");
620
+ const config = flow.configBytes(resolveConnectionConfig(opts.connectionConfig, opts.dsn, opts.uri));
621
+ const system = await this.registerSystem({
622
+ name: opts.name,
623
+ connectorType: opts.connectorType,
624
+ subjectQuery: opts.subjectQuery,
625
+ hashScope: opts.hashScope,
626
+ phiInScope: opts.phiInScope,
627
+ maxRecords: opts.maxRecords,
628
+ maxBytes: opts.maxBytes,
629
+ queryTimeout: opts.queryTimeout,
630
+ sealedConnectionConfig: await sealToKey(opts.enclave.configSealKey, config),
631
+ });
632
+ const registration = await this.registerSystemWithKey({
633
+ teamId: opts.teamId,
634
+ group: opts.group,
635
+ signers: opts.signers,
636
+ enclave: opts.enclave,
637
+ systemId: system.id,
638
+ config,
639
+ queryTemplate: opts.subjectQuery,
640
+ connectorType: opts.connectorType,
641
+ });
642
+ return { system, registration };
643
+ }
644
+
461
645
  // --- System Health ---
462
646
 
463
647
  async getSystemHealth(systemId: string): Promise<SystemHealth> {
@@ -532,6 +716,14 @@ export class BurnLedger {
532
716
  // Connection config resolution
533
717
  // ---------------------------------------------------------------------------
534
718
 
719
+ /** Standard base64, as Go decodes a []byte field. No Buffer: this module is
720
+ * also the browser entry's. */
721
+ function bytesToBase64(bytes: Uint8Array): string {
722
+ let binary = "";
723
+ for (const b of bytes) binary += String.fromCharCode(b);
724
+ return btoa(binary);
725
+ }
726
+
535
727
  function resolveConnectionConfig(
536
728
  connectionConfig: Record<string, unknown> | undefined,
537
729
  dsn: string | undefined,