Skip to content

feat(validator): Web3Signer remote signing and the keymanager /eth/v1/remotekeys route - #776

Open
pablodeymo wants to merge 5 commits into
mainfrom
validator-web3signer
Open

pablodeymo wants to merge 5 commits into
mainfrom
validator-web3signer

Conversation

@pablodeymo

@pablodeymo pablodeymo commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

  • An audit of the validator client listed remote signing as missing: keys could only be local EIP-2335 keystores, and SigningMethod had one variant. Every other consensus client supports Web3Signer, and operators who keep keys in a signer cannot run this client without it.
  • A remote signer keeps a slashing-protection record of its own: it receives every message in full and refuses a double vote itself. That is on top of this client's EIP-3076 record (feat(validator): durable EIP-3076 slashing protection #734, feat(validator): EIP-3076 slashing-protection import and export #736), which a remote key is registered in and checked against like a local one.

What Changed

  • crates/validator/src/keys/web3signer.rs (new): the Web3Signer client. Each signature is a POST /api/v1/eth2/sign/{pubkey} with type, fork_info, signingRoot and the typed message, per Web3Signer's OpenAPI. All ten signature kinds the client produces map to a request type, the gloas EXECUTION_PAYLOAD_ENVELOPE and PAYLOAD_ATTESTATION_MESSAGE included. 412 (slashing protection), 404 (key not held) and transport failures are Error::Signing, logged and counted, never retried. A URL carrying credentials, a query string or a fragment is refused, and every URL error is reported through redact, so no credential reaches a log; a failed response's body is cut to 200 characters.
  • signing.rs: every SigningContext::sign_* is async over &RwLock<ValidatorStore>. It resolves the signer under a read guard scoped to the lookup and signs after releasing it. A remote signer's answer is verified against the validator's public key and the signing root before it is used. sign_all asks for a duty's signatures at once and gives each SIGNING_BUDGET (2s), so a hung signer costs only its own keys. Selection proofs are remembered once produced. sign_block takes the BeaconBlockHeader; remote block signing is refused before bellatrix, where BLOCK_V2 wants the full block.
  • Duties (attestation, aggregation, sync_committee, payload_attestation, subscriptions): each signs its batch through sign_all and publishes whatever arrived; every signing failure is counted. attestation and proposal keep main's order: check and durably record in slashing protection, then sign.
  • keys/store.rs: SigningMethod::Web3Signer { signer: Arc<Web3Signer> }; one client per signer URL, held weakly by the store and strongly by the keys and in-flight signatures that use it. insert_remote_signer is a map insert, so a keymanager import builds its clients outside the store's lock.
  • keys/definitions.rs: entries carry type: local_keystore or type: web3signer, Lighthouse's names. An entry without a type (every existing file) is read by its fields; one naming both a keystore and a url is refused. A web3signer entry carrying a field this client does not implement (root_certificate_path, say) is refused, description excepted; a local entry is read past such fields, so a Lighthouse-written file loads.
  • http_api/remotekeys.rs (new): GET, POST, DELETE /eth/v1/remotekeys. An import is capped at 1000 keys, holds definitions_lock until the keys are in the store, saves the definitions once, registers the keys in the slashing-protection record in one write before any is loaded, and takes its entries off the file again when that write fails. DELETE leaves the record in place.
  • http_api/keystores.rs: the two routes partition the store. /keystores lists and deletes local keys only; an import of a key the other route holds, in memory or in the file, is duplicate; persist_delete removes only its own route's kind of entry; DELETE /keystores answers not_found for a key a remote signer holds and exports nothing for it.
  • bin/ethlambda/src/validator.rs: --web3signer-url, the default signer for a remote key imported without a url. Validated at startup.
  • Docs: docs/cli.md (flag, a "Remote signing (Web3Signer)" section, the route), docs/spec_deviations.md, CLAUDE.md.

Correctness / Behavior Guarantees

  • A local key signs exactly as before: same roots, same domains, same bytes.
  • No store guard is held across a network call or a slashing-protection write anywhere in the duty path.
  • The record reaches the disk before a signature is asked for, for remote keys as for local ones.
  • The signing root is always sent, and the signature that comes back is verified against it, so a signer that disagrees about the domain costs the signature rather than producing a wrong one.
  • One slow or unreachable signer delays a duty by SIGNING_BUDGET at most and never costs another key its signature.
  • readonly is always false on /remotekeys; DELETE /remotekeys has no slashing_protection field, as the spec defines none there.

Tests Added / Run

  • keys/web3signer.rs: request wire shapes pinned against Web3Signer's schema, response parsing (JSON and bare hex), URL validation (credentials, query, fragment, redaction), body truncation.
  • signing.rs: fork_info on both sides of mainnet's altair boundary; sign_all's budget; the selection-proof cache; a remote module with a local axum fake signer that signs for real, covering the request body, the 412, 404 and unreachable paths, a pre-bellatrix block, and an answer by another key, over another root, or that is no signature.
  • keys/definitions.rs and keys/store.rs: legacy file parses unchanged, typed and untyped entries, an entry with both methods refused, unimplemented fields refused on a remote entry and skipped on a local one, client sharing per URL and release with a signature in flight.
  • http_api/remotekeys.rs and keystores.rs: import/list/delete round trip with persistence, default-URL fallback, duplicates across both routes, each route deleting only its own keys and exporting nothing for the other's, registration in slashing protection and its rollback.
  • cargo test -p ethlambda-validator -p ethlambda -p ethlambda-rpc --profile release-fast: 421, 265 and 346 passed. make lint: clean. cargo fmt --check: clean.

