burnledger 0.8.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 +4 -0
- package/dist/cjs/verify.js.map +1 -1
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +21 -0
- 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 +4 -1
- package/dist/esm/verify.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +26 -0
- package/src/customer-keys.ts +180 -0
- package/src/index.ts +13 -0
- package/src/verify.ts +4 -1
package/src/cli.ts
CHANGED
|
@@ -295,6 +295,31 @@ function signatureCoversEnclavePcr0(version: string): boolean {
|
|
|
295
295
|
return ENCLAVE_PCR0_SIGNED_IN.includes(version);
|
|
296
296
|
}
|
|
297
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
|
+
|
|
298
323
|
/**
|
|
299
324
|
* Per system: did the enclave check it against a customer-signed registration,
|
|
300
325
|
* and under which key?
|
|
@@ -420,6 +445,7 @@ export function formatOutput(
|
|
|
420
445
|
L.push(` Systems: ${sys.length} (${sys.map((s) => `${s.connector_type} [${s.hash_scope}]`).join(", ")})`);
|
|
421
446
|
L.push(` Records before: ${sys.reduce((n, s) => n + (s.attested_count as number), 0)}`);
|
|
422
447
|
L.push(` Records after: ${sys.reduce((n, s) => n + (s.verified_count as number), 0)}`);
|
|
448
|
+
pushRecordCounts(L, sys);
|
|
423
449
|
L.push(` Committed: ${att.attested_at as string}`);
|
|
424
450
|
L.push(` Verified: ${sys.reduce((t, s) => {
|
|
425
451
|
const v = s.verified_at as string;
|
|
@@ -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
|
@@ -811,7 +811,10 @@ export function issuanceBytes(certificate: Cert): Uint8Array {
|
|
|
811
811
|
// RFC 8785 Canonical JSON
|
|
812
812
|
// ---------------------------------------------------------------------------
|
|
813
813
|
|
|
814
|
-
|
|
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 {
|
|
815
818
|
return textToBytes(canonicalStringify(obj));
|
|
816
819
|
}
|
|
817
820
|
|