@sanning/proof 0.17.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/dist/cli.js CHANGED
@@ -17,6 +17,8 @@
17
17
  // could not be reached reads `partial`; a pack whose records nobody hashed
18
18
  // reads `incomplete` (see below)
19
19
  // 4 mixed
20
+ // 5 sandbox: a record is signed environment dev; would be 0, or only a
21
+ // simulated witness is unchecked (`sandboxExit`)
20
22
  // `sanningd verify-status` in sanning-io/agent mirrored the 0.12 table, which
21
23
  // exited 0 on `partial`; it moves with this one.
22
24
  //
@@ -47,13 +49,15 @@ import { createHash, randomBytes } from "node:crypto";
47
49
  import { basename, resolve, sep } from "node:path";
48
50
  import { fileURLToPath } from "node:url";
49
51
  import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
50
- import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
52
+ import { CHAIN_READING_SCOPE, readChainLinks, readChains, renderChainFinding } from "./chain.js";
51
53
  import { VERDICT_DISPLAY_TITLES, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
52
54
  import { CASE_FILE_INDEX_NAME, caseFileListedPaths, jsonDepthExceeds, readCaseFileIndex, renderCaseFileFinding, unreadableCaseFileIndex, } from "./case-file.js";
53
55
  import { MAX_ZIP_ARCHIVE_BYTES, MAX_ZIP_BYTES, ZipReadError, readZip } from "./zip-reader.js";
54
56
  import { composeExport } from "./compose.js";
55
57
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
56
58
  import { verifyEvidenceBundle } from "./evidence.js";
59
+ import { readRuns, renderAccountLine, renderEnclaveLines } from "./run.js";
60
+ import { loadInstalledAdapters } from "./run-adapters.js";
57
61
  import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
58
62
  import { fileMatchToJson, matchCaseFiles, matchFiles, notAccountedFor, renderFileMatch, } from "./file-match.js";
59
63
  import { WITNESS_CACHE_NAME, readWitnessCache, } from "./witness-cache.js";
@@ -65,7 +69,8 @@ import { WITNESS_CACHE_NAME, readWitnessCache, } from "./witness-cache.js";
65
69
  import { onChainHalf, witnessLine } from "./witness-lookup.js";
66
70
  import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
67
71
  import { completenessNotes, gradePack, renderCause } from "./grading.js";
68
- import { EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
72
+ import { EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_SANDBOX, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, sandboxExit, } from "./exit-codes.js";
73
+ import { environmentMarker, onlySimulatedUnsettled, simulatedOnlyRun, SANDBOX_HEADLINE, SANDBOX_LINE, SANDBOX_TITLE, } from "./environment.js";
69
74
  // ── What a person reads is escaped at one choke point ──
70
75
  //
71
76
  // Every human-readable line this command prints passes through `guard`, which
@@ -326,7 +331,7 @@ async function runVerify(rest, io, sink) {
326
331
  gateways,
327
332
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
328
333
  });
329
- return reportAgentProof(result, bundlePath, gateways, io, sink);
334
+ return reportAgentProof(result, bundlePath, gateways, io, sink, parsed);
330
335
  }
331
336
  // Default: treat anything else as an evidence bundle. verifyEvidenceBundle
332
337
  // rejects an unknown major as "malformed" so a genuinely unknown shape still
@@ -338,8 +343,11 @@ async function runVerify(rest, io, sink) {
338
343
  });
339
344
  // Read the chains from the SAME parsed bundle the verifier saw. Findings are
340
345
  // evidence about the records in front of us, not a verdict: they are printed
341
- // and they never move `exit`, because a chain is not a completeness proof and
342
- // an exit code is exactly the kind of thing people read as one.
346
+ // and they move `exit` only through the pack's own declaration, where a v3
347
+ // declaration claims a chain complete and in order and a finding under that
348
+ // claim contradicts it (rule 11). Without a claim a chain is not a
349
+ // completeness proof, and an exit code is exactly the kind of thing people
350
+ // read as one.
343
351
  //
344
352
  // A READING THAT COULD NOT BE PRODUCED SAYS SO. This was an unguarded call:
345
353
  // a throw out of `readChains` would have escaped the command entirely and
