diff --git a/tee/cli/common/manifest.py b/tee/cli/common/manifest.py index f7968b10..7437552f 100644 --- a/tee/cli/common/manifest.py +++ b/tee/cli/common/manifest.py @@ -17,6 +17,9 @@ inputs/reth-genesis.json policy-free genesis inputs/summit-genesis.toml summit parameter choices inputs/measurements.json raw PCR map from `make measure` + inputs/founder-withdrawal-credentials.json + authored, one address per founder + inputs/harvest/.json harvested founding pubkeys + quotes network-manifest.json deploy-time facts; SHA-256 = network_id reth-genesis.json the input genesis with the policy's @@ -124,6 +127,14 @@ # `down` — so it stays gitignored while the artifact set around it commits. NODES_DIRNAME = "nodes" +# The founding cohort's inputs: founder-withdrawal-credentials.json is +# authored (node name -> withdrawal credentials); harvest/ holds what +# `network harvest` collected from the live cohort (pubkeys, quotes, +# verification reports) — provenance like measurements.json, but harvested +# rather than authored. +FOUNDERS_FILENAME = "founder-withdrawal-credentials.json" +HARVEST_DIRNAME = "harvest" + class ManifestSchemaError(Exception): """Manifest bytes don't satisfy the strict v1 schema.""" diff --git a/tee/cli/network/app.py b/tee/cli/network/app.py index 9168413a..2eae0649 100644 --- a/tee/cli/network/app.py +++ b/tee/cli/network/app.py @@ -2,7 +2,7 @@ Seismic-internal, NOT a tool node operators run: it provisions a cohort of TDX nodes (`up` / `down`) and runs the one-time network-creation steps -(`manifest`, `genesis-ceremony`). This is the CLI that is *allowed* to wrap +(`harvest`, `manifest`, `genesis-ceremony`). This is the CLI that is *allowed* to wrap Pulumi — `up` / `down` drive the seismic_node Automation-API orchestrator. The operator CLI (`seismic-tee-node`) deliberately is not; the boundary is the node @@ -41,6 +41,15 @@ def down(argv: tuple[str, ...]) -> None: forward(orchestrator.down_main, "seismic-tee-network down", argv) +@app.command(name="harvest", context_settings=PASSTHROUGH, add_help_option=False) +@click.argument("argv", nargs=-1, type=click.UNPROCESSED) +def harvest(argv: tuple[str, ...]) -> None: + """Harvest + DCAP-verify a founding cohort's summit keys into inputs/.""" + from tee.cli.network import harvest as harvest_mod + + forward(harvest_mod.main, "seismic-tee-network harvest", argv) + + @app.command(name="configure", context_settings=PASSTHROUGH, add_help_option=False) @click.argument("argv", nargs=-1, type=click.UNPROCESSED) def configure(argv: tuple[str, ...]) -> None: diff --git a/tee/cli/network/harvest.py b/tee/cli/network/harvest.py new file mode 100644 index 00000000..0ea64aac --- /dev/null +++ b/tee/cli/network/harvest.py @@ -0,0 +1,542 @@ +"""Founding harvest: collect and DCAP-verify each cohort box's summit keys. + +A founding cohort boots identity-free: each box's `summit-key-holder` +generates its summit keypairs in RAM at boot and serves +`GET /v1/quote?nonce=…` → `{pubkeys, evidence}` on :7879 until the box +accepts its config POST. Harvest is the step between `up --network` and +`manifest assemble`: it polls every box's holder, fetches its pubkeys plus +a TDX quote over a fresh per-box nonce (`report_data` binds the nonce and +both pubkeys, so a quote replayed from an earlier harvest can't satisfy +it), DCAP-verifies each quote against the network's intended image +measurements, and archives the verified facts under `inputs/harvest/` — +the provenance `assemble` pins the founding validator set from. Design +doc: +https://github.com/SeismicSystems/seismic/blob/main/docs/tee/network-founding.md + +Verification here is load-bearing, not hygiene: consensus membership is +gated by whose pubkeys enter the genesis validator set, and founding keys +bypass the deposit contract's admission path — so the harvest is the one +moment TEE residency can be checked before the set is pinned. The check +shells out to the enclave repo's `verify-quote` (exit 0 plus one JSON +report on stdout ⇔ verified), against the policy promoted from +`inputs/measurements.json` by the same admission CLI `assemble` uses. +This check is purely preventive: future users and joiners should re-run +the same verification against the archived evidence (each record keeps +the quote plus the nonce it binds) and the published collateral, rather +than trust this run's verdict. + +TODO: snapshot the DCAP collateral into inputs/harvest/dcap-collateral/ +once the capture mechanism is resolved (open question from the +verify-quote PR) — until then re-verification depends on Intel's live +collateral, which ages out from under the archived quotes. + +Any anomaly burns the whole harvest: a quote window already closed +(HTTP 410 — the box accepted a config POST), a failed verification, or a +cohort that doesn't match the authored +`inputs/founder-withdrawal-credentials.json` all abort +the run. A harvested key is trustworthy only if the same box later accepts +the real configure cleanly — never retry around a burned harvest; re-found +instead (`down` + fresh `up`). +""" + +import argparse +import json +import re +import secrets +import shutil +import subprocess +import tempfile +import time +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import requests + +from tee.cli.common import manifest as manifest_mod +from tee.cli.common.descriptor import load_descriptor, require +from tee.cli.network import bootnodes as bootnodes_mod + +# summit-key-holder's HTTP port (plain HTTP: nginx and certbot exist only +# post-configure). The node NSG restricts it to `operator_ip_cidr`, so the +# harvest runs from the operator machine that provisioned the cohort. +HOLDER_PORT = 7879 + +# Holder-readiness polling. The holder starts at network-online — well +# before the config POST — so an unreachable box is normally just still +# booting; same cadence as the other cohort gathers (bootnodes, genesis). +POLL_INTERVAL_SECONDS = 5 +HARVEST_TIMEOUT_SECONDS = 15 * 60 +WAIT_LOG_INTERVAL_SECONDS = 30 + +# The DCAP verifier from the enclave repo (bin/verify-quote), expected on +# PATH like the admission CLI. Verification-only, Linux-only at runtime — +# macOS callers run it in a Linux container. +DEFAULT_VERIFY_QUOTE_BIN = "verify-quote" + +_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") +# Holder pubkeys are summit's keystore wire format: lowercase bare hex, +# exactly as `commonware_utils::hex` renders — the spelling summit's +# genesis config_digest commits to, so any other form is rejected here +# rather than laundered into the archive. +_NODE_KEY_RE = re.compile(r"^[0-9a-f]{64}$") +_CONSENSUS_KEY_RE = re.compile(r"^[0-9a-f]{96}$") + + +class QuoteWindowClosed(Exception): + """The holder answered HTTP 410: the box already took a config POST.""" + + +@dataclass(frozen=True) +class HarvestTarget: + """One cohort box: descriptor stem (= its key in the authored + founder-withdrawal-credentials.json), its IP, and the fresh 32-byte + nonce (hex) minted for this run's quote request.""" + + name: str + public_ip: str + nonce: str + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "dir", + type=Path, + help=( + "Network directory (from `manifest init`): reads the cohort " + f"descriptors in {manifest_mod.NODES_DIRNAME}/, the authored " + f"{manifest_mod.INPUTS_DIRNAME}/{manifest_mod.FOUNDERS_FILENAME} " + f"and {manifest_mod.INPUTS_DIRNAME}/" + f"{manifest_mod.MEASUREMENTS_FILENAME}, and writes the harvested " + f"facts to {manifest_mod.INPUTS_DIRNAME}/" + f"{manifest_mod.HARVEST_DIRNAME}/" + ), + ) + parser.add_argument( + "--node", + type=Path, + nargs="+", + action="append", + default=None, + metavar="DESCRIPTOR", + help=( + "Node descriptor JSON file(s), one per cohort box — `--node " + "n1.json n2.json` and `--node n1.json --node n2.json` both work. " + "Default: every *.json in /nodes/ (written by `up " + "--network`) except bootnodes.json, sorted by name." + ), + ) + parser.add_argument( + "--verify-quote-bin", + default=DEFAULT_VERIFY_QUOTE_BIN, + help="DCAP verifier CLI from the enclave repo (bin/verify-quote)", + ) + parser.add_argument( + "--admission-bin", + default=manifest_mod.DEFAULT_ADMISSION_BIN, + help="policy-compiler CLI used to promote the measurements into the " + "policy each quote is verified against", + ) + parser.add_argument( + "--attestation-type", default=manifest_mod.DEFAULT_ATTESTATION_TYPE + ) + parser.add_argument( + "--pccs-url", + default=None, + metavar="URL", + help="forwarded to verify-quote: PCCS URL for DCAP collateral", + ) + parser.add_argument( + "--override-azure-outdated-tcb", + action="store_true", + help="forwarded to verify-quote: allow the Azure outdated-TCB override path", + ) + parser.add_argument( + "--force", + action="store_true", + help="overwrite existing harvest file(s) — a fresh harvest with new " + "nonces, replacing the archived provenance", + ) + args = parser.parse_args(argv) + + if not args.dir.is_dir(): + raise SystemExit(f"network directory not found: {args.dir}") + inputs_dir = args.dir / manifest_mod.INPUTS_DIRNAME + args.measurements = inputs_dir / manifest_mod.MEASUREMENTS_FILENAME + args.founders = inputs_dir / manifest_mod.FOUNDERS_FILENAME + if not args.measurements.is_file(): + raise SystemExit( + f"{args.measurements} not found — authored inputs live under " + f"{manifest_mod.INPUTS_DIRNAME}/; scaffold them with `manifest init`" + ) + if not args.founders.is_file(): + raise SystemExit( + f"{args.founders} not found — author it as a JSON object mapping " + "each cohort node name (descriptor filename stem) to that " + "founder's withdrawal credentials (0x-prefixed address)" + ) + if args.node is None: + nodes_dir = args.dir / manifest_mod.NODES_DIRNAME + # `configure` writes bootnodes.json into this same dir; it's runtime + # p2p state, not a node descriptor, so skip it or load_descriptor + # would abort on the missing fqdn/public_ip. + args.node = sorted( + p + for p in nodes_dir.glob("*.json") + if p.name != bootnodes_mod.BOOTNODES_FILENAME + ) + if not args.node: + raise SystemExit( + f"no --node given and no descriptors in {nodes_dir} (written " + "by `up --network`); pass --node explicitly" + ) + else: + # append+nargs yields one list per --node occurrence; flatten to the + # cohort list callers expect. + args.node = [path for group in args.node for path in group] + # Descriptor filename stems are the harvest's node names (the + # founder-credentials keys and the inputs/harvest/ filenames), so + # compare stems, not paths: two spellings of one file or two files + # sharing a stem would otherwise silently collapse into one + # harvested box. + stems = [p.stem for p in args.node] + dupes = sorted({s for s in stems if stems.count(s) > 1}) + if dupes: + raise SystemExit( + f"duplicate --node descriptor name(s): {', '.join(dupes)} — " + "each cohort box needs a unique descriptor filename stem" + ) + for path in args.node: + if not path.is_file(): + raise SystemExit(f"--node descriptor not found: {path}") + return args + + +def load_founders(path: Path, cohort: list[str]) -> dict[str, str]: + """Load inputs/founder-withdrawal-credentials.json and pair it against + the live cohort. + + The authored founder list and the harvested cohort must agree exactly: + a box with no credentials can't be pinned, and an entry with no box + means the cohort is incomplete — either way `assemble` would pin a set + other than the intended one, so the mismatch aborts the harvest. + """ + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as e: + raise SystemExit(f"{path}: not valid JSON: {e}") from None + if not isinstance(data, dict) or not all(isinstance(v, str) for v in data.values()): + raise SystemExit( + f"{path}: expected a JSON object mapping node name -> withdrawal " + "credentials (0x-prefixed address)" + ) + bad = sorted(name for name, addr in data.items() if not _ADDRESS_RE.match(addr)) + if bad: + raise SystemExit( + f"{path}: withdrawal credentials must be 0x + 40 hex chars; bad " + f"entr(ies): {', '.join(bad)}" + ) + missing = sorted(set(cohort) - set(data)) + extra = sorted(set(data) - set(cohort)) + if missing or extra: + lines = [] + if missing: + listing = ", ".join(missing) + lines.append(f" cohort box(es) with no founder entry: {listing}") + if extra: + lines.append(f" founder entr(ies) with no cohort box: {', '.join(extra)}") + raise SystemExit(f"{path} does not match the cohort:\n" + "\n".join(lines)) + return data + + +def fetch_quote(public_ip: str, nonce: str, *, timeout: int = 30) -> dict[str, Any]: + """Fetch one box's `{pubkeys, evidence}` from its summit-key-holder. + + Raises QuoteWindowClosed on HTTP 410 (the box already accepted a config + POST), a requests error on transport/HTTP failure (callers retry a + still-booting box), and ValueError on a malformed response body — + retrying can't fix a holder serving the wrong shape. + """ + url = f"http://{public_ip}:{HOLDER_PORT}/v1/quote" + response = requests.get(url, params={"nonce": nonce}, timeout=timeout) + if response.status_code == 410: + raise QuoteWindowClosed(url) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise ValueError(f"{url}: expected a JSON object, got {type(data).__name__}") + node_key = data.get("node_public_key") + if not isinstance(node_key, str) or not _NODE_KEY_RE.match(node_key): + raise ValueError( + f"{url}: node_public_key is not 64 lowercase hex chars: {node_key!r}" + ) + consensus_key = data.get("consensus_public_key") + if not isinstance(consensus_key, str) or not _CONSENSUS_KEY_RE.match(consensus_key): + raise ValueError( + f"{url}: consensus_public_key is not 96 lowercase hex chars: " + f"{consensus_key!r}" + ) + if not isinstance(data.get("evidence"), dict): + raise ValueError(f"{url}: response carries no evidence object") + return data + + +def collect_quotes( + targets: list[HarvestTarget], + *, + timeout: float = HARVEST_TIMEOUT_SECONDS, + interval: float = POLL_INTERVAL_SECONDS, +) -> dict[str, dict[str, Any]]: + """Poll every target's holder until each serves its quote, or `timeout`. + + Round-robin like the other cohort gathers, so a slow box doesn't + serialize behind the others. Transport errors and 5xx are the normal + boot tail — retried until the deadline, then aborted with a per-box + report. A closed quote window (410) or any other 4xx burns the harvest + immediately: waiting can't fix a box that already took its config POST, + or a holder that rejects well-formed requests. + """ + quotes: dict[str, dict[str, Any]] = {} + last_error: dict[str, str] = {} + started = time.monotonic() + deadline = started + timeout + next_log = 0.0 + while True: + for target in targets: + if target.name in quotes: + continue + try: + quotes[target.name] = fetch_quote(target.public_ip, target.nonce) + except QuoteWindowClosed: + raise SystemExit( + f"{target.name}: quote window closed (HTTP 410) — the box " + "already accepted a config POST, so its founding keys are " + "not harvestable. The harvest is burned: re-found (`down` " + "+ fresh `up`) rather than retrying around it." + ) from None + except ValueError as e: + raise SystemExit(f"{target.name}: {e}") from None + except requests.HTTPError as e: + status = e.response.status_code if e.response is not None else None + if status is not None and 400 <= status < 500: + raise SystemExit( + f"{target.name}: holder rejected the quote request " + f"({e}) — not a boot-tail condition; check that the " + "image and this CLI agree on the holder API." + ) from None + last_error[target.name] = str(e) + except requests.RequestException as e: + last_error[target.name] = str(e) + else: + print(f" ✓ {target.name}: pubkeys + quote harvested") + pending = [t.name for t in targets if t.name not in quotes] + if not pending: + return quotes + now = time.monotonic() + if now >= deadline: + listing = "\n".join(f" ✗ {name}: {last_error[name]}" for name in pending) + raise SystemExit( + f"{len(pending)} box(es) never served a founding quote after " + f"{int(timeout)}s (holder not up?):\n{listing}" + ) + if now >= next_log: + elapsed = int(now - started) + remaining = max(0, int(deadline - now)) + print( + f"waiting for founding quotes ({elapsed}s elapsed, " + f"{remaining}s until timeout): " + ", ".join(pending) + ) + next_log = now + WAIT_LOG_INTERVAL_SECONDS + time.sleep(interval) + + +def assert_unique_keys(quotes: dict[str, dict[str, Any]]) -> None: + """Abort if two boxes served the same pubkey. + + Summit's genesis keys validator accounts by node pubkey, so a repeated + key silently collapses the set — and two boxes holding the same + consensus key is accidental-equivocation material. Either way the + cohort is not the N distinct founders being pinned: burn. + """ + for field in ("node_public_key", "consensus_public_key"): + seen: dict[str, str] = {} + for name in sorted(quotes): + key = quotes[name][field] + if key in seen: + raise SystemExit( + f"{seen[key]} and {name} served the same {field} ({key}); " + "the cohort is not the distinct founder set being pinned. " + "The harvest is burned: re-found." + ) + seen[key] = name + + +def verify_quote( + target: HarvestTarget, + quote: dict[str, Any], + policy_path: Path, + verify_bin: str, + *, + pccs_url: str | None, + override_azure_outdated_tcb: bool, +) -> dict[str, Any]: + """DCAP-verify one harvested quote via the enclave repo's `verify-quote`. + + Its contract: exit 0 plus one JSON report on stdout ⇔ the evidence + verifies cryptographically, its report_data binds this nonce + these + pubkeys, and its measurements satisfy the policy. The evidence goes + over stdin — the same parsed evidence object the archive records. A + failure burns the harvest: a founding key whose quote doesn't verify + must never reach `assemble`. + """ + cmd = [ + verify_bin, + "--evidence", + "-", + "--policy", + str(policy_path), + "--nonce", + target.nonce, + "--node-pubkey", + quote["node_public_key"], + "--consensus-pubkey", + quote["consensus_public_key"], + ] + if pccs_url: + cmd += ["--pccs-url", pccs_url] + if override_azure_outdated_tcb: + cmd.append("--override-azure-outdated-tcb") + result = subprocess.run( + cmd, input=json.dumps(quote["evidence"]).encode("utf-8"), capture_output=True + ) + if result.returncode != 0: + detail = result.stderr.decode("utf-8", "replace").strip() + raise SystemExit( + f"{target.name}: quote verification failed — the harvest is " + f"burned (re-found rather than retrying):\n{detail}" + ) + try: + report = json.loads(result.stdout) + except json.JSONDecodeError: + report = None + if not isinstance(report, dict) or report.get("verified") is not True: + raise SystemExit( + f"{target.name}: `{verify_bin}` exited 0 without a verified " + f"report: {result.stdout!r}" + ) + return report + + +def check_overwrite(harvest_dir: Path, names: list[str], force: bool) -> None: + """Refuse to clobber an existing harvest unless --force. + + The archive is founding provenance — the nonces it holds are what make + the archived quotes re-verifiable — so replacing it is a deliberate + re-harvest, not a default. + """ + existing = sorted(name for name in names if (harvest_dir / f"{name}.json").exists()) + if existing and not force: + raise SystemExit( + f"refusing to overwrite existing harvest file(s) in {harvest_dir}: " + f"{', '.join(existing)} — pass --force for a fresh harvest (new " + "nonces; the archived provenance is replaced)" + ) + + +def save_harvest(harvest_dir: Path, records: dict[str, dict[str, Any]]) -> list[Path]: + """Write one inputs/harvest/.json per box (pretty JSON, trailing + newline — the descriptor writer's format). Each record carries the + evidence exactly as the holder served it plus the nonce it binds, so + the archived quote stays re-verifiable, and the verification report + (every quoted PCR) as measurement provenance.""" + harvest_dir.mkdir(parents=True, exist_ok=True) + written = [] + for name in sorted(records): + path = harvest_dir / f"{name}.json" + path.write_text(json.dumps(records[name], indent=2) + "\n") + written.append(path) + return written + + +def main() -> None: + args = _parse_args() + + # Fail on a missing verifier before touching the cohort (pattern: the + # ceremony's `genesis` binary check). + verify_bin = shutil.which(args.verify_quote_bin) + if verify_bin is None: + raise SystemExit( + f"`{args.verify_quote_bin}` not found on PATH. Build the enclave " + "repo's bin/verify-quote and put it on PATH. It is Linux-only at " + "runtime; on macOS run it in a Linux container." + ) + + targets = [] + for path in args.node: + descriptor = load_descriptor(path) + targets.append( + HarvestTarget( + name=path.stem, + public_ip=require(descriptor, "public_ip", path), + nonce=secrets.token_bytes(32).hex(), + ) + ) + + load_founders(args.founders, [t.name for t in targets]) + + harvest_dir = args.dir / manifest_mod.INPUTS_DIRNAME / manifest_mod.HARVEST_DIRNAME + check_overwrite(harvest_dir, [t.name for t in targets], args.force) + + try: + policy_bytes = manifest_mod.promote_measurements( + args.measurements.read_bytes(), + None, + args.attestation_type, + admission_bin=args.admission_bin, + ) + except manifest_mod.GateError as e: + raise SystemExit(f"{args.measurements}: {e}") from None + + print(f"Harvesting founding keys from {len(targets)} box(es)...") + quotes = collect_quotes(targets) + assert_unique_keys(quotes) + + harvested_at = datetime.now(UTC).isoformat(timespec="seconds") + records: dict[str, dict[str, Any]] = {} + with tempfile.NamedTemporaryFile( + prefix="measurement-policy-", suffix=".json" + ) as policy_file: + policy_file.write(policy_bytes) + policy_file.flush() + for target in targets: + quote = quotes[target.name] + report = verify_quote( + target, + quote, + Path(policy_file.name), + verify_bin, + pccs_url=args.pccs_url, + override_azure_outdated_tcb=args.override_azure_outdated_tcb, + ) + print(f" ✓ {target.name}: quote DCAP-verified against the policy") + records[target.name] = { + "harvest_nonce": target.nonce, + "node_public_key": quote["node_public_key"], + "consensus_public_key": quote["consensus_public_key"], + "evidence": quote["evidence"], + "harvested_at": harvested_at, + "verification": report, + } + + for path in save_harvest(harvest_dir, records): + print(f"wrote {path}") + print( + f"Harvest complete: {len(records)} founding box(es) verified and " + f"archived under {harvest_dir}" + ) + + +if __name__ == "__main__": + main() diff --git a/tee/cli/network/tests/test_harvest.py b/tee/cli/network/tests/test_harvest.py new file mode 100644 index 00000000..44757a2b --- /dev/null +++ b/tee/cli/network/tests/test_harvest.py @@ -0,0 +1,375 @@ +"""Tests for tee.cli.network.harvest (stdlib unittest; no test deps). + +Covers the offline logic: arg parsing, cohort/founder pairing, the +quote-poll loop and its burn conditions (fetch mocked — no live network +calls), the verify-quote shell-out contract (subprocess mocked), and the +inputs/harvest/ archive. + +Run with: + uv run python -m unittest discover -b +""" + +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import requests + +from tee.cli.common import manifest as manifest_mod +from tee.cli.network import bootnodes, harvest + +NODE_KEY = "ab" * 32 +CONSENSUS_KEY = "cd" * 48 +NONCE = "11" * 32 +EVIDENCE = {"attestation_type": "azure-tdx", "attestation": [1, 2, 3]} +ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + + +def quote_body(node_key: str = NODE_KEY, consensus_key: str = CONSENSUS_KEY) -> dict: + return { + "node_public_key": node_key, + "consensus_public_key": consensus_key, + "evidence": dict(EVIDENCE), + } + + +def response(status_code: int = 200, body: dict | None = None) -> mock.Mock: + resp = mock.Mock() + resp.status_code = status_code + resp.json.return_value = body if body is not None else quote_body() + if status_code >= 400: + error = requests.HTTPError(f"HTTP {status_code}") + error.response = resp + resp.raise_for_status.side_effect = error + else: + resp.raise_for_status.return_value = None + return resp + + +def target(name: str = "node-1", ip: str = "203.0.113.7") -> harvest.HarvestTarget: + return harvest.HarvestTarget(name=name, public_ip=ip, nonce=NONCE) + + +class ParseArgsTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.dir = Path(self._tmp.name) + inputs = self.dir / manifest_mod.INPUTS_DIRNAME + inputs.mkdir() + (inputs / manifest_mod.MEASUREMENTS_FILENAME).write_text("{}") + (inputs / manifest_mod.FOUNDERS_FILENAME).write_text("{}") + self.nodes = self.dir / manifest_mod.NODES_DIRNAME + self.nodes.mkdir() + + def test_node_defaults_to_nodes_dir_sorted_skipping_bootnodes(self): + (self.nodes / "b.json").write_text("{}") + (self.nodes / "a.json").write_text("{}") + (self.nodes / bootnodes.BOOTNODES_FILENAME).write_text("{}") + args = harvest._parse_args([str(self.dir)]) + self.assertEqual(args.node, [self.nodes / "a.json", self.nodes / "b.json"]) + + def test_repeated_node_flag_accumulates(self): + n1 = self.nodes / "n1.json" + n2 = self.nodes / "n2.json" + n1.write_text("{}") + n2.write_text("{}") + args = harvest._parse_args( + [str(self.dir), "--node", str(n1), "--node", str(n2)] + ) + self.assertEqual(args.node, [n1, n2]) + + def test_duplicate_node_descriptor_rejected(self): + n1 = self.nodes / "n1.json" + n1.write_text("{}") + with self.assertRaises(SystemExit) as ctx: + harvest._parse_args([str(self.dir), "--node", str(n1), "--node", str(n1)]) + self.assertIn("duplicate", str(ctx.exception)) + + def test_same_stem_under_different_paths_rejected(self): + # Stems are the harvest's node names: two descriptors sharing one + # stem would silently collapse into a single harvested box. + other = self.dir / "elsewhere" + other.mkdir() + n1 = self.nodes / "n1.json" + twin = other / "n1.json" + n1.write_text("{}") + twin.write_text("{}") + with self.assertRaises(SystemExit) as ctx: + harvest._parse_args([str(self.dir), "--node", str(n1), "--node", str(twin)]) + self.assertIn("duplicate", str(ctx.exception)) + self.assertIn("n1", str(ctx.exception)) + + def test_empty_nodes_dir_errors(self): + with self.assertRaises(SystemExit) as ctx: + harvest._parse_args([str(self.dir)]) + self.assertIn("no --node given", str(ctx.exception)) + + def test_missing_founders_errors_with_authoring_hint(self): + inputs = self.dir / manifest_mod.INPUTS_DIRNAME + (inputs / manifest_mod.FOUNDERS_FILENAME).unlink() + with self.assertRaises(SystemExit) as ctx: + harvest._parse_args([str(self.dir)]) + self.assertIn(manifest_mod.FOUNDERS_FILENAME, str(ctx.exception)) + self.assertIn("withdrawal credentials", str(ctx.exception)) + + def test_missing_measurements_errors(self): + ( + self.dir / manifest_mod.INPUTS_DIRNAME / manifest_mod.MEASUREMENTS_FILENAME + ).unlink() + with self.assertRaises(SystemExit) as ctx: + harvest._parse_args([str(self.dir)]) + self.assertIn(manifest_mod.MEASUREMENTS_FILENAME, str(ctx.exception)) + + def test_missing_network_dir_errors(self): + with self.assertRaises(SystemExit) as ctx: + harvest._parse_args([str(self.dir / "absent")]) + self.assertIn("network directory", str(ctx.exception)) + + +class LoadFoundersTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.path = Path(self._tmp.name) / manifest_mod.FOUNDERS_FILENAME + + def _write(self, obj) -> None: + self.path.write_text(json.dumps(obj)) + + def test_exact_match_returns_map(self): + self._write({"node-1": ADDRESS, "node-2": ADDRESS}) + founders = harvest.load_founders(self.path, ["node-1", "node-2"]) + self.assertEqual(founders["node-1"], ADDRESS) + + def test_cohort_box_without_entry_aborts(self): + self._write({"node-1": ADDRESS}) + with self.assertRaises(SystemExit) as ctx: + harvest.load_founders(self.path, ["node-1", "node-2"]) + self.assertIn("no founder entry: node-2", str(ctx.exception)) + + def test_entry_without_cohort_box_aborts(self): + self._write({"node-1": ADDRESS, "node-9": ADDRESS}) + with self.assertRaises(SystemExit) as ctx: + harvest.load_founders(self.path, ["node-1"]) + self.assertIn("no cohort box: node-9", str(ctx.exception)) + + def test_malformed_address_aborts(self): + self._write({"node-1": "0x1234"}) + with self.assertRaises(SystemExit) as ctx: + harvest.load_founders(self.path, ["node-1"]) + self.assertIn("node-1", str(ctx.exception)) + + def test_non_object_aborts(self): + self._write([ADDRESS]) + with self.assertRaises(SystemExit) as ctx: + harvest.load_founders(self.path, ["node-1"]) + self.assertIn("expected a JSON object", str(ctx.exception)) + + +class FetchQuoteTests(unittest.TestCase): + def test_returns_quote_and_passes_nonce(self): + with mock.patch.object(harvest.requests, "get") as get: + get.return_value = response() + data = harvest.fetch_quote("203.0.113.7", NONCE) + self.assertEqual(data["node_public_key"], NODE_KEY) + get.assert_called_once() + self.assertEqual(get.call_args.kwargs["params"], {"nonce": NONCE}) + self.assertIn(f":{harvest.HOLDER_PORT}/v1/quote", get.call_args.args[0]) + + def test_410_raises_quote_window_closed(self): + with mock.patch.object(harvest.requests, "get") as get: + get.return_value = response(status_code=410) + with self.assertRaises(harvest.QuoteWindowClosed): + harvest.fetch_quote("203.0.113.7", NONCE) + + def test_malformed_keys_raise_value_error(self): + for body in ( + quote_body(node_key="0x" + NODE_KEY), # keystore format is bare hex + quote_body(node_key=NODE_KEY.upper()), + quote_body(consensus_key="cd" * 32), # wrong length + {"node_public_key": NODE_KEY, "consensus_public_key": CONSENSUS_KEY}, + ): + with mock.patch.object(harvest.requests, "get") as get: + get.return_value = response(body=body) + with self.assertRaises(ValueError): + harvest.fetch_quote("203.0.113.7", NONCE) + + +class CollectQuotesTests(unittest.TestCase): + def test_retries_transport_errors_until_quote_readable(self): + with mock.patch.object( + harvest, + "fetch_quote", + side_effect=[requests.ConnectionError("refused"), quote_body()], + ): + quotes = harvest.collect_quotes([target()], timeout=5, interval=0) + self.assertEqual(quotes["node-1"]["node_public_key"], NODE_KEY) + + def test_timeout_lists_only_stuck_boxes(self): + answered = quote_body() + + def fetch(ip, nonce, **_): + if ip == "203.0.113.7": + return answered + raise requests.ConnectionError("refused") + + targets = [target("node-1", "203.0.113.7"), target("node-2", "203.0.113.8")] + with mock.patch.object(harvest, "fetch_quote", side_effect=fetch): + with self.assertRaises(SystemExit) as ctx: + harvest.collect_quotes(targets, timeout=0, interval=0) + message = str(ctx.exception) + self.assertIn("node-2", message) + self.assertNotIn("✗ node-1", message) + + def test_quote_window_closed_burns_the_harvest(self): + with mock.patch.object( + harvest, "fetch_quote", side_effect=harvest.QuoteWindowClosed("url") + ): + with self.assertRaises(SystemExit) as ctx: + harvest.collect_quotes([target()], timeout=5, interval=0) + message = str(ctx.exception) + self.assertIn("burned", message) + self.assertIn("re-found", message) + + def test_client_error_fails_fast(self): + error = requests.HTTPError("HTTP 400") + error.response = mock.Mock(status_code=400) + with mock.patch.object(harvest, "fetch_quote", side_effect=error): + with self.assertRaises(SystemExit) as ctx: + harvest.collect_quotes([target()], timeout=5, interval=0) + self.assertIn("rejected", str(ctx.exception)) + + def test_server_error_is_retried(self): + error = requests.HTTPError("HTTP 500") + error.response = mock.Mock(status_code=500) + with mock.patch.object( + harvest, "fetch_quote", side_effect=[error, quote_body()] + ): + quotes = harvest.collect_quotes([target()], timeout=5, interval=0) + self.assertIn("node-1", quotes) + + +class AssertUniqueKeysTests(unittest.TestCase): + def test_distinct_keys_pass(self): + harvest.assert_unique_keys( + { + "node-1": quote_body(), + "node-2": quote_body(node_key="ef" * 32, consensus_key="ab" * 48), + } + ) + + def test_repeated_node_key_burns(self): + with self.assertRaises(SystemExit) as ctx: + harvest.assert_unique_keys( + { + "node-1": quote_body(), + "node-2": quote_body(consensus_key="ab" * 48), + } + ) + self.assertIn("node_public_key", str(ctx.exception)) + + +class VerifyQuoteTests(unittest.TestCase): + REPORT = {"verified": True, "attestation_type": "azure-tdx", "pcrs": {}} + + def _run(self, returncode=0, stdout=b"", stderr=b""): + completed = mock.Mock(returncode=returncode, stdout=stdout, stderr=stderr) + with mock.patch.object( + harvest.subprocess, "run", return_value=completed + ) as run: + report = harvest.verify_quote( + target(), + quote_body(), + Path("/tmp/policy.json"), + "verify-quote", + pccs_url=None, + override_azure_outdated_tcb=False, + ) + return report, run + + def test_success_returns_report_and_binds_nonce_and_pubkeys(self): + report, run = self._run(stdout=json.dumps(self.REPORT).encode()) + self.assertTrue(report["verified"]) + cmd = run.call_args.args[0] + for expected in (NONCE, NODE_KEY, CONSENSUS_KEY, "-"): + self.assertIn(expected, cmd) + # The evidence travels over stdin, byte-exact with the archive. + self.assertEqual(json.loads(run.call_args.kwargs["input"]), EVIDENCE) + + def test_nonzero_exit_burns_with_stderr(self): + with self.assertRaises(SystemExit) as ctx: + self._run(returncode=1, stderr=b"binding mismatch") + message = str(ctx.exception) + self.assertIn("burned", message) + self.assertIn("binding mismatch", message) + + def test_unverified_report_aborts(self): + with self.assertRaises(SystemExit): + self._run(stdout=b'{"verified": false}') + + def test_exit_zero_with_non_json_stdout_aborts(self): + with self.assertRaises(SystemExit) as ctx: + self._run(stdout=b"not json") + self.assertIn("without a verified report", str(ctx.exception)) + + def test_optional_flags_forwarded(self): + completed = mock.Mock( + returncode=0, stdout=json.dumps(self.REPORT).encode(), stderr=b"" + ) + with mock.patch.object( + harvest.subprocess, "run", return_value=completed + ) as run: + harvest.verify_quote( + target(), + quote_body(), + Path("/tmp/policy.json"), + "verify-quote", + pccs_url="https://pccs.example", + override_azure_outdated_tcb=True, + ) + cmd = run.call_args.args[0] + self.assertIn("https://pccs.example", cmd) + self.assertIn("--override-azure-outdated-tcb", cmd) + + +class ArchiveTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.harvest_dir = Path(self._tmp.name) / manifest_mod.HARVEST_DIRNAME + + def test_save_writes_one_pretty_json_per_box(self): + record = { + "harvest_nonce": NONCE, + "node_public_key": NODE_KEY, + "consensus_public_key": CONSENSUS_KEY, + "evidence": EVIDENCE, + "harvested_at": "2026-08-04T00:00:00+00:00", + "verification": {"verified": True}, + } + written = harvest.save_harvest(self.harvest_dir, {"node-1": record}) + self.assertEqual(written, [self.harvest_dir / "node-1.json"]) + text = written[0].read_text() + self.assertTrue(text.endswith("\n")) + self.assertEqual(json.loads(text), record) + + def test_check_overwrite_refuses_existing_without_force(self): + self.harvest_dir.mkdir(parents=True) + (self.harvest_dir / "node-1.json").write_text("{}") + with self.assertRaises(SystemExit) as ctx: + harvest.check_overwrite(self.harvest_dir, ["node-1", "node-2"], False) + message = str(ctx.exception) + self.assertIn("node-1", message) + self.assertIn("--force", message) + + def test_check_overwrite_allows_force_and_fresh_dirs(self): + harvest.check_overwrite(self.harvest_dir, ["node-1"], False) + self.harvest_dir.mkdir(parents=True) + (self.harvest_dir / "node-1.json").write_text("{}") + harvest.check_overwrite(self.harvest_dir, ["node-1"], True) + + +if __name__ == "__main__": + unittest.main()