diff --git a/tee/README.md b/tee/README.md index 8b3ddec1..5dea7fe4 100644 --- a/tee/README.md +++ b/tee/README.md @@ -29,8 +29,8 @@ for its [InitConfig](https://github.com/SeismicSystems/enclave/blob/seismic/crat never provisions; it consumes a descriptor of an already-running node. VHD upload to the Azure image registry is a seismic-images concern (`make push-azure-*`), not this tool. -- **For `seismic-tee-network genesis-ceremony`:** the `genesis` binary (built from - summit) on PATH. (The summit genesis template defaults to the +- **For `seismic-tee-network genesis-ceremony`:** the `summit` binary on + PATH (its `genesis` subcommands). (The summit genesis defaults to the artifact-set copy beside `--manifest`.) - **For `seismic-tee-network up`** (cohort provisioning): the `pulumi` CLI on PATH. The `seismic_node` project pins ephemeral bootstrap stacks to @@ -131,11 +131,12 @@ uv run seismic-tee-node configure --node /tmp/node.json \ dials it on startup, and tdx-init derives the `root_key` fetch peer from it (`http://:7878`) — one list, so the two can't skew. The `--manifest` is the network identity doc you're given for the network you're joining; it -pins the `network_id`. The reth genesis JSON is POSTed alongside it (from -`--reth-genesis`, defaulting to `reth-genesis.json` beside the manifest — -the artifact-set layout); tdx-init writes it for reth's `--chain`. You -never run `seismic-tee-network` — that's Seismic-internal network -creation. +pins the `network_id`. The reth and summit genesis files are POSTed +alongside it (from `--reth-genesis`/`--summit-genesis`, defaulting to +`reth-genesis.json`/`summit-genesis.toml` beside the manifest — the +artifact-set layout); tdx-init writes them for reth's `--chain` and +summit's `--genesis-path`. You never run `seismic-tee-network` — that's +Seismic-internal network creation. ### Creating a new network (genesis ceremony) @@ -152,23 +153,23 @@ template identity that can also found a throwaway test devnet (see # and publish it + its measurements. This tool does not build. # 1. Create the network directory: `init` scaffolds the three authored -# inputs (reth genesis, summit template, measurements) under inputs/; +# inputs (reth genesis, summit genesis params, measurements) under inputs/; # you edit them; then `assemble --dir` derives the artifact set -# (manifest, injected genesis, filled template, promoted policy) into -# the directory's top level. Everything top-level is hash-pinned by the -# manifest; everything under inputs/ is provenance. Edits go to the -# *inputs* + re-assemble — the manifest itself is pure output, never -# hand-edited. -# Commit the directory for any network that matters: a fresh assemble -# mints a fresh genesis_nonce, so the same network_id can never be -# regenerated from the inputs — the directory is everything needed to -# (re)configure, join, or debug the network later. assemble needs -# `seismic-reth` on PATH for the offline genesis-hash gate. +# (manifest, injected genesis, completed summit genesis, promoted +# policy) into the directory's top level. Everything top-level is +# hash-pinned by the manifest; everything under inputs/ is provenance. +# Edits go to the *inputs* + re-assemble — the manifest itself is pure +# output, never hand-edited. +# Commit the directory for any network that matters: it is everything +# needed to (re)configure, join, or debug the network later, and its +# manifest is the network's immutable identity. assemble needs +# `seismic-reth` on PATH for the offline genesis-hash gate and `summit` +# for the genesis config digest. uv run seismic-tee-network manifest init tee/networks/devnet-3 \ --reth-genesis ../seismic-reth/crates/seismic/chainspec/res/genesis/dev.json \ --measurements ../seismic-images/build/measurements.json \ --measurement-id seismic_2026-06-11.abc123.vhd -# → edit tee/networks/devnet-3/inputs/summit-genesis-template.toml +# → edit tee/networks/devnet-3/inputs/summit-genesis.toml # (namespace, …) uv run seismic-tee-network manifest assemble tee/networks/devnet-3 @@ -202,7 +203,7 @@ uv run seismic-tee-network configure \ --manifest tee/networks/devnet-3/network-manifest.json # 4. Run the genesis ceremony once: builds genesis.toml from the cohort -# (needs the `genesis` binary on PATH) and fans it out to every summit. +# (needs the `summit` binary on PATH) and fans it out to every summit. # Waits for cohort readiness itself in two stages: each node's reth must # serve the manifest's genesis hash as block 0, then every node's Summit # pubkeys must be readable. `configure` normally waits for root-key bootstrap @@ -213,9 +214,9 @@ uv run seismic-tee-network configure \ # while provisioning is active. Elapsed and remaining time are shown while # waiting, and the command can run straight after step 3. # One-shot: send_genesis only exists in the pre-genesis node API, so this -# can never be re-run against a live network. --summit-template defaults +# can never be re-run against a live network. --summit-genesis defaults # to the artifact-set copy beside --manifest (verified against the -# manifest's summit.genesis_template_hash); --node defaults to the +# manifest's summit.genesis_config_digest); --node defaults to the # descriptors in the nodes/ dir beside it. uv run seismic-tee-network genesis-ceremony \ --manifest tee/networks/devnet-3/network-manifest.json diff --git a/tee/cli/common/manifest.py b/tee/cli/common/manifest.py index 8b7a2a0c..f7968b10 100644 --- a/tee/cli/common/manifest.py +++ b/tee/cli/common/manifest.py @@ -15,7 +15,7 @@ by the manifest; everything under `inputs/` is provenance: inputs/reth-genesis.json policy-free genesis - inputs/summit-genesis-template.toml summit parameter choices + inputs/summit-genesis.toml summit parameter choices inputs/measurements.json raw PCR map from `make measure` network-manifest.json deploy-time facts; SHA-256 = network_id @@ -24,15 +24,17 @@ injected into the registry account (the accepted admission IDs are a per-network fact); eth.genesis_hash - summit-genesis-template.toml the input template with eth_genesis_hash - filled; summit.genesis_template_hash + summit-genesis.toml the complete summit genesis every node + boots from: the input with + eth_genesis_hash filled and a validators + set; summit.genesis_config_digest measurement-policy.json Flashbots-compatible measurement allowlist promoted from the raw measurements; bootstrap_policy_hash -An input sharing its artifact's basename is the same format with derived -fields filled in at assemble time; the raw measurements become -measurement-policy.json because promotion is a format transformation. +Each artifact is its input with derived fields filled in at assemble time; +the raw measurements become measurement-policy.json because promotion is a +format transformation. Usage (one directory per network: `init` gathers the authored inputs — the only command that takes loose files — then `assemble`/`validate` operate on @@ -42,7 +44,7 @@ --reth-genesis dev.json \ --measurements ../seismic-images/build/measurements.json \ --measurement-id seismic_2026-06-11.abc123.vhd - # edit tee/networks/seismic-devnet-3/inputs/summit-genesis-template.toml: + # edit tee/networks/seismic-devnet-3/inputs/summit-genesis.toml: uv run python -m tee.cli.common.manifest assemble tee/networks/seismic-devnet-3 uv run python -m tee.cli.common.manifest validate tee/networks/seismic-devnet-3 """ @@ -53,7 +55,6 @@ import json import logging import re -import secrets import subprocess import sys import tempfile @@ -90,6 +91,14 @@ # a second one in Python. DEFAULT_ADMISSION_BIN = "seismic-measurement-admission" +# Summit's node binary; its `genesis digest` subcommand computes +# summit.genesis_config_digest. The digest is SHA-256 over summit's +# domain-prefixed SSZ serialization of the complete genesis — summit's own +# definition of chain identity (its P2P and signing domains derive from it) — +# so deploy shells out to the one implementation instead of mirroring the +# SSZ layout in Python. +DEFAULT_SUMMIT_BIN = "summit" + # Today's hardcoded summit BLS domain separator. # two chains sharing it can cross-replay BLS signatures. # TODO: make it configurable @@ -98,7 +107,10 @@ MANIFEST_FILENAME = "network-manifest.json" POLICY_FILENAME = "measurement-policy.json" RETH_GENESIS_FILENAME = "reth-genesis.json" -SUMMIT_TEMPLATE_FILENAME = "summit-genesis-template.toml" +# Both the authored input (under inputs/) and the shipped artifact (at the +# network directory top level) use this basename: same format, the +# artifact being the input with the derived fields filled in. +SUMMIT_GENESIS_FILENAME = "summit-genesis.toml" MEASUREMENTS_FILENAME = "measurements.json" # Authored inputs live under this subdir of a network directory: `manifest @@ -197,7 +209,6 @@ def validate_manifest_schema(manifest_bytes: bytes) -> dict[str, Any]: { "manifest_version", "name", - "genesis_nonce", "eth", "summit", "measurements", @@ -206,7 +217,6 @@ def validate_manifest_schema(manifest_bytes: bytes) -> dict[str, Any]: ) if not isinstance(obj["name"], str): raise ManifestSchemaError(f"name: expected string, got {obj['name']!r}") - _check_hex(obj["genesis_nonce"], 32, "genesis_nonce") eth = obj["eth"] if not isinstance(eth, dict): @@ -222,8 +232,8 @@ def validate_manifest_schema(manifest_bytes: bytes) -> dict[str, Any]: summit = obj["summit"] if not isinstance(summit, dict): raise ManifestSchemaError("summit: expected object") - _check_keys(summit, {"genesis_template_hash", "namespace"}, "summit") - _check_hex(summit["genesis_template_hash"], 32, "summit.genesis_template_hash") + _check_keys(summit, {"genesis_config_digest", "namespace"}, "summit") + _check_hex(summit["genesis_config_digest"], 32, "summit.genesis_config_digest") if not isinstance(summit["namespace"], str): raise ManifestSchemaError( f"summit.namespace: expected string, got {summit['namespace']!r}" @@ -348,11 +358,12 @@ def inject_registry_genesis_storage( return (json.dumps(genesis, indent=2) + "\n").encode("utf-8") -def _genesis_hash_of_bytes(genesis_bytes: bytes, hash_fn: Callable[[Path], str]) -> str: - """Hash a genesis that exists only as bytes (assemble's injected copy): - materialize it for the path-based `seismic-reth genesis-hash` shell-out.""" - with tempfile.NamedTemporaryFile(suffix=".json") as tf: - tf.write(genesis_bytes) +def _hash_of_bytes(data: bytes, hash_fn: Callable[[Path], str], suffix: str) -> str: + """Hash an artifact that exists only as bytes (assemble's derived copies): + materialize it for the path-based shell-outs (`seismic-reth genesis-hash`, + `summit genesis digest`).""" + with tempfile.NamedTemporaryFile(suffix=suffix) as tf: + tf.write(data) tf.flush() return hash_fn(Path(tf.name)) @@ -382,6 +393,34 @@ def reth_genesis_hash(reth_genesis: Path, reth_bin: str = "seismic-reth") -> str return out.lower() +def summit_config_digest( + summit_genesis: Path, summit_bin: str = DEFAULT_SUMMIT_BIN +) -> str: + """Compute summit.genesis_config_digest offline via `summit genesis digest`. + + The file is loaded down the same parse path a starting validator takes, so + a successful digest doubles as a verdict that the genesis is well formed: + anything this accepts a validator accepts. + """ + cmd = [summit_bin, "genesis", "digest", str(summit_genesis)] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120, check=True + ) + except FileNotFoundError: + raise GateError( + f"{summit_bin!r} not found; build summit (the `genesis digest` " + "subcommand) or pass --summit-bin" + ) from None + except subprocess.CalledProcessError as e: + raise GateError( + f"`{' '.join(cmd)}` failed: {e.stderr.strip() or e.stdout.strip()}" + ) from None + out = result.stdout.strip() + _check_hex_or_gate(out, 32, f"`{' '.join(cmd)}` output") + return out.lower() + + def _check_hex_or_gate(value: Any, nbytes: int, fieldname: str) -> None: try: _check_hex(value, nbytes, fieldname) @@ -394,18 +433,20 @@ class GateContext: """Artifact set a manifest is validated against (deploy-side gates).""" reth_genesis: Path - summit_template: Path + summit_genesis: Path policy_bytes: bytes reth_bin: str = "seismic-reth" admission_bin: str = DEFAULT_ADMISSION_BIN - # Injectable for tests; default to shelling out to seismic-reth and the - # admission CLI respectively. + summit_bin: str = DEFAULT_SUMMIT_BIN + # Injectable for tests; default to shelling out to seismic-reth, the + # admission CLI, and summit respectively. genesis_hash_fn: Callable[[Path], str] | None = None compile_fn: Callable[[bytes], dict[str, Any]] | None = None - # Set by `assemble` to its filled template copy (eth_genesis_hash injected - # when the authored file omits it); gates then check these bytes instead - # of re-reading summit_template from disk. - summit_template_bytes: bytes | None = None + digest_fn: Callable[[Path], str] | None = None + # Set by `assemble` to its completed summit genesis (eth_genesis_hash and + # a validators set injected into the authored input); gates then check + # these bytes instead of re-reading summit_genesis from disk. + summit_genesis_bytes: bytes | None = None # Set by `assemble` to its copy of the full genesis document, with the # compiled registry storage injected into the registry account; gates # then check these bytes instead of re-reading reth_genesis from disk. @@ -446,7 +487,7 @@ def run_validation_gates(manifest: dict[str, Any], ctx: GateContext) -> None: lambda p: reth_genesis_hash(p, reth_bin=ctx.reth_bin) ) computed = ( - _genesis_hash_of_bytes(genesis_bytes, hash_fn) + _hash_of_bytes(genesis_bytes, hash_fn, suffix=".json") if ctx.reth_genesis_bytes is not None else hash_fn(ctx.reth_genesis) ) @@ -457,48 +498,53 @@ def run_validation_gates(manifest: dict[str, Any], ctx: GateContext) -> None: f"from {ctx.reth_genesis}" ) - # summit.genesis_template_hash == SHA-256(template bytes) - template_bytes = ( - ctx.summit_template_bytes - if ctx.summit_template_bytes is not None - else ctx.summit_template.read_bytes() + # summit.genesis_config_digest == `summit genesis digest` over the shipped + # summit genesis: summit's own SSZ-domain digest (what its P2P and signing + # domains derive from), not a byte hash of the file. + summit_genesis_bytes = ( + ctx.summit_genesis_bytes + if ctx.summit_genesis_bytes is not None + else ctx.summit_genesis.read_bytes() + ) + digest_fn = ctx.digest_fn or ( + lambda p: summit_config_digest(p, summit_bin=ctx.summit_bin) ) - template_hash = _sha256_hex(template_bytes) - if manifest["summit"]["genesis_template_hash"] != template_hash: + computed_digest = ( + _hash_of_bytes(summit_genesis_bytes, digest_fn, suffix=".toml") + if ctx.summit_genesis_bytes is not None + else digest_fn(ctx.summit_genesis) + ) + if manifest["summit"]["genesis_config_digest"] != computed_digest: raise GateError( - f"summit.genesis_template_hash mismatch: manifest has " - f"{manifest['summit']['genesis_template_hash']}, computed " - f"{template_hash} from {ctx.summit_template}" + f"summit.genesis_config_digest mismatch: manifest has " + f"{manifest['summit']['genesis_config_digest']}, recomputed " + f"{computed_digest} from {ctx.summit_genesis}" ) - # The template's embedded eth_genesis_hash and namespace must match the + # The genesis's embedded eth_genesis_hash and namespace must match the # manifest fields (the namespace is duplicated into the manifest so # verifiers don't need to parse TOML). - template = tomllib.loads(template_bytes.decode("utf-8")) - template_eth_hash = template.get("eth_genesis_hash") + summit_genesis = tomllib.loads(summit_genesis_bytes.decode("utf-8")) + genesis_eth_hash = summit_genesis.get("eth_genesis_hash") if ( - not isinstance(template_eth_hash, str) - or template_eth_hash.lower() != manifest["eth"]["genesis_hash"] + not isinstance(genesis_eth_hash, str) + or genesis_eth_hash.lower() != manifest["eth"]["genesis_hash"] ): raise GateError( - f"summit template eth_genesis_hash is {template_eth_hash!r}, " + f"summit genesis eth_genesis_hash is {genesis_eth_hash!r}, " f"manifest has {manifest['eth']['genesis_hash']}" ) - template_namespace = template.get("namespace") - if template_namespace != manifest["summit"]["namespace"]: + genesis_namespace = summit_genesis.get("namespace") + if genesis_namespace != manifest["summit"]["namespace"]: raise GateError( - f"summit template namespace is {template_namespace!r}, " + f"summit genesis namespace is {genesis_namespace!r}, " f"manifest has {manifest['summit']['namespace']!r}" ) - if template_namespace == _SUMMIT_DEFAULT_NAMESPACE: + if genesis_namespace == _SUMMIT_DEFAULT_NAMESPACE: ctx.warn( "summit namespace is the hardcoded default '_SUMMIT'; two chains " "running the same image can cross-replay BLS signatures" ) - # The shipped copy always carries a `validators` key (assemble fills an - # empty placeholder for summit's parser); only *entries* are suspect. - if template.get("validators"): - ctx.warn("summit genesis template must not contain [[validators]] entries") # measurements.bootstrap_policy_hash == SHA-256(policy bytes) policy_hash = _sha256_hex(ctx.policy_bytes) @@ -638,9 +684,10 @@ class AssembledManifest: manifest_bytes: bytes manifest: dict[str, Any] policy_bytes: bytes - # The template copy the manifest commits to (eth_genesis_hash filled if - # the authored file omitted it) — what write_artifact_set ships. - summit_template_bytes: bytes + # The completed summit genesis the manifest commits to (eth_genesis_hash + # and a validators set filled into the authored input) — what + # write_artifact_set ships as summit-genesis.toml. + summit_genesis_bytes: bytes # The genesis copy the manifest commits to (compiled registry genesis # storage injected into the registry account) — what write_artifact_set # ships and eth.genesis_hash is computed from. @@ -649,19 +696,20 @@ class AssembledManifest: warnings: list[str] -def fill_template_genesis_hash(template_bytes: bytes, eth_genesis_hash: str) -> bytes: - """Set `eth_genesis_hash` in a summit template to the computed value. +def fill_eth_genesis_hash(genesis_bytes: bytes, eth_genesis_hash: str) -> bytes: + """Set `eth_genesis_hash` in an authored summit genesis to the computed + value. The hash is derived from reth-genesis.json — never authored — but summit's genesis-binary parser requires the field to be present in the TOML it reads, so the shipped copy must carry it. Any declared value is dropped (it can only be stale copy-paste, e.g. summit's example_genesis.toml) and the computed one is prepended — always valid TOML for a top-level key, and - deterministic, so the filled copy is what `genesis_template_hash` commits - to and the ceremony ships. + deterministic, so the filled copy is what `genesis_config_digest` commits + to and the artifact set ships. """ lines, in_table = [], False - for line in template_bytes.splitlines(keepends=True): + for line in genesis_bytes.splitlines(keepends=True): stripped = line.lstrip() # Top-level keys can only appear before the first table header; a # same-named key inside a table (none exists today) is left alone. @@ -673,7 +721,7 @@ def fill_template_genesis_hash(template_bytes: bytes, eth_genesis_hash: str) -> stripped_bytes = b"".join(lines) if "eth_genesis_hash" in tomllib.loads(stripped_bytes.decode("utf-8")): raise GateError( - "could not replace the template's declared eth_genesis_hash " + "could not replace the authored file's declared eth_genesis_hash " "(unusual TOML layout); delete the line by hand — the value is " "derived from the reth genesis" ) @@ -683,26 +731,27 @@ def fill_template_genesis_hash(template_bytes: bytes, eth_genesis_hash: str) -> def assemble( name: str, reth_genesis: Path, - summit_template: Path, + summit_genesis: Path, policy_bytes: bytes, registry: str = DEFAULT_REGISTRY, authority: str = DEFAULT_AUTHORITY, reth_bin: str = "seismic-reth", admission_bin: str = DEFAULT_ADMISSION_BIN, - genesis_nonce: bytes | None = None, + summit_bin: str = DEFAULT_SUMMIT_BIN, genesis_hash_fn: Callable[[Path], str] | None = None, compile_fn: Callable[[bytes], dict[str, Any]] | None = None, + digest_fn: Callable[[Path], str] | None = None, ) -> AssembledManifest: """Assemble, render, and gate-check a v1 network manifest. - genesis_nonce defaults to fresh OsRng bytes — the clone-deployment - uniquifier; two networks spun from otherwise identical artifacts must not - share a network_id. Only tests should pass an explicit nonce. - - The summit template's `eth_genesis_hash` is derived from reth-genesis.json, + The summit genesis's `eth_genesis_hash` is derived from reth-genesis.json, never authored: whatever the input declares (if anything) is replaced with - the computed value in the copy that `genesis_template_hash` commits to and - the artifact set ships — committed bytes never carry a stale hash. + the computed value in the completed copy the artifact set ships — + committed bytes never carry a stale hash. The manifest pins that + completed genesis via summit's own config digest (`summit genesis + digest`), which covers the consensus parameters and the validator set; + an input without validators ships an empty placeholder set + (`validators = []`). The registry account's genesis storage is likewise derived, not authored: the policy document is compiled and its registry_genesis_storage injected @@ -716,11 +765,11 @@ def assemble( if not isinstance(chain_id, int) or isinstance(chain_id, bool): raise GateError(f"reth genesis config.chainId is {chain_id!r}, not an int") - template_bytes = summit_template.read_bytes() - template = tomllib.loads(template_bytes.decode("utf-8")) - namespace = template.get("namespace") + authored_bytes = summit_genesis.read_bytes() + authored = tomllib.loads(authored_bytes.decode("utf-8")) + namespace = authored.get("namespace") if not isinstance(namespace, str): - raise GateError(f"summit template has no namespace string (got {namespace!r})") + raise GateError(f"summit genesis has no namespace string (got {namespace!r})") compile_policy = compile_fn or ( lambda b: compile_measurement_policy(b, admission_bin=admission_bin) @@ -730,35 +779,36 @@ def assemble( ) hash_fn = genesis_hash_fn or (lambda p: reth_genesis_hash(p, reth_bin=reth_bin)) - eth_hash = _genesis_hash_of_bytes(reth_genesis_bytes, hash_fn).lower() - if "eth_genesis_hash" in template and template["eth_genesis_hash"] != eth_hash: + eth_hash = _hash_of_bytes(reth_genesis_bytes, hash_fn, suffix=".json").lower() + if "eth_genesis_hash" in authored and authored["eth_genesis_hash"] != eth_hash: logger.info( - "replacing the template's declared eth_genesis_hash %s with the " + "replacing the authored eth_genesis_hash %s with the " "computed %s (the value is derived from the reth genesis)", - template["eth_genesis_hash"], + authored["eth_genesis_hash"], eth_hash, ) - if "validators" not in template: - # summit's genesis binary requires the field to *parse* the template - # (its GenesisConfig has no serde default) even though it replaces the - # value from -v; authored templates rightly omit validators, so the - # shipped copy carries an empty placeholder set. - template_bytes = b"validators = []\n" + template_bytes - template_bytes = fill_template_genesis_hash(template_bytes, eth_hash) - nonce = genesis_nonce if genesis_nonce is not None else secrets.token_bytes(32) - if len(nonce) != 32: - raise GateError(f"genesis_nonce must be 32 bytes, got {len(nonce)}") + if "validators" not in authored: + # summit requires the field to *parse* a genesis (its Genesis type has + # no serde default); an input authored without a validator set ships + # an empty placeholder. + authored_bytes = b"validators = []\n" + authored_bytes + summit_genesis_bytes = fill_eth_genesis_hash(authored_bytes, eth_hash) + resolve_digest = digest_fn or ( + lambda p: summit_config_digest(p, summit_bin=summit_bin) + ) + config_digest = _hash_of_bytes( + summit_genesis_bytes, resolve_digest, suffix=".toml" + ).lower() manifest = { "manifest_version": MANIFEST_VERSION, "name": name, - "genesis_nonce": "0x" + nonce.hex(), "eth": { "chain_id": chain_id, "genesis_hash": eth_hash, }, "summit": { - "genesis_template_hash": _sha256_hex(template_bytes), + "genesis_config_digest": config_digest, "namespace": namespace, }, "measurements": { @@ -776,13 +826,15 @@ def assemble( parsed = validate_manifest_schema(manifest_bytes) ctx = GateContext( reth_genesis=reth_genesis, - summit_template=summit_template, + summit_genesis=summit_genesis, policy_bytes=policy_bytes, reth_bin=reth_bin, admission_bin=admission_bin, + summit_bin=summit_bin, genesis_hash_fn=genesis_hash_fn, compile_fn=compile_fn, - summit_template_bytes=template_bytes, + digest_fn=digest_fn, + summit_genesis_bytes=summit_genesis_bytes, reth_genesis_bytes=reth_genesis_bytes, ) run_validation_gates(parsed, ctx) @@ -791,7 +843,7 @@ def assemble( manifest_bytes=manifest_bytes, manifest=parsed, policy_bytes=policy_bytes, - summit_template_bytes=template_bytes, + summit_genesis_bytes=summit_genesis_bytes, reth_genesis_bytes=reth_genesis_bytes, network_id=compute_network_id(manifest_bytes), warnings=ctx.warnings, @@ -805,8 +857,8 @@ def write_artifact_set( ) -> None: """Write the network artifact set: manifest, policy, and assemble's copies of the genesis artifacts the manifest commits to (registry - storage injected into the reth genesis, eth_genesis_hash filled into - the summit template). + storage injected into the reth genesis, the authored summit genesis + completed with its derived fields). A manifest is immutable for the network's lifetime — refuse to overwrite an existing one unless forced. @@ -822,21 +874,21 @@ def write_artifact_set( manifest_path.write_bytes(assembled.manifest_bytes) (out_dir / POLICY_FILENAME).write_bytes(assembled.policy_bytes) (out_dir / RETH_GENESIS_FILENAME).write_bytes(assembled.reth_genesis_bytes) - (out_dir / SUMMIT_TEMPLATE_FILENAME).write_bytes(assembled.summit_template_bytes) + (out_dir / SUMMIT_GENESIS_FILENAME).write_bytes(assembled.summit_genesis_bytes) -def starter_summit_template(name: str) -> str: - """Starter authored summit template written by `manifest init` (values +def starter_summit_genesis(name: str) -> str: + """Starter authored summit genesis written by `manifest init` (values from summit's example_genesis.toml). Every value is a per-network choice for the founder to review; nothing in it is derived. """ # json.dumps emits a valid TOML basic string for these simple values. return f"""\ -# Summit network-params template — authored input for `manifest assemble`. -# Review every value before founding a real network. Two fields are filled -# elsewhere and do not belong here: eth_genesis_hash (derived from -# reth-genesis.json at assemble time) and [[validators]] (TEE-born, filled -# by the genesis ceremony). +# Summit network parameters. `manifest assemble` prepends the two derived +# fields above this header — eth_genesis_hash (from reth-genesis.json) and +# validators (the founding validator set — TEE-born keys gathered from the +# live cohort) — and ships the completed file as summit-genesis.toml. +# Review every value before founding a real network. leader_timeout_ms = 2000 notarization_timeout_ms = 4000 nullify_timeout_ms = 4000 @@ -882,13 +934,13 @@ def init_network_dir( name: str, reth_genesis: Path, measurements: Path, - summit_template: Path | None = None, + summit_genesis: Path | None = None, measurement_id: str | None = None, ) -> list[Path]: """Scaffold a network directory's three authored inputs under inputs/. Copies the genesis and measurements in (stamping measurement_id into - the latter when given), and writes a starter summit template + the latter when given), and writes a starter summit genesis (namespace = name) unless one is supplied to copy. The founder edits these in place, then `assemble --dir` derives the artifact set into the directory's top level — inputs and the committed artifacts live @@ -901,10 +953,10 @@ def init_network_dir( contents = { RETH_GENESIS_FILENAME: reth_genesis.read_bytes(), MEASUREMENTS_FILENAME: measurements_bytes, - SUMMIT_TEMPLATE_FILENAME: ( - summit_template.read_bytes() - if summit_template is not None - else starter_summit_template(name).encode() + SUMMIT_GENESIS_FILENAME: ( + summit_genesis.read_bytes() + if summit_genesis is not None + else starter_summit_genesis(name).encode() ), } inputs_dir = out_dir / INPUTS_DIRNAME @@ -926,14 +978,16 @@ def init_network_dir( def render_network_section( manifest_bytes: bytes, reth_genesis_bytes: bytes, + summit_genesis_bytes: bytes, bootnodes: list[str], ) -> str: """Render the `[network]` config section tdx-init consumes. - base64 keeps both artifacts opaque through the TOML hop (byte-exactness + base64 keeps the artifacts opaque through the TOML hop (byte-exactness rule): tdx-init decodes and writes these exact bytes verbatim — the - manifest to `network-manifest.json`, the genesis to `reth-genesis.json` - (reth's `--chain`). + manifest to `network-manifest.json`, the reth genesis to + `reth-genesis.json` (reth's `--chain`), the summit genesis to + `summit-genesis.toml` (summit's `--genesis-path`). `bootnodes` is the enode set feeding reth's `--bootnodes` and — derived by tdx-init, `http://:7878` with the node's own entry dropped — the @@ -943,12 +997,14 @@ def render_network_section( case), and 400s for a joiner. """ manifest_b64 = base64.standard_b64encode(manifest_bytes).decode("ascii") - genesis_b64 = base64.standard_b64encode(reth_genesis_bytes).decode("ascii") + reth_b64 = base64.standard_b64encode(reth_genesis_bytes).decode("ascii") + summit_b64 = base64.standard_b64encode(summit_genesis_bytes).decode("ascii") # json.dumps emits valid TOML basic strings for enode URLs (ASCII). bootnodes_toml = ", ".join(json.dumps(b) for b in bootnodes) return ( f'[network]\nmanifest_base64 = "{manifest_b64}"\n' - f'reth_genesis_base64 = "{genesis_b64}"\n' + f'reth_genesis_base64 = "{reth_b64}"\n' + f'summit_genesis_base64 = "{summit_b64}"\n' f"bootnodes = [{bootnodes_toml}]\n" ) @@ -977,6 +1033,30 @@ def validate_reth_genesis_matches( ) +def validate_summit_genesis_matches( + manifest: dict[str, Any], summit_genesis_bytes: bytes +) -> None: + """Client-side mirror of tdx-init's POST-time summit-genesis check: valid + TOML whose namespace equals the manifest's summit.namespace. Structural + only — the *digest* commitment (manifest summit.genesis_config_digest) is + enforced by `assemble`/`validate` (via `summit genesis digest`), and live + nodes enforce agreement again by deriving their P2P and signing domains + from that digest. + """ + try: + genesis = tomllib.loads(summit_genesis_bytes.decode("utf-8")) + except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e: + raise GateError(f"summit genesis is not valid TOML: {e}") from None + namespace = genesis.get("namespace") + if not isinstance(namespace, str): + raise GateError(f"summit genesis namespace is {namespace!r}, not a string") + if namespace != manifest["summit"]["namespace"]: + raise GateError( + f"summit genesis namespace {namespace!r} does not match the " + f"manifest's summit.namespace {manifest['summit']['namespace']!r}" + ) + + def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="python -m tee.cli.common.manifest", description=__doc__ @@ -998,14 +1078,22 @@ def add_admission_bin(p: argparse.ArgumentParser) -> None: "compile the policy into registry genesis storage", ) + def add_summit_bin(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--summit-bin", + default=DEFAULT_SUMMIT_BIN, + help="summit binary whose `genesis digest` subcommand computes " + "summit.genesis_config_digest", + ) + ini = sub.add_parser("init", help="scaffold a network directory's authored inputs") ini.add_argument("dir", type=Path, help="network directory to create") ini.add_argument( "--name", default=None, - help="network name for the starter template's namespace; default: the " - "directory's basename (which is also what assemble uses as the " - "manifest name)", + help="network name for the starter summit genesis's namespace; " + "default: the directory's basename (which is also what assemble " + "uses as the manifest name)", ) ini.add_argument( "--reth-genesis", @@ -1024,12 +1112,12 @@ def add_admission_bin(p: argparse.ArgumentParser) -> None: "the PCRs of a real published image, never generated", ) ini.add_argument( - "--summit-template", + "--summit-genesis", type=Path, default=None, - help="summit template to copy in verbatim. Optional: unlike the two " - "inputs above it holds only per-network parameter choices, so the " - "default writes an editable starter with namespace = ", + help="authored summit genesis to copy in verbatim. Optional: unlike " + "the two inputs above it holds only per-network parameter choices, " + "so the default writes an editable starter with namespace = ", ) ini.add_argument( "--measurement-id", @@ -1048,7 +1136,7 @@ def add_admission_bin(p: argparse.ArgumentParser) -> None: type=Path, help=f"network directory from `manifest init`: reads its " f"{INPUTS_DIRNAME}/ ({RETH_GENESIS_FILENAME}, " - f"{SUMMIT_TEMPLATE_FILENAME}, {MEASUREMENTS_FILENAME}), takes the " + f"{SUMMIT_GENESIS_FILENAME}, {MEASUREMENTS_FILENAME}), takes the " "network name from its basename, and writes the artifact set at " "the top level", ) @@ -1075,6 +1163,7 @@ def add_admission_bin(p: argparse.ArgumentParser) -> None: ) add_reth_bin(asm) add_admission_bin(asm) + add_summit_bin(asm) val = sub.add_parser( "validate", help="re-run all gates over an assembled network directory" @@ -1083,10 +1172,11 @@ def add_admission_bin(p: argparse.ArgumentParser) -> None: "dir", type=Path, help="network directory: audits the artifact set `assemble` wrote " - "there (manifest, shipped template, policy) against its reth genesis", + "there (manifest, summit genesis, policy) against its reth genesis", ) add_reth_bin(val) add_admission_bin(val) + add_summit_bin(val) args = parser.parse_args(argv) @@ -1096,13 +1186,13 @@ def add_admission_bin(p: argparse.ArgumentParser) -> None: args.name = args.dir.resolve().name inputs_dir = args.dir / INPUTS_DIRNAME args.reth_genesis = inputs_dir / RETH_GENESIS_FILENAME - args.summit_template = inputs_dir / SUMMIT_TEMPLATE_FILENAME + args.summit_genesis = inputs_dir / SUMMIT_GENESIS_FILENAME args.measurements = inputs_dir / MEASUREMENTS_FILENAME args.out = args.dir elif args.command == "validate": args.manifest = args.dir / MANIFEST_FILENAME args.reth_genesis = args.dir / RETH_GENESIS_FILENAME - args.summit_template = args.dir / SUMMIT_TEMPLATE_FILENAME + args.summit_genesis = args.dir / SUMMIT_GENESIS_FILENAME args.measurement_policy = args.dir / POLICY_FILENAME return args @@ -1117,7 +1207,7 @@ def main() -> None: args.name, args.reth_genesis, args.measurements, - args.summit_template, + args.summit_genesis, args.measurement_id, ) for path in written: @@ -1129,13 +1219,13 @@ def main() -> None: ) print( f"Scaffolded {args.dir}. Edit the inputs (at minimum review " - f"{INPUTS_DIRNAME}/{SUMMIT_TEMPLATE_FILENAME}), then:\n" + f"{INPUTS_DIRNAME}/{SUMMIT_GENESIS_FILENAME}), then:\n" f" seismic-tee-network manifest assemble {args.dir}{id_hint}" ) elif args.command == "assemble": missing = [ p - for p in (args.reth_genesis, args.summit_template, args.measurements) + for p in (args.reth_genesis, args.summit_genesis, args.measurements) if not p.exists() ] if missing: @@ -1154,12 +1244,13 @@ def main() -> None: assembled = assemble( name=args.name, reth_genesis=args.reth_genesis, - summit_template=args.summit_template, + summit_genesis=args.summit_genesis, policy_bytes=policy_bytes, registry=args.registry, authority=args.authority, reth_bin=args.reth_bin, admission_bin=args.admission_bin, + summit_bin=args.summit_bin, ) write_artifact_set(args.out, assembled, force=args.force) logger.info("wrote %s", args.out / MANIFEST_FILENAME) @@ -1171,10 +1262,11 @@ def main() -> None: manifest = validate_manifest_schema(manifest_bytes) ctx = GateContext( reth_genesis=args.reth_genesis, - summit_template=args.summit_template, + summit_genesis=args.summit_genesis, policy_bytes=args.measurement_policy.read_bytes(), reth_bin=args.reth_bin, admission_bin=args.admission_bin, + summit_bin=args.summit_bin, ) run_validation_gates(manifest, ctx) print(f"network_id: {compute_network_id(manifest_bytes)}") diff --git a/tee/cli/common/tests/test_manifest.py b/tee/cli/common/tests/test_manifest.py index 64223be7..3fb78c64 100644 --- a/tee/cli/common/tests/test_manifest.py +++ b/tee/cli/common/tests/test_manifest.py @@ -36,9 +36,17 @@ run_validation_gates, validate_manifest_schema, validate_reth_genesis_matches, + validate_summit_genesis_matches, write_artifact_set, ) + +def _content_digest(path: Path) -> str: + """Test stand-in for `summit genesis digest`: content-derived (a byte + hash, not summit's SSZ digest), so tamper-detection gates still fire.""" + return "0x" + hashlib.sha256(path.read_bytes()).hexdigest() + + # The shared policy-compiler CLI from the enclave repo. Tests of the # subprocess boundary run only where it is built (everything else injects # compile_fn / crafts policy bytes directly, mirroring genesis_hash_fn). @@ -69,7 +77,6 @@ def promoted_policy_bytes(measurement_id: str = "img.vhd") -> bytes: FIXTURE_MANIFEST = { "manifest_version": 1, "name": "seismic-devnet-3", - "genesis_nonce": "0x" + "aa" * 32, "eth": { "chain_id": 5124, "genesis_hash": ( @@ -77,7 +84,7 @@ def promoted_policy_bytes(measurement_id: str = "img.vhd") -> bytes: ), }, "summit": { - "genesis_template_hash": "0x" + "bb" * 32, + "genesis_config_digest": "0x" + "bb" * 32, "namespace": "seismic-devnet-3", }, "measurements": { @@ -94,7 +101,7 @@ def promoted_policy_bytes(measurement_id: str = "img.vhd") -> bytes: # this value — it's the same vector the enclave crate's # parses_v1_fixture_and_derives_network_id test asserts. FIXTURE_NETWORK_ID = ( - "0xc4d4721b2e287df26022e6d27c8cf772841a872b6be08b1938cbc76d88703747" + "0x8ef142e3f2bf15f8b201c4d8cda7848a9e846222c62b5615d4d36c7fccd98a24" ) # The node-side parser pins these exact bytes in the enclave repo. Fetch its @@ -172,7 +179,7 @@ def test_reports_unsupported_version_before_unknown_keys(self): def test_rejects_malformed_hex(self): def wrong_length(m): - m["genesis_nonce"] = "0x" + "aa" * 31 + m["summit"]["genesis_config_digest"] = "0x" + "aa" * 31 def missing_prefix(m): m["eth"]["genesis_hash"] = "ab" * 32 @@ -277,6 +284,8 @@ def test_compile_failure_is_a_gate_error(self): class NetworkSectionTests(unittest.TestCase): + SUMMIT_GENESIS = b'namespace = "seismic-devnet-3"\nvalidators = []\n' + def test_network_section_round_trips_exact_bytes(self): import base64 import tomllib @@ -284,7 +293,9 @@ def test_network_section_round_trips_exact_bytes(self): manifest_bytes = render_manifest(FIXTURE_MANIFEST) genesis_bytes = json.dumps({"config": {"chainId": 5124}}).encode() section = tomllib.loads( - render_network_section(manifest_bytes, genesis_bytes, []) + render_network_section( + manifest_bytes, genesis_bytes, self.SUMMIT_GENESIS, [] + ) ) decoded = base64.standard_b64decode(section["network"]["manifest_base64"]) self.assertEqual(decoded, manifest_bytes) @@ -292,6 +303,10 @@ def test_network_section_round_trips_exact_bytes(self): section["network"]["reth_genesis_base64"] ) self.assertEqual(decoded_genesis, genesis_bytes) + decoded_summit = base64.standard_b64decode( + section["network"]["summit_genesis_base64"] + ) + self.assertEqual(decoded_summit, self.SUMMIT_GENESIS) def test_bootnodes_populated_survive_verbatim(self): import tomllib @@ -303,7 +318,9 @@ def test_bootnodes_populated_survive_verbatim(self): "enode://" + "cd" * 64 + "@5.6.7.8:30303", ] section = tomllib.loads( - render_network_section(manifest_bytes, genesis_bytes, bootnodes) + render_network_section( + manifest_bytes, genesis_bytes, self.SUMMIT_GENESIS, bootnodes + ) ) self.assertEqual(section["network"]["bootnodes"], bootnodes) @@ -315,7 +332,9 @@ def test_bootnodes_empty_key_is_present(self): manifest_bytes = render_manifest(FIXTURE_MANIFEST) genesis_bytes = json.dumps({"config": {"chainId": 5124}}).encode() - rendered = render_network_section(manifest_bytes, genesis_bytes, []) + rendered = render_network_section( + manifest_bytes, genesis_bytes, self.SUMMIT_GENESIS, [] + ) self.assertIn("bootnodes = []", rendered) section = tomllib.loads(rendered) self.assertEqual(section["network"]["bootnodes"], []) @@ -345,6 +364,34 @@ def test_rejects_missing_or_bool_chain_id(self): validate_reth_genesis_matches(FIXTURE_MANIFEST, genesis) +class SummitGenesisMatchTests(unittest.TestCase): + """validate_summit_genesis_matches — the client-side mirror of tdx-init's + POST-time summit-genesis namespace cross-check.""" + + def _genesis(self, namespace: str) -> bytes: + return f"namespace = {json.dumps(namespace)}\nvalidators = []\n".encode() + + def test_matching_namespace_passes(self): + validate_summit_genesis_matches( + FIXTURE_MANIFEST, self._genesis("seismic-devnet-3") + ) + + def test_namespace_mismatch(self): + with self.assertRaises(GateError): + validate_summit_genesis_matches( + FIXTURE_MANIFEST, self._genesis("seismic-devnet-4") + ) + + def test_rejects_non_toml(self): + with self.assertRaises(GateError): + validate_summit_genesis_matches(FIXTURE_MANIFEST, b'{"namespace": "x"}') + + def test_rejects_missing_or_non_string_namespace(self): + for genesis in (b"validators = []\n", b"namespace = 5\n"): + with self.assertRaises(GateError): + validate_summit_genesis_matches(FIXTURE_MANIFEST, genesis) + + class InjectTests(unittest.TestCase): """inject_registry_genesis_storage — the derive half of the registry gate (its exactness arms live in GateTests).""" @@ -459,9 +506,9 @@ def setUp(self): root = Path(self.tmp.name) self.reth_genesis = root / "reth-genesis.json" self._write_genesis() - # Authored templates carry no eth_genesis_hash — assemble fills it. - self.summit_template = root / "summit-genesis-template.toml" - self.summit_template.write_text('namespace = "testnet-1"\n') + # Authored inputs carry no eth_genesis_hash — assemble fills it. + self.summit_genesis = root / "summit-genesis.toml" + self.summit_genesis.write_text('namespace = "testnet-1"\n') self.policy_bytes = promoted_policy_bytes() self.out_dir = root / "out" @@ -503,11 +550,11 @@ def _assemble(self, **overrides) -> AssembledManifest: kwargs = { "name": "testnet-1", "reth_genesis": self.reth_genesis, - "summit_template": self.summit_template, + "summit_genesis": self.summit_genesis, "policy_bytes": self.policy_bytes, - "genesis_nonce": b"\xaa" * 32, "genesis_hash_fn": lambda _p: self.ETH_HASH, "compile_fn": self._report, + "digest_fn": _content_digest, } kwargs.update(overrides) # ty can't verify a **kwargs dict-splat against typed params. @@ -516,10 +563,11 @@ def _assemble(self, **overrides) -> AssembledManifest: def _ctx(self, **overrides) -> GateContext: kwargs = { "reth_genesis": self.reth_genesis, - "summit_template": self.summit_template, + "summit_genesis": self.summit_genesis, "policy_bytes": self.policy_bytes, "genesis_hash_fn": lambda _p: self.ETH_HASH, "compile_fn": self._report, + "digest_fn": _content_digest, } kwargs.update(overrides) # ty can't verify a **kwargs dict-splat against typed params. @@ -527,10 +575,10 @@ def _ctx(self, **overrides) -> GateContext: def _validate(self, assembled: AssembledManifest) -> None: """The validate path: gates re-run over the on-disk genesis, with - assemble's filled template copy standing in for the shipped file.""" + assemble's completed summit genesis standing in for the shipped file.""" run_validation_gates( assembled.manifest, - self._ctx(summit_template_bytes=assembled.summit_template_bytes), + self._ctx(summit_genesis_bytes=assembled.summit_genesis_bytes), ) def test_assemble_passes_gates_and_is_deterministic(self): @@ -541,11 +589,6 @@ def test_assemble_passes_gates_and_is_deterministic(self): self.assertEqual(first.manifest["eth"]["chain_id"], 5124) self.assertEqual(first.manifest["eth"]["genesis_hash"], self.ETH_HASH) - def test_fresh_nonce_uniquifies_clones(self): - a = self._assemble(genesis_nonce=None) - b = self._assemble(genesis_nonce=None) - self.assertNotEqual(a.network_id, b.network_id) - def test_gate_chain_id_mismatch(self): manifest = self._assemble().manifest manifest["eth"]["chain_id"] = 9999 @@ -559,25 +602,25 @@ def test_gate_eth_genesis_hash_mismatch(self): manifest, self._ctx(genesis_hash_fn=lambda _p: "0x" + "34" * 32) ) - def test_gate_template_hash_mismatch(self): + def test_gate_config_digest_mismatch(self): manifest = self._assemble().manifest - self.summit_template.write_text( + self.summit_genesis.write_text( f'eth_genesis_hash = "{self.ETH_HASH}"\n' 'namespace = "testnet-1"\n# tampered\n' ) - with self.assertRaisesRegex(GateError, "genesis_template_hash mismatch"): + with self.assertRaisesRegex(GateError, "genesis_config_digest mismatch"): run_validation_gates(manifest, self._ctx()) - def test_gate_template_namespace_mismatch(self): - # The template-bytes override stands in for the filled artifact-set - # copy, so the earlier template-hash gate passes and this one fires. + def test_gate_genesis_namespace_mismatch(self): + # The genesis-bytes override stands in for the completed artifact-set + # copy, so the earlier config-digest gate passes and this one fires. assembled = self._assemble() manifest = assembled.manifest manifest["summit"]["namespace"] = "other" with self.assertRaisesRegex(GateError, "namespace"): run_validation_gates( manifest, - self._ctx(summit_template_bytes=assembled.summit_template_bytes), + self._ctx(summit_genesis_bytes=assembled.summit_genesis_bytes), ) def test_gate_contract_missing_from_alloc(self): @@ -588,7 +631,7 @@ def test_gate_contract_missing_from_alloc(self): with self.assertRaisesRegex(GateError, "not in the reth genesis alloc"): run_validation_gates( manifest, - self._ctx(summit_template_bytes=assembled.summit_template_bytes), + self._ctx(summit_genesis_bytes=assembled.summit_genesis_bytes), ) def test_gate_policy_hash_mismatch(self): @@ -598,7 +641,7 @@ def test_gate_policy_hash_mismatch(self): assembled.manifest, self._ctx( policy_bytes=self.policy_bytes + b"\n", - summit_template_bytes=assembled.summit_template_bytes, + summit_genesis_bytes=assembled.summit_genesis_bytes, ), ) @@ -698,37 +741,36 @@ def content_hash(p: Path) -> str: raw = "0x" + hashlib.sha256(self.reth_genesis.read_bytes()).hexdigest() self.assertNotEqual(injected, raw) - def test_assemble_fills_template_hash(self): + def test_assemble_fills_eth_genesis_hash(self): # eth_genesis_hash is derived from reth-genesis.json, not authored: - # the computed hash gets prepended, and the filled copy is what the - # manifest commits to and the set ships. + # the computed hash gets prepended, and the completed copy is what the + # manifest commits to and the set ships as summit-genesis.toml. assembled = self._assemble() filled_line = f'eth_genesis_hash = "{self.ETH_HASH}"\n'.encode() - self.assertTrue(assembled.summit_template_bytes.startswith(filled_line)) + self.assertTrue(assembled.summit_genesis_bytes.startswith(filled_line)) write_artifact_set(self.out_dir, assembled) - written = self.out_dir / "summit-genesis-template.toml" - self.assertEqual(written.read_bytes(), assembled.summit_template_bytes) + written = self.out_dir / "summit-genesis.toml" + self.assertEqual(written.read_bytes(), assembled.summit_genesis_bytes) # validate-style round trip: gates re-pass over the written copy. - run_validation_gates(assembled.manifest, self._ctx(summit_template=written)) + run_validation_gates(assembled.manifest, self._ctx(summit_genesis=written)) def test_assemble_fills_empty_validators_placeholder(self): - # summit's genesis binary requires the key to *parse* the template - # (no serde default) though it replaces the value; entries stay out. + # summit requires the key to *parse* a genesis (no serde default); a + # template authored without a validator set ships an empty placeholder. assembled = self._assemble() - template = tomllib.loads(assembled.summit_template_bytes.decode()) - self.assertEqual(template["validators"], []) - self.assertFalse(any("[[validators]]" in w for w in assembled.warnings)) + genesis = tomllib.loads(assembled.summit_genesis_bytes.decode()) + self.assertEqual(genesis["validators"], []) def test_assemble_replaces_declared_genesis_hash(self): # A declared value (e.g. from summit's example_genesis.toml) is stale # copy-paste by definition: the shipped copy carries the computed # value instead, and never the declared one. stale = "0x" + "34" * 32 - self.summit_template.write_text( + self.summit_genesis.write_text( f'eth_genesis_hash = "{stale}"\nnamespace = "testnet-1"\n' ) assembled = self._assemble() - shipped = assembled.summit_template_bytes + shipped = assembled.summit_genesis_bytes self.assertNotIn(stale.encode(), shipped) self.assertTrue( shipped.startswith(f'eth_genesis_hash = "{self.ETH_HASH}"\n'.encode()) @@ -739,16 +781,16 @@ def test_assemble_replaces_declared_genesis_hash(self): def test_replace_leaves_table_keys_alone(self): # Only the top-level key is derived; a same-named key inside a table # (hypothetical) must survive untouched. - self.summit_template.write_text( + self.summit_genesis.write_text( 'namespace = "testnet-1"\n[extra]\neth_genesis_hash = "0xdead"\n' ) assembled = self._assemble() self.assertIn( - b'[extra]\neth_genesis_hash = "0xdead"\n', assembled.summit_template_bytes + b'[extra]\neth_genesis_hash = "0xdead"\n', assembled.summit_genesis_bytes ) def test_warns_on_default_summit_namespace(self): - self.summit_template.write_text('namespace = "_SUMMIT"\n') + self.summit_genesis.write_text('namespace = "_SUMMIT"\n') assembled = self._assemble() self.assertTrue(any("_SUMMIT" in w for w in assembled.warnings)) @@ -760,24 +802,22 @@ def test_init_then_assemble_shares_directory(self): raw = Path(self.tmp.name) / "raw-measurements.json" raw.write_text(json.dumps({"measurements": {"4": {"expected": "ab" * 24}}})) init_network_dir(net, "testnet-1", self.reth_genesis, raw) - authored = (inputs / "summit-genesis-template.toml").read_bytes() + authored = (inputs / "summit-genesis.toml").read_bytes() assembled = self._assemble( reth_genesis=inputs / "reth-genesis.json", - summit_template=inputs / "summit-genesis-template.toml", + summit_genesis=inputs / "summit-genesis.toml", ) write_artifact_set(net, assembled) - # Authored inputs untouched; each same-basename artifact at the top - # level carries assemble's derived copy. - self.assertEqual( - (inputs / "summit-genesis-template.toml").read_bytes(), authored - ) + # Authored inputs untouched; each artifact at the top level carries + # assemble's derived copy. + self.assertEqual((inputs / "summit-genesis.toml").read_bytes(), authored) self.assertEqual( (inputs / "reth-genesis.json").read_bytes(), self.reth_genesis.read_bytes(), ) self.assertEqual( - (net / "summit-genesis-template.toml").read_bytes(), - assembled.summit_template_bytes, + (net / "summit-genesis.toml").read_bytes(), + assembled.summit_genesis_bytes, ) self.assertEqual( (net / "reth-genesis.json").read_bytes(), assembled.reth_genesis_bytes @@ -790,7 +830,7 @@ def test_write_artifact_set_refuses_overwrite(self): "network-manifest.json", "measurement-policy.json", "reth-genesis.json", - "summit-genesis-template.toml", + "summit-genesis.toml", ): self.assertTrue((self.out_dir / name).exists(), name) # Round-trip: written bytes hash back to the same network_id. @@ -825,26 +865,26 @@ def test_scaffolds_inputs_with_starter_template(self): [ "measurements.json", "reth-genesis.json", - "summit-genesis-template.toml", + "summit-genesis.toml", ], ) self.assertEqual( (inputs / "reth-genesis.json").read_bytes(), self.reth_genesis.read_bytes(), ) - template = tomllib.loads((inputs / "summit-genesis-template.toml").read_text()) - self.assertEqual(template["namespace"], "testnet-1") - self.assertNotIn("eth_genesis_hash", template) - self.assertNotIn("validators", template) + starter = tomllib.loads((inputs / "summit-genesis.toml").read_text()) + self.assertEqual(starter["namespace"], "testnet-1") + self.assertNotIn("eth_genesis_hash", starter) + self.assertNotIn("validators", starter) - def test_copies_supplied_template_verbatim(self): + def test_copies_supplied_genesis_verbatim(self): src = Path(self.tmp.name) / "custom.toml" src.write_text('namespace = "custom"\n# comment\n') init_network_dir( self.out, "testnet-1", self.reth_genesis, self.measurements, src ) self.assertEqual( - (self.out / "inputs" / "summit-genesis-template.toml").read_bytes(), + (self.out / "inputs" / "summit-genesis.toml").read_bytes(), src.read_bytes(), ) @@ -897,8 +937,8 @@ def test_assemble_dir_resolution(self): args.reth_genesis, Path("networks/testnet-1/inputs/reth-genesis.json") ) self.assertEqual( - args.summit_template, - Path("networks/testnet-1/inputs/summit-genesis-template.toml"), + args.summit_genesis, + Path("networks/testnet-1/inputs/summit-genesis.toml"), ) self.assertEqual( args.measurements, Path("networks/testnet-1/inputs/measurements.json") @@ -927,10 +967,8 @@ def test_validate_dir_resolution(self): args = manifest_mod._parse_args(["validate", "networks/t"]) self.assertEqual(args.manifest, Path("networks/t/network-manifest.json")) self.assertEqual(args.admission_bin, DEFAULT_ADMISSION_BIN) - # validate reads the *shipped* template copy, not the authored input. - self.assertEqual( - args.summit_template, Path("networks/t/summit-genesis-template.toml") - ) + # validate reads the *shipped* summit genesis, not the authored input. + self.assertEqual(args.summit_genesis, Path("networks/t/summit-genesis.toml")) self.assertEqual( args.measurement_policy, Path("networks/t/measurement-policy.json") ) diff --git a/tee/cli/network/cohort_configure.py b/tee/cli/network/cohort_configure.py index fb055b8e..b16ae62a 100644 --- a/tee/cli/network/cohort_configure.py +++ b/tee/cli/network/cohort_configure.py @@ -51,6 +51,7 @@ build_config, post_config_to_tdx_init, resolve_reth_genesis, + resolve_summit_genesis, ) from tee.cli.node.status import poll_provisioning @@ -116,6 +117,7 @@ def _configure_node( node: Node, manifest_path: Path, reth_genesis_path: Path, + summit_genesis_path: Path, email: str, states: dict[str, str], stop: threading.Event, @@ -135,6 +137,7 @@ def _configure_node( email, genesis_node=node.genesis, reth_genesis_path=reth_genesis_path, + summit_genesis_path=summit_genesis_path, external_ip=node.public_ip, bootnodes=node.bootnodes, ) @@ -154,6 +157,7 @@ def _run_cohort( nodes: list[Node], manifest_path: Path, reth_genesis_path: Path, + summit_genesis_path: Path, email: str, ) -> dict[str, bool]: """Configure every node concurrently, refreshing the dashboard until all @@ -178,6 +182,7 @@ def _run_cohort( node, manifest_path, reth_genesis_path, + summit_genesis_path, email, states, stop, @@ -227,6 +232,7 @@ def _bootstrap_greenfield( nodes: list[Node], manifest_path: Path, reth_genesis_path: Path, + summit_genesis_path: Path, email: str, ) -> dict[str, bool]: """Two-stage greenfield bootstrap: genesis first (so its enode exists), @@ -242,7 +248,9 @@ def _bootstrap_greenfield( print("Stage 1/2: configuring the genesis node (no bootnodes yet)...") genesis.bootnodes = [] - results = _run_cohort([genesis], manifest_path, reth_genesis_path, email) + results = _run_cohort( + [genesis], manifest_path, reth_genesis_path, summit_genesis_path, email + ) if not results.get(genesis.name): print("Genesis node failed in stage 1 — skipping joiner bootstrap.") return results @@ -259,7 +267,11 @@ def _bootstrap_greenfield( print(f"Stage 2/2: configuring {len(joiners)} joining node(s) off genesis enode...") for joiner in joiners: joiner.bootnodes = [genesis_enode] - results.update(_run_cohort(joiners, manifest_path, reth_genesis_path, email)) + results.update( + _run_cohort( + joiners, manifest_path, reth_genesis_path, summit_genesis_path, email + ) + ) return results @@ -356,6 +368,17 @@ def parse_args() -> argparse.Namespace: "beside --manifest (the artifact-set layout)." ), ) + parser.add_argument( + "--summit-genesis", + type=Path, + default=None, + metavar="FILE", + help=( + "summit genesis TOML POSTed to every node; → " + "[network].summit_genesis_base64. Default: summit-genesis.toml " + "beside --manifest (the artifact-set layout)." + ), + ) parser.add_argument( "--email", default="ops@seismic.systems", @@ -386,6 +409,13 @@ def main() -> None: manifest_mod.validate_reth_genesis_matches(manifest, reth_genesis.read_bytes()) except manifest_mod.GateError as e: raise SystemExit(f"--reth-genesis {reth_genesis}: {e}") from None + summit_genesis = resolve_summit_genesis(args.summit_genesis, args.manifest) + try: + manifest_mod.validate_summit_genesis_matches( + manifest, summit_genesis.read_bytes() + ) + except manifest_mod.GateError as e: + raise SystemExit(f"--summit-genesis {summit_genesis}: {e}") from None nodes = build_cohort(args.genesis, args.join) joiners = [n.name for n in nodes if not n.genesis] @@ -409,9 +439,13 @@ def main() -> None: ) for node in nodes: node.bootnodes = enodes - results = _run_cohort(nodes, args.manifest, reth_genesis, args.email) + results = _run_cohort( + nodes, args.manifest, reth_genesis, summit_genesis, args.email + ) else: - results = _bootstrap_greenfield(nodes, args.manifest, reth_genesis, args.email) + results = _bootstrap_greenfield( + nodes, args.manifest, reth_genesis, summit_genesis, args.email + ) # Refresh the founding set from every node's live enode (fresh each run). _persist_founding_bootnodes(nodes, results, bootnodes_path) diff --git a/tee/cli/network/genesis.py b/tee/cli/network/genesis.py index 5fbe6287..ea035f23 100644 --- a/tee/cli/network/genesis.py +++ b/tee/cli/network/genesis.py @@ -1,10 +1,11 @@ """Genesis ceremony (network creation, one-time). -Gathers every cohort node's summit pubkeys, builds one `genesis.toml` -for the whole initial validator set, and POSTs it back to each node's -summit. This is run *once*, by whoever brings a network up — a validator -joining an already-bootstrapped network never runs it (it joins via the -deposit contract + sync, and is configured with `genesis_node = false`). +Gathers every cohort node's summit pubkeys, fills them into the network's +shipped summit genesis (`summit genesis set-validators`), and POSTs the +result back to each node's summit. This is run *once*, by whoever brings a +network up — a validator joining an already-bootstrapped network never runs +it (it joins via the deposit contract + sync, and is configured with +`genesis_node = false`). `eth_genesis_hash` comes from the network manifest (`--manifest`), where `manifest assemble` pinned it at deploy time — the ceremony needs no @@ -23,7 +24,6 @@ """ import argparse -import hashlib import json import shutil import subprocess @@ -93,17 +93,16 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: ), ) parser.add_argument( - "--summit-template", + "--summit-genesis", type=Path, default=None, metavar="FILE", help=( - "Summit genesis template TOML (network-params without " - "[[validators]]) the `genesis` binary fills validators into. Must " - "be the copy the manifest commits to (summit.genesis_template_" - "hash) — verified before building. Default: summit-genesis-" - "template.toml beside --manifest (the artifact-set layout " - "`manifest assemble --out` produces)." + "Summit genesis TOML `summit genesis set-validators` fills the " + "cohort's validator set into. Must be the copy the manifest " + "commits to (summit.genesis_config_digest) — verified before " + "building. Default: summit-genesis.toml beside --manifest (the " + "artifact-set layout `manifest assemble` produces)." ), ) parser.add_argument( @@ -145,19 +144,19 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: for path in args.node: if not path.is_file(): raise SystemExit(f"--node descriptor not found: {path}") - defaulted = args.summit_template is None + defaulted = args.summit_genesis is None if defaulted: - args.summit_template = ( - args.manifest.parent / manifest_mod.SUMMIT_TEMPLATE_FILENAME + args.summit_genesis = ( + args.manifest.parent / manifest_mod.SUMMIT_GENESIS_FILENAME ) - if not args.summit_template.is_file(): + if not args.summit_genesis.is_file(): hint = ( - " (the default is summit-genesis-template.toml beside --manifest; " - "pass --summit-template if it lives elsewhere)" + " (the default is summit-genesis.toml beside --manifest; " + "pass --summit-genesis if it lives elsewhere)" if defaulted else "" ) - raise SystemExit(f"--summit-template not found: {args.summit_template}{hint}") + raise SystemExit(f"--summit-genesis not found: {args.summit_genesis}{hint}") return args @@ -237,25 +236,29 @@ def _get_pubkeys( return validators, node_clients -def _verify_template_commitment(template_path: Path, manifest: dict) -> None: - """Assert the template is the one the manifest commits to - (summit.genesis_template_hash). +def _verify_genesis_commitment( + summit_genesis: Path, manifest: dict, summit_bin: str +) -> None: + """Assert the summit genesis is the one the manifest commits to + (summit.genesis_config_digest, via `summit genesis digest`). - Everything in the template (eth_genesis_hash, namespace, timeouts, - stake bounds) flows into genesis.toml as-is, so building from - uncommitted bytes would start the chain on parameters the manifest - never pinned. + Everything in it (eth_genesis_hash, namespace, timeouts, stake bounds) + flows into the built genesis.toml as-is, so building from uncommitted + bytes would start the chain on parameters the manifest never pinned. """ - computed = "0x" + hashlib.sha256(template_path.read_bytes()).hexdigest() - committed = manifest["summit"]["genesis_template_hash"] + try: + computed = manifest_mod.summit_config_digest(summit_genesis, summit_bin) + except manifest_mod.GateError as e: + raise SystemExit(f"--summit-genesis {summit_genesis}: {e}") from None + committed = manifest["summit"]["genesis_config_digest"] if computed != committed.lower(): raise SystemExit( - f"--summit-template {template_path} is not the template the " - "manifest commits to (summit.genesis_template_hash); refusing to " + f"--summit-genesis {summit_genesis} is not the genesis the " + "manifest commits to (summit.genesis_config_digest); refusing to " "build genesis.toml:\n" f" committed: {committed}\n" f" computed: {computed}\n" - "Use the artifact-set copy written by `manifest assemble --out` " + "Use the artifact-set copy written by `manifest assemble` " "(the default when it sits beside --manifest)." ) @@ -414,22 +417,21 @@ def _assert_cohort_genesis_hash( def main(): args = _parse_args() - # `genesis` is summit's binary; expect it on PATH (build summit and symlink - # its target/debug/genesis onto PATH, the same way summit expects `reth`). - # Fail with a clear message instead of a subprocess FileNotFoundError. - genesis_bin = shutil.which("genesis") - if genesis_bin is None: + # The `genesis set-validators` / `genesis digest` subcommands live on + # summit's node binary; expect it on PATH. Fail with a clear message + # instead of a subprocess FileNotFoundError. + summit_bin = shutil.which(manifest_mod.DEFAULT_SUMMIT_BIN) + if summit_bin is None: raise SystemExit( - "`genesis` binary not found on PATH. Build summit and put its " - "`genesis` binary on PATH, e.g. " - "`ln -s /target/debug/genesis ~/.cargo/bin/genesis`." + "`summit` binary not found on PATH. Build summit and put it on " + "PATH, e.g. `ln -s /target/debug/summit ~/.cargo/bin/summit`." ) try: manifest = manifest_mod.validate_manifest_schema(args.manifest.read_bytes()) except manifest_mod.ManifestSchemaError as e: raise SystemExit(f"--manifest {args.manifest}: invalid manifest: {e}") from None - _verify_template_commitment(args.summit_template, manifest) + _verify_genesis_commitment(args.summit_genesis, manifest, summit_bin) genesis_hash = manifest["eth"]["genesis_hash"] print(f"Expecting eth_genesis_hash = {genesis_hash}") @@ -454,22 +456,24 @@ def main(): json.dump(validators, f, indent=2) # Output streams to the terminal (check=True raises on failure). + genesis_path = Path(f"{tmpdir}/genesis.toml") subprocess.run( [ - genesis_bin, - "-o", - tmpdir, + summit_bin, + "genesis", + "set-validators", "-i", - str(args.summit_template), + str(args.summit_genesis), "-v", tmp_validators, + "-o", + str(genesis_path), ], check=True, ) # Log the built genesis's path (not its contents) before delivery, mirroring # how `configure` logs the merged node config it POSTs. - genesis_path = Path(f"{tmpdir}/genesis.toml") print(f"Built genesis -> {genesis_path}") for _, client in node_clients: diff --git a/tee/cli/network/orchestrator.py b/tee/cli/network/orchestrator.py index 208b57ae..06c8a2eb 100644 --- a/tee/cli/network/orchestrator.py +++ b/tee/cli/network/orchestrator.py @@ -330,7 +330,7 @@ def up_main() -> None: f" seismic-tee-network manifest init {net} \\\n" " --reth-genesis \\\n" " --measurements --measurement-id \n" - f" # edit {net}/inputs/summit-genesis-template.toml, then:\n" + f" # edit {net}/inputs/summit-genesis.toml, then:\n" f" seismic-tee-network manifest assemble {net}\n" "2. Configure the cohort (re-run on every node reboot):\n" f" seismic-tee-network configure --genesis {genesis_desc}" diff --git a/tee/cli/network/tests/test_cohort_configure.py b/tee/cli/network/tests/test_cohort_configure.py index 868cdd37..db848570 100644 --- a/tee/cli/network/tests/test_cohort_configure.py +++ b/tee/cli/network/tests/test_cohort_configure.py @@ -100,7 +100,7 @@ def fake_run(subset, *a, **k): ), ): results = cohort_configure._bootstrap_greenfield( - nodes, Path("m.json"), Path("g.json"), "e@x" + nodes, Path("m.json"), Path("g.json"), Path("s.toml"), "e@x" ) self.assertEqual(results, {"node-1": True, "node-2": True}) @@ -122,7 +122,7 @@ def fake_run(subset, *a, **k): ) as collect, ): results = cohort_configure._bootstrap_greenfield( - nodes, Path("m.json"), Path("g.json"), "e@x" + nodes, Path("m.json"), Path("g.json"), Path("s.toml"), "e@x" ) collect.assert_not_called() # never reached the enode fetch diff --git a/tee/cli/network/tests/test_genesis.py b/tee/cli/network/tests/test_genesis.py index 37509461..cb3c4f92 100644 --- a/tee/cli/network/tests/test_genesis.py +++ b/tee/cli/network/tests/test_genesis.py @@ -4,7 +4,6 @@ uv run python -m unittest discover -s tee/tests -v """ -import hashlib import json import tempfile import unittest @@ -25,11 +24,11 @@ def setUp(self): self.addCleanup(self._tmp.cleanup) self.n1 = self._file("n1.json") self.n2 = self._file("n2.json") - self.template = self._file("template.toml") + self.summit_genesis = self._file("genesis.toml") self.manifest = self._file("manifest.json") self.common = [ - "--summit-template", - str(self.template), + "--summit-genesis", + str(self.summit_genesis), "--manifest", str(self.manifest), ] @@ -81,16 +80,16 @@ def test_no_node_and_no_nodes_dir_errors(self): genesis._parse_args(list(self.common)) self.assertIn("--node", str(ctx.exception)) - def test_summit_template_defaults_to_manifest_sibling(self): - # The artifact-set layout `manifest assemble --out` writes. - sibling = Path(self._tmp.name) / "summit-genesis-template.toml" + def test_summit_genesis_defaults_to_manifest_sibling(self): + # The artifact-set layout `manifest assemble` writes. + sibling = Path(self._tmp.name) / "summit-genesis.toml" sibling.write_text("") args = genesis._parse_args( ["--node", str(self.n1), "--manifest", str(self.manifest)] ) - self.assertEqual(args.summit_template, sibling) + self.assertEqual(args.summit_genesis, sibling) - def test_missing_default_template_errors_with_hint(self): + def test_missing_default_genesis_errors_with_hint(self): with self.assertRaises(SystemExit) as ctx: genesis._parse_args( ["--node", str(self.n1), "--manifest", str(self.manifest)] @@ -98,33 +97,61 @@ def test_missing_default_template_errors_with_hint(self): self.assertIn("beside --manifest", str(ctx.exception)) -class TemplateCommitmentTests(unittest.TestCase): - """The ceremony must build genesis.toml only from the template the +class GenesisCommitmentTests(unittest.TestCase): + """The ceremony must build genesis.toml only from the summit genesis the manifest commits to — the eth hash, namespace, timeouts, and stake - bounds all flow into genesis.toml as-is.""" + bounds all flow into the built genesis.toml as-is. The digest shell-out + is mocked: `summit genesis digest` semantics are summit's own tests' + job; these cover the compare-and-refuse.""" + + DIGEST = "0x" + "ab" * 32 def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.addCleanup(self._tmp.cleanup) - self.template = Path(self._tmp.name) / "summit-genesis-template.toml" - self.template.write_text('namespace = "testnet-1"\n') + self.summit_genesis = Path(self._tmp.name) / "summit-genesis.toml" + self.summit_genesis.write_text('namespace = "testnet-1"\n') - def _manifest_committing_to(self, data: bytes) -> dict: - return { - "summit": {"genesis_template_hash": "0x" + hashlib.sha256(data).hexdigest()} - } + def _manifest_committing_to(self, digest: str) -> dict: + return {"summit": {"genesis_config_digest": digest}} - def test_committed_template_passes(self): - genesis._verify_template_commitment( - self.template, self._manifest_committing_to(self.template.read_bytes()) + def _digest_returning(self, value): + return mock.patch.object( + genesis.manifest_mod, "summit_config_digest", return_value=value ) - def test_uncommitted_template_exits(self): - with self.assertRaises(SystemExit) as ctx: - genesis._verify_template_commitment( - self.template, self._manifest_committing_to(b"other bytes") + def test_committed_genesis_passes(self): + with self._digest_returning(self.DIGEST) as digest: + genesis._verify_genesis_commitment( + self.summit_genesis, + self._manifest_committing_to(self.DIGEST), + "summit", ) - self.assertIn("genesis_template_hash", str(ctx.exception)) + digest.assert_called_once_with(self.summit_genesis, "summit") + + def test_uncommitted_genesis_exits(self): + with self._digest_returning(self.DIGEST): + with self.assertRaises(SystemExit) as ctx: + genesis._verify_genesis_commitment( + self.summit_genesis, + self._manifest_committing_to("0x" + "cd" * 32), + "summit", + ) + self.assertIn("genesis_config_digest", str(ctx.exception)) + + def test_digest_failure_exits_with_the_tool_error(self): + with mock.patch.object( + genesis.manifest_mod, + "summit_config_digest", + side_effect=genesis.manifest_mod.GateError("'summit' not found"), + ): + with self.assertRaises(SystemExit) as ctx: + genesis._verify_genesis_commitment( + self.summit_genesis, + self._manifest_committing_to(self.DIGEST), + "summit", + ) + self.assertIn("not found", str(ctx.exception)) class AssertCohortGenesisHashTests(unittest.TestCase): diff --git a/tee/cli/network/tests/test_orchestrator.py b/tee/cli/network/tests/test_orchestrator.py index 35b7f86e..cd029798 100644 --- a/tee/cli/network/tests/test_orchestrator.py +++ b/tee/cli/network/tests/test_orchestrator.py @@ -33,9 +33,8 @@ def _manifest_bytes(policy: bytes) -> bytes: { "manifest_version": 1, "name": "t", - "genesis_nonce": "0x" + "aa" * 32, "eth": {"chain_id": 5124, "genesis_hash": "0x" + "12" * 32}, - "summit": {"genesis_template_hash": "0x" + "bb" * 32, "namespace": "t"}, + "summit": {"genesis_config_digest": "0x" + "bb" * 32, "namespace": "t"}, "measurements": { "bootstrap_policy_hash": "0x" + hashlib.sha256(policy).hexdigest(), "contracts": { diff --git a/tee/cli/node/configure.py b/tee/cli/node/configure.py index 06909e5a..0ce7ec8d 100644 --- a/tee/cli/node/configure.py +++ b/tee/cli/node/configure.py @@ -18,9 +18,10 @@ There is no per-node `node.toml`: `[node]` (external_ip + genesis_node) comes from the descriptor + role, `[node.domain]` from the descriptor fqdn + `--email`, and `[network]` from `--manifest` + `--reth-genesis` + -`--bootnode`. Those network-wide artifacts stay standalone files, merged only -at POST time. The node address is *brought by the operator* via a descriptor -file (see tee/cli/common/descriptor.py), typically +`--summit-genesis` + `--bootnode`. Those network-wide artifacts stay +standalone files, merged only at POST time. The node address is *brought +by the operator* via a descriptor file (see tee/cli/common/descriptor.py), +typically `pulumi stack output --json`. The CLI never provisions infrastructure (Pulumi's job). @@ -73,6 +74,24 @@ def resolve_reth_genesis(reth_genesis: Path | None, manifest_path: Path) -> Path return path +def resolve_summit_genesis(summit_genesis: Path | None, manifest_path: Path) -> Path: + """Resolve `--summit-genesis`, defaulting to the artifact-set convention: + `summit-genesis.toml` beside the manifest, exactly where `manifest + assemble` writes its byte-verbatim copy — so the file POSTed is the one + the manifest's `summit.genesis_config_digest` was computed from. + """ + path = summit_genesis or manifest_path.parent / manifest_mod.SUMMIT_GENESIS_FILENAME + if not path.is_file(): + hint = ( + "" + if summit_genesis + else " (the default is summit-genesis.toml beside --manifest; pass " + "--summit-genesis if it lives elsewhere)" + ) + raise SystemExit(f"summit genesis not found: {path}{hint}") + return path + + def build_config( manifest_path: Path, fqdn: str, @@ -80,6 +99,7 @@ def build_config( *, genesis_node: bool, reth_genesis_path: Path, + summit_genesis_path: Path, external_ip: str, bootnodes: list[str], ) -> Path: @@ -88,9 +108,10 @@ def build_config( `--nat extip`), the role (→ `[node].genesis_node`), the descriptor's fqdn (→ `[node.domain].name`, the cert domain), `--email` (→ `[node.domain].email`), and the network manifest + reth genesis + - bootnode set (`--manifest`/`--reth-genesis`/`bootnodes` → `[network]`). - Written fresh, so there is no operator-supplied TOML that could carry a - conflicting `[node]`/`[network]` and fork the network. + summit genesis + bootnode set (`--manifest`/`--reth-genesis`/ + `--summit-genesis`/`bootnodes` → `[network]`). Written fresh, so there is + no operator-supplied TOML that could carry a conflicting + `[node]`/`[network]` and fork the network. `external_ip` is the node's own public IP (from its descriptor); reth advertises it via `--nat extip` so its enode is dialable, which is what @@ -135,6 +156,12 @@ def build_config( except manifest_mod.GateError as e: raise SystemExit(f"--reth-genesis {reth_genesis_path}: {e}") from None + summit_genesis_bytes = summit_genesis_path.read_bytes() + try: + manifest_mod.validate_summit_genesis_matches(manifest, summit_genesis_bytes) + except manifest_mod.GateError as e: + raise SystemExit(f"--summit-genesis {summit_genesis_path}: {e}") from None + # json.dumps emits valid TOML basic strings for these simple ASCII values. merged = ( f"[node]\n" @@ -142,7 +169,7 @@ def build_config( f"genesis_node = {str(genesis_node).lower()}\n\n" f"[node.domain]\nname = {json.dumps(fqdn)}\nemail = {json.dumps(email)}\n\n" + manifest_mod.render_network_section( - manifest_bytes, reth_genesis_bytes, bootnodes + manifest_bytes, reth_genesis_bytes, summit_genesis_bytes, bootnodes ) ) with tempfile.NamedTemporaryFile( @@ -200,6 +227,7 @@ def deliver_config( *, genesis_node: bool, reth_genesis_path: Path, + summit_genesis_path: Path, bootnodes: list[str], ) -> None: """Build + POST one node's config, then watch its first-boot LUKS wipe. @@ -227,6 +255,7 @@ def deliver_config( email, genesis_node=genesis_node, reth_genesis_path=reth_genesis_path, + summit_genesis_path=summit_genesis_path, external_ip=public_ip, bootnodes=bootnodes, ) @@ -320,6 +349,21 @@ def parse_args() -> argparse.Namespace: "layout `manifest assemble --out` produces)." ), ) + parser.add_argument( + "--summit-genesis", + type=Path, + default=None, + metavar="FILE", + help=( + "summit genesis TOML POSTed to the node as " + "[network].summit_genesis_base64; tdx-init writes it to " + "/run/seismic/conf/summit-genesis.toml for summit's " + "--genesis-path. Must be the file the manifest's " + "summit.genesis_config_digest was computed from. Default: " + "summit-genesis.toml beside --manifest (the artifact-set layout " + "`manifest assemble` produces)." + ), + ) parser.add_argument( "--bootnode", action="append", @@ -379,12 +423,14 @@ def main() -> None: ) reth_genesis = resolve_reth_genesis(args.reth_genesis, args.manifest) + summit_genesis = resolve_summit_genesis(args.summit_genesis, args.manifest) deliver_config( args.node, args.manifest, args.email, genesis_node=False, reth_genesis_path=reth_genesis, + summit_genesis_path=summit_genesis, bootnodes=args.bootnode, ) diff --git a/tee/cli/node/tests/test_configure.py b/tee/cli/node/tests/test_configure.py index 8b58eb6a..eda9f37a 100644 --- a/tee/cli/node/tests/test_configure.py +++ b/tee/cli/node/tests/test_configure.py @@ -18,6 +18,7 @@ from tee.cli.node.configure import ( build_config, resolve_reth_genesis, + resolve_summit_genesis, ) FQDN = "node1.example.com" @@ -43,6 +44,11 @@ def setUp(self): ".json", json.dumps({"config": {"chainId": 5124}, "alloc": {}}) ) self._tmp.append(self.reth_genesis) + # namespace matches FIXTURE_MANIFEST's summit.namespace. + self.summit_genesis = _write( + ".toml", 'namespace = "seismic-devnet-3"\nvalidators = []\n' + ) + self._tmp.append(self.summit_genesis) def tearDown(self): for p in self._tmp: @@ -60,6 +66,7 @@ def _build( EMAIL, genesis_node=genesis_node, reth_genesis_path=self.reth_genesis, + summit_genesis_path=self.summit_genesis, external_ip=EXTERNAL_IP, bootnodes=bootnodes or [], ) @@ -80,6 +87,7 @@ def test_genesis_mode(self): self.assertEqual(merged["node"]["domain"]["email"], EMAIL) self.assertTrue(merged["network"]["manifest_base64"]) self.assertTrue(merged["network"]["reth_genesis_base64"]) + self.assertTrue(merged["network"]["summit_genesis_base64"]) self.assertEqual(merged["network"]["bootnodes"], []) def test_join_mode(self): @@ -91,6 +99,7 @@ def test_join_mode(self): self.assertEqual(merged["node"]["domain"]["name"], FQDN) self.assertTrue(merged["network"]["manifest_base64"]) self.assertTrue(merged["network"]["reth_genesis_base64"]) + self.assertTrue(merged["network"]["summit_genesis_base64"]) self.assertEqual(merged["network"]["bootnodes"], [BOOTNODE]) def test_bootnodes_populated(self): @@ -112,6 +121,7 @@ def test_empty_external_ip_rejected(self): EMAIL, genesis_node=True, reth_genesis_path=self.reth_genesis, + summit_genesis_path=self.summit_genesis, external_ip="", bootnodes=[], ) @@ -132,6 +142,7 @@ def test_rejects_invalid_manifest(self): EMAIL, genesis_node=True, reth_genesis_path=self.reth_genesis, + summit_genesis_path=self.summit_genesis, external_ip=EXTERNAL_IP, bootnodes=[], ) @@ -148,6 +159,24 @@ def test_rejects_chain_id_mismatch(self): EMAIL, genesis_node=True, reth_genesis_path=wrong, + summit_genesis_path=self.summit_genesis, + external_ip=EXTERNAL_IP, + bootnodes=[], + ) + + def test_rejects_summit_namespace_mismatch(self): + # A summit genesis other than the one the manifest was assembled from + # must fail the POST build (tdx-init would 400 on it anyway). + wrong = _write(".toml", 'namespace = "other-net"\n') + self._tmp.append(wrong) + with self.assertRaises(SystemExit): + build_config( + self.manifest, + FQDN, + EMAIL, + genesis_node=True, + reth_genesis_path=self.reth_genesis, + summit_genesis_path=wrong, external_ip=EXTERNAL_IP, bootnodes=[], ) @@ -176,5 +205,21 @@ def test_missing_default_errors(self): resolve_reth_genesis(None, manifest) +class ResolveSummitGenesisTests(unittest.TestCase): + def test_defaults_to_manifest_sibling(self): + # The artifact-set layout `manifest assemble` writes. + with tempfile.TemporaryDirectory() as d: + manifest = Path(d) / "network-manifest.json" + sibling = Path(d) / "summit-genesis.toml" + sibling.write_text("") + self.assertEqual(resolve_summit_genesis(None, manifest), sibling) + + def test_missing_default_errors(self): + with tempfile.TemporaryDirectory() as d: + manifest = Path(d) / "network-manifest.json" + with self.assertRaises(SystemExit): + resolve_summit_genesis(None, manifest) + + if __name__ == "__main__": unittest.main() diff --git a/tee/networks/README.md b/tee/networks/README.md index dfdcec76..324f9bb9 100644 --- a/tee/networks/README.md +++ b/tee/networks/README.md @@ -14,11 +14,11 @@ genesis ceremony picks it up](network-dir.png) The diagram source is `network-dir.excalidraw`; re-render the PNG when editing it. -Directories are committed because a fresh `assemble` mints a fresh -`genesis_nonce`: the same `network_id` can never be regenerated from the -inputs, so the directory is everything needed to (re)configure, join, or -debug that network later. The `nodes/` descriptors are runtime output -(live IPs) and stay gitignored. +Directories are committed because the directory is everything needed to +(re)configure, join, or debug that network later, and its manifest is the +network's immutable identity — a founded network's `network_id` must +never drift. The `nodes/` descriptors are runtime output (live IPs) and +stay gitignored. ## example-devnet @@ -28,12 +28,12 @@ identity. Two intended uses: - **Found a throwaway test devnet.** The artifact set is assembled and valid, so `up --network … --count N` → `configure` → `genesis-ceremony` brings up a working cohort. If two such cohorts might ever run at once, - re-run `manifest assemble --force` first so each gets a fresh - `genesis_nonce` (cohorts sharing a `network_id` can cross-replay - attestation transcripts). Note `inputs/measurements.json` snapshots a specific + give each its own directory with a distinct `namespace` and `chainId` + (cohorts sharing a `network_id` can cross-replay attestation + transcripts). Note `inputs/measurements.json` snapshots a specific image build — when the deployed VHD moves on, refresh it and re-assemble (`up --network` refuses on a pin/policy mismatch). - **Start a real network.** Don't reuse or copy this directory — run `manifest init ` and author fresh inputs. `namespace` (the BLS - signature domain separator) and `genesis_nonce` must be unique per - network that matters. + signature domain separator) and `chainId` must be unique per network + that matters. diff --git a/tee/networks/example-devnet/inputs/summit-genesis-template.toml b/tee/networks/example-devnet/inputs/summit-genesis.toml similarity index 54% rename from tee/networks/example-devnet/inputs/summit-genesis-template.toml rename to tee/networks/example-devnet/inputs/summit-genesis.toml index 011940c4..06e1b7be 100644 --- a/tee/networks/example-devnet/inputs/summit-genesis-template.toml +++ b/tee/networks/example-devnet/inputs/summit-genesis.toml @@ -1,8 +1,8 @@ -# Summit network-params template — authored input for `manifest assemble`. -# Review every value before founding a real network. Two fields are filled -# elsewhere and do not belong here: eth_genesis_hash (derived from -# reth-genesis.json at assemble time) and [[validators]] (TEE-born, filled -# by the genesis ceremony). +# Summit network parameters. `manifest assemble` prepends the two derived +# fields above this header — eth_genesis_hash (from reth-genesis.json) and +# validators (the founding validator set — TEE-born keys gathered from the +# live cohort) — and ships the completed file as summit-genesis.toml. +# Review every value before founding a real network. leader_timeout_ms = 2000 notarization_timeout_ms = 4000 nullify_timeout_ms = 4000 diff --git a/tee/networks/example-devnet/network-manifest.json b/tee/networks/example-devnet/network-manifest.json index f79eb3b4..ca60a3b6 100644 --- a/tee/networks/example-devnet/network-manifest.json +++ b/tee/networks/example-devnet/network-manifest.json @@ -3,7 +3,6 @@ "chain_id": 5124, "genesis_hash": "0x7c1c5d208fe9b8f1b4287c1cf1e696d466f89091c935d20dc661fa4333f68b6a" }, - "genesis_nonce": "0x4fbed0378023e48392dc5cb8f8cb118bbd4d65309d013fe2bf3f69b61acabe0c", "manifest_version": 1, "measurements": { "bootstrap_policy_hash": "0x98ba9f24c6d8bc18e8ddb7bb4abea483b2365e58586b94ba4249a0a58f683882", @@ -14,7 +13,7 @@ }, "name": "example-devnet", "summit": { - "genesis_template_hash": "0x7ce6a01783a163d1bcbacc0d761b6872958396e85c0f3c5db2500d0135f250f3", + "genesis_config_digest": "0x85b39636e0041fdd97b3d13b761d198918b12b4151f2f58628e88d8982cccadc", "namespace": "example-devnet" } } diff --git a/tee/networks/example-devnet/summit-genesis-template.toml b/tee/networks/example-devnet/summit-genesis.toml similarity index 59% rename from tee/networks/example-devnet/summit-genesis-template.toml rename to tee/networks/example-devnet/summit-genesis.toml index b9e40999..6935e294 100644 --- a/tee/networks/example-devnet/summit-genesis-template.toml +++ b/tee/networks/example-devnet/summit-genesis.toml @@ -1,10 +1,10 @@ eth_genesis_hash = "0x7c1c5d208fe9b8f1b4287c1cf1e696d466f89091c935d20dc661fa4333f68b6a" validators = [] -# Summit network-params template — authored input for `manifest assemble`. -# Review every value before founding a real network. Two fields are filled -# elsewhere and do not belong here: eth_genesis_hash (derived from -# reth-genesis.json at assemble time) and [[validators]] (TEE-born, filled -# by the genesis ceremony). +# Summit network parameters. `manifest assemble` prepends the two derived +# fields above this header — eth_genesis_hash (from reth-genesis.json) and +# validators (the founding validator set — TEE-born keys gathered from the +# live cohort) — and ships the completed file as summit-genesis.toml. +# Review every value before founding a real network. leader_timeout_ms = 2000 notarization_timeout_ms = 4000 nullify_timeout_ms = 4000