From e173a4e385f5e575902ceaedadedf02febe76d42 Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Fri, 22 May 2026 03:41:45 +0100 Subject: [PATCH 1/3] docs(specs): migrate TEE provers spec from base/base Migrates docs/specs/pages/protocol/proofs/tee-provers.md from base/base to docs/base-chain/specs/protocol/proofs/tee-provers.md in this repo, per the in-progress spec migration (see base/base#2839, which is removing all specs from base/base). File copied byte-for-byte; sha256 verified identical to source (765b93c5d33cd58675775310bc4f73891a230b54bdfcedc42c6babe1793def15). --- .../specs/protocol/proofs/tee-provers.md | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 docs/base-chain/specs/protocol/proofs/tee-provers.md diff --git a/docs/base-chain/specs/protocol/proofs/tee-provers.md b/docs/base-chain/specs/protocol/proofs/tee-provers.md new file mode 100644 index 000000000..76d593f7f --- /dev/null +++ b/docs/base-chain/specs/protocol/proofs/tee-provers.md @@ -0,0 +1,332 @@ +# TEE Provers + +The TEE prover is an offchain service that produces signed proof material for `AggregateVerifier` +games by re-deriving and re-executing an L2 block range inside an AWS Nitro Enclave. The same +service backs both proposal creation and dispute nullification: callers (proposer or challenger) +submit a block range, the host collects witness data, the enclave verifies the range, and a key +held only inside the enclave signs the resulting journal. + +The signature is self-validating onchain. `TEEVerifier` recovers the signer from each proposal and +checks it against `TEEProverRegistry` for the active game implementation's `TEE_IMAGE_HASH`. A +signer from a different enclave image, or one that is no longer registered, cannot satisfy +verification. The Nitro hypervisor's per-instance attestation binds the signer's public key to a +specific PCR0, which the [registrar](./registrar) certifies separately. + +## Responsibilities + +A conforming TEE prover stack performs the following work: + +1. Serve `prover_prove` for proposal and dispute ranges over JSON-RPC. +2. Collect witness data from canonical L1, L1 beacon, and L2 RPCs on the host. +3. Forward content-verified preimages to the enclave over vsock. +4. Inside the enclave, re-derive and re-execute the L2 range and validate the claimed output root + against the re-executed one before signing anything. +5. Sign per-block journals and an aggregate journal with a secp256k1 key generated inside the + enclave. +6. Expose `enclave_signerPublicKey` and `enclave_signerAttestation` for the registrar. +7. Optionally gate every request on registry signer validity to fail closed against deregistered + enclaves. +8. Support multi-enclave deployment on a single EC2 parent so different PCR0 images can run + side-by-side across rotations. + +The TEE prover does not decide whether a proposal or dispute is correct. It re-executes the range, +signs the result if the re-execution matches the claim, and returns. Callers still recheck game +state before submitting onchain. + +## Architecture + +The service runs as two processes on a Nitro-capable EC2 parent: + +- A **host** binary (`base-prover-nitro-host`) that terminates JSON-RPC, collects witness data over + HTTP, and proxies requests to one or more enclaves. +- An **enclave** binary (`base-prover-nitro-enclave`) packed into an EIF that holds the signing key, + exposes a vsock listener, and runs the proof pipeline. + +The two processes communicate only over vsock. The enclave has no network interface; all external +RPC connectivity is on the host side. + +```mermaid +flowchart LR + caller[Proposer / Challenger] + host[NitroProverServer\nbase-prover-nitro-host] + rpc[L1 EL / L1 beacon / L2 EL] + enclave[NitroEnclave\nbase-prover-nitro-enclave] + nsm[NSM\nPCR0 / RNG / attestation] + + caller -- "prover_prove\nenclave_*" --> host + host -- "witness build" --> rpc + host -- "vsock\nlength-prefixed bincode" --> enclave + enclave --> nsm +``` + +Each vsock connection serves one request and then closes. The enclave holds no per-request state +between connections; the only persistent state inside the enclave is the signer key and the +boot-time PCR0 measurement. + +Vsock frames are length-prefixed (`u32` big-endian length + bincode payload) with a 5-minute read +timeout. The transport caps write chunks at 28 KiB to avoid a Linux kernel `virtio_vsock` SKB +corruption bug. + +## JSON-RPC Interface + +The host exposes two namespaces on a single HTTP JSON-RPC listener, plus an HTTP `GET /healthz` +proxy that routes to the JSON-RPC `healthz` method. + +| Method | Purpose | +| ---------------------------- | ------------------------------------------------------------------------ | +| `prover_prove` | Produce per-block and aggregate signed proposals for a block range. | +| `enclave_signerPublicKey` | Return the 65-byte uncompressed secp256k1 public key for each enclave. | +| `enclave_signerAttestation` | Return the COSE_Sign1 attestation document for each enclave. | +| `healthz` / `GET /healthz` | Liveness, plus optional onchain signer validity (latching) when enabled. | + +The `enclave_*` calls are all-or-nothing across multiple enclaves: if any transport fails or any +enclave returns an error, the entire response fails. Callers register every signer together, so a +partial response would be unusable. + +### prover_prove Request + +`ProofRequest` fields: + +| Field | Meaning | +| ------------------------------ | ------------------------------------------------------------------------------------------------------ | +| `l1_head` | L1 head block hash anchoring the derivation window. | +| `l1_head_number` | L1 head block number. | +| `agreed_l2_head_hash` | L2 block hash at the parent of the range. | +| `agreed_l2_output_root` | Output root at the parent. Used as the starting state. | +| `claimed_l2_output_root` | Claimed output root at the target. Trust-critical: the enclave only signs if re-execution matches it. | +| `claimed_l2_block_number` | Target L2 block number (ending block of the range). | +| `proposer` | L1 address that will submit the proof. Committed into the journal so onchain `msg.sender` must match. | +| `intermediate_block_interval` | Sampling stride for intermediate roots in the aggregate proposal. | +| `image_hash` | `keccak256(PCR0)` the caller expects. Currently informational; routing uses onchain signer validity. | + +### prover_prove Response + +`ProofResult::Tee` contains: + +| Field | Meaning | +| -------------------- | --------------------------------------------------------------------------------------------- | +| `aggregate_proposal` | One `Proposal` covering the full range with sampled intermediate roots. | +| `proposals` | Per-block `Proposal`s in order, each chaining `prev_output_root` to the previous block's root.| + +Each `Proposal`: + +| Field | Meaning | +| ------------------ | ---------------------------------------------------------------------------------- | +| `output_root` | Output root at this proposal's ending block. | +| `signature` | 65-byte secp256k1 ECDSA signature (`r || s || v`) over `keccak256(journal)`. | +| `l1_origin_hash` | L1 head hash used during derivation. | +| `l1_origin_number` | L1 head block number. | +| `l2_block_number` | Ending L2 block number for this proposal. | +| `prev_output_root` | Output root before this proposal's range. | +| `config_hash` | Per-chain config hash hardcoded into the enclave. | + +When the range contains exactly one block, the aggregate proposal is identical to the single +per-block proposal. Otherwise the aggregate carries its own signature over a journal whose +`prev_output_root` is the request's `agreed_l2_output_root`, whose `intermediate_roots` are +sampled at `intermediate_block_interval`, and whose `ending_l2_block` is the last block in the +range. + +### enclave_signerAttestation + +Takes optional `user_data` and `nonce` byte arguments. Both are capped at 512 bytes by the NSM +hardware and rejected at the host RPC layer before the vsock call. The host returns one raw +`COSE_Sign1` document per configured enclave, in the same order as `enclave_signerPublicKey`. The +registrar uses this endpoint to bind each enclave's signer to a fresh attestation before +submitting it onchain. + +## Proof Pipeline + +A single `prover_prove` request flows host → vsock → enclave → host: + +1. **Host**: `ProverService::prove_block` constructs a `Host` from the prover config, then calls + `Host::build_witness` to walk L1 EL, L1 beacon, and L2 EL and populate an `Oracle` with + hash-keyed preimages. +2. **Host**: `NitroBackend::prove` flattens the oracle into `(PreimageKey, Vec)` pairs and + `NitroTransport::prove` sends them over vsock as one `EnclaveRequest::Prove(...)` frame. +3. **Enclave**: `Oracle::new` content-verifies every `Keccak256`- or `Sha256`-keyed preimage so the + stored value actually hashes to its key. +4. **Enclave**: `BootInfo::load` extracts the proposer, L1 head, agreed/claimed roots, + intermediate-block interval, and chain ID from local preimages. +5. **Enclave**: `config_hash_for_chain` looks up a hardcoded per-chain config hash from + `CONFIG_HASHES` (computed at first access from `ChainConfig::all()`). Unknown chain IDs return + `UnsupportedChain` and refuse to prove. +6. **Enclave**: the proof prologue drives derivation and execution via + `driver.execute_with_intermediates()`. The epilogue's `validate()` is the trust-critical gate: + it confirms the re-executed final output root matches the `claimed_l2_output_root` from the + request. Signing only happens after this check passes. +7. **Enclave**: for each block result, build a `ProofJournal` with empty `intermediate_roots` and + sign it; chain `prev_output_root` through the loop. Then build and sign the aggregate journal + with sampled intermediate roots. +8. **Enclave**: return `EnclaveResponse::Prove(ProofResult::Tee { aggregate_proposal, proposals })`. +9. **Host**: return the result to the JSON-RPC caller, applying the configured proof request + timeout (default 1740 s, ~29 minutes). + +The proposer consumes both the aggregate and per-block proposals: per-block roots feed +`proposeOutputRoots` and the aggregate signature satisfies `AggregateVerifier`. The challenger +uses only the aggregate signature, repacking it for `nullify()` via +`ProofEncoder::encode_dispute_proof_bytes`. The enclave neither knows nor cares which caller it is +serving. + +## Signed Journal + +Each signature is computed as `secp256k1.sign(keccak256(journal))` and serialized as 65 bytes +(`r || s || v`). The journal is packed (not ABI-encoded), `196 + 32·N` bytes where `N` is the +number of intermediate roots: + +```text +proposer(20) || l1OriginHash(32) || prevOutputRoot(32) +startingL2Block(8) || outputRoot(32) || endingL2Block(8) +intermediateRoots(32 × N) || configHash(32) +teeImageHash(32) +``` + +Per-block proposals have `N == 0` and `startingL2Block == endingL2Block - 1`. Aggregate proposals +have `startingL2Block == firstBlock - 1`, `endingL2Block == lastBlock`, and `N == lastBlock / +intermediate_block_interval`. + +`teeImageHash` is `keccak256(PCR0)` taken at enclave boot. It is embedded in every journal so a +signature recovered onchain transitively commits to the exact EIF measurement that produced it. In +local mode (no NSM, development and test only), `teeImageHash` is zero. + +The signature `v` byte is encoded as the secp256k1 recovery id (`0` or `1`); callers normalize it +to the EIP-155 form they need before L1 submission. + +## Multi-Enclave Routing + +`--vsock-cid` accepts one or more CIDs, so a single host process can attach to multiple enclaves +running on the same EC2 parent. Each CID is an independent vsock endpoint that can run a different +EIF — a different PCR0, a different `tee_image_hash`, and a different registered signer. + +The CLI requires `--tee-prover-registry-address` whenever more than one CID is configured. Without +the registry there is no way to choose between enclaves deterministically, so multi-enclave +deployments are fail-closed-only. + +Per-request routing iterates configured CIDs in order and picks the first enclave whose signer is +currently valid in `TEEProverRegistry`: + +1. Fetch the signer public key from the enclave (skip the transport if this fails). +2. Call `isValidSigner(signer)` on `TEEProverRegistry`. +3. If valid, route the request to this enclave. If not, log and continue. +4. If no enclave in the list has a valid signer, fail the request with `NoValidSigner`. + +The common operational use is image rotation. Run the old and new EIFs side-by-side; both signers +are registered for the active game implementation's `TEE_IMAGE_HASH` during the overlap window; +after the registry switches to the new image hash only the new enclave's signer is valid, and all +new requests route to it. + +`enclave_*` calls fan out to every configured enclave so the registrar can register every signer +in one cycle. + +## Registration Gating and Health + +When `--tee-prover-registry-address` is set, the host enables two registry-backed behaviors: + +- `GET /healthz` returns healthy only after at least one enclave's signer has been confirmed valid + onchain. The health flag latches: once an enclave has been seen valid, `/healthz` continues to + report healthy even if the registry RPC later fails or the signer is deregistered. This keeps + load balancers stable across short outages. +- Every `prover_prove` request consults `RegistrationChecker::select_valid_enclave` before + forwarding. A deregistered enclave, or one whose key fetch fails, is skipped. If no enclave is + valid the request is rejected with JSON-RPC error code `-32001`. + +Without the registry flag, the host is permissive: `/healthz` returns healthy as long as the +server is running, and `prover_prove` routes to the first configured enclave. + +## Attestation + +The signer key is generated inside the enclave at startup and never leaves the enclave process. +The `Server::new_enclave` constructor: + +1. Opens an NSM session (`nsm_init`). +2. Reads PCR0 (48-byte SHA-384). Wrong length aborts startup. +3. Computes `tee_image_hash = keccak256(PCR0)` and stores it for inclusion in every signed journal. +4. Generates a secp256k1 ECDSA key with `NsmRng`, which calls + `nsm_process_request(Request::GetRandom)`. +5. Logs the signer address (no key material). + +There is no startup or periodic attestation. Attestations are produced only when the registrar +calls `enclave_signerAttestation`. Each call: + +1. Opens a fresh NSM session. +2. Calls `nsm_process_request(Request::Attestation { public_key, user_data, nonce })`. +3. Returns the raw COSE_Sign1 bytes. + +The attestation document embeds the 65-byte uncompressed public key, all populated PCRs, the +AWS-issued certificate chain, the timestamp, and the supplied `user_data`/`nonce`, all signed by +the per-instance Nitro hypervisor key. Only PCR0 is consumed by this system — it is the value +bound into every signed journal via `teeImageHash = keccak256(PCR0)`. See the +[registrar](./registrar) spec for how attestations are verified and submitted onchain. + +## Service Lifecycle + +The host startup sequence (`ServerArgs::run`): + +1. Parse CLI; initialize logging and metrics via `base_cli_utils`. +2. Resolve the `RollupConfig` and L1 chain config from `--l2-chain-id`. Fail on unknown chains. +3. Build one `NitroTransport::vsock(cid, 8000)` per `--vsock-cid`. +4. Construct `NitroProverServer::new_multi(prover_config, transports, timeout)` and, if + `--tee-prover-registry-address` is set, wrap with `RegistrationHealthConfig`. +5. Build a jsonrpsee HTTP server with a `/healthz` proxy layer, merge `ProverApiServer`, + `EnclaveApiServer`, and one of the healthz modules, and start the server. +6. Block on the server handle; exit on ctrl-C. + +The enclave startup sequence (`NitroEnclave::new`): + +1. `Server::new()` opens NSM, derives `tee_image_hash`, and generates the signer key. +2. Bind a `VsockListener` on `VMADDR_CID_ANY:8000`. +3. For each connection, spawn a handler that reads one framed `EnclaveRequest`, dispatches to + `Server::prove`, `signer_public_key`, or `signer_attestation`, writes the response, and closes + the connection. + +Per-request flow on the host: + +1. (Optional) `select_valid_enclave` chooses a registered enclave. +2. `tokio::time::timeout(proof_request_timeout, enclave.service.prove_block(request))`. +3. On timeout, return JSON-RPC `-32000` with the offending L2 block number. +4. On error from the enclave, return JSON-RPC `-32000` with the underlying error message. + +Shutdown is driven by ctrl-C handled by `RuntimeManager`. The jsonrpsee server stops, in-flight +requests drain, and the runtime exits. The enclave has no graceful shutdown path; process +termination drops NSM file descriptors via `Drop`. + +## Operator Inputs + +A TEE prover host needs: + +- L1 execution RPC URL. +- L1 beacon RPC URL. +- L2 execution RPC URL. +- L2 chain ID (used to select the rollup config and per-chain config hash). +- JSON-RPC listen address. +- One or more vsock CIDs, each backed by a Nitro Enclave running the prover EIF. +- Proof request timeout (default 1740 seconds). +- Logging filter and Prometheus metrics settings. + +Optional: + +- `TEEProverRegistry` address. Required when more than one vsock CID is configured. Enables + registration-gated health and per-request signer validation. +- Experimental witness endpoint flag for hosts that expose `debug_executePayload`. + +The enclave needs no operator inputs beyond the EIF image and the vsock channel. PCR0 is read at +boot from NSM; the signer key is generated from the hardware RNG. + +## Safety Requirements + +A TEE prover implementation must preserve these safety properties: + +- Generate the signing key inside the enclave from the NSM hardware RNG and never serialize it out + of the enclave process. +- Validate the re-executed final output root against the request's `claimed_l2_output_root` before + any signing, and refuse to sign if the check fails. +- Embed `tee_image_hash = keccak256(PCR0)` in every signed journal so signatures bind to one EIF + measurement. +- Content-verify every hash-keyed preimage as it enters the enclave so derivation cannot consume + preimages whose values do not match their keys. +- Refuse to prove for chain IDs not present in the hardcoded `CONFIG_HASHES` table. +- Cap `user_data` and `nonce` at the NSM 512-byte limit at the host RPC boundary so oversize + attestation requests cannot reach the enclave. +- Serve at most one request per vsock connection and keep no mutable state between requests so a + malformed request cannot influence a later one. +- When `--tee-prover-registry-address` is configured, fail closed on per-request signer validity + and reject the request if no configured enclave's signer is currently valid onchain. From fbafa53f057fb463c20db5725515b65a890da48d Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Fri, 22 May 2026 03:45:58 +0100 Subject: [PATCH 2/3] docs(specs): convert tee-provers to .mdx and wire into nav - Rename tee-provers.md to .mdx with Mintlify title/description frontmatter - Add new Proofs group under Protocol with the tee-provers page so it is reachable from the rendered sidebar --- .../protocol/proofs/{tee-provers.md => tee-provers.mdx} | 5 ++++- docs/docs.json | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) rename docs/base-chain/specs/protocol/proofs/{tee-provers.md => tee-provers.mdx} (98%) diff --git a/docs/base-chain/specs/protocol/proofs/tee-provers.md b/docs/base-chain/specs/protocol/proofs/tee-provers.mdx similarity index 98% rename from docs/base-chain/specs/protocol/proofs/tee-provers.md rename to docs/base-chain/specs/protocol/proofs/tee-provers.mdx index 76d593f7f..8dcfd4292 100644 --- a/docs/base-chain/specs/protocol/proofs/tee-provers.md +++ b/docs/base-chain/specs/protocol/proofs/tee-provers.mdx @@ -1,4 +1,7 @@ -# TEE Provers +--- +title: "TEE Provers" +description: "Specification of the TEE prover, an offchain service that re-executes L2 block ranges inside AWS Nitro Enclaves to produce signed proof material for AggregateVerifier games." +--- The TEE prover is an offchain service that produces signed proof material for `AggregateVerifier` games by re-deriving and re-executing an L2 block range inside an AWS Nitro Enclave. The same diff --git a/docs/docs.json b/docs/docs.json index b1d9ec8a3..ff0a6a2e5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -252,6 +252,12 @@ ] } ] + }, + { + "group": "Proofs", + "pages": [ + "base-chain/specs/protocol/proofs/tee-provers" + ] } ] }, From d596b0c8495c9d793c362e2d07d5157a1c63ffdc Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Fri, 22 May 2026 04:10:51 +0100 Subject: [PATCH 3/3] docs(specs): rename tee-provers -> tee-prover for consistency The other proof role pages (challenger, proposer, registrar, zk-prover) all use singular naming. The TEE page was the odd one out. Renames the file slug, frontmatter title, and docs.json nav entry to singular 'TEE Prover'. Body content is unchanged (it already used 'TEE prover' singular throughout). --- .../specs/protocol/proofs/{tee-provers.mdx => tee-prover.mdx} | 2 +- docs/docs.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/base-chain/specs/protocol/proofs/{tee-provers.mdx => tee-prover.mdx} (99%) diff --git a/docs/base-chain/specs/protocol/proofs/tee-provers.mdx b/docs/base-chain/specs/protocol/proofs/tee-prover.mdx similarity index 99% rename from docs/base-chain/specs/protocol/proofs/tee-provers.mdx rename to docs/base-chain/specs/protocol/proofs/tee-prover.mdx index 8dcfd4292..64911213d 100644 --- a/docs/base-chain/specs/protocol/proofs/tee-provers.mdx +++ b/docs/base-chain/specs/protocol/proofs/tee-prover.mdx @@ -1,5 +1,5 @@ --- -title: "TEE Provers" +title: "TEE Prover" description: "Specification of the TEE prover, an offchain service that re-executes L2 block ranges inside AWS Nitro Enclaves to produce signed proof material for AggregateVerifier games." --- diff --git a/docs/docs.json b/docs/docs.json index ff0a6a2e5..99e68ef5c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -256,7 +256,7 @@ { "group": "Proofs", "pages": [ - "base-chain/specs/protocol/proofs/tee-provers" + "base-chain/specs/protocol/proofs/tee-prover" ] } ]