@@ -355,7 +363,17 @@ async function runVerify(rest, io, sink) {
355
363
  catch (e) {
356
364
  chains = { unreadable: errMsg(e) };
357
365
  }
358
- const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
366
+ // The links rule 11 holds a claimed chain to (evidence-bundle.md 5.4.2), from
367
+ // the same parsed bundle. Links that cannot be read are none, which reads a
368
+ // claim UNKNOWN and never clean.
369
+ let links;
370
+ try {
371
+ links = "unreadable" in chains ? null : await readChainLinks(parsed);
372
+ }
373
+ catch {
374
+ links = null;
375
+ }
376
+ const exit = reportEvidence(result, bundlePath, gateways, io, chains, links, parsed, logs, sink);
359
377
  // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
360
378
  // beside the verdict, never inside it, so `exit` is untouched.
361
379
  if (offered !== undefined) {
@@ -1234,7 +1252,7 @@ function cliDisclosureAccount(result, logs) {
1234
1252
  extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
1235
1253
  };
1236
1254
  }
1237
- function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink) {
1255
+ function reportEvidence(result, path, gateways, io, chains, links, parsed, logs, sink) {
1238
1256
  if (result.status === "malformed") {
1239
1257
  if (sink)
1240
1258
  sink.data = verifyJson(result, null, null, null);
@@ -1324,10 +1342,24 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink)
1324
1342
  // their verdicts rather than a new check. A pack that DOES
1325
1343
  // carry a declaration this reader cannot read is a different case and exits
1326
1344
  // 3: the holder chose that field, so UNKNOWN is the honest answer.
1327
- const declaration = checkDeclaration(parsed, logs?.content);
1345
+ //
1346
+ // A v3 declaration that claims a chain is checked against this command's
1347
+ // own chain links (rule 11, evidence-bundle.md 5.4.2 v1.36, A45). Links
1348
+ // that could not be read are passed as none, which reads the claim UNKNOWN
1349
+ // and never clean.
1350
+ const declaration = checkDeclaration(parsed, logs?.content, links);
1328
1351
  const grade = gradePack(result, cliDisclosureAccount(result, logs), completenessNotes(parsed), declaration);
1352
+ // Sandbox evidence: read from the signed envelopes the pack holds.
1353
+ const marker = environmentMarker([parsed]);
1354
+ // A pack whose records are all signed `dev`, undetermined only because a
1355
+ // simulated witness cannot be looked up, would grade `verified` with that
1356
+ // witness accepted: it is sandbox evidence, exit 5, never 3.
1357
+ const simulatedOnly = grade.verdict === "partial" &&
1358
+ onlySimulatedUnsettled(result) &&
1359
+ gradePack({ ...result, status: "verified" }, cliDisclosureAccount(result, logs), completenessNotes(parsed), declaration).verdict === "verified";
1360
+ const exit = sandboxExit(mapGradeToExit(grade), marker, simulatedOnly);
1329
1361
  io.out("");
1330
- printRollup(grade.verdict, result.status, result.assertedStatus, gateways, result.onChainChecked, io);
1362
+ printRollup(grade.verdict, result.status, result.assertedStatus, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX, simulatedOnlyRun(result, gateways));
1331
1363
  io.out(paint(` declaration: ${renderDeclarationSummary(declaration)}`, declaration.verdict === "failed" ? R : declaration.verdict === "verified" ? DIM : Y));
1332
1364
  for (const n of declaration.notes)
1333
1365
  io.out(paint(` note: ${renderNote(n)}`, Y));
@@ -1346,15 +1378,15 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink)
1346
1378
  io.out(paint(` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`, DIM));
1347
1379
  }
1348
1380
  if (sink)
1349
- sink.data = verifyJson(result, grade, declaration, chains);
1350
- return mapGradeToExit(grade);
1381
+ sink.data = verifyJson(result, grade, declaration, chains, marker);
1382
+ return exit;
1351
1383
  }
1352
1384
  /**
1353
1385
  * What `verify --json` prints for an evidence bundle, the same keys and the
1354
1386
  * same words as `_verify_json` in the Python twin. `grade`, `declaration` and
1355
1387
  * `chains` are null for a bundle no verdict could be rendered over.
1356
1388
  */
