burnledger 0.3.1 → 0.5.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 (89) hide show
  1. package/README.md +15 -3
  2. package/dist/cjs/client.d.ts +14 -0
  3. package/dist/cjs/client.d.ts.map +1 -1
  4. package/dist/cjs/client.js +4 -0
  5. package/dist/cjs/client.js.map +1 -1
  6. package/dist/cjs/errors.d.ts +28 -1
  7. package/dist/cjs/errors.d.ts.map +1 -1
  8. package/dist/cjs/errors.js +30 -2
  9. package/dist/cjs/errors.js.map +1 -1
  10. package/dist/cjs/http.d.ts +21 -1
  11. package/dist/cjs/http.d.ts.map +1 -1
  12. package/dist/cjs/http.js +76 -12
  13. package/dist/cjs/http.js.map +1 -1
  14. package/dist/cjs/index.browser.d.ts +2 -4
  15. package/dist/cjs/index.browser.d.ts.map +1 -1
  16. package/dist/cjs/index.browser.js.map +1 -1
  17. package/dist/cjs/index.d.ts +14 -7
  18. package/dist/cjs/index.d.ts.map +1 -1
  19. package/dist/cjs/index.js +13 -2
  20. package/dist/cjs/index.js.map +1 -1
  21. package/dist/cjs/keys.d.ts +6 -0
  22. package/dist/cjs/keys.d.ts.map +1 -1
  23. package/dist/cjs/keys.js.map +1 -1
  24. package/dist/cjs/models.d.ts +24 -4
  25. package/dist/cjs/models.d.ts.map +1 -1
  26. package/dist/cjs/models.js +10 -4
  27. package/dist/cjs/models.js.map +1 -1
  28. package/dist/cjs/pagination.d.ts.map +1 -1
  29. package/dist/cjs/pagination.js.map +1 -1
  30. package/dist/cjs/verify.d.ts +83 -5
  31. package/dist/cjs/verify.d.ts.map +1 -1
  32. package/dist/cjs/verify.js +359 -28
  33. package/dist/cjs/verify.js.map +1 -1
  34. package/dist/cjs/web-verifier.d.ts +10 -5
  35. package/dist/cjs/web-verifier.d.ts.map +1 -1
  36. package/dist/cjs/web-verifier.js +7 -3
  37. package/dist/cjs/web-verifier.js.map +1 -1
  38. package/dist/cjs/webhooks.js.map +1 -1
  39. package/dist/esm/cli.d.ts +6 -0
  40. package/dist/esm/cli.d.ts.map +1 -1
  41. package/dist/esm/cli.js +116 -15
  42. package/dist/esm/cli.js.map +1 -1
  43. package/dist/esm/client.d.ts +14 -0
  44. package/dist/esm/client.d.ts.map +1 -1
  45. package/dist/esm/client.js +4 -0
  46. package/dist/esm/client.js.map +1 -1
  47. package/dist/esm/errors.d.ts +28 -1
  48. package/dist/esm/errors.d.ts.map +1 -1
  49. package/dist/esm/errors.js +29 -1
  50. package/dist/esm/errors.js.map +1 -1
  51. package/dist/esm/http.d.ts +21 -1
  52. package/dist/esm/http.d.ts.map +1 -1
  53. package/dist/esm/http.js +75 -12
  54. package/dist/esm/http.js.map +1 -1
  55. package/dist/esm/index.browser.d.ts +2 -4
  56. package/dist/esm/index.browser.d.ts.map +1 -1
  57. package/dist/esm/index.browser.js.map +1 -1
  58. package/dist/esm/index.d.ts +14 -7
  59. package/dist/esm/index.d.ts.map +1 -1
  60. package/dist/esm/index.js +11 -3
  61. package/dist/esm/index.js.map +1 -1
  62. package/dist/esm/keys.d.ts +6 -0
  63. package/dist/esm/keys.d.ts.map +1 -1
  64. package/dist/esm/keys.js.map +1 -1
  65. package/dist/esm/models.d.ts +24 -4
  66. package/dist/esm/models.d.ts.map +1 -1
  67. package/dist/esm/models.js +10 -4
  68. package/dist/esm/models.js.map +1 -1
  69. package/dist/esm/pagination.d.ts.map +1 -1
  70. package/dist/esm/verify.d.ts +83 -5
  71. package/dist/esm/verify.d.ts.map +1 -1
  72. package/dist/esm/verify.js +357 -28
  73. package/dist/esm/verify.js.map +1 -1
  74. package/dist/esm/web-verifier.d.ts +10 -5
  75. package/dist/esm/web-verifier.d.ts.map +1 -1
  76. package/dist/esm/web-verifier.js +11 -3
  77. package/dist/esm/web-verifier.js.map +1 -1
  78. package/dist/esm/webhooks.js.map +1 -1
  79. package/package.json +5 -3
  80. package/src/cli.ts +123 -13
  81. package/src/client.ts +16 -0
  82. package/src/errors.ts +32 -2
  83. package/src/http.ts +79 -12
  84. package/src/index.browser.ts +2 -2
  85. package/src/index.ts +16 -4
  86. package/src/keys.ts +11 -1
  87. package/src/models.ts +34 -8
  88. package/src/verify.ts +428 -28
  89. package/src/web-verifier.ts +14 -5
