burnledger 0.8.1 → 0.9.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 +139 -2
- package/dist/cjs/anchor.d.ts +5 -3
- package/dist/cjs/anchor.d.ts.map +1 -1
- package/dist/cjs/anchor.js +10 -4
- package/dist/cjs/anchor.js.map +1 -1
- package/dist/cjs/client.d.ts +95 -0
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +133 -2
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/customer-keys.d.ts +73 -1
- package/dist/cjs/customer-keys.d.ts.map +1 -1
- package/dist/cjs/customer-keys.js +329 -3
- package/dist/cjs/customer-keys.js.map +1 -1
- package/dist/cjs/enclave-registration.d.ts +153 -0
- package/dist/cjs/enclave-registration.d.ts.map +1 -0
- package/dist/cjs/enclave-registration.js +275 -0
- package/dist/cjs/enclave-registration.js.map +1 -0
- package/dist/cjs/enclave-seal.d.ts +43 -0
- package/dist/cjs/enclave-seal.d.ts.map +1 -1
- package/dist/cjs/enclave-seal.js +62 -1
- package/dist/cjs/enclave-seal.js.map +1 -1
- package/dist/cjs/errors.d.ts +10 -0
- package/dist/cjs/errors.d.ts.map +1 -1
- package/dist/cjs/errors.js +11 -1
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/index.browser.d.ts +7 -3
- package/dist/cjs/index.browser.d.ts.map +1 -1
- package/dist/cjs/index.browser.js +6 -3
- package/dist/cjs/index.browser.js.map +1 -1
- package/dist/cjs/index.d.ts +22 -10
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +30 -6
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/key-group.d.ts +80 -0
- package/dist/cjs/key-group.d.ts.map +1 -0
- package/dist/cjs/key-group.js +136 -0
- package/dist/cjs/key-group.js.map +1 -0
- package/dist/cjs/models.d.ts +60 -0
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +63 -0
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/status-document.d.ts +25 -0
- package/dist/cjs/status-document.d.ts.map +1 -0
- package/dist/cjs/status-document.js +62 -0
- package/dist/cjs/status-document.js.map +1 -0
- package/dist/cjs/verify.d.ts +108 -3
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +389 -146
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/web-verifier.d.ts +23 -3
- package/dist/cjs/web-verifier.d.ts.map +1 -1
- package/dist/cjs/web-verifier.js +31 -5
- package/dist/cjs/web-verifier.js.map +1 -1
- package/dist/esm/anchor.d.ts +5 -3
- package/dist/esm/anchor.d.ts.map +1 -1
- package/dist/esm/anchor.js +10 -4
- package/dist/esm/anchor.js.map +1 -1
- package/dist/esm/cli.d.ts +13 -19
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +88 -81
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts +95 -0
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +133 -2
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/customer-keys.d.ts +73 -1
- package/dist/esm/customer-keys.d.ts.map +1 -1
- package/dist/esm/customer-keys.js +323 -3
- package/dist/esm/customer-keys.js.map +1 -1
- package/dist/esm/enclave-registration.d.ts +153 -0
- package/dist/esm/enclave-registration.d.ts.map +1 -0
- package/dist/esm/enclave-registration.js +265 -0
- package/dist/esm/enclave-registration.js.map +1 -0
- package/dist/esm/enclave-seal.d.ts +43 -0
- package/dist/esm/enclave-seal.d.ts.map +1 -1
- package/dist/esm/enclave-seal.js +61 -1
- package/dist/esm/enclave-seal.js.map +1 -1
- package/dist/esm/errors.d.ts +10 -0
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +10 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.browser.d.ts +7 -3
- package/dist/esm/index.browser.d.ts.map +1 -1
- package/dist/esm/index.browser.js +6 -3
- package/dist/esm/index.browser.js.map +1 -1
- package/dist/esm/index.d.ts +22 -10
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +18 -8
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/key-group.d.ts +80 -0
- package/dist/esm/key-group.d.ts.map +1 -0
- package/dist/esm/key-group.js +130 -0
- package/dist/esm/key-group.js.map +1 -0
- package/dist/esm/models.d.ts +60 -0
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +59 -0
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/status-document.d.ts +25 -0
- package/dist/esm/status-document.d.ts.map +1 -0
- package/dist/esm/status-document.js +59 -0
- package/dist/esm/status-document.js.map +1 -0
- package/dist/esm/verify.d.ts +108 -3
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +386 -146
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/web-verifier.d.ts +23 -3
- package/dist/esm/web-verifier.d.ts.map +1 -1
- package/dist/esm/web-verifier.js +27 -5
- package/dist/esm/web-verifier.js.map +1 -1
- package/package.json +1 -1
- package/src/anchor.ts +10 -4
- package/src/cli.ts +96 -79
- package/src/client.ts +198 -6
- package/src/customer-keys.ts +373 -3
- package/src/enclave-registration.ts +390 -0
- package/src/enclave-seal.ts +108 -1
- package/src/errors.ts +11 -0
- package/src/index.browser.ts +9 -3
- package/src/index.ts +35 -6
- package/src/key-group.ts +181 -0
- package/src/models.ts +131 -0
- package/src/status-document.ts +59 -0
- package/src/verify.ts +499 -151
- package/src/web-verifier.ts +36 -3
package/src/cli.ts
CHANGED
|
@@ -9,7 +9,9 @@ import { nodeCrypto } from "./crypto-node.js";
|
|
|
9
9
|
import { CERTIFICATE_REVOKED, VerificationError } from "./errors.js";
|
|
10
10
|
import {
|
|
11
11
|
formatTimestamp,
|
|
12
|
+
issuingImage,
|
|
12
13
|
publicKeyFromHex,
|
|
14
|
+
registrationQualifier,
|
|
13
15
|
verifyCertificateWithStatus,
|
|
14
16
|
verifyTransparency,
|
|
15
17
|
KNOWN_FORMAT_VERSIONS,
|
|
@@ -24,23 +26,34 @@ import {
|
|
|
24
26
|
type KeyListEvidence,
|
|
25
27
|
} from "./keys.js";
|
|
26
28
|
import { ANCHOR_INCONSISTENT, checkStatementAnchor, type AnchorResult } from "./anchor.js";
|
|
27
|
-
import
|
|
29
|
+
import { unwrapStatusStatement } from "./status-document.js";
|
|
30
|
+
import type { AuthorizationPolicy, PublicKeyInfo } from "./verify.js";
|
|
31
|
+
|
|
32
|
+
// Exported from here too: tests/cli-revocation-unknown.test.ts reads it as the
|
|
33
|
+
// CLI's own parser.
|
|
34
|
+
export { unwrapStatusStatement };
|
|
28
35
|
|
|
29
36
|
type Cert = Record<string, unknown>;
|
|
30
37
|
/** A published status statement, exactly as GET /v1/certificates/{id}/status
|
|
31
38
|
* serves it: hex strings, not decoded bytes. verifyCertificateWithStatus reads
|
|
32
39
|
* this shape, so nothing here re-encodes a field the signature covers. */
|
|
33
40
|
type StatusStatement = Record<string, unknown>;
|
|
34
|
-
const USAGE =
|
|
41
|
+
const USAGE =
|
|
42
|
+
"Usage: burnledger check --cert <file> --keys <file> [--online --api-url <url>]\n" +
|
|
43
|
+
" [[--require-certified | --require-certified-system <system_id>...] --customer-key-id <id>...]\n";
|
|
35
44
|
|
|
36
45
|
function die(msg: string): never { process.stderr.write(msg); process.exit(2); }
|
|
37
46
|
|
|
38
47
|
function parseArgs(argv: string[]): {
|
|
39
48
|
certPath: string; keysPath: string; online: boolean; apiUrl: string;
|
|
49
|
+
policy: AuthorizationPolicy | undefined;
|
|
40
50
|
} {
|
|
41
51
|
const args = argv.slice(2);
|
|
42
52
|
if (args[0] !== "check") die(USAGE);
|
|
43
53
|
let certPath = "", keysPath = "", online = false, apiUrl = "";
|
|
54
|
+
let requireCertified = false;
|
|
55
|
+
const systems: string[] = [];
|
|
56
|
+
const keyIds: string[] = [];
|
|
44
57
|
for (let i = 1; i < args.length; i++) {
|
|
45
58
|
const a = args[i]!;
|
|
46
59
|
if (a === "--cert") { certPath = args[++i] ?? die("--cert requires a value\n"); }
|
|
@@ -51,9 +64,29 @@ function parseArgs(argv: string[]): {
|
|
|
51
64
|
// buys nothing — but it is a published flag that scripts already pass, and
|
|
52
65
|
// failing on an unknown flag would break them for no gain.
|
|
53
66
|
else if (a === "--api-key") { void (args[++i] ?? die("--api-key requires a value\n")); }
|
|
67
|
+
// ADR-025. Opt-in: without these, authorization: none verifies as it always
|
|
68
|
+
// has. The Go CLI has no equivalent yet; these names are the proposal for it.
|
|
69
|
+
else if (a === "--require-certified") { requireCertified = true; }
|
|
70
|
+
else if (a === "--require-certified-system") {
|
|
71
|
+
systems.push(args[++i] ?? die("--require-certified-system requires a value\n"));
|
|
72
|
+
}
|
|
73
|
+
else if (a === "--customer-key-id") { keyIds.push(args[++i] ?? die("--customer-key-id requires a value\n")); }
|
|
54
74
|
else die(`Unknown argument: ${a}\n`);
|
|
55
75
|
}
|
|
56
76
|
if (!certPath || !keysPath) die("Both --cert and --keys are required.\n");
|
|
77
|
+
// Every system, or these systems: asking for both is a mistake to name, not
|
|
78
|
+
// one to resolve silently in either direction.
|
|
79
|
+
if (requireCertified && systems.length > 0) {
|
|
80
|
+
die("--require-certified and --require-certified-system are alternatives; use one\n");
|
|
81
|
+
}
|
|
82
|
+
// Certified under a key id you did not name proves nothing to you: the relay
|
|
83
|
+
// can enroll a key group of its own, and the record would name that one.
|
|
84
|
+
if ((requireCertified || systems.length > 0) && keyIds.length === 0) {
|
|
85
|
+
die(
|
|
86
|
+
"--require-certified and --require-certified-system need --customer-key-id <id>: " +
|
|
87
|
+
"certified under a key you did not name proves nothing\n",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
57
90
|
if (online) {
|
|
58
91
|
try {
|
|
59
92
|
apiUrl = resolveApiBaseUrl(apiUrl, process.env);
|
|
@@ -61,7 +94,9 @@ function parseArgs(argv: string[]): {
|
|
|
61
94
|
die((e as Error).message + "\n");
|
|
62
95
|
}
|
|
63
96
|
}
|
|
64
|
-
|
|
97
|
+
const policy: AuthorizationPolicy | undefined =
|
|
98
|
+
keyIds.length > 0 ? { ...(systems.length > 0 ? { systems } : {}), customerKeyIds: keyIds } : undefined;
|
|
99
|
+
return { certPath, keysPath, online, apiUrl, policy };
|
|
65
100
|
}
|
|
66
101
|
|
|
67
102
|
/** Flag first, then environment — the precedence docs/cli.md promises and the
|
|
@@ -86,8 +121,8 @@ export function resolveApiBaseUrl(
|
|
|
86
121
|
* carries a signed statement — key_id == sha256(public_key) makes every
|
|
87
122
|
* identity self-checking — so an unsigned file is not refused. What changes is
|
|
88
123
|
* what this CLI is entitled to say about the statuses and dates. Mirrors
|
|
89
|
-
* loadKeysFromFile in cmd/cli/keys.go. */
|
|
90
|
-
async function loadKeys(path: string): Promise<{ keys: Map<string, PublicKeyInfo>; keyList: KeyListEvidence }> {
|
|
124
|
+
* loadKeysFromFile in cmd/cli/keys.go. Exported for testing. */
|
|
125
|
+
export async function loadKeys(path: string): Promise<{ keys: Map<string, PublicKeyInfo>; keyList: KeyListEvidence }> {
|
|
91
126
|
let entries: KeyEntry[];
|
|
92
127
|
let keyList: KeyListEvidence;
|
|
93
128
|
try {
|
|
@@ -154,59 +189,6 @@ async function fetchStatusStatement(apiUrl: string, certId: string): Promise<Sta
|
|
|
154
189
|
return unwrapStatusStatement(await res.json());
|
|
155
190
|
}
|
|
156
191
|
|
|
157
|
-
/** The API wraps every response in {"data": ...}; a caller may hand over either
|
|
158
|
-
* the envelope or the document inside it. Mirrors
|
|
159
|
-
* core.ParseCertificateStatusDocument, so this CLI and the Go one cannot
|
|
160
|
-
* disagree about which shape the endpoint returns. Exported for testing.
|
|
161
|
-
*
|
|
162
|
-
* A document that does not parse THROWS, and the caller turns that into a
|
|
163
|
-
* warning and a VALID_REVOCATION_UNKNOWN verdict. That is deliberate: an answer
|
|
164
|
-
* this build cannot read is silence about revocation, not a finding about the
|
|
165
|
-
* certificate. Handing a half-read document to the verifier instead would
|
|
166
|
-
* report "status statement signature is invalid" — a claim about the issuer —
|
|
167
|
-
* when what actually happened is that something returned nonsense.
|
|
168
|
-
*
|
|
169
|
-
* `status` itself is NOT validated against a known set. A statement saying
|
|
170
|
-
* SUSPENDED is a statement this build cannot read, and the verifier already has
|
|
171
|
-
* the right answer for that: it authenticates the signature first, then reports
|
|
172
|
-
* VALID_REVOCATION_UNKNOWN. Refusing it here would discard the statement before
|
|
173
|
-
* anyone authenticated it. */
|
|
174
|
-
export function unwrapStatusStatement(parsed: unknown): StatusStatement {
|
|
175
|
-
if (parsed === null || typeof parsed !== "object") {
|
|
176
|
-
throw new Error("status statement: response is not a JSON object");
|
|
177
|
-
}
|
|
178
|
-
let doc = parsed as StatusStatement;
|
|
179
|
-
if (doc.certificate_id === undefined && doc.data !== null && typeof doc.data === "object") {
|
|
180
|
-
doc = doc.data as StatusStatement;
|
|
181
|
-
}
|
|
182
|
-
for (const f of ["certificate_id", "status", "key_id"]) {
|
|
183
|
-
if (typeof doc[f] !== "string" || doc[f] === "") throw new Error(`status statement: ${f} is missing`);
|
|
184
|
-
}
|
|
185
|
-
for (const f of ["statement_issued_at", "statement_expires_at"]) requireRfc3339(doc, f);
|
|
186
|
-
if (doc.revoked_at != null) requireRfc3339(doc, "revoked_at");
|
|
187
|
-
if (typeof doc.sth_tree_size !== "number") throw new Error("status statement: sth_tree_size is not a number");
|
|
188
|
-
// Refuse a short or long value rather than letting it through: a truncated
|
|
189
|
-
// signature or root hash that reaches the verifier is checked against bytes
|
|
190
|
-
// nobody sent.
|
|
191
|
-
requireHex(doc, "sth_root_hash", 32);
|
|
192
|
-
requireHex(doc, "signature", 64);
|
|
193
|
-
return doc;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function requireRfc3339(doc: StatusStatement, field: string): void {
|
|
197
|
-
const v = doc[field];
|
|
198
|
-
if (typeof v !== "string" || Number.isNaN(Date.parse(v))) {
|
|
199
|
-
throw new Error(`status statement: ${field} is not RFC 3339`);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function requireHex(doc: StatusStatement, field: string, bytes: number): void {
|
|
204
|
-
const v = doc[field];
|
|
205
|
-
if (typeof v !== "string" || !new RegExp(`^[0-9a-fA-F]{${bytes * 2}}$`).test(v)) {
|
|
206
|
-
throw new Error(`status statement: ${field} is not ${bytes} bytes of hex`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
192
|
/** The verdicts a reader can act on, and therefore the ones that exit 0.
|
|
211
193
|
*
|
|
212
194
|
* VALID_REVOCATION_UNKNOWN is here. It is the verdict every offline run now
|
|
@@ -321,8 +303,15 @@ function pushRecordCounts(L: string[], sys: Record<string, unknown>[]): void {
|
|
|
321
303
|
}
|
|
322
304
|
|
|
323
305
|
/**
|
|
324
|
-
* Per system: did the enclave check it against a
|
|
325
|
-
* and
|
|
306
|
+
* Per system: did the enclave check it against a registration, under which key,
|
|
307
|
+
* and what does the image that signed the record make of that?
|
|
308
|
+
*
|
|
309
|
+
* Printed for exactly the verdicts that say the record's own signature
|
|
310
|
+
* verified: VALID, VALID_REVOCATION_UNKNOWN, the two soft key verdicts and
|
|
311
|
+
* REVOKED, as in the Go and Python CLIs and the /verify page. The qualifier is
|
|
312
|
+
* read from issuer.enclave_pcr0, which that signature covers, and
|
|
313
|
+
* verifyCertificate checks it before any of those verdicts is reached. Beside
|
|
314
|
+
* INVALID the field is unverified bytes, so nothing is printed there.
|
|
326
315
|
*
|
|
327
316
|
* This line is the WHOLE POINT of the field. ADR-025 §2: the enclave cannot
|
|
328
317
|
* refuse a second enrollment for a team — that needs durable state — so a parent
|
|
@@ -344,14 +333,11 @@ function pushRegistrationEvidence(L: string[], cert: Record<string, unknown>): v
|
|
|
344
333
|
);
|
|
345
334
|
return;
|
|
346
335
|
}
|
|
336
|
+
const qualifier = registrationQualifier(issuingImage(cert));
|
|
347
337
|
for (const sys of (cert.systems as Record<string, unknown>[]) ?? []) {
|
|
348
338
|
const name = sys.system_name as string;
|
|
349
339
|
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
|
-
);
|
|
340
|
+
L.push(` Registration [${name}]: certified under ${sys.customer_key_id as string} — ${qualifier}`);
|
|
355
341
|
} else if (sys.authorization === "none") {
|
|
356
342
|
L.push(
|
|
357
343
|
` Registration [${name}]: none — this verification was NOT checked against ` +
|
|
@@ -388,8 +374,9 @@ export function formatOutput(
|
|
|
388
374
|
status: StatusStatement | undefined, online: boolean, anchor?: AnchorResult,
|
|
389
375
|
keyList?: KeyListEvidence,
|
|
390
376
|
): string {
|
|
391
|
-
|
|
392
|
-
|
|
377
|
+
// This line prints for records that FAILED verification too, so it must not
|
|
378
|
+
// assume the shape verification would have checked.
|
|
379
|
+
const id = typeof cert.certificate_id === "string" ? cert.certificate_id.slice(0, 6) : "unknown";
|
|
393
380
|
const att = cert.attestation as Record<string, unknown>;
|
|
394
381
|
const L: string[] = [];
|
|
395
382
|
|
|
@@ -407,22 +394,33 @@ export function formatOutput(
|
|
|
407
394
|
return L.join("\n") + "\n";
|
|
408
395
|
}
|
|
409
396
|
|
|
410
|
-
// A soft key verdict is not a forgery: every signature verified, and what
|
|
411
|
-
// could not be established is the key's authority. "INVALID" would send an
|
|
412
|
-
// auditor hunting for tampering that is not there, and "VALID" — which this
|
|
413
|
-
// CLI printed — claims a check that did not happen.
|
|
414
|
-
const note = SOFT_KEY_VERDICTS[sigResult];
|
|
415
|
-
if (note !== undefined) return `Verification record dc_${id}: ${sigResult} (${note})\n`;
|
|
416
397
|
// Revocation is now a signed fact, so the reader gets the evidence with the
|
|
417
398
|
// verdict: who said it, when, and which log entry it points at.
|
|
418
399
|
if (sigResult === REVOKED) {
|
|
419
400
|
L.push(`Verification record dc_${id}: INVALID (REVOKED)`);
|
|
401
|
+
pushRegistrationEvidence(L, cert);
|
|
420
402
|
pushStatementEvidence(L, status);
|
|
421
403
|
pushAnchorEvidence(L, anchor);
|
|
422
404
|
return L.join("\n") + "\n";
|
|
423
405
|
}
|
|
424
|
-
|
|
406
|
+
const note = SOFT_KEY_VERDICTS[sigResult];
|
|
407
|
+
if (!USABLE_VERDICTS.has(sigResult) && note === undefined) {
|
|
408
|
+
return `Verification record dc_${id}: INVALID (${sigResult})\n`;
|
|
409
|
+
}
|
|
410
|
+
// A failed proof refuses the record, and it outranks a soft key verdict: a
|
|
411
|
+
// verdict that asks for a human must not hide one that refuses. The Python
|
|
412
|
+
// CLI and the /verify page already put it first; this CLI and Go printed
|
|
413
|
+
// the soft verdict instead.
|
|
425
414
|
if (transparencyFailed) return `Verification record dc_${id}: INVALID (transparency: ${transparency})\n`;
|
|
415
|
+
// A soft key verdict is not a forgery: every signature verified, and what
|
|
416
|
+
// could not be established is the key's authority. "INVALID" would send an
|
|
417
|
+
// auditor hunting for tampering that is not there, and "VALID" — which this
|
|
418
|
+
// CLI printed — claims a check that did not happen.
|
|
419
|
+
if (note !== undefined) {
|
|
420
|
+
L.push(`Verification record dc_${id}: ${sigResult} (${note})`);
|
|
421
|
+
pushRegistrationEvidence(L, cert);
|
|
422
|
+
return L.join("\n") + "\n";
|
|
423
|
+
}
|
|
426
424
|
|
|
427
425
|
// The two usable verdicts. VALID is sayable without a qualifier for the first
|
|
428
426
|
// time: it is reached only when a fresh statement, signed by a key from the
|
|
@@ -433,6 +431,7 @@ export function formatOutput(
|
|
|
433
431
|
? `${VALID_REVOCATION_UNKNOWN} (${online ? REVOCATION_UNKNOWN_ONLINE : REVOCATION_UNKNOWN_OFFLINE})`
|
|
434
432
|
: "VALID (a signed status statement says this record is live)";
|
|
435
433
|
L.push(`Verification record dc_${id}: ${tag}`);
|
|
434
|
+
const sub = displayHash((cert.subject as Record<string, unknown>).identifier_hash);
|
|
436
435
|
L.push(` Subject: sha256:${sub.slice(0, 6)}...`);
|
|
437
436
|
L.push(` Format version: ${cert.certificate_format_version as string}`);
|
|
438
437
|
|
|
@@ -583,7 +582,13 @@ export function unwrapCertificate(parsed: Cert): Cert {
|
|
|
583
582
|
|
|
584
583
|
async function main(): Promise<void> {
|
|
585
584
|
const args = parseArgs(process.argv);
|
|
586
|
-
const
|
|
585
|
+
const parsed: unknown = JSON.parse(await readFile(args.certPath, "utf-8"));
|
|
586
|
+
// No record to give a verdict about. Go's loader refuses the same file at
|
|
587
|
+
// unmarshal and exits 2, as this does.
|
|
588
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
589
|
+
die(`${args.certPath}: not a JSON object\n`);
|
|
590
|
+
}
|
|
591
|
+
const cert = unwrapCertificate(parsed as Cert);
|
|
587
592
|
const { keys, keyList } = await loadKeys(args.keysPath);
|
|
588
593
|
|
|
589
594
|
// Fetched first, because the verdict now depends on it. A status statement is
|
|
@@ -623,11 +628,19 @@ async function main(): Promise<void> {
|
|
|
623
628
|
// and VALID_KEY_COMPROMISED_LATER are cases verifyCertificateWithStatus
|
|
624
629
|
// deliberately refused to call VALID, and this is the tool an auditor runs in
|
|
625
630
|
// a script — the one whose exit code gets trusted.
|
|
631
|
+
//
|
|
632
|
+
// Only a VerificationError is a verdict. Anything else is a defect in this
|
|
633
|
+
// CLI or the SDK under it; printing it as INVALID dressed a crash up as a
|
|
634
|
+
// finding about the record, so it propagates and exits 2 instead.
|
|
626
635
|
let sigResult = "VALID";
|
|
627
|
-
try {
|
|
636
|
+
try {
|
|
637
|
+
sigResult = await verifyCertificateWithStatus(nodeCrypto, cert, keys, status ?? null, undefined, {
|
|
638
|
+
requireAuthorization: args.policy,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
628
641
|
catch (e) {
|
|
629
|
-
if (e instanceof VerificationError
|
|
630
|
-
|
|
642
|
+
if (!(e instanceof VerificationError)) throw e;
|
|
643
|
+
sigResult = e.code === CERTIFICATE_REVOKED ? REVOKED : e.message;
|
|
631
644
|
}
|
|
632
645
|
|
|
633
646
|
// NOT_AVAILABLE (no proof) is a legitimate result; only a thrown error is a
|
|
@@ -635,7 +648,11 @@ async function main(): Promise<void> {
|
|
|
635
648
|
let transparency = "NOT_AVAILABLE";
|
|
636
649
|
let transparencyFailed = false;
|
|
637
650
|
try { transparency = await verifyTransparency(nodeCrypto, cert, keys); }
|
|
638
|
-
catch (e) {
|
|
651
|
+
catch (e) {
|
|
652
|
+
if (!(e instanceof VerificationError)) throw e;
|
|
653
|
+
transparency = e.message;
|
|
654
|
+
transparencyFailed = true;
|
|
655
|
+
}
|
|
639
656
|
|
|
640
657
|
const valid = certVerdict(sigResult, transparencyFailed, anchor);
|
|
641
658
|
|
package/src/client.ts
CHANGED
|
@@ -48,7 +48,14 @@ import {
|
|
|
48
48
|
parseWebhookDelivery,
|
|
49
49
|
parseWebhookRotateResponse,
|
|
50
50
|
} from "./models.js";
|
|
51
|
+
import type { KeyEnrollmentResult, SystemRegistrationCertificate } from "./models.js";
|
|
51
52
|
import { Paginator } from "./pagination.js";
|
|
53
|
+
// Types only: erased at build, so the browser entry that shares this module
|
|
54
|
+
// never loads node:crypto. The code behind them is imported lazily, on use.
|
|
55
|
+
import type { CustomerKeyGroup } from "./customer-keys.js";
|
|
56
|
+
import type { EnclavePinOptions, RegisteredSystem } from "./enclave-registration.js";
|
|
57
|
+
import type { EnclaveIdentity } from "./enclave-seal.js";
|
|
58
|
+
import type { KeyGroupSigner } from "./key-group.js";
|
|
52
59
|
|
|
53
60
|
type Raw = Record<string, unknown>;
|
|
54
61
|
|
|
@@ -102,6 +109,13 @@ export class BurnLedger {
|
|
|
102
109
|
connectionConfig?: Record<string, unknown>;
|
|
103
110
|
dsn?: string;
|
|
104
111
|
uri?: string;
|
|
112
|
+
/**
|
|
113
|
+
* The connection config already sealed to the enclave — by
|
|
114
|
+
* sealConnectionConfig, or by createRegisteredSystem, which does it for you.
|
|
115
|
+
* The API relays these bytes and cannot open them. Instead of
|
|
116
|
+
* connectionConfig/dsn/uri, never beside them.
|
|
117
|
+
*/
|
|
118
|
+
sealedConnectionConfig?: Uint8Array;
|
|
105
119
|
subjectQuery: string;
|
|
106
120
|
hashScope?: string;
|
|
107
121
|
/** The system holds data you treat as PHI. Makes the default proof mode
|
|
@@ -112,15 +126,19 @@ export class BurnLedger {
|
|
|
112
126
|
maxBytes?: number;
|
|
113
127
|
queryTimeout?: string;
|
|
114
128
|
}): Promise<System> {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
opts.uri
|
|
119
|
-
)
|
|
129
|
+
const sealed = opts.sealedConnectionConfig;
|
|
130
|
+
if (
|
|
131
|
+
sealed !== undefined &&
|
|
132
|
+
(opts.connectionConfig !== undefined || opts.dsn !== undefined || opts.uri !== undefined)
|
|
133
|
+
) {
|
|
134
|
+
throw new Error("Cannot provide both sealedConnectionConfig and connectionConfig/dsn/uri.");
|
|
135
|
+
}
|
|
120
136
|
const body = {
|
|
121
137
|
name: opts.name,
|
|
122
138
|
connector_type: opts.connectorType,
|
|
123
|
-
|
|
139
|
+
...(sealed === undefined
|
|
140
|
+
? { connection_config: resolveConnectionConfig(opts.connectionConfig, opts.dsn, opts.uri) }
|
|
141
|
+
: { sealed_connection_config: bytesToBase64(sealed) }),
|
|
124
142
|
subject_query: opts.subjectQuery,
|
|
125
143
|
hash_scope: opts.hashScope ?? "existence",
|
|
126
144
|
phi_in_scope: opts.phiInScope ?? false,
|
|
@@ -458,6 +476,172 @@ export class BurnLedger {
|
|
|
458
476
|
await this.transport.request("DELETE", "/v1/me");
|
|
459
477
|
}
|
|
460
478
|
|
|
479
|
+
// --- Customer key groups and enclave registration (ADR-025; Node only) ---
|
|
480
|
+
//
|
|
481
|
+
// These load node:crypto on first use, lazily, because this class is also the
|
|
482
|
+
// browser entry's. ADR-025 §2 makes a client the customer runs locally the
|
|
483
|
+
// only place enrolment can happen, so a browser has no business here anyway.
|
|
484
|
+
// `teamId` is the team every signed payload binds; it is also sent as
|
|
485
|
+
// asserted_team_id, so naming the wrong team is a 403 that says so rather than
|
|
486
|
+
// an enclave 401 that cannot.
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Verify the enclave: `GET /v1/enclave/attestation` over a 32-byte challenge
|
|
490
|
+
* drawn here (or `pin.nonce`), checked against the PCR0 you pinned out of band.
|
|
491
|
+
* Returns the keys the document binds. Throws EnclaveAttestationError on
|
|
492
|
+
* anything short of a document from the pinned image answering this challenge.
|
|
493
|
+
* Pass the result as `enclave` to the calls below: configs are sealed to it,
|
|
494
|
+
* and every document they return must verify under its signing key.
|
|
495
|
+
*/
|
|
496
|
+
async attestEnclaveIdentity(pin: EnclavePinOptions): Promise<EnclaveIdentity> {
|
|
497
|
+
const flow = await import("./enclave-registration.js");
|
|
498
|
+
const { nonce, params } = flow.attestationParams(pin.nonce);
|
|
499
|
+
const data = await this.transport.request("GET", "/v1/enclave/attestation", {
|
|
500
|
+
params,
|
|
501
|
+
authenticated: false,
|
|
502
|
+
});
|
|
503
|
+
return flow.identityFromResponse(data, { ...pin, nonce });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Enrol a key group for your team (ENROLL_KEY). Every member signs; the private
|
|
508
|
+
* keys stay with the signers. `notAfter` is the latest the authorization may
|
|
509
|
+
* end, signed by every member so a relayed copy of this request cannot renew
|
|
510
|
+
* it; it defaults to the enclave's 90-day maximum. Throws VerificationError
|
|
511
|
+
* unless both documents that come back verify under `enclave`'s signing key,
|
|
512
|
+
* name exactly this group — ADR-025 §2's one comparison — and your team, end
|
|
513
|
+
* exactly at `notAfter` and do not start after `now` (this machine's clock by default).
|
|
514
|
+
*/
|
|
515
|
+
async enrollKeyGroup(opts: {
|
|
516
|
+
teamId: string;
|
|
517
|
+
group: CustomerKeyGroup;
|
|
518
|
+
signers: readonly KeyGroupSigner[];
|
|
519
|
+
enclave: EnclaveIdentity;
|
|
520
|
+
notAfter?: Date;
|
|
521
|
+
/** The time to judge the answer's not_before against. Defaults to now. */
|
|
522
|
+
now?: Date;
|
|
523
|
+
}): Promise<KeyEnrollmentResult> {
|
|
524
|
+
const flow = await import("./enclave-registration.js");
|
|
525
|
+
const { body, keyId, notAfter } = await flow.enrollBody(opts);
|
|
526
|
+
const data = await this.transport.request("POST", "/v1/enclave/keys", { json: body });
|
|
527
|
+
return flow.checkEnrollment(data, {
|
|
528
|
+
enclave: opts.enclave,
|
|
529
|
+
keyId,
|
|
530
|
+
prevKeyId: undefined,
|
|
531
|
+
teamId: opts.teamId,
|
|
532
|
+
notAfter,
|
|
533
|
+
now: opts.now,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Replace an enrolled group with another (ROTATE_KEY). The OUTGOING group
|
|
539
|
+
* signs at its threshold and EVERY incoming member signs, over the same
|
|
540
|
+
* request and `notAfter`. Systems registered under the old group keep working
|
|
541
|
+
* through the link this issues; nothing is re-registered. The answer is checked
|
|
542
|
+
* as `enrollKeyGroup`'s is, and must also link to the outgoing group.
|
|
543
|
+
*/
|
|
544
|
+
async rotateKeyGroup(opts: {
|
|
545
|
+
teamId: string;
|
|
546
|
+
previousGroup: CustomerKeyGroup;
|
|
547
|
+
previousSigners: readonly KeyGroupSigner[];
|
|
548
|
+
nextGroup: CustomerKeyGroup;
|
|
549
|
+
nextSigners: readonly KeyGroupSigner[];
|
|
550
|
+
enclave: EnclaveIdentity;
|
|
551
|
+
notAfter?: Date;
|
|
552
|
+
/** The time to judge the answer's not_before against. Defaults to now. */
|
|
553
|
+
now?: Date;
|
|
554
|
+
}): Promise<KeyEnrollmentResult> {
|
|
555
|
+
const flow = await import("./enclave-registration.js");
|
|
556
|
+
const { body, prevKeyId, nextKeyId, notAfter } = await flow.rotateBody(opts);
|
|
557
|
+
const data = await this.transport.request("POST", "/v1/enclave/keys/rotate", { json: body });
|
|
558
|
+
return flow.checkEnrollment(data, {
|
|
559
|
+
enclave: opts.enclave,
|
|
560
|
+
keyId: nextKeyId,
|
|
561
|
+
prevKeyId,
|
|
562
|
+
teamId: opts.teamId,
|
|
563
|
+
notAfter,
|
|
564
|
+
now: opts.now,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Register an existing system under your key group (REGISTER_SYSTEM), signed at
|
|
570
|
+
* the group's threshold. `config` must be the exact bytes submitted when the
|
|
571
|
+
* system was created, and `queryTemplate` the system's subject query exactly.
|
|
572
|
+
* The registration that comes back must verify under `enclave`'s signing key.
|
|
573
|
+
*/
|
|
574
|
+
async registerSystemWithKey(opts: {
|
|
575
|
+
teamId: string;
|
|
576
|
+
group: CustomerKeyGroup;
|
|
577
|
+
signers: readonly KeyGroupSigner[];
|
|
578
|
+
enclave: EnclaveIdentity;
|
|
579
|
+
systemId: string;
|
|
580
|
+
config: Uint8Array | string;
|
|
581
|
+
queryTemplate: string;
|
|
582
|
+
connectorType: string;
|
|
583
|
+
}): Promise<SystemRegistrationCertificate> {
|
|
584
|
+
const flow = await import("./enclave-registration.js");
|
|
585
|
+
const { body, keyId, configDigest } = await flow.registrationBody(opts);
|
|
586
|
+
const data = await this.transport.request("POST", "/v1/enclave/registrations", { json: body });
|
|
587
|
+
return flow.checkRegistration(data, {
|
|
588
|
+
enclave: opts.enclave,
|
|
589
|
+
keyId,
|
|
590
|
+
systemId: opts.systemId,
|
|
591
|
+
configDigest,
|
|
592
|
+
connectorType: opts.connectorType,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* ADR-025's whole customer path for a new system: seal the config to `enclave`
|
|
598
|
+
* (from {@link attestEnclaveIdentity}), create the system with only the sealed
|
|
599
|
+
* bytes, and register it under your key group.
|
|
600
|
+
*/
|
|
601
|
+
async createRegisteredSystem(opts: {
|
|
602
|
+
teamId: string;
|
|
603
|
+
group: CustomerKeyGroup;
|
|
604
|
+
signers: readonly KeyGroupSigner[];
|
|
605
|
+
enclave: EnclaveIdentity;
|
|
606
|
+
name: string;
|
|
607
|
+
connectorType: string;
|
|
608
|
+
connectionConfig?: Record<string, unknown>;
|
|
609
|
+
dsn?: string;
|
|
610
|
+
uri?: string;
|
|
611
|
+
subjectQuery: string;
|
|
612
|
+
hashScope?: string;
|
|
613
|
+
phiInScope?: boolean;
|
|
614
|
+
maxRecords?: number;
|
|
615
|
+
maxBytes?: number;
|
|
616
|
+
queryTimeout?: string;
|
|
617
|
+
}): Promise<RegisteredSystem> {
|
|
618
|
+
const flow = await import("./enclave-registration.js");
|
|
619
|
+
const { sealToKey } = await import("./enclave-seal.js");
|
|
620
|
+
const config = flow.configBytes(resolveConnectionConfig(opts.connectionConfig, opts.dsn, opts.uri));
|
|
621
|
+
const system = await this.registerSystem({
|
|
622
|
+
name: opts.name,
|
|
623
|
+
connectorType: opts.connectorType,
|
|
624
|
+
subjectQuery: opts.subjectQuery,
|
|
625
|
+
hashScope: opts.hashScope,
|
|
626
|
+
phiInScope: opts.phiInScope,
|
|
627
|
+
maxRecords: opts.maxRecords,
|
|
628
|
+
maxBytes: opts.maxBytes,
|
|
629
|
+
queryTimeout: opts.queryTimeout,
|
|
630
|
+
sealedConnectionConfig: await sealToKey(opts.enclave.configSealKey, config),
|
|
631
|
+
});
|
|
632
|
+
const registration = await this.registerSystemWithKey({
|
|
633
|
+
teamId: opts.teamId,
|
|
634
|
+
group: opts.group,
|
|
635
|
+
signers: opts.signers,
|
|
636
|
+
enclave: opts.enclave,
|
|
637
|
+
systemId: system.id,
|
|
638
|
+
config,
|
|
639
|
+
queryTemplate: opts.subjectQuery,
|
|
640
|
+
connectorType: opts.connectorType,
|
|
641
|
+
});
|
|
642
|
+
return { system, registration };
|
|
643
|
+
}
|
|
644
|
+
|
|
461
645
|
// --- System Health ---
|
|
462
646
|
|
|
463
647
|
async getSystemHealth(systemId: string): Promise<SystemHealth> {
|
|
@@ -532,6 +716,14 @@ export class BurnLedger {
|
|
|
532
716
|
// Connection config resolution
|
|
533
717
|
// ---------------------------------------------------------------------------
|
|
534
718
|
|
|
719
|
+
/** Standard base64, as Go decodes a []byte field. No Buffer: this module is
|
|
720
|
+
* also the browser entry's. */
|
|
721
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
722
|
+
let binary = "";
|
|
723
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
724
|
+
return btoa(binary);
|
|
725
|
+
}
|
|
726
|
+
|
|
535
727
|
function resolveConnectionConfig(
|
|
536
728
|
connectionConfig: Record<string, unknown> | undefined,
|
|
537
729
|
dsn: string | undefined,
|