burnledger 0.6.0 → 0.7.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,18 @@ 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";
64
76
  const FORMAT_VERSION_V3 = "3.0";
65
77
 
66
78
  /**
@@ -86,6 +98,7 @@ export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
86
98
  FORMAT_VERSION_V5,
87
99
  FORMAT_VERSION_V6,
88
100
  FORMAT_VERSION_V7,
101
+ FORMAT_VERSION_V8,
89
102
  ]);
90
103
 
91
104
  /**
@@ -106,6 +119,52 @@ function checkFormatVersion(version: unknown): string {
106
119
  }
107
120
  return version;
108
121
  }
122
+
123
+ /**
124
+ * Is `version` the same format as `floor`, or a later one?
125
+ *
126
+ * Ordered by position in KNOWN_FORMAT_VERSIONS, never by string comparison:
127
+ * "10.0" sorts before "9.0". Port of core.formatAtLeast, and it exists for the
128
+ * reason the Go one does — four gates there were written as `v === "7.0"`, read
129
+ * by everyone as "7.0 and later", and behaved as "7.0 only". Three were live
130
+ * defects and one silently downgraded a security property the day v8 was
131
+ * defined.
132
+ *
133
+ * USE THIS, not equality, whenever the question is "does this format sign X".
134
+ * An unknown version answers false for every floor, which is safe only because
135
+ * checkFormatVersion has already refused it before any payload is rebuilt.
136
+ */
137
+ function formatAtLeast(version: string, floor: string): boolean {
138
+ const vi = KNOWN_FORMAT_VERSIONS.indexOf(version);
139
+ const fi = KNOWN_FORMAT_VERSIONS.indexOf(floor);
140
+ return vi >= 0 && fi >= 0 && vi >= fi;
141
+ }
142
+
143
+ /** Ports of the core.SignatureCovers* predicates, one per signed field group. */
144
+ function signatureCoversEnclavePcr0(version: string): boolean {
145
+ return formatAtLeast(version, FORMAT_VERSION_V5);
146
+ }
147
+
148
+ function signatureCoversAlgorithm(version: string): boolean {
149
+ return formatAtLeast(version, FORMAT_VERSION_V7);
150
+ }
151
+
152
+ function signatureCoversMeasuredTransport(version: string): boolean {
153
+ return formatAtLeast(version, FORMAT_VERSION_V7);
154
+ }
155
+
156
+ /**
157
+ * Does this format sign the per-system recoverable_state?
158
+ *
159
+ * v8 only, and everything after it. Emitting the field for v7 or older would
160
+ * rebuild bytes no signer ever produced and reject every record already issued;
161
+ * omitting it for v8 rebuilds a v7-shaped subset and reports a genuine v8
162
+ * record as forged.
163
+ */
164
+ function signatureCoversRecoverableState(version: string): boolean {
165
+ return formatAtLeast(version, FORMAT_VERSION_V8);
166
+ }
167
+
109
168
  const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
110
169
  // The domain for a tree head that names its log. See buildTreeHeadPayload.
111
170
  const PAYLOAD_TYPE_TREE_HEAD_V7 = "burnledger.sth.v7";