package/src/verify.ts CHANGED
@@ -17,7 +17,11 @@
17
17
  */
18
18
 
19
19
  import type { CryptoOps } from "./crypto.js";
20
- import { VerificationError } from "./errors.js";
20
+ import {
21
+ CERTIFICATE_REVOKED,
22
+ UNSUPPORTED_FORMAT_VERSION,
23
+ VerificationError,
24
+ } from "./errors.js";
21
25
  import type { TransparencyResult, VerificationResult } from "./models.js";
22
26
 
23
27
  // ---------------------------------------------------------------------------
@@ -30,6 +34,62 @@ const HEX_CHARS = "0123456789abcdef";
30
34
  // source-of-truth const block in Go's core/payload.go byte-for-byte.
31
35
  const PAYLOAD_TYPE_ATTESTATION = "burnledger.attestation.v3";
32
36
  const PAYLOAD_TYPE_CERTIFICATE = "burnledger.certificate.v3";
37
+ // v4 signs the legal entity behind the trading name and a commitment to the
38
+ // limitations wording. v3 certificates are verified forever -- they are evidence
39
+ // designed to outlive the company -- so this is a branch, never a replacement.
40
+ const PAYLOAD_TYPE_CERTIFICATE_V4 = "burnledger.certificate.v4";
41
+ // v5 moves BOTH tags. v4 moved only the certificate's, because the attestation
42
+ // shape did not change; in v5 it does, by way of the certificate carrying it.
43
+ const PAYLOAD_TYPE_ATTESTATION_V5 = "burnledger.attestation.v5";
44
+ const PAYLOAD_TYPE_CERTIFICATE_V5 = "burnledger.certificate.v5";
45
+ const FORMAT_VERSION_V5 = "5.0";
46
+ const FORMAT_VERSION_V4 = "4.0";
47
+ // v6 renames the artifact and nothing else: the shape is byte-for-byte v5, and
48
+ // only the tag and the version string differ. It still takes its own tag, for
49
+ // the same reason v4 and v5 each took one -- a signature over a payload calling
50
+ // itself a verification record must not verify as one calling itself a
51
+ // certificate. Two names for one shape sharing a separator is exactly what a
52
+ // separator prevents.
53
+ const PAYLOAD_TYPE_ATTESTATION_V6 = "burnledger.attestation.v6";
54
+ const PAYLOAD_TYPE_VERIFICATION_RECORD_V6 = "burnledger.verification_record.v6";
55
+ const FORMAT_VERSION_V6 = "6.0";
56
+ const FORMAT_VERSION_V3 = "3.0";
57
+
58
+ /**
59
+ * Every format this build can read, oldest first. Mirrors core.FormatVersions()
60
+ * in the Go source; the cross-language conformance corpus carries a fixture per
61
+ * version, so a version present in one language and missing here fails there.
62
+ *
63
+ * Frozen because `readonly` is erased at build time: it stops a TypeScript
64
+ * caller from writing to the array, and does nothing at all to the JavaScript
65
+ * this compiles to — which is what the browser bundle actually runs, where any
66
+ * script on the page could otherwise push a version onto it.
67
+ */
68
+ export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
69
+ FORMAT_VERSION_V3,
70
+ FORMAT_VERSION_V4,
71
+ FORMAT_VERSION_V5,
72
+ FORMAT_VERSION_V6,
73
+ ]);
74
+
75
+ /**
76
+ * Returns the version, or throws if this SDK cannot read it.
77
+ *
78
+ * Rejects a non-string too: JSON carrying `6.0` as a NUMBER would compare
79
+ * unequal to every known version and silently take the oldest branch, which is
80
+ * the same trap as an unknown version wearing a different hat.
81
+ */
82
+ function checkFormatVersion(version: unknown): string {
83
+ if (typeof version !== "string" || !KNOWN_FORMAT_VERSIONS.includes(version)) {
84
+ throw new VerificationError(
85
+ `unsupported certificate_format_version ${JSON.stringify(version)}; ` +
86
+ `this version of the SDK reads ${KNOWN_FORMAT_VERSIONS.join(", ")}. ` +
87
+ "The record may be genuine and simply newer than this library.",
88
+ UNSUPPORTED_FORMAT_VERSION,
89
+ );
90
+ }
91
+ return version;
92
+ }
33
93
  const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
34
94
  const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
35
95
  // There is deliberately no verification payload type: v3 produces those facts
