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
@@ -15,7 +15,7 @@
15
15
  * certificate signing payload. issuanceBytes reconstructs json.Marshal
16
16
  * output at issuance time (transparency_status=PENDING, no transparency).
17
17
  */
18
- import { VerificationError } from "./errors.js";
18
+ import { CERTIFICATE_REVOKED, UNSUPPORTED_ALGORITHM, UNSUPPORTED_FORMAT_VERSION, VerificationError, } from "./errors.js";
19
19
  // ---------------------------------------------------------------------------
20
20
  // Pure byte helpers (no Buffer, no node:crypto)
21
21
  // ---------------------------------------------------------------------------
@@ -34,11 +34,69 @@ const PAYLOAD_TYPE_ATTESTATION_V5 = "burnledger.attestation.v5";
34
34
  const PAYLOAD_TYPE_CERTIFICATE_V5 = "burnledger.certificate.v5";
35
35
  const FORMAT_VERSION_V5 = "5.0";
36
36
  const FORMAT_VERSION_V4 = "4.0";
37
+ // v6 renames the artifact and nothing else: the shape is byte-for-byte v5, and
38
+ // only the tag and the version string differ. It still takes its own tag, for
39
+ // the same reason v4 and v5 each took one -- a signature over a payload calling
40
+ // itself a verification record must not verify as one calling itself a
41
+ // certificate. Two names for one shape sharing a separator is exactly what a
42
+ // separator prevents.
43
+ const PAYLOAD_TYPE_ATTESTATION_V6 = "burnledger.attestation.v6";
44
+ const PAYLOAD_TYPE_VERIFICATION_RECORD_V6 = "burnledger.verification_record.v6";
45
+ const FORMAT_VERSION_V6 = "6.0";
46
+ // v7 changes the SHAPE, not just the name: every system entry gains
47
+ // read_only_enforcement and transport_security, and the issuer gains the
48
+ // algorithm identifier. Its tags move for the reason v4, v5 and v6 each moved
49
+ // theirs, and this time the bytes under them genuinely differ.
50
+ const PAYLOAD_TYPE_ATTESTATION_V7 = "burnledger.attestation.v7";
51
+ const PAYLOAD_TYPE_VERIFICATION_RECORD_V7 = "burnledger.verification_record.v7";
52
+ const FORMAT_VERSION_V7 = "7.0";
53
+ const FORMAT_VERSION_V3 = "3.0";
54
+ /**
55
+ * The only signature scheme this SDK can check. A v7 record names its own
56
+ * algorithm, so a record naming anything else must be refused with its own
57
+ * code rather than fed to ed25519Verify and reported as a bad signature.
58
+ */
59
+ const ALGORITHM_ED25519 = "ed25519";
60
+ /**
61
+ * Every format this build can read, oldest first. Mirrors core.FormatVersions()
62
+ * in the Go source; the cross-language conformance corpus carries a fixture per
63
+ * version, so a version present in one language and missing here fails there.
64
+ *
65
+ * Frozen because `readonly` is erased at build time: it stops a TypeScript
66
+ * caller from writing to the array, and does nothing at all to the JavaScript
67
+ * this compiles to — which is what the browser bundle actually runs, where any
68
+ * script on the page could otherwise push a version onto it.
69
+ */
70
+ export const KNOWN_FORMAT_VERSIONS = Object.freeze([
71
+ FORMAT_VERSION_V3,
72
+ FORMAT_VERSION_V4,
73
+ FORMAT_VERSION_V5,
74
+ FORMAT_VERSION_V6,
75
+ FORMAT_VERSION_V7,
76
+ ]);
77
+ /**
78
+ * Returns the version, or throws if this SDK cannot read it.
79
+ *
80
+ * Rejects a non-string too: JSON carrying `6.0` as a NUMBER would compare
81
+ * unequal to every known version and silently take the oldest branch, which is
82
+ * the same trap as an unknown version wearing a different hat.
83
+ */
84
+ function checkFormatVersion(version) {
85
+ if (typeof version !== "string" || !KNOWN_FORMAT_VERSIONS.includes(version)) {
86
+ throw new VerificationError(`unsupported certificate_format_version ${JSON.stringify(version)}; ` +
87
+ `this version of the SDK reads ${KNOWN_FORMAT_VERSIONS.join(", ")}. ` +
88
+ "The record may be genuine and simply newer than this library.", UNSUPPORTED_FORMAT_VERSION);
89
+ }
90
+ return version;
91
+ }
37
92
  const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
