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.
- package/dist/cjs/customer-keys.d.ts +74 -0
- package/dist/cjs/customer-keys.d.ts.map +1 -0
- package/dist/cjs/customer-keys.js +139 -0
- package/dist/cjs/customer-keys.js.map +1 -0
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +11 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/verify.d.ts +4 -0
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +73 -0
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/web-verifier.d.ts +13 -0
- package/dist/cjs/web-verifier.d.ts.map +1 -1
- package/dist/cjs/web-verifier.js +2 -1
- package/dist/cjs/web-verifier.js.map +1 -1
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +71 -2
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/customer-keys.d.ts +74 -0
- package/dist/esm/customer-keys.d.ts.map +1 -0
- package/dist/esm/customer-keys.js +130 -0
- package/dist/esm/customer-keys.js.map +1 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/verify.d.ts +4 -0
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +73 -1
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/web-verifier.d.ts +13 -0
- package/dist/esm/web-verifier.d.ts.map +1 -1
- package/dist/esm/web-verifier.js +13 -1
- package/dist/esm/web-verifier.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +85 -1
- package/src/customer-keys.ts +180 -0
- package/src/index.ts +13 -0
- package/src/verify.ts +84 -1
- 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
|
-
|
|
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
|
|
package/src/web-verifier.ts
CHANGED
|
@@ -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,
|