burnledger 0.5.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 (88) hide show
  1. package/README.md +8 -1
  2. package/dist/cjs/anchor.d.ts +71 -0
  3. package/dist/cjs/anchor.d.ts.map +1 -0
  4. package/dist/cjs/anchor.js +279 -0
  5. package/dist/cjs/anchor.js.map +1 -0
  6. package/dist/cjs/client.d.ts +16 -2
  7. package/dist/cjs/client.d.ts.map +1 -1
  8. package/dist/cjs/client.js +24 -5
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/enclave-seal.d.ts +101 -0
  11. package/dist/cjs/enclave-seal.d.ts.map +1 -0
  12. package/dist/cjs/enclave-seal.js +479 -0
  13. package/dist/cjs/enclave-seal.js.map +1 -0
  14. package/dist/cjs/errors.d.ts +14 -0
  15. package/dist/cjs/errors.d.ts.map +1 -1
  16. package/dist/cjs/errors.js +15 -1
  17. package/dist/cjs/errors.js.map +1 -1
  18. package/dist/cjs/index.d.ts +12 -2
  19. package/dist/cjs/index.d.ts.map +1 -1
  20. package/dist/cjs/index.js +18 -1
  21. package/dist/cjs/index.js.map +1 -1
  22. package/dist/cjs/keys.d.ts +66 -0
  23. package/dist/cjs/keys.d.ts.map +1 -1
  24. package/dist/cjs/keys.js +128 -3
  25. package/dist/cjs/keys.js.map +1 -1
  26. package/dist/cjs/models.d.ts +20 -0
  27. package/dist/cjs/models.d.ts.map +1 -1
  28. package/dist/cjs/models.js +16 -0
  29. package/dist/cjs/models.js.map +1 -1
  30. package/dist/cjs/verify.d.ts +31 -0
  31. package/dist/cjs/verify.d.ts.map +1 -1
  32. package/dist/cjs/verify.js +181 -31
  33. package/dist/cjs/verify.js.map +1 -1
  34. package/dist/cjs/web-verifier.d.ts +44 -0
  35. package/dist/cjs/web-verifier.d.ts.map +1 -1
  36. package/dist/cjs/web-verifier.js +31 -2
  37. package/dist/cjs/web-verifier.js.map +1 -1
  38. package/dist/esm/anchor.d.ts +71 -0
  39. package/dist/esm/anchor.d.ts.map +1 -0
  40. package/dist/esm/anchor.js +275 -0
  41. package/dist/esm/anchor.js.map +1 -0
  42. package/dist/esm/cli.d.ts +45 -14
  43. package/dist/esm/cli.d.ts.map +1 -1
  44. package/dist/esm/cli.js +309 -68
  45. package/dist/esm/cli.js.map +1 -1
  46. package/dist/esm/client.d.ts +16 -2
  47. package/dist/esm/client.d.ts.map +1 -1
  48. package/dist/esm/client.js +24 -5
  49. package/dist/esm/client.js.map +1 -1
  50. package/dist/esm/enclave-seal.d.ts +101 -0
  51. package/dist/esm/enclave-seal.d.ts.map +1 -0
  52. package/dist/esm/enclave-seal.js +472 -0
  53. package/dist/esm/enclave-seal.js.map +1 -0
  54. package/dist/esm/errors.d.ts +14 -0
  55. package/dist/esm/errors.d.ts.map +1 -1
  56. package/dist/esm/errors.js +14 -0
  57. package/dist/esm/errors.js.map +1 -1
  58. package/dist/esm/index.d.ts +12 -2
  59. package/dist/esm/index.d.ts.map +1 -1
  60. package/dist/esm/index.js +10 -1
  61. package/dist/esm/index.js.map +1 -1
  62. package/dist/esm/keys.d.ts +66 -0
  63. package/dist/esm/keys.d.ts.map +1 -1
  64. package/dist/esm/keys.js +125 -3
  65. package/dist/esm/keys.js.map +1 -1
  66. package/dist/esm/models.d.ts +20 -0
  67. package/dist/esm/models.d.ts.map +1 -1
  68. package/dist/esm/models.js +15 -0
  69. package/dist/esm/models.js.map +1 -1
  70. package/dist/esm/verify.d.ts +31 -0
  71. package/dist/esm/verify.d.ts.map +1 -1
  72. package/dist/esm/verify.js +180 -33
  73. package/dist/esm/verify.js.map +1 -1
  74. package/dist/esm/web-verifier.d.ts +44 -0
  75. package/dist/esm/web-verifier.d.ts.map +1 -1
  76. package/dist/esm/web-verifier.js +30 -1
  77. package/dist/esm/web-verifier.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/anchor.ts +330 -0
  80. package/src/cli.ts +335 -73
  81. package/src/client.ts +35 -4
  82. package/src/enclave-seal.ts +582 -0
  83. package/src/errors.ts +15 -0
  84. package/src/index.ts +22 -1
  85. package/src/keys.ts +176 -3
  86. package/src/models.ts +42 -0
  87. package/src/verify.ts +206 -33
  88. package/src/web-verifier.ts +45 -1
