Skip to content

Commit

Permalink
Merge fa78e38 into f5148b2
Browse files Browse the repository at this point in the history
  • Loading branch information
nflaig authored Apr 18, 2024
2 parents f5148b2 + fa78e38 commit a114b51
Show file tree
Hide file tree
Showing 10 changed files with 366 additions and 23 deletions.
23 changes: 23 additions & 0 deletions docs/pages/validator-management/external-signer.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 5 additions & 1 deletion docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 7 additions & 8 deletions packages/cli/src/cmds/validator/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
);
Expand Down
19 changes: 15 additions & 4 deletions packages/cli/src/cmds/validator/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type IValidatorCliArgs = AccountValidatorArgs &
"externalSigner.url"?: string;
"externalSigner.pubkeys"?: string[];
"externalSigner.fetch"?: boolean;
"externalSigner.fetchInterval"?: number;

distributed?: boolean;

Expand Down Expand Up @@ -303,15 +304,16 @@ export const validatorOptions: CliCommandOptions<IValidatorCliArgs> = {
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",
Expand All @@ -322,15 +324,24 @@ export const validatorOptions: CliCommandOptions<IValidatorCliArgs> = {
.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
Expand Down
44 changes: 36 additions & 8 deletions packages/cli/src/cmds/validator/signers/logSigners.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,8 +28,8 @@ export function logSigners(logger: Pick<Logger, LogLevel.info>, 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);
}
Expand All @@ -37,17 +39,43 @@ export function logSigners(logger: Pick<Logger, LogLevel.info>, 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<string, {url: string; pubkeys: string[]}>();

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<Logger, LogLevel.warn>): 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"
);
}
}
4 changes: 2 additions & 2 deletions packages/cli/src/cmds/validator/voluntaryExit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],

Expand Down Expand Up @@ -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.
`);
}
Expand Down
84 changes: 84 additions & 0 deletions packages/validator/src/services/externalSignerSync.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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);
}
}
10 changes: 10 additions & 0 deletions packages/validator/src/services/validatorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/validator/src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit a114b51

Please sign in to comment.