burnledger 0.4.0 → 0.6.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 (104) hide show
  1. package/README.md +11 -4
  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 +26 -2
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +27 -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 +42 -1
  15. package/dist/cjs/errors.d.ts.map +1 -1
  16. package/dist/cjs/errors.js +44 -2
  17. package/dist/cjs/errors.js.map +1 -1
  18. package/dist/cjs/http.d.ts +21 -1
  19. package/dist/cjs/http.d.ts.map +1 -1
  20. package/dist/cjs/http.js +76 -12
  21. package/dist/cjs/http.js.map +1 -1
  22. package/dist/cjs/index.browser.d.ts +2 -4
  23. package/dist/cjs/index.browser.d.ts.map +1 -1
  24. package/dist/cjs/index.browser.js.map +1 -1
  25. package/dist/cjs/index.d.ts +16 -11
  26. package/dist/cjs/index.d.ts.map +1 -1
  27. package/dist/cjs/index.js +20 -1
  28. package/dist/cjs/index.js.map +1 -1
  29. package/dist/cjs/keys.d.ts +66 -0
  30. package/dist/cjs/keys.d.ts.map +1 -1
  31. package/dist/cjs/keys.js +128 -3
  32. package/dist/cjs/keys.js.map +1 -1
  33. package/dist/cjs/models.d.ts +41 -3
  34. package/dist/cjs/models.d.ts.map +1 -1
  35. package/dist/cjs/models.js +25 -4
  36. package/dist/cjs/models.js.map +1 -1
  37. package/dist/cjs/verify.d.ts +72 -6
  38. package/dist/cjs/verify.d.ts.map +1 -1
  39. package/dist/cjs/verify.js +392 -62
  40. package/dist/cjs/verify.js.map +1 -1
  41. package/dist/cjs/web-verifier.d.ts +54 -5
  42. package/dist/cjs/web-verifier.d.ts.map +1 -1
  43. package/dist/cjs/web-verifier.js +36 -3
  44. package/dist/cjs/web-verifier.js.map +1 -1
  45. package/dist/esm/anchor.d.ts +71 -0
  46. package/dist/esm/anchor.d.ts.map +1 -0
  47. package/dist/esm/anchor.js +275 -0
  48. package/dist/esm/anchor.js.map +1 -0
  49. package/dist/esm/cli.d.ts +45 -14
  50. package/dist/esm/cli.d.ts.map +1 -1
  51. package/dist/esm/cli.js +357 -50
  52. package/dist/esm/cli.js.map +1 -1
  53. package/dist/esm/client.d.ts +26 -2
  54. package/dist/esm/client.d.ts.map +1 -1
  55. package/dist/esm/client.js +27 -5
  56. package/dist/esm/client.js.map +1 -1
  57. package/dist/esm/enclave-seal.d.ts +101 -0
  58. package/dist/esm/enclave-seal.d.ts.map +1 -0
  59. package/dist/esm/enclave-seal.js +472 -0
  60. package/dist/esm/enclave-seal.js.map +1 -0
  61. package/dist/esm/errors.d.ts +42 -1
  62. package/dist/esm/errors.d.ts.map +1 -1
  63. package/dist/esm/errors.js +43 -1
  64. package/dist/esm/errors.js.map +1 -1
  65. package/dist/esm/http.d.ts +21 -1
  66. package/dist/esm/http.d.ts.map +1 -1
  67. package/dist/esm/http.js +75 -12
  68. package/dist/esm/http.js.map +1 -1
  69. package/dist/esm/index.browser.d.ts +2 -4
  70. package/dist/esm/index.browser.d.ts.map +1 -1
  71. package/dist/esm/index.browser.js.map +1 -1
  72. package/dist/esm/index.d.ts +16 -11
  73. package/dist/esm/index.d.ts.map +1 -1
  74. package/dist/esm/index.js +11 -2
  75. package/dist/esm/index.js.map +1 -1
  76. package/dist/esm/keys.d.ts +66 -0
  77. package/dist/esm/keys.d.ts.map +1 -1
  78. package/dist/esm/keys.js +125 -3
  79. package/dist/esm/keys.js.map +1 -1
  80. package/dist/esm/models.d.ts +41 -3
  81. package/dist/esm/models.d.ts.map +1 -1
  82. package/dist/esm/models.js +24 -4
  83. package/dist/esm/models.js.map +1 -1
  84. package/dist/esm/verify.d.ts +72 -6
  85. package/dist/esm/verify.d.ts.map +1 -1
  86. package/dist/esm/verify.js +390 -63
  87. package/dist/esm/verify.js.map +1 -1
  88. package/dist/esm/web-verifier.d.ts +54 -5
  89. package/dist/esm/web-verifier.d.ts.map +1 -1
  90. package/dist/esm/web-verifier.js +40 -3
  91. package/dist/esm/web-verifier.js.map +1 -1
  92. package/package.json +1 -1
  93. package/src/anchor.ts +330 -0
  94. package/src/cli.ts +389 -54
  95. package/src/client.ts +46 -4
  96. package/src/enclave-seal.ts +582 -0
  97. package/src/errors.ts +47 -2
  98. package/src/http.ts +79 -12
  99. package/src/index.browser.ts +2 -2
  100. package/src/index.ts +28 -9
  101. package/src/keys.ts +176 -3
  102. package/src/models.ts +72 -7
  103. package/src/verify.ts +467 -72
  104. package/src/web-verifier.ts +58 -5