package/src/keys.ts CHANGED
@@ -1,4 +1,15 @@
1
- // Public-key file parsing shared by the CLI (and unit-testable in isolation).
1
+ // Public-key file parsing shared by the CLI and the browser verifier, and the
2
+ // key_list.v3 verdict (ADR-017 §5a) — unit-testable in isolation.
3
+
4
+ import type { CryptoOps } from "./crypto.js";
5
+ import {
6
+ buildKeyListPayload,
7
+ evaluateKey,
8
+ formatTimestamp,
9
+ hexToBytes,
10
+ keyIsUsable,
11
+ publicKeyFromHex,
12
+ } from "./verify.js";
2
13
 
3
14
  export type KeyEntry = {
4
15
  key_id: string;
@@ -12,6 +23,25 @@ export type KeyEntry = {
12
23
  compromised_from?: string | null;
13
24
  };
14
25
 
26
+ /**
27
+ * The published key set as /.well-known/burnledger-keys serves it: the `keys`
28
+ * array, and BESIDE it — in the same object — the fields of a signed
29
+ * key_list.v3 statement when the server signs one (ADR-017 §5a). A body with no
30
+ * `signature` is the unsigned list the endpoint has always served; that is not
31
+ * an error, it is the state of the world before §5a is deployed.
32
+ *
33
+ * Mirrors core.KeyListDocument.
34
+ */
35
+ export type KeyListDocument = {
36
+ keys: KeyEntry[];
37
+ statement_issued_at?: string;
38
+ statement_expires_at?: string;
39
+ sth_tree_size?: number;
40
+ sth_root_hash?: string;
41
+ signature?: string;
42
+ key_id?: string;
43
+ };
44
+
15
45
  /**
16
46
  * Normalize a parsed keys file to an array of key entries. Accepts a bare
17
47
  * JSON array (the generated keys.json shape), a { "keys": [...] } envelope,
@@ -19,10 +49,153 @@ export type KeyEntry = {
19
49
  * actually publishes. Throws on any other shape.
20
50
  */
21
51
  export function parseKeyEntries(parsed: unknown): KeyEntry[] {
52
+ return parseKeyListDocument(parsed).keys;
53
+ }
54
+
55
+ /**
56
+ * Read a published key set in any shape this project has ever served — a bare
57
+ * array, a {"keys": [...]} object, or either inside the API's {"data": ...}
58
+ * envelope — keeping the statement fields when the object forms carry them.
59
+ * Mirrors core.ParseKeyListDocument, so this CLI, the Go CLI and the browser
60
+ * cannot disagree about which files are key sets.
61
+ */
62
+ export function parseKeyListDocument(parsed: unknown): KeyListDocument {
22
63
  const root = (parsed as { data?: unknown })?.data ?? parsed;
23
- const entries = Array.isArray(root) ? root : (root as { keys?: unknown })?.keys;
64
+ if (Array.isArray(root)) return { keys: root as KeyEntry[] };
65
+ const entries = (root as { keys?: unknown })?.keys;
24
66
  if (!Array.isArray(entries)) {
25
67
  throw new Error('--keys file must be a JSON array of keys or a {"keys": [...]} object');
26
68
  }
27
- return entries as KeyEntry[];
69
+ return root as KeyListDocument;
70
+ }
71
+
72
+ /** Verdicts about a key list. Mirrors core.KeyListResult, plus the one the CLI
73
+ * adds (SIGNING_KEY_NOT_IN_LIST) because core never reaches it: VerifyKeyList
74
+ * is told which key to trust, and this is the case where there is none to offer. */
75
+ export const KEY_LIST_VALID = "VALID";
76
+ export const KEY_LIST_MALFORMED = "MALFORMED_KEY_LIST";
77
+ export const KEY_LIST_SIGNING_KEY_UNUSABLE = "KEY_LIST_SIGNING_KEY_UNUSABLE";
78
+ export const KEY_LIST_INVALID_SIGNATURE = "INVALID_KEY_LIST_SIGNATURE";
79
+ export const KEY_LIST_STALE = "KEY_LIST_STALE";
80
+ export const KEY_LIST_SIGNING_KEY_ABSENT = "SIGNING_KEY_NOT_IN_LIST";
81
+
82
+ /**
83
+ * What a run learned about where the key statuses and dates came from.
84
+ * Mirrors keyListEvidence in cmd/cli/keys.go: it is not a pass/fail input to
85
+ * certificate verification, it qualifies one. An auditor reading
86
+ * KEY_COMPROMISED needs to know whether that word arrived as evidence they can
87
+ * keep or as JSON from an endpoint trusted for one TCP connection.
88
+ */
89
+ export interface KeyListEvidence {
90
+ /** Whether the file carried a signed statement at all. */
91
+ present: boolean;
92
+ result?: string;
93
+ /** The trusted key was taken from the list itself — trust-on-first-use, not proof. */
94
+ selfSigned: boolean;
95
+ /** statement_expires_at, normalized to the form Go prints. */
96
+ expiresAt?: string;
97
+ }
98
+
99
+ /** The whole-second RFC 3339 form every published key date uses
100
+ * (core.KeyTimeFormat). Entry dates are validated against it strictly, as
101
+ * core.KeyEntry.PublicKeyInfo does, so a date Go refuses is refused here. */
102
+ const KEY_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
103
+
104
+ function requireKeyTime(keyId: string, field: string, value: unknown): void {
105
+ if (typeof value !== "string" || !KEY_TIME_RE.test(value)) {
106
+ throw new Error(`key ${keyId}: ${field} is not YYYY-MM-DDTHH:MM:SSZ`);
107
+ }
108
+ }
109
+
110
+ function requireHex(field: string, value: unknown, bytes: number): Uint8Array {
111
+ if (typeof value !== "string" || value.length !== bytes * 2 || !/^[0-9a-fA-F]+$/.test(value)) {
112
+ throw new Error(`key list: ${field} is not ${bytes} bytes of hex`);
113
+ }
114
+ return hexToBytes(value);
115
+ }
116
+
117
+ /**
118
+ * Check a key-list statement the way cmd/cli/keys.go does, so four verifiers
119
+ * agree: the trusted key is looked up IN THE LIST ITSELF (trust-on-first-use,
120
+ * and selfSigned says so), then core.VerifyKeyList's order — the list parses
121
+ * and has no duplicate, the signing key is usable at `now` under ADR-017's
122
+ * table, the signature verifies over the published entries, and only then
123
+ * freshness. A stale verdict is never returned for a signature never tested.
124
+ *
125
+ * THROWS where the Go CLI exits 2: an entry that does not parse, a key_id that
126
+ * is not sha256(public_key), or a statement whose fields will not read. Someone
127
+ * signed something the reader cannot read, and continuing as though the file
128
+ * were merely unsigned would throw that fact away.
129
+ */
130
+ export async function verifyKeyList(
131
+ crypto: CryptoOps,
132
+ doc: KeyListDocument,
133
+ now: Date,
134
+ ): Promise<KeyListEvidence> {
135
+ if (doc.keys.length === 0) throw new Error("keys file contains no keys");
136
+ const signed = doc.signature !== undefined && doc.signature !== "";
137
+ const keys = new Map<string, Awaited<ReturnType<typeof publicKeyFromHex>>>();
138
+ let duplicate = false;
139
+ for (const e of doc.keys) {
140
+ if (typeof e.public_key !== "string") throw new Error(`key ${e.key_id}: public_key is not a string`);
141
+ const pki = await publicKeyFromHex(crypto, e.public_key, {
142
+ keyStatus: e.key_status,
143
+ notBefore: e.not_before,
144
+ notAfter: e.not_after ?? undefined,
145
+ compromisedFrom: e.compromised_from ?? undefined,
146
+ });
147
+ // The strict Go loader rules — key_id is sha256(public_key), dates are
148
+ // whole-second UTC — apply to a SIGNED list, whose statement signs the
149
+ // published strings. An unsigned list keeps this CLI's existing
150
+ // tolerance (an absent key_id is allowed, and the map is keyed on the
151
+ // derived id either way); tightening the unsigned path would refuse files
152
+ // it accepts today.
153
+ if (signed) {
154
+ if (e.key_id !== pki.keyId) {
155
+ throw new Error(`key_id ${e.key_id} does not match sha256(public_key) (${pki.keyId})`);
156
+ }
157
+ if (e.not_before != null && e.not_before !== "") requireKeyTime(e.key_id, "not_before", e.not_before);
158
+ if (e.not_after != null) requireKeyTime(e.key_id, "not_after", e.not_after);
159
+ if (e.compromised_from != null) requireKeyTime(e.key_id, "compromised_from", e.compromised_from);
160
+ }
161
+ if (keys.has(pki.keyId)) duplicate = true;
162
+ keys.set(pki.keyId, pki);
163
+ }
164
+
165
+ if (!signed) {
166
+ return { present: false, selfSigned: false };
167
+ }
168
+
169
+ // The statement's own fields. Refused rather than defaulted: a zero
170
+ // statement_expires_at would read as "expired", which is an answer.
171
+ requireHex("sth_root_hash", doc.sth_root_hash, 32);
172
+ const signature = requireHex("signature", doc.signature, 64);
173
+ if (typeof doc.key_id !== "string" || doc.key_id === "") throw new Error("key list: key_id is missing");
174
+ if (typeof doc.sth_tree_size !== "number" || !Number.isInteger(doc.sth_tree_size) || doc.sth_tree_size < 0) {
175
+ throw new Error("key list: sth_tree_size is not a non-negative integer");
176
+ }
177
+ const issued = formatTimestamp(doc.statement_issued_at);
178
+ const expires = formatTimestamp(doc.statement_expires_at);
179
+
180
+ const evidence: KeyListEvidence = { present: true, selfSigned: false, expiresAt: expires };
181
+ const signingKey = keys.get(doc.key_id);
182
+ if (signingKey === undefined) {
183
+ return { ...evidence, result: KEY_LIST_SIGNING_KEY_ABSENT };
184
+ }
185
+ evidence.selfSigned = true;
186
+
187
+ if (duplicate) return { ...evidence, result: KEY_LIST_MALFORMED };
188
+
189
+ const at = formatTimestamp(now.toISOString());
190
+ if (!keyIsUsable(evaluateKey(signingKey, Math.floor(Date.parse(at) / 1000)))) {
191
+ return { ...evidence, result: KEY_LIST_SIGNING_KEY_UNUSABLE };
192
+ }
193
+ const payload = buildKeyListPayload(doc as unknown as Record<string, unknown>);
194
+ if (!(await crypto.ed25519Verify(signingKey.keyBytes, payload, signature))) {
195
+ return { ...evidence, result: KEY_LIST_INVALID_SIGNATURE };
196
+ }
197
+ if (at < issued || at >= expires) {
198
+ return { ...evidence, result: KEY_LIST_STALE };
199
+ }
200
+ return { ...evidence, result: KEY_LIST_VALID };
28
201
  }
package/src/models.ts CHANGED
@@ -253,6 +253,27 @@ export interface BatchAttestationError {
253
253
  readonly subjectIdentifier: string;
254
254
  }
255
255
 
256
+ // ---------------------------------------------------------------------------
257
+ // Batch revocation types
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /** Result of POST /v1/certificates/batch-revoke. The server answers 200
261
+ * whether all, some or none of the ids were revoked; `errors` is the only
262
+ * signal of partial completion. The records in `certificates` never carry a
263
+ * `statusStatement` (revoking invalidates the cached one). */
264
+ export interface BatchRevokeResponse {
265
+ readonly certificates: readonly CertificateResponse[];
266
+ readonly errors: readonly BatchRevokeError[];
267
+ }
268
+
269
+ export interface BatchRevokeError {
270
+ /** Position in the submitted certificateIds list. */
271
+ readonly index: number;
272
+ readonly certificateId: string;
273
+ /** Code and message, e.g. "NOT_FOUND: certificate not found". */
274
+ readonly error: string;
275
+ }
276
+
256
277
  // ---------------------------------------------------------------------------
257
278
  // Certificate stats types
258
279
  // ---------------------------------------------------------------------------
@@ -305,6 +326,8 @@ export interface ApiKeyListItem {
305
326
  readonly id: string;
306
327
  readonly prefix: string;
307
328
  readonly role: ApiKeyRole;
329
+ /** The team the key is pinned to; undefined for an unpinned key. */
330
+ readonly teamId: string | undefined;
308
331
  readonly createdAt: Date;
309
332
  readonly revokedAt: Date | undefined;
310
333
  }
@@ -313,6 +336,8 @@ export interface ApiKeyResponse {
313
336
  readonly id: string;
314
337
  readonly prefix: string;
315
338
  readonly key: string;
339
+ /** The team the key is pinned to; undefined for an unpinned key. */
340
+ readonly teamId: string | undefined;
316
341
  readonly createdAt: Date;
317
342
  }
318
343
 
@@ -487,6 +512,21 @@ export function parseBatchAttestationResponse(d: Raw): BatchAttestationResponse
487
512
  };
488
513
  }
489
514
 
515
+ function parseBatchRevokeError(d: Raw): BatchRevokeError {
516
+ return {
517
+ index: d.index,
518
+ certificateId: d.certificate_id,
519
+ error: d.error,
520
+ };
521
+ }
522
+
523
+ export function parseBatchRevokeResponse(d: Raw): BatchRevokeResponse {
524
+ return {
525
+ certificates: (d.certificates ?? []).map(parseCertificateResponse),
526
+ errors: (d.errors ?? []).map(parseBatchRevokeError),
527
+ };
528
+ }
529
+
490
530
  function parseMonthlyCount(d: Raw): MonthlyCount {
491
531
  return {
492
532
  month: d.month,
@@ -533,6 +573,7 @@ export function parseApiKeyListItem(d: Raw): ApiKeyListItem {
533
573
  id: d.id,
534
574
  prefix: d.prefix,
535
575
  role: d.role as ApiKeyRole,
576
+ teamId: d.team_id ?? undefined,
536
577
  createdAt: parseDt(d.created_at),
537
578
  revokedAt: parseDtOpt(d.revoked_at),
538
579
  };
@@ -543,6 +584,7 @@ export function parseApiKeyResponse(d: Raw): ApiKeyResponse {
543
584
  id: d.id,
544
585
  prefix: d.prefix,
545
586
  key: d.key,
587
+ teamId: d.team_id ?? undefined,
546
588
  createdAt: parseDt(d.created_at),
547
589
  };
548
590
  }
package/src/verify.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  import type { CryptoOps } from "./crypto.js";
20
20
  import {
21
21
  CERTIFICATE_REVOKED,
22
+ UNSUPPORTED_ALGORITHM,
22
23
  UNSUPPORTED_FORMAT_VERSION,
23
24
  VerificationError,
24
25
  } from "./errors.js";
@@ -53,8 +54,22 @@ const FORMAT_VERSION_V4 = "4.0";
53
54
  const PAYLOAD_TYPE_ATTESTATION_V6 = "burnledger.attestation.v6";
54
55
  const PAYLOAD_TYPE_VERIFICATION_RECORD_V6 = "burnledger.verification_record.v6";
55
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";
56
64
  const FORMAT_VERSION_V3 = "3.0";
57
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
+
58
73
  /**
59
74
  * Every format this build can read, oldest first. Mirrors core.FormatVersions()
60
75
  * in the Go source; the cross-language conformance corpus carries a fixture per
@@ -70,6 +85,7 @@ export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
70
85
  FORMAT_VERSION_V4,
71
86
  FORMAT_VERSION_V5,
72
87
  FORMAT_VERSION_V6,
88
+ FORMAT_VERSION_V7,
73
89
  ]);
74
90
 
75
91
  /**
@@ -91,10 +107,13 @@ function checkFormatVersion(version: unknown): string {
91
107
  return version;
92
108
  }
93
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";
94
112
  const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
95
113
  // There is deliberately no verification payload type: v3 produces those facts
96
114
  // in the same enclave call that signs the certificate (ADR-016 §2).
97
115
  const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
116
+ const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
98
117
 
99
118
  export function hexToBytes(hex: string): Uint8Array {
100
119
  const len = hex.length >>> 1;
@@ -258,6 +277,14 @@ const USABLE_KEY_VERDICTS: ReadonlySet<string> = new Set([
258
277
  VALID_KEY_COMPROMISED_LATER,
259
278
  ]);
260
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
+
261
288
  /** Whether a key was authorized to sign at anchor time `t`.
262
289
  *
263
290
  * Returns `"VALID"` when the key clears. The anchor is seconds since the epoch
@@ -411,9 +438,23 @@ export async function verifyCertificate(
411
438
  // signed bytes from the version, so an unreadable version makes all of them
412
439
  // meaningless - and "unknown issuer key" would be the wrong thing to tell
413
440
  // someone holding a record that is merely newer than this library.
414
- checkFormatVersion((certificate as Record<string, unknown>).certificate_format_version);
441
+ const formatVersion = checkFormatVersion(
442
+ (certificate as Record<string, unknown>).certificate_format_version,
443
+ );
415
444
 
416
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
+ }
417
458
  const keyId = issuer.key_id as string;
418
459
 
419
460
  const pki = publicKeys.get(keyId);
@@ -592,6 +633,20 @@ export async function verifyTransparency(
592
633
  );
593
634
  }
594
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
+
595
650
  if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
596
651
  throw new VerificationError("merkle inclusion proof is invalid");
597
652
  }
@@ -851,20 +906,39 @@ function buildAttestationPayload(
851
906
  certFormatVersion: string,
852
907
  ): Uint8Array {
853
908
  const attestedAt = formatTimestamp(att.attested_at);
854
- const systems = (att.systems as Record<string, unknown>[]).map((s) => ({
855
- canonical_version: (s.canonical_version as string | null) ?? null,
856
- connector_type: s.connector_type as string,
857
- hash_scope: s.hash_scope as string,
858
- merkle_root: s.merkle_root
859
- ? toHex(s.merkle_root as string)
860
- : null,
861
- // The system's own observation time, not the envelope's.
862
- observed_at: formatTimestamp(s.observed_at),
863
- query_hash: toHex(s.query_hash as string),
864
- record_count: s.record_count as number,
865
- system_id: s.system_id as string,
866
- system_name: s.system_name as string,
867
- }));
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
+ });
868
942
 
869
943
  const payload = {
870
944
  attested_at: attestedAt,
@@ -876,9 +950,29 @@ function buildAttestationPayload(
876
950
  return canonicalJson(payload);
877
951
  }
878
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
+
879
972
  /** Domain separator for the record itself, by certificate format version. */
880
973
  function certificatePayloadType(version: string): string {
881
974
  checkFormatVersion(version);
975
+ if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
882
976
  if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
883
977
  if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_CERTIFICATE_V5;
884
978
  if (version === FORMAT_VERSION_V4) return PAYLOAD_TYPE_CERTIFICATE_V4;
@@ -893,6 +987,7 @@ function certificatePayloadType(version: string): string {
893
987
  */
894
988
  function attestationPayloadType(version: string): string {
895
989
  checkFormatVersion(version);
990
+ if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_ATTESTATION_V7;
896
991
  if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_ATTESTATION_V6;
897
992
  if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_ATTESTATION_V5;
898
993
  return PAYLOAD_TYPE_ATTESTATION;
@@ -906,19 +1001,36 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
906
1001
  // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
907
1002
  // and a verification list joined only on the human-editable system_name,
908
1003
  // which made a partial deletion indistinguishable from a complete one.
909
- const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => ({
910
- attested_at: formatTimestamp(s.attested_at),
911
- attested_count: s.attested_count as number,
912
- canonical_version: (s.canonical_version as string | null) ?? null,
913
- connector_type: s.connector_type as string,
914
- hash_scope: s.hash_scope as string,
915
- merkle_root: s.merkle_root ? toHex(s.merkle_root as string) : null,
916
- query_hash: toHex(s.query_hash as string),
917
- system_id: s.system_id as string,
918
- system_name: s.system_name as string,
919
- verified_at: formatTimestamp(s.verified_at),
920
- verified_count: s.verified_count as number,
921
- }));
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
+ });
922
1034
 
923
1035
  // The attestation block carries no system list of its own: the merged list
924
1036
  // above is a superset of it. verification_signature is gone entirely.
@@ -931,11 +1043,11 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
931
1043
  // Fields added after v3 are gated on the certificate's OWN version. A v3
932
1044
  // certificate must reconstruct to the same bytes forever; reading these
933
1045
  // unconditionally would break every certificate already issued.
934
- const version = cert.certificate_format_version as string;
935
1046
  // v6 carries v5's shape exactly, so it takes every gate v5 takes. Naming
936
1047
  // these "Plus" rather than testing equality at each use is what stops a new
937
1048
  // version from silently missing one.
938
- const isV5Plus = version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6;
1049
+ const isV5Plus =
1050
+ version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6 || isV7;
939
1051
  const isV4Plus = version === FORMAT_VERSION_V4 || isV5Plus;
940
1052
 
941
1053
  const issuerObj: Record<string, unknown> = {
@@ -943,6 +1055,12 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
943
1055
  name: issuer.name as string,
944
1056
  public_key: toHex(issuer.public_key as string),
945
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
+ }
946
1064
  if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
947
1065
  issuerObj.legal_entity = issuer.legal_entity;
948
1066
  }
@@ -1021,16 +1139,37 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
1021
1139
  observed_at: s.attested_at,
1022
1140
  merkle_root: s.merkle_root,
1023
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,
1024
1147
  }));
1025
1148
  }
1026
1149
 
1027
- function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
1028
- const payload = {
1029
- 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,
1030
1168
  root_hash: toHex(head.root_hash as string),
1031
1169
  timestamp: formatTimestamp(head.timestamp),
1032
1170
  tree_size: head.tree_size as number,
1033
1171
  };
1172
+ if (named) payload.log_id = logId;
1034
1173
  return canonicalJson(payload);
1035
1174
  }
1036
1175
 
@@ -1218,6 +1357,40 @@ export function buildCertificateStatusPayload(
1218
1357
  return canonicalJson(payload);
1219
1358
  }
1220
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
+
1221
1394
  /** The verdict when a certificate verified but nothing said whether it was revoked. */
1222
1395
  export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
1223
1396