burnledger 0.9.0 → 0.10.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 +9 -4
- package/dist/cjs/audit-pack.d.ts +201 -0
- package/dist/cjs/audit-pack.d.ts.map +1 -0
- package/dist/cjs/audit-pack.js +867 -0
- package/dist/cjs/audit-pack.js.map +1 -0
- package/dist/cjs/client.d.ts +41 -1
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +107 -59
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/enclave-registration.d.ts +14 -2
- package/dist/cjs/enclave-registration.d.ts.map +1 -1
- package/dist/cjs/enclave-registration.js +14 -2
- package/dist/cjs/enclave-registration.js.map +1 -1
- package/dist/cjs/index.d.ts +46 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +114 -2
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/models.d.ts +28 -2
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +19 -1
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/node-runtime.d.ts +40 -0
- package/dist/cjs/node-runtime.d.ts.map +1 -0
- package/dist/cjs/node-runtime.js +42 -0
- package/dist/cjs/node-runtime.js.map +1 -0
- package/dist/cjs/run-record.d.ts +130 -0
- package/dist/cjs/run-record.d.ts.map +1 -0
- package/dist/cjs/run-record.js +272 -0
- package/dist/cjs/run-record.js.map +1 -0
- package/dist/cjs/verify.d.ts +58 -0
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +277 -38
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/webhooks.d.ts +9 -2
- package/dist/cjs/webhooks.d.ts.map +1 -1
- package/dist/cjs/webhooks.js +29 -11
- package/dist/cjs/webhooks.js.map +1 -1
- package/dist/esm/audit-pack.d.ts +201 -0
- package/dist/esm/audit-pack.d.ts.map +1 -0
- package/dist/esm/audit-pack.js +858 -0
- package/dist/esm/audit-pack.js.map +1 -0
- package/dist/esm/cli.d.ts +52 -0
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +283 -11
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts +41 -1
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +108 -27
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/enclave-registration.d.ts +14 -2
- package/dist/esm/enclave-registration.d.ts.map +1 -1
- package/dist/esm/enclave-registration.js +14 -2
- package/dist/esm/enclave-registration.js.map +1 -1
- package/dist/esm/index.d.ts +46 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +64 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/models.d.ts +28 -2
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +18 -1
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/node-runtime.d.ts +40 -0
- package/dist/esm/node-runtime.d.ts.map +1 -0
- package/dist/esm/node-runtime.js +38 -0
- package/dist/esm/node-runtime.js.map +1 -0
- package/dist/esm/run-record.d.ts +130 -0
- package/dist/esm/run-record.d.ts.map +1 -0
- package/dist/esm/run-record.js +262 -0
- package/dist/esm/run-record.js.map +1 -0
- package/dist/esm/verify.d.ts +58 -0
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +270 -41
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/webhooks.d.ts +9 -2
- package/dist/esm/webhooks.d.ts.map +1 -1
- package/dist/esm/webhooks.js +29 -11
- package/dist/esm/webhooks.js.map +1 -1
- package/package.json +1 -1
- package/src/audit-pack.ts +1067 -0
- package/src/cli.ts +289 -10
- package/src/client.ts +132 -27
- package/src/enclave-registration.ts +14 -2
- package/src/index.ts +130 -1
- package/src/models.ts +47 -3
- package/src/node-runtime.ts +57 -0
- package/src/run-record.ts +371 -0
- package/src/verify.ts +301 -41
- package/src/webhooks.ts +28 -11
package/src/cli.ts
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
6
|
import { realpathSync } from "node:fs";
|
|
7
|
+
import { basename } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { nodeCrypto } from "./crypto-node.js";
|
|
9
10
|
import { CERTIFICATE_REVOKED, VerificationError } from "./errors.js";
|
|
10
11
|
import {
|
|
12
|
+
bytesToHex,
|
|
11
13
|
formatTimestamp,
|
|
12
14
|
issuingImage,
|
|
13
15
|
publicKeyFromHex,
|
|
@@ -27,6 +29,8 @@ import {
|
|
|
27
29
|
} from "./keys.js";
|
|
28
30
|
import { ANCHOR_INCONSISTENT, checkStatementAnchor, type AnchorResult } from "./anchor.js";
|
|
29
31
|
import { unwrapStatusStatement } from "./status-document.js";
|
|
32
|
+
import { chainAuditPackPages, verifyAuditPack, type AuditPackPage, type AuditPackResult } from "./audit-pack.js";
|
|
33
|
+
import { checkRunAgainstRecords, type RunCheckRecord, type RunRecord } from "./run-record.js";
|
|
30
34
|
import type { AuthorizationPolicy, PublicKeyInfo } from "./verify.js";
|
|
31
35
|
|
|
32
36
|
// Exported from here too: tests/cli-revocation-unknown.test.ts reads it as the
|
|
@@ -40,23 +44,26 @@ type Cert = Record<string, unknown>;
|
|
|
40
44
|
type StatusStatement = Record<string, unknown>;
|
|
41
45
|
const USAGE =
|
|
42
46
|
"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"
|
|
47
|
+
" [[--require-certified | --require-certified-system <system_id>...] --customer-key-id <id>...]\n" +
|
|
48
|
+
" burnledger check --pack <file> [--pack <file>...] [--keys <file>] [--online --api-url <url>]\n";
|
|
44
49
|
|
|
45
50
|
function die(msg: string): never { process.stderr.write(msg); process.exit(2); }
|
|
46
51
|
|
|
47
52
|
function parseArgs(argv: string[]): {
|
|
48
|
-
certPath: string; keysPath: string; online: boolean; apiUrl: string;
|
|
53
|
+
certPath: string; packPaths: string[]; keysPath: string; online: boolean; apiUrl: string;
|
|
49
54
|
policy: AuthorizationPolicy | undefined;
|
|
50
55
|
} {
|
|
51
56
|
const args = argv.slice(2);
|
|
52
57
|
if (args[0] !== "check") die(USAGE);
|
|
53
58
|
let certPath = "", keysPath = "", online = false, apiUrl = "";
|
|
54
59
|
let requireCertified = false;
|
|
60
|
+
const packPaths: string[] = [];
|
|
55
61
|
const systems: string[] = [];
|
|
56
62
|
const keyIds: string[] = [];
|
|
57
63
|
for (let i = 1; i < args.length; i++) {
|
|
58
64
|
const a = args[i]!;
|
|
59
65
|
if (a === "--cert") { certPath = args[++i] ?? die("--cert requires a value\n"); }
|
|
66
|
+
else if (a === "--pack") { packPaths.push(args[++i] ?? die("--pack requires a value\n")); }
|
|
60
67
|
else if (a === "--keys") { keysPath = args[++i] ?? die("--keys requires a value\n"); }
|
|
61
68
|
else if (a === "--online") { online = true; }
|
|
62
69
|
else if (a === "--api-url") { apiUrl = args[++i] ?? die("--api-url requires a value\n"); }
|
|
@@ -73,7 +80,17 @@ function parseArgs(argv: string[]): {
|
|
|
73
80
|
else if (a === "--customer-key-id") { keyIds.push(args[++i] ?? die("--customer-key-id requires a value\n")); }
|
|
74
81
|
else die(`Unknown argument: ${a}\n`);
|
|
75
82
|
}
|
|
76
|
-
|
|
83
|
+
// One record or a pack of them, never both: the two have different verdicts,
|
|
84
|
+
// different exit codes and different output, and a run that printed one
|
|
85
|
+
// after the other would leave a script reading the wrong first line.
|
|
86
|
+
if (packPaths.length > 0 && certPath) die("--pack and --cert are alternatives; use one\n");
|
|
87
|
+
if (packPaths.length === 0 && (!certPath || !keysPath)) die("Both --cert and --keys are required.\n");
|
|
88
|
+
// The authorization policy is a question about one record's systems. Over a
|
|
89
|
+
// pack it would need a per-record answer the pack output has no line for,
|
|
90
|
+
// and refusing it is better than silently ignoring a flag that says "fail".
|
|
91
|
+
if (packPaths.length > 0 && (requireCertified || systems.length > 0 || keyIds.length > 0)) {
|
|
92
|
+
die("--require-certified, --require-certified-system and --customer-key-id are for a single --cert, not --pack\n");
|
|
93
|
+
}
|
|
77
94
|
// Every system, or these systems: asking for both is a mistake to name, not
|
|
78
95
|
// one to resolve silently in either direction.
|
|
79
96
|
if (requireCertified && systems.length > 0) {
|
|
@@ -96,7 +113,7 @@ function parseArgs(argv: string[]): {
|
|
|
96
113
|
}
|
|
97
114
|
const policy: AuthorizationPolicy | undefined =
|
|
98
115
|
keyIds.length > 0 ? { ...(systems.length > 0 ? { systems } : {}), customerKeyIds: keyIds } : undefined;
|
|
99
|
-
return { certPath, keysPath, online, apiUrl, policy };
|
|
116
|
+
return { certPath, packPaths, keysPath, online, apiUrl, policy };
|
|
100
117
|
}
|
|
101
118
|
|
|
102
119
|
/** Flag first, then environment — the precedence docs/cli.md promises and the
|
|
@@ -182,11 +199,61 @@ export async function loadKeys(path: string): Promise<{ keys: Map<string, Public
|
|
|
182
199
|
* issuer's signature.
|
|
183
200
|
*
|
|
184
201
|
* No Authorization header is sent. The endpoint takes none, and sending an API
|
|
185
|
-
* key to an arbitrary --api-url is a credential leak the check never needed.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
202
|
+
* key to an arbitrary --api-url is a credential leak the check never needed.
|
|
203
|
+
*
|
|
204
|
+
* Read under a 1 MiB cap and a total deadline, matching the anchor fetch
|
|
205
|
+
* (anchor.ts) and the Go CLI (cmd/cli/online.go): a status statement is a few
|
|
206
|
+
* hundred bytes, so anything near the cap is a misbehaving or hostile server.
|
|
207
|
+
* The AbortController bounds the whole fetch including the body read, which a
|
|
208
|
+
* server dripping bytes could otherwise hold open. Exported for testing. */
|
|
209
|
+
export async function fetchStatusStatement(apiUrl: string, certId: string): Promise<StatusStatement> {
|
|
210
|
+
const url = `${apiUrl.replace(/\/+$/, "")}/v1/certificates/${certId}/status`;
|
|
211
|
+
const controller = new AbortController();
|
|
212
|
+
const timer = setTimeout(() => { controller.abort(); }, STATUS_FETCH_TIMEOUT_MS);
|
|
213
|
+
try {
|
|
214
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
215
|
+
const body = await readCapped(res);
|
|
216
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
217
|
+
try {
|
|
218
|
+
return unwrapStatusStatement(JSON.parse(body));
|
|
219
|
+
} catch (e) {
|
|
220
|
+
throw new Error(`parse response: ${e instanceof Error ? e.message : String(e)}`);
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** How much of the status response to buffer (1 MiB) and how long the whole
|
|
228
|
+
* fetch may take (10 s), matching cmd/cli/online.go and anchor.ts. */
|
|
229
|
+
const MAX_STATUS_BYTES = 1 << 20;
|
|
230
|
+
const STATUS_FETCH_TIMEOUT_MS = 10_000;
|
|
231
|
+
|
|
232
|
+
/** Read a response body, stopping at the cap instead of buffering whatever a
|
|
233
|
+
* hostile server decides to send. The stream is cancelled on the way out, so an
|
|
234
|
+
* endless body costs one megabyte and not the process. */
|
|
235
|
+
async function readCapped(res: Response): Promise<string> {
|
|
236
|
+
const reader = res.body?.getReader();
|
|
237
|
+
if (reader === undefined) return "";
|
|
238
|
+
const chunks: Uint8Array[] = [];
|
|
239
|
+
let total = 0;
|
|
240
|
+
for (;;) {
|
|
241
|
+
const { done, value } = await reader.read();
|
|
242
|
+
if (done) break;
|
|
243
|
+
total += value.length;
|
|
244
|
+
if (total > MAX_STATUS_BYTES) {
|
|
245
|
+
await reader.cancel();
|
|
246
|
+
throw new Error(`read response: body exceeds ${MAX_STATUS_BYTES} bytes`);
|
|
247
|
+
}
|
|
248
|
+
chunks.push(value);
|
|
249
|
+
}
|
|
250
|
+
const joined = new Uint8Array(total);
|
|
251
|
+
let at = 0;
|
|
252
|
+
for (const c of chunks) {
|
|
253
|
+
joined.set(c, at);
|
|
254
|
+
at += c.length;
|
|
255
|
+
}
|
|
256
|
+
return new TextDecoder().decode(joined);
|
|
190
257
|
}
|
|
191
258
|
|
|
192
259
|
/** The verdicts a reader can act on, and therefore the ones that exit 0.
|
|
@@ -271,7 +338,7 @@ const REVOCATION_UNKNOWN_ONLINE =
|
|
|
271
338
|
* enclave measurement was unattributable when the signature covers it.
|
|
272
339
|
* scripts/check-pcr0-signed-parity.py now pins this list to the Go function.
|
|
273
340
|
*/
|
|
274
|
-
const ENCLAVE_PCR0_SIGNED_IN: readonly string[] = Object.freeze(["5.0", "6.0", "7.0", "8.0", "9.0"]);
|
|
341
|
+
const ENCLAVE_PCR0_SIGNED_IN: readonly string[] = Object.freeze(["5.0", "6.0", "7.0", "8.0", "9.0", "10.0"]);
|
|
275
342
|
|
|
276
343
|
function signatureCoversEnclavePcr0(version: string): boolean {
|
|
277
344
|
return ENCLAVE_PCR0_SIGNED_IN.includes(version);
|
|
@@ -566,6 +633,217 @@ function pushStatementEvidence(L: string[], status: StatusStatement | undefined)
|
|
|
566
633
|
}
|
|
567
634
|
}
|
|
568
635
|
|
|
636
|
+
/** One page's block, laid out as docs/audit-pack.md specifies. Every line is
|
|
637
|
+
* evidence the verdict rests on, and the two that qualify it — where the keys
|
|
638
|
+
* came from, and whether another page exists — are printed on a VERIFIED page
|
|
639
|
+
* too, because that word says nothing about either. Exported for testing:
|
|
640
|
+
* docs/cli.md pins two of these blocks byte for byte. */
|
|
641
|
+
export function formatAuditPackOutput(
|
|
642
|
+
fileName: string, result: AuditPackResult, opts: { keysFile?: string; online: boolean },
|
|
643
|
+
): string {
|
|
644
|
+
const name = basename(fileName);
|
|
645
|
+
if (result.verdict === "UNREADABLE") return `Audit pack ${name}: UNREADABLE (${result.reason})\n`;
|
|
646
|
+
// Every readable result carries a header; the scanner refuses a page
|
|
647
|
+
// without one before a verdict is reached.
|
|
648
|
+
const header = result.header!;
|
|
649
|
+
const L: string[] = [];
|
|
650
|
+
L.push(`Audit pack ${name}: ${result.verdict}`);
|
|
651
|
+
L.push(` Format: ${header.format}`);
|
|
652
|
+
L.push(` Generated: ${header.generatedAt}`);
|
|
653
|
+
L.push(` Records: ${result.records - result.failed.length} verified, ${result.failed.length} refused`);
|
|
654
|
+
L.push(` Not yet in the log: ${result.notIncluded}`);
|
|
655
|
+
L.push(` Labeled revoked by the issuer: ${result.revoked} (unsigned; re-run with --online to evidence)`);
|
|
656
|
+
if (opts.online) {
|
|
657
|
+
L.push(` Revoked by signed statement: ${result.statusRevoked} of ${result.statusChecked} checked`);
|
|
658
|
+
if (result.statusRevoked > 0) {
|
|
659
|
+
L.push(" Revoked:");
|
|
660
|
+
for (const id of result.revokedIds) L.push(` ${id}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
L.push(
|
|
664
|
+
result.keysFromPack
|
|
665
|
+
? " Keys: from the pack itself (trust on first use; pass --keys to pin them)"
|
|
666
|
+
: ` Keys: from ${basename(opts.keysFile ?? "")}`,
|
|
667
|
+
);
|
|
668
|
+
const treeSize = header.logHead?.tree_size ?? 0;
|
|
669
|
+
switch (result.logHead) {
|
|
670
|
+
case "VALID":
|
|
671
|
+
L.push(` Log head: tree size ${treeSize}, signature VALID under ${result.logHeadKeyId}`);
|
|
672
|
+
break;
|
|
673
|
+
case "ABSENT":
|
|
674
|
+
L.push(" Log head: absent");
|
|
675
|
+
break;
|
|
676
|
+
case "INVALID_SIGNATURE":
|
|
677
|
+
L.push(` Log head: tree size ${treeSize}, signature INVALID_SIGNATURE (no key in use signed it)`);
|
|
678
|
+
break;
|
|
679
|
+
case "UNREADABLE":
|
|
680
|
+
L.push(" Log head: unreadable");
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
L.push(` Digest: sha256:${result.digest}`);
|
|
684
|
+
if (result.run !== null) {
|
|
685
|
+
let inclusion = "not yet in the log";
|
|
686
|
+
if (result.runInclusion === "VALID") {
|
|
687
|
+
// VALID is only reached through a proof, whose entry_index the
|
|
688
|
+
// inclusion check read as an exact integer.
|
|
689
|
+
inclusion = `in the log at index ${String(result.run.transparency!.entry_index)}`;
|
|
690
|
+
} else if (result.runInclusion !== "NOT_INCLUDED") {
|
|
691
|
+
inclusion = `inclusion ${result.runInclusion}`;
|
|
692
|
+
}
|
|
693
|
+
L.push(` Run record: ${result.runResult} under ${result.run.issuer.keyId}, ${inclusion}`);
|
|
694
|
+
}
|
|
695
|
+
switch (result.footerStatement) {
|
|
696
|
+
case "VALID":
|
|
697
|
+
// VALID is only reached through a footer that carries signed fields; the
|
|
698
|
+
// instant is printed as the file wrote it, as the Go CLI prints it.
|
|
699
|
+
L.push(
|
|
700
|
+
` Footer: signed under ${result.footerKeyId} at ${result.footer!.signed!.statement_issued_at} ` +
|
|
701
|
+
"(count and digest attested by the issuer)",
|
|
702
|
+
);
|
|
703
|
+
break;
|
|
704
|
+
case "UNSIGNED":
|
|
705
|
+
L.push(" Footer: unsigned (count and digest are the issuer's unsigned word)");
|
|
706
|
+
break;
|
|
707
|
+
default:
|
|
708
|
+
L.push(` Footer: ${result.footerStatement}`);
|
|
709
|
+
}
|
|
710
|
+
if (result.footer === null) {
|
|
711
|
+
L.push(" Next page: unknown (no footer)");
|
|
712
|
+
} else if (result.footer.nextCursor === null) {
|
|
713
|
+
L.push(" Next page: none");
|
|
714
|
+
} else {
|
|
715
|
+
L.push(` Next page: ${result.footer.nextCursor} (more pages exist; this file alone is not the whole answer)`);
|
|
716
|
+
}
|
|
717
|
+
if (result.failed.length > 0) {
|
|
718
|
+
L.push(" Refused:");
|
|
719
|
+
for (const f of result.failed) {
|
|
720
|
+
L.push(f.certificateId === "" ? ` #${f.index}: ${f.reason}` : ` #${f.index} ${f.certificateId}: ${f.reason}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (result.verdict !== "VERIFIED") L.push(` Findings: ${result.reason}`);
|
|
724
|
+
return L.join("\n") + "\n";
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** The chain line printed after several pages. Exported for testing. */
|
|
728
|
+
export function formatAuditPackChain(chain: ReturnType<typeof chainAuditPackPages>): string {
|
|
729
|
+
if (!chain.complete) return `Pages: INCOMPLETE: ${chain.reason}\n`;
|
|
730
|
+
const names = chain.order.map((p) => p.name).join(", ");
|
|
731
|
+
return `Pages: ${chain.order.length} files chain into one complete answer: ${names}\n`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** What reproducing a run record's roots needs, collected across every page
|
|
735
|
+
* given: the subject hash and certificate id of every record the pages carry,
|
|
736
|
+
* and the record itself. A set of pages that carry different run records is
|
|
737
|
+
* not one run and is refused. Mirrors runAggregate in cmd/cli/auditpack.go. */
|
|
738
|
+
export interface RunAggregate {
|
|
739
|
+
record: RunRecord | null;
|
|
740
|
+
conflict: boolean;
|
|
741
|
+
records: RunCheckRecord[];
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function aggregateRun(agg: RunAggregate, result: AuditPackResult): void {
|
|
745
|
+
if (result.run === null) return;
|
|
746
|
+
if (agg.record === null) agg.record = result.run;
|
|
747
|
+
else if (agg.record.runId !== result.run.runId) agg.conflict = true;
|
|
748
|
+
for (let i = 0; i < result.runRecordIds.length; i++) {
|
|
749
|
+
agg.records.push({ certificateId: result.runRecordIds[i]!, subjectHash: result.runSubjects[i]! });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** The run block that follows the pages, laid out as docs/cli.md pins it. ok
|
|
754
|
+
* is false when the run's roots could not be reproduced from a complete chain,
|
|
755
|
+
* or the pages disagree about which run they are. A shortfall against the
|
|
756
|
+
* declared list and a close after the deadline are reported, not refused:
|
|
757
|
+
* they are findings about the cycle, which is the auditor's question, not
|
|
758
|
+
* about the evidence. Mirrors formatRunCheck in cmd/cli/auditpack.go. */
|
|
759
|
+
export async function formatRunCheck(
|
|
760
|
+
agg: RunAggregate, complete: boolean,
|
|
761
|
+
): Promise<{ output: string; ok: boolean }> {
|
|
762
|
+
if (agg.conflict) return { output: "Run: INVALID: the pages carry records of different runs\n", ok: false };
|
|
763
|
+
const r = agg.record!;
|
|
764
|
+
const L: string[] = [];
|
|
765
|
+
L.push(`Run ${r.runId}: ${r.runKind}`);
|
|
766
|
+
L.push(` Opened: ${r.openedAt}`);
|
|
767
|
+
const late = r.closedAt > r.notAfter ? " (AFTER the declared deadline)" : "";
|
|
768
|
+
L.push(` Closed: ${r.closedAt}, deadline ${r.notAfter}${late}`);
|
|
769
|
+
L.push(
|
|
770
|
+
` Declared list: ${r.listCount} entries, sha256:${bytesToHex(r.listDigest)} ` +
|
|
771
|
+
"(the customer's assertion; the enclave never sees the list)",
|
|
772
|
+
);
|
|
773
|
+
L.push(` Issued under the run: ${r.recordsCount} records over ${r.subjectsCount} distinct subjects`);
|
|
774
|
+
if (!complete) {
|
|
775
|
+
L.push(" Roots: NOT CHECKED (the pages do not chain into the whole run; every page is needed to reproduce the roots)");
|
|
776
|
+
return { output: L.join("\n") + "\n", ok: false };
|
|
777
|
+
}
|
|
778
|
+
const check = await checkRunAgainstRecords(nodeCrypto, r, agg.records);
|
|
779
|
+
const ok = check.recordsMatch && check.subjectsMatch;
|
|
780
|
+
L.push(
|
|
781
|
+
ok
|
|
782
|
+
? ` Roots: REPRODUCED from the ${agg.records.length} records in these pages`
|
|
783
|
+
: ` Roots: MISMATCH: the ${agg.records.length} records in these pages do not reproduce the record's roots`,
|
|
784
|
+
);
|
|
785
|
+
if (check.listShortfall > 0) {
|
|
786
|
+
L.push(` Shortfall: ${check.listShortfall} listed subjects have no record in this run`);
|
|
787
|
+
} else if (check.listShortfall < 0) {
|
|
788
|
+
L.push(` Shortfall: none; ${-check.listShortfall} more subjects than the list declared`);
|
|
789
|
+
} else {
|
|
790
|
+
L.push(" Shortfall: none; every listed subject has a record");
|
|
791
|
+
}
|
|
792
|
+
return { output: L.join("\n") + "\n", ok };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** `--pack`: each file verified on its own, then the set checked to chain.
|
|
796
|
+
* Exit 0 only when every page VERIFIED and the chain is complete — a single
|
|
797
|
+
* page whose footer names a next cursor is not the whole answer. An UNREADABLE
|
|
798
|
+
* page ends the run at 2, as an unreadable --cert does: no record was checked,
|
|
799
|
+
* and a page with no header has nothing to chain by. Mirrors runPackCheck in
|
|
800
|
+
* cmd/cli/auditpack.go. */
|
|
801
|
+
async function mainPack(args: ReturnType<typeof parseArgs>): Promise<void> {
|
|
802
|
+
let keys: Map<string, PublicKeyInfo> | null = null;
|
|
803
|
+
if (args.keysPath) keys = (await loadKeys(args.keysPath)).keys;
|
|
804
|
+
|
|
805
|
+
// Step 8 per record, with the same absence of credentials as a single
|
|
806
|
+
// record. A statement that could not be had leaves that record at
|
|
807
|
+
// VALID_REVOCATION_UNKNOWN, as offline; the reader is told on stderr.
|
|
808
|
+
const status = args.online
|
|
809
|
+
? async (certId: string): Promise<StatusStatement | null> => {
|
|
810
|
+
try {
|
|
811
|
+
return await fetchStatusStatement(args.apiUrl, certId);
|
|
812
|
+
} catch (e) {
|
|
813
|
+
process.stderr.write(
|
|
814
|
+
`warning: signed status statement unavailable for ${certId}: ${e instanceof Error ? e.message : String(e)}\n`,
|
|
815
|
+
);
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
: undefined;
|
|
820
|
+
|
|
821
|
+
const now = new Date();
|
|
822
|
+
const pages: AuditPackPage[] = [];
|
|
823
|
+
const run: RunAggregate = { record: null, conflict: false, records: [] };
|
|
824
|
+
let refused = false;
|
|
825
|
+
for (const path of args.packPaths) {
|
|
826
|
+
const result = await verifyAuditPack(nodeCrypto, await readFile(path), keys, now, { status });
|
|
827
|
+
process.stdout.write(formatAuditPackOutput(path, result, { keysFile: args.keysPath, online: args.online }));
|
|
828
|
+
if (result.verdict === "UNREADABLE" || result.header === null) process.exit(2);
|
|
829
|
+
if (result.verdict !== "VERIFIED") refused = true;
|
|
830
|
+
pages.push({
|
|
831
|
+
name: basename(path),
|
|
832
|
+
cursor: result.header.cursor,
|
|
833
|
+
nextCursor: result.footer?.nextCursor ?? null,
|
|
834
|
+
});
|
|
835
|
+
aggregateRun(run, result);
|
|
836
|
+
}
|
|
837
|
+
const chain = chainAuditPackPages(pages);
|
|
838
|
+
if (args.packPaths.length > 1) process.stdout.write(formatAuditPackChain(chain));
|
|
839
|
+
if (run.record !== null) {
|
|
840
|
+
const { output, ok } = await formatRunCheck(run, chain.complete);
|
|
841
|
+
process.stdout.write(output);
|
|
842
|
+
if (!ok) refused = true;
|
|
843
|
+
}
|
|
844
|
+
process.exit(refused || !chain.complete ? 1 : 0);
|
|
845
|
+
}
|
|
846
|
+
|
|
569
847
|
/** Accept the raw API response too: GET /v1/certificates/{id} nests the
|
|
570
848
|
* signed certificate as data.certificate. Exported for testing. */
|
|
571
849
|
export function unwrapCertificate(parsed: Cert): Cert {
|
|
@@ -582,6 +860,7 @@ export function unwrapCertificate(parsed: Cert): Cert {
|
|
|
582
860
|
|
|
583
861
|
async function main(): Promise<void> {
|
|
584
862
|
const args = parseArgs(process.argv);
|
|
863
|
+
if (args.packPaths.length > 0) return mainPack(args);
|
|
585
864
|
const parsed: unknown = JSON.parse(await readFile(args.certPath, "utf-8"));
|
|
586
865
|
// No record to give a verdict about. Go's loader refuses the same file at
|
|
587
866
|
// unmarshal and exits 2, as this does.
|