burnledger 0.8.1 → 0.9.1

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 (142) hide show
  1. package/README.md +144 -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 +175 -54
  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 +165 -0
  15. package/dist/cjs/enclave-registration.d.ts.map +1 -0
  16. package/dist/cjs/enclave-registration.js +287 -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 +75 -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/node-runtime.d.ts +40 -0
  43. package/dist/cjs/node-runtime.d.ts.map +1 -0
  44. package/dist/cjs/node-runtime.js +42 -0
  45. package/dist/cjs/node-runtime.js.map +1 -0
  46. package/dist/cjs/status-document.d.ts +25 -0
  47. package/dist/cjs/status-document.d.ts.map +1 -0
  48. package/dist/cjs/status-document.js +62 -0
  49. package/dist/cjs/status-document.js.map +1 -0
  50. package/dist/cjs/verify.d.ts +108 -3
  51. package/dist/cjs/verify.d.ts.map +1 -1
  52. package/dist/cjs/verify.js +451 -171
  53. package/dist/cjs/verify.js.map +1 -1
  54. package/dist/cjs/web-verifier.d.ts +23 -3
  55. package/dist/cjs/web-verifier.d.ts.map +1 -1
  56. package/dist/cjs/web-verifier.js +31 -5
  57. package/dist/cjs/web-verifier.js.map +1 -1
  58. package/dist/cjs/webhooks.d.ts +9 -2
  59. package/dist/cjs/webhooks.d.ts.map +1 -1
  60. package/dist/cjs/webhooks.js +29 -11
  61. package/dist/cjs/webhooks.js.map +1 -1
  62. package/dist/esm/anchor.d.ts +5 -3
  63. package/dist/esm/anchor.d.ts.map +1 -1
  64. package/dist/esm/anchor.js +10 -4
  65. package/dist/esm/anchor.js.map +1 -1
  66. package/dist/esm/cli.d.ts +30 -17
  67. package/dist/esm/cli.d.ts.map +1 -1
  68. package/dist/esm/cli.js +142 -83
  69. package/dist/esm/cli.js.map +1 -1
  70. package/dist/esm/client.d.ts +95 -0
  71. package/dist/esm/client.d.ts.map +1 -1
  72. package/dist/esm/client.js +175 -21
  73. package/dist/esm/client.js.map +1 -1
  74. package/dist/esm/customer-keys.d.ts +73 -1
  75. package/dist/esm/customer-keys.d.ts.map +1 -1
  76. package/dist/esm/customer-keys.js +323 -3
  77. package/dist/esm/customer-keys.js.map +1 -1
  78. package/dist/esm/enclave-registration.d.ts +165 -0
  79. package/dist/esm/enclave-registration.d.ts.map +1 -0
  80. package/dist/esm/enclave-registration.js +277 -0
  81. package/dist/esm/enclave-registration.js.map +1 -0
  82. package/dist/esm/enclave-seal.d.ts +43 -0
  83. package/dist/esm/enclave-seal.d.ts.map +1 -1
  84. package/dist/esm/enclave-seal.js +61 -1
  85. package/dist/esm/enclave-seal.js.map +1 -1
  86. package/dist/esm/errors.d.ts +10 -0
  87. package/dist/esm/errors.d.ts.map +1 -1
  88. package/dist/esm/errors.js +10 -0
  89. package/dist/esm/errors.js.map +1 -1
  90. package/dist/esm/index.browser.d.ts +7 -3
  91. package/dist/esm/index.browser.d.ts.map +1 -1
  92. package/dist/esm/index.browser.js +6 -3
  93. package/dist/esm/index.browser.js.map +1 -1
  94. package/dist/esm/index.d.ts +22 -10
  95. package/dist/esm/index.d.ts.map +1 -1
  96. package/dist/esm/index.js +30 -8
  97. package/dist/esm/index.js.map +1 -1
  98. package/dist/esm/key-group.d.ts +80 -0
  99. package/dist/esm/key-group.d.ts.map +1 -0
  100. package/dist/esm/key-group.js +130 -0
  101. package/dist/esm/key-group.js.map +1 -0
  102. package/dist/esm/models.d.ts +60 -0
  103. package/dist/esm/models.d.ts.map +1 -1
  104. package/dist/esm/models.js +59 -0
  105. package/dist/esm/models.js.map +1 -1
  106. package/dist/esm/node-runtime.d.ts +40 -0
  107. package/dist/esm/node-runtime.d.ts.map +1 -0
  108. package/dist/esm/node-runtime.js +38 -0
  109. package/dist/esm/node-runtime.js.map +1 -0
  110. package/dist/esm/status-document.d.ts +25 -0
  111. package/dist/esm/status-document.d.ts.map +1 -0
  112. package/dist/esm/status-document.js +59 -0
  113. package/dist/esm/status-document.js.map +1 -0
  114. package/dist/esm/verify.d.ts +108 -3
  115. package/dist/esm/verify.d.ts.map +1 -1
  116. package/dist/esm/verify.js +448 -171
  117. package/dist/esm/verify.js.map +1 -1
  118. package/dist/esm/web-verifier.d.ts +23 -3
  119. package/dist/esm/web-verifier.d.ts.map +1 -1
  120. package/dist/esm/web-verifier.js +27 -5
  121. package/dist/esm/web-verifier.js.map +1 -1
  122. package/dist/esm/webhooks.d.ts +9 -2
  123. package/dist/esm/webhooks.d.ts.map +1 -1
  124. package/dist/esm/webhooks.js +29 -11
  125. package/dist/esm/webhooks.js.map +1 -1
  126. package/package.json +1 -1
  127. package/src/anchor.ts +10 -4
  128. package/src/cli.ts +145 -78
  129. package/src/client.ts +241 -25
  130. package/src/customer-keys.ts +373 -3
  131. package/src/enclave-registration.ts +402 -0
  132. package/src/enclave-seal.ts +108 -1
  133. package/src/errors.ts +11 -0
  134. package/src/index.browser.ts +9 -3
  135. package/src/index.ts +48 -6
  136. package/src/key-group.ts +181 -0
  137. package/src/models.ts +131 -0
  138. package/src/node-runtime.ts +57 -0
  139. package/src/status-document.ts +59 -0
  140. package/src/verify.ts +565 -177
  141. package/src/web-verifier.ts +36 -3
  142. package/src/webhooks.ts +28 -11
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 {
@@ -147,64 +182,61 @@ async function loadKeys(path: string): Promise<{ keys: Map<string, PublicKeyInfo
147
182
  * issuer's signature.
148
183
  *
149
184
  * No Authorization header is sent. The endpoint takes none, and sending an API
150
- * key to an arbitrary --api-url is a credential leak the check never needed. */
151
- async function fetchStatusStatement(apiUrl: string, certId: string): Promise<StatusStatement> {
152
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}/v1/certificates/${certId}/status`);
153
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
154
- return unwrapStatusStatement(await res.json());
155
- }
156
-
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.
185
+ * key to an arbitrary --api-url is a credential leak the check never needed.
168
186
  *
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`);
187
+ * Read under a 1 MiB cap and a total deadline, matching the anchor fetch
188
+ * (anchor.ts) and the Go CLI (cmd/cli/online.go): a status statement is a few
189
+ * hundred bytes, so anything near the cap is a misbehaving or hostile server.
190
+ * The AbortController bounds the whole fetch including the body read, which a
191
+ * server dripping bytes could otherwise hold open. Exported for testing. */
192
+ export async function fetchStatusStatement(apiUrl: string, certId: string): Promise<StatusStatement> {
193
+ const url = `${apiUrl.replace(/\/+$/, "")}/v1/certificates/${certId}/status`;
194
+ const controller = new AbortController();
195
+ const timer = setTimeout(() => { controller.abort(); }, STATUS_FETCH_TIMEOUT_MS);
196
+ try {
197
+ const res = await fetch(url, { signal: controller.signal });
198
+ const body = await readCapped(res);
199
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
200
+ try {
201
+ return unwrapStatusStatement(JSON.parse(body));
202
+ } catch (e) {
203
+ throw new Error(`parse response: ${e instanceof Error ? e.message : String(e)}`);
204
+ }
205
+ } finally {
206
+ clearTimeout(timer);
184
207
  }
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
208
  }
195
209
 
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`);
210
+ /** How much of the status response to buffer (1 MiB) and how long the whole
211
+ * fetch may take (10 s), matching cmd/cli/online.go and anchor.ts. */
212
+ const MAX_STATUS_BYTES = 1 << 20;
213
+ const STATUS_FETCH_TIMEOUT_MS = 10_000;
214
+
215
+ /** Read a response body, stopping at the cap instead of buffering whatever a
216
+ * hostile server decides to send. The stream is cancelled on the way out, so an
217
+ * endless body costs one megabyte and not the process. */
218
+ async function readCapped(res: Response): Promise<string> {
219
+ const reader = res.body?.getReader();
220
+ if (reader === undefined) return "";
221
+ const chunks: Uint8Array[] = [];
222
+ let total = 0;
223
+ for (;;) {
224
+ const { done, value } = await reader.read();
225
+ if (done) break;
226
+ total += value.length;
227
+ if (total > MAX_STATUS_BYTES) {
228
+ await reader.cancel();
229
+ throw new Error(`read response: body exceeds ${MAX_STATUS_BYTES} bytes`);
230
+ }
231
+ chunks.push(value);
200
232
  }
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`);
233
+ const joined = new Uint8Array(total);
234
+ let at = 0;
235
+ for (const c of chunks) {
236
+ joined.set(c, at);
237
+ at += c.length;
207
238
  }
