Skip to content
BaseHub by wbnns Updated

TEE Prover

The TEE prover is the 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 powers both proposal creation and dispute nullification: callers (proposer or challenger) submit a block range, the host gathers witness data, the enclave re-executes the range, and a per-instance key that lives only inside the enclave signs the resulting journal.

Verification of the signature is self-contained 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 has been deregistered, cannot satisfy verification. The Nitro hypervisor’s per-instance attestation binds the signer’s public key to a specific PCR0, which the registrar certifies separately.

A conforming TEE prover stack:

  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. Re-derive and re-execute the L2 range inside the enclave 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 never decides whether a proposal or dispute is correct. It re-executes the range, signs the result when the re-execution matches the claim, and returns. Callers still recheck game state before submitting onchain.

The service runs as two processes on a Nitro-capable EC2 parent:

  • A host binary (base-prover-nitro-host) that terminates JSON-RPC, gathers 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 talk only over vsock. The enclave has no network interface; all external RPC connectivity sits on the host side.

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 handles exactly one request before closing. The enclave keeps no per-request state between connections; the only persistent state inside is the signer key and the boot-time PCR0 measurement.

Vsock frames use a u32 big-endian length prefix followed by a bincode payload, with a 5-minute read timeout. The transport caps write chunks at 28 KiB to dodge a Linux kernel virtio_vsock SKB corruption bug.

The host serves two JSON-RPC namespaces on a single HTTP listener, plus an HTTP GET /healthz proxy that routes to the JSON-RPC healthz method.

MethodPurpose
prover_proveProduce per-block and aggregate signed proposals for a block range.
enclave_signerPublicKeyReturn the 65-byte uncompressed secp256k1 public key for each enclave.
enclave_signerAttestationReturn the COSE_Sign1 attestation document for each enclave.
healthz / GET /healthzLiveness, plus optional onchain signer validity (latching) when enabled.

The enclave_* calls are all-or-nothing across multiple enclaves: a transport failure or an error from any enclave fails the whole response. Callers register every signer together, so a partial response would be unusable.

ProofRequest fields:

FieldMeaning
l1_headL1 head block hash anchoring the derivation window.
l1_head_numberL1 head block number.
agreed_l2_head_hashL2 block hash at the parent of the range.
agreed_l2_output_rootOutput root at the parent. Used as the starting state.
claimed_l2_output_rootClaimed output root at the target. Trust-critical: the enclave only signs if re-execution matches it.
claimed_l2_block_numberTarget L2 block number (ending block of the range).
proposerL1 address that will submit the proof. Committed into the journal so onchain msg.sender must match.
intermediate_block_intervalSampling stride for intermediate roots in the aggregate proposal.
image_hashkeccak256(PCR0) the caller expects. Currently informational; routing uses onchain signer validity.

ProofResult::Tee carries:

FieldMeaning
aggregate_proposalOne Proposal covering the full range with sampled intermediate roots.
proposalsPer-block Proposals in order, each chaining prev_output_root to the previous block’s root.

Each Proposal:

FieldMeaning
output_rootOutput root at this proposal’s ending block.
signature65-byte secp256k1 ECDSA signature (`r
l1_origin_hashL1 head hash used during derivation.
l1_origin_numberL1 head block number.
l2_block_numberEnding L2 block number for this proposal.
prev_output_rootOutput root before this proposal’s range.
config_hashPer-chain config hash hardcoded into the enclave.

A single-block range collapses the aggregate proposal into the lone per-block proposal. For multi-block ranges 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 final block in the range.

Takes optional user_data and nonce byte arguments. The NSM hardware caps each at 512 bytes, and oversize values are rejected at the host RPC layer before reaching vsock. 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.

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<u8>) 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 request’s claimed_l2_output_root. 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 only uses the aggregate signature, repacking it for nullify() via ProofEncoder::encode_dispute_proof_bytes. The enclave neither knows nor cares which caller it is serving.

Each signature is secp256k1.sign(keccak256(journal)), 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:

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. Embedding it into every journal means 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 whichever EIP-155 form they need before L1 submission.

--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.

--tee-prover-registry-address is required whenever more than one CID is configured. Without the registry there is no deterministic way to pick between enclaves, so multi-enclave deployments are fail-closed-only.

Per-request routing walks 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 case is image rotation. Run the old and new EIFs side-by-side; both signers stay 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 every new request routes to it.

enclave_* calls fan out to every configured enclave so the registrar can register every signer in one cycle.

Setting --tee-prover-registry-address enables two registry-backed behaviors on the host:

  • GET /healthz only reports healthy 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 through 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 reports healthy as long as the server is up, and prover_prove routes to the first configured enclave.

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). A wrong-length PCR0 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 spec for how attestations are verified and submitted onchain.

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.

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.

A conforming TEE prover preserves 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 disagree with 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.