feat: CLI command to get validator address by S3 bucket or KMS key ID (#3795)

### Description

Create the `hyperlane validator address` command, making it easy for users to get the address of their validator key.

### Related issues

Fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3341

### Backward compatibility

Yes

### Testing

Manual testing under all conditions/edge cases

---------

Co-authored-by: J M Rossy <jm.rossy@gmail.com>
pull/3846/head
Ali Alaoui 5 months ago committed by GitHub
parent fdd1421540
commit b22a0f4538
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/neat-ducks-own.md
  2. 2
      typescript/cli/cli.ts
  3. 3
      typescript/cli/package.json
  4. 32
      typescript/cli/src/commands/options.ts
  5. 51
      typescript/cli/src/commands/validator.ts
  6. 3
      typescript/cli/src/utils/env.ts
  7. 166
      typescript/cli/src/validator/address.ts
  8. 1332
      yarn.lock

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/cli': minor
---
Add hyperlane validator address command to retrieve validator address from AWS

@ -20,6 +20,7 @@ import {
} from './src/commands/options.js';
import { sendCommand } from './src/commands/send.js';
import { statusCommand } from './src/commands/status.js';
import { validatorCommand } from './src/commands/validator.js';
import { contextMiddleware } from './src/context/context.js';
import { configureLogger, errorRed } from './src/logger.js';
import { checkVersion } from './src/utils/version-check.js';
@ -55,6 +56,7 @@ try {
.command(ismCommand)
.command(sendCommand)
.command(statusCommand)
.command(validatorCommand)
.version(VERSION)
.demandCommand()
.strict()

@ -3,10 +3,13 @@
"version": "3.12.2",
"description": "A command-line utility for common Hyperlane operations",
"dependencies": {
"@aws-sdk/client-kms": "^3.577.0",
"@aws-sdk/client-s3": "^3.577.0",
"@hyperlane-xyz/registry": "^1.0.7",
"@hyperlane-xyz/sdk": "3.12.2",
"@hyperlane-xyz/utils": "3.12.2",
"@inquirer/prompts": "^3.0.0",
"asn1.js": "^5.4.1",
"bignumber.js": "^9.1.1",
"chalk": "^5.3.0",
"ethers": "^5.7.2",

@ -146,3 +146,35 @@ export const addressCommandOption = (
description,
demandOption,
});
/* Validator options */
export const awsAccessKeyCommandOption: Options = {
type: 'string',
description: 'AWS access key of IAM user associated with validator',
default: ENV.AWS_ACCESS_KEY_ID,
defaultDescription: 'process.env.AWS_ACCESS_KEY_ID',
};
export const awsSecretKeyCommandOption: Options = {
type: 'string',
description: 'AWS secret access key of IAM user associated with validator',
default: ENV.AWS_SECRET_ACCESS_KEY,
defaultDescription: 'process.env.AWS_SECRET_ACCESS_KEY',
};
export const awsRegionCommandOption: Options = {
type: 'string',
describe: 'AWS region associated with validator',
default: ENV.AWS_REGION,
defaultDescription: 'process.env.AWS_REGION',
};
export const awsBucketCommandOption: Options = {
type: 'string',
describe: 'AWS S3 bucket containing validator signatures and announcement',
};
export const awsKeyIdCommandOption: Options = {
type: 'string',
describe: 'Key ID from AWS KMS',
};

@ -0,0 +1,51 @@
import { CommandModule } from 'yargs';
import { CommandModuleWithContext } from '../context/types.js';
import { log } from '../logger.js';
import { getValidatorAddress } from '../validator/address.js';
import {
awsAccessKeyCommandOption,
awsBucketCommandOption,
awsKeyIdCommandOption,
awsRegionCommandOption,
awsSecretKeyCommandOption,
} from './options.js';
// Parent command to help configure and set up Hyperlane validators
export const validatorCommand: CommandModule = {
command: 'validator',
describe: 'Configure and manage Hyperlane validators',
builder: (yargs) => yargs.command(addressCommand).demandCommand(),
handler: () => log('Command required'),
};
// If AWS access key needed for future validator commands, move to context
const addressCommand: CommandModuleWithContext<{
accessKey: string;
secretKey: string;
region: string;
bucket: string;
keyId: string;
}> = {
command: 'address',
describe: 'Get the validator address from S3 bucket or KMS key ID',
builder: {
'access-key': awsAccessKeyCommandOption,
'secret-key': awsSecretKeyCommandOption,
region: awsRegionCommandOption,
bucket: awsBucketCommandOption,
'key-id': awsKeyIdCommandOption,
},
handler: async ({ context, accessKey, secretKey, region, bucket, keyId }) => {
await getValidatorAddress({
context,
accessKey,
secretKey,
region,
bucket,
keyId,
});
process.exit(0);
},
};

@ -4,6 +4,9 @@ const envScheme = z.object({
HYP_KEY: z.string().optional(),
ANVIL_IP_ADDR: z.string().optional(),
ANVIL_PORT: z.number().optional(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
AWS_REGION: z.string().optional(),
});
const parsedEnv = envScheme.safeParse(process.env);

@ -0,0 +1,166 @@
import { GetPublicKeyCommand, KMSClient } from '@aws-sdk/client-kms';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { input } from '@inquirer/prompts';
// @ts-ignore
import asn1 from 'asn1.js';
import { ethers } from 'ethers';
import { assert } from '@hyperlane-xyz/utils';
import { CommandContext } from '../context/types.js';
import { log, logBlue } from '../logger.js';
export async function getValidatorAddress({
context,
accessKey,
secretKey,
region,
bucket,
keyId,
}: {
context: CommandContext;
accessKey?: string;
secretKey?: string;
region?: string;
bucket?: string;
keyId?: string;
}) {
if (!bucket && !keyId) {
throw new Error('Must provide either an S3 bucket or a KMS Key ID.');
}
// Query user for AWS parameters if not passed in or stored as .env variables
accessKey ||= await getAccessKeyId(context.skipConfirmation);
secretKey ||= await getSecretAccessKey(context.skipConfirmation);
region ||= await getRegion(context.skipConfirmation);
assert(accessKey, 'No access key ID set.');
assert(secretKey, 'No secret access key set.');
assert(region, 'No AWS region set.');
let validatorAddress;
if (bucket) {
validatorAddress = await getAddressFromBucket(
bucket,
accessKey,
secretKey,
region,
);
} else {
validatorAddress = await getAddressFromKey(
keyId!,
accessKey,
secretKey,
region,
);
}
logBlue('Validator address is: ');
log(validatorAddress);
}
/**
* Displays validator key address from
* validator announcement S3 bucket.
*/
async function getAddressFromBucket(
bucket: string,
accessKeyId: string,
secretAccessKey: string,
region: string,
) {
const s3Client = new S3Client({
region: region,
credentials: {
accessKeyId,
secretAccessKey,
},
});
const { Body } = await s3Client.send(
new GetObjectCommand({
Bucket: bucket,
Key: 'announcement.json',
}),
);
if (Body) {
const announcement = JSON.parse(await Body?.transformToString());
return announcement['value']['validator'];
} else {
throw new Error('Announcement file announcement.json not found in bucket');
}
}
/**
* Logs validator key address using AWS KMS key ID.
* Taken from github.com/tkporter/get-aws-kms-address/
*/
async function getAddressFromKey(
keyId: string,
accessKeyId: string,
secretAccessKey: string,
region: string,
) {
const client = new KMSClient({
region: region,
credentials: {
accessKeyId,
secretAccessKey,
},
});
const publicKeyResponse = await client.send(
new GetPublicKeyCommand({ KeyId: keyId }),
);
return getEthereumAddress(Buffer.from(publicKeyResponse.PublicKey!));
}
const EcdsaPubKey = asn1.define('EcdsaPubKey', function (this: any) {
this.seq().obj(
this.key('algo').seq().obj(this.key('a').objid(), this.key('b').objid()),
this.key('pubKey').bitstr(),
);
});
function getEthereumAddress(publicKey: Buffer): string {
// The public key is ASN1 encoded in a format according to
// https://tools.ietf.org/html/rfc5480#section-2
const res = EcdsaPubKey.decode(publicKey, 'der');
let pubKeyBuffer: Buffer = res.pubKey.data;
// The public key starts with a 0x04 prefix that needs to be removed
// more info: https://www.oreilly.com/library/view/mastering-ethereum/9781491971932/ch04.html
pubKeyBuffer = pubKeyBuffer.slice(1, pubKeyBuffer.length);
const address = ethers.utils.keccak256(pubKeyBuffer); // keccak256 hash of publicKey
return `0x${address.slice(-40)}`; // take last 20 bytes as ethereum address
}
async function getAccessKeyId(skipConfirmation: boolean) {
if (skipConfirmation) throw new Error('No AWS access key ID set.');
else
return await input({
message:
'Please enter AWS access key ID or use the AWS_ACCESS_KEY_ID environment variable.',
});
}
async function getSecretAccessKey(skipConfirmation: boolean) {
if (skipConfirmation) throw new Error('No AWS secret access key set.');
else
return await input({
message:
'Please enter AWS secret access key or use the AWS_SECRET_ACCESS_KEY environment variable.',
});
}
async function getRegion(skipConfirmation: boolean) {
if (skipConfirmation) throw new Error('No AWS region set.');
else
return await input({
message:
'Please enter AWS region or use the AWS_REGION environment variable.',
});
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save