Related Issues / PRs

  • Not exercised against a real Web3Signer yet; the protocol tests use a local fake. Worth a manual run with the consensys/web3signer image before relying on it.
  • feat(validator): persist per-validator fee recipient, graffiti and gas limit in validator_definitions.yml #780 adds suggested_fee_recipient, graffiti and gas_limit to the definitions file; until it lands, a web3signer entry carrying them is refused as unimplemented.
  • Both delete routes still hold the store's write lock across a file read and save per key, as /keystores already did.
  • Follow-ups, out of scope here: client certificates / custom CA for the signer, importing every key the signer lists at startup (GET /api/v1/eth2/publicKeys), a remote-signer request-latency histogram.

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test — the validator crate, the binary and the rpc crate were run at release-fast (1032 tests); CI runs the rest

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

PR 776 review: Web3Signer remote signing

The overall design is sound. Signing behind a short read guard with an Arc-cloned SigningMethod, routes that partition local and remote keys, untagged definitions that stay backward-compatible, and guards that run before signing all look right. Below are the issues worth addressing, most important first.

Correctness / safety

1. Signatures from the remote signer are never verified (keys/web3signer.rs, parse_signature_body / Web3Signer::sign)

Any 96 bytes in the response are accepted and published. Failure modes:

  • a misconfigured signer that maps the pubkey to the wrong key;
  • a signer behind a compromised proxy;
  • an older Web3Signer that computes a different root (e.g. an unknown version).

Each of these makes the client gossip invalid attestations or blocks. That costs peer score, and for a block it wastes a proposal. Selection proofs are also fed straight into is_aggregator unverified.

The fix is cheap: one blst verify, about 1 ms. In the Web3Signer arm of SigningContext::sign (signing.rs), check the returned signature against pubkey and root with DST. Map a failure to Error::Signing.

2. One slow signer stalls every attestation in the slot (attestation.rs, the join_all block)

  • REQUEST_TIMEOUT is 8 s.
  • join_all waits for the slowest future before anything is published.
  • With keys spread across two signers, or with local keys mixed in, one hung signer delays all attestations by up to 8 s. That is past the 4 s attestation deadline and well into the aggregation window.

Suggested fixes:

  • Bound the batch with a deadline tied to the slot. For example, wrap the batch in tokio::time::timeout and treat unfinished futures as failures.
  • Or publish local signatures first.
  • Either way, 8 s is too long for attestation signing.

3. Race between remote import and remote delete leaves a key signing that is not on disk (http_api/remotekeys.rs, import)

import drops _definitions_guard before taking store.write(). A DELETE /remotekeys can run in that gap:

  1. It sees the just-persisted entry and removes it from the file.
  2. It finds nothing in memory and reports deleted.
  3. Then finish_import inserts the key.

The result is a key reported as deleted that keeps signing until restart. Holding the definitions lock across the store write is consistent with the documented lock order (definitions → store), so it is safe. Hold it until after finish_import.

4. Inconsistent state on the finish_import duplicate path (remotekeys.rs, finish_import)

If the store-side duplicate check fires, the remote definition has already been persisted. The file then says remote, memory says local, and the response says duplicate. Item 3 makes this path effectively unreachable, but if it is kept, it should undo the upsert.

Performance

5. Selection proofs are serial round trips, computed twice per duty

subscriptions::subscribe and aggregation (the selected loop) each call selection_for sequentially, once per duty. sync_committee does up to 4 serial selection proofs per duty. With remote keys, each call is a network round trip.

The same slot selection proof is also requested once at subscription time and again at aggregation time. Two changes would help:

  • Use join_all here as the attestation path does.
  • Consider caching (pubkey, slot) → proof. This is safe because the proof is deterministic.

6. Signing metric now includes network time

metrics::time_signing() in sync_committee.rs, payload_attestation.rs and attestation.rs now measures network latency. The metric's meaning changed silently. Either document it or split local and remote timing.

Protocol / Web3Signer compatibility

7. Unconfirmed request types for gloas

Check that Web3Signer actually supports EXECUTION_PAYLOAD_ENVELOPE and PAYLOAD_ATTESTATION_MESSAGE and accepts a GLOAS/FULU version. These request types look speculative today. If they are not supported, gloas duties fail at runtime for remote keys. Worth a note in docs/cli.md.

8. Test uses a pre-Bellatrix header (signing.rs, test a_block_request_names_the_fork_and_carries_the_header)

For BLOCK_V2 with ALTAIR, Web3Signer expects the full block, not block_header. Only Bellatrix and later use block_header. The test pins a request shape a real signer would reject. Use a post-Bellatrix fork in the test.

9. No TLS backend check

Confirm the workspace reqwest has a TLS backend enabled (rustls-tls or default-tls). Otherwise https:// signers parse fine at startup and then fail on every request.