1357
- function verifyJson(result, grade, declaration, chains) {
1389
+ function verifyJson(result, grade, declaration, chains, marker = null) {
1358
1390
  let chainBlock = null;
1359
1391
  if (chains !== null) {
1360
1392
  chainBlock =
@@ -1380,6 +1412,9 @@ function verifyJson(result, grade, declaration, chains) {
1380
1412
  ? { presented: result.events.length, content_verified: grade.recordsVerified }
1381
1413
  : null,
1382
1414
  chains: chainBlock,
1415
+ // Present only where the pack holds an envelope signed `dev`
1416
+ // (envelope-spec §2 MAY), so a production pack's JSON is unchanged.
1417
+ ...(marker !== null ? { environment_marker: marker } : {}),
1383
1418
  };
1384
1419
  }
1385
1420
  /**
@@ -1440,7 +1475,8 @@ function reportExport(exp, io) {
1440
1475
  }
1441
1476
  }
1442
1477
  }
1443
- function reportAgentProof(result, path, gateways, io, sink) {
1478
+ function reportAgentProof(result, path, gateways, io, sink, parsed) {
1479
+ const marker = result.status === "malformed" ? null : environmentMarker([parsed]);
1444
1480
  if (sink) {
1445
1481
  sink.data = {
1446
1482
  verdict: result.status,
@@ -1448,6 +1484,7 @@ function reportAgentProof(result, path, gateways, io, sink) {
1448
1484
  spec_version: "sanning.agent.proof/v1",
1449
1485
  on_chain_checked: result.onChainChecked,
1450
1486
  notes: [...result.errors],
1487
+ ...(marker !== null ? { environment_marker: marker } : {}),
1451
1488
  };
1452
1489
  }
1453
1490
  if (result.status === "malformed") {
@@ -1464,17 +1501,30 @@ function reportAgentProof(result, path, gateways, io, sink) {
1464
1501
  io.out("");
1465
1502
  // An agent inclusion proof carries no records and no disclosed bytes, so
1466
1503
  // there is no pack to grade: the bundle status IS the headline here.
1467
- printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
1504
+ const exit = sandboxExit(exitCodeFor(result.status), marker);
1505
+ printRollup(result.status, result.status, null, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX);
1468
1506
  for (const e of result.errors)
1469
1507
  io.out(paint(` note: ${e}`, DIM));
1470
- return exitCodeFor(result.status);
1508
+ return exit;
1471
1509
  }
1472
- function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked, io) {
1510
+ function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked, io, marker = null, sandbox = false, simulatedOnly = false) {
1511
+ // Three scopes, not two. A run with gateways whose only witnesses are
1512
+ // simulated looked nothing up (`simulatedOnlyRun`): saying "on-chain" there
1513
+ // claims a check that never happened, and saying "no gateway re-fetch
1514
+ // requested" denies the flag the reader passed. Every other run that is not
1515
+ // counted as checked on a chain, a run that stopped early among them, keeps
1516
+ // the second scope as before.
1473
1517
  const scope = onChainChecked
1474
1518
  ? `verified offline + on-chain against ${gateways.length} gateway(s)`
1475
- : "verified offline (no gateway re-fetch requested)";
1519
+ : simulatedOnly
1520
+ ? "verified offline; a simulated witness is not on a chain, so nothing was looked up"
1521
+ : "verified offline (no gateway re-fetch requested)";
1476
1522
  let head;
1477
- switch (headline) {
1523
+ switch (sandbox ? "sandbox" : headline) {
1524
+ // Sandbox evidence (exit 5) never prints VERIFIED.
1525
+ case "sandbox":
1526
+ head = paint(SANDBOX_HEADLINE, Y);
1527
+ break;
1478
1528
  case "verified":
1479
1529
  head = paint("VERIFIED", G);
1480
1530
  break;
@@ -1492,6 +1542,8 @@ function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked,
1492
1542
  head = paint(headline.toUpperCase(), R);
1493
1543
  }
1494
1544
  io.out(`${head} ${paint(scope, DIM)}`);
1545
+ if (marker !== null)
1546
+ io.out(paint(` ${SANDBOX_LINE}`, Y));
1495
1547
  // Both verdicts, whenever they differ, because they answer different
1496
1548
  // questions: §3 grades the signed bundle, the pack verdict grades the bundle
1497
1549
  // plus whatever bytes travelled beside it. A pack can be incomplete while the
@@ -2072,7 +2124,7 @@ async function unzip(arg, prefix, tree, seen) {
2072
2124
  // 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
2073
2125
  export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
2074
2126
 
2075
- sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
2127
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--enclave-policy <file>] [--enclave-rpc <url>] [--json]
2076
2128
 
2077
2129
  <folder> a pack folder (bundle.json plus logs/), a folder that holds
2078
2130
  several packs, or an unzipped case file. Packs are found by the
@@ -2110,6 +2162,17 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
2110
2162
  contribution, the packs, each with its index, the files outside
2111
2163
  them, the case file index comparison, the witness line and the
2112
2164
  on-chain half.
2165
+ --enclave-policy
2166
+ a sanning.enclave-policy/v1 file: what this reader accepts of
2167
+ an enclave attestation. Where the packs hold a runner's run, an
2168
+ enclave attestation line and a run-account line print beside
2169
+ the witness line (DRAFT); neither moves the verdict or the exit
2170
+ code. Without a policy, or without an adapter for the provider
2171
+ installed, the line reads not_checked.
2172
+ --enclave-rpc
2173
+ a chain JSON-RPC endpoint: a flare-fcc run also gets the online
2174
+ line, read from the contracts the policy names. It moves
2175
+ nothing either.
2113
2176
 
2114
2177
  Files outside every pack are matched too, always, so
2115
2178
  sanning-proof case audit-file.zip checks a whole download in one command: each
@@ -2184,12 +2247,21 @@ const titleOf = (word) => Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_T
2184
2247
  * scope sentence and the witness line are printed with it every time.
2185
2248
  */
2186
2249
  function caseReport(data) {
2187
- const lines = [
2188
- data.title,
2189
- ` ${data.summary}`,
2190
- ` ${data.witness.rendered}`,
2191
- ` ${data.reliance}`,
2192
- ];
2250
+ const lines = [data.title];
2251
+ // Sandbox evidence: printed with every headline the marker is on.
2252
+ if (data.environment_marker !== undefined)
2253
+ lines.push(` ${SANDBOX_LINE}`);
2254
+ lines.push(` ${data.summary}`, ` ${data.witness.rendered}`);
2255
+ // Where the packs hold a runner's run: its enclave attestation line and its
2256
+ // run-account line, beside the witness line.
2257
+ const enclave = data.enclave_attestation ?? [];
2258
+ const account = data.run_account ?? [];
2259
+ for (let i = 0; i < Math.min(enclave.length, account.length); i++) {
2260
+ for (const line of enclave[i].rendered)
2261
+ lines.push(` ${line}`);
2262
+ lines.push(` ${account[i].rendered}`);
2263
+ }
2264
+ lines.push(` ${data.reliance}`);
2193
2265
  for (const e of data.exceptions)
2194
2266
  lines.push(` - ${e.rendered}`);
2195
2267
  for (const n of data.notes)
@@ -2197,7 +2269,11 @@ function caseReport(data) {
2197
2269
  lines.push(`packs ${data.packs.length}`);
2198
2270
  for (const p of data.packs) {
2199
2271
  const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
2200
- const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
2272
+ const grade = p.grade === null
2273
+ ? "not graded"
2274
+ : p.grade === "verified" && p.environment_marker !== undefined
2275
+ ? SANDBOX_TITLE
2276
+ : titleOf(p.grade);
2201
2277
  // Each record's status, counted per pack (A43): the JSON lists every one.
2202
2278
  const mine = data.records.filter((r) => r.pack_index === p.index);
2203
2279
  const count = (status) => mine.filter((r) => r.status === status).length;
@@ -2235,13 +2311,23 @@ function caseReport(data) {
2235
2311
  }
2236
2312
  const oc = data.on_chain;
2237
2313
  if (oc.checked) {
2238
- lines.push(`on-chain looked up against ${oc.gateways.length} gateway(s), ` +
2239
- "pack by pack; the reading above is offline and this does not " +
2240
- "change it");
2314
+ // A drop whose only witnesses are simulated had nothing looked up on a
2315
+ // chain, and the header says so rather than "looked up against".
2316
+ const simulated = oc.packs.reduce((n, r) => n + (r.simulated ?? 0), 0);
2317
+ const onlySimulated = simulated > 0 &&
2318
+ oc.packs.every((r) => (r.simulated ?? 0) === r.witnesses && (r.witness_times ?? []).length === 0);
2319
+ lines.push(onlySimulated
2320
+ ? "on-chain nothing looked up: every witness here is simulated, " +
2321
+ "which is not on a chain; the reading above is offline and this " +
2322
+ "does not change it"
2323
+ : `on-chain looked up against ${oc.gateways.length} gateway(s), ` +
2324
+ "pack by pack; the reading above is offline and this does not " +
2325
+ "change it");
2241
2326
  for (const r of oc.packs) {
2327
+ const sim = r.simulated ? `, ${r.simulated} of them simulated (not on a chain)` : "";
2242
2328
  lines.push(` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
2243
2329
  `confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
2244
- `unreachable, ${r.not_checked} not checked`);
2330
+ `unreachable, ${r.not_checked} not checked${sim}`);
2245
2331
  }
2246
2332
  }
2247
2333
  return lines;
@@ -2333,6 +2419,12 @@ export async function caseFileReadings(tree, whole, cap = MAX_FOLDER_BYTES) {
2333
2419
  }
2334
2420
  return out;
2335
2421
  }
2422
+ // One pack's environment marker, as the key its JSON row carries only where
2423
+ // the pack holds an envelope signed `dev`.
2424
+ function packMarker(bundle) {
2425
+ const m = environmentMarker([bundle]);
2426
+ return m !== null ? { environment_marker: m } : {};
2427
+ }
2336
2428
  function caseError(e, asJson, io) {
2337
2429
  if (asJson)
2338
2430
  outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
@@ -2417,11 +2509,69 @@ export async function looseFiles(tree, split, cap = MAX_FOLDER_BYTES) {
2417
2509
  }
2418
2510
  return { offered, skipped };
2419
2511
  }
2512
+ // A policy file that is not JSON: read, and unreadable (`policy_unreadable`).
2513
+ const POLICY_NOT_JSON = Symbol("policy not JSON");
2514
+ async function readEnclavePolicy(path) {
2515
+ if (path === null)
2516
+ return null;
2517
+ let raw;
2518
+ try {
2519
+ if (!(await stat(path)).isFile())
2520
+ throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
2521
+ raw = new Uint8Array(await readFile(path));
2522
+ }
2523
+ catch (e) {
2524
+ if (e instanceof CaseInputError)
2525
+ throw e;
2526
+ throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
2527
+ }
2528
+ try {
2529
+ return parseRefusingDuplicates(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw));
2530
+ }
2531
+ catch {
2532
+ return POLICY_NOT_JSON;
2533
+ }
2534
+ }
2535
+ /** The two line members of `--json`: one entry per run, with the lines it prints. */
2536
+ function runLines(runs) {
2537
+ const enclave = runs.map((r) => ({
2538
+ pack_ref: r.packRef,
2539
+ pack_index: r.packIndex,
2540
+ event_id: r.eventId,
2541
+ runner_public_key: r.runnerPublicKey,
2542
+ agent_public_key: r.agentPublicKey,
2543
+ state: r.enclave.state,
2544
+ kinds: r.enclave.kinds,
2545
+ notes: r.enclave.notes,
2546
+ reason: r.enclave.reason,
2547
+ provider: r.enclave.provider,
2548
+ evaluation: r.enclave.evaluation,
2549
+ evaluated_at: r.enclave.evaluatedAt,
2550
+ token_checked: r.enclave.tokenChecked,
2551
+ online: r.online !== null ? { state: r.online.state, kinds: r.online.kinds } : null,
2552
+ rendered: renderEnclaveLines(r).map(escapeControl),
2553
+ }));
2554
+ const account = runs.map((r) => ({
2555
+ pack_ref: r.packRef,
2556
+ pack_index: r.packIndex,
2557
+ event_id: r.eventId,
2558
+ runner_public_key: r.runnerPublicKey,
2559
+ state: r.account.state,
2560
+ findings: r.account.findings.map((f) => ({ kind: f.kind, event_id: f.eventId })),
2561
+ notes: r.account.notes.map((n) => ({ kind: n.kind, event_id: n.eventId })),
2562
+ closing_event_id: r.account.closingEventId,
2563
+ reason: r.account.reason,
2564
+ rendered: escapeControl(renderAccountLine(r)),
2565
+ }));
2566
+ return [enclave, account];
2567
+ }
2420
2568
  async function caseCommand(argv, io) {
2421
2569
  let asJson = false;
2422
2570
  let gateways = [];
2423
2571
  const paths = [];
2424
2572
  let filePaths;
2573
+ let enclavePolicy = null;
2574
+ let enclaveRpc = null;
2425
2575
  for (let i = 0; i < argv.length; i++) {
2426
2576
  const arg = argv[i];
2427
2577
  if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
@@ -2454,6 +2604,19 @@ async function caseCommand(argv, io) {
2454
2604
  filePaths = [...(filePaths ?? []), ...taken];
2455
2605
  i += taken.length;
2456
2606
  }
2607
+ else if (arg === "--enclave-policy" || arg === "--enclave-rpc") {
2608
+ const next = argv[i + 1];
2609
+ if (next === undefined) {
2610
+ io.err(arg === "--enclave-policy" ? "case: --enclave-policy requires a policy file" : "case: --enclave-rpc requires a URL");
2611
+ outBlock(io, CASE_USAGE);
2612
+ return EXIT_MALFORMED;
2613
+ }
2614
+ if (arg === "--enclave-policy")
2615
+ enclavePolicy = next;
2616
+ else
2617
+ enclaveRpc = next;
2618
+ i++;
2619
+ }
2457
2620
  else if (arg.startsWith("--")) {
2458
2621
  io.err(`case: unknown option ${arg}`);
2459
2622
  outBlock(io, CASE_USAGE);
@@ -2477,6 +2640,15 @@ async function caseCommand(argv, io) {
2477
2640
  offered = read.offered;
2478
2641
  skipped = read.skipped;
2479
2642
  }
2643
+ let policy;
2644
+ try {
2645
+ policy = await readEnclavePolicy(enclavePolicy);
2646
+ }
2647
+ catch (e) {
2648
+ if (e instanceof CaseInputError)
2649
+ return caseError(e, asJson, io);
2650
+ throw e;
2651
+ }
2480
2652
  const whole = { n: MAX_FOLDER_BYTES };
2481
2653
  let split;
2482
2654
  let tree;
@@ -2506,13 +2678,24 @@ async function caseCommand(argv, io) {
2506
2678
  for (const [p, bundle] of bundles) {
2507
2679
  caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p, whole, cacheRefused)));
2508
2680
  }
2509
- const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) });
2681
+ const confirmedStamps = [];
2682
+ const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) }, confirmedStamps);
2510
2683
  const witness = witnessLine(onChain, caches.flat());