@@ -446,8 +505,9 @@ export async function verifyCertificate(
446
505
  // Step 0b: can this SDK check the algorithm the record names? Asked before
447
506
  // any signature, because verifying an Ed25519 signature over a record that
448
507
  // 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) {
508
+ // Below v7 no record names one, so there is nothing to check; from v7 on
509
+ // every record does, which is why this asks "v7 or later" rather than "v7".
510
+ if (signatureCoversAlgorithm(formatVersion) && issuer.algorithm !== ALGORITHM_ED25519) {
451
511
  throw new VerificationError(
452
512
  `record names signature algorithm ${JSON.stringify(issuer.algorithm)}; ` +
453
513
  `this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
@@ -906,10 +966,16 @@ function buildAttestationPayload(
906
966
  certFormatVersion: string,
907
967
  ): Uint8Array {
908
968
  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;
969
+ // v7 signs two per-system measurements v6 leaves on a mutable row, and v8
970
+ // adds recoverable_state beside them. Both gated on the record's OWN version:
971
+ // emitting a field for an older format would rebuild bytes no signer ever
972
+ // produced and reject every record already issued.
973
+ //
974
+ // Predicates, not equalities. v8 signs everything v7 signs, so a `=== "7.0"`
975
+ // here would drop read_only_enforcement and transport_security out of a v8
976
+ // payload and call every genuine v8 record a forgery.
977
+ const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
978
+ const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
913
979
  const systems = (att.systems as Record<string, unknown>[]).map((s) => {
914
980
  const sys: Record<string, unknown> = {
915
981
  canonical_version: (s.canonical_version as string | null) ?? null,
@@ -925,16 +991,26 @@ function buildAttestationPayload(
925
991
  system_id: s.system_id as string,
926
992
  system_name: s.system_name as string,
927
993
  };
928
- if (isV7) {
994
+ if (coversMeasured) {
929
995
  sys.read_only_enforcement = requireMeasured(
930
996
  s.read_only_enforcement,
931
997
  "read_only_enforcement",
932
998
  s.system_name,
999
+ certFormatVersion,
933
1000
  );
934
1001
  sys.transport_security = requireMeasured(
935
1002
  s.transport_security,
936
1003
  "transport_security",
937
1004
  s.system_name,
1005
+ certFormatVersion,
1006
+ );
1007
+ }
1008
+ if (coversRecoverable) {
1009
+ sys.recoverable_state = requireMeasured(
1010
+ s.recoverable_state,
1011
+ "recoverable_state",
1012
+ s.system_name,
1013
+ certFormatVersion,
938
1014
  );
939
1015
  }
940
1016
  return sys;
@@ -951,18 +1027,27 @@ function buildAttestationPayload(
951
1027
  }
952
1028
 
953
1029
  /**
954
- * Read a v7 measured field that MUST be a non-empty string.
1030
+ * Read a signed measured field that MUST be a non-empty string.
955
1031
  *
956
1032
  * A missing or non-string value cannot be turned into `""` and canonicalized:
957
1033
  * the signer never emits an empty measurement, so an empty one here would
958
1034
  * rebuild bytes no signature covers and be reported as forgery. Refusing with a
959
1035
  * document-shape message says the true thing — this record is malformed, not
960
1036
  * this record is fake.
1037
+ *
1038
+ * The version is passed in rather than written into the message: it was the
1039
+ * literal "7.0" here, which would have told the holder of an incomplete v8
1040
+ * record that their v8 document was a v7 one.
961
1041
  */
962
- function requireMeasured(value: unknown, field: string, systemName: unknown): string {
1042
+ function requireMeasured(
1043
+ value: unknown,
1044
+ field: string,
1045
+ systemName: unknown,
1046
+ version: string,
1047
+ ): string {
963
1048
  if (typeof value !== "string" || value === "") {
964
1049
  throw new VerificationError(
965
- `system ${JSON.stringify(systemName)}: ${field} is missing from a 7.0 record, ` +
1050
+ `system ${JSON.stringify(systemName)}: ${field} is missing from a ${version} record, ` +
966
1051
  "which signs it; the document is incomplete rather than unverifiable",
967
1052
  );
968
1053
  }
@@ -972,6 +1057,7 @@ function requireMeasured(value: unknown, field: string, systemName: unknown): st
972
1057
  /** Domain separator for the record itself, by certificate format version. */
973
1058
  function certificatePayloadType(version: string): string {
974
1059
  checkFormatVersion(version);
1060
+ if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_VERIFICATION_RECORD_V8;
975
1061
  if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
976
1062
  if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
977
1063
  if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_CERTIFICATE_V5;
@@ -987,6 +1073,10 @@ function certificatePayloadType(version: string): string {
987
1073
  */
988
1074
  function attestationPayloadType(version: string): string {
989
1075
  checkFormatVersion(version);
1076
+ // Equality here, deliberately: each format has its OWN separator, so this is
1077
+ // a lookup rather than a "this version onward" question. v3 and v4 share one
1078
+ // because their attestation bytes are identical.
1079
+ if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_ATTESTATION_V8;
990
1080
  if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_ATTESTATION_V7;
991
1081
  if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_ATTESTATION_V6;
992
1082
  if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_ATTESTATION_V5;
@@ -1002,7 +1092,8 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1002
1092
  // and a verification list joined only on the human-editable system_name,
1003
1093
  // which made a partial deletion indistinguishable from a complete one.
1004
1094
  const version = cert.certificate_format_version as string;
1005
- const isV7 = version === FORMAT_VERSION_V7;
1095
+ const coversMeasured = signatureCoversMeasuredTransport(version);
1096
+ const coversRecoverable = signatureCoversRecoverableState(version);
1006
1097
  const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => {
1007
1098
  const sys: Record<string, unknown> = {
1008
1099
  attested_at: formatTimestamp(s.attested_at),
@@ -1017,16 +1108,26 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1017
1108
  verified_at: formatTimestamp(s.verified_at),
1018
1109
  verified_count: s.verified_count as number,
1019
1110
  };
1020
- if (isV7) {
1111
+ if (coversMeasured) {
1021
1112
  sys.read_only_enforcement = requireMeasured(
1022
1113
  s.read_only_enforcement,
1023
1114
  "read_only_enforcement",
1024
1115
  s.system_name,
1116
+ version,
1025
1117
  );
1026
1118
  sys.transport_security = requireMeasured(
1027
1119
  s.transport_security,
1028
1120
  "transport_security",
1029
1121
  s.system_name,
1122
+ version,
1123
+ );
1124
+ }
1125
+ if (coversRecoverable) {
1126
+ sys.recoverable_state = requireMeasured(
1127
+ s.recoverable_state,
1128
+ "recoverable_state",
1129
+ s.system_name,
1130
+ version,
1030
1131
  );
1031
1132
  }
1032
1133
  return sys;
@@ -1043,12 +1144,12 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1043
1144
  // Fields added after v3 are gated on the certificate's OWN version. A v3
1044
1145
  // certificate must reconstruct to the same bytes forever; reading these
1045
1146
  // 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;
1147
+ // v6 carries v5's shape exactly, so it takes every gate v5 takes. Asking
1148
+ // "this version onward" rather than listing versions at each use is what
1149
+ // stops a new format from silently missing one: these were a chain of
1150
+ // equalities that had to be extended by hand for v6, then v7, then v8.
1151
+ const isV5Plus = formatAtLeast(version, FORMAT_VERSION_V5);
1152
+ const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
1052
1153
 
1053
1154
  const issuerObj: Record<string, unknown> = {
1054
1155
  key_id: issuer.key_id as string,
@@ -1058,15 +1159,24 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
1058
1159
  // v7 signs the algorithm, and does so unconditionally: unlike legal_entity
1059
1160
  // and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
1060
1161
  // so a v7 record missing it is malformed rather than merely sparse.
1061
- if (isV7) {
1062
- issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer");
1162
+ if (signatureCoversAlgorithm(version)) {
1163
+ issuerObj.algorithm = requireMeasured(
1164
+ issuer.algorithm,
1165
+ "issuer.algorithm",
1166
+ "issuer",
1167
+ version,
1168
+ );
1063
1169
  }
1064
1170
  if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
1065
1171
  issuerObj.legal_entity = issuer.legal_entity;
1066
1172
  }
1067
1173
  // Signed from v5, and omitted when absent or empty: a build with no
1068
1174
  // measurement signs none, and "" is a value a reader could mistake for one.
1069
- if (isV5Plus && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
1175
+ if (
1176
+ signatureCoversEnclavePcr0(version) &&
1177
+ typeof issuer.enclave_pcr0 === "string" &&
1178
+ issuer.enclave_pcr0 !== ""
1179
+ ) {
1070
1180
  issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
1071
1181
  }
1072
1182
 
@@ -1144,6 +1254,10 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
1144
1254
  // reads them.
1145
1255
  read_only_enforcement: s.read_only_enforcement,
1146
1256
  transport_security: s.transport_security,
1257
+ // Same reasoning for v8. It is measured at attest time and carried
1258
+ // unchanged onto the outcome, so projecting it back is a copy; dropping it
1259
+ // here would make every genuine v8 record fail its attestation signature.
1260
+ recoverable_state: s.recoverable_state,
1147
1261
  }));
1148
1262
  }
1149
1263