Smaller items

  • drop_unused_signers relies on Arc::strong_count > 1 (keys/store.rs). In-flight signing futures hold clones, so a signer whose last key was just removed survives until the next mutation. Deriving liveness from self.validators would be exact and less fragile.
  • Credentials can still leak through parse errors (Web3Signer::new). The credentials check returns only the host, but the earlier Url::parse error path reports base_url verbatim. That string can include user:pass@ if parsing fails for an unrelated reason.
  • Error::Keystore { path: url, … } is reused for remote-definition errors (store.rs load, web3signer.rs). The variant name is misleading in logs. Consider a dedicated RemoteSigner variant.
  • The proposal path's header check is debug-only. It recomputes the root from produced.header() and relies on debug_assert_eq! to match block_root. Since this is the slashable signature, a release-mode if header.hash_tree_root() != block_root { return Err(..) } is cheap insurance.
  • Signatures can outlive a successful delete. A delete through either route can return while an in-flight sign still holds a cloned SigningMethod, so a signature can be produced and published after "deleted". This is not new in kind, since publishing already happened after the lock was released. The window is now up to the remote timeout, though, which is worth a line in the delete docs.

Automated review by Claude · claude-opus-5-5 · custom prompt

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

  1. [P1] Import/delete race can reactivate a deleted key — remotekeys.rs:105. Releasing definitions_lock before updating the store allows a concurrent DELETE to remove the persisted definition and report deleted, followed by this import activating the key in memory. The client can then sign after the operator believes deletion succeeded. Keep the definitions lock through the store update.

  2. [P1] Subscription signing can stall the entire duty loop — subscriptions.rs:51. Selection proofs now require sequential remote requests. refresh_epoch awaits this loop before serving any duties, without an overall timeout. At 100 ms per request, 200 duties block processing for 20 seconds; an unresponsive signer costs eight seconds per duty. Request proofs concurrently with bounded concurrency and prevent subscription work from delaying scheduled duties.

  3. [P2] One stalled signer discards healthy validators’ attestations — attestation.rs:203. join_all withholds every completed signature until the slowest request finishes. If one remote signer stalls until the slot deadline, the outer timeout drops the entire batch, including successful local signatures. Publish completed signatures incrementally, or enforce a signing deadline that leaves time to submit successful results.

Tests could not run: Rustup attempted to write to its read-only toolchain directory.


Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

🤖 Gemini Code Review

Overall, this is a well-structured and well-tested implementation of Web3Signer remote signing and the /eth/v1/remotekeys keymanager API.