2511
- const code = caseExitCode(reading.verdict, onChain.verdict);
2684
+ // Sandbox evidence: over every envelope every pack holds, and per
2685
+ // pack for its row. Only a case that would otherwise exit 0 exits 5.
2686
+ const marker = environmentMarker(bundles.map(([, b]) => b));
2687
+ // Undetermined only because a simulated witness cannot be looked up: every
2688
+ // other witness confirmed, none unreachable or mismatched, every pack names
2689
+ // one. With every record signed `dev` that is sandbox evidence, exit 5.
2690
+ const simulatedOnly = reading.verdict === "verified" &&
2691
+ onChain.verdict === "undetermined" &&
2692
+ onChain.packs.some((r) => (r.simulated ?? 0) > 0) &&
2693
+ onChain.packs.every((r) => r.witnesses > 0 && r.unreachable === 0 && r.mismatched === 0 && r.not_checked === (r.simulated ?? 0));
2694
+ const code = sandboxExit(caseExitCode(reading.verdict, onChain.verdict), marker, simulatedOnly);
2512
2695
  const nil = (v) => (v === undefined ? null : v);
2513
2696
  const data = {
2514
2697
  verdict: reading.verdict,
2515
- title: titleOf(reading.verdict),
2698
+ title: reading.verdict === "verified" && marker !== null ? SANDBOX_TITLE : titleOf(reading.verdict),
2516
2699
  reliance: caseReliance(reading.verdict, reading.handoffRecords),
2517
2700
  exit_code: code,
2518
2701
  summary: renderCaseSummary(reading),
@@ -2572,13 +2755,23 @@ async function caseCommand(argv, io) {
2572
2755
  grade: reading.packs[i].grade,
2573
2756
  records_withheld: reading.packs[i].recordsWithheld,
2574
2757
  declaration_version: reading.packs[i].declarationVersion,
2758
+ ...packMarker(bundles[i][1]),
2575
2759
  })),
