burnledger 0.5.0 → 0.6.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/README.md +8 -1
- package/dist/cjs/anchor.d.ts +71 -0
- package/dist/cjs/anchor.d.ts.map +1 -0
- package/dist/cjs/anchor.js +279 -0
- package/dist/cjs/anchor.js.map +1 -0
- package/dist/cjs/client.d.ts +16 -2
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +24 -5
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/enclave-seal.d.ts +101 -0
- package/dist/cjs/enclave-seal.d.ts.map +1 -0
- package/dist/cjs/enclave-seal.js +479 -0
- package/dist/cjs/enclave-seal.js.map +1 -0
- package/dist/cjs/errors.d.ts +14 -0
- package/dist/cjs/errors.d.ts.map +1 -1
- package/dist/cjs/errors.js +15 -1
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/index.d.ts +12 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +18 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/keys.d.ts +66 -0
- package/dist/cjs/keys.d.ts.map +1 -1
- package/dist/cjs/keys.js +128 -3
- package/dist/cjs/keys.js.map +1 -1
- package/dist/cjs/models.d.ts +20 -0
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +16 -0
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/verify.d.ts +31 -0
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +181 -31
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/web-verifier.d.ts +44 -0
- package/dist/cjs/web-verifier.d.ts.map +1 -1
- package/dist/cjs/web-verifier.js +31 -2
- package/dist/cjs/web-verifier.js.map +1 -1
- package/dist/esm/anchor.d.ts +71 -0
- package/dist/esm/anchor.d.ts.map +1 -0
- package/dist/esm/anchor.js +275 -0
- package/dist/esm/anchor.js.map +1 -0
- package/dist/esm/cli.d.ts +45 -14
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +309 -68
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts +16 -2
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +24 -5
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/enclave-seal.d.ts +101 -0
- package/dist/esm/enclave-seal.d.ts.map +1 -0
- package/dist/esm/enclave-seal.js +472 -0
- package/dist/esm/enclave-seal.js.map +1 -0
- package/dist/esm/errors.d.ts +14 -0
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +14 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +12 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +10 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/keys.d.ts +66 -0
- package/dist/esm/keys.d.ts.map +1 -1
- package/dist/esm/keys.js +125 -3
- package/dist/esm/keys.js.map +1 -1
- package/dist/esm/models.d.ts +20 -0
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +15 -0
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/verify.d.ts +31 -0
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +180 -33
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/web-verifier.d.ts +44 -0
- package/dist/esm/web-verifier.d.ts.map +1 -1
- package/dist/esm/web-verifier.js +30 -1
- package/dist/esm/web-verifier.js.map +1 -1
- package/package.json +1 -1
- package/src/anchor.ts +330 -0
- package/src/cli.ts +335 -73
- package/src/client.ts +35 -4
- package/src/enclave-seal.ts +582 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +22 -1
- package/src/keys.ts +176 -3
- package/src/models.ts +42 -0
- package/src/verify.ts +206 -33
- package/src/web-verifier.ts +45 -1
package/src/keys.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
// Public-key file parsing shared by the CLI
|
|
1
|
+
// Public-key file parsing shared by the CLI and the browser verifier, and the
|
|
2
|
+
// key_list.v3 verdict (ADR-017 §5a) — unit-testable in isolation.
|
|
3
|
+
|
|
4
|
+
import type { CryptoOps } from "./crypto.js";
|
|
5
|
+
import {
|
|
6
|
+
buildKeyListPayload,
|
|
7
|
+
evaluateKey,
|
|
8
|
+
formatTimestamp,
|
|
9
|
+
hexToBytes,
|
|
10
|
+
keyIsUsable,
|
|
11
|
+
publicKeyFromHex,
|
|
12
|
+
} from "./verify.js";
|
|
2
13
|
|
|
3
14
|
export type KeyEntry = {
|
|
4
15
|
key_id: string;
|
|
@@ -12,6 +23,25 @@ export type KeyEntry = {
|
|
|
12
23
|
compromised_from?: string | null;
|
|
13
24
|
};
|
|
14
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The published key set as /.well-known/burnledger-keys serves it: the `keys`
|
|
28
|
+
* array, and BESIDE it — in the same object — the fields of a signed
|
|
29
|
+
* key_list.v3 statement when the server signs one (ADR-017 §5a). A body with no
|
|
30
|
+
* `signature` is the unsigned list the endpoint has always served; that is not
|
|
31
|
+
* an error, it is the state of the world before §5a is deployed.
|
|
32
|
+
*
|
|
33
|
+
* Mirrors core.KeyListDocument.
|
|
34
|
+
*/
|
|
35
|
+
export type KeyListDocument = {
|
|
36
|
+
keys: KeyEntry[];
|
|
37
|
+
statement_issued_at?: string;
|
|
38
|
+
statement_expires_at?: string;
|
|
39
|
+
sth_tree_size?: number;
|
|
40
|
+
sth_root_hash?: string;
|
|
41
|
+
signature?: string;
|
|
42
|
+
key_id?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
15
45
|
/**
|
|
16
46
|
* Normalize a parsed keys file to an array of key entries. Accepts a bare
|
|
17
47
|
* JSON array (the generated keys.json shape), a { "keys": [...] } envelope,
|
|
@@ -19,10 +49,153 @@ export type KeyEntry = {
|
|
|
19
49
|
* actually publishes. Throws on any other shape.
|
|
20
50
|
*/
|
|
21
51
|
export function parseKeyEntries(parsed: unknown): KeyEntry[] {
|
|
52
|
+
return parseKeyListDocument(parsed).keys;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read a published key set in any shape this project has ever served — a bare
|
|
57
|
+
* array, a {"keys": [...]} object, or either inside the API's {"data": ...}
|
|
58
|
+
* envelope — keeping the statement fields when the object forms carry them.
|
|
59
|
+
* Mirrors core.ParseKeyListDocument, so this CLI, the Go CLI and the browser
|
|
60
|
+
* cannot disagree about which files are key sets.
|
|
61
|
+
*/
|
|
62
|
+
export function parseKeyListDocument(parsed: unknown): KeyListDocument {
|
|
22
63
|
const root = (parsed as { data?: unknown })?.data ?? parsed;
|
|
23
|
-
|
|
64
|
+
if (Array.isArray(root)) return { keys: root as KeyEntry[] };
|
|
65
|
+
const entries = (root as { keys?: unknown })?.keys;
|
|
24
66
|
if (!Array.isArray(entries)) {
|
|
25
67
|
throw new Error('--keys file must be a JSON array of keys or a {"keys": [...]} object');
|
|
26
68
|
}
|
|
27
|
-
return
|
|
69
|
+
return root as KeyListDocument;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Verdicts about a key list. Mirrors core.KeyListResult, plus the one the CLI
|
|
73
|
+
* adds (SIGNING_KEY_NOT_IN_LIST) because core never reaches it: VerifyKeyList
|
|
74
|
+
* is told which key to trust, and this is the case where there is none to offer. */
|
|
75
|
+
export const KEY_LIST_VALID = "VALID";
|
|
76
|
+
export const KEY_LIST_MALFORMED = "MALFORMED_KEY_LIST";
|
|
77
|
+
export const KEY_LIST_SIGNING_KEY_UNUSABLE = "KEY_LIST_SIGNING_KEY_UNUSABLE";
|
|
78
|
+
export const KEY_LIST_INVALID_SIGNATURE = "INVALID_KEY_LIST_SIGNATURE";
|
|
79
|
+
export const KEY_LIST_STALE = "KEY_LIST_STALE";
|
|
80
|
+
export const KEY_LIST_SIGNING_KEY_ABSENT = "SIGNING_KEY_NOT_IN_LIST";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What a run learned about where the key statuses and dates came from.
|
|
84
|
+
* Mirrors keyListEvidence in cmd/cli/keys.go: it is not a pass/fail input to
|
|
85
|
+
* certificate verification, it qualifies one. An auditor reading
|
|
86
|
+
* KEY_COMPROMISED needs to know whether that word arrived as evidence they can
|
|
87
|
+
* keep or as JSON from an endpoint trusted for one TCP connection.
|
|
88
|
+
*/
|
|
89
|
+
export interface KeyListEvidence {
|
|
90
|
+
/** Whether the file carried a signed statement at all. */
|
|
91
|
+
present: boolean;
|
|
92
|
+
result?: string;
|
|
93
|
+
/** The trusted key was taken from the list itself — trust-on-first-use, not proof. */
|
|
94
|
+
selfSigned: boolean;
|
|
95
|
+
/** statement_expires_at, normalized to the form Go prints. */
|
|
96
|
+
expiresAt?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The whole-second RFC 3339 form every published key date uses
|
|
100
|
+
* (core.KeyTimeFormat). Entry dates are validated against it strictly, as
|
|
101
|
+
* core.KeyEntry.PublicKeyInfo does, so a date Go refuses is refused here. */
|
|
102
|
+
const KEY_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
103
|
+
|
|
104
|
+
function requireKeyTime(keyId: string, field: string, value: unknown): void {
|
|
105
|
+
if (typeof value !== "string" || !KEY_TIME_RE.test(value)) {
|
|
106
|
+
throw new Error(`key ${keyId}: ${field} is not YYYY-MM-DDTHH:MM:SSZ`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function requireHex(field: string, value: unknown, bytes: number): Uint8Array {
|
|
111
|
+
if (typeof value !== "string" || value.length !== bytes * 2 || !/^[0-9a-fA-F]+$/.test(value)) {
|
|
112
|
+
throw new Error(`key list: ${field} is not ${bytes} bytes of hex`);
|
|
113
|
+
}
|
|
114
|
+
return hexToBytes(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check a key-list statement the way cmd/cli/keys.go does, so four verifiers
|
|
119
|
+
* agree: the trusted key is looked up IN THE LIST ITSELF (trust-on-first-use,
|
|
120
|
+
* and selfSigned says so), then core.VerifyKeyList's order — the list parses
|
|
121
|
+
* and has no duplicate, the signing key is usable at `now` under ADR-017's
|
|
122
|
+
* table, the signature verifies over the published entries, and only then
|
|
123
|
+
* freshness. A stale verdict is never returned for a signature never tested.
|
|
124
|
+
*
|
|
125
|
+
* THROWS where the Go CLI exits 2: an entry that does not parse, a key_id that
|
|
126
|
+
* is not sha256(public_key), or a statement whose fields will not read. Someone
|
|
127
|
+
* signed something the reader cannot read, and continuing as though the file
|
|
128
|
+
* were merely unsigned would throw that fact away.
|
|
129
|
+
*/
|
|
130
|
+
export async function verifyKeyList(
|
|
131
|
+
crypto: CryptoOps,
|
|
132
|
+
doc: KeyListDocument,
|
|
133
|
+
now: Date,
|
|
134
|
+
): Promise<KeyListEvidence> {
|
|
135
|
+
if (doc.keys.length === 0) throw new Error("keys file contains no keys");
|
|
136
|
+
const signed = doc.signature !== undefined && doc.signature !== "";
|
|
137
|
+
const keys = new Map<string, Awaited<ReturnType<typeof publicKeyFromHex>>>();
|
|
138
|
+
let duplicate = false;
|
|
139
|
+
for (const e of doc.keys) {
|
|
140
|
+
if (typeof e.public_key !== "string") throw new Error(`key ${e.key_id}: public_key is not a string`);
|
|
141
|
+
const pki = await publicKeyFromHex(crypto, e.public_key, {
|
|
142
|
+
keyStatus: e.key_status,
|
|
143
|
+
notBefore: e.not_before,
|
|
144
|
+
notAfter: e.not_after ?? undefined,
|
|
145
|
+
compromisedFrom: e.compromised_from ?? undefined,
|
|
146
|
+
});
|
|
147
|
+
// The strict Go loader rules — key_id is sha256(public_key), dates are
|
|
148
|
+
// whole-second UTC — apply to a SIGNED list, whose statement signs the
|
|
149
|
+
// published strings. An unsigned list keeps this CLI's existing
|
|
150
|
+
// tolerance (an absent key_id is allowed, and the map is keyed on the
|
|
151
|
+
// derived id either way); tightening the unsigned path would refuse files
|
|
152
|
+
// it accepts today.
|
|
153
|
+
if (signed) {
|
|
154
|
+
if (e.key_id !== pki.keyId) {
|
|
155
|
+
throw new Error(`key_id ${e.key_id} does not match sha256(public_key) (${pki.keyId})`);
|
|
156
|
+
}
|
|
157
|
+
if (e.not_before != null && e.not_before !== "") requireKeyTime(e.key_id, "not_before", e.not_before);
|
|
158
|
+
if (e.not_after != null) requireKeyTime(e.key_id, "not_after", e.not_after);
|
|
159
|
+
if (e.compromised_from != null) requireKeyTime(e.key_id, "compromised_from", e.compromised_from);
|
|
160
|
+
}
|
|
161
|
+
if (keys.has(pki.keyId)) duplicate = true;
|
|
162
|
+
keys.set(pki.keyId, pki);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!signed) {
|
|
166
|
+
return { present: false, selfSigned: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The statement's own fields. Refused rather than defaulted: a zero
|
|
170
|
+
// statement_expires_at would read as "expired", which is an answer.
|
|
171
|
+
requireHex("sth_root_hash", doc.sth_root_hash, 32);
|
|
172
|
+
const signature = requireHex("signature", doc.signature, 64);
|
|
173
|
+
if (typeof doc.key_id !== "string" || doc.key_id === "") throw new Error("key list: key_id is missing");
|
|
174
|
+
if (typeof doc.sth_tree_size !== "number" || !Number.isInteger(doc.sth_tree_size) || doc.sth_tree_size < 0) {
|
|
175
|
+
throw new Error("key list: sth_tree_size is not a non-negative integer");
|
|
176
|
+
}
|
|
177
|
+
const issued = formatTimestamp(doc.statement_issued_at);
|
|
178
|
+
const expires = formatTimestamp(doc.statement_expires_at);
|
|
179
|
+
|
|
180
|
+
const evidence: KeyListEvidence = { present: true, selfSigned: false, expiresAt: expires };
|
|
181
|
+
const signingKey = keys.get(doc.key_id);
|
|
182
|
+
if (signingKey === undefined) {
|
|
183
|
+
return { ...evidence, result: KEY_LIST_SIGNING_KEY_ABSENT };
|
|
184
|
+
}
|
|
185
|
+
evidence.selfSigned = true;
|
|
186
|
+
|
|
187
|
+
if (duplicate) return { ...evidence, result: KEY_LIST_MALFORMED };
|
|
188
|
+
|
|
189
|
+
const at = formatTimestamp(now.toISOString());
|
|
190
|
+
if (!keyIsUsable(evaluateKey(signingKey, Math.floor(Date.parse(at) / 1000)))) {
|
|
191
|
+
return { ...evidence, result: KEY_LIST_SIGNING_KEY_UNUSABLE };
|
|
192
|
+
}
|
|
193
|
+
const payload = buildKeyListPayload(doc as unknown as Record<string, unknown>);
|
|
194
|
+
if (!(await crypto.ed25519Verify(signingKey.keyBytes, payload, signature))) {
|
|
195
|
+
return { ...evidence, result: KEY_LIST_INVALID_SIGNATURE };
|
|
196
|
+
}
|
|
197
|
+
if (at < issued || at >= expires) {
|
|
198
|
+
return { ...evidence, result: KEY_LIST_STALE };
|
|
199
|
+
}
|
|
200
|
+
return { ...evidence, result: KEY_LIST_VALID };
|
|
28
201
|
}
|
package/src/models.ts
CHANGED
|
@@ -253,6 +253,27 @@ export interface BatchAttestationError {
|
|
|
253
253
|
readonly subjectIdentifier: string;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Batch revocation types
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/** Result of POST /v1/certificates/batch-revoke. The server answers 200
|
|
261
|
+
* whether all, some or none of the ids were revoked; `errors` is the only
|
|
262
|
+
* signal of partial completion. The records in `certificates` never carry a
|
|
263
|
+
* `statusStatement` (revoking invalidates the cached one). */
|
|
264
|
+
export interface BatchRevokeResponse {
|
|
265
|
+
readonly certificates: readonly CertificateResponse[];
|
|
266
|
+
readonly errors: readonly BatchRevokeError[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface BatchRevokeError {
|
|
270
|
+
/** Position in the submitted certificateIds list. */
|
|
271
|
+
readonly index: number;
|
|
272
|
+
readonly certificateId: string;
|
|
273
|
+
/** Code and message, e.g. "NOT_FOUND: certificate not found". */
|
|
274
|
+
readonly error: string;
|
|
275
|
+
}
|
|
276
|
+
|
|
256
277
|
// ---------------------------------------------------------------------------
|
|
257
278
|
// Certificate stats types
|
|
258
279
|
// ---------------------------------------------------------------------------
|
|
@@ -305,6 +326,8 @@ export interface ApiKeyListItem {
|
|
|
305
326
|
readonly id: string;
|
|
306
327
|
readonly prefix: string;
|
|
307
328
|
readonly role: ApiKeyRole;
|
|
329
|
+
/** The team the key is pinned to; undefined for an unpinned key. */
|
|
330
|
+
readonly teamId: string | undefined;
|
|
308
331
|
readonly createdAt: Date;
|
|
309
332
|
readonly revokedAt: Date | undefined;
|
|
310
333
|
}
|
|
@@ -313,6 +336,8 @@ export interface ApiKeyResponse {
|
|
|
313
336
|
readonly id: string;
|
|
314
337
|
readonly prefix: string;
|
|
315
338
|
readonly key: string;
|
|
339
|
+
/** The team the key is pinned to; undefined for an unpinned key. */
|
|
340
|
+
readonly teamId: string | undefined;
|
|
316
341
|
readonly createdAt: Date;
|
|
317
342
|
}
|
|
318
343
|
|
|
@@ -487,6 +512,21 @@ export function parseBatchAttestationResponse(d: Raw): BatchAttestationResponse
|
|
|
487
512
|
};
|
|
488
513
|
}
|
|
489
514
|
|
|
515
|
+
function parseBatchRevokeError(d: Raw): BatchRevokeError {
|
|
516
|
+
return {
|
|
517
|
+
index: d.index,
|
|
518
|
+
certificateId: d.certificate_id,
|
|
519
|
+
error: d.error,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function parseBatchRevokeResponse(d: Raw): BatchRevokeResponse {
|
|
524
|
+
return {
|
|
525
|
+
certificates: (d.certificates ?? []).map(parseCertificateResponse),
|
|
526
|
+
errors: (d.errors ?? []).map(parseBatchRevokeError),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
490
530
|
function parseMonthlyCount(d: Raw): MonthlyCount {
|
|
491
531
|
return {
|
|
492
532
|
month: d.month,
|
|
@@ -533,6 +573,7 @@ export function parseApiKeyListItem(d: Raw): ApiKeyListItem {
|
|
|
533
573
|
id: d.id,
|
|
534
574
|
prefix: d.prefix,
|
|
535
575
|
role: d.role as ApiKeyRole,
|
|
576
|
+
teamId: d.team_id ?? undefined,
|
|
536
577
|
createdAt: parseDt(d.created_at),
|
|
537
578
|
revokedAt: parseDtOpt(d.revoked_at),
|
|
538
579
|
};
|
|
@@ -543,6 +584,7 @@ export function parseApiKeyResponse(d: Raw): ApiKeyResponse {
|
|
|
543
584
|
id: d.id,
|
|
544
585
|
prefix: d.prefix,
|
|
545
586
|
key: d.key,
|
|
587
|
+
teamId: d.team_id ?? undefined,
|
|
546
588
|
createdAt: parseDt(d.created_at),
|
|
547
589
|
};
|
|
548
590
|
}
|
package/src/verify.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { CryptoOps } from "./crypto.js";
|
|
20
20
|
import {
|
|
21
21
|
CERTIFICATE_REVOKED,
|
|
22
|
+
UNSUPPORTED_ALGORITHM,
|
|
22
23
|
UNSUPPORTED_FORMAT_VERSION,
|
|
23
24
|
VerificationError,
|
|
24
25
|
} from "./errors.js";
|
|
@@ -53,8 +54,22 @@ const FORMAT_VERSION_V4 = "4.0";
|
|
|
53
54
|
const PAYLOAD_TYPE_ATTESTATION_V6 = "burnledger.attestation.v6";
|
|
54
55
|
const PAYLOAD_TYPE_VERIFICATION_RECORD_V6 = "burnledger.verification_record.v6";
|
|
55
56
|
const FORMAT_VERSION_V6 = "6.0";
|
|
57
|
+
// v7 changes the SHAPE, not just the name: every system entry gains
|
|
58
|
+
// read_only_enforcement and transport_security, and the issuer gains the
|
|
59
|
+
// algorithm identifier. Its tags move for the reason v4, v5 and v6 each moved
|
|
60
|
+
// theirs, and this time the bytes under them genuinely differ.
|
|
61
|
+
const PAYLOAD_TYPE_ATTESTATION_V7 = "burnledger.attestation.v7";
|
|
62
|
+
const PAYLOAD_TYPE_VERIFICATION_RECORD_V7 = "burnledger.verification_record.v7";
|
|
63
|
+
const FORMAT_VERSION_V7 = "7.0";
|
|
56
64
|
const FORMAT_VERSION_V3 = "3.0";
|
|
57
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The only signature scheme this SDK can check. A v7 record names its own
|
|
68
|
+
* algorithm, so a record naming anything else must be refused with its own
|
|
69
|
+
* code rather than fed to ed25519Verify and reported as a bad signature.
|
|
70
|
+
*/
|
|
71
|
+
const ALGORITHM_ED25519 = "ed25519";
|
|
72
|
+
|
|
58
73
|
/**
|
|
59
74
|
* Every format this build can read, oldest first. Mirrors core.FormatVersions()
|
|
60
75
|
* in the Go source; the cross-language conformance corpus carries a fixture per
|
|
@@ -70,6 +85,7 @@ export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
|
|
|
70
85
|
FORMAT_VERSION_V4,
|
|
71
86
|
FORMAT_VERSION_V5,
|
|
72
87
|
FORMAT_VERSION_V6,
|
|
88
|
+
FORMAT_VERSION_V7,
|
|
73
89
|
]);
|
|
74
90
|
|
|
75
91
|
/**
|
|
@@ -91,10 +107,13 @@ function checkFormatVersion(version: unknown): string {
|
|
|
91
107
|
return version;
|
|
92
108
|
}
|
|
93
109
|
const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
|
|
110
|
+
// The domain for a tree head that names its log. See buildTreeHeadPayload.
|
|
111
|
+
const PAYLOAD_TYPE_TREE_HEAD_V7 = "burnledger.sth.v7";
|
|
94
112
|
const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
|
|
95
113
|
// There is deliberately no verification payload type: v3 produces those facts
|
|
96
114
|
// in the same enclave call that signs the certificate (ADR-016 §2).
|
|
97
115
|
const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
|
|
116
|
+
const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
|
|
98
117
|
|
|
99
118
|
export function hexToBytes(hex: string): Uint8Array {
|
|
100
119
|
const len = hex.length >>> 1;
|
|
@@ -258,6 +277,14 @@ const USABLE_KEY_VERDICTS: ReadonlySet<string> = new Set([
|
|
|
258
277
|
VALID_KEY_COMPROMISED_LATER,
|
|
259
278
|
]);
|
|
260
279
|
|
|
280
|
+
/** Whether a key verdict permits relying on a signature. Mirrors
|
|
281
|
+
* core.KeyIsUsable; exported so the key-list verifier (keys.ts) refuses the
|
|
282
|
+
* same set requireUsableKey refuses rather than keeping a second copy of it —
|
|
283
|
+
* a second copy is exactly how #684's fourth answer came to exist. */
|
|
284
|
+
export function keyIsUsable(verdict: string): boolean {
|
|
285
|
+
return USABLE_KEY_VERDICTS.has(verdict);
|
|
286
|
+
}
|
|
287
|
+
|
|
261
288
|
/** Whether a key was authorized to sign at anchor time `t`.
|
|
262
289
|
*
|
|
263
290
|
* Returns `"VALID"` when the key clears. The anchor is seconds since the epoch
|
|
@@ -411,9 +438,23 @@ export async function verifyCertificate(
|
|
|
411
438
|
// signed bytes from the version, so an unreadable version makes all of them
|
|
412
439
|
// meaningless - and "unknown issuer key" would be the wrong thing to tell
|
|
413
440
|
// someone holding a record that is merely newer than this library.
|
|
414
|
-
|
|
441
|
+
const formatVersion = checkFormatVersion(
|
|
442
|
+
(certificate as Record<string, unknown>).certificate_format_version,
|
|
443
|
+
);
|
|
415
444
|
|
|
416
445
|
const issuer = certificate.issuer as Record<string, unknown>;
|
|
446
|
+
// Step 0b: can this SDK check the algorithm the record names? Asked before
|
|
447
|
+
// any signature, because verifying an Ed25519 signature over a record that
|
|
448
|
+
// 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) {
|
|
451
|
+
throw new VerificationError(
|
|
452
|
+
`record names signature algorithm ${JSON.stringify(issuer.algorithm)}; ` +
|
|
453
|
+
`this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
|
|
454
|
+
"The record may be genuine and simply signed with a scheme this library does not implement.",
|
|
455
|
+
UNSUPPORTED_ALGORITHM,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
417
458
|
const keyId = issuer.key_id as string;
|
|
418
459
|
|
|
419
460
|
const pki = publicKeys.get(keyId);
|
|
@@ -592,6 +633,20 @@ export async function verifyTransparency(
|
|
|
592
633
|
);
|
|
593
634
|
}
|
|
594
635
|
|
|
636
|
+
// Same rule for the log id (Q31). transparency.log_id sits beside log_url and
|
|
637
|
+
// is the field a reader looks at to answer "which log is this?", and nothing
|
|
638
|
+
// signs it — so a holder could relabel a genuine proof as belonging to a
|
|
639
|
+
// different log while every signature still checked out. A head predating log
|
|
640
|
+
// ids has neither side set and passes.
|
|
641
|
+
const signedLogId = (sth.log_id as string | undefined) ?? "";
|
|
642
|
+
const unsignedLogId = (transparency.log_id as string | undefined) ?? "";
|
|
643
|
+
if (unsignedLogId !== signedLogId) {
|
|
644
|
+
throw new VerificationError(
|
|
645
|
+
`transparency.log_id (${JSON.stringify(unsignedLogId)}) does not match the signed ` +
|
|
646
|
+
`tree head (${JSON.stringify(signedLogId)}); it is not covered by any signature`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
595
650
|
if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
|
|
596
651
|
throw new VerificationError("merkle inclusion proof is invalid");
|
|
597
652
|
}
|
|
@@ -851,20 +906,39 @@ function buildAttestationPayload(
|
|
|
851
906
|
certFormatVersion: string,
|
|
852
907
|
): Uint8Array {
|
|
853
908
|
const attestedAt = formatTimestamp(att.attested_at);
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
: null,
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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;
|
|
913
|
+
const systems = (att.systems as Record<string, unknown>[]).map((s) => {
|
|
914
|
+
const sys: Record<string, unknown> = {
|
|
915
|
+
canonical_version: (s.canonical_version as string | null) ?? null,
|
|
916
|
+
connector_type: s.connector_type as string,
|
|
917
|
+
hash_scope: s.hash_scope as string,
|
|
918
|
+
merkle_root: s.merkle_root
|
|
919
|
+
? toHex(s.merkle_root as string)
|
|
920
|
+
: null,
|
|
921
|
+
// The system's own observation time, not the envelope's.
|
|
922
|
+
observed_at: formatTimestamp(s.observed_at),
|
|
923
|
+
query_hash: toHex(s.query_hash as string),
|
|
924
|
+
record_count: s.record_count as number,
|
|
925
|
+
system_id: s.system_id as string,
|
|
926
|
+
system_name: s.system_name as string,
|
|
927
|
+
};
|
|
928
|
+
if (isV7) {
|
|
929
|
+
sys.read_only_enforcement = requireMeasured(
|
|
930
|
+
s.read_only_enforcement,
|
|
931
|
+
"read_only_enforcement",
|
|
932
|
+
s.system_name,
|
|
933
|
+
);
|
|
934
|
+
sys.transport_security = requireMeasured(
|
|
935
|
+
s.transport_security,
|
|
936
|
+
"transport_security",
|
|
937
|
+
s.system_name,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
return sys;
|
|
941
|
+
});
|
|
868
942
|
|
|
869
943
|
const payload = {
|
|
870
944
|
attested_at: attestedAt,
|
|
@@ -876,9 +950,29 @@ function buildAttestationPayload(
|
|
|
876
950
|
return canonicalJson(payload);
|
|
877
951
|
}
|
|
878
952
|
|
|
953
|
+
/**
|
|
954
|
+
* Read a v7 measured field that MUST be a non-empty string.
|
|
955
|
+
*
|
|
956
|
+
* A missing or non-string value cannot be turned into `""` and canonicalized:
|
|
957
|
+
* the signer never emits an empty measurement, so an empty one here would
|
|
958
|
+
* rebuild bytes no signature covers and be reported as forgery. Refusing with a
|
|
959
|
+
* document-shape message says the true thing — this record is malformed, not
|
|
960
|
+
* this record is fake.
|
|
961
|
+
*/
|
|
962
|
+
function requireMeasured(value: unknown, field: string, systemName: unknown): string {
|
|
963
|
+
if (typeof value !== "string" || value === "") {
|
|
964
|
+
throw new VerificationError(
|
|
965
|
+
`system ${JSON.stringify(systemName)}: ${field} is missing from a 7.0 record, ` +
|
|
966
|
+
"which signs it; the document is incomplete rather than unverifiable",
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
return value;
|
|
970
|
+
}
|
|
971
|
+
|
|
879
972
|
/** Domain separator for the record itself, by certificate format version. */
|
|
880
973
|
function certificatePayloadType(version: string): string {
|
|
881
974
|
checkFormatVersion(version);
|
|
975
|
+
if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
|
|
882
976
|
if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
|
|
883
977
|
if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_CERTIFICATE_V5;
|
|
884
978
|
if (version === FORMAT_VERSION_V4) return PAYLOAD_TYPE_CERTIFICATE_V4;
|
|
@@ -893,6 +987,7 @@ function certificatePayloadType(version: string): string {
|
|
|
893
987
|
*/
|
|
894
988
|
function attestationPayloadType(version: string): string {
|
|
895
989
|
checkFormatVersion(version);
|
|
990
|
+
if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_ATTESTATION_V7;
|
|
896
991
|
if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_ATTESTATION_V6;
|
|
897
992
|
if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_ATTESTATION_V5;
|
|
898
993
|
return PAYLOAD_TYPE_ATTESTATION;
|
|
@@ -906,19 +1001,36 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
906
1001
|
// One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
|
|
907
1002
|
// and a verification list joined only on the human-editable system_name,
|
|
908
1003
|
// which made a partial deletion indistinguishable from a complete one.
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1004
|
+
const version = cert.certificate_format_version as string;
|
|
1005
|
+
const isV7 = version === FORMAT_VERSION_V7;
|
|
1006
|
+
const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => {
|
|
1007
|
+
const sys: Record<string, unknown> = {
|
|
1008
|
+
attested_at: formatTimestamp(s.attested_at),
|
|
1009
|
+
attested_count: s.attested_count as number,
|
|
1010
|
+
canonical_version: (s.canonical_version as string | null) ?? null,
|
|
1011
|
+
connector_type: s.connector_type as string,
|
|
1012
|
+
hash_scope: s.hash_scope as string,
|
|
1013
|
+
merkle_root: s.merkle_root ? toHex(s.merkle_root as string) : null,
|
|
1014
|
+
query_hash: toHex(s.query_hash as string),
|
|
1015
|
+
system_id: s.system_id as string,
|
|
1016
|
+
system_name: s.system_name as string,
|
|
1017
|
+
verified_at: formatTimestamp(s.verified_at),
|
|
1018
|
+
verified_count: s.verified_count as number,
|
|
1019
|
+
};
|
|
1020
|
+
if (isV7) {
|
|
1021
|
+
sys.read_only_enforcement = requireMeasured(
|
|
1022
|
+
s.read_only_enforcement,
|
|
1023
|
+
"read_only_enforcement",
|
|
1024
|
+
s.system_name,
|
|
1025
|
+
);
|
|
1026
|
+
sys.transport_security = requireMeasured(
|
|
1027
|
+
s.transport_security,
|
|
1028
|
+
"transport_security",
|
|
1029
|
+
s.system_name,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
return sys;
|
|
1033
|
+
});
|
|
922
1034
|
|
|
923
1035
|
// The attestation block carries no system list of its own: the merged list
|
|
924
1036
|
// above is a superset of it. verification_signature is gone entirely.
|
|
@@ -931,11 +1043,11 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
931
1043
|
// Fields added after v3 are gated on the certificate's OWN version. A v3
|
|
932
1044
|
// certificate must reconstruct to the same bytes forever; reading these
|
|
933
1045
|
// unconditionally would break every certificate already issued.
|
|
934
|
-
const version = cert.certificate_format_version as string;
|
|
935
1046
|
// v6 carries v5's shape exactly, so it takes every gate v5 takes. Naming
|
|
936
1047
|
// these "Plus" rather than testing equality at each use is what stops a new
|
|
937
1048
|
// version from silently missing one.
|
|
938
|
-
const isV5Plus =
|
|
1049
|
+
const isV5Plus =
|
|
1050
|
+
version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6 || isV7;
|
|
939
1051
|
const isV4Plus = version === FORMAT_VERSION_V4 || isV5Plus;
|
|
940
1052
|
|
|
941
1053
|
const issuerObj: Record<string, unknown> = {
|
|
@@ -943,6 +1055,12 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
943
1055
|
name: issuer.name as string,
|
|
944
1056
|
public_key: toHex(issuer.public_key as string),
|
|
945
1057
|
};
|
|
1058
|
+
// v7 signs the algorithm, and does so unconditionally: unlike legal_entity
|
|
1059
|
+
// and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
|
|
1060
|
+
// so a v7 record missing it is malformed rather than merely sparse.
|
|
1061
|
+
if (isV7) {
|
|
1062
|
+
issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer");
|
|
1063
|
+
}
|
|
946
1064
|
if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
|
|
947
1065
|
issuerObj.legal_entity = issuer.legal_entity;
|
|
948
1066
|
}
|
|
@@ -1021,16 +1139,37 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
|
|
|
1021
1139
|
observed_at: s.attested_at,
|
|
1022
1140
|
merkle_root: s.merkle_root,
|
|
1023
1141
|
canonical_version: s.canonical_version,
|
|
1142
|
+
// Carried through so the v7 attestation payload can be rebuilt from the
|
|
1143
|
+
// record. Undefined on older formats, where buildAttestationPayload never
|
|
1144
|
+
// reads them.
|
|
1145
|
+
read_only_enforcement: s.read_only_enforcement,
|
|
1146
|
+
transport_security: s.transport_security,
|
|
1024
1147
|
}));
|
|
1025
1148
|
}
|
|
1026
1149
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1150
|
+
/**
|
|
1151
|
+
* Port of Go's BuildTreeHeadPayload.
|
|
1152
|
+
*
|
|
1153
|
+
* A head that names its log is signed in a DIFFERENT DOMAIN from one that does
|
|
1154
|
+
* not (Q31). That is what makes log_id unstrippable: removing it moves the
|
|
1155
|
+
* rebuild into burnledger.sth.v3 and adding one moves it into
|
|
1156
|
+
* burnledger.sth.v7, and the signature fails either way. Heads signed before
|
|
1157
|
+
* log ids existed carry none and reproduce exactly the bytes they always did.
|
|
1158
|
+
*
|
|
1159
|
+
* Exported for anchor.ts, which rebuilds the preimage of a head fetched from
|
|
1160
|
+
* GET /v1/log/head. Not re-exported from index.ts: it is an internal seam
|
|
1161
|
+
* between two modules of this package, not published surface.
|
|
1162
|
+
*/
|
|
1163
|
+
export function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
|
|
1164
|
+
const logId = head.log_id;
|
|
1165
|
+
const named = typeof logId === "string" && logId !== "";
|
|
1166
|
+
const payload: Record<string, unknown> = {
|
|
1167
|
+
payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
|
|
1030
1168
|
root_hash: toHex(head.root_hash as string),
|
|
1031
1169
|
timestamp: formatTimestamp(head.timestamp),
|
|
1032
1170
|
tree_size: head.tree_size as number,
|
|
1033
1171
|
};
|
|
1172
|
+
if (named) payload.log_id = logId;
|
|
1034
1173
|
return canonicalJson(payload);
|
|
1035
1174
|
}
|
|
1036
1175
|
|
|
@@ -1218,6 +1357,40 @@ export function buildCertificateStatusPayload(
|
|
|
1218
1357
|
return canonicalJson(payload);
|
|
1219
1358
|
}
|
|
1220
1359
|
|
|
1360
|
+
/**
|
|
1361
|
+
* Port of Go's BuildKeyListPayload (core/key_list.go).
|
|
1362
|
+
*
|
|
1363
|
+
* The key array is sorted by key_id before signing: canonical JSON sorts
|
|
1364
|
+
* object keys but does nothing to array order, and the server has no reason to
|
|
1365
|
+
* preserve any particular order across a rotation. Optional entry fields are
|
|
1366
|
+
* omitted, never emitted as null, for the reason buildCertificateStatusPayload
|
|
1367
|
+
* omits them. `keys` is the array exactly as HANDED to the verifier — the
|
|
1368
|
+
* signature covers the published entries, not a parsed reinterpretation of
|
|
1369
|
+
* them, which is what makes an edited key_status detectable.
|
|
1370
|
+
*/
|
|
1371
|
+
export function buildKeyListPayload(doc: Record<string, unknown>): Uint8Array {
|
|
1372
|
+
const entries = (doc.keys as Record<string, unknown>[]).map((e) => {
|
|
1373
|
+
const k: Record<string, unknown> = {};
|
|
1374
|
+
if (e.compromised_from != null) k.compromised_from = e.compromised_from;
|
|
1375
|
+
k.key_id = e.key_id;
|
|
1376
|
+
k.key_status = e.key_status;
|
|
1377
|
+
if (e.not_after != null) k.not_after = e.not_after;
|
|
1378
|
+
if (e.not_before != null && e.not_before !== "") k.not_before = e.not_before;
|
|
1379
|
+
k.public_key = e.public_key;
|
|
1380
|
+
return k;
|
|
1381
|
+
});
|
|
1382
|
+
entries.sort((a, b) => ((a.key_id as string) < (b.key_id as string) ? -1 : a.key_id === b.key_id ? 0 : 1));
|
|
1383
|
+
const payload: Record<string, unknown> = {
|
|
1384
|
+
payload_type: PAYLOAD_TYPE_KEY_LIST,
|
|
1385
|
+
keys: entries,
|
|
1386
|
+
statement_expires_at: formatTimestamp(doc.statement_expires_at),
|
|
1387
|
+
statement_issued_at: formatTimestamp(doc.statement_issued_at),
|
|
1388
|
+
sth_root_hash: toHex(doc.sth_root_hash as string),
|
|
1389
|
+
sth_tree_size: doc.sth_tree_size as number,
|
|
1390
|
+
};
|
|
1391
|
+
return canonicalJson(payload);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1221
1394
|
/** The verdict when a certificate verified but nothing said whether it was revoked. */
|
|
1222
1395
|
export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
1223
1396
|
|