Here are a few issues and improvements to consider:

  1. Race window between disk persistence and in-memory store update in remotekeys::import (crates/validator/src/http_api/remotekeys.rs:94-116)
    In remotekeys::import, _definitions_guard is dropped at line 105 before acquiring context.store.write().await at line 107 (note also that the comments at lines 95–97 and lines 177–180 contradict each other on whether definitions_lock is still held).
    If a concurrent DELETE /eth/v1/remotekeys or POST /eth/v1/keystores for the same pubkey runs in the window between drop(_definitions_guard) and context.store.write().await:

    • A DELETE /eth/v1/remotekeys can acquire definitions_lock and store.write(), see on_disk == true, remove the entry from validator_definitions.yml, and report "deleted"—after which finish_import acquires store.write() and inserts the deleted key into memory.
    • A POST /eth/v1/keystores can overwrite validator_definitions.yml during keystores::prepare_import before keystores::finish_import notices the key in store.

    Since KeymanagerContext::definitions_lock (crates/validator/src/http_api/mod.rs:55-60) establishes the lock hierarchy definitions_lock -> store, keeping _definitions_guard alive until after drop(store) at line 116 (just as remotekeys::delete does at lines 206–217) makes the disk and in-memory updates atomic across all keymanager handlers without risk of deadlock.

  2. Duplicate definitions in validator_definitions.yml are silently overwritten when the second entry is remote (crates/validator/src/keys/store.rs:74-86)
    In ValidatorStore::load, the LocalKeystore branch (lines 150–162) checks if pubkey is already present in store.validators and errors with "validator ... is defined more than once in validator_definitions.yml". However, the SigningDefinition::Web3Signer branch calls store.insert_remote(pubkey, url)? directly, which overwrites any existing local or remote entry for pubkey. Checking store.contains(&pubkey) before store.insert_remote will keep duplicate detection consistent regardless of entry order.

  3. Web3Signer::new accepts URLs with query strings or fragments (crates/validator/src/keys/web3signer.rs:49-70)
    Web3Signer::sign builds the request URL via format!("{}/api/v1/eth2/sign/{pubkey_hex}", self.base_url) (line 98). If url contains a query (?) or fragment (#), reqwest::Url::parse accepts it, and the formatted /api/v1/eth2/sign/{pubkey_hex} suffix ends up inside the query string or fragment rather than the URL path. Rejecting parsed.query().is_some() || parsed.fragment().is_some() (or non-empty path segments if unintended) in Web3Signer::new will catch this up front.

  4. Sequential remote signing in subscriptions, aggregation, sync_committee, and payload_attestation (crates/validator/src/subscriptions.rs:51-71, crates/validator/src/aggregation.rs:95-106, 156-205, crates/validator/src/sync_committee.rs:135-160, 197-225, 263-285, crates/validator/src/payload_attestation.rs:123-155)
    AttestationService uses futures::future::join_all so that N remote validators do not pay N sequential HTTP round-trips within a slot. The other duty loops still .await each signature serially. This is especially noticeable in:

    • subscriptions::subscribe, which calls selection_for (AGGREGATION_SLOT signature) sequentially for every attester duty across the epoch.
    • AggregationService::run, which calls selection_for sequentially for every duty in the slot at the 2/3-slot deadline before fetching and signing aggregates.

    Running these signing passes concurrently with join_all (as done in attestation.rs) would avoid serial round-trip latency when multiple validators share a remote signer.


Automated review by Gemini · gemini-4-argon · custom prompt

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

🤖 Grok Code Review

I'll read the keymanager import/delete paths and signing lock usage to verify the remote-signer changes.


Automated review by Grok (xAI) · grok-4.7 · custom prompt

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

🤖 DeepSeek Code Review

Overall direction is sound, but I found a few issues worth addressing before merge.

  • Keymanager import/delete race (crates/validator/src/http_api/remotekeys.rs)
    In remotekeys::import, drop(_definitions_guard) happens after prepare_import has already persisted the definition, but before finish_import takes the store write lock and inserts the key into memory. A concurrent DELETE /eth/v1/remotekeys for the same key can therefore:

    1. Acquire definitions_lock,
    2. See the just-written definition on disk (on_disk == true),
    3. Remove that definition and report deleted,
    4. Release the lock,
    5. Have its store.write land before the importer’s store.write.

    The importer then only rechecks store.contains in finish_import; since the delete never saw the key in memory, this returns false, so the importer inserts the key into the live store. The process now signs with a key the API reported deleted, until the next restart. This is a correctness/safety problem for a keymanager API.

    Suggest either holding definitions_lock across finish_import’s store write, or re-reading/re-validating the definitions entry under definitions_lock after taking the store write lock, not just checking store.contains. The same split exists in the existing keystores import path, so it is worth fixing there too.

  • Plaintext HTTP accepted for Web3Signer (crates/validator/src/keys/web3signer.rs)
    The scheme check explicitly permits both http:// and https:// for any host. For a process that sends complete, slashing-protection-relevant messages — and whose signer holds live validator keys — this exposes signing requests to network eavesdroppers/MITM when the signer URL is not loopback. The docs say the signer is reached over “plain HTTPS,” but the code does not enforce that.

    Recommend requiring https://, or at minimum permitting http:// only for loopback/private addresses and warning loudly. Otherwise an operator can introduce a serious signing-oracle/slashing risk with a seemingly harmless --web3signer-url http://....

  • Remote signing remains serial for large non-attestation duties (crates/validator/src/sync_committee.rs, crates/validator/src/payload_attestation.rs, crates/validator/src/subscriptions.rs)
    The attestation path correctly uses join_all so a validator set behind one Web3Signer pays one round trip per slot rather than N. But the sync-committee loops in sync_committee.rs still sign one validator at a time: sign_sync_committee_message, sign_sync_selection_proof, and sign_contribution_and_proof all .await inside sequential loops. A full sync committee can be on the order of 512 validators; behind one remote signer that is 512 sequential round trips in one slot, which will not fit. payload_attestation.rs and the aggregator-selection path in subscriptions.rs have the same shape, though with smaller expected cardinality.

    Consider applying the same bounded-concurrency approach to those signing loops, or at least to sync committee message signing.


Automated review by DeepSeek · deepseek-v4-pro · custom prompt

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

  1. Remote-key import has a persistence/locking race — crates/validator/src/http_api/remotekeys.rs:73-112. prepare_import persists the definition, but import then explicitly drops definitions_lock before inserting into the store. A concurrent delete can remove that definition and return deleted, after which the import inserts the in-memory key and returns imported, leaving memory and disk disagreeing. Similarly, finish_import can return duplicate after its definition was already persisted. Keep definitions_lock held through the store mutation, preserving the existing definitions_lock -> store order, and ensure a post-persist failure cannot leave an unreported definition behind.

  2. Most remote-signing duty paths still perform network round trips serially — notably aggregation.rs:94-107, subscriptions.rs:50-71, payload_attestation.rs:120-145, and sync_committee.rs:132-153,194-214,260-280. With 100 validators, even a 100 ms signer adds 10 seconds to selection or message production; an unreachable signer can multiply the 8-second timeout by the number of duties, effectively disabling those duties. These loops need bounded concurrency similar to the attestation path. Caching deterministic selection proofs per (validator, slot/subnet) would also avoid signing them again in subscriptions and aggregation.

  3. Attestation signing fan-out is unbounded — crates/validator/src/attestation.rs:200-218. join_all concurrently issues one HTTP request per duty, so a large validator installation can overwhelm Web3Signer or its connection pool and cause timeouts. Prefer a bounded buffer_unordered/semaphore-based approach, while retaining each duty’s association with its result.

  4. Mixed signing definitions are not actually rejected — crates/validator/src/keys/definitions.rs:34-56. Untagged Serde variants ignore unknown fields by default. Consequently, an entry containing both keystore paths and url deserializes as LocalKeystore, silently ignoring the URL, despite the comment claiming the field sets cannot match both variants. Validate that exactly one complete signing field set is present, using custom deserialization or dedicated payload structs with unknown-field rejection.

Otherwise, the change looks carefully done: store read guards are narrowed correctly, attestation/proposal guards still run before signing, attestation fork information uses the target epoch, and the block-header root construction preserves the signed block root.


Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt

@MegaRedHand MegaRedHand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the Web3Signer and /eth/v1/remotekeys changes, 15 inline comments.

The main concern is liveness: a slow or unreachable signer can now stall duties for every validator, local keys included (subscriptions.rs, attestation.rs, aggregation.rs). The rest are correctness issues in the keymanager routes, two places where secrets can reach the logs, a docs claim about TLS trust roots that doesn't match the reqwest features in use, and some smaller performance items.

None of this was tested against a real Web3Signer. The BLOCK_V2 comment on signing.rs is from memory of its schema, not checked against its source.

Comment thread crates/validator/src/subscriptions.rs Outdated
.collect()
};
let mut subscriptions = Vec::with_capacity(duties.len());
for duty in duties {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subscribe now makes one remote selection-proof call per duty, one after another, inside refresh_epoch on the main duty loop, with no overall deadline. A slow or unreachable signer therefore stalls duties for every validator, local keys included.

Example: 100 validators, two epochs of duties held, signer host blackholed so each call waits the full 8s REQUEST_TIMEOUT. That's 200 × 8s ≈ 27 minutes stuck in refresh_epoch, about 133 slots of missed proposals and attestations for every key. Even a healthy signer at ~10ms per call adds ~2s before the boundary slot's proposal.

The proposal and attestation paths have slot-budget timeouts; this one has none. Running these calls concurrently with a deadline, or moving them off the duty loop, would fix it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. subscribe asks for every selection proof at once through signing::sign_all, each bounded by SIGNING_BUDGET (2s), so an unreachable signer costs the epoch boundary 2s at most instead of one timeout per duty. The proofs that arrive are remembered, so the aggregation tick does not ask again.

Comment thread crates/validator/src/attestation.rs Outdated
// behind it for as long as the guard was held.
let signatures = {
let _timing = crate::metrics::time_signing();
futures::future::join_all(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

join_all waits for the slowest signature before anything is published, so one unresponsive signer delays or loses the whole slot's batch, including local keys and keys on other, healthy signers.

Example: 50 local keys plus 1 key on a hung Web3Signer. The hung request takes the full 8s timeout, which is about all of remaining_in(slot) after attestation data is fetched at 1/3 slot. The attest timeout then drops the future, and the 50 local signatures, already computed and already recorded by the guard, are never submitted and can't be retried. Before this change a remote failure couldn't affect local keys.

A per-request deadline shorter than the slot budget, publishing whatever finished, would keep one signer from taking down the rest.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. Attestations go through sign_all: each signature has 2s, one still outstanding then is a signing failure for that key alone, and the batch is published with whatever arrived, local keys and other signers' keys included.

.collect()
};
let mut selected: Vec<(AttesterDutyDto, _)> = Vec::new();
for duty in duties {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only attestations were made concurrent. Here, selection proofs and then the aggregate-and-proof signatures are still awaited one round trip at a time, with only the last third of the slot left. With many remote keys the timeout abandons the batch and every aggregate is lost. sync_committee::publish_messages, contributions and payload_attestation have the same shape.

Each selection proof is also requested again per duty: once by subscribe at the epoch boundary and again here, so 2–3 remote calls per duty per epoch where one cached result would do.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. Aggregation (selection proofs, then the aggregate-and-proofs), sync committee messages, sync selection proofs, contributions and payload attestations all sign their batch through sign_all. SigningContext::sign_selection_proof remembers an attestation selection proof once produced, so the one subscribe asked for is reused here.

// handler and the keystores import both hold `definitions_lock`.
let mut prepared = Vec::with_capacity(request.remote_keys.len());
{
let store = context.store.read().await;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read guard is held across the whole prepare_import loop, and each key in that loop does a full definitions.save() (whole YAML file rewritten, then renamed) and builds a throwaway reqwest Client. There's no batch cap; the keystores route caps at 100.

Example: importing 5,000 keys a signer already holds, which is a normal migration, rewrites a growing file 5,000 times (O(N²) bytes written), synchronously in an async handler. Meanwhile a concurrent keystores import queued on store.write() behind this guard blocks new readers (tokio's RwLock lets a queued writer do that), so every duty's store.read() in SigningContext::sign waits for the whole batch and attestations and proposals stall.

Capping the batch, saving the definitions once per request, and not holding the store guard across file I/O would avoid this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. The import is capped at 1000 keys (MAX_REMOTE_KEYS_PER_IMPORT), reads the store once under a guard released at once, saves the definitions once per request, and builds the signer clients before taking the store's write lock, which now covers map inserts only. Since the merge with main (9787aab) the same request also registers its keys in the slashing-protection record, in one write, before any is loaded.

Not changed: both delete routes still hold the store's write lock across a file read and save per key, as /keystores did before this PR. I left that for a follow-up.

/// key material on an HTTP call. A definitions file with no entry for them is
/// enough to keep them from being loaded again.
fn persist_delete(context: &KeymanagerContext, pubkey: &BlsPubkey) -> crate::Result<()> {
pub(super) fn persist_delete(context: &KeymanagerContext, pubkey: &BlsPubkey) -> crate::Result<()> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

persist_delete removes any definitions entry with this pubkey, whatever its type, and both delete_ones call it whenever the in-memory key belongs to their route. So when memory and the file disagree on local vs. remote, one route deletes the other route's file entry, despite the description saying the routes partition the store.

Example: memory holds a local key and the file holds a remote entry for the same pubkey. One way to get there: remotekeys::prepare_import persists the remote entry, then finish_import finds the key already in the store and answers duplicate. Now DELETE /eth/v1/keystores computes on_disk = false (the entry is remote) and in_memory = true, and persist_delete erases the remote definition while reporting deleted. remotekeys::delete_one has the mirror case.

Having persist_delete remove only an entry of the expected SigningDefinition variant would fix it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. persist_delete takes an Origin and removes only an entry of that route's kind (test: a_delete_never_erases_the_other_routes_file_entry). The state in the example can no longer be reached through the API either: duplicates are now judged before anything is written.

request: &SigningRequest<'_>,
) -> Result<BlsSignature> {
let pubkey_hex = encode_hex(&pubkey.0);
let url = format!("{}/api/v1/eth2/sign/{pubkey_hex}", self.base_url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A URL with a query string or fragment passes validation in new, but the signing path is appended by string concatenation, so it ends up inside the query: https://signer:9000?token=abc becomes https://signer:9000?token=abc/api/v1/eth2/sign/0x.., which requests /. The likely 404 is then reported as "remote signer ... does not hold this key", which is misleading. And since only userinfo is refused, token=abc is printed in every signing error.

Rejecting a query or fragment in Web3Signer::new, or building the path with Url::join / path_segments_mut, fixes both.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. Web3Signer::new refuses a URL with a query string or a fragment (a_url_with_a_query_or_fragment_is_refused).

Comment thread crates/validator/src/aggregation.rs Outdated
let signature = match self
.context
.sign_aggregate_and_proof(&store, &pubkey, root, slot)
.sign_aggregate_and_proof(store, &pubkey, message)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remote signing failures here ("Failed to sign aggregate" just below), for the selection proof at line 99, and in subscribe are only logged with warn!, never counted with inc_signing_failures(). The PR description and docs/cli.md say a signer refusal or an unreachable signer is "logged, counted as a signing failure".

With Web3Signer down or refusing during aggregation, every aggregate is skipped while signing_failures stays at 0, so alerts on that metric don't fire for lost aggregation duties. Local signing couldn't fail past the key lookup before this PR, so these paths never mattered until now.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. Selection-proof, aggregate and subscription signing failures all call inc_signing_failures() now, and so do the sync committee and payload attestation paths.

/// Nothing is asked of the signer here; see [`Self::load`] for why. The
/// URL is checked for shape, which is the one failure worth refusing up
/// front: an entry nobody could ever reach.
pub fn insert_remote(&mut self, pubkey: BlsPubkey, url: &str) -> Result<()> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insert_remote builds a reqwest Client (rustls config plus root store) while the store write lock is held, which goes against the stated rule that the write lock only covers a cheap insert. Importing keys on several new signer URLs builds one TLS client per URL under store.write(), blocking every duty's per-signature read() meanwhile.

Meanwhile remotekeys::prepare_import builds a Web3Signer only to validate the URL and then drops it, so each key pays for a client twice. Building the Arc<Web3Signer> once in prepare (or adding a validate-only function) and passing it to a write-locked insert that only touches the map would fix both.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. The keymanager import builds or reuses the Arc<Web3Signer> once in prepare_import, outside the store's lock, and insert_remote_signer is a map insert. insert_remote, which builds a client, is left for startup only.

// The header is taken from the decoded container, never from anything
// reassembled here, and it is taken once: its root is what the
// guard's slot is recorded against and what the signature covers.
let header = produced.header();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

produced.header() and produced.block_root() each compute the body's hash tree root, so the body, execution payload transactions included, is hashed twice per proposal (three times in debug builds) inside the proposal's time budget.

let header = produced.header(); let block_root = header.hash_tree_root(); only rehashes the 5-field header, and the debug_assert comparing the two then checks nothing new and can go.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. The block root is header.hash_tree_root(), and the debug_assert is gone.

Comment thread crates/validator/src/keys/store.rs Outdated
/// every mutation, since each can orphan one: an insert that replaces a
/// remote entry with a local one, a remove, a remote insert on a new URL
/// for a key that was the last on its old one.
fn drop_unused_signers(&mut self) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drop_unused_signers treats a client as unused by Arc::strong_count, but the clone signer() hands to an in-flight signature also counts. If a URL's last key is deleted through the keymanager while one of its requests is in flight, the count is 2, the client is kept, and nothing prunes it after the request finishes until some later mutation. So the guarantee release_it_together tests doesn't hold under concurrency.

Keeping a Weak<Web3Signer> in the URL map, with the strong references held by the keys that use it, would remove the need for manual pruning.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a13eda9. The URL map holds Weak<Web3Signer>; the keys and any signature in flight hold the strong references, so nothing is pruned by count (a_client_held_by_an_in_flight_signature_is_released_when_it_ends).

@pablodeymo

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. All 15 threads are answered inline; the fixes are in a13eda9. Three things changed since then that the review has not seen:

  • Merged main (9787aab). Remote keys now go through the EIP-3076 record from feat(validator): durable EIP-3076 slashing protection #734 like local ones: registered before they are loaded, at startup and by POST /eth/v1/remotekeys, and checked and recorded before the signer is asked. DELETE /eth/v1/keystores answers not_found for a key a remote signer holds and exports nothing for it.
  • Remote signatures are verified (46f289c) against the validator's public key and the signing root before they are used.
  • Unknown fields in the definitions file (ce564e4) are refused only on a web3signer entry, so a Lighthouse-written file of local keystores still loads.

Still open: nothing has been run against a real Web3Signer, and the BLOCK_V2 shape is unchecked against its source. Both delete routes still hold the store's write lock across a file read and save per key, as /keystores did before this PR.

… route to

the validator client. A validator's key can now stay in a Web3Signer: each
signature is a POST /api/v1/eth2/sign/{pubkey} carrying the message in full,
the fork it is signed under and the signing root, so the signer's own
slashing protection applies to it. Every signature the client produces is
covered, the gloas envelope and payload attestation included.

Every SigningContext::sign_* is async over the shared store lock: it resolves
the signer under a read guard scoped to the lookup and signs after releasing
it, because a remote signature is an HTTP round trip and the lock is
write-preferring. A slot's attestations are signed concurrently so many keys
behind one signer cost one round trip. Block signing takes the header and
aggregate signing the AggregateAndProof, since the signer reads them.

validator_definitions.yml stays backward compatible: a remote entry carries
`url` where a local one has its keystore paths. /eth/v1/keystores and
/eth/v1/remotekeys partition the store, each listing and deleting its own
kind. --web3signer-url is the default signer for a remote key imported
without a url of its own.

A remote key is covered by this client's EIP-3076 record like a local one,
beside the signer's own: the record is opened over every loaded key, a block
or attestation is checked and recorded before the signer is asked, and POST
/eth/v1/remotekeys registers its keys in one durable write before any is
loaded, taking its entries off the file again when that write fails. DELETE
/eth/v1/keystores answers `not_found` for a key the remote route holds and
exports nothing for it.
…entries,

and keep signer URLs and response bodies out of the logs. A keystore import
for a key a remote signer holds, and a remote import for a key the
definitions file already lists, now answer `duplicate` instead of overwriting
the other route's entry; both are judged on the file under the definitions
lock and again on the store under the write lock. A remote signer URL that
carries credentials is refused, since the URL is named in every signing
error, and a failed response's body is cut to 200 characters before it is
reported.
…per-signature budget, and make the keymanager routes judge and change the file and the store together.

Each duty now asks for its signatures at once through `signing::sign_all`, each given `SIGNING_BUDGET` (2s), so a hung signer costs only its own keys and the batch goes out with whatever arrived. Selection proofs are remembered in `SigningContext` once produced, so the aggregation tick reuses the subscription's. Selection, aggregate and subscription failures are now counted as signing failures.

Both imports hold `definitions_lock` until the store insert and judge duplicates before writing anything; `persist_delete` only removes its own route's kind of entry. A remote import is capped at 1000 keys, saves the definitions once, builds signer clients outside the store lock, and the store holds those clients weakly.

Definitions entries carry `type: local_keystore` / `type: web3signer`; an untyped entry naming both a keystore and a url, and any unknown field, is refused. Signer URL errors no longer echo credentials, and a URL with a query or fragment is refused. Remote block signing is refused before bellatrix (BLOCK_V2 wants the full block there). A proposal's block root is taken from its header. The docs now say TLS uses the bundled webpki roots, not the system's.

Both deletes share `flush_earlier_removal`, so a retry after a removal whose directory flush failed is not told the key is gone before the removal is durable.
…try of the definitions file, and read past it on a local one.

Refusing every unknown field stopped the client on any file Lighthouse wrote, since Lighthouse puts its own per-validator settings on each entry. The hazard the refusal was for is a remote entry: there an unread field is most likely one of Lighthouse's signer settings (`root_certificate_path`, `request_timeout_ms`), and skipping it would sign through a connection the operator did not describe. So a web3signer entry carrying one is refused at startup, naming every such field, whether the entry states its `type` or is read as remote by its `url`.

`description` is the one field a remote entry may carry unread: Lighthouse writes it on every entry, an empty one included, and it changes nothing about how the key signs. A local entry is read past any field this client does not have, as it was before entries had a `type`; those fields are not written back.
… and the signing root before it is used.

Whatever 96 bytes the signer answered were returned as the signature. A signer holding another key under the pubkey, one computing a different root for a fork it does not know, or a proxy rewriting the body would have this client publish a signature no peer accepts, and decide `is_aggregator` on bytes that are not the selection proof. `SigningContext::sign` now checks the answer and reports one that does not verify as a signing failure for that key: logged, counted, never retried, and never remembered as a selection proof.

The fake signer in the tests signs the request's `signingRoot` with the test key, so the signing path is exercised with real signatures, and can answer with another key's signature, another root's, or bytes that are no signature.

@MegaRedHand MegaRedHand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the remote-signing path and the two keymanager routes. The main concerns are about how this behaves with many remote keys (the subscriptions.rs and signing.rs comments) and one credential leak in keys/store.rs. The two delete comments on keystores.rs and remotekeys.rs only happen when the definitions file and memory disagree about a key's kind, i.e. after a hand edit while the client runs; both imports otherwise prevent that.

Heads-up: this conflicts with #779 (six files), and #779 signs exits through the old synchronous sign_root. Whichever lands second needs a VOLUNTARY_EXIT signing body and an async sign_voluntary_exit; details on #779.

),
}
}
let selections = sign_all(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This puts a signer round trip ahead of every epoch's slot-0 block. refresh_epoch (lib.rs:768) calls subscribe before propose (lib.rs:332), so with remote keys the boundary proposal now waits on these selection proofs: up to the full SIGNING_BUDGET (2s) if the signer hangs, and that delays blocks from local keys too. On main these were local signatures.

Could the selection proofs come off the proposal's path, e.g. run after propose, or in a spawned task that sends the subscriptions once they're in?

/// deadline is dropped and reported as an [`Error::Signing`] for its key, so
/// one unresponsive signer delays the batch by the budget at most and never
/// takes the signatures that did arrive down with it.
pub async fn sign_all<T, F>(requests: impl IntoIterator<Item = (BlsPubkey, F)>) -> Vec<Result<T>>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no cap on how many of these are in flight. reqwest is built without its http2 feature (default-features = false, features = ["rustls-tls"]), and Web3Signer is usually plain http anyway, so every request in flight is its own TCP connection. With 1000+ remote keys that's 1000+ sockets at an epoch boundary, on top of the beacon-node, metrics and keymanager ones.

ethlambda validator also never raises RLIMIT_NOFILE; only run_node calls raise_fd_limit. On the default soft limit of 1024, connect() fails with EMFILE and every remote signature fails, attestations and proposals included.

Suggest capping concurrency per signer (a semaphore, or buffer_unordered) and raising the fd limit in the validator sub-command too.

// `is_aggregator` on bytes that are not the selection proof.
// A local key needs no such check: its signature is computed
// here, over this root.
if !verifies(pubkey, root, &signature) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verifies is a full BLS verify (pairing plus subgroup check, ~1ms), run synchronously inside the future that sign_all wraps in timeout_at. Responses that arrive before the deadline are verified one after another on the duty task, and that time isn't bounded by the budget.

With ~1000 remote keys at an epoch boundary, that's roughly 1–3s of CPU ahead of the slot-0 proposal, so "one slow signer delays a duty by SIGNING_BUDGET at most" doesn't hold. Batch-verifying the batch's signatures, or verifying in spawn_blocking, would fix it.

slot: Slot,
) -> Result<BlsSignature> {
self.sign_root(store, pubkey, self.selection_proof_signing_root(slot))
if let Some(proof) = self.selection_proofs().get(&(*pubkey, slot)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache is checked before the store, so a key deleted through the keymanager mid-epoch still gets its selection proof here while its duties stay scheduled. The aggregation tick then fetches the aggregate and fails at sign_aggregate_and_proof with UnknownValidator. The new inc_signing_failures() calls (aggregation.rs:126 and :228, subscriptions.rs:88) count that as a signing failure, which main didn't, so alerts fire on a deliberate delete.

Minor. Checking that the store still holds the key before returning a cached proof, or dropping a key's proofs when it's deleted, would fix it.

SigningDefinition::Web3Signer { url } => {
let pubkey = parse_pubkey(&definition.voting_public_key).map_err(|err| {
Error::Keystore {
path: url.clone(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reports the raw URL from the file. With url: https://user:pass@signer:9000 and a typo in voting_public_key, startup aborts with an error that contains the credentials, before Web3Signer::new gets the chance to refuse them. That breaks the "every URL error is reported through redact" guarantee. Either redact it here, or name the entry by its position in the file instead.

) -> Json<StatusResponse> {
let _definitions_guard = context.definitions_lock.lock().await;

let mut store = context.store.write().await;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know the description calls this out as matching /keystores, but it gets worse here. The write lock is held across delete_one for every pubkey, and each one parses the definitions file (definitions_entry) and then saves and fsyncs it (persist_delete). The request isn't capped, so a DELETE of a few hundred keys stalls every signature's read-lock lookup for that long, local keys included, since tokio::sync::RwLock is write-preferring.

Memory-first order can stay. Judge every key against one read of the file under definitions_lock, take the write lock only to remove the keys from memory, release it, then save the file once.

if in_memory {
store.remove(pubkey);
}
context.settings.forget(pubkey);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forget runs whenever the file has a remote entry, including when the key in memory is a local one this route leaves alone (on_disk true, in_memory false, store.contains true). That local key keeps proposing but loses its fee recipient, graffiti and gas-limit overrides, so its next blocks pay the default recipient.

Only reachable when file and memory disagree about the key's kind, but forget should be skipped while the store still holds the key as local.

// the key elsewhere, is what turns a failed delete into two hosts signing
// for one validator.
match persist_delete(context, pubkey) {
match persist_delete(context, pubkey, Origin::Local) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same disagreement, the other way round: memory holds a local key and the file holds a remote entry for it. delete_one falls through to delete_local (remote_on_disk && store.contains(pubkey)), persist_delete(.., Origin::Local) leaves the remote entry in place, and the response is deleted plus the slashing-protection export.

On restart the remote entry loads and signs through Web3Signer, whose own record knows nothing about votes the key casts after the operator moved it elsewhere. That's the "reported gone while a restart brings it back" outcome the comment above warns against. Only reachable after a hand edit, but I'd answer an error here rather than deleted while an entry of the other kind is left in the file.

// left behind by a failed delete, is still "already known", and
// overwriting it would turn a key the operator disabled into a signing
// one. Judged before anything is built or staged.
let on_disk = definitions.0.iter().any(|definition| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this decodes every definition's pubkey once per imported key. An import at the 1000-key cap against a file with ~1000 entries is ~1M parse_pubkey calls, each allocating, under definitions_lock. The same scan is copied into keystores.rs:597, definitions_entry, persist_delete and the rollback retain. A HashSet<BlsPubkey> built once per request (or a ValidatorDefinitions::find) would remove both the cost and the copies.

// the whole batch, local keys included, while it waits.
let signatures =
{
let _timing = crate::metrics::time_signing();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: time_signing() now wraps sign_all, so for remote keys the signing-time histogram measures the network round trip plus up to 2s of budget waiting rather than signing. The same applies in sync_committee.rs and payload_attestation.rs. Worth either the remote-signer latency histogram the description lists as a follow-up, or a note in docs/metrics.md that the metric's meaning changed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants