burnledger 0.5.0 → 0.6.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 (88) hide show
  1. package/README.md +8 -1
  2. package/dist/cjs/anchor.d.ts +71 -0
  3. package/dist/cjs/anchor.d.ts.map +1 -0
  4. package/dist/cjs/anchor.js +279 -0
  5. package/dist/cjs/anchor.js.map +1 -0
  6. package/dist/cjs/client.d.ts +16 -2
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +24 -5
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/enclave-seal.d.ts +101 -0
  11. package/dist/cjs/enclave-seal.d.ts.map +1 -0
  12. package/dist/cjs/enclave-seal.js +479 -0
  13. package/dist/cjs/enclave-seal.js.map +1 -0
  14. package/dist/cjs/errors.d.ts +14 -0
  15. package/dist/cjs/errors.d.ts.map +1 -1
  16. package/dist/cjs/errors.js +15 -1
  17. package/dist/cjs/errors.js.map +1 -1
  18. package/dist/cjs/index.d.ts +12 -2
  19. package/dist/cjs/index.d.ts.map +1 -1
  20. package/dist/cjs/index.js +18 -1
  21. package/dist/cjs/index.js.map +1 -1
  22. package/dist/cjs/keys.d.ts +66 -0
  23. package/dist/cjs/keys.d.ts.map +1 -1
  24. package/dist/cjs/keys.js +128 -3
  25. package/dist/cjs/keys.js.map +1 -1
  26. package/dist/cjs/models.d.ts +20 -0
  27. package/dist/cjs/models.d.ts.map +1 -1
  28. package/dist/cjs/models.js +16 -0
  29. package/dist/cjs/models.js.map +1 -1
  30. package/dist/cjs/verify.d.ts +31 -0
  31. package/dist/cjs/verify.d.ts.map +1 -1
  32. package/dist/cjs/verify.js +181 -31
  33. package/dist/cjs/verify.js.map +1 -1
  34. package/dist/cjs/web-verifier.d.ts +44 -0
  35. package/dist/cjs/web-verifier.d.ts.map +1 -1
  36. package/dist/cjs/web-verifier.js +31 -2
  37. package/dist/cjs/web-verifier.js.map +1 -1
  38. package/dist/esm/anchor.d.ts +71 -0
  39. package/dist/esm/anchor.d.ts.map +1 -0
  40. package/dist/esm/anchor.js +275 -0
  41. package/dist/esm/anchor.js.map +1 -0
  42. package/dist/esm/cli.d.ts +45 -14
  43. package/dist/esm/cli.d.ts.map +1 -1
  44. package/dist/esm/cli.js +315 -70
  45. package/dist/esm/cli.js.map +1 -1
  46. package/dist/esm/client.d.ts +16 -2
  47. package/dist/esm/client.d.ts.map +1 -1
  48. package/dist/esm/client.js +24 -5
  49. package/dist/esm/client.js.map +1 -1
  50. package/dist/esm/enclave-seal.d.ts +101 -0
  51. package/dist/esm/enclave-seal.d.ts.map +1 -0
  52. package/dist/esm/enclave-seal.js +472 -0
  53. package/dist/esm/enclave-seal.js.map +1 -0
  54. package/dist/esm/errors.d.ts +14 -0
  55. package/dist/esm/errors.d.ts.map +1 -1
  56. package/dist/esm/errors.js +14 -0
  57. package/dist/esm/errors.js.map +1 -1
  58. package/dist/esm/index.d.ts +12 -2
  59. package/dist/esm/index.d.ts.map +1 -1
  60. package/dist/esm/index.js +10 -1
  61. package/dist/esm/index.js.map +1 -1
  62. package/dist/esm/keys.d.ts +66 -0
  63. package/dist/esm/keys.d.ts.map +1 -1
  64. package/dist/esm/keys.js +125 -3
  65. package/dist/esm/keys.js.map +1 -1
  66. package/dist/esm/models.d.ts +20 -0
  67. package/dist/esm/models.d.ts.map +1 -1
  68. package/dist/esm/models.js +15 -0
  69. package/dist/esm/models.js.map +1 -1
  70. package/dist/esm/verify.d.ts +31 -0
  71. package/dist/esm/verify.d.ts.map +1 -1
  72. package/dist/esm/verify.js +180 -33
  73. package/dist/esm/verify.js.map +1 -1
  74. package/dist/esm/web-verifier.d.ts +44 -0
  75. package/dist/esm/web-verifier.d.ts.map +1 -1
  76. package/dist/esm/web-verifier.js +30 -1
  77. package/dist/esm/web-verifier.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/anchor.ts +330 -0
  80. package/src/cli.ts +341 -75
  81. package/src/client.ts +35 -4
  82. package/src/enclave-seal.ts +582 -0
  83. package/src/errors.ts +15 -0
  84. package/src/index.ts +22 -1
  85. package/src/keys.ts +176 -3
  86. package/src/models.ts +42 -0
  87. package/src/verify.ts +206 -33
  88. package/src/web-verifier.ts +45 -1
