burnledger 0.6.1 → 0.8.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.
package/src/verify.ts CHANGED
@@ -61,6 +61,31 @@ const FORMAT_VERSION_V6 = "6.0";
61
61
  const PAYLOAD_TYPE_ATTESTATION_V7 = "burnledger.attestation.v7";
62
62
  const PAYLOAD_TYPE_VERIFICATION_RECORD_V7 = "burnledger.verification_record.v7";
63
63
  const FORMAT_VERSION_V7 = "7.0";
64
+ // v8 adds one per-system field, recoverable_state: whether anything checked for
65
+ // a restorable copy of what the record certifies gone. Its tags move for the
66
+ // reason every earlier version's did, and this time the bytes under them differ
67
+ // by that field. Everything v7 signs, v8 still signs.
68
+ //
69
+ // READ-ONLY IN THIS BUILD, mirroring core: nothing here issues a v8 record. A
70
+ // verifier that can read a format has to ship BEFORE anything signs one, or a
71
+ // genuine record reaches a holder whose SDK rebuilds it under the wrong domain
72
+ // and reports a forgery (runbook invariant 5).
73
+ const PAYLOAD_TYPE_ATTESTATION_V8 = "burnledger.attestation.v8";
74
+ const PAYLOAD_TYPE_VERIFICATION_RECORD_V8 = "burnledger.verification_record.v8";
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";
64
89
  const FORMAT_VERSION_V3 = "3.0";
65
90
 
66
91
  /**
@@ -86,6 +111,8 @@ export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
86
111
  FORMAT_VERSION_V5,
87
112
  FORMAT_VERSION_V6,
88
113
  FORMAT_VERSION_V7,
114
+ FORMAT_VERSION_V8,
115
+ FORMAT_VERSION_V9,
89
116
  ]);
90
117
 
91
118
  /**
@@ -106,6 +133,110 @@ function checkFormatVersion(version: unknown): string {
106
133
  }
107
134
  return version;
108
135
  }
136
+
137
+ /**
138
+ * Is `version` the same format as `floor`, or a later one?
139
+ *
140
+ * Ordered by position in KNOWN_FORMAT_VERSIONS, never by string comparison:
141
+ * "10.0" sorts before "9.0". Port of core.formatAtLeast, and it exists for the
142
+ * reason the Go one does — four gates there were written as `v === "7.0"`, read
143
+ * by everyone as "7.0 and later", and behaved as "7.0 only". Three were live
144
+ * defects and one silently downgraded a security property the day v8 was
145
+ * defined.
146
+ *
147
+ * USE THIS, not equality, whenever the question is "does this format sign X".
148
+ * An unknown version answers false for every floor, which is safe only because
149
+ * checkFormatVersion has already refused it before any payload is rebuilt.
150
+ */
151
+ function formatAtLeast(version: string, floor: string): boolean {
152
+ const vi = KNOWN_FORMAT_VERSIONS.indexOf(version);
153
+ const fi = KNOWN_FORMAT_VERSIONS.indexOf(floor);
154
+ return vi >= 0 && fi >= 0 && vi >= fi;
155
+ }
156
+
157
+ /** Ports of the core.SignatureCovers* predicates, one per signed field group. */
158
+ function signatureCoversEnclavePcr0(version: string): boolean {
159
+ return formatAtLeast(version, FORMAT_VERSION_V5);
160
+ }
161
+
162
+ function signatureCoversAlgorithm(version: string): boolean {
163
+ return formatAtLeast(version, FORMAT_VERSION_V7);
164
+ }
165
+
166
+ function signatureCoversMeasuredTransport(version: string): boolean {
167
+ return formatAtLeast(version, FORMAT_VERSION_V7);
168
+ }
169
+
170
+ /**
171
+ * Does this format sign the per-system recoverable_state?
172
+ *
173
+ * v8 only, and everything after it. Emitting the field for v7 or older would
174
+ * rebuild bytes no signer ever produced and reject every record already issued;
175
+ * omitting it for v8 rebuilds a v7-shaped subset and reports a genuine v8
176
+ * record as forged.
177
+ */
178
+ function signatureCoversRecoverableState(version: string): boolean {
179
+ return formatAtLeast(version, FORMAT_VERSION_V8);
180
+ }
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
+
109
240
  const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
110
241
  // The domain for a tree head that names its log. See buildTreeHeadPayload.
111
242
  const PAYLOAD_TYPE_TREE_HEAD_V7 = "burnledger.sth.v7";
