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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** The Node-only capabilities the shared client needs, injected at the entry.
|
|
2
|
+
*
|
|
3
|
+
* WHY THIS EXISTS. `client.ts` is imported by BOTH entry points — the Node one
|
|
4
|
+
* (index.ts) and the browser one (index.browser.ts). A few of its methods are
|
|
5
|
+
* Node-only by nature: key enrolment and system registration (ADR-025 §2 makes a
|
|
6
|
+
* client the customer runs locally the only place enrolment can happen), and
|
|
7
|
+
* `savePdf`, which writes to a filesystem path. Their implementations live in
|
|
8
|
+
* `enclave-registration.ts`, `enclave-seal.ts` (and, through them, `key-group.ts`
|
|
9
|
+
* and `crypto-node.ts`) and in `node:fs/promises` — all of which import
|
|
10
|
+
* `node:crypto` or another Node builtin.
|
|
11
|
+
*
|
|
12
|
+
* If the client named any of those modules directly — even behind a dynamic
|
|
13
|
+
* `import("./enclave-registration.js")` — a browser bundler following the browser
|
|
14
|
+
* entry's graph would drag `node:crypto` into the bundle and fail the build (the
|
|
15
|
+
* dashboard's vite build did exactly this). So the client never names them. The
|
|
16
|
+
* Node entry registers a loader for them here on import; the browser entry never
|
|
17
|
+
* does. A browser caller of a Node-only method therefore gets a clear error, and
|
|
18
|
+
* a browser bundle of the browser entry reaches no Node builtin at all.
|
|
19
|
+
*
|
|
20
|
+
* The imports below are type-only, so they are erased at build and add nothing to
|
|
21
|
+
* any bundle.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type * as RegistrationModule from "./enclave-registration.js";
|
|
25
|
+
import type * as EnclaveSealModule from "./enclave-seal.js";
|
|
26
|
+
|
|
27
|
+
/** The Node-only surface the client reaches through {@link loadNodeRuntime}. */
|
|
28
|
+
export interface NodeRuntime {
|
|
29
|
+
readonly registration: typeof RegistrationModule;
|
|
30
|
+
readonly seal: typeof EnclaveSealModule;
|
|
31
|
+
/** `node:fs/promises`' `writeFile`, narrowed to what `savePdf` needs. */
|
|
32
|
+
writeFile(path: string, data: Uint8Array): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type NodeRuntimeLoader = () => Promise<NodeRuntime>;
|
|
36
|
+
|
|
37
|
+
let loader: NodeRuntimeLoader | undefined;
|
|
38
|
+
|
|
39
|
+
/** Register the Node runtime. Called once by the Node entry point (index.ts) on
|
|
40
|
+
* import; never by the browser entry. */
|
|
41
|
+
export function setNodeRuntimeLoader(load: NodeRuntimeLoader): void {
|
|
42
|
+
loader = load;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Load the Node-only runtime, or throw if it was never registered — the case in
|
|
46
|
+
* a browser bundle built from `burnledger/browser`. */
|
|
47
|
+
export function loadNodeRuntime(): Promise<NodeRuntime> {
|
|
48
|
+
if (loader === undefined) {
|
|
49
|
+
return Promise.reject(
|
|
50
|
+
new Error(
|
|
51
|
+
"this operation is Node-only (key enrolment, system registration and " +
|
|
52
|
+
'savePdf); import BurnLedger from "burnledger", not "burnledger/browser".',
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return loader();
|
|
57
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/** The run record (docs/run-record.md, ADR-031 option C).
|
|
2
|
+
*
|
|
3
|
+
* A port of core/run_record.go, verdict for verdict. A run is one bulk
|
|
4
|
+
* deletion cycle; the run record is the enclave's signed word on it — what the
|
|
5
|
+
* customer declared the input list to be, what the enclave actually issued
|
|
6
|
+
* under the run, and when it opened and closed. Held beside every record of
|
|
7
|
+
* the run (an audit pack filtered by run_id, every page of it), it lets a
|
|
8
|
+
* reader establish that the records are exactly what was issued, how many
|
|
9
|
+
* listed subjects have no record, and whether the run closed in time.
|
|
10
|
+
*
|
|
11
|
+
* What it does not establish is that the list was the regulator's list: the
|
|
12
|
+
* enclave never sees the list, and list_digest and list_count are the
|
|
13
|
+
* customer's assertion, signed and dated here rather than implicit.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { CryptoOps } from "./crypto.js";
|
|
17
|
+
import { VerificationError } from "./errors.js";
|
|
18
|
+
import {
|
|
19
|
+
buildLogLeafPayload,
|
|
20
|
+
bytesToHex,
|
|
21
|
+
canonicalJson,
|
|
22
|
+
decodeFixed,
|
|
23
|
+
evaluateKey,
|
|
24
|
+
formatTimestamp,
|
|
25
|
+
formatUuid,
|
|
26
|
+
hashLeaf,
|
|
27
|
+
keyIsUsable,
|
|
28
|
+
merkleRoot,
|
|
29
|
+
parseUuid,
|
|
30
|
+
signatureCoversRunId,
|
|
31
|
+
transparencyVerdictName,
|
|
32
|
+
verifyTransparencyHead,
|
|
33
|
+
verifyTransparencyInclusion,
|
|
34
|
+
type PublicKeyInfo,
|
|
35
|
+
} from "./verify.js";
|
|
36
|
+
|
|
37
|
+
/** The domain-separation tag of a run record. */
|
|
38
|
+
export const PAYLOAD_TYPE_RUN_RECORD = "burnledger.run_record.v1";
|
|
39
|
+
|
|
40
|
+
/** What a run was: a regulator's bulk cycle or a customer's own sweep. Carried
|
|
41
|
+
* signed, so an auditor reads what the customer claimed the run to be rather
|
|
42
|
+
* than inferring it from a date. */
|
|
43
|
+
export type RunKind = "drop_cycle" | "bulk_erasure";
|
|
44
|
+
|
|
45
|
+
const RUN_KINDS: ReadonlySet<string> = new Set<RunKind>(["drop_cycle", "bulk_erasure"]);
|
|
46
|
+
|
|
47
|
+
export const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
48
|
+
|
|
49
|
+
/** Mirrors core.RunRecord, as GET /v1/runs/{id} serves it under `record` and
|
|
50
|
+
* an audit pack carries it in its header. UUIDs are canonical lowercase,
|
|
51
|
+
* timestamps whole-second UTC, exactly as the payload signs them. */
|
|
52
|
+
export interface RunRecord {
|
|
53
|
+
runId: string;
|
|
54
|
+
runKind: string;
|
|
55
|
+
/** Signed, so a run record cannot be presented as another team's. */
|
|
56
|
+
teamId: string;
|
|
57
|
+
/** The customer's assertion about the input list: SHA-256 over its canonical
|
|
58
|
+
* form (one normalized identifier per line, sorted, LF-terminated), and how
|
|
59
|
+
* many entries it had. 32 bytes. */
|
|
60
|
+
listDigest: Uint8Array;
|
|
61
|
+
listCount: number;
|
|
62
|
+
/** Merkle root and count over the DISTINCT subject hashes the enclave issued
|
|
63
|
+
* records for under this run, leaves sorted. Computed by the enclave, never
|
|
64
|
+
* asserted. 32 bytes. */
|
|
65
|
+
subjectsRoot: Uint8Array;
|
|
66
|
+
subjectsCount: number;
|
|
67
|
+
/** Merkle root and count over the certificate ids issued under this run,
|
|
68
|
+
* leaves sorted. A run with no records has zero roots and zero counts. */
|
|
69
|
+
recordsRoot: Uint8Array;
|
|
70
|
+
recordsCount: number;
|
|
71
|
+
/** The systems every record in the run covered. */
|
|
72
|
+
systemIds: string[];
|
|
73
|
+
openedAt: string;
|
|
74
|
+
closedAt: string;
|
|
75
|
+
/** The customer-declared deadline, so "on time" is checkable against closedAt. */
|
|
76
|
+
notAfter: string;
|
|
77
|
+
issuer: { keyId: string; enclavePcr0: string };
|
|
78
|
+
/** 64 bytes. */
|
|
79
|
+
signature: Uint8Array;
|
|
80
|
+
transparencyStatus: string;
|
|
81
|
+
/** The record's own inclusion proof once it is in the log, in the shape a
|
|
82
|
+
* certificate's takes. Outside the signature, as on a certificate. */
|
|
83
|
+
transparency?: Record<string, unknown>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a verifier learns about a run record's signature (core.RunRecordResult). */
|
|
87
|
+
export type RunRecordResult =
|
|
88
|
+
| "VALID"
|
|
89
|
+
| "MALFORMED_RUN_RECORD"
|
|
90
|
+
| "UNKNOWN_KEY"
|
|
91
|
+
| "KEY_UNUSABLE"
|
|
92
|
+
| "INVALID_RUN_RECORD_SIGNATURE";
|
|
93
|
+
|
|
94
|
+
/** Mirrors core.RunCheck: what a reader learns by holding a run record beside
|
|
95
|
+
* the records issued under it. */
|
|
96
|
+
export interface RunCheck {
|
|
97
|
+
/** The certificate ids reproduce records_root and records_count. */
|
|
98
|
+
recordsMatch: boolean;
|
|
99
|
+
/** The subject hashes reproduce subjects_root and subjects_count. */
|
|
100
|
+
subjectsMatch: boolean;
|
|
101
|
+
/** listCount minus subjectsCount: how many listed subjects have no record in
|
|
102
|
+
* the run. Zero is the number an auditor wants. */
|
|
103
|
+
listShortfall: number;
|
|
104
|
+
/** closedAt <= notAfter. */
|
|
105
|
+
closedInTime: boolean;
|
|
106
|
+
/** Records whose own signed run_id (format 10.0 and later) names a different
|
|
107
|
+
* run, or no run, while the pack presents them as this run's. A record
|
|
108
|
+
* without the field (older formats) is neither counted nor refused: it says
|
|
109
|
+
* nothing about its run, and the roots are what bind it. */
|
|
110
|
+
mislabelled: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What checkRunAgainstRecords needs from one record of the run. */
|
|
114
|
+
export interface RunCheckRecord {
|
|
115
|
+
certificateId: string;
|
|
116
|
+
/** subject.identifier_hash, 32 bytes. */
|
|
117
|
+
subjectHash: Uint8Array;
|
|
118
|
+
/** The record's own signed run_id, when it carries one. */
|
|
119
|
+
runId?: string;
|
|
120
|
+
formatVersion?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type Doc = Record<string, unknown>;
|
|
124
|
+
|
|
125
|
+
function isObject(v: unknown): v is Doc {
|
|
126
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A UUID field as Go's uuid.UUID unmarshals it: null or absent is the nil
|
|
130
|
+
* UUID (encoding/json leaves the zero value), a string in any form uuid.Parse
|
|
131
|
+
* reads is canonical, anything else fails the document. */
|
|
132
|
+
function uuidOrNil(v: unknown, path: string): string {
|
|
133
|
+
if (v === null || v === undefined) return NIL_UUID;
|
|
134
|
+
if (typeof v !== "string") throw new VerificationError(`${path} is not a string`);
|
|
135
|
+
try {
|
|
136
|
+
return formatUuid(parseUuid(v));
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e instanceof TypeError) throw new VerificationError(`${path} is not a UUID: ${JSON.stringify(v)}`);
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A fixed byte array as Go's [N]byte unmarshals it: null or absent is all
|
|
144
|
+
* zero, anything else must be exactly N bytes. */
|
|
145
|
+
function bytesOrZero(v: unknown, path: string, length: number): Uint8Array {
|
|
146
|
+
if (v === null || v === undefined) return new Uint8Array(length);
|
|
147
|
+
return decodeFixed(v, path, length);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A count as Go's uint64 unmarshals it: null or absent is 0, and a fraction
|
|
151
|
+
* or a negative fails the document. */
|
|
152
|
+
function countOrZero(v: unknown, path: string): number {
|
|
153
|
+
if (v === null || v === undefined) return 0;
|
|
154
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0) {
|
|
155
|
+
throw new VerificationError(`${path} is not a non-negative integer: ${JSON.stringify(v)}`);
|
|
156
|
+
}
|
|
157
|
+
return v;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stringOrEmpty(v: unknown, path: string): string {
|
|
161
|
+
if (v === null || v === undefined) return "";
|
|
162
|
+
if (typeof v !== "string") throw new VerificationError(`${path} is not a string`);
|
|
163
|
+
return v;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Read a run record as served, refusing what Go's ParseRunRecordDocument and
|
|
167
|
+
* the unmarshal behind it refuse. Throws a VerificationError naming the field. */
|
|
168
|
+
export function parseRunRecordDocument(doc: unknown): RunRecord {
|
|
169
|
+
if (!isObject(doc)) throw new VerificationError("run record: not a JSON object");
|
|
170
|
+
const rawIssuer = doc.issuer;
|
|
171
|
+
if (rawIssuer !== null && rawIssuer !== undefined && !isObject(rawIssuer)) {
|
|
172
|
+
throw new VerificationError("issuer is not an object");
|
|
173
|
+
}
|
|
174
|
+
const issuer: Doc = isObject(rawIssuer) ? rawIssuer : {};
|
|
175
|
+
const rawSystems = doc.system_ids;
|
|
176
|
+
let systemIds: string[] = [];
|
|
177
|
+
if (rawSystems !== null && rawSystems !== undefined) {
|
|
178
|
+
if (!Array.isArray(rawSystems)) throw new VerificationError("system_ids is not an array");
|
|
179
|
+
systemIds = rawSystems.map((v, i) => uuidOrNil(v, `system_ids[${i}]`));
|
|
180
|
+
}
|
|
181
|
+
const record: RunRecord = {
|
|
182
|
+
runId: uuidOrNil(doc.run_id, "run_id"),
|
|
183
|
+
runKind: stringOrEmpty(doc.run_kind, "run_kind"),
|
|
184
|
+
teamId: uuidOrNil(doc.team_id, "team_id"),
|
|
185
|
+
listDigest: bytesOrZero(doc.list_digest, "list_digest", 32),
|
|
186
|
+
listCount: countOrZero(doc.list_count, "list_count"),
|
|
187
|
+
subjectsRoot: bytesOrZero(doc.subjects_root, "subjects_root", 32),
|
|
188
|
+
subjectsCount: countOrZero(doc.subjects_count, "subjects_count"),
|
|
189
|
+
recordsRoot: bytesOrZero(doc.records_root, "records_root", 32),
|
|
190
|
+
recordsCount: countOrZero(doc.records_count, "records_count"),
|
|
191
|
+
systemIds,
|
|
192
|
+
openedAt: formatTimestamp(doc.opened_at, "opened_at"),
|
|
193
|
+
closedAt: formatTimestamp(doc.closed_at, "closed_at"),
|
|
194
|
+
notAfter: formatTimestamp(doc.not_after, "not_after"),
|
|
195
|
+
issuer: {
|
|
196
|
+
keyId: stringOrEmpty(issuer.key_id, "issuer.key_id"),
|
|
197
|
+
enclavePcr0: stringOrEmpty(issuer.enclave_pcr0, "issuer.enclave_pcr0"),
|
|
198
|
+
},
|
|
199
|
+
signature: bytesOrZero(doc.signature, "signature", 64),
|
|
200
|
+
transparencyStatus: stringOrEmpty(doc.transparency_status, "transparency_status"),
|
|
201
|
+
};
|
|
202
|
+
const transparency = doc.transparency;
|
|
203
|
+
if (transparency !== null && transparency !== undefined) {
|
|
204
|
+
if (!isObject(transparency)) throw new VerificationError("transparency is not an object");
|
|
205
|
+
record.transparency = transparency;
|
|
206
|
+
}
|
|
207
|
+
return record;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The canonical signing payload. Every signed field is present; the system
|
|
211
|
+
* ids are sorted so two records over the same set sign the same bytes. Mirrors
|
|
212
|
+
* core.BuildRunRecordPayload, including its refusal of an unknown run_kind. */
|
|
213
|
+
export function buildRunRecordPayload(r: RunRecord): Uint8Array {
|
|
214
|
+
if (!RUN_KINDS.has(r.runKind)) {
|
|
215
|
+
throw new VerificationError(`run record: unknown run_kind ${r.runKind}`);
|
|
216
|
+
}
|
|
217
|
+
return canonicalJson({
|
|
218
|
+
payload_type: PAYLOAD_TYPE_RUN_RECORD,
|
|
219
|
+
closed_at: formatTimestamp(r.closedAt, "closed_at"),
|
|
220
|
+
enclave_pcr0: r.issuer.enclavePcr0,
|
|
221
|
+
key_id: r.issuer.keyId,
|
|
222
|
+
list_count: r.listCount,
|
|
223
|
+
list_digest: bytesToHex(r.listDigest),
|
|
224
|
+
not_after: formatTimestamp(r.notAfter, "not_after"),
|
|
225
|
+
opened_at: formatTimestamp(r.openedAt, "opened_at"),
|
|
226
|
+
records_count: r.recordsCount,
|
|
227
|
+
records_root: bytesToHex(r.recordsRoot),
|
|
228
|
+
run_id: r.runId,
|
|
229
|
+
run_kind: r.runKind,
|
|
230
|
+
subjects_count: r.subjectsCount,
|
|
231
|
+
subjects_root: bytesToHex(r.subjectsRoot),
|
|
232
|
+
system_ids: [...r.systemIds].sort(),
|
|
233
|
+
team_id: r.teamId,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function epochSeconds(rfc3339: string, field: string): number {
|
|
238
|
+
return Math.floor(Date.parse(formatTimestamp(rfc3339, field)) / 1000);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Checks the record's signature against the reader's keys, in the order every
|
|
242
|
+
* verifier uses: the shape, the key's authority at the record's own close
|
|
243
|
+
* instant, then the signature. Mirrors core.VerifyRunRecord. */
|
|
244
|
+
export async function verifyRunRecord(
|
|
245
|
+
crypto: CryptoOps,
|
|
246
|
+
r: RunRecord,
|
|
247
|
+
keys: Map<string, PublicKeyInfo>,
|
|
248
|
+
): Promise<RunRecordResult> {
|
|
249
|
+
const closedAt = epochSeconds(r.closedAt, "closed_at");
|
|
250
|
+
if (!RUN_KINDS.has(r.runKind) || r.runId === NIL_UUID || closedAt < epochSeconds(r.openedAt, "opened_at")) {
|
|
251
|
+
return "MALFORMED_RUN_RECORD";
|
|
252
|
+
}
|
|
253
|
+
const key = keys.get(r.issuer.keyId);
|
|
254
|
+
if (key === undefined) return "UNKNOWN_KEY";
|
|
255
|
+
if (!keyIsUsable(evaluateKey(key, closedAt))) return "KEY_UNUSABLE";
|
|
256
|
+
const payload = buildRunRecordPayload(r);
|
|
257
|
+
if (!(await crypto.ed25519Verify(key.keyBytes, payload, r.signature))) return "INVALID_RUN_RECORD_SIGNATURE";
|
|
258
|
+
return "VALID";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** What the run record's log leaf commits to: the SHA-256 of its canonical
|
|
262
|
+
* payload. A certificate leaf hashes the whole document less its transparency
|
|
263
|
+
* block; a run record's payload already is the whole signed document. */
|
|
264
|
+
export function runRecordHash(crypto: CryptoOps, r: RunRecord): Promise<Uint8Array> {
|
|
265
|
+
return crypto.sha256(buildRunRecordPayload(r));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Checks the record's own transparency proof the way a certificate's is
|
|
269
|
+
* checked, under core.TransparencyResult's names: NOT_INCLUDED when it carries
|
|
270
|
+
* none, INVALID_INCLUSION_PROOF for a leaf that is not a RUN_RECORD leaf,
|
|
271
|
+
* UNREADABLE_DOCUMENT where the leaf cannot be built. Mirrors
|
|
272
|
+
* core.VerifyRunRecordInclusion. */
|
|
273
|
+
export async function verifyRunRecordInclusion(
|
|
274
|
+
crypto: CryptoOps,
|
|
275
|
+
r: RunRecord,
|
|
276
|
+
key: PublicKeyInfo,
|
|
277
|
+
): Promise<string> {
|
|
278
|
+
const transparency = r.transparency;
|
|
279
|
+
if (transparency === undefined) return "NOT_INCLUDED";
|
|
280
|
+
if (transparency.entry_type !== "RUN_RECORD") return "INVALID_INCLUSION_PROOF";
|
|
281
|
+
let leaf: Uint8Array;
|
|
282
|
+
try {
|
|
283
|
+
const payload = buildLogLeafPayload("RUN_RECORD", r.runId, await runRecordHash(crypto, r), transparency.appended_at);
|
|
284
|
+
leaf = await hashLeaf(crypto, payload);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
if (e instanceof VerificationError) return "UNREADABLE_DOCUMENT";
|
|
287
|
+
throw e;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
await verifyTransparencyHead(crypto, transparency, key, r.issuer.keyId);
|
|
291
|
+
await verifyTransparencyInclusion(crypto, transparency, leaf);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
if (e instanceof VerificationError) return transparencyVerdictName(e);
|
|
294
|
+
throw e;
|
|
295
|
+
}
|
|
296
|
+
return "VALID";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function compareBytes(a: Uint8Array, b: Uint8Array): number {
|
|
300
|
+
const n = Math.min(a.length, b.length);
|
|
301
|
+
for (let i = 0; i < n; i++) {
|
|
302
|
+
if (a[i] !== b[i]) return a[i]! - b[i]!;
|
|
303
|
+
}
|
|
304
|
+
return a.length - b.length;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The root over a leaf set the way a run record commits to one: leaves
|
|
308
|
+
* sorted bytewise, and 32 zero bytes for the empty set. */
|
|
309
|
+
async function sortedRoot(crypto: CryptoOps, leaves: Uint8Array[]): Promise<Uint8Array> {
|
|
310
|
+
if (leaves.length === 0) return new Uint8Array(32);
|
|
311
|
+
return merkleRoot(crypto, [...leaves].sort(compareBytes));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** What a run record commits to, from what was issued under it. Subjects are
|
|
315
|
+
* de-duplicated — a subject certified twice in one run is one subject — and
|
|
316
|
+
* both leaf sets are sorted, so the roots do not depend on issuance order and a
|
|
317
|
+
* verifier can reproduce them from a pack in any order. A subject leaf is its
|
|
318
|
+
* 32-byte hash and a record leaf the certificate id's 16 bytes, both through
|
|
319
|
+
* the RFC 6962 leaf hash. Mirrors core.RunRoots. */
|
|
320
|
+
export async function runRoots(
|
|
321
|
+
crypto: CryptoOps,
|
|
322
|
+
subjects: Uint8Array[],
|
|
323
|
+
certificateIds: string[],
|
|
324
|
+
): Promise<{ subjectsRoot: Uint8Array; subjectsCount: number; recordsRoot: Uint8Array; recordsCount: number }> {
|
|
325
|
+
const seen = new Set<string>();
|
|
326
|
+
const subjectLeaves: Uint8Array[] = [];
|
|
327
|
+
for (const s of subjects) {
|
|
328
|
+
const key = bytesToHex(s);
|
|
329
|
+
if (seen.has(key)) continue;
|
|
330
|
+
seen.add(key);
|
|
331
|
+
subjectLeaves.push(await hashLeaf(crypto, s));
|
|
332
|
+
}
|
|
333
|
+
const recordLeaves: Uint8Array[] = [];
|
|
334
|
+
for (const id of certificateIds) recordLeaves.push(await hashLeaf(crypto, parseUuid(id)));
|
|
335
|
+
return {
|
|
336
|
+
subjectsRoot: await sortedRoot(crypto, subjectLeaves),
|
|
337
|
+
subjectsCount: subjectLeaves.length,
|
|
338
|
+
recordsRoot: await sortedRoot(crypto, recordLeaves),
|
|
339
|
+
recordsCount: recordLeaves.length,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
344
|
+
return compareBytes(a, b) === 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Reproduces the run record's roots from the records a reader holds and
|
|
348
|
+
* compares them. The reader must hold every record of the run — a complete
|
|
349
|
+
* chain of pack pages filtered by the run — or the roots cannot match, which
|
|
350
|
+
* is the honest answer for a partial set. Mirrors core.CheckRunAgainstRecords. */
|
|
351
|
+
export async function checkRunAgainstRecords(
|
|
352
|
+
crypto: CryptoOps,
|
|
353
|
+
r: RunRecord,
|
|
354
|
+
records: RunCheckRecord[],
|
|
355
|
+
): Promise<RunCheck> {
|
|
356
|
+
let mislabelled = 0;
|
|
357
|
+
for (const rec of records) {
|
|
358
|
+
if (rec.runId !== undefined && rec.runId !== r.runId) mislabelled++;
|
|
359
|
+
if (rec.runId === undefined && rec.formatVersion !== undefined && signatureCoversRunId(rec.formatVersion)) {
|
|
360
|
+
mislabelled++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const roots = await runRoots(crypto, records.map((rec) => rec.subjectHash), records.map((rec) => rec.certificateId));
|
|
364
|
+
return {
|
|
365
|
+
recordsMatch: bytesEqual(roots.recordsRoot, r.recordsRoot) && roots.recordsCount === r.recordsCount,
|
|
366
|
+
subjectsMatch: bytesEqual(roots.subjectsRoot, r.subjectsRoot) && roots.subjectsCount === r.subjectsCount,
|
|
367
|
+
listShortfall: r.listCount - r.subjectsCount,
|
|
368
|
+
closedInTime: epochSeconds(r.closedAt, "closed_at") <= epochSeconds(r.notAfter, "not_after"),
|
|
369
|
+
mislabelled,
|
|
370
|
+
};
|
|
371
|
+
}
|