burnledger 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cjs/customer-keys.d.ts +74 -0
  2. package/dist/cjs/customer-keys.d.ts.map +1 -0
  3. package/dist/cjs/customer-keys.js +139 -0
  4. package/dist/cjs/customer-keys.js.map +1 -0
  5. package/dist/cjs/index.d.ts +2 -0
  6. package/dist/cjs/index.d.ts.map +1 -1
  7. package/dist/cjs/index.js +11 -1
  8. package/dist/cjs/index.js.map +1 -1
  9. package/dist/cjs/verify.d.ts +4 -0
  10. package/dist/cjs/verify.d.ts.map +1 -1
  11. package/dist/cjs/verify.js +73 -0
  12. package/dist/cjs/verify.js.map +1 -1
  13. package/dist/cjs/web-verifier.d.ts +13 -0
  14. package/dist/cjs/web-verifier.d.ts.map +1 -1
  15. package/dist/cjs/web-verifier.js +2 -1
  16. package/dist/cjs/web-verifier.js.map +1 -1
  17. package/dist/esm/cli.d.ts.map +1 -1
  18. package/dist/esm/cli.js +71 -2
  19. package/dist/esm/cli.js.map +1 -1
  20. package/dist/esm/customer-keys.d.ts +74 -0
  21. package/dist/esm/customer-keys.d.ts.map +1 -0
  22. package/dist/esm/customer-keys.js +130 -0
  23. package/dist/esm/customer-keys.js.map +1 -0
  24. package/dist/esm/index.d.ts +2 -0
  25. package/dist/esm/index.d.ts.map +1 -1
  26. package/dist/esm/index.js +1 -0
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/verify.d.ts +4 -0
  29. package/dist/esm/verify.d.ts.map +1 -1
  30. package/dist/esm/verify.js +73 -1
  31. package/dist/esm/verify.js.map +1 -1
  32. package/dist/esm/web-verifier.d.ts +13 -0
  33. package/dist/esm/web-verifier.d.ts.map +1 -1
  34. package/dist/esm/web-verifier.js +13 -1
  35. package/dist/esm/web-verifier.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/cli.ts +85 -1
  38. package/src/customer-keys.ts +180 -0
  39. package/src/index.ts +13 -0
  40. package/src/verify.ts +84 -1
  41. package/src/web-verifier.ts +14 -0
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  publicKeyFromHex,
13
13
  verifyCertificateWithStatus,
14
14
  verifyTransparency,
15
+ KNOWN_FORMAT_VERSIONS,
15
16
  VALID_REVOCATION_UNKNOWN,
16
17
  } from "./verify.js";