@@ -446,8 +577,9 @@ export async function verifyCertificate(
446
577
  // Step 0b: can this SDK check the algorithm the record names? Asked before
447
578
  // any signature, because verifying an Ed25519 signature over a record that
448
579
  // 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) {
580
+ // Below v7 no record names one, so there is nothing to check; from v7 on
581
+ // every record does, which is why this asks "v7 or later" rather than "v7".
582
+ if (signatureCoversAlgorithm(formatVersion) && issuer.algorithm !== ALGORITHM_ED25519) {
451
583
  throw new VerificationError(
452
584
  `record names signature algorithm ${JSON.stringify(issuer.algorithm)}; ` +
453
585
  `this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
@@ -906,10 +1038,16 @@ function buildAttestationPayload(
906
1038
  certFormatVersion: string,
907
1039
  ): Uint8Array {
908
1040
  const attestedAt = formatTimestamp(att.attested_at);
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;
1041
+ // v7 signs two per-system measurements v6 leaves on a mutable row, and v8
1042
+ // adds recoverable_state beside them. Both gated on the record's OWN version:
1043
+ // emitting a field for an older format would rebuild bytes no signer ever
1044
+ // produced and reject every record already issued.
1045
+ //
1046
+ // Predicates, not equalities. v8 signs everything v7 signs, so a `=== "7.0"`
1047
+ // here would drop read_only_enforcement and transport_security out of a v8
1048
+ // payload and call every genuine v8 record a forgery.
1049
+ const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
1050
+ const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
913
1051
  const systems = (att.systems as Record<string, unknown>[]).map((s) => {
914
1052
  const sys: Record<string, unknown> = {
915
1053
  canonical_version: (s.canonical_version as string | null) ?? null,
@@ -925,16 +1063,26 @@ function buildAttestationPayload(
925
1063
  system_id: s.system_id as string,
926
1064
  system_name: s.system_name as string,
927
1065
  };
928
- if (isV7) {
1066
+ if (coversMeasured) {
929
1067
  sys.read_only_enforcement = requireMeasured(
930
1068
  s.read_only_enforcement,
931
1069
  "read_only_enforcement",
932
1070
  s.system_name,
1071
+ certFormatVersion,
933
1072
  );
934
1073
  sys.transport_security = requireMeasured(
935
1074
  s.transport_security,
936
1075
  "transport_security",
937
1076
  s.system_name,
1077
+ certFormatVersion,
1078
+ );
1079
+ }
1080
+ if (coversRecoverable) {
1081
+ sys.recoverable_state = requireMeasured(
1082
+ s.recoverable_state,
1083
+ "recoverable_state",
1084
+ s.system_name,
1085
+ certFormatVersion,
938
1086
  );
939
1087
  }
940
1088
  return sys;
@@ -951,18 +1099,27 @@ function buildAttestationPayload(
951
1099
  }
952
1100
 
953
1101
  /**
954
- * Read a v7 measured field that MUST be a non-empty string.
1102
+ * Read a signed measured field that MUST be a non-empty string.
955
1103
  *
956
1104
  * A missing or non-string value cannot be turned into `""` and canonicalized:
957
1105
  * the signer never emits an empty measurement, so an empty one here would
958
1106
  * rebuild bytes no signature covers and be reported as forgery. Refusing with a
959
1107
  * document-shape message says the true thing — this record is malformed, not
960
1108
  * this record is fake.
1109
+ *
1110
+ * The version is passed in rather than written into the message: it was the
1111
+ * literal "7.0" here, which would have told the holder of an incomplete v8
1112
+ * record that their v8 document was a v7 one.
961
1113
  */
962
- function requireMeasured(value: unknown, field: string, systemName: unknown): string {
1114
+ function requireMeasured(
1115
+ value: unknown,
1116
+ field: string,
1117
+ systemName: unknown,
1118
+ version: string,
1119
+ ): string {
963
1120
  if (typeof value !== "string" || value === "") {
964
1121
  throw new VerificationError(
965
- `system ${JSON.stringify(systemName)}: ${field} is missing from a 7.0 record, ` +
1122
+ `system ${JSON.stringify(systemName)}: ${field} is missing from a ${version} record, ` +
966
1123
  "which signs it; the document is incomplete rather than unverifiable",
967
1124
  );
968
1125
  }
@@ -972,6 +1129,8 @@ function requireMeasured(value: unknown, field: string, systemName: unknown): st
972
1129
  /** Domain separator for the record itself, by certificate format version. */
973
1130
  function certificatePayloadType(version: string): string {
974
1131
  checkFormatVersion(version);
1132
+ if (version === FORMAT_VERSION_V9) return PAYLOAD_TYPE_VERIFICATION_RECORD_V9;
1133
+ if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_VERIFICATION_RECORD_V8;
975
1134
  if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
976
1135
  if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
977
1136
  if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_CERTIFICATE_V5;
@@ -987,6 +1146,11 @@ function certificatePayloadType(version: string): string {
987
1146
  */
988
1147
  function attestationPayloadType(version: string): string {
989
1148
  checkFormatVersion(version);
1149
+ // Equality here, deliberately: each format has its OWN separator, so this is
1150
+ // a lookup rather than a "this version onward" question. v3 and v4 share one
1151
+ // because their attestation bytes are identical.
1152
+ if (version === FORMAT_VERSION_V9) return PAYLOAD_TYPE_ATTESTATION_V9;
1153
+ if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_ATTESTATION_V8;
990
1154
  if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_ATTESTATION_V7;
991
1155
  if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_ATTESTATION_V6;
992
1156
  if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_ATTESTATION_V5;
@@ -1002,7 +1166,9 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1002
1166
  // and a verification list joined only on the human-editable system_name,
1003
1167
  // which made a partial deletion indistinguishable from a complete one.
1004
1168
  const version = cert.certificate_format_version as string;
1005
- const isV7 = version === FORMAT_VERSION_V7;
1169
+ const coversMeasured = signatureCoversMeasuredTransport(version);
1170
+ const coversRecoverable = signatureCoversRecoverableState(version);
1171
+ const coversAuthorization = signatureCoversAuthorization(version);
1006
1172
  const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => {
1007
1173
  const sys: Record<string, unknown> = {
1008
1174
  attested_at: formatTimestamp(s.attested_at),
@@ -1017,18 +1183,33 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1017
1183
  verified_at: formatTimestamp(s.verified_at),
1018
1184
  verified_count: s.verified_count as number,
1019
1185
  };
1020
- if (isV7) {
1186
+ if (coversMeasured) {
1021
1187
  sys.read_only_enforcement = requireMeasured(
1022
1188
  s.read_only_enforcement,
1023
1189
  "read_only_enforcement",
1024
1190
  s.system_name,
1191
+ version,
1025
1192
  );
1026
1193
  sys.transport_security = requireMeasured(
1027
1194
  s.transport_security,
1028
1195
  "transport_security",
1029
1196
  s.system_name,
1197
+ version,
1198
+ );
1199
+ }
1200
+ if (coversRecoverable) {
1201
+ sys.recoverable_state = requireMeasured(
1202
+ s.recoverable_state,
1203
+ "recoverable_state",
1204
+ s.system_name,
1205
+ version,
1030
1206
  );
1031
1207
  }
1208
+ if (coversAuthorization) {
1209
+ const { authorization, customerKeyId } = requireAuthorization(s, s.system_name);
1210
+ sys.authorization = authorization;
1211
+ if (customerKeyId !== "") sys.customer_key_id = customerKeyId;
1212
+ }
1032
1213
  return sys;
1033
1214
  });
1034
1215
 
@@ -1043,12 +1224,12 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1043
1224
  // Fields added after v3 are gated on the certificate's OWN version. A v3
1044
1225
  // certificate must reconstruct to the same bytes forever; reading these
1045
1226
  // unconditionally would break every certificate already issued.
1046
- // v6 carries v5's shape exactly, so it takes every gate v5 takes. Naming
1047
- // these "Plus" rather than testing equality at each use is what stops a new
1048
- // version from silently missing one.
1049
- const isV5Plus =
1050
- version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6 || isV7;
1051
- const isV4Plus = version === FORMAT_VERSION_V4 || isV5Plus;
1227
+ // v6 carries v5's shape exactly, so it takes every gate v5 takes. Asking
1228
+ // "this version onward" rather than listing versions at each use is what
1229
+ // stops a new format from silently missing one: these were a chain of
1230
+ // equalities that had to be extended by hand for v6, then v7, then v8.
1231
+ const isV5Plus = formatAtLeast(version, FORMAT_VERSION_V5);
1232
+ const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
1052
1233
 
1053
1234
  const issuerObj: Record<string, unknown> = {
1054
1235
  key_id: issuer.key_id as string,
@@ -1058,15 +1239,24 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1058
1239
  // v7 signs the algorithm, and does so unconditionally: unlike legal_entity
1059
1240
  // and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
1060
1241
  // so a v7 record missing it is malformed rather than merely sparse.
1061
- if (isV7) {
1062
- issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer");
1242
+ if (signatureCoversAlgorithm(version)) {
1243
+ issuerObj.algorithm = requireMeasured(
1244
+ issuer.algorithm,
1245
+ "issuer.algorithm",
1246
+ "issuer",
1247
+ version,
1248
+ );
1063
1249
  }
1064
1250
  if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
1065
1251
  issuerObj.legal_entity = issuer.legal_entity;
1066
1252
  }
1067
1253
  // Signed from v5, and omitted when absent or empty: a build with no
1068
1254
  // measurement signs none, and "" is a value a reader could mistake for one.
1069
- if (isV5Plus && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
1255
+ if (
1256
+ signatureCoversEnclavePcr0(version) &&
1257
+ typeof issuer.enclave_pcr0 === "string" &&
1258
+ issuer.enclave_pcr0 !== ""
1259
+ ) {
1070
1260
  issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
1071
1261
  }
1072
1262
 
@@ -1144,6 +1334,10 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
1144
1334
  // reads them.
1145
1335
  read_only_enforcement: s.read_only_enforcement,
1146
1336
  transport_security: s.transport_security,
1337
+ // Same reasoning for v8. It is measured at attest time and carried
1338
+ // unchanged onto the outcome, so projecting it back is a copy; dropping it
1339
+ // here would make every genuine v8 record fail its attestation signature.
1340
+ recoverable_state: s.recoverable_state,
1147
1341
  }));
1148
1342
  }
1149
1343
 
@@ -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,