239
+ return new TextDecoder().decode(joined);
208
240
  }
209
241
 
210
242
  /** The verdicts a reader can act on, and therefore the ones that exit 0.
@@ -321,8 +353,15 @@ function pushRecordCounts(L: string[], sys: Record<string, unknown>[]): void {
321
353
  }
322
354
 
323
355
  /**
324
- * Per system: did the enclave check it against a customer-signed registration,
325
- * and under which key?
356
+ * Per system: did the enclave check it against a registration, under which key,
357
+ * and what does the image that signed the record make of that?
358
+ *
359
+ * Printed for exactly the verdicts that say the record's own signature
360
+ * verified: VALID, VALID_REVOCATION_UNKNOWN, the two soft key verdicts and
361
+ * REVOKED, as in the Go and Python CLIs and the /verify page. The qualifier is
362
+ * read from issuer.enclave_pcr0, which that signature covers, and
363
+ * verifyCertificate checks it before any of those verdicts is reached. Beside
364
+ * INVALID the field is unverified bytes, so nothing is printed there.
326
365
  *
327
366
  * This line is the WHOLE POINT of the field. ADR-025 §2: the enclave cannot
328
367
  * refuse a second enrollment for a team — that needs durable state — so a parent
@@ -344,14 +383,11 @@ function pushRegistrationEvidence(L: string[], cert: Record<string, unknown>): v
344
383
  );
345
384
  return;
346
385
  }
386
+ const qualifier = registrationQualifier(issuingImage(cert));
347
387
  for (const sys of (cert.systems as Record<string, unknown>[]) ?? []) {
348
388
  const name = sys.system_name as string;
349
389
  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
- );
390
+ L.push(` Registration [${name}]: certified under ${sys.customer_key_id as string} — ${qualifier}`);
355
391
  } else if (sys.authorization === "none") {
356
392
  L.push(
357
393
  ` Registration [${name}]: none — this verification was NOT checked against ` +
@@ -388,8 +424,9 @@ export function formatOutput(
388
424
  status: StatusStatement | undefined, online: boolean, anchor?: AnchorResult,
389
425
  keyList?: KeyListEvidence,
390
426
  ): string {
391
- const id = (cert.certificate_id as string).slice(0, 6);
392
- const sub = displayHash((cert.subject as Record<string, unknown>).identifier_hash);
427
+ // This line prints for records that FAILED verification too, so it must not
428
+ // assume the shape verification would have checked.
429
+ const id = typeof cert.certificate_id === "string" ? cert.certificate_id.slice(0, 6) : "unknown";
393
430
  const att = cert.attestation as Record<string, unknown>;
394
431
  const L: string[] = [];
395
432
 
@@ -407,22 +444,33 @@ export function formatOutput(
407
444
  return L.join("\n") + "\n";
408
445
  }
409
446
 
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
447
  // Revocation is now a signed fact, so the reader gets the evidence with the
417
448
  // verdict: who said it, when, and which log entry it points at.
418
449
  if (sigResult === REVOKED) {
419
450
  L.push(`Verification record dc_${id}: INVALID (REVOKED)`);
451
+ pushRegistrationEvidence(L, cert);
420
452
  pushStatementEvidence(L, status);
421
453
  pushAnchorEvidence(L, anchor);
422
454
  return L.join("\n") + "\n";
423
455
  }
424
- if (!USABLE_VERDICTS.has(sigResult)) return `Verification record dc_${id}: INVALID (${sigResult})\n`;
456
+ const note = SOFT_KEY_VERDICTS[sigResult];
457
+ if (!USABLE_VERDICTS.has(sigResult) && note === undefined) {
458
+ return `Verification record dc_${id}: INVALID (${sigResult})\n`;
459
+ }
460
+ // A failed proof refuses the record, and it outranks a soft key verdict: a
461
+ // verdict that asks for a human must not hide one that refuses. The Python
462
+ // CLI and the /verify page already put it first; this CLI and Go printed
463
+ // the soft verdict instead.
425
464
  if (transparencyFailed) return `Verification record dc_${id}: INVALID (transparency: ${transparency})\n`;
465
+ // A soft key verdict is not a forgery: every signature verified, and what
466
+ // could not be established is the key's authority. "INVALID" would send an
467
+ // auditor hunting for tampering that is not there, and "VALID" — which this
468
+ // CLI printed — claims a check that did not happen.
469
+ if (note !== undefined) {
470
+ L.push(`Verification record dc_${id}: ${sigResult} (${note})`);
471
+ pushRegistrationEvidence(L, cert);
472
+ return L.join("\n") + "\n";
473
+ }
426
474
 
427
475
  // The two usable verdicts. VALID is sayable without a qualifier for the first
428
476
  // time: it is reached only when a fresh statement, signed by a key from the
@@ -433,6 +481,7 @@ export function formatOutput(
433
481
  ? `${VALID_REVOCATION_UNKNOWN} (${online ? REVOCATION_UNKNOWN_ONLINE : REVOCATION_UNKNOWN_OFFLINE})`
434
482
  : "VALID (a signed status statement says this record is live)";
435
483
  L.push(`Verification record dc_${id}: ${tag}`);
484
+ const sub = displayHash((cert.subject as Record<string, unknown>).identifier_hash);
436
485
  L.push(` Subject: sha256:${sub.slice(0, 6)}...`);
437
486
  L.push(` Format version: ${cert.certificate_format_version as string}`);
438
487
 
@@ -583,7 +632,13 @@ export function unwrapCertificate(parsed: Cert): Cert {
583
632
 
584
633
  async function main(): Promise<void> {
585
634
  const args = parseArgs(process.argv);
586
- const cert = unwrapCertificate(JSON.parse(await readFile(args.certPath, "utf-8")) as Cert);
635
+ const parsed: unknown = JSON.parse(await readFile(args.certPath, "utf-8"));
636
+ // No record to give a verdict about. Go's loader refuses the same file at
637
+ // unmarshal and exits 2, as this does.
638
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
639
+ die(`${args.certPath}: not a JSON object\n`);
640
+ }
641
+ const cert = unwrapCertificate(parsed as Cert);
587
642
  const { keys, keyList } = await loadKeys(args.keysPath);
588
643
 
589
644
  // Fetched first, because the verdict now depends on it. A status statement is
@@ -623,11 +678,19 @@ async function main(): Promise<void> {
623
678
  // and VALID_KEY_COMPROMISED_LATER are cases verifyCertificateWithStatus
624
679
  // deliberately refused to call VALID, and this is the tool an auditor runs in
625
680
  // a script — the one whose exit code gets trusted.
681
+ //
682
+ // Only a VerificationError is a verdict. Anything else is a defect in this
683
+ // CLI or the SDK under it; printing it as INVALID dressed a crash up as a
684
+ // finding about the record, so it propagates and exits 2 instead.
626
685
  let sigResult = "VALID";
627
- try { sigResult = await verifyCertificateWithStatus(nodeCrypto, cert, keys, status ?? null); }
686
+ try {
687
+ sigResult = await verifyCertificateWithStatus(nodeCrypto, cert, keys, status ?? null, undefined, {
688
+ requireAuthorization: args.policy,
689
+ });
690
+ }
628
691
  catch (e) {
629
- if (e instanceof VerificationError && e.code === CERTIFICATE_REVOKED) sigResult = REVOKED;
630
- else sigResult = e instanceof Error ? e.message : "unknown error";
692
+ if (!(e instanceof VerificationError)) throw e;
693
+ sigResult = e.code === CERTIFICATE_REVOKED ? REVOKED : e.message;
631
694
  }
632
695
 
633
696
  // NOT_AVAILABLE (no proof) is a legitimate result; only a thrown error is a
@@ -635,7 +698,11 @@ async function main(): Promise<void> {
635
698
  let transparency = "NOT_AVAILABLE";
636
699
  let transparencyFailed = false;
637
700
  try { transparency = await verifyTransparency(nodeCrypto, cert, keys); }
638
- catch (e) { transparency = e instanceof Error ? e.message : "unknown error"; transparencyFailed = true; }
701
+ catch (e) {
702
+ if (!(e instanceof VerificationError)) throw e;
703
+ transparency = e.message;
704
+ transparencyFailed = true;
705
+ }
639
706
 
640
707
  const valid = certVerdict(sigResult, transparencyFailed, anchor);
641
708