93
+ // The domain for a tree head that names its log. See buildTreeHeadPayload.
94
+ const PAYLOAD_TYPE_TREE_HEAD_V7 = "burnledger.sth.v7";
38
95
  const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
39
96
  // There is deliberately no verification payload type: v3 produces those facts
40
97
  // in the same enclave call that signs the certificate (ADR-016 §2).
41
98
  const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
99
+ const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
42
100
  export function hexToBytes(hex) {
43
101
  const len = hex.length >>> 1;
44
102
  const out = new Uint8Array(len);
@@ -125,6 +183,29 @@ export const KEY_OUTSIDE_VALIDITY = "KEY_OUTSIDE_VALIDITY";
125
183
  export const KEY_COMPROMISED = "KEY_COMPROMISED";
126
184
  export const VALID_KEY_COMPROMISED_LATER = "VALID_KEY_COMPROMISED_LATER";
127
185
  export const VALID_KEY_WINDOW_UNKNOWN = "VALID_KEY_WINDOW_UNKNOWN";
186
+ /**
187
+ * The revocation states a statement may assert. Mirrors
188
+ * core.ValidCertificateStatusValue: anything else means the statement was read
189
+ * but not understood, which is not the same as "not revoked".
190
+ */
191
+ const KNOWN_STATUS_VALUES = new Set(["ACTIVE", "REVOKED"]);
192
+ /**
193
+ * The key verdicts that permit relying on a signature: VALID plus the two soft
194
+ * verdicts ADR-017 §2 says a reader must be SHOWN rather than refused. Anything
195
+ * not named here is refused, including a verdict added in a later release.
196
+ */
197
+ const USABLE_KEY_VERDICTS = new Set([
198
+ "VALID",
199
+ VALID_KEY_WINDOW_UNKNOWN,
200
+ VALID_KEY_COMPROMISED_LATER,
201
+ ]);
202
+ /** Whether a key verdict permits relying on a signature. Mirrors
203
+ * core.KeyIsUsable; exported so the key-list verifier (keys.ts) refuses the
204
+ * same set requireUsableKey refuses rather than keeping a second copy of it —
205
+ * a second copy is exactly how #684's fourth answer came to exist. */
206
+ export function keyIsUsable(verdict) {
207
+ return USABLE_KEY_VERDICTS.has(verdict);
208
+ }
128
209
  /** Whether a key was authorized to sign at anchor time `t`.
129
210
  *
130
211
  * Returns `"VALID"` when the key clears. The anchor is seconds since the epoch
@@ -167,7 +248,17 @@ export function evaluateKey(pki, anchor) {
167
248
  // for. Fail closed, as the boolean it replaces did.
168
249
  return "UNKNOWN_KEY";
169
250
  }
170
- /** The anchor a certificate supplies, or null when it supplies none. */
251
+ /**
252
+ * The anchor a certificate supplies, or null when it supplies none.
253
+ *
254
+ * READS AN UNSIGNED FIELD. `transparency` is stripped before signing, so on a
255
+ * certificate handed to a verifier the timestamp is holder-editable. Use
256
+ * {@link verifiedCertificateAnchor} anywhere the anchor decides whether a key
257
+ * may be relied on; this is safe only where the same timestamp is inside a
258
+ * payload the caller goes on to verify, which is what verifyTransparency does.
259
+ *
260
+ * Exported and unchanged because it is published API.
261
+ */
171
262
  export function certificateAnchor(certificate) {
172
263
  const transparency = certificate.transparency;
173
264
  if (transparency == null)
@@ -182,35 +273,95 @@ export function certificateAnchor(certificate) {
182
273
  return null;
183
274
  }
184
275
  }
276
+ /**
277
+ * The anchor, but only once the tree head carrying it has been checked against
278
+ * the key being evaluated.
279
+ *
280
+ * The bypass this closes: for a key declared compromised, evaluateKey answers
281
+ * KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
282
+ * anchor predates the compromise — and the second is a verdict requireUsableKey
283
+ * passes through as the RESULT of verifyCertificate. Since the timestamp is
284
+ * covered by no signature, a holder could backdate it and have a certificate
285
+ * signed with a compromised key reported as verified-with-a-caveat. Deleting
286
+ * `transparency` failed closed; keeping a doctored one did not.
287
+ *
288
+ * A head that does not verify yields no anchor, which is the same conservative
289
+ * answer as a certificate carrying no transparency block at all.
290
+ */
291
+ async function verifiedCertificateAnchor(crypto, certificate, pki) {
292
+ const anchor = certificateAnchor(certificate);
293
+ if (anchor === null)
294
+ return null;
295
+ const transparency = certificate.transparency;
296
+ const sth = transparency.signed_tree_head;
297
+ try {
298
+ const headPayload = buildTreeHeadPayload(sth);
299
+ const headSig = decodeSignature(sth.signature);
300
+ if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig)))
301
+ return null;
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ return anchor;
307
+ }
185
308
  /** Throws for a key that cannot be used at all; returns the verdict otherwise.
186
309
  *
187
310
  * VALID_KEY_COMPROMISED_LATER and VALID_KEY_WINDOW_UNKNOWN are returned rather
188
311
  * than thrown because they are not refusals — they are results a caller must
189
312
  * show the reader instead of collapsing into green.
190
313
  */
