import {Keystore} from "@chainsafe/bls-keystore"; import {SecretKey} from "@chainsafe/blst"; import { BuilderBoostFactorData, DeleteRemoteKeyStatus, DeletionStatus, FeeRecipientData, GasLimitData, GraffitiData, ImportRemoteKeyStatus, ImportStatus, KeystoreStr, ProposerConfigResponse, PubkeyHex, RemoteSignerDefinition, ResponseStatus, SignerDefinition, SlashingProtectionData, } from "@lodestar/api/keymanager"; import {KeymanagerApiMethods as Api} from "@lodestar/api/keymanager/server"; import {ApiError} from "@lodestar/api/server"; import {Epoch} from "@lodestar/types"; import {fromHex, isValidHttpUrl} from "@lodestar/utils"; import {Interchange, SignerType, Validator} from "@lodestar/validator"; import {getPubkeyHexFromKeystore, isValidatePubkeyHex} from "../../../util/format.js"; import {parseFeeRecipient} from "../../../util/index.js"; import {DecryptKeystoresThreadPool} from "./decryptKeystores/index.js"; import {IPersistedKeysBackend} from "./interface.js"; export class KeymanagerApi implements Api { constructor( private readonly validator: Validator, private readonly persistedKeysBackend: IPersistedKeysBackend, private readonly signal: AbortSignal, private readonly proposerConfigWriteDisabled?: boolean ) {} private checkIfProposerWriteEnabled(): void { if (this.proposerConfigWriteDisabled === true) { throw Error("proposerSettingsFile option activated"); } } async listFeeRecipient({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.assertValidKnownPubkey(pubkey); return {data: {pubkey, ethaddress: this.validator.validatorStore.getFeeRecipient(pubkey)}}; } async setFeeRecipient({pubkey, ethaddress}: FeeRecipientData): ReturnType { this.checkIfProposerWriteEnabled(); this.assertValidKnownPubkey(pubkey); this.validator.validatorStore.setFeeRecipient(pubkey, parseFeeRecipient(ethaddress)); this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); return {status: 202}; } async deleteFeeRecipient({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); this.assertValidKnownPubkey(pubkey); this.validator.validatorStore.deleteFeeRecipient(pubkey); this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); return {status: 204}; } async getGraffiti({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.assertValidKnownPubkey(pubkey); const graffiti = this.validator.validatorStore.getGraffiti(pubkey); if (graffiti === undefined) { throw new ApiError(404, `No graffiti for pubkey ${pubkey}`); } return {data: {pubkey, graffiti}}; } async setGraffiti({pubkey, graffiti}: GraffitiData): ReturnType { this.checkIfProposerWriteEnabled(); this.assertValidKnownPubkey(pubkey); this.validator.validatorStore.setGraffiti(pubkey, graffiti); this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); return {status: 202}; } async deleteGraffiti({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); this.assertValidKnownPubkey(pubkey); this.validator.validatorStore.deleteGraffiti(pubkey); this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); return {status: 204}; } async getGasLimit({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.assertValidKnownPubkey(pubkey); const gasLimit = this.validator.validatorStore.getGasLimit(pubkey); return {data: {pubkey, gasLimit}}; } async setGasLimit({pubkey, gasLimit}: GasLimitData): ReturnType { this.checkIfProposerWriteEnabled(); this.assertValidKnownPubkey(pubkey); this.validator.validatorStore.setGasLimit(pubkey, gasLimit); this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); return {status: 202}; } async deleteGasLimit({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); this.assertValidKnownPubkey(pubkey); this.validator.validatorStore.deleteGasLimit(pubkey); this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); return {status: 204}; } async listKeys(): ReturnType { const localKeys = this.validator.validatorStore .votingPubkeys() .filter((pubkey) => this.validator.validatorStore.getSigner(pubkey)?.type === SignerType.Local); return { data: localKeys.map((pubkey) => ({ validatingPubkey: pubkey, derivationPath: "", readonly: false, })), }; } async importKeystores({ keystores, passwords, slashingProtection, }: { keystores: KeystoreStr[]; passwords: string[]; slashingProtection?: SlashingProtectionData; }): ReturnType { if (slashingProtection) { // The arguments to this function is passed in within the body of an HTTP request // hence fastify will parse it into an object before this function is called. // Even though the slashingProtection is typed as SlashingProtectionData, // at runtime, when the handler for the request is selected, it would see slashingProtection // as an object, hence trying to parse it using JSON.parse won't work. Instead, we cast straight to Interchange const interchange = ensureJSON(slashingProtection); await this.validator.importInterchange(interchange); } const statuses: {status: ImportStatus; message?: string}[] = []; const decryptKeystores = new DecryptKeystoresThreadPool(keystores.length, this.signal); for (let i = 0; i < keystores.length; i++) { try { const keystoreStr = keystores[i]; const password = passwords[i]; if (password === undefined) { throw new ApiError(400, `No password for keystores[${i}]`); } const keystore = Keystore.parse(keystoreStr); const pubkeyHex = getPubkeyHexFromKeystore(keystore); // Check for duplicates and skip keystore before decrypting if (this.validator.validatorStore.hasVotingPubkey(pubkeyHex)) { statuses[i] = {status: ImportStatus.duplicate}; continue; } decryptKeystores.queue( {keystoreStr, password}, async (secretKeyBytes: Uint8Array) => { const secretKey = SecretKey.fromBytes(secretKeyBytes); // Persist the key to disk for restarts, before adding to in-memory store // If the keystore exist and has a lock it will throw this.persistedKeysBackend.writeKeystore({ keystoreStr, password, // Lock immediately since it's gonna be used lockBeforeWrite: true, // Always write, even if it's already persisted for consistency. // The in-memory validatorStore is the ground truth to decide duplicates persistIfDuplicate: true, }); // Add to in-memory store to start validating immediately await this.validator.validatorStore.addSigner({type: SignerType.Local, secretKey}); statuses[i] = {status: ImportStatus.imported}; }, (e: Error) => { statuses[i] = {status: ImportStatus.error, message: e.message}; } ); } catch (e) { statuses[i] = {status: ImportStatus.error, message: (e as Error).message}; } } await decryptKeystores.completed(); return {data: statuses}; } async deleteKeys({pubkeys}: {pubkeys: PubkeyHex[]}): ReturnType { const deletedKey: boolean[] = []; const statuses = new Array<{status: DeletionStatus; message?: string}>(pubkeys.length); for (let i = 0; i < pubkeys.length; i++) { try { const pubkeyHex = pubkeys[i]; if (!isValidatePubkeyHex(pubkeyHex)) { throw new ApiError(400, `Invalid pubkey ${pubkeyHex}`); } // Skip unknown keys or remote signers const signer = this.validator.validatorStore.getSigner(pubkeyHex); if (signer && signer.type === SignerType.Local) { // Remove key from live local signer deletedKey[i] = this.validator.validatorStore.removeSigner(pubkeyHex); // Remove key from block duties // Remove from attestation duties // Remove from Sync committee duties // Remove from indices this.validator.removeDutiesForKey(pubkeyHex); } // Attempts to delete everything first, and returns status. // This unlocks the keystore, so perform after deleting from in-memory store const diskDeleteStatus = this.persistedKeysBackend.deleteKeystore(pubkeyHex); if (diskDeleteStatus) { // TODO: What if the diskDeleteStatus status is inconsistent? deletedKey[i] = true; } } catch (e) { statuses[i] = {status: DeletionStatus.error, message: (e as Error).message}; } } const pubkeysBytes = pubkeys.map((pubkeyHex) => fromHex(pubkeyHex)); const interchangeV5 = await this.validator.exportInterchange(pubkeysBytes, { version: "5", }); // After exporting slashing protection data in bulk, render the status const pubkeysWithSlashingProtectionData = new Set(interchangeV5.data.map((data) => data.pubkey)); for (let i = 0; i < pubkeys.length; i++) { if (statuses[i]?.status === DeletionStatus.error) { continue; } const status = deletedKey[i] ? DeletionStatus.deleted : pubkeysWithSlashingProtectionData.has(pubkeys[i]) ? DeletionStatus.not_active : DeletionStatus.not_found; statuses[i] = {status}; } return { data: { statuses, slashingProtection: JSON.stringify(interchangeV5), }, }; } async listRemoteKeys(): ReturnType { const remoteKeys: SignerDefinition[] = []; for (const pubkeyHex of this.validator.validatorStore.votingPubkeys()) { const signer = this.validator.validatorStore.getSigner(pubkeyHex); if (signer && signer.type === SignerType.Remote) { remoteKeys.push({pubkey: signer.pubkey, url: signer.url, readonly: false}); } } return { data: remoteKeys, }; } async importRemoteKeys({ remoteSigners, }: { remoteSigners: RemoteSignerDefinition[]; }): ReturnType { const importPromises = remoteSigners.map(async ({pubkey, url}): Promise> => { try { if (!isValidatePubkeyHex(pubkey)) { throw new ApiError(400, `Invalid pubkey ${pubkey}`); } if (!isValidHttpUrl(url)) { throw new ApiError(400, `Invalid URL ${url}`); } // Check if key exists if (this.validator.validatorStore.hasVotingPubkey(pubkey)) { return {status: ImportRemoteKeyStatus.duplicate}; } // Else try to add it await this.validator.validatorStore.addSigner({type: SignerType.Remote, pubkey, url}); this.persistedKeysBackend.writeRemoteKey({ pubkey, url, // Always write, even if it's already persisted for consistency. // The in-memory validatorStore is the ground truth to decide duplicates persistIfDuplicate: true, }); return {status: ImportRemoteKeyStatus.imported}; } catch (e) { return {status: ImportRemoteKeyStatus.error, message: (e as Error).message}; } }); return { data: await Promise.all(importPromises), }; } async deleteRemoteKeys({pubkeys}: {pubkeys: PubkeyHex[]}): ReturnType { const results = pubkeys.map((pubkeyHex): ResponseStatus => { try { if (!isValidatePubkeyHex(pubkeyHex)) { throw new ApiError(400, `Invalid pubkey ${pubkeyHex}`); } const signer = this.validator.validatorStore.getSigner(pubkeyHex); // Remove key from live local signer const deletedFromMemory = signer && signer.type === SignerType.Remote ? this.validator.validatorStore.removeSigner(pubkeyHex) : false; if (deletedFromMemory) { // Remove duties if key was deleted from in-memory store this.validator.removeDutiesForKey(pubkeyHex); } const deletedFromDisk = this.persistedKeysBackend.deleteRemoteKey(pubkeyHex); return { status: deletedFromMemory || deletedFromDisk ? DeleteRemoteKeyStatus.deleted : DeleteRemoteKeyStatus.not_found, }; } catch (e) { return {status: DeleteRemoteKeyStatus.error, message: (e as Error).message}; } }); return { data: results, }; } async getBuilderBoostFactor({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.assertValidKnownPubkey(pubkey); const builderBoostFactor = this.validator.validatorStore.getBuilderBoostFactor(pubkey); return {data: {pubkey, builderBoostFactor}}; } async setBuilderBoostFactor({ pubkey, builderBoostFactor, }: BuilderBoostFactorData): ReturnType { this.checkIfProposerWriteEnabled(); this.assertValidKnownPubkey(pubkey); this.validator.validatorStore.setBuilderBoostFactor(pubkey, builderBoostFactor); this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); return {status: 202}; } async deleteBuilderBoostFactor({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); this.validator.validatorStore.deleteBuilderBoostFactor(pubkey); this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); return {status: 204}; } async getProposerConfig({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.assertValidKnownPubkey(pubkey); const config = this.validator.validatorStore.getProposerConfig(pubkey); const data: ProposerConfigResponse = { ...config, builder: config?.builder ? { ...config.builder, // Default JSON serialization can't handle BigInt boostFactor: config.builder.boostFactor ? config.builder.boostFactor.toString() : undefined, } : undefined, }; return {data}; } async signVoluntaryExit({pubkey, epoch}: {pubkey: PubkeyHex; epoch?: Epoch}): ReturnType { this.assertValidKnownPubkey(pubkey); return {data: await this.validator.signVoluntaryExit(pubkey, epoch)}; } private assertValidKnownPubkey(pubkey: PubkeyHex): void { if (!isValidatePubkeyHex(pubkey)) { throw new ApiError(400, `Invalid pubkey ${pubkey}`); } if (!this.validator.validatorStore.hasVotingPubkey(pubkey)) { throw new ApiError(404, `Validator pubkey ${pubkey} not known`); } } } /** * Given a variable with JSON that maybe stringified or not, return parsed JSON */ function ensureJSON(strOrJson: string | T): T { if (typeof strOrJson === "string") { return JSON.parse(strOrJson) as T; } return strOrJson; }