burnledger 0.8.1 → 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 +73 -1
  11. package/dist/cjs/customer-keys.d.ts.map +1 -1
  12. package/dist/cjs/customer-keys.js +329 -3
  13. package/dist/cjs/customer-keys.js.map +1 -1
  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 +22 -10
  31. package/dist/cjs/index.d.ts.map +1 -1
  32. package/dist/cjs/index.js +30 -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 +108 -3
  47. package/dist/cjs/verify.d.ts.map +1 -1
  48. package/dist/cjs/verify.js +389 -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 +88 -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 +73 -1
  67. package/dist/esm/customer-keys.d.ts.map +1 -1
  68. package/dist/esm/customer-keys.js +323 -3
  69. package/dist/esm/customer-keys.js.map +1 -1
  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 +22 -10
  87. package/dist/esm/index.d.ts.map +1 -1
  88. package/dist/esm/index.js +18 -8
  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 +108 -3
  103. package/dist/esm/verify.d.ts.map +1 -1
  104. package/dist/esm/verify.js +386 -146
  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 +96 -79
  113. package/src/client.ts +198 -6
  114. package/src/customer-keys.ts +373 -3
  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 +35 -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 +499 -151
  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
@@ -321,8 +303,15 @@ function pushRecordCounts(L: string[], sys: Record<string, unknown>[]): void {
321
303
  }
322
304
 
323
305
  /**
324
- * Per system: did the enclave check it against a customer-signed registration,
325
- * 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.
326
315
  *
327
316
  * This line is the WHOLE POINT of the field. ADR-025 §2: the enclave cannot
328
317
  * refuse a second enrollment for a team — that needs durable state — so a parent
@@ -344,14 +333,11 @@ function pushRegistrationEvidence(L: string[], cert: Record<string, unknown>): v
344
333
  );
345
334
  return;
346
335
  }
336
+ const qualifier = registrationQualifier(issuingImage(cert));
347
337
  for (const sys of (cert.systems as Record<string, unknown>[]) ?? []) {
348
338
  const name = sys.system_name as string;
349
339
  if (sys.authorization === "certified") {
350
- L.push(
351
- ` Registration [${name}]: certified under ${sys.customer_key_id as string} — ` +
352
- "the enclave checked this system against a customer-signed registration. " +
353
- "If that key id is not yours, someone else authorized this verification.",
354
- );
340
+ L.push(` Registration [${name}]: certified under ${sys.customer_key_id as string} — ${qualifier}`);
355
341
  } else if (sys.authorization === "none") {
356
342
  L.push(
357
343
  ` Registration [${name}]: none — this verification was NOT checked against ` +
@@ -388,8 +374,9 @@ export function formatOutput(
388
374
  status: StatusStatement | undefined, online: boolean, anchor?: AnchorResult,
389
375
  keyList?: KeyListEvidence,
390
376
  ): string {
391
- const id = (cert.certificate_id as string).slice(0, 6);
392
- 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";
393
380
  const att = cert.attestation as Record<string, unknown>;
394
381
  const L: string[] = [];
395
382
 
@@ -407,22 +394,33 @@ export function formatOutput(
407
394
  return L.join("\n") + "\n";
408
395
  }
409
396
 
410
- // A soft key verdict is not a forgery: every signature verified, and what
411
- // could not be established is the key's authority. "INVALID" would send an
412
- // auditor hunting for tampering that is not there, and "VALID" — which this
413
- // CLI printed — claims a check that did not happen.
414
- const note = SOFT_KEY_VERDICTS[sigResult];
415
- if (note !== undefined) return `Verification record dc_${id}: ${sigResult} (${note})\n`;
416
397
  // Revocation is now a signed fact, so the reader gets the evidence with the
417
398
  // verdict: who said it, when, and which log entry it points at.
418
399
  if (sigResult === REVOKED) {
419
400
  L.push(`Verification record dc_${id}: INVALID (REVOKED)`);
401
+ pushRegistrationEvidence(L, cert);
420
402
  pushStatementEvidence(L, status);
421
403
  pushAnchorEvidence(L, anchor);
422
404
  return L.join("\n") + "\n";
423
405
  }
424
- 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.
425
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
+ }
426
424
 
427
425
  // The two usable verdicts. VALID is sayable without a qualifier for the first
428
426
  // time: it is reached only when a fresh statement, signed by a key from the
@@ -433,6 +431,7 @@ export function formatOutput(
433
431
  ? `${VALID_REVOCATION_UNKNOWN} (${online ? REVOCATION_UNKNOWN_ONLINE : REVOCATION_UNKNOWN_OFFLINE})`
434
432
  : "VALID (a signed status statement says this record is live)";
435
433
  L.push(`Verification record dc_${id}: ${tag}`);
434
+ const sub = displayHash((cert.subject as Record<string, unknown>).identifier_hash);
436
435
  L.push(` Subject: sha256:${sub.slice(0, 6)}...`);
437
436
  L.push(` Format version: ${cert.certificate_format_version as string}`);
438
437
 
@@ -583,7 +582,13 @@ export function unwrapCertificate(parsed: Cert): Cert {
583
582
 
584
583
  async function main(): Promise<void> {
585
584
  const args = parseArgs(process.argv);
586
- 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);
587
592
  const { keys, keyList } = await loadKeys(args.keysPath);
588
593
 
589
594
  // Fetched first, because the verdict now depends on it. A status statement is
@@ -623,11 +628,19 @@ async function main(): Promise<void> {
623
628
  // and VALID_KEY_COMPROMISED_LATER are cases verifyCertificateWithStatus
624
629
  // deliberately refused to call VALID, and this is the tool an auditor runs in
625
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.
626
635
  let sigResult = "VALID";
627
- 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
+ }
628
641
  catch (e) {
629
- if (e instanceof VerificationError && e.code === CERTIFICATE_REVOKED) sigResult = REVOKED;
630
- 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;
631
644
  }
632
645
 
633
646
  // NOT_AVAILABLE (no proof) is a legitimate result; only a thrown error is a
@@ -635,7 +648,11 @@ async function main(): Promise<void> {
635
648
  let transparency = "NOT_AVAILABLE";
636
649
  let transparencyFailed = false;
637
650
  try { transparency = await verifyTransparency(nodeCrypto, cert, keys); }
638
- 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
+ }
639
656
 
640
657
  const valid = certVerdict(sigResult, transparencyFailed, anchor);
641
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,