package/src/cli.ts CHANGED
@@ -6,57 +6,97 @@ import { readFile } from "node:fs/promises";
6
6
  import { realpathSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { nodeCrypto } from "./crypto-node.js";
9
- import { verifyCertificate, verifyTransparency, publicKeyFromHex } from "./verify.js";
10
- import { parseKeyEntries, type KeyEntry } from "./keys.js";
9
+ import { CERTIFICATE_REVOKED, VerificationError } from "./errors.js";
10
+ import {
11
+ formatTimestamp,
12
+ publicKeyFromHex,
13
+ verifyCertificateWithStatus,
14
+ verifyTransparency,
15
+ VALID_REVOCATION_UNKNOWN,
16
+ } from "./verify.js";
17
+ import {
18
+ KEY_LIST_STALE,
19
+ KEY_LIST_VALID,
20
+ parseKeyListDocument,
21
+ verifyKeyList,
22
+ type KeyEntry,
23
+ type KeyListEvidence,
24
+ } from "./keys.js";
25
+ import { ANCHOR_INCONSISTENT, checkStatementAnchor, type AnchorResult } from "./anchor.js";
11
26
  import type { PublicKeyInfo } from "./verify.js";
12
27
 
13
28
  type Cert = Record<string, unknown>;
14
- const USAGE = "Usage: burnledger check --cert <file> --keys <file> [--online --api-url <url> --api-key <key>]\n";
29
+ /** A published status statement, exactly as GET /v1/certificates/{id}/status
30
+ * serves it: hex strings, not decoded bytes. verifyCertificateWithStatus reads
31
+ * this shape, so nothing here re-encodes a field the signature covers. */
32
+ type StatusStatement = Record<string, unknown>;
33
+ const USAGE = "Usage: burnledger check --cert <file> --keys <file> [--online --api-url <url>]\n";
15
34
 
16
35
  function die(msg: string): never { process.stderr.write(msg); process.exit(2); }
17
36
 
18
37
  function parseArgs(argv: string[]): {
19
- certPath: string; keysPath: string; online: boolean; apiUrl: string; apiKey: string;
38
+ certPath: string; keysPath: string; online: boolean; apiUrl: string;
20
39
  } {
21
40
  const args = argv.slice(2);
22
41
  if (args[0] !== "check") die(USAGE);
23
- let certPath = "", keysPath = "", online = false, apiUrl = "", apiKey = "";
42
+ let certPath = "", keysPath = "", online = false, apiUrl = "";
24
43
  for (let i = 1; i < args.length; i++) {
25
44
  const a = args[i]!;
26
45
  if (a === "--cert") { certPath = args[++i] ?? die("--cert requires a value\n"); }
27
46
  else if (a === "--keys") { keysPath = args[++i] ?? die("--keys requires a value\n"); }
28
47
  else if (a === "--online") { online = true; }
29
48
  else if (a === "--api-url") { apiUrl = args[++i] ?? die("--api-url requires a value\n"); }
30
- else if (a === "--api-key") { apiKey = args[++i] ?? die("--api-key requires a value\n"); }
49
+ // Accepted and ignored. The status endpoint is unauthenticated, so the key
50
+ // buys nothing — but it is a published flag that scripts already pass, and
51
+ // failing on an unknown flag would break them for no gain.
52
+ else if (a === "--api-key") { void (args[++i] ?? die("--api-key requires a value\n")); }
31
53
  else die(`Unknown argument: ${a}\n`);
32
54
  }
33
55
  if (!certPath || !keysPath) die("Both --cert and --keys are required.\n");
34
56
  if (online) {
35
57
  try {
36
- ({ apiUrl, apiKey } = resolveApiConfig(apiUrl, apiKey, process.env));
58
+ apiUrl = resolveApiBaseUrl(apiUrl, process.env);
37
59
  } catch (e) {
38
60
  die((e as Error).message + "\n");
39
61
  }
40
62
  }
41
- return { certPath, keysPath, online, apiUrl, apiKey };
63
+ return { certPath, keysPath, online, apiUrl };
42
64
  }
43
65
 
44
66
  /** Flag first, then environment — the precedence docs/cli.md promises and the
45
- * Go CLI (cmd/cli/online.go resolveAPIConfig) implements. Exported for testing. */
46
- export function resolveApiConfig(
47
- apiUrl: string, apiKey: string, env: Record<string, string | undefined>,
48
- ): { apiUrl: string; apiKey: string } {
67
+ * Go CLI (cmd/cli/online.go resolveAPIBaseURL) implements. Exported for testing.
68
+ *
69
+ * It no longer resolves a key, and that is the change. GET
70
+ * /v1/certificates/{id}/status is unauthenticated by design: the audience for
71
+ * revocation is a relying party holding a certificate — a regulator, an
72
+ * auditor, the customer's customer — who has no account here and never will.
73
+ * Demanding an API key was what stopped that reader asking the one question the
74
+ * artifact exists to answer. */
75
+ export function resolveApiBaseUrl(
76
+ apiUrl: string, env: Record<string, string | undefined>,
77
+ ): string {
49
78
  apiUrl ||= env.BURNLEDGER_API_URL ?? "";
50
79
  if (!apiUrl) throw new Error("--api-url or BURNLEDGER_API_URL environment variable is required for online mode");
51
- apiKey ||= env.BURNLEDGER_API_KEY ?? "";
52
- if (!apiKey) throw new Error("--api-key or BURNLEDGER_API_KEY environment variable is required for online mode");
53
- return { apiUrl, apiKey };
80
+ return apiUrl;
54
81
  }
55
82
 
56
- async function loadKeys(path: string): Promise<Map<string, PublicKeyInfo>> {
83
+ /** Load the key set and, separately, what the file establishes about the key
84
+ * STATUSES (ADR-017 §5a). The keys load the same way whether or not the file
85
+ * carries a signed statement — key_id == sha256(public_key) makes every
86
+ * identity self-checking — so an unsigned file is not refused. What changes is
87
+ * what this CLI is entitled to say about the statuses and dates. Mirrors
88
+ * loadKeysFromFile in cmd/cli/keys.go. */
89
+ async function loadKeys(path: string): Promise<{ keys: Map<string, PublicKeyInfo>; keyList: KeyListEvidence }> {
57
90
  let entries: KeyEntry[];
91
+ let keyList: KeyListEvidence;
58
92
  try {
59
- entries = parseKeyEntries(JSON.parse(await readFile(path, "utf-8")));
93
+ const doc = parseKeyListDocument(JSON.parse(await readFile(path, "utf-8")));
94
+ entries = doc.keys;
95
+ // A statement that will not verify is a verdict; one that will not PARSE
96
+ // is a hard error, exactly as in Go: someone signed something the reader
97
+ // cannot read, and continuing as though the file were merely unsigned
98
+ // would throw that fact away.
99
+ keyList = await verifyKeyList(nodeCrypto, doc, new Date());
60
100
  } catch (e) {
61
101
  die((e as Error).message + "\n");
62
102
  }
@@ -92,31 +132,117 @@ async function loadKeys(path: string): Promise<Map<string, PublicKeyInfo>> {
92
132
  }
93
133
  map.set(pki.keyId, pki);
94
134
  }
95
- return map;
135
+ return { keys: map, keyList };
96
136
  }
97
137
 
98
- async function checkRevocation(apiUrl: string, apiKey: string, certId: string) {
99
- const res = await fetch(`${apiUrl}/v1/certificates/${certId}/revocation-status`, {
100
- headers: { Authorization: `Bearer ${apiKey}` },
101
- });
102
- if (!res.ok) throw new Error(`Revocation check failed: HTTP ${res.status}`);
103
- return (await res.json() as { data: { status: string; reason?: string } }).data;
138
+ /** Retrieve the SIGNED status statement for a certificate.
139
+ *
140
+ * It replaces a GET of /v1/certificates/{id}/revocation-status, which returned
141
+ * a bare {"status": "..."} with no signature over it. That answer was the API's
142
+ * word — or the word of whatever --api-url happened to point at — and this CLI
143
+ * printed it. Nothing about it was checkable, so a network attacker, a stale
144
+ * proxy or a wrong URL could suppress a revocation and the reader could not
145
+ * tell. Every field of this response that the verdict acts on is covered by the
146
+ * issuer's signature.
147
+ *
148
+ * No Authorization header is sent. The endpoint takes none, and sending an API
149
+ * key to an arbitrary --api-url is a credential leak the check never needed. */
150
+ async function fetchStatusStatement(apiUrl: string, certId: string): Promise<StatusStatement> {
151
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}/v1/certificates/${certId}/status`);
152
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
153
+ return unwrapStatusStatement(await res.json());
154
+ }
155
+
156
+ /** The API wraps every response in {"data": ...}; a caller may hand over either
157
+ * the envelope or the document inside it. Mirrors
158
+ * core.ParseCertificateStatusDocument, so this CLI and the Go one cannot
159
+ * disagree about which shape the endpoint returns. Exported for testing.
160
+ *
161
+ * A document that does not parse THROWS, and the caller turns that into a
162
+ * warning and a VALID_REVOCATION_UNKNOWN verdict. That is deliberate: an answer
163
+ * this build cannot read is silence about revocation, not a finding about the
164
+ * certificate. Handing a half-read document to the verifier instead would
165
+ * report "status statement signature is invalid" — a claim about the issuer —
166
+ * when what actually happened is that something returned nonsense.
167
+ *
168
+ * `status` itself is NOT validated against a known set. A statement saying
169
+ * SUSPENDED is a statement this build cannot read, and the verifier already has
170
+ * the right answer for that: it authenticates the signature first, then reports
171
+ * VALID_REVOCATION_UNKNOWN. Refusing it here would discard the statement before
172
+ * anyone authenticated it. */
173
+ export function unwrapStatusStatement(parsed: unknown): StatusStatement {
174
+ if (parsed === null || typeof parsed !== "object") {
175
+ throw new Error("status statement: response is not a JSON object");
176
+ }
177
+ let doc = parsed as StatusStatement;
178
+ if (doc.certificate_id === undefined && doc.data !== null && typeof doc.data === "object") {
179
+ doc = doc.data as StatusStatement;
180
+ }
181
+ for (const f of ["certificate_id", "status", "key_id"]) {
182
+ if (typeof doc[f] !== "string" || doc[f] === "") throw new Error(`status statement: ${f} is missing`);
183
+ }
184
+ for (const f of ["statement_issued_at", "statement_expires_at"]) requireRfc3339(doc, f);
185
+ if (doc.revoked_at != null) requireRfc3339(doc, "revoked_at");
186
+ if (typeof doc.sth_tree_size !== "number") throw new Error("status statement: sth_tree_size is not a number");
187
+ // Refuse a short or long value rather than letting it through: a truncated
188
+ // signature or root hash that reaches the verifier is checked against bytes
189
+ // nobody sent.
190
+ requireHex(doc, "sth_root_hash", 32);
191
+ requireHex(doc, "signature", 64);
192
+ return doc;
193
+ }
194
+
195
+ function requireRfc3339(doc: StatusStatement, field: string): void {
196
+ const v = doc[field];
197
+ if (typeof v !== "string" || Number.isNaN(Date.parse(v))) {
198
+ throw new Error(`status statement: ${field} is not RFC 3339`);
199
+ }
104
200
  }
105
201
 
202
+ function requireHex(doc: StatusStatement, field: string, bytes: number): void {
203
+ const v = doc[field];
204
+ if (typeof v !== "string" || !new RegExp(`^[0-9a-fA-F]{${bytes * 2}}$`).test(v)) {
205
+ throw new Error(`status statement: ${field} is not ${bytes} bytes of hex`);
206
+ }
207
+ }
208
+
209
+ /** The verdicts a reader can act on, and therefore the ones that exit 0.
210
+ *
211
+ * VALID_REVOCATION_UNKNOWN is here. It is the verdict every offline run now
212
+ * carries, and it means what an offline run has always meant: the document
213
+ * verified, and nothing was learned about revocation. Failing it would turn
214
+ * every existing `burnledger check` red overnight while telling the reader
215
+ * nothing new — the honest words are on the first line, where a human reads
216
+ * them, and the exit code keeps the meaning it was given. The soft KEY verdicts
217
+ * still exit 1, unchanged: those need a human before the record is relied on. */
218
+ const USABLE_VERDICTS: ReadonlySet<string> = new Set(["VALID", VALID_REVOCATION_UNKNOWN]);
219
+
220
+ /** The verdict word for an authenticated statement that says REVOKED.
221
+ *
222
+ * It is derived from VerificationError.code, never from the error's prose and
223
+ * never from the statement's own `status` field: every other rejection on the
224
+ * status path — unknown key, revoked key, bad signature, a statement about a
225
+ * different certificate — means the statement could not be authenticated, and
226
+ * rendering one of those as "REVOKED" would report an unauthenticated document
227
+ * as a fact about this certificate. */
228
+ const REVOKED = "REVOKED";
229
+
106
230
  /** The pass/fail decision behind the exit code. A signature failure, a
107
- * transparency FAILURE (thrown, not NOT_AVAILABLE), or an online-confirmed
108
- * revocation all make the certificate invalid. Exported for testing. */
231
+ * revocation, or a transparency FAILURE (thrown, not NOT_AVAILABLE) all make
232
+ * the certificate unusable. Revocation is no longer a separate input: it is
233
+ * part of the verdict verifyCertificateWithStatus returns. Exported for
234
+ * testing.
235
+ *
236
+ * An INCONSISTENT anchor refuses, mirroring exitCode in cmd/cli/check.go. It is
237
+ * not a doubt: it is a proof, from the issuer's own signatures, that the head a
238
+ * statement was anchored to is not a prefix of the head the log now publishes.
239
+ * An anchor that could NOT be checked does not refuse — the reader is told so,
240
+ * and the statement still stands on its own signature. */
109
241
  export function certVerdict(
110
- sigResult: string,
111
- transparencyFailed: boolean,
112
- online: boolean,
113
- revocation: { status: string } | undefined,
242
+ sigResult: string, transparencyFailed: boolean, anchor?: AnchorResult,
114
243
  ): boolean {
115
- return (
116
- sigResult === "VALID" &&
117
- !transparencyFailed &&
118
- !(online && revocation?.status === "REVOKED")
119
- );
244
+ if (anchor?.verdict === ANCHOR_INCONSISTENT) return false;
245
+ return USABLE_VERDICTS.has(sigResult) && !transparencyFailed;
120
246
  }
121
247
 
122
248
  /** Verdicts that are neither a pass nor a refusal, and what each one leaves
@@ -132,15 +258,21 @@ const SOFT_KEY_VERDICTS: Record<string, string> = {
132
258
  "confirm against the witness's record",
133
259
  };
134
260
 
135
- /** Qualifies everything this CLI says from
136
- * GET /v1/certificates/{id}/revocation-status. That response is a plain JSON
137
- * envelope with no signature over it, so it is the API's word rather than
138
- * something the reader can re-check. The signed form exists — a
139
- * certificate-status statement, which verifyCertificateWithStatus verifies —
140
- * and this CLI does not fetch one; until it does, saying whose word this is
141
- * beats printing the field as an established fact. Mirrors
142
- * unsignedRevocationNote in cmd/cli/format.go. */
143
- const UNSIGNED_REVOCATION_NOTE = "unsigned API response, not a signed status statement";
261
+ /** What a VALID_REVOCATION_UNKNOWN verdict leaves unestablished, and there are
262
+ * two ways to arrive at it.
263
+ *
264
+ * Offline it is the ordinary case and always was: nothing was asked, so nothing
265
+ * is known. Online it means the ask failed — a status endpoint that did not
266
+ * answer, an answer this build could not read, or one whose window had closed.
267
+ * Both are silence about revocation, and silence is reported as unknown. This
268
+ * CLI used to report both as VALID, which reads as "checked and not revoked".
269
+ * Mirrors revocationUnknownOffline/Online in cmd/cli/format.go. */
270
+ const REVOCATION_UNKNOWN_OFFLINE =
271
+ "offline — nothing was asked about revocation, so nothing is known; " +
272
+ "re-run with --online to fetch a signed status statement";
273
+ const REVOCATION_UNKNOWN_ONLINE =
274
+ "no usable signed status statement — the issuer said nothing this " +
275
+ "build could read about revocation, which is not the same as saying the record is live";
144
276
 
145
277
  /**
146
278
  * The certificate formats whose signing payload covers enclave_pcr0.
@@ -150,9 +282,13 @@ const UNSIGNED_REVOCATION_NOTE = "unsigned API response, not a signed status sta
150
282
  * It was `=== "5.0"`, which silently stopped being true the moment v6 was
151
283
  * added: v6 signs the measurement exactly as v5 does, and this CLI told the
152
284
  * reader the field was under no signature while the Go CLI said the opposite
153
- * about the same file.
285
+ * about the same file. Widening it to a set did not stop the same thing
286
+ * happening again at v7 — burnledger 0.6.0 shipped reading v7 while still
287
+ * naming only 5.0 and 6.0 here, so a holder of a v7 record was told its
288
+ * enclave measurement was unattributable when the signature covers it.
289
+ * scripts/check-pcr0-signed-parity.py now pins this list to the Go function.
154
290
  */
155
- const ENCLAVE_PCR0_SIGNED_IN: readonly string[] = Object.freeze(["5.0", "6.0"]);
291
+ const ENCLAVE_PCR0_SIGNED_IN: readonly string[] = Object.freeze(["5.0", "6.0", "7.0"]);
156
292
 
157
293
  function signatureCoversEnclavePcr0(version: string): boolean {
158
294
  return ENCLAVE_PCR0_SIGNED_IN.includes(version);
@@ -168,37 +304,54 @@ function displayHash(v: unknown): string {
168
304
 
169
305
  export function formatOutput(
170
306
  cert: Cert, sigResult: string, transparency: string, transparencyFailed: boolean,
171
- revocation: { status: string; reason?: string } | undefined, online: boolean,
307
+ status: StatusStatement | undefined, online: boolean, anchor?: AnchorResult,
308
+ keyList?: KeyListEvidence,
172
309
  ): string {
173
310
  const id = (cert.certificate_id as string).slice(0, 6);
174
311
  const sub = displayHash((cert.subject as Record<string, unknown>).identifier_hash);
175
312
  const att = cert.attestation as Record<string, unknown>;
176
313
  const L: string[] = [];
177
314
 
315
+ // A proven-inconsistent anchor outranks every verdict below it, including
316
+ // REVOKED. The issuer has contradicted itself about an append-only structure,
317
+ // so nothing it says about this record — revoked or live — can be taken at
318
+ // face value, and printing "REVOKED" or "VALID" here would answer a question
319
+ // that is no longer the important one. Mirrors formatResult in
320
+ // cmd/cli/format.go, which puts this branch in the same place.
321
+ if (anchor?.verdict === ANCHOR_INCONSISTENT) {
322
+ L.push(`Verification record dc_${id}: INVALID (LOG_ANCHOR_INCONSISTENT)`);
323
+ L.push(` ${anchor.detail}`);
324
+ L.push(` The certificate itself verified as ${sigResult}; the issuer's log is what does not add up.`);
325
+ pushStatementEvidence(L, status);
326
+ return L.join("\n") + "\n";
327
+ }
328
+
178
329
  // A soft key verdict is not a forgery: every signature verified, and what
179
330
  // could not be established is the key's authority. "INVALID" would send an
180
331
  // auditor hunting for tampering that is not there, and "VALID" — which this
181
332
  // CLI printed — claims a check that did not happen.
182
333
  const note = SOFT_KEY_VERDICTS[sigResult];
183
334
  if (note !== undefined) return `Verification record dc_${id}: ${sigResult} (${note})\n`;
184
- if (sigResult !== "VALID") return `Verification record dc_${id}: INVALID (${sigResult})\n`;
185
- if (transparencyFailed) return `Verification record dc_${id}: INVALID (transparency: ${transparency})\n`;
186
- if (online && revocation?.status === "REVOKED") {
335
+ // Revocation is now a signed fact, so the reader gets the evidence with the
336
+ // verdict: who said it, when, and which log entry it points at.
337
+ if (sigResult === REVOKED) {
187
338
  L.push(`Verification record dc_${id}: INVALID (REVOKED)`);
188
- if (revocation.reason) L.push(` Revocation reason: ${revocation.reason}`);
189
- // Fail closed on the API's word — an unbelieved revocation is the worse
190
- // error — but name the source: this verdict rests on the same unsigned
191
- // field as the one below.
192
- L.push(` Revocation source: ${UNSIGNED_REVOCATION_NOTE}`);
339
+ pushStatementEvidence(L, status);
340
+ pushAnchorEvidence(L, anchor);
193
341
  return L.join("\n") + "\n";
194
342
  }
343
+ if (!USABLE_VERDICTS.has(sigResult)) return `Verification record dc_${id}: INVALID (${sigResult})\n`;
344
+ if (transparencyFailed) return `Verification record dc_${id}: INVALID (transparency: ${transparency})\n`;
195
345
 
196
- // An --online answer is not a stronger answer, only a different one, so the
197
- // qualifier is replaced rather than dropped.
198
- const tag = online
199
- ? ` (the API reports it is not revoked; ${UNSIGNED_REVOCATION_NOTE})`
200
- : " (offline — revocation status not checked)";
201
- L.push(`Verification record dc_${id}: VALID${tag}`);
346
+ // The two usable verdicts. VALID is sayable without a qualifier for the first
347
+ // time: it is reached only when a fresh statement, signed by a key from the
348
+ // key file and naming this certificate, said ACTIVE. Everything short of that
349
+ // is VALID_REVOCATION_UNKNOWN and says so in the verdict itself, not in a
350
+ // footnote a script will never read.
351
+ const tag = sigResult === VALID_REVOCATION_UNKNOWN
352
+ ? `${VALID_REVOCATION_UNKNOWN} (${online ? REVOCATION_UNKNOWN_ONLINE : REVOCATION_UNKNOWN_OFFLINE})`
353
+ : "VALID (a signed status statement says this record is live)";
354
+ L.push(`Verification record dc_${id}: ${tag}`);
202
355
  L.push(` Subject: sha256:${sub.slice(0, 6)}...`);
203
356
  L.push(` Format version: ${cert.certificate_format_version as string}`);
204
357
 
@@ -246,12 +399,90 @@ export function formatOutput(
246
399
  } else {
247
400
  L.push(` Transparency log: ${transparency === "NOT_AVAILABLE" ? "not included" : transparency}`);
248
401
  }
249
- if (online && revocation) {
250
- L.push(` Revocation status: ${revocation.status} (${UNSIGNED_REVOCATION_NOTE})`);
251
- }
402
+ pushStatementEvidence(L, status);
403
+ pushAnchorEvidence(L, anchor);
404
+ pushKeyListEvidence(L, keyList);
252
405
  return L.join("\n") + "\n";
253
406
  }
254
407
 
408
+ /** Where the key statuses this verdict rests on came from, printed on every
409
+ * run — including the ordinary one, where the answer is "an unsigned list".
410
+ *
411
+ * ADR-017 §5a's point is not that an unsigned key list is unusable. It is that
412
+ * the two halves of a key set have different lifetimes: key_id is
413
+ * sha256(public_key), so every key's IDENTITY is self-checking offline forever,
414
+ * while its status and dates are only as good as the endpoint they were
415
+ * fetched from and the moment they were fetched. Mirrors writeKeyListEvidence
416
+ * in cmd/cli/format.go, word for word, so the four verifiers say one thing. */
417
+ function pushKeyListEvidence(L: string[], e: KeyListEvidence | undefined): void {
418
+ if (e === undefined) return;
419
+ if (!e.present) {
420
+ L.push(" Key statuses: unsigned key list — identities are self-checking " +
421
+ "(key_id = sha256(public_key)); statuses and dates are not evidenced");
422
+ return;
423
+ }
424
+ if (e.result === KEY_LIST_VALID) {
425
+ L.push(` Key statuses: signed key_list.v3, VALID until ${e.expiresAt}`);
426
+ if (e.selfSigned) {
427
+ // Said out loud, every time. A list that vouches for itself proves only
428
+ // that whoever wrote it holds the key it names, and a reader who does
429
+ // not know that will read "VALID" as more than it is.
430
+ L.push(" Key list trust: the list is signed by a key inside itself — " +
431
+ "trust-on-first-use, not proof; pin this file and compare it on later runs");
432
+ }
433
+ return;
434
+ }
435
+ if (e.result === KEY_LIST_STALE) {
436
+ L.push(` Key statuses: signed key_list.v3, but its window closed at ${e.expiresAt} — ` +
437
+ "the identities still check, the statuses no longer do");
438
+ return;
439
+ }
440
+ L.push(` Key statuses: signed key_list.v3 did NOT verify (${e.result}) — ` +
441
+ "treat its statuses and dates as unevidenced");
442
+ }
443
+
444
+ /** Whether the statement's log anchor was tested, and how it came out.
445
+ *
446
+ * The UNVERIFIED line matters as much as the CONFIRMED one. A statement whose
447
+ * anchor could not be checked is a statement that rests entirely on its
448
+ * issuer's signature, and a reader who is not told that will assume the stronger
449
+ * thing — which is the mistake this whole command has been correcting. Mirrors
450
+ * writeAnchorEvidence in cmd/cli/format.go. */
451
+ function pushAnchorEvidence(L: string[], anchor: AnchorResult | undefined): void {
452
+ if (anchor === undefined) return;
453
+ L.push(` Statement log anchor: ${anchor.verdict} (${anchor.detail})`);
454
+ }
455
+
456
+ /** What the signed statement said, and by whom.
457
+ *
458
+ * Nothing is printed when there is no statement: the verdict line has already
459
+ * told the reader that revocation is unknown, and a "Revocation status: n/a"
460
+ * line here would put a word where there is no evidence.
461
+ *
462
+ * `Statement signed by` is not decoration. The verdict rests on that key, the
463
+ * key came from the reader's own --keys file, and naming it is what lets an
464
+ * auditor say which key they are trusting rather than "the API said so".
465
+ * Mirrors writeStatementEvidence in cmd/cli/format.go. */
466
+ function pushStatementEvidence(L: string[], status: StatusStatement | undefined): void {
467
+ if (status === undefined) return;
468
+ L.push(` Revocation status: ${String(status.status)} (signed status statement)`);
469
+ L.push(` Statement signed by: ${String(status.key_id)}`);
470
+ L.push(` Statement valid: ${formatTimestamp(status.statement_issued_at)} to ` +
471
+ `${formatTimestamp(status.statement_expires_at)}`);
472
+ // The head the issuer had seen when it spoke. Printed as evidence; whether it
473
+ // is a prefix of the head the log publishes now is a separate question, and
474
+ // the "Statement log anchor" line below answers it (anchor.ts).
475
+ L.push(` Statement anchored to: tree size ${String(status.sth_tree_size)}, ` +
476
+ `root ${displayHash(status.sth_root_hash)}`);
477
+ if (status.revoked_at != null) L.push(` Revoked at: ${formatTimestamp(status.revoked_at)}`);
478
+ if (status.replacement_certificate_id != null) {
479
+ L.push(` Replaced by: ${String(status.replacement_certificate_id)}`);
480
+ }
481
+ if (status.revocation_log_index != null) {
482
+ L.push(` Revocation log entry: #${String(status.revocation_log_index)}`);
483
+ }
484
+ }
485
+
255
486
  /** Accept the raw API response too: GET /v1/certificates/{id} nests the
256
487
  * signed certificate as data.certificate. Exported for testing. */
257
488
  export function unwrapCertificate(parsed: Cert): Cert {
@@ -269,15 +500,51 @@ export function unwrapCertificate(parsed: Cert): Cert {
269
500
  async function main(): Promise<void> {
270
501
  const args = parseArgs(process.argv);
271
502
  const cert = unwrapCertificate(JSON.parse(await readFile(args.certPath, "utf-8")) as Cert);
272
- const keys = await loadKeys(args.keysPath);
503
+ const { keys, keyList } = await loadKeys(args.keysPath);
504
+
505
+ // Fetched first, because the verdict now depends on it. A status statement is
506
+ // an input to the verdict, not a footnote printed beside one:
507
+ // verifyCertificateWithStatus answers REVOKED, VALID or
508
+ // VALID_REVOCATION_UNKNOWN from the same call, and splitting that decision
509
+ // across two checks is how this CLI ended up printing VALID for a certificate
510
+ // whose revocation it had never established.
511
+ let status: StatusStatement | undefined;
512
+ let anchor: AnchorResult | undefined;
513
+ if (args.online) {
514
+ try {
515
+ status = await fetchStatusStatement(args.apiUrl, cert.certificate_id as string);
516
+ } catch (e) {
517
+ // Non-fatal, and NOT a pass. Leaving `status` undefined sends the verdict
518
+ // to VALID_REVOCATION_UNKNOWN rather than to VALID: an endpoint that did
519
+ // not answer has said nothing about revocation, and the older code
520
+ // reported that silence as a clean bill of health.
521
+ process.stderr.write(
522
+ `warning: signed status statement unavailable: ${e instanceof Error ? e.message : String(e)}\n`,
523
+ );
524
+ }
525
+ // The statement's anchor is only checkable against the log, and only with a
526
+ // key the reader already holds. A statement signed by a key that is not in
527
+ // --keys cannot be authenticated at all, so there is nothing to anchor and
528
+ // no anchor line is printed. See anchor.ts for what this does and does not
529
+ // close.
530
+ if (status !== undefined) {
531
+ const key = keys.get(status.key_id as string);
532
+ if (key !== undefined) {
533
+ anchor = await checkStatementAnchor(nodeCrypto, args.apiUrl, status, key);
534
+ }
535
+ }
536
+ }
273
537
 
274
538
  // The verifier's own verdict, never a hardcoded word. VALID_KEY_WINDOW_UNKNOWN
275
- // and VALID_KEY_COMPROMISED_LATER are cases verifyCertificate deliberately
276
- // refused to call VALID, and this is the tool an auditor runs in a script —
277
- // the one whose exit code gets trusted.
539
+ // and VALID_KEY_COMPROMISED_LATER are cases verifyCertificateWithStatus
540
+ // deliberately refused to call VALID, and this is the tool an auditor runs in
541
+ // a script — the one whose exit code gets trusted.
278
542
  let sigResult = "VALID";
279
- try { sigResult = await verifyCertificate(nodeCrypto, cert, keys); }
280
- catch (e) { sigResult = e instanceof Error ? e.message : "unknown error"; }
543
+ try { sigResult = await verifyCertificateWithStatus(nodeCrypto, cert, keys, status ?? null); }
544
+ catch (e) {
545
+ if (e instanceof VerificationError && e.code === CERTIFICATE_REVOKED) sigResult = REVOKED;
546
+ else sigResult = e instanceof Error ? e.message : "unknown error";
547
+ }
281
548
 
282
549
  // NOT_AVAILABLE (no proof) is a legitimate result; only a thrown error is a
283
550
  // transparency FAILURE and must fail the verdict + exit code.
@@ -286,12 +553,11 @@ async function main(): Promise<void> {
286
553
  try { transparency = await verifyTransparency(nodeCrypto, cert, keys); }
287
554
  catch (e) { transparency = e instanceof Error ? e.message : "unknown error"; transparencyFailed = true; }
288
555
 
289
- let revocation: { status: string; reason?: string } | undefined;
290
- if (args.online) revocation = await checkRevocation(args.apiUrl, args.apiKey, cert.certificate_id as string);
291
-
292
- const valid = certVerdict(sigResult, transparencyFailed, args.online, revocation);
556
+ const valid = certVerdict(sigResult, transparencyFailed, anchor);
293
557
 
294
- process.stdout.write(formatOutput(cert, sigResult, transparency, transparencyFailed, revocation, args.online));
558
+ process.stdout.write(
559
+ formatOutput(cert, sigResult, transparency, transparencyFailed, status, args.online, anchor, keyList),
560
+ );
295
561
  process.exit(valid ? 0 : 1);
296
562
  }
297
563
 
package/src/client.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  ApiKeyResponse,
12
12
  Attestation,
13
13
  BatchAttestationResponse,
14
+ BatchRevokeResponse,
14
15
  CertificateResponse,
15
16
  CertificateStats,
16
17
  ConsistencyProof,
@@ -31,6 +32,7 @@ import {
31
32
  parseApiKeyResponse,
32
33
  parseAttestation,
33
34
  parseBatchAttestationResponse,
35
+ parseBatchRevokeResponse,
34
36
  parseCertificateResponse,
35
37
  parseCertificateStats,
36
38
  parseConsistencyProof,
@@ -65,6 +67,10 @@ export interface BurnLedgerOptions {
65
67
  teamId?: string;
66
68
  }
67
69
 
70
+ /** Per-call cap on POST /v1/certificates/batch-revoke, the same 100 the
71
+ * server enforces (service.maxBatchRevoke). Chunk larger sets by this. */
72
+ export const MAX_BATCH_REVOKE = 100;
73
+
68
74
  export class BurnLedger {
69
75
  private readonly transport: Transport;
70
76
 
@@ -306,6 +312,27 @@ export class BurnLedger {
306
312
  return parseCertificateResponse(data as Raw);
307
313
  }
308
314
 
315
+ /** Revokes up to MAX_BATCH_REVOKE records under one reason. Partial
316
+ * completion is the normal case, not an exception: inspect `errors`. */
317
+ async batchRevokeCertificates(
318
+ certificateIds: string[],
319
+ opts: { reason: string },
320
+ ): Promise<BatchRevokeResponse> {
321
+ if (certificateIds.length > MAX_BATCH_REVOKE) {
322
+ // The server answers 400 and revokes nothing; failing here spares the
323
+ // caller a rate-limited round trip for a request that cannot succeed.
324
+ throw new Error(
325
+ `batchRevokeCertificates: ${certificateIds.length} ids exceeds the limit of ${MAX_BATCH_REVOKE} per call`,
326
+ );
327
+ }
328
+ const data = await this.transport.request(
329
+ "POST",
330
+ "/v1/certificates/batch-revoke",
331
+ { json: { certificate_ids: certificateIds, reason: opts.reason } },
332
+ );
333
+ return parseBatchRevokeResponse(data as Raw);
334
+ }
335
+
309
336
  async getCertificateStats(): Promise<CertificateStats> {
310
337
  const data = await this.transport.request("GET", "/v1/certificates/stats");
311
338
  return parseCertificateStats(data as Raw);
@@ -396,10 +423,14 @@ export class BurnLedger {
396
423
  );
397
424
  }
398
425
 
399
- async createApiKey(): Promise<ApiKeyResponse> {
400
- const data = await this.transport.request("POST", "/v1/api-keys", {
401
- json: {},
402
- });
426
+ /** Create an API key. `teamId` pins it to one team the caller belongs to:
427
+ * a pinned key always acts for that team and refuses a conflicting
428
+ * `X-Team-ID` with 403. Omitted means unpinned — the server resolves the
429
+ * team per request, as every key created before pinning existed does. */
430
+ async createApiKey(params?: { teamId?: string }): Promise<ApiKeyResponse> {
431
+ const json: Record<string, unknown> = {};
432
+ if (params?.teamId !== undefined) json.team_id = params.teamId;
433
+ const data = await this.transport.request("POST", "/v1/api-keys", { json });
403
434
  return parseApiKeyResponse(data as Raw);
404
435
  }
405
436