diff --git a/docs/pages/validator-management/external-signer.md b/docs/pages/validator-management/external-signer.md new file mode 100644 index 000000000000..00299eb1e2a4 --- /dev/null +++ b/docs/pages/validator-management/external-signer.md @@ -0,0 +1,23 @@ +# External Signer + +Lodestar supports connecting an external signing server like [Web3Signer](https://docs.web3signer.consensys.io/), [Diva](https://docs.shamirlabs.org/), +or any other service implementing the [remote signing specification](https://github.com/ethereum/remote-signing-api). This allows the validator client +to operate without storing any validator private keys locally by delegating the signing of messages (e.g. attestations, beacon blocks) to the external signer +which is accessed through a [REST API](https://ethereum.github.io/remote-signing-api/) via HTTP(S). This API should not be exposed directly to the public +Internet and appropriate firewall rules should be in place to restrict access only from the validator client. + +## Configuration + +Lodestar provides [CLI options](./validator-cli.md#--externalsignerurl) to connect an external signer. + +```sh +./lodestar validator --externalSigner.url "http://localhost:9000" --externalSigner.fetch +``` + +The validator client will fetch the list of public keys from the external signer and automatically keep them in sync with signers in local validator store +by adding newly discovered public keys and removing no longer present public keys on external signer. + +By default, the list of public keys will be fetched from the external signer once per epoch (6.4 minutes). This interval can be configured by setting [`--externalSigner.fetchInterval`](./validator-cli.md#--externalsignerfetchinterval) flag which takes a number in milliseconds. + +Alternatively, if it is not desired to use all public keys imported on the external signer, it is also possible to explicitly specify a list of public keys to use +by setting the [`--externalSigner.pubkeys`](./validator-cli.md#--externalsignerpubkeys) flag instead of [`--externalSigner.fetch`](./validator-cli.md#--externalsignerfetch). diff --git a/docs/sidebars.ts b/docs/sidebars.ts index c64e39b8b5f2..30ef55c60b81 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -23,7 +23,11 @@ const sidebars: SidebarsConfig = { { type: "category", label: "Validator", - items: ["validator-management/validator-cli", "validator-management/vc-configuration"], + items: [ + "validator-management/validator-cli", + "validator-management/vc-configuration", + "validator-management/external-signer", + ], }, { type: "category", diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index cb3191d07a7d..80b1040f226a 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -26,7 +26,7 @@ import {parseBuilderSelection, parseBuilderBoostFactor} from "../../util/propose import {getAccountPaths, getValidatorPaths} from "./paths.js"; import {IValidatorCliArgs, validatorMetricsDefaultOptions, validatorMonitoringDefaultOptions} from "./options.js"; import {getSignersFromArgs} from "./signers/index.js"; -import {logSigners} from "./signers/logSigners.js"; +import {logSigners, warnOrExitNoSigners} from "./signers/logSigners.js"; import {KeymanagerApi} from "./keymanager/impl.js"; import {PersistedKeysBackend} from "./keymanager/persistedKeys.js"; import {IPersistedKeysBackend} from "./keymanager/interface.js"; @@ -93,13 +93,7 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr // Ensure the validator has at least one key if (signers.length === 0) { - if (args["keymanager"]) { - logger.warn("No local keystores or remote signers found with current args, expecting to be added via keymanager"); - } else { - throw new YargsError( - "No local keystores and remote signers found with current args, start with --keymanager if intending to add them later (via keymanager)" - ); - } + warnOrExitNoSigners(args, logger); } logSigners(logger, signers); @@ -172,6 +166,11 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr useProduceBlockV3: args.useProduceBlockV3, broadcastValidation: parseBroadcastValidation(args.broadcastValidation), blindedLocal: args.blindedLocal, + externalSigner: { + url: args["externalSigner.url"], + fetch: args["externalSigner.fetch"], + fetchInterval: args["externalSigner.fetchInterval"], + }, }, metrics ); diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 5ca59280250f..d1603461e438 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -58,6 +58,7 @@ export type IValidatorCliArgs = AccountValidatorArgs & "externalSigner.url"?: string; "externalSigner.pubkeys"?: string[]; "externalSigner.fetch"?: boolean; + "externalSigner.fetchInterval"?: number; distributed?: boolean; @@ -303,15 +304,16 @@ export const validatorOptions: CliCommandOptions = { type: "boolean", }, - // Remote signer + // External signer "externalSigner.url": { description: "URL to connect to an external signing server", type: "string", - group: "externalSignerUrl", + group: "externalSigner", }, "externalSigner.pubkeys": { + implies: ["externalSigner.url"], description: "List of validator public keys used by an external signer. May also provide a single string of comma-separated public keys", type: "array", @@ -322,15 +324,24 @@ export const validatorOptions: CliCommandOptions = { .map((item) => item.split(",")) .flat(1) .map(ensure0xPrefix), - group: "externalSignerUrl", + group: "externalSigner", }, "externalSigner.fetch": { + implies: ["externalSigner.url"], conflicts: ["externalSigner.pubkeys"], description: "Fetch the list of public keys to validate from an external signer. Cannot be used in combination with `--externalSigner.pubkeys`", type: "boolean", - group: "externalSignerUrl", + group: "externalSigner", + }, + + "externalSigner.fetchInterval": { + implies: ["externalSigner.fetch"], + description: + "Interval in milliseconds between fetching the list of public keys from external signer, once per epoch by default", + type: "number", + group: "externalSigner", }, // Distributed validator diff --git a/packages/cli/src/cmds/validator/signers/logSigners.ts b/packages/cli/src/cmds/validator/signers/logSigners.ts index 85b7922cca15..47b0edc3a4d8 100644 --- a/packages/cli/src/cmds/validator/signers/logSigners.ts +++ b/packages/cli/src/cmds/validator/signers/logSigners.ts @@ -1,5 +1,7 @@ import {Signer, SignerLocal, SignerRemote, SignerType} from "@lodestar/validator"; import {LogLevel, Logger, toSafePrintableUrl} from "@lodestar/utils"; +import {YargsError} from "../../../util/errors.js"; +import {IValidatorCliArgs} from "../options.js"; /** * Log each pubkeys for auditing out keys are loaded from the logs @@ -26,8 +28,8 @@ export function logSigners(logger: Pick, signers: Signer[ } } - for (const {url, pubkeys} of groupExternalSignersByUrl(remoteSigners)) { - logger.info(`External signers on URL: ${toSafePrintableUrl(url)}`); + for (const {url, pubkeys} of groupRemoteSignersByUrl(remoteSigners)) { + logger.info(`Remote signers on URL: ${toSafePrintableUrl(url)}`); for (const pubkey of pubkeys) { logger.info(pubkey); } @@ -37,17 +39,43 @@ export function logSigners(logger: Pick, signers: Signer[ /** * Only used for logging remote signers grouped by URL */ -function groupExternalSignersByUrl(externalSigners: SignerRemote[]): {url: string; pubkeys: string[]}[] { +function groupRemoteSignersByUrl(remoteSigners: SignerRemote[]): {url: string; pubkeys: string[]}[] { const byUrl = new Map(); - for (const externalSigner of externalSigners) { - let x = byUrl.get(externalSigner.url); + for (const remoteSigner of remoteSigners) { + let x = byUrl.get(remoteSigner.url); if (!x) { - x = {url: externalSigner.url, pubkeys: []}; - byUrl.set(externalSigner.url, x); + x = {url: remoteSigner.url, pubkeys: []}; + byUrl.set(remoteSigner.url, x); } - x.pubkeys.push(externalSigner.pubkey); + x.pubkeys.push(remoteSigner.pubkey); } return Array.from(byUrl.values()); } + +/** + * Notify user if there are no signers at startup, this might be intended but could also be due to + * misconfiguration. It is possible that signers are added later via keymanager or if an external signer + * is connected with fetching enabled, but otherwise exit the process and suggest a different configuration. + */ +export function warnOrExitNoSigners(args: IValidatorCliArgs, logger: Pick): void { + if (args["keymanager"] && !args["externalSigner.fetch"]) { + logger.warn("No local keystores or remote keys found with current args, expecting to be added via keymanager"); + } else if (!args["keymanager"] && args["externalSigner.fetch"]) { + logger.warn("No remote keys found with current args, expecting to be added to external signer and fetched later"); + } else if (args["keymanager"] && args["externalSigner.fetch"]) { + logger.warn( + "No local keystores or remote keys found with current args, expecting to be added via keymanager or fetched from external signer later" + ); + } else { + if (args["externalSigner.url"]) { + throw new YargsError( + "No remote keys found with current args, start with --externalSigner.fetch to automatically fetch from external signer" + ); + } + throw new YargsError( + "No local keystores or remote keys found with current args, start with --keymanager if intending to add them later via keymanager" + ); + } +} diff --git a/packages/cli/src/cmds/validator/voluntaryExit.ts b/packages/cli/src/cmds/validator/voluntaryExit.ts index 4676e94f7547..89a908516f03 100644 --- a/packages/cli/src/cmds/validator/voluntaryExit.ts +++ b/packages/cli/src/cmds/validator/voluntaryExit.ts @@ -41,7 +41,7 @@ If no `pubkeys` are provided, it will exit all validators that have been importe command: "validator voluntary-exit --network goerli --externalSigner.url http://signer:9000 --externalSigner.fetch --pubkeys 0xF00", description: - "Perform a voluntary exit for the validator who has a public key 0xF00 and its secret key is on a remote signer", + "Perform a voluntary exit for the validator who has a public key 0xF00 and its secret key is on an external signer", }, ], @@ -92,7 +92,7 @@ If no `pubkeys` are provided, it will exit all validators that have been importe throw new YargsError(`No validators to exit found with current args. Ensure --dataDir and --network match values used when importing keys via validator import or alternatively, import keys by providing --importKeystores arg to voluntary-exit command. - If attempting to exit validators on a remote signer, make sure values are provided for + If attempting to exit validators on an external signer, make sure values are provided for the necessary --externalSigner options. `); } diff --git a/packages/validator/src/services/externalSignerSync.ts b/packages/validator/src/services/externalSignerSync.ts new file mode 100644 index 000000000000..edfc0f5bc350 --- /dev/null +++ b/packages/validator/src/services/externalSignerSync.ts @@ -0,0 +1,84 @@ +import bls from "@chainsafe/bls"; +import {CoordType} from "@chainsafe/bls/types"; +import {fromHexString} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; +import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {toSafePrintableUrl} from "@lodestar/utils"; + +import {LoggerVc} from "../util/index.js"; +import {externalSignerGetKeys} from "../util/externalSignerClient.js"; +import {SignerType, ValidatorStore} from "./validatorStore.js"; + +export type ExternalSignerOptions = { + url?: string; + fetch?: boolean; + fetchInterval?: number; +}; + +/** + * This service is responsible for keeping the keys managed by the connected + * external signer and the validator client in sync by adding newly discovered keys + * and removing no longer present keys on external signer from the validator store. + */ +export function pollExternalSignerPubkeys( + config: ChainForkConfig, + logger: LoggerVc, + signal: AbortSignal, + validatorStore: ValidatorStore, + opts?: ExternalSignerOptions +): void { + const externalSigner = opts ?? {}; + + if (!externalSigner.url || !externalSigner.fetch) { + return; // Disabled + } + + async function fetchExternalSignerPubkeys(): Promise { + // External signer URL is already validated earlier + const externalSignerUrl = externalSigner.url as string; + const printableUrl = toSafePrintableUrl(externalSignerUrl); + + try { + logger.debug("Fetching public keys from external signer", {url: printableUrl}); + const externalPubkeys = await externalSignerGetKeys(externalSignerUrl); + assertValidPubkeysHex(externalPubkeys); + logger.debug("Received public keys from external signer", {url: printableUrl, count: externalPubkeys.length}); + + const localPubkeys = validatorStore.getRemoteSignerPubkeys(externalSignerUrl); + logger.debug("Local public keys stored for external signer", {url: printableUrl, count: localPubkeys.length}); + + const localPubkeysSet = new Set(localPubkeys); + for (const pubkey of externalPubkeys) { + if (!localPubkeysSet.has(pubkey)) { + await validatorStore.addSigner({type: SignerType.Remote, pubkey, url: externalSignerUrl}); + logger.info("Added remote signer", {pubkey, url: printableUrl}); + } + } + + const externalPubkeysSet = new Set(externalPubkeys); + for (const pubkey of localPubkeys) { + if (!externalPubkeysSet.has(pubkey)) { + validatorStore.removeSigner(pubkey); + logger.info("Removed remote signer", {pubkey, url: printableUrl}); + } + } + } catch (e) { + logger.error("Failed to fetch public keys from external signer", {url: printableUrl}, e as Error); + } + } + + const interval = setInterval( + fetchExternalSignerPubkeys, + externalSigner.fetchInterval ?? + // Once per epoch by default + SLOTS_PER_EPOCH * config.SECONDS_PER_SLOT * 1000 + ); + signal.addEventListener("abort", () => clearInterval(interval), {once: true}); +} + +function assertValidPubkeysHex(pubkeysHex: string[]): void { + for (const pubkeyHex of pubkeysHex) { + const pubkeyBytes = fromHexString(pubkeyHex); + bls.PublicKey.fromBytes(pubkeyBytes, CoordType.jacobian, true); + } +} diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 0c3c0b33cea8..6cd9ed8dc065 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -418,6 +418,16 @@ export class ValidatorStore { return this.validators.has(pubkeyHex); } + getRemoteSignerPubkeys(signerUrl: string): PubkeyHex[] { + const pubkeysHex = []; + for (const {signer} of this.validators.values()) { + if (signer.type === SignerType.Remote && signer.url === signerUrl) { + pubkeysHex.push(signer.pubkey); + } + } + return pubkeysHex; + } + async signBlock( pubkey: BLSPubkey, blindedOrFull: allForks.FullOrBlindedBeaconBlock, diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index deff99ee93f0..8590fc1d0068 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -12,6 +12,7 @@ import {AttestationService} from "./services/attestation.js"; import {IndicesService} from "./services/indices.js"; import {SyncCommitteeService} from "./services/syncCommittee.js"; import {pollPrepareBeaconProposer, pollBuilderValidatorRegistration} from "./services/prepareBeaconProposer.js"; +import {ExternalSignerOptions, pollExternalSignerPubkeys} from "./services/externalSignerSync.js"; import {Interchange, InterchangeFormatVersion, ISlashingProtection} from "./slashingProtection/index.js"; import {assertEqualParams, getLoggerVc, NotEqualParamsError} from "./util/index.js"; import {ChainHeaderTracker} from "./services/chainHeaderTracker.js"; @@ -59,6 +60,7 @@ export type ValidatorOptions = { useProduceBlockV3?: boolean; broadcastValidation?: routes.beacon.BroadcastValidation; blindedLocal?: boolean; + externalSigner?: ExternalSignerOptions; }; // TODO: Extend the timeout, and let it be customizable @@ -200,6 +202,7 @@ export class Validator { ); pollPrepareBeaconProposer(config, loggerVc, api, clock, validatorStore, metrics); pollBuilderValidatorRegistration(config, loggerVc, api, clock, validatorStore, metrics); + pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts.externalSigner); const emitter = new ValidatorEventEmitter(); // Validator event emitter can have more than 10 listeners in a normal course of operation diff --git a/packages/validator/test/unit/services/externalSignerSync.test.ts b/packages/validator/test/unit/services/externalSignerSync.test.ts new file mode 100644 index 000000000000..5fdf7d5ae5b2 --- /dev/null +++ b/packages/validator/test/unit/services/externalSignerSync.test.ts @@ -0,0 +1,181 @@ +import {MockedFunction, afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import {toBufferBE} from "bigint-buffer"; +import bls from "@chainsafe/bls"; +import {SecretKey} from "@chainsafe/bls/types"; +import {createChainForkConfig} from "@lodestar/config"; +import {chainConfig} from "@lodestar/config/default"; +import {ExternalSignerOptions, pollExternalSignerPubkeys} from "../../../src/services/externalSignerSync.js"; +import {SignerRemote, SignerType, ValidatorStore} from "../../../src/services/validatorStore.js"; +import {externalSignerGetKeys} from "../../../src/util/externalSignerClient.js"; +import {initValidatorStore} from "../../utils/validatorStore.js"; +import {getApiClientStub} from "../../utils/apiStub.js"; +import {loggerVc} from "../../utils/logger.js"; + +vi.mock("../../../src/util/externalSignerClient.js"); + +describe("External signer sync", () => { + const config = createChainForkConfig({}); + const api = getApiClientStub(); + + const externalSignerUrl = "http://localhost"; + const opts: Required = { + url: externalSignerUrl, + fetch: true, + fetchInterval: 100, + }; + + // Initialize pubkeys in beforeAll() so bls is already initialized + let pubkeys: string[]; + let secretKeys: SecretKey[]; + + let externalSignerGetKeysStub: MockedFunction; + + beforeAll(() => { + vi.useFakeTimers(); + secretKeys = Array.from({length: 3}, (_, i) => bls.SecretKey.fromBytes(toBufferBE(BigInt(i + 1), 32))); + pubkeys = secretKeys.map((sk) => sk.toPublicKey().toHex()); + externalSignerGetKeysStub = vi.mocked(externalSignerGetKeys); + }); + + let validatorStore: ValidatorStore; + // To stop fetch interval + let controller: AbortController; + + beforeEach(async () => { + // Initialize validator store without signers + validatorStore = await initValidatorStore([], api, chainConfig); + controller = new AbortController(); + }); + + afterEach(() => controller.abort()); + + afterAll(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("should add remote signer for newly discovered public key from external signer", async () => { + const pubkey = pubkeys[0]; + externalSignerGetKeysStub.mockResolvedValueOnce([pubkey]); + + pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts); + + await waitForFetchInterval(); + + expect(validatorStore.hasSomeValidators()).toBe(true); + expect(validatorStore.getSigner(pubkey)).toEqual({ + type: SignerType.Remote, + pubkey: pubkey, + url: externalSignerUrl, + }); + }); + + it("should remove remote signer for no longer present public key on external signer", async () => { + const pubkey = pubkeys[0]; + await validatorStore.addSigner({type: SignerType.Remote, pubkey: pubkey, url: externalSignerUrl}); + expect(validatorStore.hasSomeValidators()).toBe(true); + + externalSignerGetKeysStub.mockResolvedValueOnce([]); + + pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts); + + await waitForFetchInterval(); + + expect(validatorStore.hasSomeValidators()).toBe(false); + expect(validatorStore.getSigner(pubkey)).toBeUndefined(); + }); + + it("should add / remove remote signers to match public keys on external signer", async () => { + const existingPubkeys = pubkeys.slice(0, 2); + for (const pubkey of existingPubkeys) { + await validatorStore.addSigner({type: SignerType.Remote, pubkey, url: externalSignerUrl}); + } + expect(validatorStore.hasSomeValidators()).toBe(true); + expect(validatorStore.votingPubkeys()).toEqual(existingPubkeys); + + const removedPubkey = existingPubkeys[0]; + const addedPubkeys = pubkeys.slice(existingPubkeys.length, pubkeys.length); + const externalPubkeys = [...existingPubkeys.slice(1), ...addedPubkeys]; + + externalSignerGetKeysStub.mockResolvedValueOnce(externalPubkeys); + + pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts); + + await waitForFetchInterval(); + + expect(validatorStore.hasSomeValidators()).toBe(true); + expect(validatorStore.hasVotingPubkey(removedPubkey)).toBe(false); + expect(validatorStore.votingPubkeys()).toEqual(externalPubkeys); + }); + + it("should not modify signers if public keys did not change on external signer", async () => { + for (const pubkey of pubkeys) { + await validatorStore.addSigner({type: SignerType.Remote, pubkey, url: externalSignerUrl}); + } + expect(validatorStore.hasSomeValidators()).toBe(true); + expect(validatorStore.votingPubkeys()).toEqual(pubkeys); + + externalSignerGetKeysStub.mockResolvedValueOnce(pubkeys); + + pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts); + + await waitForFetchInterval(); + + expect(validatorStore.hasSomeValidators()).toBe(true); + expect(validatorStore.votingPubkeys()).toEqual(pubkeys); + }); + + it("should not remove local signer if public key is not present on external signer", async () => { + const localPubkey = pubkeys[0]; + await validatorStore.addSigner({type: SignerType.Local, secretKey: secretKeys[0]}); + expect(validatorStore.hasVotingPubkey(localPubkey)).toBe(true); + + externalSignerGetKeysStub.mockResolvedValueOnce(pubkeys.slice(1)); + + pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts); + + await waitForFetchInterval(); + + expect(validatorStore.hasVotingPubkey(localPubkey)).toBe(true); + }); + + it("should not remove remote signer with a different url as configured external signer", async () => { + const diffUrlPubkey = pubkeys[0]; + await validatorStore.addSigner({type: SignerType.Remote, pubkey: diffUrlPubkey, url: "http://differentSigner"}); + expect(validatorStore.hasVotingPubkey(diffUrlPubkey)).toBe(true); + + externalSignerGetKeysStub.mockResolvedValueOnce(pubkeys.slice(1)); + + pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts); + + await waitForFetchInterval(); + + expect(validatorStore.hasVotingPubkey(diffUrlPubkey)).toBe(true); + }); + + it("should not add remote signer if public key fetched from external signer is invalid", async () => { + const invalidPubkey = "0x1234"; + externalSignerGetKeysStub.mockResolvedValueOnce([invalidPubkey]); + + pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts); + + await waitForFetchInterval(); + + expect(validatorStore.hasSomeValidators()).toBe(false); + }); + + it("should not add remote signers if fetching public keys from external signer is disabled", async () => { + externalSignerGetKeysStub.mockResolvedValueOnce(pubkeys); + + pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, {...opts, fetch: false}); + + await waitForFetchInterval(); + + expect(validatorStore.hasSomeValidators()).toBe(false); + expect(validatorStore.votingPubkeys()).toEqual([]); + }); + + async function waitForFetchInterval(): Promise { + await vi.advanceTimersByTimeAsync(opts.fetchInterval); + } +});