Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement standard keymanager API - review #3880

Merged
merged 10 commits into from
Apr 12, 2022
15 changes: 11 additions & 4 deletions packages/cli/src/cmds/validator/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,17 @@ export async function validatorHandler(args: IValidatorCliArgs & IGlobalArgs): P
await validator.start();

// Start keymanager API backend
// Only if keymanagerEnabled flag is set to true and at least one keystore path is supplied
const firstImportKeystorePath = args.importKeystoresPath?.[0];
if (args.keymanagerEnabled && firstImportKeystorePath) {
const keymanagerApi = new KeymanagerApi(logger, validator, firstImportKeystorePath);
// Only if keymanagerEnabled flag is set to true
if (args.keymanagerEnabled) {
if (!args.importKeystoresPath || args.importKeystoresPath.length === 0) {
throw new YargsError("For keymanagerEnabled must set importKeystoresPath to at least 1 path");
}

// Use the first path in importKeystoresPath as directory to write keystores
// KeymanagerApi must ensure that the path is a directory and not a file
const firstImportKeystorePath = args.importKeystoresPath[0];

const keymanagerApi = new KeymanagerApi(validator, firstImportKeystorePath);

const keymanagerServer = new KeymanagerServer(
{
Expand Down
15 changes: 6 additions & 9 deletions packages/cli/src/cmds/validator/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {CoordType, PublicKey, SecretKey} from "@chainsafe/bls";
import {deriveEth2ValidatorKeys, deriveKeyFromMnemonic} from "@chainsafe/bls-keygen";
import {interopSecretKey} from "@chainsafe/lodestar-beacon-state-transition";
import {externalSignerGetKeys} from "@chainsafe/lodestar-validator";
import {LOCK_FILE_EXT, getLockFile} from "@chainsafe/lodestar-keymanager-server";
import {lockFilepath, unlockFilepath} from "@chainsafe/lodestar-keymanager-server";
import {defaultNetwork, IGlobalArgs} from "../../options";
import {parseRange, stripOffNewlines, YargsError} from "../../util";
import {ValidatorDirManager} from "../../validatorDir";
Expand Down Expand Up @@ -53,13 +53,9 @@ export async function getLocalSecretKeys(

const keystorePaths = args.importKeystoresPath.map((filepath) => resolveKeystorePaths(filepath)).flat(1);

// Create lock files for all keystores
const lockFile = getLockFile();
const lockFilePaths = keystorePaths.map((keystorePath) => keystorePath + LOCK_FILE_EXT);

// Lock all keystores first
for (const lockFilePath of lockFilePaths) {
lockFile.lockSync(lockFilePath);
for (const keystorePath of keystorePaths) {
lockFilepath(keystorePath);
}

const secretKeys = await Promise.all(
Expand All @@ -71,8 +67,9 @@ export async function getLocalSecretKeys(
return {
secretKeys,
unlockSecretKeys: () => {
for (const lockFilePath of lockFilePaths) {
lockFile.unlockSync(lockFilePath);
for (const keystorePath of keystorePaths) {
// Should not throw if lock file is already deleted
unlockFilepath(keystorePath);
}
},
};
Expand Down
31 changes: 14 additions & 17 deletions packages/cli/src/validatorDir/ValidatorDir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@ import path from "node:path";
import bls, {SecretKey} from "@chainsafe/bls";
import {Keystore} from "@chainsafe/bls-keystore";
import {phase0} from "@chainsafe/lodestar-types";
import {getLockFile} from "@chainsafe/lodestar-keymanager-server";
import {lockFilepath, unlockFilepath} from "@chainsafe/lodestar-keymanager-server";
import {YargsError, readValidatorPassphrase} from "../util";
import {decodeEth1TxData} from "../depositContract/depositData";
import {add0xPrefix} from "../util/format";
import {
VOTING_KEYSTORE_FILE,
WITHDRAWAL_KEYSTORE_FILE,
LOCK_FILE,
ETH1_DEPOSIT_DATA_FILE,
ETH1_DEPOSIT_AMOUNT_FILE,
ETH1_DEPOSIT_TX_HASH_FILE,
Expand Down Expand Up @@ -50,23 +49,21 @@ export interface IEth1DepositData {
* ```
*/
export class ValidatorDir {
dir: string;
private lockfilePath: string;
private readonly dirpath: string;

/**
* Open `dir`, creating a lockfile to prevent concurrent access.
* Errors if there is a filesystem error or if a lockfile already exists
* @param dir
*/
constructor(baseDir: string, pubkey: string, options?: IValidatorDirOptions) {
this.dir = path.join(baseDir, add0xPrefix(pubkey));
this.lockfilePath = path.join(this.dir, LOCK_FILE);
this.dirpath = path.join(baseDir, add0xPrefix(pubkey));

if (!fs.existsSync(this.dir)) throw new YargsError(`Validator directory ${this.dir} does not exist`);
if (!fs.existsSync(this.dirpath)) {
throw new YargsError(`Validator directory ${this.dirpath} does not exist`);
}

const lockFile = getLockFile();
try {
lockFile.lockSync(this.lockfilePath);
lockFilepath(this.dirpath);
} catch (e) {
if (options && options.force) {
// Ignore error, maybe log?
Expand All @@ -80,7 +77,7 @@ export class ValidatorDir {
* Removes the lockfile associated with this validator dir
*/
close(): void {
getLockFile().unlockSync(this.lockfilePath);
unlockFilepath(this.dirpath);
}

/**
Expand All @@ -91,7 +88,7 @@ export class ValidatorDir {
* @param secretsDir
*/
async votingKeypair(secretsDir: string): Promise<SecretKey> {
const keystorePath = path.join(this.dir, VOTING_KEYSTORE_FILE);
const keystorePath = path.join(this.dirpath, VOTING_KEYSTORE_FILE);
return await this.unlockKeypair(keystorePath, secretsDir);
}

Expand All @@ -103,7 +100,7 @@ export class ValidatorDir {
* @param secretsDir
*/
async withdrawalKeypair(secretsDir: string): Promise<SecretKey> {
const keystorePath = path.join(this.dir, WITHDRAWAL_KEYSTORE_FILE);
const keystorePath = path.join(this.dirpath, WITHDRAWAL_KEYSTORE_FILE);
return await this.unlockKeypair(keystorePath, secretsDir);
}

Expand All @@ -127,14 +124,14 @@ export class ValidatorDir {
* when relying upon this value.
*/
eth1DepositTxHashExists(): boolean {
return fs.existsSync(path.join(this.dir, ETH1_DEPOSIT_TX_HASH_FILE));
return fs.existsSync(path.join(this.dirpath, ETH1_DEPOSIT_TX_HASH_FILE));
}

/**
* Saves the `tx_hash` to a file in `this.dir`.
*/
saveEth1DepositTxHash(txHash: string): void {
const filepath = path.join(this.dir, ETH1_DEPOSIT_TX_HASH_FILE);
const filepath = path.join(this.dirpath, ETH1_DEPOSIT_TX_HASH_FILE);

if (fs.existsSync(filepath)) throw new YargsError(`ETH1_DEPOSIT_TX_HASH_FILE ${filepath} already exists`);

Expand All @@ -146,8 +143,8 @@ export class ValidatorDir {
* submitting an Eth1 deposit.
*/
eth1DepositData(): IEth1DepositData {
const depositDataPath = path.join(this.dir, ETH1_DEPOSIT_DATA_FILE);
const depositAmountPath = path.join(this.dir, ETH1_DEPOSIT_AMOUNT_FILE);
const depositDataPath = path.join(this.dirpath, ETH1_DEPOSIT_DATA_FILE);
const depositAmountPath = path.join(this.dirpath, ETH1_DEPOSIT_AMOUNT_FILE);
const depositDataRlp = fs.readFileSync(depositDataPath, "utf8");
const depositAmount = fs.readFileSync(depositAmountPath, "utf8");

Expand Down
4 changes: 0 additions & 4 deletions packages/cli/src/validatorDir/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ export const WITHDRAWAL_KEYSTORE_FILE = "withdrawal-keystore.json";
export const ETH1_DEPOSIT_DATA_FILE = "eth1-deposit-data.rlp";
export const ETH1_DEPOSIT_AMOUNT_FILE = "eth1-deposit-gwei.txt";
export const ETH1_DEPOSIT_TX_HASH_FILE = "eth1-deposit-tx-hash.txt";
/**
* The file used for indicating if a directory is in-use by another process.
*/
export const LOCK_FILE = ".lock";

// Dynamic paths computed from the validator pubkey

Expand Down
65 changes: 33 additions & 32 deletions packages/keymanager-server/src/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,22 @@ import {
import {fromHexString} from "@chainsafe/ssz";
import {Interchange, SignerType, Validator} from "@chainsafe/lodestar-validator";
import {PubkeyHex} from "@chainsafe/lodestar-validator/src/types";
import {ILogger} from "@chainsafe/lodestar-utils";
import {LOCK_FILE_EXT, getLockFile} from "./util/lockfile";
import {lockFilepath, unlockFilepath} from "./util/lockfile";

export const KEY_IMPORTED_PREFIX = "key_imported";

export class KeymanagerApi implements Api {
constructor(
private readonly logger: ILogger,
private readonly validator: Validator,
private readonly importKeystoresPath: string
) {}

getKeystorePathInfoForKey = (pubkey: string): {keystoreFilePath: string; lockFilePath: string} => {
const keystoreFilename = `${KEY_IMPORTED_PREFIX}_${pubkey}.json`;
const keystoreFilePath = path.join(this.importKeystoresPath, keystoreFilename);
return {
keystoreFilePath,
lockFilePath: `${keystoreFilePath}${LOCK_FILE_EXT}`,
};
};
constructor(private readonly validator: Validator, private readonly importKeystoresDirpath: string) {
if (fs.existsSync(importKeystoresDirpath)) {
// Ensure is directory
if (!fs.statSync(importKeystoresDirpath).isDirectory()) {
throw Error("importKeystoresPath is not a directory");
}
} else {
// Or, create empty directory
fs.mkdirSync(importKeystoresDirpath, {recursive: true});
}
}

/**
* List all validating pubkeys known to and decrypted by this keymanager binary
Expand Down Expand Up @@ -63,9 +59,9 @@ export class KeymanagerApi implements Api {
* Users SHOULD send slashing_protection data associated with the imported pubkeys. MUST follow the format defined in
* EIP-3076: Slashing Protection Interchange Format.
*
* @param keystores JSON-encoded keystore files generated with the Launchpad
* @param keystoresStr JSON-encoded keystore files generated with the Launchpad
* @param passwords Passwords to unlock imported keystore files. `passwords[i]` must unlock `keystores[i]`
* @param slashingProtection Slashing protection data for some of the keys of `keystores`
* @param slashingProtectionStr Slashing protection data for some of the keys of `keystores`
* @returns Status result of each `request.keystores` with same length and order of `request.keystores`
*
* https://github.com/ethereum/keymanager-APIs/blob/0c975dae2ac6053c8245ebdb6a9f27c2f114f407/keymanager-oapi.yaml
Expand All @@ -80,7 +76,7 @@ export class KeymanagerApi implements Api {
message?: string;
}[];
}> {
const interchange = (slashingProtectionStr as unknown) as Interchange;
const interchange = JSON.parse(slashingProtectionStr) as Interchange;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@dadepo do you know why JSON.parse() was not necessary if slashingProtectionStr is supposed to be a string?

Copy link
Contributor

@dadepo dadepo Apr 5, 2022

Choose a reason for hiding this comment

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

That's because the arguments to the function is passed in as an http body and fastify parses it already, so by the time our code calls finds the relevant handler here it is already an object hence no need to use JSON.parse within the function.

I also think the types safety of the function is weaker as it is decoupled from the calling logic in here so at runtime, it is an object that is passed in, which is then attempted to be parsed into JSON

My guess is that If it were a query path or params, then the parsing will be needed.

Copy link
Contributor Author

@dapplion dapplion Apr 5, 2022

Choose a reason for hiding this comment

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

So can you add a nice long comment explaining this above my change and do conditional parsing?

const interchange: Interchange = typeof slashingProtectionStr === "string" ? JSON.parse(slashingProtectionStr) : slashingProtectionStr;

Copy link
Contributor

Choose a reason for hiding this comment

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

I added a comment but I feel that the conditional parsing is not necessary since the endpoint is going to be called with the payload within the body of the request.

Also the snippet you shared won't work. What will work will require changing the type for slashingProtectionStr: SlashingProtectionData | Record<string, unknown> and then have the conditional parsing as follows:

    const interchange = typeof slashingProtectionStr === "string"
        ? (JSON.parse(slashingProtectionStr) as Interchange)
        : ((slashingProtectionStr as unknown) as Interchange);

Which I think is a bit too much since payload will always come within the body

await this.validator.validatorStore.importInterchange(interchange);

const statuses: {status: ImportStatus; message?: string}[] = [];
Expand All @@ -105,12 +101,13 @@ export class KeymanagerApi implements Api {
const pubKey = secretKey.toPublicKey().toHex();
this.validator.validatorStore.addSigner({type: SignerType.Local, secretKey});

const keystorePathInfo = this.getKeystorePathInfoForKey(pubKey);
const keystoreFilepath = this.getKeystoreFilepath(pubKey);

// Lock before writing keystore
lockFilepath(keystoreFilepath);

// Persist keys for latter restarts
await fs.promises.writeFile(keystorePathInfo.keystoreFilePath, keystoreStr, {encoding: "utf8"});
const lockFile = getLockFile();
lockFile.lockSync(keystorePathInfo.lockFilePath);
// Persist keys for latter restarts, directory of `keystoreFilepath` is created in the constructor
await fs.promises.writeFile(keystoreFilepath, keystoreStr, {encoding: "utf8"});

statuses[i] = {status: ImportStatus.imported};
} catch (e) {
Expand All @@ -137,7 +134,7 @@ export class KeymanagerApi implements Api {
* Slashing protection data must only be returned for keys from `request.pubkeys` for which a
* `deleted` or `not_active` status is returned.
*
* @param pubkeys List of public keys to delete.
* @param pubkeysHex List of public keys to delete.
* @returns Deletion status of all keys in `request.pubkeys` in the same order.
*
* https://github.com/ethereum/keymanager-APIs/blob/0c975dae2ac6053c8245ebdb6a9f27c2f114f407/keymanager-oapi.yaml
Expand Down Expand Up @@ -172,14 +169,14 @@ export class KeymanagerApi implements Api {
// Remove from Sync committee duties
// Remove from indices
this.validator.removeDutiesForKey(pubkeyHex);
const keystorePathInfo = this.getKeystorePathInfoForKey(pubkeyHex);

const keystoreFilepath = this.getKeystoreFilepath(pubkeyHex);

// Remove key from persistent storage
for (const keystoreFile of await fs.promises.readdir(this.importKeystoresPath)) {
if (keystoreFile.indexOf(pubkeyHex) !== -1) {
await fs.promises.unlink(keystorePathInfo.keystoreFilePath);
await fs.promises.unlink(keystorePathInfo.lockFilePath);
}
}
dapplion marked this conversation as resolved.
Show resolved Hide resolved
fs.unlinkSync(keystoreFilepath);

// Unlock last
unlockFilepath(keystoreFilepath);
} catch (e) {
statuses[i] = {status: DeletionStatus.error, message: (e as Error).message};
}
Expand Down Expand Up @@ -210,4 +207,8 @@ export class KeymanagerApi implements Api {
slashingProtection: JSON.stringify(interchangeV5),
};
}

private getKeystoreFilepath(pubkeyHex: string): string {
return path.join(this.importKeystoresDirpath, `${KEY_IMPORTED_PREFIX}_${pubkeyHex}.json`);
}
}
3 changes: 3 additions & 0 deletions packages/keymanager-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ export class KeymanagerServer {

constructor(optsArg: Partial<RestApiOptions>, modules: IRestApiModules) {
this.logger = modules.logger;

// Apply opts defaults
const opts = {
...restApiOptionsDefault,
// optsArg is a Partial type, any of its properties can be undefined. If port is set to undefined,
// it overrides the default port value in restApiOptionsDefault to be undefined.
...Object.fromEntries(Object.entries(optsArg).filter(([_, v]) => v != null)),
};

Expand Down
30 changes: 28 additions & 2 deletions packages/keymanager-server/src/util/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,42 @@ type Lockfile = {
};

let lockFile: Lockfile | null = null;
export const LOCK_FILE_EXT = ".lock";

function getLockFilepath(filepath: string): string {
return `${filepath}.lock`;
}

/**
* When lockfile it's required it registers listeners to process
* Since it's only used by the validator client, require lazily to not pollute
* beacon_node client context
*/
export function getLockFile(): Lockfile {
function getLockFile(): Lockfile {
if (!lockFile) {
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
lockFile = require("lockfile") as Lockfile;
}
return lockFile;
}

/**
* Creates a .lock file for `filepath`, argument passed must not be the lock path
* @param filepath File to lock, i.e. `keystore_0001.json`
*/
export function lockFilepath(filepath: string): void {
const lf = getLockFile();

lf.lockSync(getLockFilepath(filepath));
}

/**
* Deletes a .lock file for `filepath`, argument passed must not be the lock path
* @param filepath File to unlock, i.e. `keystore_0001.json`
*/
export function unlockFilepath(filepath: string): void {
const lf = getLockFile();

// Does not throw if the lock file is already deleted
// https://github.com/npm/lockfile/blob/6590779867ee9bdc5dbebddc962640759892bb91/lockfile.js#L68
lf.unlockSync(getLockFilepath(filepath));
}
Loading