burnledger 0.9.1 → 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/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 +58 -0
- package/dist/cjs/client.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 +69 -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/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 +207 -5
- package/dist/cjs/verify.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 +33 -0
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +225 -5
- 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 +59 -1
- package/dist/esm/client.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 +52 -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/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 +200 -8
- package/dist/esm/verify.js.map +1 -1
- package/package.json +1 -1
- package/src/audit-pack.ts +1067 -0
- package/src/cli.ts +234 -5
- package/src/client.ts +82 -1
- package/src/index.ts +117 -1
- package/src/models.ts +47 -3
- package/src/run-record.ts +371 -0
- package/src/verify.ts +228 -8
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
|
|
@@ -321,7 +338,7 @@ const REVOCATION_UNKNOWN_ONLINE =
|
|
|
321
338
|
* enclave measurement was unattributable when the signature covers it.
|
|
322
339
|
* scripts/check-pcr0-signed-parity.py now pins this list to the Go function.
|
|
323
340
|
*/
|
|
324
|
-
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"]);
|
|
325
342
|
|
|
326
343
|
function signatureCoversEnclavePcr0(version: string): boolean {
|
|
327
344
|
return ENCLAVE_PCR0_SIGNED_IN.includes(version);
|
|
@@ -616,6 +633,217 @@ function pushStatementEvidence(L: string[], status: StatusStatement | undefined)
|
|
|
616
633
|
}
|
|
617
634
|
}
|
|
618
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
|
+
|
|
619
847
|
/** Accept the raw API response too: GET /v1/certificates/{id} nests the
|
|
620
848
|
* signed certificate as data.certificate. Exported for testing. */
|
|
621
849
|
export function unwrapCertificate(parsed: Cert): Cert {
|
|
@@ -632,6 +860,7 @@ export function unwrapCertificate(parsed: Cert): Cert {
|
|
|
632
860
|
|
|
633
861
|
async function main(): Promise<void> {
|
|
634
862
|
const args = parseArgs(process.argv);
|
|
863
|
+
if (args.packPaths.length > 0) return mainPack(args);
|
|
635
864
|
const parsed: unknown = JSON.parse(await readFile(args.certPath, "utf-8"));
|
|
636
865
|
// No record to give a verdict about. Go's loader refuses the same file at
|
|
637
866
|
// unmarshal and exits 2, as this does.
|
package/src/client.ts
CHANGED
|
@@ -19,6 +19,8 @@ import type {
|
|
|
19
19
|
LogEntry,
|
|
20
20
|
Profile,
|
|
21
21
|
RevocationStatus,
|
|
22
|
+
Run,
|
|
23
|
+
RunKind,
|
|
22
24
|
SignedTreeHead,
|
|
23
25
|
System,
|
|
24
26
|
SystemHealth,
|
|
@@ -40,6 +42,7 @@ import {
|
|
|
40
42
|
parseLogEntry,
|
|
41
43
|
parseProfile,
|
|
42
44
|
parseRevocationStatus,
|
|
45
|
+
parseRun,
|
|
43
46
|
parseSignedTreeHead,
|
|
44
47
|
parseSystem,
|
|
45
48
|
parseSystemHealth,
|
|
@@ -191,6 +194,9 @@ export class BurnLedger {
|
|
|
191
194
|
proofMode?: string;
|
|
192
195
|
expiresIn?: string;
|
|
193
196
|
webhookUrl?: string;
|
|
197
|
+
/** Issue the record under this OPEN run (ADR-031). The run must be this
|
|
198
|
+
* team's and its declared systems must be exactly systemIds. */
|
|
199
|
+
runId?: string;
|
|
194
200
|
},
|
|
195
201
|
): Promise<Attestation> {
|
|
196
202
|
const body: Record<string, unknown> = {
|
|
@@ -200,6 +206,7 @@ export class BurnLedger {
|
|
|
200
206
|
};
|
|
201
207
|
if (opts?.systemIds !== undefined) body.system_ids = opts.systemIds;
|
|
202
208
|
if (opts?.webhookUrl !== undefined) body.webhook_url = opts.webhookUrl;
|
|
209
|
+
if (opts?.runId !== undefined) body.run_id = opts.runId;
|
|
203
210
|
|
|
204
211
|
const data = await this.transport.request("POST", "/v1/attestations", {
|
|
205
212
|
json: body,
|
|
@@ -267,13 +274,16 @@ export class BurnLedger {
|
|
|
267
274
|
systemIds: string[];
|
|
268
275
|
proofMode?: string;
|
|
269
276
|
expiresIn?: string;
|
|
277
|
+
/** Issue every record under this OPEN run (ADR-031). */
|
|
278
|
+
runId?: string;
|
|
270
279
|
}): Promise<BatchAttestationResponse> {
|
|
271
|
-
const body = {
|
|
280
|
+
const body: Record<string, unknown> = {
|
|
272
281
|
subject_identifiers: opts.subjectIdentifiers,
|
|
273
282
|
system_ids: opts.systemIds,
|
|
274
283
|
proof_mode: opts.proofMode ?? "count",
|
|
275
284
|
expires_in: opts.expiresIn ?? "72h",
|
|
276
285
|
};
|
|
286
|
+
if (opts.runId !== undefined) body.run_id = opts.runId;
|
|
277
287
|
const data = await this.transport.request("POST", "/v1/attestations/batch", {
|
|
278
288
|
json: body,
|
|
279
289
|
});
|
|
@@ -377,6 +387,77 @@ export class BurnLedger {
|
|
|
377
387
|
return this.transport.requestBytes("GET", path);
|
|
378
388
|
}
|
|
379
389
|
|
|
390
|
+
/** One page of an audit pack (docs/audit-pack.md): the export's records as
|
|
391
|
+
* signed documents, with the issuer's keys and log head, framed by a header
|
|
392
|
+
* and a footer. The footer's next_cursor names the next page; pass it back
|
|
393
|
+
* as `cursor` and keep each page as its own file — pages chain by their
|
|
394
|
+
* cursors, not by their bytes. */
|
|
395
|
+
async exportAuditPack(opts?: {
|
|
396
|
+
status?: "ACTIVE" | "REVOKED";
|
|
397
|
+
issuedAfter?: string;
|
|
398
|
+
issuedBefore?: string;
|
|
399
|
+
/** Only the records issued under this run (ADR-031); a closed run's pack
|
|
400
|
+
* carries the signed run record in its header. */
|
|
401
|
+
runId?: string;
|
|
402
|
+
cursor?: string;
|
|
403
|
+
}): Promise<Uint8Array> {
|
|
404
|
+
const params: Record<string, string> = {};
|
|
405
|
+
if (opts?.status !== undefined) params.status = opts.status;
|
|
406
|
+
if (opts?.issuedAfter !== undefined) params.issued_after = opts.issuedAfter;
|
|
407
|
+
if (opts?.issuedBefore !== undefined) params.issued_before = opts.issuedBefore;
|
|
408
|
+
if (opts?.runId !== undefined) params.run_id = opts.runId;
|
|
409
|
+
if (opts?.cursor !== undefined) params.cursor = opts.cursor;
|
|
410
|
+
|
|
411
|
+
const qs = new URLSearchParams(params).toString();
|
|
412
|
+
const path = qs ? `/v1/certificates/audit-pack?${qs}` : "/v1/certificates/audit-pack";
|
|
413
|
+
return this.transport.requestBytes("GET", path);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// --- Runs (ADR-031) ---
|
|
417
|
+
|
|
418
|
+
/** Open a bulk run with the customer's declaration: what the input list was
|
|
419
|
+
* (its SHA-256 over the canonical form and entry count), the deadline, and
|
|
420
|
+
* the systems every record in the run will cover. The enclave never sees the
|
|
421
|
+
* list; the digest and count are signed into the run record as declared. */
|
|
422
|
+
async openRun(opts: {
|
|
423
|
+
runKind: RunKind;
|
|
424
|
+
/** 64 hex characters. */
|
|
425
|
+
listDigest: string;
|
|
426
|
+
listCount: number;
|
|
427
|
+
notAfter: Date | string;
|
|
428
|
+
systemIds: string[];
|
|
429
|
+
}): Promise<Run> {
|
|
430
|
+
const body = {
|
|
431
|
+
run_kind: opts.runKind,
|
|
432
|
+
list_digest: opts.listDigest,
|
|
433
|
+
list_count: opts.listCount,
|
|
434
|
+
not_after: opts.notAfter instanceof Date ? opts.notAfter.toISOString() : opts.notAfter,
|
|
435
|
+
system_ids: opts.systemIds,
|
|
436
|
+
};
|
|
437
|
+
const data = await this.transport.request("POST", "/v1/runs", { json: body });
|
|
438
|
+
return parseRun(data as Raw);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Close a run: the enclave signs the run record over what it issued under
|
|
442
|
+
* it and the record is appended to the transparency log. 409 (ConflictError)
|
|
443
|
+
* when the run is not OPEN — including a run an enclave restart forgot,
|
|
444
|
+
* which is marked LOST and can never be closed. */
|
|
445
|
+
async closeRun(runId: string): Promise<Run> {
|
|
446
|
+
const data = await this.transport.request("POST", `/v1/runs/${runId}/close`);
|
|
447
|
+
return parseRun(data as Raw);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async getRun(runId: string): Promise<Run> {
|
|
451
|
+
const data = await this.transport.request("GET", `/v1/runs/${runId}`);
|
|
452
|
+
return parseRun(data as Raw);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
listRuns(opts?: { limit?: number }): Paginator<Run> {
|
|
456
|
+
return new Paginator(this.transport, "/v1/runs", parseRun, {
|
|
457
|
+
params: { limit: opts?.limit ?? 25 },
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
380
461
|
// --- Webhooks ---
|
|
381
462
|
|
|
382
463
|
async registerWebhook(opts: { url: string }): Promise<Webhook> {
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,9 @@ export type {
|
|
|
48
48
|
ConnectorType,
|
|
49
49
|
HashScope,
|
|
50
50
|
LogEntryType,
|
|
51
|
+
Run,
|
|
52
|
+
RunKind,
|
|
53
|
+
RunStatus,
|
|
51
54
|
VerificationResult,
|
|
52
55
|
TransparencyResult,
|
|
53
56
|
ApiKeyRole,
|
|
@@ -84,7 +87,7 @@ export type {
|
|
|
84
87
|
|
|
85
88
|
// Raw-JSON → model parsers, for consumers that fetch API responses outside
|
|
86
89
|
// the client (the dashboard's paginated list hooks) but render SDK types.
|
|
87
|
-
export { parseSystem, parseCertificateResponse, parseWebhook } from "./models.js";
|
|
90
|
+
export { parseSystem, parseCertificateResponse, parseRun, parseWebhook } from "./models.js";
|
|
88
91
|
|
|
89
92
|
export { Paginator } from "./pagination.js";
|
|
90
93
|
|
|
@@ -170,6 +173,119 @@ export function verifyConsistency(
|
|
|
170
173
|
|
|
171
174
|
export { verifyWebhookSignature, isTimestampFresh } from "./webhooks.js";
|
|
172
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Audit pack verification (docs/audit-pack.md). Node only: a pack is a file an
|
|
178
|
+
* auditor holds, and the browser verifier reads one record at a time.
|
|
179
|
+
*/
|
|
180
|
+
export {
|
|
181
|
+
AUDIT_PACK_FORMAT,
|
|
182
|
+
PAYLOAD_TYPE_AUDIT_PACK_FOOTER,
|
|
183
|
+
auditPackFooterStatement,
|
|
184
|
+
buildAuditPackFooterPayload,
|
|
185
|
+
chainAuditPackPages,
|
|
186
|
+
} from "./audit-pack.js";
|
|
187
|
+
export type {
|
|
188
|
+
AuditPackFilters,
|
|
189
|
+
AuditPackFooter,
|
|
190
|
+
AuditPackFooterResult,
|
|
191
|
+
AuditPackFooterSigned,
|
|
192
|
+
AuditPackFooterStatement,
|
|
193
|
+
AuditPackHeader,
|
|
194
|
+
AuditPackLogHead,
|
|
195
|
+
AuditPackOptions,
|
|
196
|
+
AuditPackPage,
|
|
197
|
+
AuditPackRecordFailure,
|
|
198
|
+
AuditPackResult,
|
|
199
|
+
AuditPackVerdict,
|
|
200
|
+
SignedTreeHeadDocument,
|
|
201
|
+
} from "./audit-pack.js";
|
|
202
|
+
import {
|
|
203
|
+
verifyAuditPack as _verifyAuditPack,
|
|
204
|
+
verifyAuditPackFooter as _verifyAuditPackFooter,
|
|
205
|
+
} from "./audit-pack.js";
|
|
206
|
+
import type {
|
|
207
|
+
AuditPackFooterResult,
|
|
208
|
+
AuditPackFooterStatement,
|
|
209
|
+
AuditPackOptions,
|
|
210
|
+
AuditPackResult,
|
|
211
|
+
} from "./audit-pack.js";
|
|
212
|
+
|
|
213
|
+
/** Verify one audit pack page offline, as `burnledger check --pack` does.
|
|
214
|
+
* `keys` null uses the pack's own issuer_keys (trust on first use, and the
|
|
215
|
+
* result's keysFromPack says so); a supplied key set outranks them. */
|
|
216
|
+
export function verifyAuditPack(
|
|
217
|
+
source: string | Uint8Array,
|
|
218
|
+
keys: Map<string, PublicKeyInfo> | null,
|
|
219
|
+
now?: Date,
|
|
220
|
+
options?: AuditPackOptions,
|
|
221
|
+
): Promise<AuditPackResult> {
|
|
222
|
+
return _verifyAuditPack(nodeCrypto, source, keys, now ?? new Date(), options);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Verify a footer statement — rebuilt from a page's header and footer by
|
|
226
|
+
* auditPackFooterStatement — under a key set. verifyAuditPack already does
|
|
227
|
+
* this for every signed footer it reads; this is for a reader holding the
|
|
228
|
+
* statement on its own. */
|
|
229
|
+
export function verifyAuditPackFooter(
|
|
230
|
+
statement: AuditPackFooterStatement,
|
|
231
|
+
keys: Map<string, PublicKeyInfo>,
|
|
232
|
+
): Promise<AuditPackFooterResult> {
|
|
233
|
+
return _verifyAuditPackFooter(nodeCrypto, statement, keys);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The run record (docs/run-record.md, ADR-031): the enclave's signed word on
|
|
238
|
+
* one bulk run, checked beside the run's records. Node only, as the audit pack
|
|
239
|
+
* verifier that carries it is.
|
|
240
|
+
*/
|
|
241
|
+
export {
|
|
242
|
+
PAYLOAD_TYPE_RUN_RECORD,
|
|
243
|
+
buildRunRecordPayload,
|
|
244
|
+
parseRunRecordDocument,
|
|
245
|
+
} from "./run-record.js";
|
|
246
|
+
export type { RunCheck, RunCheckRecord, RunRecord, RunRecordResult } from "./run-record.js";
|
|
247
|
+
import {
|
|
248
|
+
checkRunAgainstRecords as _checkRunAgainstRecords,
|
|
249
|
+
runRecordHash as _runRecordHash,
|
|
250
|
+
runRoots as _runRoots,
|
|
251
|
+
verifyRunRecord as _verifyRunRecord,
|
|
252
|
+
verifyRunRecordInclusion as _verifyRunRecordInclusion,
|
|
253
|
+
} from "./run-record.js";
|
|
254
|
+
import type { RunCheck, RunCheckRecord, RunRecord, RunRecordResult } from "./run-record.js";
|
|
255
|
+
|
|
256
|
+
/** Verify a run record's signature under a key set: the shape, the key's
|
|
257
|
+
* authority at the record's close instant, then the signature. */
|
|
258
|
+
export function verifyRunRecord(record: RunRecord, keys: Map<string, PublicKeyInfo>): Promise<RunRecordResult> {
|
|
259
|
+
return _verifyRunRecord(nodeCrypto, record, keys);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** What the run record's transparency leaf commits to: SHA-256 of its payload. */
|
|
263
|
+
export function runRecordHash(record: RunRecord): Promise<Uint8Array> {
|
|
264
|
+
return _runRecordHash(nodeCrypto, record);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Verify the run record's own inclusion proof under the key that signed it,
|
|
268
|
+
* as core.TransparencyResult names the outcome; NOT_INCLUDED without one. */
|
|
269
|
+
export function verifyRunRecordInclusion(record: RunRecord, key: PublicKeyInfo): Promise<string> {
|
|
270
|
+
return _verifyRunRecordInclusion(nodeCrypto, record, key);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The roots a run record commits to, reproduced from subject hashes and
|
|
274
|
+
* certificate ids in any order: subjects de-duplicated, both leaf sets sorted. */
|
|
275
|
+
export function runRoots(
|
|
276
|
+
subjects: Uint8Array[],
|
|
277
|
+
certificateIds: string[],
|
|
278
|
+
): Promise<{ subjectsRoot: Uint8Array; subjectsCount: number; recordsRoot: Uint8Array; recordsCount: number }> {
|
|
279
|
+
return _runRoots(nodeCrypto, subjects, certificateIds);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Hold a run record beside every record of the run: whether the roots
|
|
283
|
+
* reproduce, the shortfall against the declared list, whether it closed in
|
|
284
|
+
* time, and how many records are labelled with another run. */
|
|
285
|
+
export function checkRunAgainstRecords(record: RunRecord, records: RunCheckRecord[]): Promise<RunCheck> {
|
|
286
|
+
return _checkRunAgainstRecords(nodeCrypto, record, records);
|
|
287
|
+
}
|
|
288
|
+
|
|
173
289
|
/**
|
|
174
290
|
* Client-side connection-config sealing.
|
|
175
291
|
*
|
package/src/models.ts
CHANGED
|
@@ -73,7 +73,9 @@ export type ConnectorType =
|
|
|
73
73
|
| "bigquery"
|
|
74
74
|
| "azure_blob";
|
|
75
75
|
export type HashScope = "full" | "existence";
|
|
76
|
-
export type LogEntryType = "CERTIFICATE" | "REVOCATION";
|
|
76
|
+
export type LogEntryType = "CERTIFICATE" | "REVOCATION" | "RUN_RECORD";
|
|
77
|
+
export type RunKind = "drop_cycle" | "bulk_erasure";
|
|
78
|
+
export type RunStatus = "OPEN" | "CLOSED" | "LOST";
|
|
77
79
|
|
|
78
80
|
/** Result of offline certificate signature verification. */
|
|
79
81
|
export type VerificationResult = "VALID" | "INVALID";
|
|
@@ -190,11 +192,35 @@ export interface SignedTreeHead {
|
|
|
190
192
|
export interface LogEntry {
|
|
191
193
|
readonly index: number;
|
|
192
194
|
readonly entryType: LogEntryType;
|
|
193
|
-
|
|
195
|
+
/** Present on CERTIFICATE and REVOCATION leaves. */
|
|
196
|
+
readonly certificateId: string | undefined;
|
|
197
|
+
/** Present on RUN_RECORD leaves (ADR-031). */
|
|
198
|
+
readonly runId: string | undefined;
|
|
194
199
|
readonly hash: string;
|
|
195
200
|
readonly appendedAt: Date;
|
|
196
201
|
}
|
|
197
202
|
|
|
203
|
+
/** One bulk run (ADR-031, docs/run-record.md): the customer's declaration as
|
|
204
|
+
* opened, and once CLOSED the enclave-signed run record under `record`, with
|
|
205
|
+
* its transparency proof merged in when a signed tree head covers its leaf.
|
|
206
|
+
* The record is kept raw so it can be handed straight to verifyRunRecord. */
|
|
207
|
+
export interface Run {
|
|
208
|
+
readonly id: string;
|
|
209
|
+
readonly teamId: string;
|
|
210
|
+
readonly runKind: RunKind;
|
|
211
|
+
/** SHA-256 of the input list's canonical form, 64 hex characters. */
|
|
212
|
+
readonly listDigest: string;
|
|
213
|
+
readonly listCount: number;
|
|
214
|
+
readonly notAfter: Date;
|
|
215
|
+
readonly systemIds: readonly string[];
|
|
216
|
+
readonly status: RunStatus;
|
|
217
|
+
readonly openedAt: Date;
|
|
218
|
+
readonly closedAt: Date | undefined;
|
|
219
|
+
/** The run record's leaf in the transparency log, once appended. */
|
|
220
|
+
readonly logIndex: number | undefined;
|
|
221
|
+
readonly record: Record<string, unknown> | undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
198
224
|
export interface InclusionProof {
|
|
199
225
|
readonly index: number;
|
|
200
226
|
readonly treeSize: number;
|
|
@@ -486,12 +512,30 @@ export function parseLogEntry(d: Raw): LogEntry {
|
|
|
486
512
|
return {
|
|
487
513
|
index: d.index,
|
|
488
514
|
entryType: d.entry_type as LogEntryType,
|
|
489
|
-
certificateId: d.certificate_id,
|
|
515
|
+
certificateId: d.certificate_id ?? undefined,
|
|
516
|
+
runId: d.run_id ?? undefined,
|
|
490
517
|
hash: d.hash,
|
|
491
518
|
appendedAt: parseDt(d.appended_at),
|
|
492
519
|
};
|
|
493
520
|
}
|
|
494
521
|
|
|
522
|
+
export function parseRun(d: Raw): Run {
|
|
523
|
+
return {
|
|
524
|
+
id: d.id,
|
|
525
|
+
teamId: d.team_id,
|
|
526
|
+
runKind: d.run_kind as RunKind,
|
|
527
|
+
listDigest: d.list_digest,
|
|
528
|
+
listCount: d.list_count,
|
|
529
|
+
notAfter: parseDt(d.not_after),
|
|
530
|
+
systemIds: d.system_ids ?? [],
|
|
531
|
+
status: d.status as RunStatus,
|
|
532
|
+
openedAt: parseDt(d.opened_at),
|
|
533
|
+
closedAt: parseDtOpt(d.closed_at),
|
|
534
|
+
logIndex: d.log_index ?? undefined,
|
|
535
|
+
record: d.record ?? undefined,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
495
539
|
export function parseInclusionProof(d: Raw): InclusionProof {
|
|
496
540
|
return {
|
|
497
541
|
index: d.index,
|