diff --git a/src/commands/actions/arguments/index.ts b/src/commands/actions/arguments/index.ts new file mode 100644 index 0000000..98c6510 --- /dev/null +++ b/src/commands/actions/arguments/index.ts @@ -0,0 +1,19 @@ +import keystoreArgument from './keystore'; +import keystorePathArgument from './keystore-path'; +import ownerNonceArgument from './owner-nonce'; +import operatorIdsArgument from './operator-ids'; +import ownerAddressArgument from './owner-address'; +import keystorePasswordArgument from './password'; +import outputFolderArgument from './output-folder'; +import operatorPublicKeysArgument from './operator-public-keys'; + +export { + keystoreArgument, + keystorePathArgument, + ownerNonceArgument, + operatorIdsArgument, + ownerAddressArgument, + keystorePasswordArgument, + outputFolderArgument, + operatorPublicKeysArgument +}; diff --git a/src/commands/actions/arguments/keystore.ts b/src/commands/actions/arguments/keystore.ts new file mode 100644 index 0000000..140dd8a --- /dev/null +++ b/src/commands/actions/arguments/keystore.ts @@ -0,0 +1,32 @@ +import { fileExistsValidator, jsonFileValidator, sanitizePath } from '../validators'; + +/** + * Keystore argument validates if keystore file exists and is valid keystore file. + */ +export default { + arg1: '-ks', + arg2: '--keystore', + options: { + required: false, + type: String, + help: 'The validator keystore file path. Only one keystore file can be specified using this argument' + }, + interactive: { + options: { + type: 'text', + message: 'Provide the keystore file path', + validateSingle: (filePath: string): any => { + filePath = sanitizePath(String(filePath).trim()); + let isValid = fileExistsValidator(filePath); + if (isValid !== true) { + return isValid; + } + isValid = jsonFileValidator(filePath); + if (isValid !== true) { + return isValid; + } + return true; + }, + } + } +}; diff --git a/src/commands/actions/validators/index.ts b/src/commands/actions/validators/index.ts new file mode 100644 index 0000000..d008b66 --- /dev/null +++ b/src/commands/actions/validators/index.ts @@ -0,0 +1,13 @@ +import { sanitizePath, fileExistsValidator, jsonFileValidator } from './file'; +import { keystorePasswordValidator } from './keystore-password'; +import { isOperatorsLengthValid } from "./operator-ids"; +import { operatorPublicKeyValidator } from './operator'; + +export { + sanitizePath, + jsonFileValidator, + fileExistsValidator, + isOperatorsLengthValid, + keystorePasswordValidator, + operatorPublicKeyValidator, +} diff --git a/src/lib/KeyShares/KeySharesData/validators/index.ts b/src/lib/KeyShares/KeySharesData/validators/index.ts new file mode 100644 index 0000000..d4c47c4 --- /dev/null +++ b/src/lib/KeyShares/KeySharesData/validators/index.ts @@ -0,0 +1,15 @@ +import { OpeatorsListValidator } from './operator-unique'; +import { PublicKeyValidator } from './public-key'; +import { OwnerAddressValidator } from './owner-address'; +import { OwnerNonceValidator } from './owner-nonce'; +import { MatchLengthValidator } from './match'; +import { OpeatorPublicKeyValidator } from './operator-public-key'; + +export { + OpeatorsListValidator, + PublicKeyValidator, + OwnerAddressValidator, + OwnerNonceValidator, + MatchLengthValidator, + OpeatorPublicKeyValidator, +} diff --git a/src/lib/KeyShares/KeySharesItem.ts b/src/lib/KeyShares/KeySharesItem.ts new file mode 100644 index 0000000..fde882a --- /dev/null +++ b/src/lib/KeyShares/KeySharesItem.ts @@ -0,0 +1,193 @@ +import * as ethers from 'ethers'; +import * as web3Helper from '../helpers/web3.helper'; +import { + IsOptional, + ValidateNested, + validateSync +} from 'class-validator'; + +import { KeySharesData } from './KeySharesData/KeySharesData'; +import { KeySharesPayload } from './KeySharesData/KeySharesPayload'; +import { EncryptShare } from '../Encryption/Encryption'; +import { IKeySharesPartitialData } from './KeySharesData/IKeySharesData'; +import { IOperator } from './KeySharesData/IOperator'; +import { operatorSortedList } from '../helpers/operator.helper'; +import { OwnerAddressFormatError, OwnerNonceFormatError } from '../exceptions/keystore'; + +export interface IKeySharesPayloadData { + publicKey: string, + operators: IOperator[], + encryptedShares: EncryptShare[], +} + +export interface IKeySharesToSignatureData { + ownerAddress: string, + ownerNonce: number, + privateKey: string, +} + +export interface IKeySharesFromSignatureData { + ownerAddress: string, + ownerNonce: number, + publicKey: string, +} + +const SIGNATURE_LENGHT = 192; +const PUBLIC_KEY_LENGHT = 96; + +/** + * Key shares file data interface. + */ +export class KeySharesItem { + @IsOptional() + @ValidateNested() + public data: KeySharesData; + + @IsOptional() + @ValidateNested() + public payload: KeySharesPayload; + + constructor() { + this.data = new KeySharesData(); + this.payload = new KeySharesPayload(); + } + + /** + * Build payload from operators list, encrypted shares and validator public key + * @param publicKey + * @param operatorIds + * @param encryptedShares + */ + async buildPayload(metaData: IKeySharesPayloadData, toSignatureData: IKeySharesToSignatureData): Promise { + const { + ownerAddress, + ownerNonce, + privateKey, + } = toSignatureData; + + if (!Number.isInteger(ownerNonce) || ownerNonce < 0) { + throw new OwnerNonceFormatError(ownerNonce, 'Owner nonce is not positive integer'); + } + + let address; + try { + address = web3Helper.web3.utils.toChecksumAddress(ownerAddress); + } catch { + throw new OwnerAddressFormatError(ownerAddress, 'Owner address is not a valid Ethereum address'); + } + + const payload = this.payload.build({ + publicKey: metaData.publicKey, + operatorIds: operatorSortedList(metaData.operators).map(operator => operator.id), + encryptedShares: metaData.encryptedShares, + }); + + const signature = await web3Helper.buildSignature(`${address}:${ownerNonce}`, privateKey); + const signSharesBytes = web3Helper.hexArrayToBytes([signature, payload.sharesData]); + + payload.sharesData = `0x${signSharesBytes.toString('hex')}`; + + // verify signature + await this.validateSingleShares(payload.sharesData, { + ownerAddress, + ownerNonce, + publicKey: await web3Helper.privateToPublicKey(privateKey), + }); + + return payload; + } + + + async validateSingleShares(shares: string, fromSignatureData: IKeySharesFromSignatureData): Promise { + const { ownerAddress, ownerNonce, publicKey } = fromSignatureData; + + if (!Number.isInteger(ownerNonce) || ownerNonce < 0) { + throw new OwnerNonceFormatError(ownerNonce, 'Owner nonce is not positive integer'); + } + + const address = web3Helper.web3.utils.toChecksumAddress(ownerAddress); + const signaturePt = shares.replace('0x', '').substring(0, SIGNATURE_LENGHT); + + await web3Helper.validateSignature(`${address}:${ownerNonce}`, `0x${signaturePt}`, publicKey); + } + + /** + * Build shares from bytes string and operators list length + * @param bytes + * @param operatorCount + */ + buildSharesFromBytes(bytes: string, operatorCount: number): any { + const sharesPt = bytes.replace('0x', '').substring(SIGNATURE_LENGHT); + + const pkSplit = sharesPt.substring(0, operatorCount * PUBLIC_KEY_LENGHT); + const pkArray = ethers.utils.arrayify('0x' + pkSplit); + const sharesPublicKeys = this.splitArray(operatorCount, pkArray) + .map(item => ethers.utils.hexlify(item)); + + const eSplit = bytes.substring(operatorCount * PUBLIC_KEY_LENGHT); + const eArray = ethers.utils.arrayify('0x' + eSplit); + const encryptedKeys = this.splitArray(operatorCount, eArray).map(item => + Buffer.from(ethers.utils.hexlify(item).replace('0x', ''), 'hex').toString( + 'base64', + ), + ); + + return { sharesPublicKeys, encryptedKeys }; + } + + /** + * Updates the current instance with partial data and payload, and validates. + * @param data Partial key shares data. + * @param payload Partial key shares payload. + */ + update(data: IKeySharesPartitialData): void { + this.data.update(data); + this.validate(); + } + + /** + * Validate everything + */ + validate(): any { + validateSync(this); + } + + /** + * Initialise from JSON or object data. + */ + async fromJson(content: string | any): Promise { + const body = typeof content === 'string' ? JSON.parse(content) : content; + this.data.update(body.data); + this.payload.update(body.payload); + this.validate(); + // Custom validation: verify signature + await this.validateSingleShares(this.payload.sharesData, { + ownerAddress: this.data.ownerAddress as string, + ownerNonce: this.data.ownerNonce as number, + publicKey: this.data.publicKey as string, + }); + + return this; + } + + /** + * Stringify key shares to be ready for saving in file. + */ + toJson(): string { + return JSON.stringify({ + data: this.data || null, + payload: this.payload || null, + }, null, 2); + } + + private splitArray(parts: number, arr: Uint8Array) { + const partLength = Math.floor(arr.length / parts); + const partsArr = []; + for (let i = 0; i < parts; i++) { + const start = i * partLength; + const end = start + partLength; + partsArr.push(arr.slice(start, end)); + } + return partsArr; + } +} diff --git a/src/lib/exceptions/base.ts b/src/lib/exceptions/base.ts new file mode 100644 index 0000000..c86bb03 --- /dev/null +++ b/src/lib/exceptions/base.ts @@ -0,0 +1,8 @@ +export class BaseCustomError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + this.stack = `${this.name}: ${this.message}`; // Customizing stack + } +}