package/src/verify.ts CHANGED
@@ -17,7 +17,12 @@
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_ALGORITHM,
23
+ UNSUPPORTED_FORMAT_VERSION,
24
+ VerificationError,
25
+ } from "./errors.js";
21
26
  import type { TransparencyResult, VerificationResult } from "./models.js";
22
27
 
23
28
  // ---------------------------------------------------------------------------
@@ -40,11 +45,75 @@ const PAYLOAD_TYPE_ATTESTATION_V5 = "burnledger.attestation.v5";
40
45
  const PAYLOAD_TYPE_CERTIFICATE_V5 = "burnledger.certificate.v5";
41
46
  const FORMAT_VERSION_V5 = "5.0";
42
47
  const FORMAT_VERSION_V4 = "4.0";
48
+ // v6 renames the artifact and nothing else: the shape is byte-for-byte v5, and
49
+ // only the tag and the version string differ. It still takes its own tag, for
50
+ // the same reason v4 and v5 each took one -- a signature over a payload calling
51
+ // itself a verification record must not verify as one calling itself a
52
+ // certificate. Two names for one shape sharing a separator is exactly what a
53
+ // separator prevents.
54
+ const PAYLOAD_TYPE_ATTESTATION_V6 = "burnledger.attestation.v6";
55
+ const PAYLOAD_TYPE_VERIFICATION_RECORD_V6 = "burnledger.verification_record.v6";
56
+ const FORMAT_VERSION_V6 = "6.0";
57
+ // v7 changes the SHAPE, not just the name: every system entry gains
58
+ // read_only_enforcement and transport_security, and the issuer gains the
59
+ // algorithm identifier. Its tags move for the reason v4, v5 and v6 each moved
60
+ // theirs, and this time the bytes under them genuinely differ.
61
+ const PAYLOAD_TYPE_ATTESTATION_V7 = "burnledger.attestation.v7";
62
+ const PAYLOAD_TYPE_VERIFICATION_RECORD_V7 = "burnledger.verification_record.v7";
63
+ const FORMAT_VERSION_V7 = "7.0";
64
+ const FORMAT_VERSION_V3 = "3.0";
65
+
66
+ /**
67
+ * The only signature scheme this SDK can check. A v7 record names its own
68
+ * algorithm, so a record naming anything else must be refused with its own
69
+ * code rather than fed to ed25519Verify and reported as a bad signature.
70
+ */
71
+ const ALGORITHM_ED25519 = "ed25519";
72
+
73
+ /**
74
+ * Every format this build can read, oldest first. Mirrors core.FormatVersions()
75
+ * in the Go source; the cross-language conformance corpus carries a fixture per
76
+ * version, so a version present in one language and missing here fails there.
77
+ *
78
+ * Frozen because `readonly` is erased at build time: it stops a TypeScript
79
+ * caller from writing to the array, and does nothing at all to the JavaScript
80
+ * this compiles to — which is what the browser bundle actually runs, where any
81
+ * script on the page could otherwise push a version onto it.
82
+ */
83
+ export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
84
+ FORMAT_VERSION_V3,
85
+ FORMAT_VERSION_V4,
86
+ FORMAT_VERSION_V5,
87
+ FORMAT_VERSION_V6,
88
+ FORMAT_VERSION_V7,
89
+ ]);
90
+
91
+ /**
92
+ * Returns the version, or throws if this SDK cannot read it.
93
+ *
94
+ * Rejects a non-string too: JSON carrying `6.0` as a NUMBER would compare
95
+ * unequal to every known version and silently take the oldest branch, which is
96
+ * the same trap as an unknown version wearing a different hat.
97
+ */
98
+ function checkFormatVersion(version: unknown): string {
99
+ if (typeof version !== "string" || !KNOWN_FORMAT_VERSIONS.includes(version)) {
100
+ throw new VerificationError(
101
+ `unsupported certificate_format_version ${JSON.stringify(version)}; ` +
102
+ `this version of the SDK reads ${KNOWN_FORMAT_VERSIONS.join(", ")}. ` +
103
+ "The record may be genuine and simply newer than this library.",
104
+ UNSUPPORTED_FORMAT_VERSION,
105
+ );
106
+ }
107
+ return version;
108
+ }
43
109
  const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
110
+ // The domain for a tree head that names its log. See buildTreeHeadPayload.
111
+ const PAYLOAD_TYPE_TREE_HEAD_V7 = "burnledger.sth.v7";
44
112
  const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
45
113
  // There is deliberately no verification payload type: v3 produces those facts
46
114
  // in the same enclave call that signs the certificate (ADR-016 §2).
47
115
  const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
116
+ const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
48
117
 