17
18
  import {
@@ -288,12 +289,92 @@ const REVOCATION_UNKNOWN_ONLINE =
288
289
  * enclave measurement was unattributable when the signature covers it.
289
290
  * scripts/check-pcr0-signed-parity.py now pins this list to the Go function.
290
291
  */
291
- const ENCLAVE_PCR0_SIGNED_IN: readonly string[] = Object.freeze(["5.0", "6.0", "7.0", "8.0"]);
292
+ const ENCLAVE_PCR0_SIGNED_IN: readonly string[] = Object.freeze(["5.0", "6.0", "7.0", "8.0", "9.0"]);
292
293
 
293
294
  function signatureCoversEnclavePcr0(version: string): boolean {
294
295
  return ENCLAVE_PCR0_SIGNED_IN.includes(version);
295
296
  }
296
297
 
298
+ /** Follows a system that attested zero records. The same words in all three CLIs. */
299
+ const MATCHED_NOTHING_NOTE =
300
+ " — the query matched nothing here, so this record is no evidence " +
301
+ "about this system; if it should hold the subject, its query is wrong";
302
+
303
+ /**
304
+ * Each system's own counts. The totals hide a system that matched nothing: 11
305
+ * records across two systems read the same whether both held some or one held
306
+ * all eleven and the other's query found nothing. A zero is a legitimate answer
307
+ * — the subject was simply not there — so it is stated, not refused. But a
308
+ * query that CANNOT match (a wrong column, a term query on an analyzed field)
309
+ * also reads zero, and then the record is no evidence about that system at
310
+ * all. Only the reader knows which, so the line says both.
311
+ */
312
+ function pushRecordCounts(L: string[], sys: Record<string, unknown>[]): void {
313
+ for (const s of sys) {
314
+ const attested = s.attested_count as number;
315
+ L.push(
316
+ ` Records [${s.system_name as string}]: ${attested} at attestation, ` +
317
+ `${s.verified_count as number} after deletion` +
318
+ (attested === 0 ? MATCHED_NOTHING_NOTE : ""),
319
+ );
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Per system: did the enclave check it against a customer-signed registration,
325
+ * and under which key?
326
+ *
327
+ * This line is the WHOLE POINT of the field. ADR-025 §2: the enclave cannot
328
+ * refuse a second enrollment for a team — that needs durable state — so a parent
329
+ * can enroll a key of its own. What stops that being useful is that the record
330
+ * names the key id and the customer sees a key they do not hold. A verifier that
331
+ * reads the field and never shows it removes the only place that happens.
332
+ *
333
+ * Absence is stated rather than left silent. Records issued before 9.0 carry no
334
+ * field, and ADR-025 is explicit that their absence means "issued before this
335
+ * existed", never a pass — a blank where a control should be is exactly how a
336
+ * reader concludes the control ran.
337
+ */
338
+ function pushRegistrationEvidence(L: string[], cert: Record<string, unknown>): void {
339
+ const version = cert.certificate_format_version as string;
340
+ if (!KNOWN_FORMAT_VERSIONS.includes(version) || !formatAtLeastV9(version)) {
341
+ L.push(
342
+ ` Registration: not stated (format ${version} predates the field; ` +
343
+ "its absence is not a pass)",
344
+ );
345
+ return;
346
+ }
347
+ for (const sys of (cert.systems as Record<string, unknown>[]) ?? []) {
348
+ const name = sys.system_name as string;
349
+ if (sys.authorization === "certified") {
350
+ L.push(
351
+ ` Registration [${name}]: certified under ${sys.customer_key_id as string} — ` +
352
+ "the enclave checked this system against a customer-signed registration. " +
353
+ "If that key id is not yours, someone else authorized this verification.",
354
+ );
355
+ } else if (sys.authorization === "none") {
356
+ L.push(
357
+ ` Registration [${name}]: none — this verification was NOT checked against ` +
358
+ "a customer-signed registration.",
359
+ );
360
+ } else {
361
+ // Unreachable through verifyCertificate, which rebuilds the payload and
362
+ // refuses an unrecognized value. Printed rather than skipped because a
363
+ // caller that formatted a record it never verified must not be handed a
364
+ // blank where a control belongs.
365
+ L.push(
366
+ ` Registration [${name}]: unreadable (${JSON.stringify(sys.authorization ?? null)} ` +
367
+ "is not a value this build knows)",
368
+ );
369
+ }
370
+ }
371
+ }
372
+
373
+ /** Mirrors core.FormatAtLeast for 9.0, by POSITION: "10.0" sorts before "7.0". */
374
+ function formatAtLeastV9(version: string): boolean {
375
+ return KNOWN_FORMAT_VERSIONS.indexOf(version) >= KNOWN_FORMAT_VERSIONS.indexOf("9.0");
376
+ }
377
+
297
378
  /** Byte fields arrive as number arrays (Go [N]byte) or strings; show hex. */
298
379
  function displayHash(v: unknown): string {
299
380
  if (Array.isArray(v)) {
@@ -364,6 +445,7 @@ export function formatOutput(
364
445
  L.push(` Systems: ${sys.length} (${sys.map((s) => `${s.connector_type} [${s.hash_scope}]`).join(", ")})`);
365
446
  L.push(` Records before: ${sys.reduce((n, s) => n + (s.attested_count as number), 0)}`);
366
447
  L.push(` Records after: ${sys.reduce((n, s) => n + (s.verified_count as number), 0)}`);
448
+ pushRecordCounts(L, sys);
367
449
  L.push(` Committed: ${att.attested_at as string}`);
368
450
  L.push(` Verified: ${sys.reduce((t, s) => {
369
451
  const v = s.verified_at as string;
@@ -393,6 +475,8 @@ export function formatOutput(
393
475
  }
394
476
  }
395
477
 
478
+ pushRegistrationEvidence(L, cert);
479
+
396
480
  if (transparency === "INCLUDED") {
397
481
  const t = cert.transparency as Record<string, unknown>;
398
482
  L.push(` Transparency log: entry #${t.entry_index as number}, inclusion proof VALID`);
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Customer key groups (ADR-025 §2), computed on the customer's own machine.
3
+ *
4
+ * WHY THIS IS CLIENT-SIDE AND NOT AN API CALL. The private halves never leave
5
+ * the customer's machines -- that is the entire point of the mechanism. What a
6
+ * customer needs from us is not a service that computes their key id, but the
7
+ * ability to compute it themselves and compare it against the id the enclave
8
+ * signed. An id returned over HTTP would be an id the parent could choose, and
9
+ * the one comparison this design rests on would be checking our answer against
10
+ * our answer.
11
+ *
12
+ * WHY IT MUST MATCH GO EXACTLY. `customer_key_id` is derived independently by
13
+ * the enclave and by the customer. If the two derivations disagree by so much
14
+ * as a JSON separator, enrollment SUCCEEDS, the enclave signs a statement
15
+ * naming an id, and the customer's comparison fails -- indistinguishable, from
16
+ * where they are standing, from a parent having substituted their key. Every
17
+ * expectation in tests/customer-key-parity.test.ts comes from a corpus the Go
18
+ * generator writes by calling core itself.
19
+ */
20
+
21
+ import { canonicalJson } from "./verify.js";
22
+ import type { CryptoOps } from "./crypto.js";
23
+
24
+ /**
25
+ * Mirrors core.CustomerKeyIDPrefix. It distinguishes a customer's key id from
26
+ * an issuer key id, which are otherwise the same shape and would be confusable
27
+ * in a log line at exactly the wrong moment.
28
+ */
29
+ export const CUSTOMER_KEY_ID_PREFIX = "cust_k_";
30
+
31
+ /**
32
+ * Mirrors core.payloadTypeCustomerKeyGroup. Unexported in Go because it is
33
+ * never signed or transmitted -- only hashed -- but it IS inside the hash, so a
34
+ * reimplementation that omits it computes a different id for every group.
35
+ */
36
+ const PAYLOAD_TYPE_CUSTOMER_KEY_GROUP = "burnledger.customer_key_group.v1";
37
+
38
+ export const PAYLOAD_TYPE_KEY_ROTATION_REQUEST = "burnledger.key_rotation_request.v1";
39
+
40
+ /** Mirrors core.MaxCustomerKeyGroupMembers. */
41
+ export const MAX_CUSTOMER_KEY_GROUP_MEMBERS = 16;
42
+
43
+ const ED25519_PUBLIC_KEY_SIZE = 32;
44
+
45
+ /** A group that cannot be enrolled, for a reason stated in the message. */
46
+ export class InvalidKeyGroupError extends Error {
47
+ constructor(message: string) {
48
+ super(message);
49
+ this.name = "InvalidKeyGroupError";
50
+ }
51
+ }
52
+
53
+ export interface CustomerKeyGroup {
54
+ /** How many distinct members must sign. 1 <= threshold <= members.length. */
55
+ threshold: number;
56
+ /**
57
+ * The enrolled public keys. Order is not significant: the id sorts the
58
+ * fingerprints, so two customers who list the same keys in a different order
59
+ * enrol the same group rather than two indistinguishable ones.
60
+ */
61
+ members: Uint8Array[];
62
+ }
63
+
64
+ function toHex(bytes: Uint8Array): string {
65
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
66
+ }
67
+
68
+ /** hex(SHA-256(public key)) -- a member's identity inside a group. */
69
+ export async function memberFingerprint(
70
+ crypto: CryptoOps,
71
+ publicKey: Uint8Array,
72
+ ): Promise<string> {
73
+ return toHex(await crypto.sha256(publicKey));
74
+ }
75
+
76
+ /** Throws InvalidKeyGroupError unless this group could ever be used. */
77
+ export async function validateKeyGroup(
78
+ crypto: CryptoOps,
79
+ group: CustomerKeyGroup,
80
+ ): Promise<void> {
81
+ if (group.members.length === 0) {
82
+ throw new InvalidKeyGroupError("a customer key group needs at least one member");
83
+ }
84
+ if (group.members.length > MAX_CUSTOMER_KEY_GROUP_MEMBERS) {
85
+ throw new InvalidKeyGroupError(
86
+ `a customer key group may have at most ${MAX_CUSTOMER_KEY_GROUP_MEMBERS} members, ` +
87
+ `got ${group.members.length}`,
88
+ );
89
+ }
90
+ if (
91
+ !Number.isInteger(group.threshold) ||
92
+ group.threshold < 1 ||
93
+ group.threshold > group.members.length
94
+ ) {
95
+ throw new InvalidKeyGroupError(
96
+ `threshold ${group.threshold} is not between 1 and the ${group.members.length} members`,
97
+ );
98
+ }
99
+ const seen = new Set<string>();
100
+ for (const member of group.members) {
101
+ if (member.length !== ED25519_PUBLIC_KEY_SIZE) {
102
+ throw new InvalidKeyGroupError(
103
+ `every member must be a ${ED25519_PUBLIC_KEY_SIZE}-byte Ed25519 key`,
104
+ );
105
+ }
106
+ const fingerprint = await memberFingerprint(crypto, member);
107
+ if (seen.has(fingerprint)) {
108
+ // A repeated member would let one holder satisfy a threshold meant to
109
+ // require several, which is the entire point of one.
110
+ throw new InvalidKeyGroupError("the same member key is listed twice");
111
+ }
112
+ seen.add(fingerprint);
113
+ }
114
+ }
115
+
116
+ /** The members' fingerprints, sorted, so the id does not depend on order. */
117
+ export async function memberFingerprints(
118
+ crypto: CryptoOps,
119
+ group: CustomerKeyGroup,
120
+ ): Promise<string[]> {
121
+ const out: string[] = [];
122
+ for (const member of group.members) {
123
+ out.push(await memberFingerprint(crypto, member));
124
+ }
125
+ return out.sort();
126
+ }
127
+
128
+ /**
129
+ * The id the enclave will compute for this group.
130
+ *
131
+ * Throws rather than returning an id for a group that could never be enrolled:
132
+ * an id for an unusable group is a value a caller would go on to compare
133
+ * against, and it would never match anything.
134
+ */
135
+ export async function customerKeyId(
136
+ crypto: CryptoOps,
137
+ group: CustomerKeyGroup,
138
+ ): Promise<string> {
139
+ await validateKeyGroup(crypto, group);
140
+ const document = {
141
+ member_key_ids: await memberFingerprints(crypto, group),
142
+ payload_type: PAYLOAD_TYPE_CUSTOMER_KEY_GROUP,
143
+ threshold: group.threshold,
144
+ };
145
+ return CUSTOMER_KEY_ID_PREFIX + toHex(await crypto.sha256(canonicalJson(document)));
146
+ }
147
+
148
+ /**
149
+ * The canonical bytes the OUTGOING group's members sign to authorise a rotation.
150
+ *
151
+ * This is the only payload in the scheme signed by the customer rather than by
152
+ * the enclave, so it is the only one where a signature captured elsewhere could
153
+ * be replayed into the mechanism -- hence its own payload type.
154
+ *
155
+ * Both ids are bound, not just the successor: a statement naming only the
156
+ * incoming key could be lifted onto a different predecessor, re-presenting the
157
+ * same signatures as though another group had approved it.
158
+ */
159
+ export function buildKeyRotationRequestPayload(request: {
160
+ assertedTeamId: string;
161
+ prevKeyId: string;
162
+ nextKeyId: string;
163
+ }): Uint8Array {
164
+ if (!request.prevKeyId || !request.nextKeyId) {
165
+ throw new Error("a rotation request names both the key being replaced and its replacement");
166
+ }
167
+ if (request.prevKeyId === request.nextKeyId) {
168
+ // A self-rotation is a cycle in the chain the enclave walks, and it walks
169
+ // without a visited set.
170
+ throw new Error(
171
+ "a rotation request cannot name the same key as both predecessor and successor",
172
+ );
173
+ }
174
+ return canonicalJson({
175
+ asserted_team_id: request.assertedTeamId,
176
+ next_key_id: request.nextKeyId,
177
+ payload_type: PAYLOAD_TYPE_KEY_ROTATION_REQUEST,
178
+ prev_key_id: request.prevKeyId,
179
+ });
180
+ }
package/src/index.ts CHANGED
@@ -165,3 +165,16 @@ export {
165
165
  verifyEnclaveConfigSealKey,
166
166
  } from "./enclave-seal.js";
167
167
  export type { SealOptions } from "./enclave-seal.js";
168
+
169
+ export {
170
+ CUSTOMER_KEY_ID_PREFIX,
171
+ InvalidKeyGroupError,
172
+ MAX_CUSTOMER_KEY_GROUP_MEMBERS,
173
+ PAYLOAD_TYPE_KEY_ROTATION_REQUEST,
174
+ buildKeyRotationRequestPayload,
175
+ customerKeyId,
176
+ memberFingerprint,
177
+ memberFingerprints,
178
+ validateKeyGroup,
179
+ } from "./customer-keys.js";
180
+ export type { CustomerKeyGroup } from "./customer-keys.js";
package/src/verify.ts CHANGED
@@ -73,6 +73,19 @@ const FORMAT_VERSION_V7 = "7.0";
73
73
  const PAYLOAD_TYPE_ATTESTATION_V8 = "burnledger.attestation.v8";
74
74
  const PAYLOAD_TYPE_VERIFICATION_RECORD_V8 = "burnledger.verification_record.v8";
75
75
  const FORMAT_VERSION_V8 = "8.0";
76
+ // v9 adds two per-system fields, authorization and customer_key_id: whether the
77
+ // enclave checked this system against a customer-signed registration
78
+ // certificate (ADR-025 §4), and under which customer key group if it did.
79
+ //
80
+ // The attestation gains NO field at v9. Its tag still moves, because the tag
81
+ // follows the record's version and the record's bytes changed — a v9
82
+ // attestation rebuilt under burnledger.attestation.v8 is a different preimage.
83
+ //
84
+ // READ-ONLY IN THIS BUILD, mirroring core, for the same runbook-invariant-5
85
+ // reason spelled out for v8 above.
86
+ const PAYLOAD_TYPE_ATTESTATION_V9 = "burnledger.attestation.v9";
87
+ const PAYLOAD_TYPE_VERIFICATION_RECORD_V9 = "burnledger.verification_record.v9";
88
+ const FORMAT_VERSION_V9 = "9.0";
76
89
  const FORMAT_VERSION_V3 = "3.0";
77
90
 
78
91
  /**
@@ -99,6 +112,7 @@ export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
99
112
  FORMAT_VERSION_V6,
100
113
  FORMAT_VERSION_V7,
101
114
  FORMAT_VERSION_V8,
115
+ FORMAT_VERSION_V9,
102
116
  ]);
103
117
 
104
118
  /**
@@ -165,6 +179,64 @@ function signatureCoversRecoverableState(version: string): boolean {
165
179
  return formatAtLeast(version, FORMAT_VERSION_V8);
166
180
  }
167
181
 
182
+ /**
183
+ * Does this format sign the per-system authorization and customer_key_id?
184
+ *
185
+ * v9 onward. `authorization` is always present on a v9 record; `customer_key_id`
186
+ * only when it is certified, and OMITTED rather than empty when it is not — a
187
+ * key id of "" would sign as a value, and a reader comparing against their own
188
+ * key id must not have to know that "" means "nobody".
189
+ */
190
+ function signatureCoversAuthorization(version: string): boolean {
191
+ return formatAtLeast(version, FORMAT_VERSION_V9);
192
+ }
193
+
194
+ /** The two values a v9 record's per-system authorization may take. */
195
+ const AUTHORIZATION_CERTIFIED = "certified";
196
+ const AUTHORIZATION_NONE = "none";
197
+
198
+ /**
199
+ * Read a v9 system's authorization and key id, refusing what core refuses.
200
+ *
201
+ * Port of checkAuthorization in core/payload.go. It runs on the VERIFY side
202
+ * too, and must: an unset or unrecognized value is not something to rebuild
203
+ * bytes from and report as a bad signature — the record is malformed. The two
204
+ * refused combinations are the ones that would be a lie rather than a shape
205
+ * error: certified naming nobody is ADR-025 §2's second-key attack with the
206
+ * evidence removed, and none naming somebody claims an authority that was
207
+ * never checked.
208
+ */
209
+ function requireAuthorization(
210
+ s: Record<string, unknown>,
211
+ systemName: unknown,
212
+ ): { authorization: string; customerKeyId: string } {
213
+ const a = s.authorization;
214
+ if (a !== AUTHORIZATION_CERTIFIED && a !== AUTHORIZATION_NONE) {
215
+ throw new VerificationError(
216
+ `system ${JSON.stringify(systemName)}: authorization is ${JSON.stringify(a ?? null)}, ` +
217
+ `which format ${FORMAT_VERSION_V9} requires to be one of certified, none`,
218
+ );
219
+ }
220
+ const raw = s.customer_key_id;
221
+ if (raw !== undefined && raw !== null && typeof raw !== "string") {
222
+ throw new VerificationError(
223
+ `system ${JSON.stringify(systemName)}: customer_key_id is not a string`,
224
+ );
225
+ }
226
+ const customerKeyId = typeof raw === "string" ? raw : "";
227
+ if (a === AUTHORIZATION_CERTIFIED && customerKeyId === "") {
228
+ throw new VerificationError(
229
+ `system ${JSON.stringify(systemName)}: authorization is certified but customer_key_id is empty`,
230
+ );
231
+ }
232
+ if (a === AUTHORIZATION_NONE && customerKeyId !== "") {
233
+ throw new VerificationError(
234
+ `system ${JSON.stringify(systemName)}: authorization is none but customer_key_id is set`,
235
+ );
236
+ }
237
+ return { authorization: a, customerKeyId };
238
+ }
239
+
168
240
  const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
169
241
  // The domain for a tree head that names its log. See buildTreeHeadPayload.
170
242
  const PAYLOAD_TYPE_TREE_HEAD_V7 = "burnledger.sth.v7";
@@ -739,7 +811,10 @@ export function issuanceBytes(certificate: Cert): Uint8Array {
739
811
  // RFC 8785 Canonical JSON
740
812
  // ---------------------------------------------------------------------------
741
813
 
742
- function canonicalJson(obj: unknown): Uint8Array {
814
+ /** Exported for tests/verify.test.ts, which keeps a second implementation of
815
+ * this and must be able to prove the two agree. Not re-exported from index.ts,
816
+ * so it stays off the published API. */
817
+ export function canonicalJson(obj: unknown): Uint8Array {
743
818
  return textToBytes(canonicalStringify(obj));
744
819
  }
745
820
 
@@ -1057,6 +1132,7 @@ function requireMeasured(
1057
1132
  /** Domain separator for the record itself, by certificate format version. */
1058
1133
  function certificatePayloadType(version: string): string {
1059
1134
  checkFormatVersion(version);
1135
+ if (version === FORMAT_VERSION_V9) return PAYLOAD_TYPE_VERIFICATION_RECORD_V9;
1060
1136
  if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_VERIFICATION_RECORD_V8;
1061
1137
  if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
1062
1138
  if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
@@ -1076,6 +1152,7 @@ function attestationPayloadType(version: string): string {
1076
1152
  // Equality here, deliberately: each format has its OWN separator, so this is
1077
1153
  // a lookup rather than a "this version onward" question. v3 and v4 share one
1078
1154
  // because their attestation bytes are identical.
1155
+ if (version === FORMAT_VERSION_V9) return PAYLOAD_TYPE_ATTESTATION_V9;
1079
1156
  if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_ATTESTATION_V8;
1080
1157
  if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_ATTESTATION_V7;
1081
1158
  if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_ATTESTATION_V6;
@@ -1094,6 +1171,7 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1094
1171
  const version = cert.certificate_format_version as string;
1095
1172
  const coversMeasured = signatureCoversMeasuredTransport(version);
1096
1173
  const coversRecoverable = signatureCoversRecoverableState(version);
1174
+ const coversAuthorization = signatureCoversAuthorization(version);
1097
1175
  const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => {
1098
1176
  const sys: Record<string, unknown> = {
1099
1177
  attested_at: formatTimestamp(s.attested_at),
@@ -1130,6 +1208,11 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1130
1208
  version,
1131
1209
  );
1132
1210
  }
1211
+ if (coversAuthorization) {
1212
+ const { authorization, customerKeyId } = requireAuthorization(s, s.system_name);
1213
+ sys.authorization = authorization;
1214
+ if (customerKeyId !== "") sys.customer_key_id = customerKeyId;
1215
+ }
1133
1216
  return sys;
1134
1217
  });
1135
1218
 
@@ -29,6 +29,7 @@ import {
29
29
  verifyCertificateWithStatus as _verifyCertificateWithStatus,
30
30
  verifyTransparency as _verifyTransparency,
31
31
  publicKeyFromHex as _publicKeyFromHex,
32
+ KNOWN_FORMAT_VERSIONS,
32
33
  } from "./verify.js";
33
34
  import type { PublicKeyInfo, PublicKeyOptions } from "./verify.js";
34
35
  import { parseKeyListDocument, verifyKeyList as _verifyKeyList } from "./keys.js";
@@ -87,6 +88,19 @@ export function checkStatementAnchor(
87
88
 
88
89
  export { ANCHOR_CONFIRMED, ANCHOR_INCONSISTENT, ANCHOR_UNVERIFIED };
89
90
 
91
+ /**
92
+ * Every record format this bundle can read, oldest first.
93
+ *
94
+ * The page needs it to answer "does this record's format predate a field?" —
95
+ * the registration row asks exactly that. Exported rather than restated in
96
+ * verify.js, because a hand-kept copy of this list going stale is the defect
97
+ * scripts/check-pcr0-signed-parity.py exists because of.
98
+ *
99
+ * ORDER IS LOAD-BEARING: "this format or newer" is answered by POSITION, never
100
+ * by comparing the strings, because "10.0" sorts before "7.0".
101
+ */
102
+ export { KNOWN_FORMAT_VERSIONS };
103
+
90
104
  /** Verify the transparency proof embedded in a certificate. */
91
105
  export function verifyTransparency(
92
106
  certificate: Cert,