@sanning/proof 0.18.0 → 0.19.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 +2 -2
- package/dist/case-file.js +2 -1
- package/dist/case.js +11 -4
- package/dist/chain.d.ts +28 -0
- package/dist/chain.js +61 -4
- package/dist/cli.js +226 -33
- package/dist/declaration.d.ts +43 -4
- package/dist/declaration.js +198 -14
- package/dist/environment.d.ts +42 -0
- package/dist/environment.js +136 -0
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +32 -1
- package/dist/exit-codes.d.ts +18 -0
- package/dist/exit-codes.js +25 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +20 -4
- package/dist/run-adapters.d.ts +10 -0
- package/dist/run-adapters.js +35 -0
- package/dist/run.d.ts +209 -0
- package/dist/run.js +1405 -0
- package/dist/verifier.d.ts +43 -0
- package/dist/verifier.js +104 -1
- package/dist/witness-lookup.d.ts +13 -1
- package/dist/witness-lookup.js +12 -2
- package/package.json +1 -1
- package/src/case-file.ts +2 -1
- package/src/case.ts +11 -4
- package/src/chain.ts +81 -4
- package/src/cli.ts +259 -28
- package/src/declaration.ts +242 -12
- package/src/environment.ts +145 -0
- package/src/evidence.ts +29 -1
- package/src/exit-codes.ts +28 -0
- package/src/index.ts +30 -1
- package/src/run-adapters.ts +44 -0
- package/src/run.ts +1598 -0
- package/src/verifier.ts +94 -1
- package/src/witness-lookup.ts +18 -1
- package/vectors/README.md +37 -0
- package/vectors/sandbox-evidence.json +3161 -0
package/dist/run.js
ADDED
|
@@ -0,0 +1,1405 @@
|
|
|
1
|
+
// The run reading: the enclave attestation line and the run-account line.
|
|
2
|
+
//
|
|
3
|
+
// DRAFT. `specs/enclave-attestation.md` and `specs/run-records.md` define both
|
|
4
|
+
// lines; this module is their provider-neutral half, the twin of
|
|
5
|
+
// `src/sanning_proof/run.py`. It changes no verdict, no exception and no exit
|
|
6
|
+
// code: a drop without an opening record reads exactly as it did before, and a
|
|
7
|
+
// drop with one gets two lines beside the verdict.
|
|
8
|
+
//
|
|
9
|
+
// No new cryptography. The kernel has already verified every envelope and every
|
|
10
|
+
// stamp; this module reads the records it verified and adds SHA-256 and
|
|
11
|
+
// comparisons. The token, the certificate chain and the result signature are an
|
|
12
|
+
// ADAPTER's, a separate package. `readRuns` imports nothing: a caller passes the
|
|
13
|
+
// adapters it loaded (see `run-adapters.ts`, Node-only), and without one every
|
|
14
|
+
// opening record reads `not_checked` with `no_adapter`.
|
|
15
|
+
import { bytesToHex, hexToBytes, sha256Hex, utf8 } from "./crypto.js";
|
|
16
|
+
import { verifyEvidenceBundle } from "./evidence.js";
|
|
17
|
+
import { sideContentEntry, sideContentKeys } from "./side-content.js";
|
|
18
|
+
import { DuplicateKeyError, exceedsTextDepth, jcs, jcsParse, jcsParseWithinLimits, MAX_READ_DEPTH, renderKey, } from "./verifier.js";
|
|
19
|
+
export const ATTESTATION_VERSION = "sanning.enclave-attestation/v1";
|
|
20
|
+
export const BINDING_VERSION = "sanning.run-binding/v1";
|
|
21
|
+
export const POLICY_VERSION = "sanning.enclave-policy/v1";
|
|
22
|
+
export const MANIFEST_VERSION = "sanning.run-manifest/v1";
|
|
23
|
+
export const RUN_OPENED = "runner.run_opened";
|
|
24
|
+
export const RUN_CLOSED = "runner.run_closed";
|
|
25
|
+
const RUNNER_PREFIX = "runner.";
|
|
26
|
+
export const PROVIDER_GCP = "gcp-confidential-space";
|
|
27
|
+
export const PROVIDER_FLARE = "flare-fcc";
|
|
28
|
+
/**
|
|
29
|
+
* The fixed table: provider id to the package that answers for it. A provider
|
|
30
|
+
* id read from a pack is looked up HERE and nowhere else, so a pack naming `os`
|
|
31
|
+
* or `../x` loads nothing.
|
|
32
|
+
*/
|
|
33
|
+
export const ADAPTER_MODULES = Object.freeze({
|
|
34
|
+
[PROVIDER_GCP]: "@sanning/enclave-gcp",
|
|
35
|
+
[PROVIDER_FLARE]: "@sanning/enclave-flare",
|
|
36
|
+
});
|
|
37
|
+
/** Printed after an enclave line whose token was checked. Never a state. */
|
|
38
|
+
export const REVOCATION_SENTENCE = "enclave attestation: revocation not checked " +
|
|
39
|
+
"(the pack carries no certificate revocation lists)";
|
|
40
|
+
export const ENCLAVE_STATES = ["confirmed", "differs", "not_checked"];
|
|
41
|
+
export const RUN_ACCOUNT_STATES = ["closed", "differs", "not_closed"];
|
|
42
|
+
/** Why an enclave line reads `not_checked`. */
|
|
43
|
+
export const ENCLAVE_NOT_CHECKED_KINDS = [
|
|
44
|
+
"no_adapter",
|
|
45
|
+
"no_attestation",
|
|
46
|
+
"unknown_version",
|
|
47
|
+
"no_policy",
|
|
48
|
+
"policy_unreadable",
|
|
49
|
+
"adapter_failed",
|
|
50
|
+
"no_opening_record",
|
|
51
|
+
];
|
|
52
|
+
/** The provider-neutral checks that make a line `differs`, in line order. */
|
|
53
|
+
export const ENCLAVE_DIFFERS_KINDS = [
|
|
54
|
+
"attestation_malformed",
|
|
55
|
+
"subject_key_not_signer",
|
|
56
|
+
"manifest_not_content",
|
|
57
|
+
"binding_other_run",
|
|
58
|
+
"binding_other_manifest",
|
|
59
|
+
"binding_other_keys",
|
|
60
|
+
"signed_at_outside_tolerance",
|
|
61
|
+
"manifest_content_mismatch",
|
|
62
|
+
"manifest_unreadable",
|
|
63
|
+
"case_hash_differs",
|
|
64
|
+
"agent_record_under_runner_key",
|
|
65
|
+
];
|
|
66
|
+
/** The kinds an adapter may answer with; any other reads `adapter_failed`. */
|
|
67
|
+
export const ENCLAVE_NOTE_KINDS = ["no_issue_time", "manifest_withheld", "stamp_unwitnessed"];
|
|
68
|
+
/**
|
|
69
|
+
* Every kind and note the kernel itself names. An adapter declares its own
|
|
70
|
+
* failure kinds (its `kinds`, section 9); none of them may be one of these, so
|
|
71
|
+
* an adapter can never speak for a neutral check.
|
|
72
|
+
*/
|
|
73
|
+
export const NEUTRAL_KINDS = new Set([
|
|
74
|
+
...ENCLAVE_NOT_CHECKED_KINDS,
|
|
75
|
+
...ENCLAVE_DIFFERS_KINDS,
|
|
76
|
+
...ENCLAVE_NOTE_KINDS,
|
|
77
|
+
]);
|
|
78
|
+
/** The form of a kind an adapter declares: snake_case, one word on the line. */
|
|
79
|
+
const KIND_FORM = /^[a-z][a-z0-9_]{0,63}$/;
|
|
80
|
+
export const ACCOUNT_FINDING_KINDS = [
|
|
81
|
+
"record_not_listed",
|
|
82
|
+
"listed_record_absent",
|
|
83
|
+
"more_than_one_closing_record",
|
|
84
|
+
"closing_malformed",
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Why the online line (`flare-fcc` only, section 8) reads `not_checked`:
|
|
88
|
+
* nothing was decided. `adapter_failed` is the kernel's.
|
|
89
|
+
*/
|
|
90
|
+
export const ONLINE_NOT_CHECKED_KINDS = [
|
|
91
|
+
"adapter_failed",
|
|
92
|
+
"evidence_not_confirmed",
|
|
93
|
+
"no_contracts",
|
|
94
|
+
"manifest_withheld",
|
|
95
|
+
"manifest_unreadable",
|
|
96
|
+
"chain_unavailable",
|
|
97
|
+
"not_final",
|
|
98
|
+
];
|
|
99
|
+
/** Why the online line reads `differs`: the chain answered, and it does not match. */
|
|
100
|
+
export const ONLINE_DIFFERS_KINDS = [
|
|
101
|
+
"instruction_count",
|
|
102
|
+
"instruction_other_extension",
|
|
103
|
+
"instruction_other_machine",
|
|
104
|
+
"instruction_other_operation",
|
|
105
|
+
"instruction_other_message",
|
|
106
|
+
"transaction_failed",
|
|
107
|
+
"log_malformed",
|
|
108
|
+
"run_opened_differs",
|
|
109
|
+
"run_opened_count",
|
|
110
|
+
"machine_not_production",
|
|
111
|
+
"machine_other_extension",
|
|
112
|
+
"code_not_supported",
|
|
113
|
+
"call_reverted",
|
|
114
|
+
];
|
|
115
|
+
export const ACCOUNT_NOTE_KINDS = [
|
|
116
|
+
"agent_key_unknown",
|
|
117
|
+
"runner_record_under_agent_key",
|
|
118
|
+
"runner_record_bytes_withheld",
|
|
119
|
+
"payload_hashes_absent",
|
|
120
|
+
"unacknowledged_record_absent",
|
|
121
|
+
];
|
|
122
|
+
const SAFE = Number.MAX_SAFE_INTEGER;
|
|
123
|
+
const U32 = 2 ** 32 - 1;
|
|
124
|
+
const U8 = 255;
|
|
125
|
+
const LOWER_HEX64 = /^[0-9a-f]{64}$/;
|
|
126
|
+
const HEX_0X = /^0x(?:[0-9a-fA-F]{2})*$/;
|
|
127
|
+
const LOWER_0X = /^0x(?:[0-9a-f]{2})*$/;
|
|
128
|
+
const B64URL_32 = /^[A-Za-z0-9_-]{43}$/;
|
|
129
|
+
/**
|
|
130
|
+
* A plane's origin (section 4): http or https, a lowercase host (a DNS name, an
|
|
131
|
+
* IPv4 address, or an IPv6 address in brackets), and an optional port from 1
|
|
132
|
+
* to 65535 with no leading zero, never the scheme's default (443, 80). Nothing
|
|
133
|
+
* after it: no path, no trailing slash.
|
|
134
|
+
*/
|
|
135
|
+
const LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
|
|
136
|
+
const ORIGIN = new RegExp(`^https?://(?:${LABEL}(?:\\.${LABEL})*|\\[([0-9a-f:.]+)\\])` +
|
|
137
|
+
"(?::(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$");
|
|
138
|
+
/** A plane origin in its one form (section 4): `isOrigin` in Python. */
|
|
139
|
+
function isOrigin(value) {
|
|
140
|
+
const m = ORIGIN.exec(value);
|
|
141
|
+
if (m === null)
|
|
142
|
+
return false;
|
|
143
|
+
if (m[1] !== undefined && !isIpv6Text(m[1]))
|
|
144
|
+
return false;
|
|
145
|
+
return !value.endsWith(value.startsWith("https://") ? ":443" : ":80");
|
|
146
|
+
}
|
|
147
|
+
const isHex = (c) => c !== "" && "0123456789abcdef".includes(c);
|
|
148
|
+
const isDigit = (c) => c !== "" && c >= "0" && c <= "9";
|
|
149
|
+
/**
|
|
150
|
+
* Whether `s`, the text between a host's brackets, is an IPv6 address as the
|
|
151
|
+
* WHATWG URL Standard's IPv6 parser accepts one (RFC 4291 text form), in
|
|
152
|
+
* lowercase. A step for step port of the parser, the twin of Python's
|
|
153
|
+
* `is_ipv6_text` and the runner's `run.IsPlaneOrigin`.
|
|
154
|
+
*/
|
|
155
|
+
export function isIpv6Text(s) {
|
|
156
|
+
const n = s.length;
|
|
157
|
+
const at = (k) => (k < n ? s[k] : "");
|
|
158
|
+
let i = 0;
|
|
159
|
+
let piece = 0;
|
|
160
|
+
let compress = null;
|
|
161
|
+
if (at(i) === ":") {
|
|
162
|
+
if (at(i + 1) !== ":")
|
|
163
|
+
return false;
|
|
164
|
+
i += 2;
|
|
165
|
+
piece += 1;
|
|
166
|
+
compress = piece;
|
|
167
|
+
}
|
|
168
|
+
while (i < n) {
|
|
169
|
+
if (piece === 8)
|
|
170
|
+
return false;
|
|
171
|
+
if (s[i] === ":") {
|
|
172
|
+
if (compress !== null)
|
|
173
|
+
return false;
|
|
174
|
+
i += 1;
|
|
175
|
+
piece += 1;
|
|
176
|
+
compress = piece;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
let length = 0;
|
|
180
|
+
while (length < 4 && isHex(at(i))) {
|
|
181
|
+
i += 1;
|
|
182
|
+
length += 1;
|
|
183
|
+
}
|
|
184
|
+
if (at(i) === ".") {
|
|
185
|
+
if (length === 0)
|
|
186
|
+
return false;
|
|
187
|
+
i -= length;
|
|
188
|
+
if (piece > 6)
|
|
189
|
+
return false;
|
|
190
|
+
let seen = 0;
|
|
191
|
+
while (i < n) {
|
|
192
|
+
if (seen > 0) {
|
|
193
|
+
if (s[i] === "." && seen < 4)
|
|
194
|
+
i += 1;
|
|
195
|
+
else
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (!isDigit(at(i)))
|
|
199
|
+
return false;
|
|
200
|
+
let part = null;
|
|
201
|
+
while (isDigit(at(i))) {
|
|
202
|
+
const d = Number(s[i]);
|
|
203
|
+
if (part === null)
|
|
204
|
+
part = d;
|
|
205
|
+
else if (part === 0)
|
|
206
|
+
return false;
|
|
207
|
+
else
|
|
208
|
+
part = part * 10 + d;
|
|
209
|
+
if (part > 255)
|
|
210
|
+
return false;
|
|
211
|
+
i += 1;
|
|
212
|
+
}
|
|
213
|
+
seen += 1;
|
|
214
|
+
if (seen === 2 || seen === 4)
|
|
215
|
+
piece += 1;
|
|
216
|
+
}
|
|
217
|
+
if (seen !== 4)
|
|
218
|
+
return false;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
if (at(i) === ":") {
|
|
222
|
+
i += 1;
|
|
223
|
+
if (i === n)
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
else if (i < n) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
piece += 1;
|
|
230
|
+
}
|
|
231
|
+
return compress !== null || piece === 8;
|
|
232
|
+
}
|
|
233
|
+
const INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?Z$/;
|
|
234
|
+
// ---- The strict reader ------------------------------------------------------
|
|
235
|
+
/** The object does not read under the one spelling. The message is the reason. */
|
|
236
|
+
export class AttestationRefused extends Error {
|
|
237
|
+
}
|
|
238
|
+
/** `attestation_version` is a string this reader does not know. */
|
|
239
|
+
export class UnknownAttestationVersion extends AttestationRefused {
|
|
240
|
+
}
|
|
241
|
+
const obj = (members) => ({ kind: "obj", members });
|
|
242
|
+
const STR = { kind: "str" };
|
|
243
|
+
const NSTR = { kind: "nstr" };
|
|
244
|
+
const LHEX64 = { kind: "lhex64" };
|
|
245
|
+
const HEX = { kind: "hex" };
|
|
246
|
+
const ORIGIN_NODE = { kind: "origin" };
|
|
247
|
+
const hexn = (bytes) => ({ kind: "hexn", bytes });
|
|
248
|
+
const lhexn = (bytes) => ({ kind: "lhexn", bytes });
|
|
249
|
+
const int = (max) => ({ kind: "int", max });
|
|
250
|
+
const konst = (value) => ({ kind: "const", value });
|
|
251
|
+
const SUBJECT = [
|
|
252
|
+
["runner_public_key", LHEX64],
|
|
253
|
+
["agent_public_key", LHEX64],
|
|
254
|
+
["manifest_hash", LHEX64],
|
|
255
|
+
["run_id", NSTR],
|
|
256
|
+
];
|
|
257
|
+
const ATTESTATION = [
|
|
258
|
+
["attestation_version", konst(ATTESTATION_VERSION)],
|
|
259
|
+
["subject", obj(SUBJECT)],
|
|
260
|
+
["provider", NSTR],
|
|
261
|
+
["evidence", { kind: "any" }],
|
|
262
|
+
];
|
|
263
|
+
function bindingSchema(request) {
|
|
264
|
+
return [
|
|
265
|
+
["binding_version", konst(BINDING_VERSION)],
|
|
266
|
+
["run_id", NSTR],
|
|
267
|
+
["manifest_hash", LHEX64],
|
|
268
|
+
["runner_public_key", LHEX64],
|
|
269
|
+
["agent_public_key", LHEX64],
|
|
270
|
+
["credential_public_key", LHEX64],
|
|
271
|
+
["case_hash", LHEX64],
|
|
272
|
+
["plane", ORIGIN_NODE],
|
|
273
|
+
["request", obj(request)],
|
|
274
|
+
];
|
|
275
|
+
}
|
|
276
|
+
const GCP_BINDING = bindingSchema([["provider", konst(PROVIDER_GCP)]]);
|
|
277
|
+
const FLARE_BINDING = bindingSchema([
|
|
278
|
+
["provider", konst(PROVIDER_FLARE)],
|
|
279
|
+
["chain_id", int(SAFE)],
|
|
280
|
+
["instruction_id", lhexn(32)],
|
|
281
|
+
["tee_id", lhexn(20)],
|
|
282
|
+
["opener", lhexn(20)],
|
|
283
|
+
]);
|
|
284
|
+
const GCP_EVIDENCE = [
|
|
285
|
+
["binding", obj(GCP_BINDING)],
|
|
286
|
+
["token", NSTR],
|
|
287
|
+
];
|
|
288
|
+
const PUBLIC_KEY = [
|
|
289
|
+
["x", hexn(32)],
|
|
290
|
+
["y", hexn(32)],
|
|
291
|
+
];
|
|
292
|
+
const TEE_INFO = [
|
|
293
|
+
["challenge", hexn(32)],
|
|
294
|
+
["publicKey", obj(PUBLIC_KEY)],
|
|
295
|
+
["initialSigningPolicyId", int(U32)],
|
|
296
|
+
["initialSigningPolicyHash", hexn(32)],
|
|
297
|
+
["lastSigningPolicyId", int(U32)],
|
|
298
|
+
["lastSigningPolicyHash", hexn(32)],
|
|
299
|
+
["chainId", int(SAFE)],
|
|
300
|
+
[
|
|
301
|
+
"state",
|
|
302
|
+
obj([
|
|
303
|
+
["systemState", HEX],
|
|
304
|
+
["systemStateVersion", hexn(32)],
|
|
305
|
+
["state", HEX],
|
|
306
|
+
["stateVersion", hexn(32)],
|
|
307
|
+
]),
|
|
308
|
+
],
|
|
309
|
+
["teeTimestamp", int(SAFE)],
|
|
310
|
+
["machinePathListNonce", int(SAFE)],
|
|
311
|
+
["machinePathListHash", hexn(32)],
|
|
312
|
+
];
|
|
313
|
+
const MACHINE_DATA = [
|
|
314
|
+
["extensionId", hexn(32)],
|
|
315
|
+
["initialOwner", hexn(20)],
|
|
316
|
+
["codeHash", hexn(32)],
|
|
317
|
+
["platform", hexn(32)],
|
|
318
|
+
["publicKey", obj(PUBLIC_KEY)],
|
|
319
|
+
["governanceHash", hexn(32)],
|
|
320
|
+
];
|
|
321
|
+
const INFO = [
|
|
322
|
+
["teeInfo", obj(TEE_INFO)],
|
|
323
|
+
["machineData", obj(MACHINE_DATA)],
|
|
324
|
+
["dataSignature", HEX],
|
|
325
|
+
["attestation", STR],
|
|
326
|
+
];
|
|
327
|
+
const RESULT = [
|
|
328
|
+
["id", hexn(32)],
|
|
329
|
+
["submissionTag", STR],
|
|
330
|
+
["status", int(U8)],
|
|
331
|
+
["log", STR],
|
|
332
|
+
["opType", hexn(32)],
|
|
333
|
+
["opCommand", hexn(32)],
|
|
334
|
+
["additionalResultStatus", HEX],
|
|
335
|
+
["version", STR],
|
|
336
|
+
["data", HEX],
|
|
337
|
+
];
|
|
338
|
+
const FLARE_EVIDENCE = [
|
|
339
|
+
["result", obj(RESULT)],
|
|
340
|
+
["signature", HEX],
|
|
341
|
+
["info", obj(INFO)],
|
|
342
|
+
];
|
|
343
|
+
const EVIDENCE = {
|
|
344
|
+
[PROVIDER_GCP]: GCP_EVIDENCE,
|
|
345
|
+
[PROVIDER_FLARE]: FLARE_EVIDENCE,
|
|
346
|
+
};
|
|
347
|
+
function isPlainObject(v) {
|
|
348
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
349
|
+
}
|
|
350
|
+
function has(o, k) {
|
|
351
|
+
return Object.prototype.hasOwnProperty.call(o, k);
|
|
352
|
+
}
|
|
353
|
+
/** A JSON number whose value is a whole number within 2^53 - 1. */
|
|
354
|
+
function wholeNumber(v) {
|
|
355
|
+
return typeof v === "number" && Number.isSafeInteger(v) ? v + 0 : null;
|
|
356
|
+
}
|
|
357
|
+
function walk(value, node, path) {
|
|
358
|
+
switch (node.kind) {
|
|
359
|
+
case "any":
|
|
360
|
+
if (!isPlainObject(value))
|
|
361
|
+
throw new AttestationRefused(`${path} must be an object`);
|
|
362
|
+
return;
|
|
363
|
+
case "obj": {
|
|
364
|
+
if (!isPlainObject(value))
|
|
365
|
+
throw new AttestationRefused(`${path} must be an object`);
|
|
366
|
+
const allowed = new Set(node.members.map(([name]) => name));
|
|
367
|
+
const unknown = Object.keys(value)
|
|
368
|
+
.filter((k) => !allowed.has(k))
|
|
369
|
+
.sort();
|
|
370
|
+
if (unknown.length > 0) {
|
|
371
|
+
throw new AttestationRefused(`${path}: unknown member ${renderKey(unknown[0])}`);
|
|
372
|
+
}
|
|
373
|
+
for (const [name, sub] of node.members) {
|
|
374
|
+
if (!has(value, name))
|
|
375
|
+
throw new AttestationRefused(`${path}.${name} is required`);
|
|
376
|
+
walk(value[name], sub, `${path}.${name}`);
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case "int": {
|
|
381
|
+
const n = wholeNumber(value);
|
|
382
|
+
if (n === null)
|
|
383
|
+
throw new AttestationRefused(`${path} must be a whole number`);
|
|
384
|
+
if (n < 0 || n > node.max)
|
|
385
|
+
throw new AttestationRefused(`${path} is out of range`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (typeof value !== "string")
|
|
390
|
+
throw new AttestationRefused(`${path} must be a string`);
|
|
391
|
+
switch (node.kind) {
|
|
392
|
+
case "str":
|
|
393
|
+
return;
|
|
394
|
+
case "nstr":
|
|
395
|
+
if (value === "")
|
|
396
|
+
throw new AttestationRefused(`${path} must be a non-empty string`);
|
|
397
|
+
return;
|
|
398
|
+
case "const":
|
|
399
|
+
if (value !== node.value)
|
|
400
|
+
throw new AttestationRefused(`${path} must be ${renderKey(node.value)}`);
|
|
401
|
+
return;
|
|
402
|
+
case "lhex64":
|
|
403
|
+
if (!LOWER_HEX64.test(value)) {
|
|
404
|
+
throw new AttestationRefused(`${path} must be 64 lowercase hex characters`);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
case "hex":
|
|
408
|
+
case "hexn":
|
|
409
|
+
if (!HEX_0X.test(value))
|
|
410
|
+
throw new AttestationRefused(`${path} must be 0x-prefixed hex`);
|
|
411
|
+
if (node.kind === "hexn" && value.length !== 2 + 2 * node.bytes) {
|
|
412
|
+
throw new AttestationRefused(`${path} must be ${node.bytes} bytes`);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
case "origin":
|
|
416
|
+
if (!isOrigin(value)) {
|
|
417
|
+
throw new AttestationRefused(`${path} must be an origin (scheme://host[:port])`);
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
case "lhexn":
|
|
421
|
+
if (!LOWER_0X.test(value) || value.length !== 2 + 2 * node.bytes) {
|
|
422
|
+
throw new AttestationRefused(`${path} must be lowercase 0x-prefixed hex of ${node.bytes} bytes`);
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/** Whether a parsed value holds a number past 2^53 - 1 (or not finite). Iterative. */
|
|
428
|
+
function hasUnsafeInteger(value) {
|
|
429
|
+
const stack = [value];
|
|
430
|
+
while (stack.length > 0) {
|
|
431
|
+
const node = stack.pop();
|
|
432
|
+
if (typeof node === "number") {
|
|
433
|
+
if (!Number.isFinite(node))
|
|
434
|
+
return true;
|
|
435
|
+
if (Number.isInteger(node) && !Number.isSafeInteger(node))
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
else if (Array.isArray(node)) {
|
|
439
|
+
stack.push(...node);
|
|
440
|
+
}
|
|
441
|
+
else if (typeof node === "object" && node !== null) {
|
|
442
|
+
stack.push(...Object.values(node));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
function bytesEqual(a, b) {
|
|
448
|
+
if (a.length !== b.length)
|
|
449
|
+
return false;
|
|
450
|
+
for (let i = 0; i < a.length; i++)
|
|
451
|
+
if (a[i] !== b[i])
|
|
452
|
+
return false;
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Read bytes that must be `JCS(value)`: strict UTF-8, valid JSON, no repeated
|
|
457
|
+
* name, no integer beyond 2^53 - 1, and canonical. Bytes nested deeper than
|
|
458
|
+
* every kernel canonicalizes cannot be shown to be JCS, decided on the bytes
|
|
459
|
+
* before any parser runs (section 2.1).
|
|
460
|
+
*/
|
|
461
|
+
function oneSpelling(raw, what) {
|
|
462
|
+
if (exceedsTextDepth(raw, MAX_READ_DEPTH))
|
|
463
|
+
throw new AttestationRefused(`${what} is not JCS`);
|
|
464
|
+
let value;
|
|
465
|
+
try {
|
|
466
|
+
value = jcsParse(raw);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
const reason = err instanceof DuplicateKeyError ? err.message : "not JSON";
|
|
470
|
+
throw new AttestationRefused(`${what}: ${reason}`);
|
|
471
|
+
}
|
|
472
|
+
if (hasUnsafeInteger(value)) {
|
|
473
|
+
throw new AttestationRefused(`${what} holds an integer beyond 2^53 - 1`);
|
|
474
|
+
}
|
|
475
|
+
let canonical = null;
|
|
476
|
+
try {
|
|
477
|
+
canonical = utf8(jcs(value));
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
canonical = null;
|
|
481
|
+
}
|
|
482
|
+
if (canonical === null || !bytesEqual(canonical, raw)) {
|
|
483
|
+
throw new AttestationRefused(`${what} is not JCS`);
|
|
484
|
+
}
|
|
485
|
+
return value;
|
|
486
|
+
}
|
|
487
|
+
function readBinding(value, schema, path) {
|
|
488
|
+
walk(value, obj(schema), path);
|
|
489
|
+
const v = value;
|
|
490
|
+
return {
|
|
491
|
+
runId: v.run_id,
|
|
492
|
+
manifestHash: v.manifest_hash,
|
|
493
|
+
runnerPublicKey: v.runner_public_key,
|
|
494
|
+
agentPublicKey: v.agent_public_key,
|
|
495
|
+
credentialPublicKey: v.credential_public_key,
|
|
496
|
+
caseHash: v.case_hash,
|
|
497
|
+
plane: v.plane,
|
|
498
|
+
request: v.request,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/** A binding as JCS text, so two bindings compare as the bytes they stand for. */
|
|
502
|
+
function bindingText(b) {
|
|
503
|
+
return jcs({
|
|
504
|
+
run_id: b.runId,
|
|
505
|
+
manifest_hash: b.manifestHash,
|
|
506
|
+
runner_public_key: b.runnerPublicKey,
|
|
507
|
+
agent_public_key: b.agentPublicKey,
|
|
508
|
+
credential_public_key: b.credentialPublicKey,
|
|
509
|
+
case_hash: b.caseHash,
|
|
510
|
+
plane: b.plane,
|
|
511
|
+
request: b.request,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function isBinding(v) {
|
|
515
|
+
if (!isPlainObject(v))
|
|
516
|
+
return false;
|
|
517
|
+
for (const k of ["runId", "manifestHash", "runnerPublicKey", "agentPublicKey", "credentialPublicKey", "caseHash", "plane"]) {
|
|
518
|
+
if (typeof v[k] !== "string")
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
return isPlainObject(v.request);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Read the `enclave_attestation` member of an opening record strictly. Throws
|
|
525
|
+
* `UnknownAttestationVersion` for a version string this reader does not know,
|
|
526
|
+
* and `AttestationRefused` for anything else that is not the one spelling.
|
|
527
|
+
*/
|
|
528
|
+
export function readAttestation(value) {
|
|
529
|
+
const path = "enclave_attestation";
|
|
530
|
+
if (!isPlainObject(value))
|
|
531
|
+
throw new AttestationRefused(`${path} must be an object`);
|
|
532
|
+
if (!has(value, "attestation_version")) {
|
|
533
|
+
throw new AttestationRefused(`${path}.attestation_version is required`);
|
|
534
|
+
}
|
|
535
|
+
const version = value.attestation_version;
|
|
536
|
+
if (typeof version !== "string") {
|
|
537
|
+
throw new AttestationRefused(`${path}.attestation_version must be a string`);
|
|
538
|
+
}
|
|
539
|
+
if (version !== ATTESTATION_VERSION) {
|
|
540
|
+
throw new UnknownAttestationVersion(`${path}.attestation_version is not a version this reader knows`);
|
|
541
|
+
}
|
|
542
|
+
walk(value, obj(ATTESTATION), path);
|
|
543
|
+
const provider = value.provider;
|
|
544
|
+
const evidence = value.evidence;
|
|
545
|
+
const subject = value.subject;
|
|
546
|
+
let binding = null;
|
|
547
|
+
const schema = has(EVIDENCE, provider) ? EVIDENCE[provider] : undefined;
|
|
548
|
+
if (schema !== undefined) {
|
|
549
|
+
const evPath = `${path}.evidence`;
|
|
550
|
+
walk(evidence, obj(schema), evPath);
|
|
551
|
+
if (provider === PROVIDER_GCP) {
|
|
552
|
+
binding = readBinding(evidence.binding, GCP_BINDING, `${evPath}.binding`);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
const dataPath = `${evPath}.result.data`;
|
|
556
|
+
const data = evidence.result.data.slice(2);
|
|
557
|
+
binding = readBinding(oneSpelling(hexToBytes(data), dataPath), FLARE_BINDING, dataPath);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
runnerPublicKey: subject.runner_public_key,
|
|
562
|
+
agentPublicKey: subject.agent_public_key,
|
|
563
|
+
manifestHash: subject.manifest_hash,
|
|
564
|
+
runId: subject.run_id,
|
|
565
|
+
provider,
|
|
566
|
+
evidence,
|
|
567
|
+
binding,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/** `readAttestation` over bytes that must be `JCS(object)`. */
|
|
571
|
+
export function readAttestationJson(raw) {
|
|
572
|
+
return readAttestation(oneSpelling(raw, "enclave_attestation"));
|
|
573
|
+
}
|
|
574
|
+
const KEY_HEX = /^[0-9a-fA-F]{64}$/;
|
|
575
|
+
/** A key as a key: 64 hex digits of either case in their lowercase form; else as it is. */
|
|
576
|
+
function asKey(value) {
|
|
577
|
+
if (typeof value !== "string")
|
|
578
|
+
return "";
|
|
579
|
+
return KEY_HEX.test(value) ? value.toLowerCase() : value;
|
|
580
|
+
}
|
|
581
|
+
function line(state, kinds, extra = {}) {
|
|
582
|
+
return {
|
|
583
|
+
state,
|
|
584
|
+
kinds,
|
|
585
|
+
notes: [],
|
|
586
|
+
reason: null,
|
|
587
|
+
provider: null,
|
|
588
|
+
evaluatedAt: null,
|
|
589
|
+
evaluation: null,
|
|
590
|
+
tokenChecked: false,
|
|
591
|
+
...extra,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function eventType(h) {
|
|
595
|
+
const t = h.record?.event_type;
|
|
596
|
+
return typeof t === "string" ? t : null;
|
|
597
|
+
}
|
|
598
|
+
// The disclosed bytes the kernel bound to this record's content_hash, by its
|
|
599
|
+
// own precedence: in the body first, then beside it. Called only for a record
|
|
600
|
+
// whose content the kernel bound, so the hex reads.
|
|
601
|
+
function disclosed(ev, eventId, content) {
|
|
602
|
+
if (typeof ev.content === "string")
|
|
603
|
+
return hexToBytes(ev.content);
|
|
604
|
+
const entry = sideContentEntry(content, eventId);
|
|
605
|
+
if (!entry.offered)
|
|
606
|
+
return null;
|
|
607
|
+
if (entry.value instanceof Uint8Array)
|
|
608
|
+
return entry.value;
|
|
609
|
+
return hexToBytes(entry.value);
|
|
610
|
+
}
|
|
611
|
+
async function heldRecords(pack, packIndex, witnessedRefs) {
|
|
612
|
+
const result = await verifyEvidenceBundle(pack.bundle, {
|
|
613
|
+
content: (pack.content ?? undefined),
|
|
614
|
+
});
|
|
615
|
+
const bundle = pack.bundle;
|
|
616
|
+
const body = isPlainObject(bundle) ? bundle.body : undefined;
|
|
617
|
+
const events = isPlainObject(body) ? body.events : undefined;
|
|
618
|
+
// One result per body event, in order; a pack whose wrapper does not hold
|
|
619
|
+
// has none, so none of its records is read.
|
|
620
|
+
if (!Array.isArray(events) || events.length !== result.events.length)
|
|
621
|
+
return [];
|
|
622
|
+
const stamps = new Map();
|
|
623
|
+
for (const s of result.stamps ?? [])
|
|
624
|
+
if (s.ok && s.stampRef)
|
|
625
|
+
stamps.set(s.stampRef, s);
|
|
626
|
+
const out = [];
|
|
627
|
+
for (let i = 0; i < events.length; i++) {
|
|
628
|
+
const ev = events[i];
|
|
629
|
+
const er = result.events[i];
|
|
630
|
+
if (!isPlainObject(ev))
|
|
631
|
+
continue;
|
|
632
|
+
const envelope = ev.envelope;
|
|
633
|
+
if (!isPlainObject(envelope))
|
|
634
|
+
continue;
|
|
635
|
+
// Only a verified record is ever read, and its event_id is a UUID.
|
|
636
|
+
const eventId = typeof envelope.event_id === "string" ? envelope.event_id : "";
|
|
637
|
+
const rawHex = ev.event_bytes;
|
|
638
|
+
const bytesSupplied = typeof rawHex === "string" && rawHex.length > 0;
|
|
639
|
+
// A verified envelope whose record bytes are present is one whose bytes
|
|
640
|
+
// bound to its payload_hash: the kernel fails it otherwise.
|
|
641
|
+
const verified = er.envelopeOk;
|
|
642
|
+
let raw = null;
|
|
643
|
+
let record = null;
|
|
644
|
+
if (verified) {
|
|
645
|
+
try {
|
|
646
|
+
raw = hexToBytes(rawHex);
|
|
647
|
+
const parsed = jcsParse(raw);
|
|
648
|
+
record = isPlainObject(parsed) ? parsed : null;
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
raw = null;
|
|
652
|
+
record = null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
let stampEnd = null;
|
|
656
|
+
let witnessed = false;
|
|
657
|
+
const incl = ev.inclusion;
|
|
658
|
+
// The stamp the kernel verified as INCLUDING this record: its inclusion,
|
|
659
|
+
// and nothing else about the event. Bytes handed in beside the pack can
|
|
660
|
+
// fail an event; they must not move the time.
|
|
661
|
+
if (er.inclusionOk && isPlainObject(incl) && typeof incl.stamp_ref === "string") {
|
|
662
|
+
const st = stamps.get(incl.stamp_ref);
|
|
663
|
+
if (st !== undefined) {
|
|
664
|
+
stampEnd = st.intervalEnd;
|
|
665
|
+
// Witnessed means looked up and confirmed, by the caller, on the
|
|
666
|
+
// public record. A stamp that merely names a witness is signed by
|
|
667
|
+
// nobody but the pack's wrapper key, which whoever sealed the pack
|
|
668
|
+
// holds, so its interval end is theirs to choose.
|
|
669
|
+
witnessed = witnessedRefs.has(st.stampRef);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const payloadHash = envelope.payload_hash;
|
|
673
|
+
out.push({
|
|
674
|
+
packRef: pack.ref,
|
|
675
|
+
packIndex,
|
|
676
|
+
eventId,
|
|
677
|
+
publicKey: asKey(envelope.public_key),
|
|
678
|
+
payloadHash: typeof payloadHash === "string" ? payloadHash : null,
|
|
679
|
+
signedAt: envelope.signed_at,
|
|
680
|
+
verified,
|
|
681
|
+
bytesSupplied,
|
|
682
|
+
raw,
|
|
683
|
+
record,
|
|
684
|
+
content: er.contentOk === true ? disclosed(ev, eventId, pack.content) : null,
|
|
685
|
+
stampEnd,
|
|
686
|
+
stampWitnessed: witnessed,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
return out;
|
|
690
|
+
}
|
|
691
|
+
// ---- Times ----------------------------------------------------------------------
|
|
692
|
+
function daysFromCivil(y, m, d) {
|
|
693
|
+
y -= m <= 2 ? 1 : 0;
|
|
694
|
+
const era = Math.floor((y >= 0 ? y : y - 399) / 400);
|
|
695
|
+
const yoe = y - era * 400;
|
|
696
|
+
const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1;
|
|
697
|
+
const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
|
|
698
|
+
return era * 146097 + doe - 719468;
|
|
699
|
+
}
|
|
700
|
+
function instant(value) {
|
|
701
|
+
if (typeof value !== "string")
|
|
702
|
+
return null;
|
|
703
|
+
const m = INSTANT.exec(value);
|
|
704
|
+
if (m === null)
|
|
705
|
+
return null;
|
|
706
|
+
const [y, mo, d, h, mi, s] = [1, 2, 3, 4, 5, 6].map((i) => Number(m[i]));
|
|
707
|
+
const seconds = daysFromCivil(y, mo, d) * 86400 + h * 3600 + mi * 60 + s;
|
|
708
|
+
const frac = m[7];
|
|
709
|
+
return [seconds, frac !== undefined && /[1-9]/.test(frac)];
|
|
710
|
+
}
|
|
711
|
+
/** Check 9: `|signed_at - issued_at| <= tolerance`, exactly. */
|
|
712
|
+
function within(signedAt, issuedAt, tolerance) {
|
|
713
|
+
const t = instant(signedAt);
|
|
714
|
+
if (t === null)
|
|
715
|
+
return false;
|
|
716
|
+
const [seconds, fraction] = t;
|
|
717
|
+
const hi = issuedAt + tolerance;
|
|
718
|
+
return seconds >= issuedAt - tolerance && (seconds < hi || (seconds === hi && !fraction));
|
|
719
|
+
}
|
|
720
|
+
function rfc3339(seconds) {
|
|
721
|
+
const days = Math.floor(seconds / 86400);
|
|
722
|
+
let rem = seconds - days * 86400;
|
|
723
|
+
const z = days + 719468;
|
|
724
|
+
const era = Math.floor((z >= 0 ? z : z - 146096) / 146097);
|
|
725
|
+
const doe = z - era * 146097;
|
|
726
|
+
const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
|
|
727
|
+
let y = yoe + era * 400;
|
|
728
|
+
const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
|
|
729
|
+
const mp = Math.floor((5 * doy + 2) / 153);
|
|
730
|
+
const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
|
|
731
|
+
const m = mp + (mp < 10 ? 3 : -9);
|
|
732
|
+
y += m <= 2 ? 1 : 0;
|
|
733
|
+
const h = Math.floor(rem / 3600);
|
|
734
|
+
rem -= h * 3600;
|
|
735
|
+
const mi = Math.floor(rem / 60);
|
|
736
|
+
const s = rem - mi * 60;
|
|
737
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
738
|
+
return `${p(y, 4)}-${p(m)}-${p(d)}T${p(h)}:${p(mi)}:${p(s)}Z`;
|
|
739
|
+
}
|
|
740
|
+
// ---- The policy -----------------------------------------------------------------
|
|
741
|
+
function providerPolicy(policy, provider) {
|
|
742
|
+
if (policy === null || policy === undefined)
|
|
743
|
+
return ["none", null];
|
|
744
|
+
if (!isPlainObject(policy) ||
|
|
745
|
+
policy.policy_version !== POLICY_VERSION ||
|
|
746
|
+
!isPlainObject(policy.providers)) {
|
|
747
|
+
return ["unreadable", null];
|
|
748
|
+
}
|
|
749
|
+
// No entry is no_policy; an entry that is there and not an object, null
|
|
750
|
+
// included, is one this reader cannot read.
|
|
751
|
+
if (!has(policy.providers, provider))
|
|
752
|
+
return ["none", null];
|
|
753
|
+
const entry = policy.providers[provider];
|
|
754
|
+
if (!isPlainObject(entry))
|
|
755
|
+
return ["unreadable", null];
|
|
756
|
+
const tol = wholeNumber(entry.signed_at_tolerance_seconds);
|
|
757
|
+
if (tol === null || tol < 0)
|
|
758
|
+
return ["unreadable", null];
|
|
759
|
+
return ["ok", entry];
|
|
760
|
+
}
|
|
761
|
+
// ---- The enclave line ------------------------------------------------------------
|
|
762
|
+
function base64UrlStrict32(s) {
|
|
763
|
+
if (!B64URL_32.test(s))
|
|
764
|
+
return null;
|
|
765
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=";
|
|
766
|
+
const bin = atob(b64);
|
|
767
|
+
const out = new Uint8Array(bin.length);
|
|
768
|
+
for (let i = 0; i < bin.length; i++)
|
|
769
|
+
out[i] = bin.charCodeAt(i);
|
|
770
|
+
let back = btoa(String.fromCharCode(...out));
|
|
771
|
+
back = back.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
772
|
+
return back === s && out.length === 32 ? out : null;
|
|
773
|
+
}
|
|
774
|
+
async function manifestCaseHash(content) {
|
|
775
|
+
let m;
|
|
776
|
+
try {
|
|
777
|
+
m = jcsParseWithinLimits(content);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
if (!isPlainObject(m) || m.manifest_version !== MANIFEST_VERSION)
|
|
783
|
+
return null;
|
|
784
|
+
const caseId = m.case_id;
|
|
785
|
+
const salt = m.case_salt;
|
|
786
|
+
if (typeof caseId !== "string" || caseId === "" || typeof salt !== "string")
|
|
787
|
+
return null;
|
|
788
|
+
const saltBytes = base64UrlStrict32(salt);
|
|
789
|
+
if (saltBytes === null)
|
|
790
|
+
return null;
|
|
791
|
+
const id = utf8(caseId);
|
|
792
|
+
const joined = new Uint8Array(saltBytes.length + id.length);
|
|
793
|
+
joined.set(saltBytes, 0);
|
|
794
|
+
joined.set(id, saltBytes.length);
|
|
795
|
+
return sha256Hex(joined);
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* The time the token is evaluated at (7): the interval end of a stamp that
|
|
799
|
+
* holds the opening record and whose witness the reader's lookup confirmed;
|
|
800
|
+
* otherwise null, the token's own issue time.
|
|
801
|
+
*/
|
|
802
|
+
/**
|
|
803
|
+
* @internal For the kernel's own tests: the enclave line of one opening record,
|
|
804
|
+
* verified as the caller says, under a provider table the caller names. It is
|
|
805
|
+
* how a test reaches a provider outside `ADAPTER_MODULES`; `readRuns` always
|
|
806
|
+
* reads under `ADAPTER_MODULES`.
|
|
807
|
+
*/
|
|
808
|
+
export async function enclaveLineForTest(record, publicKey, content, policy, adapters, table) {
|
|
809
|
+
const opening = {
|
|
810
|
+
packRef: "p",
|
|
811
|
+
packIndex: 0,
|
|
812
|
+
eventId: "e",
|
|
813
|
+
publicKey,
|
|
814
|
+
payloadHash: null,
|
|
815
|
+
signedAt: "2026-10-13T00:00:00Z",
|
|
816
|
+
verified: true,
|
|
817
|
+
bytesSupplied: true,
|
|
818
|
+
raw: utf8(jcs(record)),
|
|
819
|
+
record,
|
|
820
|
+
content,
|
|
821
|
+
stampEnd: null,
|
|
822
|
+
stampWitnessed: false,
|
|
823
|
+
};
|
|
824
|
+
const [l] = await enclaveLine(opening, [opening], policy, adapters, table);
|
|
825
|
+
return l;
|
|
826
|
+
}
|
|
827
|
+
function evaluationTime(opening) {
|
|
828
|
+
return opening.stampWitnessed ? opening.stampEnd : null;
|
|
829
|
+
}
|
|
830
|
+
function tryRead(opening) {
|
|
831
|
+
if (opening.record === null)
|
|
832
|
+
return null;
|
|
833
|
+
const metadata = opening.record.metadata;
|
|
834
|
+
if (!isPlainObject(metadata) || !has(metadata, "enclave_attestation"))
|
|
835
|
+
return null;
|
|
836
|
+
try {
|
|
837
|
+
oneSpelling(opening.raw ?? new Uint8Array(), "the opening record");
|
|
838
|
+
return readAttestation(metadata.enclave_attestation);
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
if (err instanceof AttestationRefused)
|
|
842
|
+
return null;
|
|
843
|
+
throw err;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* The failure kinds an adapter declares as its `kinds`: an array of snake_case
|
|
848
|
+
* strings, at least one, none of them a kind or note the kernel names
|
|
849
|
+
* (`NEUTRAL_KINDS`). `null` where the declaration is not of that form, an
|
|
850
|
+
* empty one included, and then the adapter is not loaded.
|
|
851
|
+
*/
|
|
852
|
+
export function declaredKinds(adapter) {
|
|
853
|
+
const kinds = isPlainObjectLike(adapter) ? adapter.kinds : undefined;
|
|
854
|
+
if (!Array.isArray(kinds) || kinds.length === 0)
|
|
855
|
+
return null;
|
|
856
|
+
for (const k of kinds) {
|
|
857
|
+
if (typeof k !== "string" || !KIND_FORM.test(k) || NEUTRAL_KINDS.has(k))
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
return new Set(kinds);
|
|
861
|
+
}
|
|
862
|
+
function isPlainObjectLike(v) {
|
|
863
|
+
return (typeof v === "object" || typeof v === "function") && v !== null;
|
|
864
|
+
}
|
|
865
|
+
async function enclaveLine(opening, held, policy, adapters, table = ADAPTER_MODULES) {
|
|
866
|
+
if (adapters.size === 0)
|
|
867
|
+
return [line("not_checked", ["no_adapter"]), tryRead(opening)];
|
|
868
|
+
const record = opening.record;
|
|
869
|
+
const metadata = record.metadata;
|
|
870
|
+
if (!isPlainObject(metadata) || !has(metadata, "enclave_attestation")) {
|
|
871
|
+
return [line("not_checked", ["no_attestation"]), null];
|
|
872
|
+
}
|
|
873
|
+
let att;
|
|
874
|
+
try {
|
|
875
|
+
oneSpelling(opening.raw ?? new Uint8Array(), "the opening record");
|
|
876
|
+
att = readAttestation(metadata.enclave_attestation);
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
if (err instanceof UnknownAttestationVersion) {
|
|
880
|
+
return [line("not_checked", ["unknown_version"], { reason: err.message }), null];
|
|
881
|
+
}
|
|
882
|
+
if (err instanceof AttestationRefused) {
|
|
883
|
+
return [line("differs", ["attestation_malformed"], { reason: err.message }), null];
|
|
884
|
+
}
|
|
885
|
+
throw err;
|
|
886
|
+
}
|
|
887
|
+
const provider = att.provider;
|
|
888
|
+
const adapter = has(table, provider) ? adapters.get(provider) : undefined;
|
|
889
|
+
if (adapter === undefined)
|
|
890
|
+
return [line("not_checked", ["no_adapter"], { provider }), att];
|
|
891
|
+
const [status, entry] = providerPolicy(policy, provider);
|
|
892
|
+
if (status !== "ok") {
|
|
893
|
+
return [line("not_checked", [status === "none" ? "no_policy" : "policy_unreadable"], { provider }), att];
|
|
894
|
+
}
|
|
895
|
+
const at = evaluationTime(opening);
|
|
896
|
+
let answer;
|
|
897
|
+
try {
|
|
898
|
+
const declared = declaredKinds(adapter);
|
|
899
|
+
if (declared === null)
|
|
900
|
+
throw new TypeError("the adapter declares no kinds");
|
|
901
|
+
answer = await adapter.check(att, { policy: entry, at });
|
|
902
|
+
const answerKinds = answer?.kinds ?? [];
|
|
903
|
+
const answerBinding = answer?.binding ?? null;
|
|
904
|
+
const answerIssuedAt = answer?.issuedAt ?? null;
|
|
905
|
+
if (!isPlainObject(answer) ||
|
|
906
|
+
!ENCLAVE_STATES.includes(answer.state) ||
|
|
907
|
+
!Array.isArray(answerKinds) ||
|
|
908
|
+
answerKinds.some((k) => typeof k !== "string" || !declared.has(k)) ||
|
|
909
|
+
!(answerBinding === null || isBinding(answerBinding)) ||
|
|
910
|
+
!(answerIssuedAt === null || Number.isSafeInteger(answerIssuedAt))) {
|
|
911
|
+
throw new TypeError("not an AdapterResult");
|
|
912
|
+
}
|
|
913
|
+
// A differs answer names the checks that failed, and only a differs
|
|
914
|
+
// answer names any.
|
|
915
|
+
if ((answer.state === "differs") !== (answerKinds.length > 0)) {
|
|
916
|
+
throw new TypeError("kinds that do not fit the state");
|
|
917
|
+
}
|
|
918
|
+
// A not_checked line carries one kind (section 6), and a not_checked
|
|
919
|
+
// answer names none, so it has no kind for the line to carry.
|
|
920
|
+
if (answer.state === "not_checked") {
|
|
921
|
+
throw new TypeError("not_checked, with no kind for the line");
|
|
922
|
+
}
|
|
923
|
+
// Confirmed means the adapter verified a binding: without one, checks 7
|
|
924
|
+
// and 11 would have nothing to run on.
|
|
925
|
+
if (answer.state === "confirmed" && answerBinding === null) {
|
|
926
|
+
throw new TypeError("confirmed without the binding it verified");
|
|
927
|
+
}
|
|
928
|
+
// Where the kernel read the binding itself (a registered provider), the
|
|
929
|
+
// adapter verified that binding and no other.
|
|
930
|
+
if (answerBinding !== null && att.binding !== null && bindingText(answerBinding) !== bindingText(att.binding)) {
|
|
931
|
+
throw new TypeError("a binding other than the one the evidence carries");
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
return [line("not_checked", ["adapter_failed"], { provider }), att];
|
|
936
|
+
}
|
|
937
|
+
const kinds = [];
|
|
938
|
+
const notes = [];
|
|
939
|
+
// Check 1.
|
|
940
|
+
if (att.runnerPublicKey !== opening.publicKey)
|
|
941
|
+
kinds.push("subject_key_not_signer");
|
|
942
|
+
const event = record.event;
|
|
943
|
+
const contentHash = isPlainObject(event) ? event.content_hash : undefined;
|
|
944
|
+
if (contentHash !== att.manifestHash)
|
|
945
|
+
kinds.push("manifest_not_content");
|
|
946
|
+
// Check 7: on the binding the adapter verified, or, where the adapter did
|
|
947
|
+
// not confirm, on the one the kernel read from the evidence.
|
|
948
|
+
const b = answer.binding ?? att.binding;
|
|
949
|
+
if (b !== null) {
|
|
950
|
+
if (b.runId !== att.runId)
|
|
951
|
+
kinds.push("binding_other_run");
|
|
952
|
+
if (b.manifestHash !== att.manifestHash)
|
|
953
|
+
kinds.push("binding_other_manifest");
|
|
954
|
+
if (b.runnerPublicKey !== att.runnerPublicKey || b.agentPublicKey !== att.agentPublicKey) {
|
|
955
|
+
kinds.push("binding_other_keys");
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
kinds.push(...(answer.kinds ?? []));
|
|
959
|
+
// Check 9.
|
|
960
|
+
const issuedAt = answer.issuedAt ?? null;
|
|
961
|
+
if (issuedAt !== null) {
|
|
962
|
+
const tolerance = wholeNumber(entry.signed_at_tolerance_seconds) ?? 0;
|
|
963
|
+
if (!within(opening.signedAt, issuedAt, tolerance))
|
|
964
|
+
kinds.push("signed_at_outside_tolerance");
|
|
965
|
+
}
|
|
966
|
+
else if (answer.state === "confirmed") {
|
|
967
|
+
// A provider whose evidence has no issue time: check 9 did not run.
|
|
968
|
+
notes.push("no_issue_time");
|
|
969
|
+
}
|
|
970
|
+
// Check 11.
|
|
971
|
+
if (opening.content === null) {
|
|
972
|
+
notes.push("manifest_withheld");
|
|
973
|
+
}
|
|
974
|
+
else if ((await sha256Hex(opening.content)) !== att.manifestHash) {
|
|
975
|
+
kinds.push("manifest_content_mismatch");
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
const caseHash = await manifestCaseHash(opening.content);
|
|
979
|
+
if (caseHash === null)
|
|
980
|
+
kinds.push("manifest_unreadable");
|
|
981
|
+
else if (b !== null && caseHash !== b.caseHash)
|
|
982
|
+
kinds.push("case_hash_differs");
|
|
983
|
+
}
|
|
984
|
+
// Check 10.
|
|
985
|
+
for (const h of held) {
|
|
986
|
+
if (h === opening || !h.verified || h.record === null)
|
|
987
|
+
continue;
|
|
988
|
+
const runnerType = (eventType(h) ?? "").startsWith(RUNNER_PREFIX);
|
|
989
|
+
if (h.publicKey === att.runnerPublicKey && !runnerType) {
|
|
990
|
+
if (!kinds.includes("agent_record_under_runner_key"))
|
|
991
|
+
kinds.push("agent_record_under_runner_key");
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (opening.stampEnd !== null && !opening.stampWitnessed)
|
|
995
|
+
notes.push("stamp_unwitnessed");
|
|
996
|
+
let evaluation = null;
|
|
997
|
+
let evaluatedAt = null;
|
|
998
|
+
const tokenChecked = answer.tokenChecked === true;
|
|
999
|
+
if (tokenChecked) {
|
|
1000
|
+
if (at !== null) {
|
|
1001
|
+
evaluation = "stamp";
|
|
1002
|
+
evaluatedAt = at;
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
evaluation = "token_issue_time";
|
|
1006
|
+
if (issuedAt !== null)
|
|
1007
|
+
evaluatedAt = rfc3339(issuedAt);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return [
|
|
1011
|
+
line(kinds.length > 0 ? "differs" : answer.state, kinds, {
|
|
1012
|
+
notes,
|
|
1013
|
+
provider,
|
|
1014
|
+
evaluatedAt,
|
|
1015
|
+
evaluation,
|
|
1016
|
+
tokenChecked,
|
|
1017
|
+
}),
|
|
1018
|
+
att,
|
|
1019
|
+
];
|
|
1020
|
+
}
|
|
1021
|
+
// ---- The run account -----------------------------------------------------------------
|
|
1022
|
+
const CLOSING_KEYS = new Set([
|
|
1023
|
+
"outcome",
|
|
1024
|
+
"payload_hashes",
|
|
1025
|
+
"record_count",
|
|
1026
|
+
"record_ids",
|
|
1027
|
+
"unacknowledged_payload_hashes",
|
|
1028
|
+
"unacknowledged_record_ids",
|
|
1029
|
+
]);
|
|
1030
|
+
class ClosingRefused extends Error {
|
|
1031
|
+
}
|
|
1032
|
+
function idList(value, path) {
|
|
1033
|
+
if (!Array.isArray(value))
|
|
1034
|
+
throw new ClosingRefused(`${path} must be a list`);
|
|
1035
|
+
for (const v of value) {
|
|
1036
|
+
if (typeof v !== "string" || v === "")
|
|
1037
|
+
throw new ClosingRefused(`${path} must hold non-empty strings`);
|
|
1038
|
+
}
|
|
1039
|
+
if (new Set(value).size !== value.length)
|
|
1040
|
+
throw new ClosingRefused(`${path} names one record twice`);
|
|
1041
|
+
return value;
|
|
1042
|
+
}
|
|
1043
|
+
function hashList(value, path, n) {
|
|
1044
|
+
if (!Array.isArray(value))
|
|
1045
|
+
throw new ClosingRefused(`${path} must be a list`);
|
|
1046
|
+
for (const v of value) {
|
|
1047
|
+
if (typeof v !== "string" || !LOWER_HEX64.test(v)) {
|
|
1048
|
+
throw new ClosingRefused(`${path} must hold 64 lowercase hex characters each`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (value.length !== n)
|
|
1052
|
+
throw new ClosingRefused(`${path} is not one hash per id`);
|
|
1053
|
+
return value;
|
|
1054
|
+
}
|
|
1055
|
+
function readClosing(record) {
|
|
1056
|
+
const path = "metadata.closing";
|
|
1057
|
+
const metadata = record.metadata;
|
|
1058
|
+
const closing = isPlainObject(metadata) ? metadata.closing : undefined;
|
|
1059
|
+
if (!isPlainObject(closing))
|
|
1060
|
+
throw new ClosingRefused(`${path} must be an object`);
|
|
1061
|
+
const unknown = Object.keys(closing)
|
|
1062
|
+
.filter((k) => !CLOSING_KEYS.has(k))
|
|
1063
|
+
.sort();
|
|
1064
|
+
if (unknown.length > 0)
|
|
1065
|
+
throw new ClosingRefused(`${path}: unknown member ${renderKey(unknown[0])}`);
|
|
1066
|
+
for (const k of ["record_count", "record_ids", "outcome"]) {
|
|
1067
|
+
if (!has(closing, k))
|
|
1068
|
+
throw new ClosingRefused(`${path}.${k} is required`);
|
|
1069
|
+
}
|
|
1070
|
+
const ids = idList(closing.record_ids, `${path}.record_ids`);
|
|
1071
|
+
const count = wholeNumber(closing.record_count);
|
|
1072
|
+
if (count === null || count !== ids.length) {
|
|
1073
|
+
throw new ClosingRefused(`${path}.record_count is not the number of record_ids`);
|
|
1074
|
+
}
|
|
1075
|
+
if (!isPlainObject(closing.outcome))
|
|
1076
|
+
throw new ClosingRefused(`${path}.outcome must be an object`);
|
|
1077
|
+
const hashes = has(closing, "payload_hashes")
|
|
1078
|
+
? hashList(closing.payload_hashes, `${path}.payload_hashes`, ids.length)
|
|
1079
|
+
: null;
|
|
1080
|
+
const hasUids = has(closing, "unacknowledged_record_ids");
|
|
1081
|
+
const hasUhashes = has(closing, "unacknowledged_payload_hashes");
|
|
1082
|
+
if (hasUids !== hasUhashes) {
|
|
1083
|
+
throw new ClosingRefused(`${path}: unacknowledged_record_ids and unacknowledged_payload_hashes travel together`);
|
|
1084
|
+
}
|
|
1085
|
+
let uids = [];
|
|
1086
|
+
let uhashes = [];
|
|
1087
|
+
if (hasUids) {
|
|
1088
|
+
uids = idList(closing.unacknowledged_record_ids, `${path}.unacknowledged_record_ids`);
|
|
1089
|
+
uhashes = hashList(closing.unacknowledged_payload_hashes, `${path}.unacknowledged_payload_hashes`, uids.length);
|
|
1090
|
+
}
|
|
1091
|
+
const idSet = new Set(ids);
|
|
1092
|
+
if (uids.some((u) => idSet.has(u))) {
|
|
1093
|
+
throw new ClosingRefused(`${path}: a record is both acknowledged and not`);
|
|
1094
|
+
}
|
|
1095
|
+
return [ids, hashes, uids, uhashes];
|
|
1096
|
+
}
|
|
1097
|
+
function entry(kind, eventId = null) {
|
|
1098
|
+
return { kind, eventId };
|
|
1099
|
+
}
|
|
1100
|
+
function runAccount(runnerKey, agentKey, held, ignored) {
|
|
1101
|
+
const notes = [];
|
|
1102
|
+
if (agentKey === null)
|
|
1103
|
+
notes.push(entry("agent_key_unknown"));
|
|
1104
|
+
const keys = new Set([runnerKey]);
|
|
1105
|
+
if (agentKey !== null)
|
|
1106
|
+
keys.add(agentKey);
|
|
1107
|
+
for (const h of ignored) {
|
|
1108
|
+
if (agentKey !== null && h.publicKey === agentKey) {
|
|
1109
|
+
notes.push(entry("runner_record_under_agent_key", h.eventId));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const members = held.filter((h) => h.verified && keys.has(h.publicKey) && !ignored.has(h));
|
|
1113
|
+
for (const h of members) {
|
|
1114
|
+
if (h.publicKey === runnerKey && !h.bytesSupplied) {
|
|
1115
|
+
notes.push(entry("runner_record_bytes_withheld", h.eventId));
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
const closings = members.filter((h) => h.publicKey === runnerKey && eventType(h) === RUN_CLOSED);
|
|
1119
|
+
const base = { findings: [], closingEventId: null, reason: null };
|
|
1120
|
+
if (closings.length === 0)
|
|
1121
|
+
return { ...base, state: "not_closed", notes };
|
|
1122
|
+
if (closings.length > 1) {
|
|
1123
|
+
return {
|
|
1124
|
+
...base,
|
|
1125
|
+
state: "differs",
|
|
1126
|
+
findings: closings.map((h) => entry("more_than_one_closing_record", h.eventId)),
|
|
1127
|
+
notes,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
const closing = closings[0];
|
|
1131
|
+
let ids, hashes, uids, uhashes;
|
|
1132
|
+
try {
|
|
1133
|
+
[ids, hashes, uids, uhashes] = readClosing(closing.record ?? {});
|
|
1134
|
+
}
|
|
1135
|
+
catch (err) {
|
|
1136
|
+
if (!(err instanceof ClosingRefused))
|
|
1137
|
+
throw err;
|
|
1138
|
+
return {
|
|
1139
|
+
state: "differs",
|
|
1140
|
+
findings: [entry("closing_malformed", closing.eventId)],
|
|
1141
|
+
notes,
|
|
1142
|
+
closingEventId: closing.eventId,
|
|
1143
|
+
reason: err.message,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
if (hashes === null)
|
|
1147
|
+
notes.push(entry("payload_hashes_absent", closing.eventId));
|
|
1148
|
+
const listed = new Map(ids.map((id, i) => [id, hashes ? hashes[i] : null]));
|
|
1149
|
+
const unacked = new Map(uids.map((id, i) => [id, uhashes[i]]));
|
|
1150
|
+
const findings = [];
|
|
1151
|
+
const others = members.filter((h) => h !== closing);
|
|
1152
|
+
for (const h of others) {
|
|
1153
|
+
if (listed.has(h.eventId)) {
|
|
1154
|
+
const want = listed.get(h.eventId);
|
|
1155
|
+
if (want === null || want === h.payloadHash)
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
else if (unacked.has(h.eventId) && unacked.get(h.eventId) === h.payloadHash) {
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
findings.push(entry("record_not_listed", h.eventId));
|
|
1162
|
+
}
|
|
1163
|
+
// Presence is presence: a listed record the account ignores (a runner.*
|
|
1164
|
+
// record under the agent key) is still in the drop.
|
|
1165
|
+
const pool = held.filter((h) => h.verified && h !== closing && (agentKey === null || keys.has(h.publicKey)));
|
|
1166
|
+
for (const [eventId, want] of listed) {
|
|
1167
|
+
if (!pool.some((h) => h.eventId === eventId && (want === null || h.payloadHash === want))) {
|
|
1168
|
+
findings.push(entry("listed_record_absent", eventId));
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
for (const [eventId, want] of unacked) {
|
|
1172
|
+
if (!pool.some((h) => h.eventId === eventId && h.payloadHash === want)) {
|
|
1173
|
+
notes.push(entry("unacknowledged_record_absent", eventId));
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return {
|
|
1177
|
+
state: findings.length > 0 ? "differs" : "closed",
|
|
1178
|
+
findings,
|
|
1179
|
+
notes,
|
|
1180
|
+
closingEventId: closing.eventId,
|
|
1181
|
+
reason: null,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
// ---- Putting it together -----------------------------------------------------------------
|
|
1185
|
+
/**
|
|
1186
|
+
* Read every run in a drop: one reading per opening record, and one per closing
|
|
1187
|
+
* record whose runner key opened nothing readable. `adapters` maps a provider
|
|
1188
|
+
* id to a loaded adapter; only ids in `ADAPTER_MODULES` are ever consulted, and
|
|
1189
|
+
* an empty map is a reader with no adapter. Never throws on a pack.
|
|
1190
|
+
*/
|
|
1191
|
+
async function onlineLine(opening, att, policy, adapters, rpc) {
|
|
1192
|
+
if (rpc === null || att === null || !has(ADAPTER_MODULES, att.provider))
|
|
1193
|
+
return null;
|
|
1194
|
+
const adapter = adapters.get(att.provider);
|
|
1195
|
+
if (adapter === undefined || typeof adapter.online !== "function")
|
|
1196
|
+
return null;
|
|
1197
|
+
const [status, entry] = providerPolicy(policy, att.provider);
|
|
1198
|
+
if (status !== "ok")
|
|
1199
|
+
return null;
|
|
1200
|
+
const content = opening.content;
|
|
1201
|
+
const manifest = content !== null && (await sha256Hex(content)) === att.manifestHash ? content : null;
|
|
1202
|
+
try {
|
|
1203
|
+
const answer = await adapter.online(att, { policy: entry, at: evaluationTime(opening), manifest, rpc });
|
|
1204
|
+
if (!isPlainObject(answer) || !Array.isArray(answer.kinds))
|
|
1205
|
+
throw new TypeError("not an OnlineLine");
|
|
1206
|
+
const kinds = answer.kinds;
|
|
1207
|
+
const allowed = answer.state === "confirmed"
|
|
1208
|
+
? []
|
|
1209
|
+
: answer.state === "not_checked"
|
|
1210
|
+
? ONLINE_NOT_CHECKED_KINDS
|
|
1211
|
+
: answer.state === "differs"
|
|
1212
|
+
? ONLINE_DIFFERS_KINDS
|
|
1213
|
+
: undefined;
|
|
1214
|
+
if (allowed === undefined ||
|
|
1215
|
+
kinds.length !== (answer.state === "confirmed" ? 0 : 1) ||
|
|
1216
|
+
kinds.some((k) => typeof k !== "string" || !allowed.includes(k))) {
|
|
1217
|
+
throw new TypeError("not an OnlineLine");
|
|
1218
|
+
}
|
|
1219
|
+
return { state: answer.state, kinds: kinds };
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
return { state: "not_checked", kinds: ["adapter_failed"] };
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* What makes a pack the same pack: SHA-256 of the JCS of its wrapper (the bytes
|
|
1227
|
+
* the wrapper's signature covers, and the signature) together with its side
|
|
1228
|
+
* content, each entry as the bytes the kernel binds to `content_hash`. Never
|
|
1229
|
+
* the reader's name for it. `null` where either part cannot be put in that
|
|
1230
|
+
* form: a pack with no identity is never the same as another.
|
|
1231
|
+
*/
|
|
1232
|
+
async function packIdentity(pack) {
|
|
1233
|
+
try {
|
|
1234
|
+
const side = [];
|
|
1235
|
+
for (const id of sideContentKeys(pack.content).sort()) {
|
|
1236
|
+
const value = sideContentEntry(pack.content, id).value;
|
|
1237
|
+
if (value instanceof Uint8Array) {
|
|
1238
|
+
side.push([id, "b:" + bytesToHex(value)]);
|
|
1239
|
+
}
|
|
1240
|
+
else if (typeof value === "string") {
|
|
1241
|
+
try {
|
|
1242
|
+
side.push([id, "b:" + bytesToHex(hexToBytes(value))]);
|
|
1243
|
+
}
|
|
1244
|
+
catch {
|
|
1245
|
+
side.push([id, "s:" + value]);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return await sha256Hex(utf8(jcs({ bundle: pack.bundle, content: side })));
|
|
1253
|
+
}
|
|
1254
|
+
catch {
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
/** A verified `runner.*` record under one of `keys`. */
|
|
1259
|
+
function runnerRecordUnder(h, keys) {
|
|
1260
|
+
return h.verified && keys.has(h.publicKey) && (eventType(h) ?? "").startsWith(RUNNER_PREFIX);
|
|
1261
|
+
}
|
|
1262
|
+
export async function readRuns(packs, options = {}) {
|
|
1263
|
+
const adapters = options.adapters ?? new Map();
|
|
1264
|
+
const held = [];
|
|
1265
|
+
// A pack handed in again with the same bytes is read once, at its first
|
|
1266
|
+
// position, and the lookups made for every copy count for it
|
|
1267
|
+
// (specs/run-records.md section 3).
|
|
1268
|
+
const lookups = [];
|
|
1269
|
+
const first = new Map();
|
|
1270
|
+
const keep = [];
|
|
1271
|
+
for (let i = 0; i < packs.length; i++) {
|
|
1272
|
+
const confirmed = new Set();
|
|
1273
|
+
for (const r of options.witnessed?.[i] ?? [])
|
|
1274
|
+
if (typeof r === "string")
|
|
1275
|
+
confirmed.add(r);
|
|
1276
|
+
const identity = await packIdentity(packs[i]);
|
|
1277
|
+
if (identity !== null && first.has(identity)) {
|
|
1278
|
+
for (const r of confirmed)
|
|
1279
|
+
lookups[first.get(identity)].add(r);
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
if (identity !== null)
|
|
1283
|
+
first.set(identity, keep.length);
|
|
1284
|
+
keep.push(i);
|
|
1285
|
+
lookups.push(confirmed);
|
|
1286
|
+
}
|
|
1287
|
+
for (let k = 0; k < keep.length; k++) {
|
|
1288
|
+
held.push(...(await heldRecords(packs[keep[k]], keep[k], lookups[k])));
|
|
1289
|
+
}
|
|
1290
|
+
const openings = held.filter((h) => eventType(h) === RUN_OPENED && h.verified);
|
|
1291
|
+
const read = new Map(openings.map((h) => [h, tryRead(h)]));
|
|
1292
|
+
// A key's runner role is proven by an attested opening: one whose
|
|
1293
|
+
// attestation reads and names, as the runner key, the key that signed it.
|
|
1294
|
+
// That opening's word on its agent key is taken; any other record's claim
|
|
1295
|
+
// that a proven runner key is an agent key is only that record's word
|
|
1296
|
+
// (proof#238). The agent keys are decided before any opening is admitted,
|
|
1297
|
+
// and a key that is an agent key and no proven runner key opens no run:
|
|
1298
|
+
// its runner.run_opened record is a runner.* record under an agent key.
|
|
1299
|
+
const attested = openings.filter((h) => {
|
|
1300
|
+
const a = read.get(h);
|
|
1301
|
+
return a !== null && a !== undefined && a.runnerPublicKey === h.publicKey;
|
|
1302
|
+
});
|
|
1303
|
+
const proven = new Set(attested.map((h) => h.publicKey));
|
|
1304
|
+
const agentKeys = new Set();
|
|
1305
|
+
for (const h of attested) {
|
|
1306
|
+
const k = read.get(h).agentPublicKey;
|
|
1307
|
+
if (!proven.has(k))
|
|
1308
|
+
agentKeys.add(k);
|
|
1309
|
+
}
|
|
1310
|
+
// A runner.* record under a run's agent key is not the runner's.
|
|
1311
|
+
const ignored = new Set(held.filter((h) => runnerRecordUnder(h, agentKeys)));
|
|
1312
|
+
const runs = [];
|
|
1313
|
+
const runnerKeys = new Set();
|
|
1314
|
+
for (const opening of openings) {
|
|
1315
|
+
if (ignored.has(opening))
|
|
1316
|
+
continue;
|
|
1317
|
+
const [enclave, att] = await enclaveLine(opening, held, options.policy, adapters);
|
|
1318
|
+
const agentKey = att !== null ? att.agentPublicKey : null;
|
|
1319
|
+
runnerKeys.add(opening.publicKey);
|
|
1320
|
+
// Where the run's agent key is also a proven runner key, this run still
|
|
1321
|
+
// ignores, and notes, the runner.* records under it.
|
|
1322
|
+
const own = new Set(agentKey !== null && agentKey !== opening.publicKey ? [agentKey] : []);
|
|
1323
|
+
const runIgnored = new Set([...ignored, ...held.filter((h) => !ignored.has(h) && runnerRecordUnder(h, own))]);
|
|
1324
|
+
runs.push({
|
|
1325
|
+
packRef: opening.packRef,
|
|
1326
|
+
packIndex: opening.packIndex,
|
|
1327
|
+
eventId: opening.eventId,
|
|
1328
|
+
runnerPublicKey: opening.publicKey,
|
|
1329
|
+
agentPublicKey: agentKey,
|
|
1330
|
+
enclave,
|
|
1331
|
+
account: runAccount(opening.publicKey, agentKey, held, runIgnored),
|
|
1332
|
+
online: await onlineLine(opening, att, options.policy, adapters, options.rpc ?? null),
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
for (const h of held) {
|
|
1336
|
+
if (h.verified && eventType(h) === RUN_CLOSED && !ignored.has(h) && !runnerKeys.has(h.publicKey)) {
|
|
1337
|
+
runnerKeys.add(h.publicKey);
|
|
1338
|
+
runs.push({
|
|
1339
|
+
packRef: h.packRef,
|
|
1340
|
+
packIndex: h.packIndex,
|
|
1341
|
+
eventId: h.eventId,
|
|
1342
|
+
runnerPublicKey: h.publicKey,
|
|
1343
|
+
agentPublicKey: null,
|
|
1344
|
+
enclave: line("not_checked", ["no_opening_record"]),
|
|
1345
|
+
account: runAccount(h.publicKey, null, held, ignored),
|
|
1346
|
+
online: null,
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return { runs };
|
|
1351
|
+
}
|
|
1352
|
+
// ---- What a command prints ------------------------------------------------------
|
|
1353
|
+
//
|
|
1354
|
+
// DRAFT wording: the line's words wait for the glossary. The twin of the
|
|
1355
|
+
// renderers at the foot of `run.py`; the case commands print them beside the
|
|
1356
|
+
// witness line.
|
|
1357
|
+
function runRef(r) {
|
|
1358
|
+
const verb = r.enclave.kinds.includes("no_opening_record") ? "closed" : "opened";
|
|
1359
|
+
return `the run ${verb} by record ${r.eventId} in ${r.packRef}`;
|
|
1360
|
+
}
|
|
1361
|
+
/** The enclave line, the revocation sentence where a token was checked, and the online line. */
|
|
1362
|
+
export function renderEnclaveLines(r) {
|
|
1363
|
+
const e = r.enclave;
|
|
1364
|
+
let s = `enclave attestation: ${e.state}`;
|
|
1365
|
+
if (e.kinds.length > 0)
|
|
1366
|
+
s += ` (${e.kinds.join(", ")})`;
|
|
1367
|
+
s += `, ${runRef(r)}`;
|
|
1368
|
+
if (e.provider !== null)
|
|
1369
|
+
s += `, provider ${e.provider}`;
|
|
1370
|
+
if (e.evaluation === "stamp") {
|
|
1371
|
+
s += `; evaluated at ${e.evaluatedAt}, the interval end of a stamp that holds the opening record, its witness confirmed on the public record`;
|
|
1372
|
+
}
|
|
1373
|
+
else if (e.evaluation === "token_issue_time") {
|
|
1374
|
+
const at = e.evaluatedAt !== null ? ` (${e.evaluatedAt})` : "";
|
|
1375
|
+
s += `; evaluated at the token's own issue time${at}: no stamp whose witness was confirmed says when the opening record was witnessed`;
|
|
1376
|
+
}
|
|
1377
|
+
if (e.notes.length > 0)
|
|
1378
|
+
s += `; note: ${e.notes.join(", ")}`;
|
|
1379
|
+
if (e.reason !== null)
|
|
1380
|
+
s += `; ${e.reason}`;
|
|
1381
|
+
const lines = [s];
|
|
1382
|
+
if (e.tokenChecked)
|
|
1383
|
+
lines.push(REVOCATION_SENTENCE);
|
|
1384
|
+
if (r.online !== null) {
|
|
1385
|
+
let o = `opening on Flare: ${r.online.state}`;
|
|
1386
|
+
if (r.online.kinds.length > 0)
|
|
1387
|
+
o += ` (${r.online.kinds.join(", ")})`;
|
|
1388
|
+
lines.push(o);
|
|
1389
|
+
}
|
|
1390
|
+
return lines;
|
|
1391
|
+
}
|
|
1392
|
+
const entries = (xs) => xs.map((x) => (x.eventId !== null ? `${x.kind} ${x.eventId}` : x.kind)).join(", ");
|
|
1393
|
+
/** The run-account line (specs/run-records.md). */
|
|
1394
|
+
export function renderAccountLine(r) {
|
|
1395
|
+
const a = r.account;
|
|
1396
|
+
let s = `run account: ${a.state}`;
|
|
1397
|
+
if (a.findings.length > 0)
|
|
1398
|
+
s += ` (${entries(a.findings)})`;
|
|
1399
|
+
s += `, ${runRef(r)}`;
|
|
1400
|
+
if (a.notes.length > 0)
|
|
1401
|
+
s += `; note: ${entries(a.notes)}`;
|
|
1402
|
+
if (a.reason !== null)
|
|
1403
|
+
s += `; ${a.reason}`;
|
|
1404
|
+
return s;
|
|
1405
|
+
}
|