2576
2760
  outside: split.outside,
2577
2761
  not_regular: split.notRegular,
2578
2762
  case_file: caseFile,
2579
2763
  on_chain: onChain,
2580
2764
  ...(cacheRefused.length > 0 ? { witness_cache_not_read: cacheRefused } : {}),
2765
+ ...(marker !== null ? { environment_marker: marker } : {}),
2581
2766
  };
2767
+ // The runs in the drop (DRAFT, specs/enclave-attestation.md and
2768
+ // run-records.md): an enclave attestation line and a run-account line per
2769
+ // run, beside the witness line. Neither moves `code`, and a drop that holds
2770
+ // no run prints and writes exactly what it did before.
2771
+ const runs = (await readRuns(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })), { policy, adapters: await loadInstalledAdapters(), rpc: enclaveRpc, witnessed: confirmedStamps })).runs;
2772
+ const withRuns = data;
2773
+ if (runs.length > 0)
2774
+ [withRuns.enclave_attestation, withRuns.run_account] = runLines(runs);
2582
2775
  // The files outside every pack (evidence-bundle.md 5.7, A29, A41), always,
2583
2776
  // then the files after --files. A pack's logs/ is its side content and is
2584
2777
  // never an offered file; only the paths after --files are. Both are matched
package/dist/crypto.d.ts CHANGED
@@ -4,6 +4,23 @@ export declare function isHexField(value: unknown, nibbles: number, lower?: bool
4
4
  export declare function utf8(s: string): Uint8Array;
5
5
  export declare function sha256Bytes(bytes: Uint8Array): Promise<Uint8Array>;
6
6
  export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
7
+ /** The sentence a refused signature reads in an envelope result. */
8
+ export declare const SIGNATURE_FAILED = "Ed25519 signature verification failed";
9
+ export declare const ED25519_RULE_REASONS: readonly ["the public key is not the canonical encoding of a point", "the public key is a point of small order", "the public key is not in the prime-order subgroup", "R is not the canonical encoding of a point", "R is the identity", "R is a point of small order", "R is not in the prime-order subgroup", "S is not below the group order"];
10
+ /**
11
+ * The clause of the rule that (`publicKey`, `signature`) breaks, or `null`
12
+ * when it breaks none and the equation is what is left to check. Lengths are
13
+ * the caller's (32 and 64 bytes). Never throws.
14
+ */
15
+ export declare function ed25519RuleReason(signature: Uint8Array, publicKey: Uint8Array): string | null;
16
+ /**
17
+ * `null` iff `signatureHex` is a signature of `message` under `publicKeyHex`
18
+ * by the rule; otherwise the sentence the refusal reads: `SIGNATURE_FAILED`,
19
+ * with `": "` and the clause of the rule it broke when it broke one. A
20
+ * malformed field, a wrong length and an equation that does not hold read
21
+ * the bare sentence. Never throws. The Python twin is `ed25519_refusal`.
22
+ */
23
+ export declare function ed25519Refusal(message: Uint8Array, signatureHex: string, publicKeyHex: string): Promise<string | null>;
7
24
  export declare function ed25519Verify(message: Uint8Array, signatureHex: string, publicKeyHex: string): Promise<boolean>;
8
25
  export declare function ed25519Sign(message: Uint8Array, privateKeyHex: string): Promise<string>;
9
26
  export declare function ed25519PublicKey(privateKeyHex: string): Promise<string>;
package/dist/crypto.js CHANGED
@@ -6,7 +6,9 @@ import * as ed from "@noble/ed25519";
6
6
  // @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
7
7
  // pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
8
8
  // browser and in Node >= 19, so the same code path runs in the app and in tests.
9
- ed.etc.sha512Async = async (...msgs) => new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
9
+ // noble 3 ships this provider as its default; it is set here so the kernel
10
+ // says what it hashes with rather than inheriting it.
11
+ ed.hashes.sha512Async = async (message) => new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(message)));
10
12
  // WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