191
- function requireUsableKey(pki, anchor, keyId) {
314
+ function requireUsableKey(pki, anchor, keyId,
315
+ // Named, because this is also the key that signs a certificate-status
316
+ // statement, and "issuer key is compromised" would send a reader looking at
317
+ // the wrong key.
318
+ role = "issuer key") {
192
319
  const verdict = evaluateKey(pki, anchor);
193
320
  if (verdict === KEY_COMPROMISED) {
194
- throw new VerificationError(`issuer key is compromised: ${keyId}`);
321
+ throw new VerificationError(`${role} is compromised: ${keyId}`);
195
322
  }
196
323
  if (verdict === KEY_OUTSIDE_VALIDITY) {
197
- throw new VerificationError(`issuer key was not valid when it signed: ${keyId}`);
324
+ throw new VerificationError(`${role} was not valid when it signed: ${keyId}`);
198
325
  }
199
326
  if (verdict === "UNKNOWN_KEY") {
200
- throw new VerificationError(`issuer key has an unusable status "${pki.keyStatus}": ${keyId}`);
327
+ throw new VerificationError(`${role} has an unusable status "${pki.keyStatus}": ${keyId}`);
328
+ }
329
+ // An ALLOW-list, matching core.KeyIsUsable, which was inverted away from
330
+ // exactly the shape above. Naming the refusals and returning everything else
331
+ // means a verdict added to evaluateKey later becomes a PASS by default — and
332
+ // the value returned here is propagated out of verifyCertificate as its
333
+ // result, so "everything else" is rendered to a reader as "verified, with a
334
+ // note". Go would refuse the same document. That is #684's three-verifiers-
335
+ // two-answers divergence, in the direction that calls a bad document good.
336
+ if (!USABLE_KEY_VERDICTS.has(verdict)) {
337
+ throw new VerificationError(`${role} returned a verdict this SDK does not recognize (${verdict}): ${keyId}`);
201
338
  }
202
339
  return verdict;
203
340
  }
204
341
  /** Verify all signatures on a deletion certificate offline. */
205
342
  export async function verifyCertificate(crypto, certificate, publicKeys) {
343
+ // Step 0: can this SDK read the format at all? Every check below rebuilds
344
+ // signed bytes from the version, so an unreadable version makes all of them
345
+ // meaningless - and "unknown issuer key" would be the wrong thing to tell
346
+ // someone holding a record that is merely newer than this library.
347
+ const formatVersion = checkFormatVersion(certificate.certificate_format_version);
206
348
  const issuer = certificate.issuer;
349
+ // Step 0b: can this SDK check the algorithm the record names? Asked before
350
+ // any signature, because verifying an Ed25519 signature over a record that
351
+ // says it was signed with something else answers a question nobody asked.
352
+ // Below v7 no record names one, so there is nothing to check.
353
+ if (formatVersion === FORMAT_VERSION_V7 && issuer.algorithm !== ALGORITHM_ED25519) {
354
+ throw new VerificationError(`record names signature algorithm ${JSON.stringify(issuer.algorithm)}; ` +
355
+ `this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
356
+ "The record may be genuine and simply signed with a scheme this library does not implement.", UNSUPPORTED_ALGORITHM);
357
+ }
207
358
  const keyId = issuer.key_id;
208
359
  const pki = publicKeys.get(keyId);
209
360
  if (pki === undefined)
210
361
  throw new VerificationError(`unknown issuer key: ${keyId}`);
211
362
  // The key check runs before any signature check: a signature verified
212
363
  // against a key nobody vouches for proves nothing about the issuer.
213
- const keyVerdict = requireUsableKey(pki, certificateAnchor(certificate), keyId);
364
+ const keyVerdict = requireUsableKey(pki, await verifiedCertificateAnchor(crypto, certificate, pki), keyId);
214
365
  // 1. Certificate signature FIRST — nothing below may trust a field until the
215
366
  // bytes carrying it are covered by a verified signature.
216
367
  const certPayload = buildCertificatePayload(certificate);
@@ -333,6 +484,17 @@ export async function verifyTransparency(crypto, certificate, publicKeys) {
333
484
  throw new VerificationError(`transparency.tree_size (${unsignedSize}) does not match the signed tree head ` +
334
485
  `(${treeSize}); it is not covered by any signature`);
335
486
  }
487
+ // Same rule for the log id (Q31). transparency.log_id sits beside log_url and
488
+ // is the field a reader looks at to answer "which log is this?", and nothing
489
+ // signs it — so a holder could relabel a genuine proof as belonging to a
490
+ // different log while every signature still checked out. A head predating log
491
+ // ids has neither side set and passes.
492
+ const signedLogId = sth.log_id ?? "";
493
+ const unsignedLogId = transparency.log_id ?? "";
494
+ if (unsignedLogId !== signedLogId) {
495
+ throw new VerificationError(`transparency.log_id (${JSON.stringify(unsignedLogId)}) does not match the signed ` +
496
+ `tree head (${JSON.stringify(signedLogId)}); it is not covered by any signature`);
497
+ }
336
498
  if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
337
499
  throw new VerificationError("merkle inclusion proof is invalid");
338
500
  }
@@ -567,31 +729,85 @@ function civilFromDays(days) {
567
729
  // ---------------------------------------------------------------------------
568
730
  function buildAttestationPayload(subject, att, certFormatVersion) {
569
731
  const attestedAt = formatTimestamp(att.attested_at);
570
- const systems = att.systems.map((s) => ({
571
- canonical_version: s.canonical_version ?? null,
572
- connector_type: s.connector_type,
573
- hash_scope: s.hash_scope,
574
- merkle_root: s.merkle_root
575
- ? toHex(s.merkle_root)
576
- : null,
577
- // The system's own observation time, not the envelope's.
578
- observed_at: formatTimestamp(s.observed_at),
579
- query_hash: toHex(s.query_hash),
580
- record_count: s.record_count,
581
- system_id: s.system_id,
582
- system_name: s.system_name,
583
- }));
732
+ // v7 signs two per-system measurements v6 leaves on a mutable row. Gated on
733
+ // the record's OWN version: emitting them for an older format would rebuild
734
+ // bytes no signer ever produced and reject every record already issued.
735
+ const isV7 = certFormatVersion === FORMAT_VERSION_V7;
736
+ const systems = att.systems.map((s) => {
737
+ const sys = {
738
+ canonical_version: s.canonical_version ?? null,
739
+ connector_type: s.connector_type,
740
+ hash_scope: s.hash_scope,
741
+ merkle_root: s.merkle_root
742
+ ? toHex(s.merkle_root)
743
+ : null,
744
+ // The system's own observation time, not the envelope's.
745
+ observed_at: formatTimestamp(s.observed_at),
746
+ query_hash: toHex(s.query_hash),
747
+ record_count: s.record_count,
748
+ system_id: s.system_id,
749
+ system_name: s.system_name,
750
+ };
751
+ if (isV7) {
752
+ sys.read_only_enforcement = requireMeasured(s.read_only_enforcement, "read_only_enforcement", s.system_name);
753
+ sys.transport_security = requireMeasured(s.transport_security, "transport_security", s.system_name);
754
+ }
755
+ return sys;
756
+ });
584
757
  const payload = {
585
758
  attested_at: attestedAt,
586
- payload_type: certFormatVersion === FORMAT_VERSION_V5
587
- ? PAYLOAD_TYPE_ATTESTATION_V5
588
- : PAYLOAD_TYPE_ATTESTATION,
759
+ payload_type: attestationPayloadType(certFormatVersion),
589
760
  proof_mode: att.proof_mode,
590
761
  subject_hash: toHex(subject.identifier_hash),
591
762
  systems,
592
763
  };
593
764
  return canonicalJson(payload);
594
765
  }
766
+ /**
767
+ * Read a v7 measured field that MUST be a non-empty string.
768
+ *
769
+ * A missing or non-string value cannot be turned into `""` and canonicalized:
770
+ * the signer never emits an empty measurement, so an empty one here would
771
+ * rebuild bytes no signature covers and be reported as forgery. Refusing with a
772
+ * document-shape message says the true thing — this record is malformed, not
773
+ * this record is fake.
774
+ */
775
+ function requireMeasured(value, field, systemName) {
776
+ if (typeof value !== "string" || value === "") {
777
+ throw new VerificationError(`system ${JSON.stringify(systemName)}: ${field} is missing from a 7.0 record, ` +
778
+ "which signs it; the document is incomplete rather than unverifiable");
779
+ }
780
+ return value;
781
+ }
782
+ /** Domain separator for the record itself, by certificate format version. */
783
+ function certificatePayloadType(version) {
784
+ checkFormatVersion(version);
785
+ if (version === FORMAT_VERSION_V7)
786
+ return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
787
+ if (version === FORMAT_VERSION_V6)
788
+ return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
789
+ if (version === FORMAT_VERSION_V5)
790
+ return PAYLOAD_TYPE_CERTIFICATE_V5;
791
+ if (version === FORMAT_VERSION_V4)
792
+ return PAYLOAD_TYPE_CERTIFICATE_V4;
793
+ return PAYLOAD_TYPE_CERTIFICATE;
794
+ }
795
+ /**
796
+ * Domain separator for the attestation carried by a record of that format. It
797
+ * follows the record's version because a verifier rebuilds the attestation
798
+ * payload from the record it is checking, and must reproduce the exact bytes
799
+ * the enclave signed.
800
+ */
801
+ function attestationPayloadType(version) {
802
+ checkFormatVersion(version);
803
+ if (version === FORMAT_VERSION_V7)
804
+ return PAYLOAD_TYPE_ATTESTATION_V7;
805
+ if (version === FORMAT_VERSION_V6)
806
+ return PAYLOAD_TYPE_ATTESTATION_V6;
807
+ if (version === FORMAT_VERSION_V5)
808
+ return PAYLOAD_TYPE_ATTESTATION_V5;
809
+ return PAYLOAD_TYPE_ATTESTATION;
810
+ }
595
811
  function buildCertificatePayload(cert) {
596
812
  const att = cert.attestation;
597
813
  const issuer = cert.issuer;
@@ -599,19 +815,28 @@ function buildCertificatePayload(cert) {
599
815
  // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
600
816
  // and a verification list joined only on the human-editable system_name,
601
817
  // which made a partial deletion indistinguishable from a complete one.
602
- const systems = (cert.systems ?? []).map((s) => ({
603
- attested_at: formatTimestamp(s.attested_at),
604
- attested_count: s.attested_count,
605
- canonical_version: s.canonical_version ?? null,
606
- connector_type: s.connector_type,
607
- hash_scope: s.hash_scope,
608
- merkle_root: s.merkle_root ? toHex(s.merkle_root) : null,
609
- query_hash: toHex(s.query_hash),
610
- system_id: s.system_id,
611
- system_name: s.system_name,
612
- verified_at: formatTimestamp(s.verified_at),
613
- verified_count: s.verified_count,
614
- }));
818
+ const version = cert.certificate_format_version;
819
+ const isV7 = version === FORMAT_VERSION_V7;
820
+ const systems = (cert.systems ?? []).map((s) => {
821
+ const sys = {
822
+ attested_at: formatTimestamp(s.attested_at),
823
+ attested_count: s.attested_count,
824
+ canonical_version: s.canonical_version ?? null,
825
+ connector_type: s.connector_type,
826
+ hash_scope: s.hash_scope,
827
+ merkle_root: s.merkle_root ? toHex(s.merkle_root) : null,
828
+ query_hash: toHex(s.query_hash),
829
+ system_id: s.system_id,
830
+ system_name: s.system_name,
831
+ verified_at: formatTimestamp(s.verified_at),
832
+ verified_count: s.verified_count,
833
+ };
834
+ if (isV7) {
835
+ sys.read_only_enforcement = requireMeasured(s.read_only_enforcement, "read_only_enforcement", s.system_name);
836
+ sys.transport_security = requireMeasured(s.transport_security, "transport_security", s.system_name);
837
+ }
838
+ return sys;
839
+ });
615
840
  // The attestation block carries no system list of its own: the merged list
616
841
  // above is a superset of it. verification_signature is gone entirely.
617
842
  const attObj = {
@@ -622,20 +847,28 @@ function buildCertificatePayload(cert) {
622
847
  // Fields added after v3 are gated on the certificate's OWN version. A v3
623
848
  // certificate must reconstruct to the same bytes forever; reading these
624
849
  // unconditionally would break every certificate already issued.
625
- const version = cert.certificate_format_version;
626
- const isV5 = version === FORMAT_VERSION_V5;
627
- const isV4Plus = version === FORMAT_VERSION_V4 || isV5;
850
+ // v6 carries v5's shape exactly, so it takes every gate v5 takes. Naming
851
+ // these "Plus" rather than testing equality at each use is what stops a new
852
+ // version from silently missing one.
853
+ const isV5Plus = version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6 || isV7;
854
+ const isV4Plus = version === FORMAT_VERSION_V4 || isV5Plus;
628
855
  const issuerObj = {
629
856
  key_id: issuer.key_id,
630
857
  name: issuer.name,
631
858
  public_key: toHex(issuer.public_key),
632
859
  };
860
+ // v7 signs the algorithm, and does so unconditionally: unlike legal_entity
861
+ // and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
862
+ // so a v7 record missing it is malformed rather than merely sparse.
863
+ if (isV7) {
864
+ issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer");
865
+ }
633
866
  if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
634
867
  issuerObj.legal_entity = issuer.legal_entity;
635
868
  }
636
869
  // Signed from v5, and omitted when absent or empty: a build with no
637
870
  // measurement signs none, and "" is a value a reader could mistake for one.
638
- if (isV5 && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
871
+ if (isV5Plus && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
639
872
  issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
640
873
  }
641
874
  // v5 drops identifier_type_hint: signed, but always the constant "custom",
@@ -643,7 +876,7 @@ function buildCertificatePayload(cert) {
643
876
  const subjectObj = {
644
877
  identifier_hash: toHex(subject.identifier_hash),
645
878
  };
646
- if (!isV5) {
879
+ if (!isV5Plus) {
647
880
  subjectObj.identifier_type_hint = subject.identifier_type_hint;
648
881
  }
649
882
  // status and revocation are deliberately absent (ADR-016 §3): a signature
@@ -656,11 +889,7 @@ function buildCertificatePayload(cert) {
656
889
  certificate_id: cert.certificate_id,
657
890
  issued_at: formatTimestamp(cert.issued_at),
658
891
  issuer: issuerObj,
659
- payload_type: isV5
660
- ? PAYLOAD_TYPE_CERTIFICATE_V5
661
- : isV4Plus
662
- ? PAYLOAD_TYPE_CERTIFICATE_V4
663
- : PAYLOAD_TYPE_CERTIFICATE,
892
+ payload_type: certificatePayloadType(version),
664
893
  subject: subjectObj,
665
894
  systems,
666
895
  };
@@ -703,15 +932,37 @@ function attestationSystems(cert) {
703
932
  observed_at: s.attested_at,
704
933
  merkle_root: s.merkle_root,
705
934
  canonical_version: s.canonical_version,
935
+ // Carried through so the v7 attestation payload can be rebuilt from the
936
+ // record. Undefined on older formats, where buildAttestationPayload never
937
+ // reads them.
938
+ read_only_enforcement: s.read_only_enforcement,
939
+ transport_security: s.transport_security,
706
940
  }));
707
941
  }
708
- function buildTreeHeadPayload(head) {
942
+ /**
943
+ * Port of Go's BuildTreeHeadPayload.
944
+ *
945
+ * A head that names its log is signed in a DIFFERENT DOMAIN from one that does
946
+ * not (Q31). That is what makes log_id unstrippable: removing it moves the
947
+ * rebuild into burnledger.sth.v3 and adding one moves it into
948
+ * burnledger.sth.v7, and the signature fails either way. Heads signed before
949
+ * log ids existed carry none and reproduce exactly the bytes they always did.
950
+ *
951
+ * Exported for anchor.ts, which rebuilds the preimage of a head fetched from
952
+ * GET /v1/log/head. Not re-exported from index.ts: it is an internal seam
953
+ * between two modules of this package, not published surface.
954
+ */
955
+ export function buildTreeHeadPayload(head) {
956
+ const logId = head.log_id;
957
+ const named = typeof logId === "string" && logId !== "";
709
958
  const payload = {
710
- payload_type: PAYLOAD_TYPE_TREE_HEAD,
959
+ payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
711
960
  root_hash: toHex(head.root_hash),
712
961
  timestamp: formatTimestamp(head.timestamp),
713
962
  tree_size: head.tree_size,
714
963
  };
964
+ if (named)
965
+ payload.log_id = logId;
715
966
  return canonicalJson(payload);
716
967
  }
717
968
  // ---------------------------------------------------------------------------
@@ -866,6 +1117,42 @@ export function buildCertificateStatusPayload(stmt) {
866
1117
  }
867
1118
  return canonicalJson(payload);
868
1119
  }
1120
+ /**
1121
+ * Port of Go's BuildKeyListPayload (core/key_list.go).
1122
+ *
1123
+ * The key array is sorted by key_id before signing: canonical JSON sorts
1124
+ * object keys but does nothing to array order, and the server has no reason to
1125
+ * preserve any particular order across a rotation. Optional entry fields are
1126
+ * omitted, never emitted as null, for the reason buildCertificateStatusPayload
1127
+ * omits them. `keys` is the array exactly as HANDED to the verifier — the
1128
+ * signature covers the published entries, not a parsed reinterpretation of
1129
+ * them, which is what makes an edited key_status detectable.
1130
+ */
1131
+ export function buildKeyListPayload(doc) {
1132
+ const entries = doc.keys.map((e) => {
1133
+ const k = {};
1134
+ if (e.compromised_from != null)
1135
+ k.compromised_from = e.compromised_from;
1136
+ k.key_id = e.key_id;
1137
+ k.key_status = e.key_status;
1138
+ if (e.not_after != null)
1139
+ k.not_after = e.not_after;
1140
+ if (e.not_before != null && e.not_before !== "")
1141
+ k.not_before = e.not_before;
1142
+ k.public_key = e.public_key;
1143
+ return k;
1144
+ });
1145
+ entries.sort((a, b) => (a.key_id < b.key_id ? -1 : a.key_id === b.key_id ? 0 : 1));
1146
+ const payload = {
1147
+ payload_type: PAYLOAD_TYPE_KEY_LIST,
1148
+ keys: entries,
1149
+ statement_expires_at: formatTimestamp(doc.statement_expires_at),
1150
+ statement_issued_at: formatTimestamp(doc.statement_issued_at),
1151
+ sth_root_hash: toHex(doc.sth_root_hash),
1152
+ sth_tree_size: doc.sth_tree_size,
1153
+ };
1154
+ return canonicalJson(payload);
1155
+ }
869
1156
  /** The verdict when a certificate verified but nothing said whether it was revoked. */
870
1157
  export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
871
1158
  /**
@@ -881,28 +1168,43 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
881
1168
  * | input | result |
882
1169
  * |---|---|
883
1170
  * | fresh statement, REVOKED | throws VerificationError |
884
- * | fresh statement, ACTIVE | `"VALID"` |
885
- * | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"` |
1171
+ * | fresh statement, ACTIVE | `"VALID"`, or a soft verdict on either key |
1172
+ * | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"`, or the certificate key's soft verdict |
1173
+ * | statement key unusable (ADR-017) | throws VerificationError |
886
1174
  *
887
- * The last row is the point: `verifyCertificate` answers VALID there, which
1175
+ * The third row is the point: `verifyCertificate` answers VALID there, which
888
1176
  * reads as "not revoked" and is not something it checked.
889
1177
  */
890
1178
  export async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now) {
1179
+ // A refusal throws out of verifyCertificate, so `base` is VALID or one of the
1180
+ // two soft verdicts. Returning a soft verdict here skipped every check below
1181
+ // it: a revoked certificate signed by a compromised-later key answered
1182
+ // VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
1183
+ // verdict is held instead, and resolved against what the statement says.
891
1184
  const base = await verifyCertificate(crypto, certificate, publicKeys);
892
- if (base !== "VALID") {
893
- return base;
894
- }
895
1185
  if (status == null) {
896
- return VALID_REVOCATION_UNKNOWN;
1186
+ return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
897
1187
  }
898
1188
  const keyId = status.key_id;
899
1189
  const info = keyId ? publicKeys.get(keyId) : undefined;
900
1190
  if (!info) {
901
1191
  throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
902
1192
  }
903
- if (info.revoked) {
904
- throw new VerificationError("status statement signed by a revoked key");
905
- }
1193
+ // The whole ADR-017 table, not just the deprecated boolean. `revoked` is
1194
+ // never set by a key loaded from /.well-known/burnledger-keys — that endpoint
1195
+ // publishes key_status — so this check read `false` for a key the issuer had
1196
+ // declared COMPROMISED, and whoever held that key could mint an ACTIVE
1197
+ // statement for a revoked certificate that every SDK and the browser bundle
1198
+ // rendered as a signed "not revoked". core.VerifyCertificateWithStatus has
1199
+ // always evaluated it.
1200
+ //
1201
+ // The anchor is the verifier's own clock, not a field inside the statement: a
1202
+ // statement must already be inside its own validity window to be served at
1203
+ // all, and `now` is a time this verifier observed rather than one the signing
1204
+ // key asserted about itself.
1205
+ const when = now ?? new Date();
1206
+ const at = formatTimestamp(when.toISOString());
1207
+ const statusKeyVerdict = requireUsableKey(info, parseRfc3339(at), keyId, "status statement key");
906
1208
  const payload = buildCertificateStatusPayload(status);
907
1209
  // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
908
1210
  // array depending on the producer, and every other signature on this path
@@ -930,17 +1232,42 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
930
1232
  }
931
1233
  // Compared in the normalized form the payload signs, so the freshness check
932
1234
  // cannot disagree with what was signed about the same two instants.
933
- const at = formatTimestamp((now ?? new Date()).toISOString());
934
1235
  const issued = formatTimestamp(status.statement_issued_at);
935
1236
  const expires = formatTimestamp(status.statement_expires_at);
936
1237
  if (at < issued || at >= expires) {
937
1238
  // Stale is not a weaker answer, it is no answer — including for a REVOKED
938
1239
  // statement, which must never decay into VALID.
939
- return VALID_REVOCATION_UNKNOWN;
1240
+ return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
940
1241
  }
941
1242
  if (status.status === "REVOKED") {
942
- throw new VerificationError("certificate has been revoked");
1243
+ // Coded, because this is the only status-path rejection that is a fact
1244
+ // about the certificate rather than a failure to authenticate the
1245
+ // statement. A caller that cannot tell the two apart will render an
1246
+ // unsigned statement as a revocation.
1247
+ throw new VerificationError("certificate has been revoked", CERTIFICATE_REVOKED);
1248
+ }
1249
+ // A state this SDK does not recognize is NOT active. This tested only for
1250
+ // REVOKED and treated everything else as a pass, so a signed, fresh,
1251
+ // correctly-bound statement saying "SUSPENDED" — or "revoked" in the wrong
1252
+ // case — was reported as revocation CHECKED AND PASSED. Mirrors
1253
+ // core.ValidCertificateStatusValue.
1254
+ if (!KNOWN_STATUS_VALUES.has(status.status)) {
1255
+ return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
943
1256
  }
944
- return "VALID";
1257
+ // A soft verdict on the status key is the result, not a footnote — but it is
1258
+ // reported only once the statement has authenticated and been read, so it can
1259
+ // neither speak for an unverified statement nor suppress a revocation.
1260
+ return resolveWithBase(base, statusKeyVerdict);
1261
+ }
1262
+ /**
1263
+ * Picks what to report when the certificate's own key returned a soft verdict
1264
+ * and the status path also has something to say. A doubt about the key that
1265
+ * signed the certificate questions the whole document, so it outranks a doubt
1266
+ * about revocation or about the statement's own key; only one verdict string
1267
+ * comes back, and this is the one a reader most needs. Mirrors
1268
+ * core.resolveWithBase.
1269
+ */
1270
+ function resolveWithBase(base, verdict) {
1271
+ return base !== "VALID" ? base : verdict;
945
1272
  }
946
1273
  //# sourceMappingURL=verify.js.map