Espresso 3b: TEE batcher (re-hosted) - #459
Conversation
f840eee to
12b46a6
Compare
c78ed9f to
319b3ab
Compare
12b46a6 to
f8480f8
Compare
319b3ab to
3e7dcd9
Compare
9330de1 to
1616378
Compare
6cf6a1f to
eb6ff32
Compare
1616378 to
25a6c63
Compare
eb6ff32 to
ab13906
Compare
| // Sign represents the interface for signing things via eth_sign. | ||
| func (s *SignerClient) Sign(ctx context.Context, address common.Address, data []byte) ([]byte, error) { | ||
| var result hexutil.Bytes | ||
| if err := s.client.CallContext(ctx, &result, "eth_sign", address, data); err != nil { |
There was a problem hiding this comment.
This eth_sign call can't work against op-signer, and I don't think op-signer should be extended to make it work either.
op-signer doesn't serve eth_sign. Its server registers only two namespaces — eth (eth_signTransaction) and opsigner (signBlockPayload, signBlockPayloadV2): https://github.com/ethereum-optimism/infra/blob/main/op-signer/service/service.go#L73-L82. There is no arbitrary-data signing method. And this SignerClient can only talk to op-signer in the first place: NewSignerClient dials with op-signer's mutual-TLS and then handshakes with a health_status ping before returning, so pointing it at a plain geth or another HSM front-end fails at construction. So the call here errors method-not-found at runtime against a real op-signer.
The reason op-signer has no such method is deliberate, and it's why I'd argue against adding one. An HSM-backed signer must never sign raw bytes the caller hands it. If it did, a compromised batcher could pass a 32-byte value that is really the sighash of an L1 transaction spending the funded key, or a block payload for equivocation, and the HSM would sign it. That's why every op-signer method reconstructs the thing being signed server-side from typed arguments and binds a domain tag and chain id into the hash — see BlockPayloadArgs (domain, chainId, payloadBytes) and Message().ToSigningHash(). The client never sends a bare hash. Adding an eth_sign that signs any digest would remove exactly that protection for a key that also signs L1 transactions.
There's a second, backend-independent problem: eth_sign applies the EIP-191 prefix ("\x19Ethereum Signed Message:\n32" || hash), but the verify side recovers over the raw digest (crypto.SigToPub(batchHash, sig) in op-node/rollup/derive/espresso_batch.go):
optimism/op-node/rollup/derive/espresso_batch.go
Lines 105 to 107 in 014f88d
If remote HSM signing of Espresso batches is a requirement, the right shape is a purpose-built op-signer method modeled on signBlockPayload: the client sends typed args (a fixed domain tag, the L2 chain id / namespace, and the batch commitment), op-signer reconstructs the domain-separated digest and signs it with the HSM key, and op-node verifies the same digest. That also resolves the separate domain-separation gap (the batch digest is currently a bare keccak256(rlp(batch)) with no namespace binding). It is a change in the op-signer repo, so it can't land from this PR alone — until it exists, only the local private-key ChainSigner actually works. I'd suggest dropping this eth_sign helper and the clientSigner branch here rather than shipping a path that can't sign or verify.
There was a problem hiding this comment.
Jean is going to look and respond here as he did this work.
There was a problem hiding this comment.
Thanks for the thorough writeup. The analysis is right about op-signer, and the fact that it reads as targeting op-signer at all is a documentation gap on our side, so let me fill in the missing context first.
This Sign call doesn't talk to op-signer. The --signer.endpoint it's deployed against is espresso-kms-signer (https://github.com/EspressoSystems/espresso-kms-signer), a small AWS KMS signing sidecar we built specifically to speak the signer protocol this batcher uses: health_status, eth_signTransaction, and eth_sign (https://github.com/EspressoSystems/espresso-kms-signer/blob/758e4d0/src/rpc.rs#L26-L46). It runs as an ECS sidecar next to op-batcher-tee and has done full batch-posting cycles on our kms test devnets (batches accepted on L1 and on HotShot). On the client side, NewSignerClient isn't actually op-signer-specific: mTLS only kicks in when tlsConfig.Enabled is set (https://github.com/celo-org/optimism/blob/c828d61cd9/op-service/signer/client.go#L31-L65) (plain HTTP otherwise), and the handshake is just a health_status call into a Go string, which the sidecar answers. That genericness is what let us add KMS signing with no batcher code changes.
On EIP-191: agreed that a standard eth_sign would break recovery, but the sidecar's eth_sign is deliberately non-standard; it signs the raw 32-byte digest with no message prefix and returns r||s||v with v ∈ {0,1}, (i.e. go-ethereum's crypto). Sign convention, which makes it semantically identical to the privateKeySigner path in this PR (crypto.Sign(hash, privKey) (https://github.com/celo-org/optimism/blob/c828d61cd9/op-service/crypto/espresso.go#L161)). Both verify the same way under SigToPub. And it's pinned rather than hoped-for: the sidecar's fixture generator (https://github.com/EspressoSystems/espresso-kms-signer/blob/758e4d0/tests/fixtures/gen/main.go#L158-L178) is a Go program that imports op-service/signer itself and records the exact JSON-RPC params bytes geth's RPC client marshals (including the base64 []byte encoding), which CI replays against the production handler; there's also a localstack test that runs the real KMS path end-to-end and asserts the recovered address. That said, you're completely right that calling a method eth_sign while breaking eth_sign semantics is asking for exactly this confusion. I'd be happy to rename it in a follow-up, and we'll add a doc comment on SignerClient.Sign pointing at the sidecar and its semantics either way. (Small note: the espresso_batch.go verify code you linked has since moved into espresso-streamers digest recovery (https://github.com/EspressoSystems/espresso-streamers/blob/1884a718fbf7/op/derivation/espresso_batch.go#L102-L104).)
On "an HSM signer should never sign raw caller-supplied bytes", no pushback on the principle, and I'd rather be precise about what it costs us here. A raw-digest endpoint does mean the sidecar's eth_signTransaction guards (chainId, from, to-allowlist) only protect against a buggy caller, not a malicious one; anyone who can reach the endpoint can get a signature over an arbitrary digest, including an L1 tx sighash. What bounds the damage is that this key was never a batch-eligibility authority: batch acceptance in TEE mode requires an EIP-712 commitment signature from the ephemeral key generated inside the Nitro enclave and verified on-chain via the TEE verifier; the sidecar never touches that key. So a compromised sidecar (or its host) can spend the batcher address's gas funds and inject noise into our own HotShot namespace, but it can't make derivation accept a batch the enclave didn't produce. That's our documented trust model: the sidecar is trusted for availability, not integrity.
You're also right about the missing domain separation, and that one stands on its own: the namespace lives in the transaction envelope outside the signed bytes, so the signature binds neither chain nor namespace, under the local key just as much as the remote one. Fixing it means changing the digest every verifier reconstructs, so it's a coordinated change across the batcher and espresso-streamers with a migration story for payloads already in the stream, and we'll file it as a tracked issue rather than fold it into this PR.
Where I'd push back is on the remedy. A typed, domain-separated signing method modeled on signBlockPayload is the right end state, but it belongs in espresso-kms-signer (op-signer isn't in this deployment), and it should land together with the digest-scheme change since both alter what verifiers recover over. Dropping clientSigner in the meantime wouldn't remove the capability this comment worries about; it would move the key from KMS hardware into batcher memory, which is a strict downgrade for the same attack surface. So my proposal: keep clientSigner/Sign as-is here, add a comment linking the sidecar and its non-standard semantics, and file two linked follow-ups, one for the typed domain-separated method (including the eth_sign rename/retirement) and one for the digest-scheme migration (which I will discuss with the team). Happy to talk through the typed-method design if you have opinions on the shape!
There was a problem hiding this comment.
Hi @jjeangal, I hadn't realised this was talking to the kms signer.
So yes, agreed on the followups 👍
05288e9 to
78e33c9
Compare
8a27289 to
4ad160f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4037710ec1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Sign represents the interface for signing things via eth_sign. | ||
| func (s *SignerClient) Sign(ctx context.Context, address common.Address, data []byte) ([]byte, error) { | ||
| var result hexutil.Bytes | ||
| if err := s.client.CallContext(ctx, &result, "eth_sign", address, data); err != nil { |
There was a problem hiding this comment.
Encode eth_sign payload as hex data
When Espresso is configured with a remote signer, this passes a raw []byte as the JSON-RPC argument to eth_sign. Bare Go byte slices marshal as base64 JSON strings, but Ethereum JSON-RPC DATA parameters are expected to be 0x-hex encoded, so remote signers will reject the request or sign unexpected bytes; wrap the payload as hexutil.Bytes(data) before calling the signer.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Working as designed. The remote signer is espresso-kms-signer, whose eth_sign expects Go's default []byte JSON encoding (pinned by fixtures and validated end-to-end). Applying hexutil.Bytes would actually break it.
Addresses the stall found in the PR 459 review of peekNextBatch: the old streamer could discard a good batch when the channel manager tip and the streamer position described different blocks, stalling it for good. The v2 streamer owns the tip tracking: position and tip hash move together, repositioning is explicit via SetBatchPosition, and Reset / SetProperHead / Refresh / Update are gone. - pin espresso-streamers to 22c396aa (PR 36, op streamer v2) - construct the streamer in StartBatchSubmitting (needs a context and a synced L2 node to resolve its anchor), anchor it at the safe L2 head, and run it from its own poll loops via Start/Stop - drop peekNextBatch and the cachedSyncStatus provider; the loading loop now just Peeks batches extending the tracked tip and advances after the channel manager accepts them - re-anchor instead of skipping on ToBlock/AddL2Block failures - drop the dead BatchStreamerFromCLIConfig (no callers, v1-only API) Co-Authored-By: Claude Fable 5 <[email protected]>
…t running after an error.
…r reset on the same heads.
…art does not block later start attempts. Co-Authored-By: Claude Fable 5 <[email protected]>
…t startup does not depend on the L2 endpoint serving the historical origin height. Co-Authored-By: Claude Fable 5 <[email protected]>
…oint so that the Espresso batcher never treats pre-activation blocks as its own. Co-Authored-By: Claude Fable 5 <[email protected]>
…k timeout so that a stalled endpoint cannot hang StartBatchSubmitting while it holds the start mutex. Co-Authored-By: Claude Fable 5 <[email protected]>
…cal-safe head so that already-derived blocks are not resubmitted after a restart. Co-Authored-By: Claude Fable 5 <[email protected]>
…choring on a cached unsafe floor block, whose reorg could permanently wedge fork selection. Co-Authored-By: Claude Fable 5 <[email protected]>
…ero LocalSafeL2 cannot floor the queue at genesis and re-enqueue derived history. Co-Authored-By: Claude Fable 5 <[email protected]>
…ocalSafeL2 cannot disable the stale-batch re-anchor floor and republish derived blocks. Co-Authored-By: Claude Fable 5 <[email protected]>
…ent sync-status failure cannot empty the channel manager while leaving the streamer at its old cursor. Co-Authored-By: Claude Fable 5 <[email protected]>
|
@jjeangal I merged the |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ee6cc80d9f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // call reverts (Unauthorized{Espresso,Fallback}Batcher) and the batcher loops. | ||
| func (l *BatchSubmitter) isBatcherActive(ctx context.Context) (bool, error) { | ||
| // Check if contract code exists at the address | ||
| code, err := l.L1Client.CodeAt(ctx, l.RollupConfig.BatchAuthenticatorAddress, nil) |
There was a problem hiding this comment.
Wrap the active-check CodeAt call in a timeout
When a BatchAuthenticator is configured, publishStateToL1 reaches this check on every publish tick via shouldSkipPublishForActiveSeq. This CodeAt call uses the publishing loop's lifetime killCtx, while the contract calls just below are bounded by NetworkTimeout; if the L1 RPC accepts the request but stalls here, the publishing loop never reaches publishTxToL1 and graceful shutdown waits until the caller's stop context force-cancels the kill context. Please wrap this call in the same per-RPC timeout used for the subsequent reads.
Useful? React with 👍 / 👎.
Based on #448
Pulls in the Espresso/TEE batcher .
op-batcher/enclave/attestation.goand modifies the driver to add an Espresso path. Bulk of the changes is inespresso_-prefixed files.This is #447, re-hosted from an in-repo branch (now properly stacked).