11
13
  // Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
12
14
  // typing satisfied without a runtime copy.
@@ -70,31 +72,129 @@ export async function sha256Bytes(bytes) {
70
72
  export async function sha256Hex(bytes) {
71
73
  return bytesToHex(await sha256Bytes(bytes));
72
74
  }
73
- // Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
74
- // appear in the envelope; message is raw bytes. Never throws — a malformed
75
- // signature or key is "not verified," not an exception, so a hostile envelope
76
- // can't crash the checker.
75
+ // ---------------------------------------------------------------------------
76
+ // The Ed25519 rule (envelope-spec §7, A44, proof#230)
77
+ //
78
+ // Strict RFC 8032 (`zip215: false`, a frozen invariant), and more: the public
79
+ // key A and the signature's R must each be the canonical encoding of a point
80
+ // in the prime-order subgroup other than the identity, and S below the group
81
+ // order L. Only then is the equation checked.
82
+ //
83
+ // Why more than `zip215: false`: noble checks the equation with the cofactor,
84
+ // 8·S·B = 8·R + 8·k·A, and Go's crypto/ed25519 and libsodium without it, so on
85
+ // their own they disagree when A or R has a small-order component. noble 2.3
86
+ // also could not encode an identity R and hashed nothing for k, so it accepted
87
+ // one signature from a key holder over every message. With A and R in the
88
+ // prime-order subgroup the two equations are the same equation, and every
89
+ // kernel accepts exactly the same signatures.
90
+ //
91
+ // Each clause is checked here, in this order, and named in these words: the
92
+ // Python twin is `ed25519_rule_reason` in `src/sanning_proof/ed25519_rule.py`
93
+ // and the Go kernel's `verifyEd25519Strict` in sanning-io/agent `pkg/proof`.
94
+ // Several clauses are ones noble 3 refuses on its own; they are checked here
95
+ // anyway so the refusal names the clause, which a library's `false` cannot.
96
+ /** The sentence a refused signature reads in an envelope result. */
97
+ export const SIGNATURE_FAILED = "Ed25519 signature verification failed";
98
+ export const ED25519_RULE_REASONS = [
99
+ "the public key is not the canonical encoding of a point",
100
+ "the public key is a point of small order",
101
+ "the public key is not in the prime-order subgroup",
102
+ "R is not the canonical encoding of a point",
103
+ "R is the identity",
104
+ "R is a point of small order",
105
+ "R is not in the prime-order subgroup",
106
+ "S is not below the group order",
107
+ ];
108
+ const [KEY_NOT_CANONICAL, KEY_SMALL_ORDER, KEY_NOT_IN_SUBGROUP, R_NOT_CANONICAL, R_IDENTITY, R_SMALL_ORDER, R_NOT_IN_SUBGROUP, S_NOT_BELOW_L,] = ED25519_RULE_REASONS;
109
+ const ED25519_L = 2n ** 252n + 27742317777372353535851937790883648493n;
110
+ function bytesToNumberLE(bytes) {
111
+ let v = 0n;
112
+ for (let i = bytes.length - 1; i >= 0; i--)
113
+ v = (v << 8n) | BigInt(bytes[i]);
114
+ return v;
115
+ }
116
+ // `Point.fromBytes(..., false)` is RFC 8032 decoding with its two strict
117
+ // branches: it throws on y at or above p and on x zero with the sign bit set,
118
+ // the two non-canonical encodings of a point, and on bytes that are no point.
119
+ function decodeStrict(bytes) {
120
+ try {
121
+ return ed.Point.fromBytes(bytes, false);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /**
128
+ * The clause of the rule that (`publicKey`, `signature`) breaks, or `null`
129
+ * when it breaks none and the equation is what is left to check. Lengths are
130
+ * the caller's (32 and 64 bytes). Never throws.
131
+ */
132
+ export function ed25519RuleReason(signature, publicKey) {
133
+ const A = decodeStrict(publicKey);
134
+ if (A === null)
135
+ return KEY_NOT_CANONICAL;
136
+ if (A.isSmallOrder())
137
+ return KEY_SMALL_ORDER;
138
+ if (!A.isTorsionFree())
139
+ return KEY_NOT_IN_SUBGROUP;
140
+ const R = decodeStrict(signature.subarray(0, 32));
141
+ if (R === null)
142
+ return R_NOT_CANONICAL;
143
+ if (R.is0())
144
+ return R_IDENTITY;
145
+ if (R.isSmallOrder())
146
+ return R_SMALL_ORDER;
147
+ if (!R.isTorsionFree())
148
+ return R_NOT_IN_SUBGROUP;
149
+ if (bytesToNumberLE(signature.subarray(32, 64)) >= ED25519_L)
150
+ return S_NOT_BELOW_L;
151
+ return null;
152
+ }
153
+ /**
154
+ * `null` iff `signatureHex` is a signature of `message` under `publicKeyHex`
155
+ * by the rule; otherwise the sentence the refusal reads: `SIGNATURE_FAILED`,
156
+ * with `": "` and the clause of the rule it broke when it broke one. A
157
+ * malformed field, a wrong length and an equation that does not hold read
158
+ * the bare sentence. Never throws. The Python twin is `ed25519_refusal`.
159
+ */
160
+ export async function ed25519Refusal(message, signatureHex, publicKeyHex) {
161
+ let signature;
162
+ let publicKey;
163
+ try {
164
+ signature = hexToBytes(signatureHex);
165
+ publicKey = hexToBytes(publicKeyHex);
166
+ }
167
+ catch {
168
+ return SIGNATURE_FAILED;
169
+ }
170
+ if (signature.length !== 64 || publicKey.length !== 32)
171
+ return SIGNATURE_FAILED;
172
+ const reason = ed25519RuleReason(signature, publicKey);
173
+ if (reason !== null)
174
+ return `${SIGNATURE_FAILED}: ${reason}`;
175
+ try {
176
+ return (await ed.verifyAsync(signature, message, publicKey, { zip215: false })) ? null : SIGNATURE_FAILED;
177
+ }
178
+ catch {
179
+ return SIGNATURE_FAILED;
180
+ }
181
+ }
182
+ // Verify an Ed25519 signature by the rule above. Inputs are hex (signature,
183
+ // public key) as they appear in the envelope; message is raw bytes. Never
184
+ // throws — a malformed signature or key is "not verified," not an exception,
185
+ // so a hostile envelope can't crash the checker.
77
186
  // Arguments in the Python twin's order, `ed25519_verify(message,
78
187
  // signature_hex, public_key_hex)` (P-03, 0.13.0). The message came second
79
188
  // until 0.13.0; a caller that still passes the signature first gets a compile
80
189
  // error, because a string is not a Uint8Array.
81
190
  export async function ed25519Verify(message, signatureHex, publicKeyHex) {
82
- try {
83
- // zip215:false → strict RFC-8032 verification, matching the Go agent's
84
- // crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
85
- return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
86
- zip215: false,
87
- });
88
- }
89
- catch {
90
- return false;
91
- }
191
+ return (await ed25519Refusal(message, signatureHex, publicKeyHex)) === null;
92
192
  }
93
193
  // Ed25519 SIGN the raw `message` bytes with a 32-byte private-key seed (lowercase
94
194
  // hex). Returns the signature as lowercase hex. The signing counterpart to
95
195
  // ed25519Verify — the ONE signing primitive the composer (`proof export`,
96
196
  // evidence-export.md §5) needs to Ed25519-sign the export wrapper with the
97
- // exporter's key ("anyone can export", P-4). The @noble sha512Async hook wired at
197
+ // exporter's key ("anyone can export", P-4). The @noble sha512Async provider wired at
98
198
  // module load serves signing and verification alike, so this stays WebCrypto-only.
99
199
  export async function ed25519Sign(message, privateKeyHex) {
100
200
  return bytesToHex(await ed.signAsync(message, hexToBytes(privateKeyHex)));