49
118
  export function hexToBytes(hex: string): Uint8Array {
50
119
  const len = hex.length >>> 1;
@@ -137,16 +206,30 @@ export interface PublicKeyInfo {
137
206
  readonly revoked: boolean;
138
207
  }
139
208
 
209
+ /**
210
+ * The options publicKeyFromHex accepts, mirroring one entry of the published key
211
+ * set at `/.well-known/burnledger-keys`.
212
+ *
213
+ * Named, and exported, because it is restated by every entry point that wraps
214
+ * publicKeyFromHex — index.ts, index.browser.ts, web-verifier.ts. When the
215
+ * ADR-017 fields were added, two of the three were widened and the browser entry
216
+ * was not, so the dashboard could not express a compromised key through the API
217
+ * it imports and every key it built looked unconditionally active. A shared type
218
+ * is what stops the next field from being added to two places out of three.
219
+ */
220
+ export interface PublicKeyOptions {
221
+ /** @deprecated Use keyStatus. Retained: true still means "unusable". */
222
+ revoked?: boolean;
223
+ keyStatus?: string;
224
+ notBefore?: string;
225
+ notAfter?: string;
226
+ compromisedFrom?: string;
227
+ }
228
+
140
229
  export async function publicKeyFromHex(
141
230
  crypto: CryptoOps,
142
231
  hexKey: string,
143
- opts?: {
144
- revoked?: boolean;
145
- keyStatus?: string;
146
- notBefore?: string;
147
- notAfter?: string;
148
- compromisedFrom?: string;
149
- },
232
+ opts?: PublicKeyOptions,
150
233
  ): Promise<PublicKeyInfo> {
151
234
  const raw = hexToBytes(hexKey);
152
235
  if (raw.length !== 32) {
@@ -176,6 +259,32 @@ export const KEY_COMPROMISED = "KEY_COMPROMISED";
176
259
  export const VALID_KEY_COMPROMISED_LATER = "VALID_KEY_COMPROMISED_LATER";
177
260
  export const VALID_KEY_WINDOW_UNKNOWN = "VALID_KEY_WINDOW_UNKNOWN";
178
261
 
262
+ /**
263
+ * The revocation states a statement may assert. Mirrors
264
+ * core.ValidCertificateStatusValue: anything else means the statement was read
265
+ * but not understood, which is not the same as "not revoked".
266
+ */
267
+ const KNOWN_STATUS_VALUES: ReadonlySet<string> = new Set(["ACTIVE", "REVOKED"]);
268
+
269
+ /**
270
+ * The key verdicts that permit relying on a signature: VALID plus the two soft
271
+ * verdicts ADR-017 §2 says a reader must be SHOWN rather than refused. Anything
272
+ * not named here is refused, including a verdict added in a later release.
273
+ */
274
+ const USABLE_KEY_VERDICTS: ReadonlySet<string> = new Set([
275
+ "VALID",
276
+ VALID_KEY_WINDOW_UNKNOWN,
277
+ VALID_KEY_COMPROMISED_LATER,
278
+ ]);
279
+
280
+ /** Whether a key verdict permits relying on a signature. Mirrors
281
+ * core.KeyIsUsable; exported so the key-list verifier (keys.ts) refuses the
282
+ * same set requireUsableKey refuses rather than keeping a second copy of it —
283
+ * a second copy is exactly how #684's fourth answer came to exist. */
284
+ export function keyIsUsable(verdict: string): boolean {
285
+ return USABLE_KEY_VERDICTS.has(verdict);
286
+ }
287
+
179
288
  /** Whether a key was authorized to sign at anchor time `t`.
180
289
  *
181
290
  * Returns `"VALID"` when the key clears. The anchor is seconds since the epoch
@@ -215,7 +324,17 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
215
324
  return "UNKNOWN_KEY";
216
325
  }
217
326
 
218
- /** The anchor a certificate supplies, or null when it supplies none. */
327
+ /**
328
+ * The anchor a certificate supplies, or null when it supplies none.
329
+ *
330
+ * READS AN UNSIGNED FIELD. `transparency` is stripped before signing, so on a
331
+ * certificate handed to a verifier the timestamp is holder-editable. Use
332
+ * {@link verifiedCertificateAnchor} anywhere the anchor decides whether a key
333
+ * may be relied on; this is safe only where the same timestamp is inside a
334
+ * payload the caller goes on to verify, which is what verifyTransparency does.
335
+ *
336
+ * Exported and unchanged because it is published API.
337
+ */
219
338
  export function certificateAnchor(certificate: Record<string, unknown>): number | null {
220
339
  const transparency = certificate.transparency as Record<string, unknown> | null | undefined;
221
340
  if (transparency == null) return null;
@@ -228,22 +347,77 @@ export function certificateAnchor(certificate: Record<string, unknown>): number
228
347
  }
229
348
  }
230
349
 
350
+ /**
351
+ * The anchor, but only once the tree head carrying it has been checked against
352
+ * the key being evaluated.
353
+ *
354
+ * The bypass this closes: for a key declared compromised, evaluateKey answers
355
+ * KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
356
+ * anchor predates the compromise — and the second is a verdict requireUsableKey
357
+ * passes through as the RESULT of verifyCertificate. Since the timestamp is
358
+ * covered by no signature, a holder could backdate it and have a certificate
359
+ * signed with a compromised key reported as verified-with-a-caveat. Deleting
360
+ * `transparency` failed closed; keeping a doctored one did not.
361
+ *
362
+ * A head that does not verify yields no anchor, which is the same conservative
363
+ * answer as a certificate carrying no transparency block at all.
364
+ */
365
+ async function verifiedCertificateAnchor(
366
+ crypto: CryptoOps,
367
+ certificate: Record<string, unknown>,
368
+ pki: PublicKeyInfo,
369
+ ): Promise<number | null> {
370
+ const anchor = certificateAnchor(certificate);
371
+ if (anchor === null) return null;
372
+
373
+ const transparency = certificate.transparency as Record<string, unknown>;
374
+ const sth = transparency.signed_tree_head as Record<string, unknown>;
375
+ try {
376
+ const headPayload = buildTreeHeadPayload(sth);
377
+ const headSig = decodeSignature(sth.signature);
378
+ if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
379
+ } catch {
380
+ return null;
381
+ }
382
+ return anchor;
383
+ }
384
+
231
385
  /** Throws for a key that cannot be used at all; returns the verdict otherwise.
232
386
  *
233
387
  * VALID_KEY_COMPROMISED_LATER and VALID_KEY_WINDOW_UNKNOWN are returned rather
234
388
  * than thrown because they are not refusals — they are results a caller must
235
389
  * show the reader instead of collapsing into green.
236
390
  */
237
- function requireUsableKey(pki: PublicKeyInfo, anchor: number | null, keyId: string): string {
391
+ function requireUsableKey(
392
+ pki: PublicKeyInfo,
393
+ anchor: number | null,
394
+ keyId: string,
395
+ // Named, because this is also the key that signs a certificate-status
396
+ // statement, and "issuer key is compromised" would send a reader looking at
397
+ // the wrong key.
398
+ role = "issuer key",
399
+ ): string {
238
400
  const verdict = evaluateKey(pki, anchor);
239
401
  if (verdict === KEY_COMPROMISED) {
240
- throw new VerificationError(`issuer key is compromised: ${keyId}`);
402
+ throw new VerificationError(`${role} is compromised: ${keyId}`);
241
403
  }
242
404
  if (verdict === KEY_OUTSIDE_VALIDITY) {
243
- throw new VerificationError(`issuer key was not valid when it signed: ${keyId}`);
405
+ throw new VerificationError(`${role} was not valid when it signed: ${keyId}`);
244
406
  }
245
407
  if (verdict === "UNKNOWN_KEY") {
246
- throw new VerificationError(`issuer key has an unusable status "${pki.keyStatus}": ${keyId}`);
408
+ throw new VerificationError(`${role} has an unusable status "${pki.keyStatus}": ${keyId}`);
409
+ }
410
+ // An ALLOW-list, matching core.KeyIsUsable, which was inverted away from
411
+ // exactly the shape above. Naming the refusals and returning everything else
412
+ // means a verdict added to evaluateKey later becomes a PASS by default — and
413
+ // the value returned here is propagated out of verifyCertificate as its
414
+ // result, so "everything else" is rendered to a reader as "verified, with a
415
+ // note". Go would refuse the same document. That is #684's three-verifiers-
416
+ // two-answers divergence, in the direction that calls a bad document good.
417
+ if (!USABLE_KEY_VERDICTS.has(verdict)) {
418
+ throw new VerificationError(
419
+ `${role} returned a verdict this SDK does not recognize (${verdict}): ${keyId}`,
420
+ );
247
421
  }
248
422
  return verdict;
249
423
  }
@@ -260,14 +434,38 @@ export async function verifyCertificate(
260
434
  certificate: Cert,
261
435
  publicKeys: Map<string, PublicKeyInfo>,
262
436
  ): Promise<VerificationResult> {
437
+ // Step 0: can this SDK read the format at all? Every check below rebuilds
438
+ // signed bytes from the version, so an unreadable version makes all of them
439
+ // meaningless - and "unknown issuer key" would be the wrong thing to tell
440
+ // someone holding a record that is merely newer than this library.
441
+ const formatVersion = checkFormatVersion(
442
+ (certificate as Record<string, unknown>).certificate_format_version,
443
+ );
444
+
263
445
  const issuer = certificate.issuer as Record<string, unknown>;
446
+ // Step 0b: can this SDK check the algorithm the record names? Asked before
447
+ // any signature, because verifying an Ed25519 signature over a record that
448
+ // says it was signed with something else answers a question nobody asked.
449
+ // Below v7 no record names one, so there is nothing to check.
450
+ if (formatVersion === FORMAT_VERSION_V7 && issuer.algorithm !== ALGORITHM_ED25519) {
451
+ throw new VerificationError(
452
+ `record names signature algorithm ${JSON.stringify(issuer.algorithm)}; ` +
453
+ `this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
454
+ "The record may be genuine and simply signed with a scheme this library does not implement.",
455
+ UNSUPPORTED_ALGORITHM,
456
+ );
457
+ }
264
458
  const keyId = issuer.key_id as string;
265
459
 
266
460
  const pki = publicKeys.get(keyId);
267
461
  if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
268
462
  // The key check runs before any signature check: a signature verified
269
463
  // against a key nobody vouches for proves nothing about the issuer.
270
- const keyVerdict = requireUsableKey(pki, certificateAnchor(certificate), keyId);
464
+ const keyVerdict = requireUsableKey(
465
+ pki,
466
+ await verifiedCertificateAnchor(crypto, certificate, pki),
467
+ keyId,
468
+ );
271
469
 
272
470
  // 1. Certificate signature FIRST — nothing below may trust a field until the
273
471
  // bytes carrying it are covered by a verified signature.
@@ -435,6 +633,20 @@ export async function verifyTransparency(
435
633
  );
436
634
  }
437
635
 
636
+ // Same rule for the log id (Q31). transparency.log_id sits beside log_url and
637
+ // is the field a reader looks at to answer "which log is this?", and nothing
638
+ // signs it — so a holder could relabel a genuine proof as belonging to a
639
+ // different log while every signature still checked out. A head predating log
640
+ // ids has neither side set and passes.
641
+ const signedLogId = (sth.log_id as string | undefined) ?? "";
642
+ const unsignedLogId = (transparency.log_id as string | undefined) ?? "";
643
+ if (unsignedLogId !== signedLogId) {
644
+ throw new VerificationError(
645
+ `transparency.log_id (${JSON.stringify(unsignedLogId)}) does not match the signed ` +
646
+ `tree head (${JSON.stringify(signedLogId)}); it is not covered by any signature`,
647
+ );
648
+ }
649
+
438
650
  if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
439
651
  throw new VerificationError("merkle inclusion proof is invalid");
440
652
  }
@@ -694,27 +906,43 @@ function buildAttestationPayload(
694
906
  certFormatVersion: string,
695
907
  ): Uint8Array {
696
908
  const attestedAt = formatTimestamp(att.attested_at);
697
- const systems = (att.systems as Record<string, unknown>[]).map((s) => ({
698
- canonical_version: (s.canonical_version as string | null) ?? null,
699
- connector_type: s.connector_type as string,
700
- hash_scope: s.hash_scope as string,
701
- merkle_root: s.merkle_root
702
- ? toHex(s.merkle_root as string)
703
- : null,
704
- // The system's own observation time, not the envelope's.
705
- observed_at: formatTimestamp(s.observed_at),
706
- query_hash: toHex(s.query_hash as string),
707
- record_count: s.record_count as number,
708
- system_id: s.system_id as string,
709
- system_name: s.system_name as string,
710
- }));
909
+ // v7 signs two per-system measurements v6 leaves on a mutable row. Gated on
910
+ // the record's OWN version: emitting them for an older format would rebuild
911
+ // bytes no signer ever produced and reject every record already issued.
912
+ const isV7 = certFormatVersion === FORMAT_VERSION_V7;
913
+ const systems = (att.systems as Record<string, unknown>[]).map((s) => {
914
+ const sys: Record<string, unknown> = {
915
+ canonical_version: (s.canonical_version as string | null) ?? null,
916
+ connector_type: s.connector_type as string,
917
+ hash_scope: s.hash_scope as string,
918
+ merkle_root: s.merkle_root
919
+ ? toHex(s.merkle_root as string)
920
+ : null,
921
+ // The system's own observation time, not the envelope's.
922
+ observed_at: formatTimestamp(s.observed_at),
923
+ query_hash: toHex(s.query_hash as string),
924
+ record_count: s.record_count as number,
925
+ system_id: s.system_id as string,
926
+ system_name: s.system_name as string,
927
+ };
928
+ if (isV7) {
929
+ sys.read_only_enforcement = requireMeasured(
930
+ s.read_only_enforcement,
931
+ "read_only_enforcement",
932
+ s.system_name,
933
+ );
934
+ sys.transport_security = requireMeasured(
935
+ s.transport_security,
936
+ "transport_security",
937
+ s.system_name,
938
+ );
939
+ }
940
+ return sys;
941
+ });
711
942
 
712
943
  const payload = {
713
944
  attested_at: attestedAt,
714
- payload_type:
715
- certFormatVersion === FORMAT_VERSION_V5
716
- ? PAYLOAD_TYPE_ATTESTATION_V5
717
- : PAYLOAD_TYPE_ATTESTATION,
945
+ payload_type: attestationPayloadType(certFormatVersion),
718
946
  proof_mode: att.proof_mode as string,
719
947
  subject_hash: toHex(subject.identifier_hash as string),
720
948
  systems,
@@ -722,6 +950,49 @@ function buildAttestationPayload(
722
950
  return canonicalJson(payload);
723
951
  }
724
952
 
953
+ /**
954
+ * Read a v7 measured field that MUST be a non-empty string.
955
+ *
956
+ * A missing or non-string value cannot be turned into `""` and canonicalized:
957
+ * the signer never emits an empty measurement, so an empty one here would
958
+ * rebuild bytes no signature covers and be reported as forgery. Refusing with a
959
+ * document-shape message says the true thing — this record is malformed, not
960
+ * this record is fake.
961
+ */
962
+ function requireMeasured(value: unknown, field: string, systemName: unknown): string {
963
+ if (typeof value !== "string" || value === "") {
964
+ throw new VerificationError(
965
+ `system ${JSON.stringify(systemName)}: ${field} is missing from a 7.0 record, ` +
966
+ "which signs it; the document is incomplete rather than unverifiable",
967
+ );
968
+ }
969
+ return value;
970
+ }
971
+
972
+ /** Domain separator for the record itself, by certificate format version. */
973
+ function certificatePayloadType(version: string): string {
974
+ checkFormatVersion(version);
975
+ if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
976
+ if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
977
+ if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_CERTIFICATE_V5;
978
+ if (version === FORMAT_VERSION_V4) return PAYLOAD_TYPE_CERTIFICATE_V4;
979
+ return PAYLOAD_TYPE_CERTIFICATE;
980
+ }
981
+
982
+ /**
983
+ * Domain separator for the attestation carried by a record of that format. It
984
+ * follows the record's version because a verifier rebuilds the attestation
985
+ * payload from the record it is checking, and must reproduce the exact bytes
986
+ * the enclave signed.
987
+ */
988
+ function attestationPayloadType(version: string): string {
989
+ checkFormatVersion(version);
990
+ if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_ATTESTATION_V7;
991
+ if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_ATTESTATION_V6;
992
+ if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_ATTESTATION_V5;
993
+ return PAYLOAD_TYPE_ATTESTATION;
994
+ }
995
+
725
996
  function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
726
997
  const att = cert.attestation as Record<string, unknown>;
727
998
  const issuer = cert.issuer as Record<string, unknown>;
@@ -730,19 +1001,36 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
730
1001
  // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
731
1002
  // and a verification list joined only on the human-editable system_name,
732
1003
  // which made a partial deletion indistinguishable from a complete one.
733
- const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => ({
734
- attested_at: formatTimestamp(s.attested_at),
735
- attested_count: s.attested_count as number,
736
- canonical_version: (s.canonical_version as string | null) ?? null,
737
- connector_type: s.connector_type as string,
738
- hash_scope: s.hash_scope as string,
739
- merkle_root: s.merkle_root ? toHex(s.merkle_root as string) : null,
740
- query_hash: toHex(s.query_hash as string),
741
- system_id: s.system_id as string,
742
- system_name: s.system_name as string,
743
- verified_at: formatTimestamp(s.verified_at),
744
- verified_count: s.verified_count as number,
745
- }));
1004
+ const version = cert.certificate_format_version as string;
1005
+ const isV7 = version === FORMAT_VERSION_V7;
1006
+ const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => {
1007
+ const sys: Record<string, unknown> = {
1008
+ attested_at: formatTimestamp(s.attested_at),
1009
+ attested_count: s.attested_count as number,
1010
+ canonical_version: (s.canonical_version as string | null) ?? null,
1011
+ connector_type: s.connector_type as string,
1012
+ hash_scope: s.hash_scope as string,
1013
+ merkle_root: s.merkle_root ? toHex(s.merkle_root as string) : null,
1014
+ query_hash: toHex(s.query_hash as string),
1015
+ system_id: s.system_id as string,
1016
+ system_name: s.system_name as string,
1017
+ verified_at: formatTimestamp(s.verified_at),
1018
+ verified_count: s.verified_count as number,
1019
+ };
1020
+ if (isV7) {
1021
+ sys.read_only_enforcement = requireMeasured(
1022
+ s.read_only_enforcement,
1023
+ "read_only_enforcement",
1024
+ s.system_name,
1025
+ );
1026
+ sys.transport_security = requireMeasured(
1027
+ s.transport_security,
1028
+ "transport_security",
1029
+ s.system_name,
1030
+ );
1031
+ }
1032
+ return sys;
1033
+ });
746
1034
 
747
1035
  // The attestation block carries no system list of its own: the merged list
748
1036
  // above is a superset of it. verification_signature is gone entirely.
@@ -755,21 +1043,30 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
755
1043
  // Fields added after v3 are gated on the certificate's OWN version. A v3
756
1044
  // certificate must reconstruct to the same bytes forever; reading these
757
1045
  // unconditionally would break every certificate already issued.
758
- const version = cert.certificate_format_version as string;
759
- const isV5 = version === FORMAT_VERSION_V5;
760
- const isV4Plus = version === FORMAT_VERSION_V4 || isV5;
1046
+ // v6 carries v5's shape exactly, so it takes every gate v5 takes. Naming
1047
+ // these "Plus" rather than testing equality at each use is what stops a new
1048
+ // version from silently missing one.
1049
+ const isV5Plus =
1050
+ version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6 || isV7;
1051
+ const isV4Plus = version === FORMAT_VERSION_V4 || isV5Plus;
761
1052
 
762
1053
  const issuerObj: Record<string, unknown> = {
763
1054
  key_id: issuer.key_id as string,
764
1055
  name: issuer.name as string,
765
1056
  public_key: toHex(issuer.public_key as string),
766
1057
  };
1058
+ // v7 signs the algorithm, and does so unconditionally: unlike legal_entity
1059
+ // and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
1060
+ // so a v7 record missing it is malformed rather than merely sparse.
1061
+ if (isV7) {
1062
+ issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer");
1063
+ }
767
1064
  if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
768
1065
  issuerObj.legal_entity = issuer.legal_entity;
769
1066
  }
770
1067
  // Signed from v5, and omitted when absent or empty: a build with no
771
1068
  // measurement signs none, and "" is a value a reader could mistake for one.
772
- if (isV5 && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
1069
+ if (isV5Plus && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
773
1070
  issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
774
1071
  }
775
1072
 
@@ -778,7 +1075,7 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
778
1075
  const subjectObj: Record<string, unknown> = {
779
1076
  identifier_hash: toHex(subject.identifier_hash as string),
780
1077
  };
781
- if (!isV5) {
1078
+ if (!isV5Plus) {
782
1079
  subjectObj.identifier_type_hint = subject.identifier_type_hint as string;
783
1080
  }
784
1081
 
@@ -792,11 +1089,7 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
792
1089
  certificate_id: cert.certificate_id as string,
793
1090
  issued_at: formatTimestamp(cert.issued_at),
794
1091
  issuer: issuerObj,
795
- payload_type: isV5
796
- ? PAYLOAD_TYPE_CERTIFICATE_V5
797
- : isV4Plus
798
- ? PAYLOAD_TYPE_CERTIFICATE_V4
799
- : PAYLOAD_TYPE_CERTIFICATE,
1092
+ payload_type: certificatePayloadType(version),
800
1093
  subject: subjectObj,
801
1094
  systems,
802
1095
  };
@@ -846,16 +1139,37 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
846
1139
  observed_at: s.attested_at,
847
1140
  merkle_root: s.merkle_root,
848
1141
  canonical_version: s.canonical_version,
1142
+ // Carried through so the v7 attestation payload can be rebuilt from the
1143
+ // record. Undefined on older formats, where buildAttestationPayload never
1144
+ // reads them.
1145
+ read_only_enforcement: s.read_only_enforcement,
1146
+ transport_security: s.transport_security,
849
1147
  }));
850
1148
  }
851
1149
 
852
- function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
853
- const payload = {
854
- payload_type: PAYLOAD_TYPE_TREE_HEAD,
1150
+ /**
1151
+ * Port of Go's BuildTreeHeadPayload.
1152
+ *
1153
+ * A head that names its log is signed in a DIFFERENT DOMAIN from one that does
1154
+ * not (Q31). That is what makes log_id unstrippable: removing it moves the
1155
+ * rebuild into burnledger.sth.v3 and adding one moves it into
1156
+ * burnledger.sth.v7, and the signature fails either way. Heads signed before
1157
+ * log ids existed carry none and reproduce exactly the bytes they always did.
1158
+ *
1159
+ * Exported for anchor.ts, which rebuilds the preimage of a head fetched from
1160
+ * GET /v1/log/head. Not re-exported from index.ts: it is an internal seam
1161
+ * between two modules of this package, not published surface.
1162
+ */
1163
+ export function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
1164
+ const logId = head.log_id;
1165
+ const named = typeof logId === "string" && logId !== "";
1166
+ const payload: Record<string, unknown> = {
1167
+ payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
855
1168
  root_hash: toHex(head.root_hash as string),
856
1169
  timestamp: formatTimestamp(head.timestamp),
857
1170
  tree_size: head.tree_size as number,
858
1171
  };
1172
+ if (named) payload.log_id = logId;
859
1173
  return canonicalJson(payload);
860
1174
  }
861
1175
 
@@ -1043,6 +1357,40 @@ export function buildCertificateStatusPayload(
1043
1357
  return canonicalJson(payload);
1044
1358
  }
1045
1359
 
1360
+ /**
1361
+ * Port of Go's BuildKeyListPayload (core/key_list.go).
1362
+ *
1363
+ * The key array is sorted by key_id before signing: canonical JSON sorts
1364
+ * object keys but does nothing to array order, and the server has no reason to
1365
+ * preserve any particular order across a rotation. Optional entry fields are
1366
+ * omitted, never emitted as null, for the reason buildCertificateStatusPayload
1367
+ * omits them. `keys` is the array exactly as HANDED to the verifier — the
1368
+ * signature covers the published entries, not a parsed reinterpretation of
1369
+ * them, which is what makes an edited key_status detectable.
1370
+ */
1371
+ export function buildKeyListPayload(doc: Record<string, unknown>): Uint8Array {
1372
+ const entries = (doc.keys as Record<string, unknown>[]).map((e) => {
1373
+ const k: Record<string, unknown> = {};
1374
+ if (e.compromised_from != null) k.compromised_from = e.compromised_from;
1375
+ k.key_id = e.key_id;
1376
+ k.key_status = e.key_status;
1377
+ if (e.not_after != null) k.not_after = e.not_after;
1378
+ if (e.not_before != null && e.not_before !== "") k.not_before = e.not_before;
1379
+ k.public_key = e.public_key;
1380
+ return k;
1381
+ });
1382
+ entries.sort((a, b) => ((a.key_id as string) < (b.key_id as string) ? -1 : a.key_id === b.key_id ? 0 : 1));
1383
+ const payload: Record<string, unknown> = {
1384
+ payload_type: PAYLOAD_TYPE_KEY_LIST,
1385
+ keys: entries,
1386
+ statement_expires_at: formatTimestamp(doc.statement_expires_at),
1387
+ statement_issued_at: formatTimestamp(doc.statement_issued_at),
1388
+ sth_root_hash: toHex(doc.sth_root_hash as string),
1389
+ sth_tree_size: doc.sth_tree_size as number,
1390
+ };
1391
+ return canonicalJson(payload);
1392
+ }
1393
+
1046
1394
  /** The verdict when a certificate verified but nothing said whether it was revoked. */
1047
1395
  export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
1048
1396
 
@@ -1059,10 +1407,11 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
1059
1407
  * | input | result |
1060
1408
  * |---|---|
1061
1409
  * | fresh statement, REVOKED | throws VerificationError |
1062
- * | fresh statement, ACTIVE | `"VALID"` |
1063
- * | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"` |
1410
+ * | fresh statement, ACTIVE | `"VALID"`, or a soft verdict on either key |
1411
+ * | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"`, or the certificate key's soft verdict |
1412
+ * | statement key unusable (ADR-017) | throws VerificationError |
1064
1413
  *
1065
- * The last row is the point: `verifyCertificate` answers VALID there, which
1414
+ * The third row is the point: `verifyCertificate` answers VALID there, which
1066
1415
  * reads as "not revoked" and is not something it checked.
1067
1416
  */
1068
1417
  export async function verifyCertificateWithStatus(
@@ -1072,12 +1421,14 @@ export async function verifyCertificateWithStatus(
1072
1421
  status?: Record<string, unknown> | null,
1073
1422
  now?: Date,
1074
1423
  ): Promise<string> {
1424
+ // A refusal throws out of verifyCertificate, so `base` is VALID or one of the
1425
+ // two soft verdicts. Returning a soft verdict here skipped every check below
1426
+ // it: a revoked certificate signed by a compromised-later key answered
1427
+ // VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
1428
+ // verdict is held instead, and resolved against what the statement says.
1075
1429
  const base = await verifyCertificate(crypto, certificate, publicKeys);
1076
- if (base !== "VALID") {
1077
- return base as string;
1078
- }
1079
1430
  if (status == null) {
1080
- return VALID_REVOCATION_UNKNOWN;
1431
+ return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
1081
1432
  }
1082
1433
 
1083
1434
  const keyId = status.key_id as string | undefined;
@@ -1085,9 +1436,27 @@ export async function verifyCertificateWithStatus(
1085
1436
  if (!info) {
1086
1437
  throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
1087
1438
  }
1088
- if (info.revoked) {
1089
- throw new VerificationError("status statement signed by a revoked key");
1090
- }
1439
+
1440
+ // The whole ADR-017 table, not just the deprecated boolean. `revoked` is
1441
+ // never set by a key loaded from /.well-known/burnledger-keys — that endpoint
1442
+ // publishes key_status — so this check read `false` for a key the issuer had
1443
+ // declared COMPROMISED, and whoever held that key could mint an ACTIVE
1444
+ // statement for a revoked certificate that every SDK and the browser bundle
1445
+ // rendered as a signed "not revoked". core.VerifyCertificateWithStatus has
1446
+ // always evaluated it.
1447
+ //
1448
+ // The anchor is the verifier's own clock, not a field inside the statement: a
1449
+ // statement must already be inside its own validity window to be served at
1450
+ // all, and `now` is a time this verifier observed rather than one the signing
1451
+ // key asserted about itself.
1452
+ const when = now ?? new Date();
1453
+ const at = formatTimestamp(when.toISOString());
1454
+ const statusKeyVerdict = requireUsableKey(
1455
+ info,
1456
+ parseRfc3339(at),
1457
+ keyId!,
1458
+ "status statement key",
1459
+ );
1091
1460
 
1092
1461
  const payload = buildCertificateStatusPayload(status);
1093
1462
  // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
@@ -1118,17 +1487,43 @@ export async function verifyCertificateWithStatus(
1118
1487
 
1119
1488
  // Compared in the normalized form the payload signs, so the freshness check
1120
1489
  // cannot disagree with what was signed about the same two instants.
1121
- const at = formatTimestamp((now ?? new Date()).toISOString());
1122
1490
  const issued = formatTimestamp(status.statement_issued_at);
1123
1491
  const expires = formatTimestamp(status.statement_expires_at);
1124
1492
  if (at < issued || at >= expires) {
1125
1493
  // Stale is not a weaker answer, it is no answer — including for a REVOKED
1126
1494
  // statement, which must never decay into VALID.
1127
- return VALID_REVOCATION_UNKNOWN;
1495
+ return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
1128
1496
  }
1129
1497
 
1130
1498
  if (status.status === "REVOKED") {
1131
- throw new VerificationError("certificate has been revoked");
1499
+ // Coded, because this is the only status-path rejection that is a fact
1500
+ // about the certificate rather than a failure to authenticate the
1501
+ // statement. A caller that cannot tell the two apart will render an
1502
+ // unsigned statement as a revocation.
1503
+ throw new VerificationError("certificate has been revoked", CERTIFICATE_REVOKED);
1504
+ }
1505
+ // A state this SDK does not recognize is NOT active. This tested only for
1506
+ // REVOKED and treated everything else as a pass, so a signed, fresh,
1507
+ // correctly-bound statement saying "SUSPENDED" — or "revoked" in the wrong
1508
+ // case — was reported as revocation CHECKED AND PASSED. Mirrors
1509
+ // core.ValidCertificateStatusValue.
1510
+ if (!KNOWN_STATUS_VALUES.has(status.status as string)) {
1511
+ return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
1132
1512
  }
1133
- return "VALID";
1513
+ // A soft verdict on the status key is the result, not a footnote — but it is
1514
+ // reported only once the statement has authenticated and been read, so it can
1515
+ // neither speak for an unverified statement nor suppress a revocation.
1516
+ return resolveWithBase(base, statusKeyVerdict);
1517
+ }
1518
+
1519
+ /**
1520
+ * Picks what to report when the certificate's own key returned a soft verdict
1521
+ * and the status path also has something to say. A doubt about the key that
1522
+ * signed the certificate questions the whole document, so it outranks a doubt
1523
+ * about revocation or about the statement's own key; only one verdict string
1524
+ * comes back, and this is the one a reader most needs. Mirrors
1525
+ * core.resolveWithBase.
1526
+ */
1527
+ function resolveWithBase(base: string, verdict: string): string {
1528
+ return base !== "VALID" ? base : verdict;
1134
1529
  }