@@ -101,16 +161,56 @@ function textToBytes(s: string): Uint8Array {
101
161
  // Public key info
102
162
  // ---------------------------------------------------------------------------
103
163
 
164
+ /** A signing key's disposition (ADR-017 §1).
165
+ *
166
+ * There is deliberately no "revoked": that word belongs to certificate
167
+ * revocation, and one word meaning two things is the confusion ADR-017 ends.
168
+ * Retirement is routine and a retired key's signatures stay exactly as good as
169
+ * the day they were made; compromise is an incident with a date.
170
+ */
171
+ export type KeyStatus = "active" | "retired" | "compromised";
172
+
104
173
  export interface PublicKeyInfo {
105
174
  readonly keyBytes: Uint8Array;
106
175
  readonly keyId: string;
176
+ /** Raw `key_status` from the published entry. Kept as a string rather than
177
+ * KeyStatus so a word this verifier does not understand can fail closed
178
+ * instead of being narrowed away at the type level. */
179
+ readonly keyStatus: string;
180
+ /** RFC 3339, whole seconds, exactly as published. */
181
+ readonly notBefore?: string;
182
+ readonly notAfter?: string;
183
+ readonly compromisedFrom?: string;
184
+ /** @deprecated Superseded by keyStatus. Retained because it is published
185
+ * surface: a caller still passing `{ revoked: true }` gets the same
186
+ * fail-closed behaviour it always did. */
107
187
  readonly revoked: boolean;
108
188
  }
109
189
 
190
+ /**
191
+ * The options publicKeyFromHex accepts, mirroring one entry of the published key
192
+ * set at `/.well-known/burnledger-keys`.
193
+ *
194
+ * Named, and exported, because it is restated by every entry point that wraps
195
+ * publicKeyFromHex — index.ts, index.browser.ts, web-verifier.ts. When the
196
+ * ADR-017 fields were added, two of the three were widened and the browser entry
197
+ * was not, so the dashboard could not express a compromised key through the API
198
+ * it imports and every key it built looked unconditionally active. A shared type
199
+ * is what stops the next field from being added to two places out of three.
200
+ */
201
+ export interface PublicKeyOptions {
202
+ /** @deprecated Use keyStatus. Retained: true still means "unusable". */
203
+ revoked?: boolean;
204
+ keyStatus?: string;
205
+ notBefore?: string;
206
+ notAfter?: string;
207
+ compromisedFrom?: string;
208
+ }
209
+
110
210
  export async function publicKeyFromHex(
111
211
  crypto: CryptoOps,
112
212
  hexKey: string,
113
- opts?: { revoked?: boolean },
213
+ opts?: PublicKeyOptions,
114
214
  ): Promise<PublicKeyInfo> {
115
215
  const raw = hexToBytes(hexKey);
116
216
  if (raw.length !== 32) {
@@ -118,7 +218,181 @@ export async function publicKeyFromHex(
118
218
  }
119
219
  const hash = await crypto.sha256(raw);
120
220
  const keyId = "dp_k_" + bytesToHex(hash);
121
- return { keyBytes: raw, keyId, revoked: opts?.revoked ?? false };
221
+ const revoked = opts?.revoked ?? false;
222
+ // An old caller passing revoked:true meant "this key is unusable" and must
223
+ // keep getting exactly that. Mapping it to a status this verifier cannot
224
+ // interpret reaches the same fail-closed branch as before.
225
+ const keyStatus = opts?.keyStatus ?? (revoked ? "revoked" : "active");
226
+ return {
227
+ keyBytes: raw,
228
+ keyId,
229
+ keyStatus,
230
+ notBefore: opts?.notBefore,
231
+ notAfter: opts?.notAfter,
232
+ compromisedFrom: opts?.compromisedFrom,
233
+ revoked,
234
+ };
235
+ }
236
+
237
+ /** Verdicts for the key check (ADR-017 §2). */
238
+ export const KEY_OUTSIDE_VALIDITY = "KEY_OUTSIDE_VALIDITY";
239
+ export const KEY_COMPROMISED = "KEY_COMPROMISED";
240
+ export const VALID_KEY_COMPROMISED_LATER = "VALID_KEY_COMPROMISED_LATER";
241
+ export const VALID_KEY_WINDOW_UNKNOWN = "VALID_KEY_WINDOW_UNKNOWN";
242
+
243
+ /**
244
+ * The revocation states a statement may assert. Mirrors
245
+ * core.ValidCertificateStatusValue: anything else means the statement was read
246
+ * but not understood, which is not the same as "not revoked".
247
+ */
248
+ const KNOWN_STATUS_VALUES: ReadonlySet<string> = new Set(["ACTIVE", "REVOKED"]);
249
+
250
+ /**
251
+ * The key verdicts that permit relying on a signature: VALID plus the two soft
252
+ * verdicts ADR-017 §2 says a reader must be SHOWN rather than refused. Anything
253
+ * not named here is refused, including a verdict added in a later release.
254
+ */
255
+ const USABLE_KEY_VERDICTS: ReadonlySet<string> = new Set([
256
+ "VALID",
257
+ VALID_KEY_WINDOW_UNKNOWN,
258
+ VALID_KEY_COMPROMISED_LATER,
259
+ ]);
260
+
261
+ /** Whether a key was authorized to sign at anchor time `t`.
262
+ *
263
+ * Returns `"VALID"` when the key clears. The anchor is seconds since the epoch
264
+ * from a time the verifier observed independently — the signed tree head's
265
+ * timestamp — never the certificate's own `issued_at`, which is signed by the
266
+ * very key whose validity is in question.
267
+ *
268
+ * Mirrors core.EvaluateKey in Go, including both of its settled readings: an
269
+ * entry stating no interval has nothing to check, so a missing anchor is not
270
+ * "unknown"; and a compromised key with no anchor does not soften, because
271
+ * without an anchor we cannot show the signature predates the compromise.
272
+ */
273
+ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
274
+ // A caller can still build a PublicKeyInfo by hand with revoked:true rather
275
+ // than going through publicKeyFromHex. That flag meant "this key is
276
+ // unusable" and must keep meaning exactly that.
277
+ if (pki.revoked) return "UNKNOWN_KEY";
278
+ if (pki.keyStatus === "compromised") {
279
+ if (anchor === null || pki.compromisedFrom === undefined) return KEY_COMPROMISED;
280
+ return anchor < parseRfc3339(pki.compromisedFrom)
281
+ ? VALID_KEY_COMPROMISED_LATER
282
+ : KEY_COMPROMISED;
283
+ }
284
+ if (pki.keyStatus === "active" || pki.keyStatus === "retired" || pki.keyStatus === "") {
285
+ if (pki.notBefore === undefined && pki.notAfter === undefined) return "VALID";
286
+ if (anchor === null) return VALID_KEY_WINDOW_UNKNOWN;
287
+ if (pki.notBefore !== undefined && anchor < parseRfc3339(pki.notBefore)) {
288
+ return KEY_OUTSIDE_VALIDITY;
289
+ }
290
+ if (pki.notAfter !== undefined && anchor >= parseRfc3339(pki.notAfter)) {
291
+ return KEY_OUTSIDE_VALIDITY;
292
+ }
293
+ return "VALID";
294
+ }
295
+ // A disposition this verifier does not understand is not one it can vouch
296
+ // for. Fail closed, as the boolean it replaces did.
297
+ return "UNKNOWN_KEY";
298
+ }
299
+
300
+ /**
301
+ * The anchor a certificate supplies, or null when it supplies none.
302
+ *
303
+ * READS AN UNSIGNED FIELD. `transparency` is stripped before signing, so on a
304
+ * certificate handed to a verifier the timestamp is holder-editable. Use
305
+ * {@link verifiedCertificateAnchor} anywhere the anchor decides whether a key
306
+ * may be relied on; this is safe only where the same timestamp is inside a
307
+ * payload the caller goes on to verify, which is what verifyTransparency does.
308
+ *
309
+ * Exported and unchanged because it is published API.
310
+ */
311
+ export function certificateAnchor(certificate: Record<string, unknown>): number | null {
312
+ const transparency = certificate.transparency as Record<string, unknown> | null | undefined;
313
+ if (transparency == null) return null;
314
+ const sth = transparency.signed_tree_head as Record<string, unknown> | undefined;
315
+ if (sth === undefined || typeof sth.timestamp !== "string") return null;
316
+ try {
317
+ return parseRfc3339(sth.timestamp);
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * The anchor, but only once the tree head carrying it has been checked against
325
+ * the key being evaluated.
326
+ *
327
+ * The bypass this closes: for a key declared compromised, evaluateKey answers
328
+ * KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
329
+ * anchor predates the compromise — and the second is a verdict requireUsableKey
330
+ * passes through as the RESULT of verifyCertificate. Since the timestamp is
331
+ * covered by no signature, a holder could backdate it and have a certificate
332
+ * signed with a compromised key reported as verified-with-a-caveat. Deleting
333
+ * `transparency` failed closed; keeping a doctored one did not.
334
+ *
335
+ * A head that does not verify yields no anchor, which is the same conservative
336
+ * answer as a certificate carrying no transparency block at all.
337
+ */
338
+ async function verifiedCertificateAnchor(
339
+ crypto: CryptoOps,
340
+ certificate: Record<string, unknown>,
341
+ pki: PublicKeyInfo,
342
+ ): Promise<number | null> {
343
+ const anchor = certificateAnchor(certificate);
344
+ if (anchor === null) return null;
345
+
346
+ const transparency = certificate.transparency as Record<string, unknown>;
347
+ const sth = transparency.signed_tree_head as Record<string, unknown>;
348
+ try {
349
+ const headPayload = buildTreeHeadPayload(sth);
350
+ const headSig = decodeSignature(sth.signature);
351
+ if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
352
+ } catch {
353
+ return null;
354
+ }
355
+ return anchor;
356
+ }
357
+
358
+ /** Throws for a key that cannot be used at all; returns the verdict otherwise.
359
+ *
360
+ * VALID_KEY_COMPROMISED_LATER and VALID_KEY_WINDOW_UNKNOWN are returned rather
361
+ * than thrown because they are not refusals — they are results a caller must
362
+ * show the reader instead of collapsing into green.
363
+ */
364
+ function requireUsableKey(
365
+ pki: PublicKeyInfo,
366
+ anchor: number | null,
367
+ keyId: string,
368
+ // Named, because this is also the key that signs a certificate-status
369
+ // statement, and "issuer key is compromised" would send a reader looking at
370
+ // the wrong key.
371
+ role = "issuer key",
372
+ ): string {
373
+ const verdict = evaluateKey(pki, anchor);
374
+ if (verdict === KEY_COMPROMISED) {
375
+ throw new VerificationError(`${role} is compromised: ${keyId}`);
376
+ }
377
+ if (verdict === KEY_OUTSIDE_VALIDITY) {
378
+ throw new VerificationError(`${role} was not valid when it signed: ${keyId}`);
379
+ }
380
+ if (verdict === "UNKNOWN_KEY") {
381
+ throw new VerificationError(`${role} has an unusable status "${pki.keyStatus}": ${keyId}`);
382
+ }
383
+ // An ALLOW-list, matching core.KeyIsUsable, which was inverted away from
384
+ // exactly the shape above. Naming the refusals and returning everything else
385
+ // means a verdict added to evaluateKey later becomes a PASS by default — and
386
+ // the value returned here is propagated out of verifyCertificate as its
387
+ // result, so "everything else" is rendered to a reader as "verified, with a
388
+ // note". Go would refuse the same document. That is #684's three-verifiers-
389
+ // two-answers divergence, in the direction that calls a bad document good.
390
+ if (!USABLE_KEY_VERDICTS.has(verdict)) {
391
+ throw new VerificationError(
392
+ `${role} returned a verdict this SDK does not recognize (${verdict}): ${keyId}`,
393
+ );
394
+ }
395
+ return verdict;
122
396
  }
123
397
 
124
398
  // ---------------------------------------------------------------------------
@@ -133,12 +407,24 @@ export async function verifyCertificate(
133
407
  certificate: Cert,
134
408
  publicKeys: Map<string, PublicKeyInfo>,
135
409
  ): Promise<VerificationResult> {
410
+ // Step 0: can this SDK read the format at all? Every check below rebuilds
411
+ // signed bytes from the version, so an unreadable version makes all of them
412
+ // meaningless - and "unknown issuer key" would be the wrong thing to tell
413
+ // someone holding a record that is merely newer than this library.
414
+ checkFormatVersion((certificate as Record<string, unknown>).certificate_format_version);
415
+
136
416
  const issuer = certificate.issuer as Record<string, unknown>;
137
417
  const keyId = issuer.key_id as string;
138
418
 
139
419
  const pki = publicKeys.get(keyId);
140
420
  if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
141
- if (pki.revoked) throw new VerificationError(`issuer key is revoked: ${keyId}`);
421
+ // The key check runs before any signature check: a signature verified
422
+ // against a key nobody vouches for proves nothing about the issuer.
423
+ const keyVerdict = requireUsableKey(
424
+ pki,
425
+ await verifiedCertificateAnchor(crypto, certificate, pki),
426
+ keyId,
427
+ );
142
428
 
143
429
  // 1. Certificate signature FIRST — nothing below may trust a field until the
144
430
  // bytes carrying it are covered by a verified signature.
@@ -160,6 +446,7 @@ export async function verifyCertificate(
160
446
  attested_at: att.attested_at,
161
447
  systems: attestationSystems(certificate),
162
448
  },
449
+ certificate.certificate_format_version as string,
163
450
  );
164
451
  const attSig = decodeSignature(att.attestation_signature as string);
165
452
  if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
@@ -222,7 +509,10 @@ export async function verifyCertificate(
222
509
  );
223
510
  }
224
511
 
225
- return "VALID" as VerificationResult;
512
+ // A soft key verdict is the result, not a footnote: VALID_KEY_WINDOW_UNKNOWN
513
+ // and VALID_KEY_COMPROMISED_LATER mean this document still needs a human, and
514
+ // reporting VALID here would be the lying by omission ADR-017 exists to stop.
515
+ return keyVerdict as VerificationResult;
226
516
  }
227
517
 
228
518
  const NIL_UUID = "00000000-0000-0000-0000-000000000000";
@@ -247,7 +537,8 @@ export async function verifyTransparency(
247
537
 
248
538
  const pki = publicKeys.get(keyId);
249
539
  if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
250
- if (pki.revoked) throw new VerificationError(`issuer key is revoked: ${keyId}`);
540
+ // The tree head carries its own timestamp, so this path always has an anchor.
541
+ requireUsableKey(pki, certificateAnchor(certificate), keyId);
251
542
 
252
543
  // 1. Tree head signature
253
544
  const headPayload = buildTreeHeadPayload(sth);
@@ -557,6 +848,7 @@ function civilFromDays(days: number): [number, number, number] {
557
848
  function buildAttestationPayload(
558
849
  subject: Record<string, unknown>,
559
850
  att: Record<string, unknown>,
851
+ certFormatVersion: string,
560
852
  ): Uint8Array {
561
853
  const attestedAt = formatTimestamp(att.attested_at);
562
854
  const systems = (att.systems as Record<string, unknown>[]).map((s) => ({
@@ -576,7 +868,7 @@ function buildAttestationPayload(
576
868
 
577
869
  const payload = {
578
870
  attested_at: attestedAt,
579
- payload_type: PAYLOAD_TYPE_ATTESTATION,
871
+ payload_type: attestationPayloadType(certFormatVersion),
580
872
  proof_mode: att.proof_mode as string,
581
873
  subject_hash: toHex(subject.identifier_hash as string),
582
874
  systems,
@@ -584,6 +876,28 @@ function buildAttestationPayload(
584
876
  return canonicalJson(payload);
585
877
  }
586
878
 
879
+ /** Domain separator for the record itself, by certificate format version. */
880
+ function certificatePayloadType(version: string): string {
881
+ checkFormatVersion(version);
882
+ if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
883
+ if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_CERTIFICATE_V5;
884
+ if (version === FORMAT_VERSION_V4) return PAYLOAD_TYPE_CERTIFICATE_V4;
885
+ return PAYLOAD_TYPE_CERTIFICATE;
886
+ }
887
+
888
+ /**
889
+ * Domain separator for the attestation carried by a record of that format. It
890
+ * follows the record's version because a verifier rebuilds the attestation
891
+ * payload from the record it is checking, and must reproduce the exact bytes
892
+ * the enclave signed.
893
+ */
894
+ function attestationPayloadType(version: string): string {
895
+ checkFormatVersion(version);
896
+ if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_ATTESTATION_V6;
897
+ if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_ATTESTATION_V5;
898
+ return PAYLOAD_TYPE_ATTESTATION;
899
+ }
900
+
587
901
  function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
588
902
  const att = cert.attestation as Record<string, unknown>;
589
903
  const issuer = cert.issuer as Record<string, unknown>;
@@ -614,31 +928,60 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
614
928
  proof_mode: att.proof_mode as string,
615
929
  };
616
930
 
617
- const issuerObj = {
931
+ // Fields added after v3 are gated on the certificate's OWN version. A v3
932
+ // certificate must reconstruct to the same bytes forever; reading these
933
+ // unconditionally would break every certificate already issued.
934
+ const version = cert.certificate_format_version as string;
935
+ // v6 carries v5's shape exactly, so it takes every gate v5 takes. Naming
936
+ // these "Plus" rather than testing equality at each use is what stops a new
937
+ // version from silently missing one.
938
+ const isV5Plus = version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6;
939
+ const isV4Plus = version === FORMAT_VERSION_V4 || isV5Plus;
940
+
941
+ const issuerObj: Record<string, unknown> = {
618
942
  key_id: issuer.key_id as string,
619
943
  name: issuer.name as string,
620
944
  public_key: toHex(issuer.public_key as string),
621
945
  };
946
+ if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
947
+ issuerObj.legal_entity = issuer.legal_entity;
948
+ }
949
+ // Signed from v5, and omitted when absent or empty: a build with no
950
+ // measurement signs none, and "" is a value a reader could mistake for one.
951
+ if (isV5Plus && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
952
+ issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
953
+ }
622
954
 
623
- const subjectObj = {
955
+ // v5 drops identifier_type_hint: signed, but always the constant "custom",
956
+ // so it was never evidence. v3 and v4 keep it or they stop verifying.
957
+ const subjectObj: Record<string, unknown> = {
624
958
  identifier_hash: toHex(subject.identifier_hash as string),
625
- identifier_type_hint: subject.identifier_type_hint as string,
626
959
  };
960
+ if (!isV5Plus) {
961
+ subjectObj.identifier_type_hint = subject.identifier_type_hint as string;
962
+ }
627
963
 
628
964
  // status and revocation are deliberately absent (ADR-016 §3): a signature
629
965
  // commits to bytes at an instant, revocation is discovered later, so it
630
966
  // travels as a separate short-lived signed status statement.
631
- const payload = {
967
+ const payload: Record<string, unknown> = {
632
968
  attestation: attObj,
633
969
  attestation_id: cert.attestation_id as string,
634
970
  certificate_format_version: cert.certificate_format_version as string,
635
971
  certificate_id: cert.certificate_id as string,
636
972
  issued_at: formatTimestamp(cert.issued_at),
637
973
  issuer: issuerObj,
638
- payload_type: PAYLOAD_TYPE_CERTIFICATE,
974
+ payload_type: certificatePayloadType(version),
639
975
  subject: subjectObj,
640
976
  systems,
641
977
  };
978
+ if (isV4Plus && cert.scope != null) {
979
+ const scope = cert.scope as Record<string, unknown>;
980
+ payload.scope = {
981
+ text_sha256: toHex(scope.text_sha256 as string),
982
+ version: scope.version as string,
983
+ };
984
+ }
642
985
  return canonicalJson(payload);
643
986
  }
644
987
 
@@ -891,10 +1234,11 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
891
1234
  * | input | result |
892
1235
  * |---|---|
893
1236
  * | fresh statement, REVOKED | throws VerificationError |
894
- * | fresh statement, ACTIVE | `"VALID"` |
895
- * | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"` |
1237
+ * | fresh statement, ACTIVE | `"VALID"`, or a soft verdict on either key |
1238
+ * | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"`, or the certificate key's soft verdict |
1239
+ * | statement key unusable (ADR-017) | throws VerificationError |
896
1240
  *
897
- * The last row is the point: `verifyCertificate` answers VALID there, which
1241
+ * The third row is the point: `verifyCertificate` answers VALID there, which
898
1242
  * reads as "not revoked" and is not something it checked.
899
1243
  */
900
1244
  export async function verifyCertificateWithStatus(
@@ -904,12 +1248,14 @@ export async function verifyCertificateWithStatus(
904
1248
  status?: Record<string, unknown> | null,
905
1249
  now?: Date,
906
1250
  ): Promise<string> {
1251
+ // A refusal throws out of verifyCertificate, so `base` is VALID or one of the
1252
+ // two soft verdicts. Returning a soft verdict here skipped every check below
1253
+ // it: a revoked certificate signed by a compromised-later key answered
1254
+ // VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
1255
+ // verdict is held instead, and resolved against what the statement says.
907
1256
  const base = await verifyCertificate(crypto, certificate, publicKeys);
908
- if (base !== "VALID") {
909
- return base as string;
910
- }
911
1257
  if (status == null) {
912
- return VALID_REVOCATION_UNKNOWN;
1258
+ return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
913
1259
  }
914
1260
 
915
1261
  const keyId = status.key_id as string | undefined;
@@ -917,9 +1263,27 @@ export async function verifyCertificateWithStatus(
917
1263
  if (!info) {
918
1264
  throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
919
1265
  }
920
- if (info.revoked) {
921
- throw new VerificationError("status statement signed by a revoked key");
922
- }
1266
+
1267
+ // The whole ADR-017 table, not just the deprecated boolean. `revoked` is
1268
+ // never set by a key loaded from /.well-known/burnledger-keys — that endpoint
1269
+ // publishes key_status — so this check read `false` for a key the issuer had
1270
+ // declared COMPROMISED, and whoever held that key could mint an ACTIVE
1271
+ // statement for a revoked certificate that every SDK and the browser bundle
1272
+ // rendered as a signed "not revoked". core.VerifyCertificateWithStatus has
1273
+ // always evaluated it.
1274
+ //
1275
+ // The anchor is the verifier's own clock, not a field inside the statement: a
1276
+ // statement must already be inside its own validity window to be served at
1277
+ // all, and `now` is a time this verifier observed rather than one the signing
1278
+ // key asserted about itself.
1279
+ const when = now ?? new Date();
1280
+ const at = formatTimestamp(when.toISOString());
1281
+ const statusKeyVerdict = requireUsableKey(
1282
+ info,
1283
+ parseRfc3339(at),
1284
+ keyId!,
1285
+ "status statement key",
1286
+ );
923
1287
 
924
1288
  const payload = buildCertificateStatusPayload(status);
925
1289
  // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
@@ -932,25 +1296,61 @@ export async function verifyCertificateWithStatus(
932
1296
 
933
1297
  // A validly signed statement about a different certificate must not be
934
1298
  // allowed to speak for this one.
935
- const certId = (certificate as Record<string, unknown>).id ??
936
- (certificate as Record<string, unknown>).certificate_id;
1299
+ //
1300
+ // Compared against `certificate_id` and nothing else. This used to fall back
1301
+ // through `certificate.id`, which the signature does not cover:
1302
+ // buildCertificatePayload (core/payload.go) enumerates nine keys and `id` is
1303
+ // not among them, and no verifier rejects unknown top-level keys. So a holder
1304
+ // of a REVOKED certificate could add `"id": "<some other certificate>"`,
1305
+ // staple that certificate's ACTIVE status statement — served unauthenticated
1306
+ // by design — and this check would compare the two things the attacker chose.
1307
+ // Every signature still verified; the binding was the only forged part.
1308
+ // core/verify.go never had the fallback, so Go said STATUS_STATEMENT_MISMATCH
1309
+ // while both SDKs and the browser bundle said VALID.
1310
+ const certId = (certificate as Record<string, unknown>).certificate_id;
937
1311
  if (status.certificate_id !== certId) {
938
1312
  throw new VerificationError("status statement is about a different certificate");
939
1313
  }
940
1314
 
941
1315
  // Compared in the normalized form the payload signs, so the freshness check
942
1316
  // cannot disagree with what was signed about the same two instants.
943
- const at = formatTimestamp((now ?? new Date()).toISOString());
944
1317
  const issued = formatTimestamp(status.statement_issued_at);
945
1318
  const expires = formatTimestamp(status.statement_expires_at);
946
1319
  if (at < issued || at >= expires) {
947
1320
  // Stale is not a weaker answer, it is no answer — including for a REVOKED
948
1321
  // statement, which must never decay into VALID.
949
- return VALID_REVOCATION_UNKNOWN;
1322
+ return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
950
1323
  }
951
1324
 
952
1325
  if (status.status === "REVOKED") {
953
- throw new VerificationError("certificate has been revoked");
1326
+ // Coded, because this is the only status-path rejection that is a fact
1327
+ // about the certificate rather than a failure to authenticate the
1328
+ // statement. A caller that cannot tell the two apart will render an
1329
+ // unsigned statement as a revocation.
1330
+ throw new VerificationError("certificate has been revoked", CERTIFICATE_REVOKED);
954
1331
  }
955
- return "VALID";
1332
+ // A state this SDK does not recognize is NOT active. This tested only for
1333
+ // REVOKED and treated everything else as a pass, so a signed, fresh,
1334
+ // correctly-bound statement saying "SUSPENDED" — or "revoked" in the wrong
1335
+ // case — was reported as revocation CHECKED AND PASSED. Mirrors
1336
+ // core.ValidCertificateStatusValue.
1337
+ if (!KNOWN_STATUS_VALUES.has(status.status as string)) {
1338
+ return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
1339
+ }
1340
+ // A soft verdict on the status key is the result, not a footnote — but it is
1341
+ // reported only once the statement has authenticated and been read, so it can
1342
+ // neither speak for an unverified statement nor suppress a revocation.
1343
+ return resolveWithBase(base, statusKeyVerdict);
1344
+ }
1345
+
1346
+ /**
1347
+ * Picks what to report when the certificate's own key returned a soft verdict
1348
+ * and the status path also has something to say. A doubt about the key that
1349
+ * signed the certificate questions the whole document, so it outranks a doubt
1350
+ * about revocation or about the statement's own key; only one verdict string
1351
+ * comes back, and this is the one a reader most needs. Mirrors
1352
+ * core.resolveWithBase.
1353
+ */
1354
+ function resolveWithBase(base: string, verdict: string): string {
1355
+ return base !== "VALID" ? base : verdict;
956
1356
  }
@@ -4,9 +4,9 @@
4
4
  * Imports the leaf modules directly rather than re-exporting from
5
5
  * `index.browser.ts`: that entry pulls in the HTTP client, which imports
6
6
  * `node:fs/promises` and cannot be bundled for a browser at all. The page needs
7
- * four functions, so this exposes four functions — the surface a relying party's
8
- * browser is handed is then something you can read in one screen instead of
9
- * inferring from a minified artifact.
7
+ * four functions and one error code, so that is what this exposes — the surface
8
+ * a relying party's browser is handed is then something you can read in one
9
+ * screen instead of inferring from a minified artifact.
10
10
  *
11
11
  * Built by `scripts/build-web-verifier.mjs`. The bundle this replaces was built
12
12
  * by hand once and never regenerated, and had drifted into disagreeing with the
@@ -16,13 +16,14 @@
16
16
  * source file anywhere in the repository.
17
17
  */
18
18
  import { browserCrypto } from "./crypto-browser.js";
19
+ import { CERTIFICATE_REVOKED, UNSUPPORTED_FORMAT_VERSION } from "./errors.js";
19
20
  import {
20
21
  verifyCertificate as _verifyCertificate,
21
22
  verifyCertificateWithStatus as _verifyCertificateWithStatus,
22
23
  verifyTransparency as _verifyTransparency,
23
24
  publicKeyFromHex as _publicKeyFromHex,
24
25
  } from "./verify.js";
25
- import type { PublicKeyInfo } from "./verify.js";
26
+ import type { PublicKeyInfo, PublicKeyOptions } from "./verify.js";
26
27
  import type { VerificationResult, TransparencyResult } from "./models.js";
27
28
 
28
29
  type Cert = Record<string, unknown>;
@@ -50,6 +51,14 @@ export function verifyCertificateWithStatus(
50
51
  return _verifyCertificateWithStatus(browserCrypto, certificate, publicKeys, status, now);
51
52
  }
52
53
 
54
+ /**
55
+ * The `code` a VerificationError carries when a status statement verified and
56
+ * says REVOKED. The page needs it to tell that fact apart from a statement it
57
+ * could not authenticate, which is a different claim and must not be painted
58
+ * as a revocation.
59
+ */
60
+ export { CERTIFICATE_REVOKED, UNSUPPORTED_FORMAT_VERSION };
61
+
53
62
  /** Verify the transparency proof embedded in a certificate. */
54
63
  export function verifyTransparency(
55
64
  certificate: Cert,
@@ -61,7 +70,7 @@ export function verifyTransparency(
61
70
  /** Construct a PublicKeyInfo from a hex-encoded Ed25519 public key. */
62
71
  export function publicKeyFromHex(
63
72
  hexKey: string,
64
- opts?: { revoked?: boolean },
73
+ opts?: PublicKeyOptions,
65
74
  ): Promise<PublicKeyInfo> {
66
75
  return _publicKeyFromHex(browserCrypto, hexKey, opts);
67
76
  }