Refactor Wallet and HDNodeWallet structure.

This commit is contained in:
Richard Moore 2022-11-10 04:04:27 -05:00
parent bbc488a472
commit 1e56d5044e
5 changed files with 146 additions and 216 deletions

View File

@ -6,11 +6,18 @@ import {
defineProperties, resolveProperties, assert, assertArgument
} from "../utils/index.js";
import {
encryptKeystoreJson, encryptKeystoreJsonSync,
} from "./json-keystore.js";
import type { SigningKey } from "../crypto/index.js";
import type { TypedDataDomain, TypedDataField } from "../hash/index.js";
import type { Provider, TransactionRequest } from "../providers/index.js";
import type { TransactionLike } from "../transaction/index.js";
import type { ProgressCallback } from "../crypto/index.js";
export class BaseWallet extends AbstractSigner {
readonly address!: string;
@ -34,6 +41,16 @@ export class BaseWallet extends AbstractSigner {
return new BaseWallet(this.#signingKey, provider);
}
async encrypt(password: Uint8Array | string, progressCallback?: ProgressCallback): Promise<string> {
const account = { address: this.address, privateKey: this.privateKey };
return await encryptKeystoreJson(account, password, { progressCallback });
}
encryptSync(password: Uint8Array | string): string {
const account = { address: this.address, privateKey: this.privateKey };
return encryptKeystoreJsonSync(account, password);
}
async signTransaction(tx: TransactionRequest): Promise<string> {
// Replace any Addressable or ENS name with an address
@ -72,6 +89,9 @@ export class BaseWallet extends AbstractSigner {
// Populate any ENS names
const populated = await TypedDataEncoder.resolveNames(domain, types, value, async (name: string) => {
// @TODO: this should use resolveName; addresses don't
// need a provider
assert(this.provider != null, "cannot resolve ENS names without a provider", "UNSUPPORTED_OPERATION", {
operation: "resolveName",
info: { name }

View File

@ -194,21 +194,6 @@ export class HDNodeWallet extends BaseWallet {
"m", 0, 0, mnemonic, null);
}
static fromSeed(seed: BytesLike): HDNodeWallet {
return HDNodeWallet.#fromSeed(seed, null);
}
static fromPhrase(phrase: string, password: string = "", path: null | string = defaultPath, wordlist: Wordlist = langEn): HDNodeWallet {
if (!path) { path = defaultPath; }
const mnemonic = Mnemonic.fromPhrase(phrase, password, wordlist)
return HDNodeWallet.#fromSeed(mnemonic.computeSeed(), mnemonic).derivePath(path);
}
static fromMnemonic(mnemonic: Mnemonic, path: null | string = defaultPath): HDNodeWallet {
if (!path) { path = defaultPath; }
return HDNodeWallet.#fromSeed(mnemonic.computeSeed(), mnemonic).derivePath(path);
}
static fromExtendedKey(extendedKey: string): HDNodeWallet | HDNodeVoidWallet {
const bytes = getBytes(decodeBase58(extendedKey)); // @TODO: redact
@ -240,11 +225,30 @@ export class HDNodeWallet extends BaseWallet {
assertArgument(false, "invalid extended key prefix", "extendedKey", "[ REDACTED ]");
}
static createRandom(password: string = "", path: null | string = defaultPath, wordlist: Wordlist = langEn): HDNodeWallet {
if (!path) { path = defaultPath; }
static createRandom(password?: string, path?: string, wordlist?: Wordlist): HDNodeWallet {
if (password == null) { password = ""; }
if (path == null) { path = defaultPath; }
if (wordlist == null) { wordlist = langEn; }
const mnemonic = Mnemonic.fromEntropy(randomBytes(16), password, wordlist)
return HDNodeWallet.#fromSeed(mnemonic.computeSeed(), mnemonic).derivePath(path);
}
static fromMnemonic(mnemonic: Mnemonic, path?: string): HDNodeWallet {
if (!path) { path = defaultPath; }
return HDNodeWallet.#fromSeed(mnemonic.computeSeed(), mnemonic).derivePath(path);
}
static fromPhrase(phrase: string, password?: string, path?: string, wordlist?: Wordlist): HDNodeWallet {
if (password == null) { password = ""; }
if (path == null) { path = defaultPath; }
if (wordlist == null) { wordlist = langEn; }
const mnemonic = Mnemonic.fromPhrase(phrase, password, wordlist)
return HDNodeWallet.#fromSeed(mnemonic.computeSeed(), mnemonic).derivePath(path);
}
static fromSeed(seed: BytesLike): HDNodeWallet {
return HDNodeWallet.#fromSeed(seed, null);
}
}
export class HDNodeVoidWallet extends VoidSigner {

View File

@ -23,6 +23,5 @@ export { Wallet } from "./wallet.js";
export type { CrowdsaleAccount } from "./json-crowdsale.js";
export type {
KeystoreAccountParams, KeystoreAccount,
EncryptOptions
KeystoreAccount, EncryptOptions
} from "./json-keystore.js"

View File

@ -14,30 +14,22 @@ import type { BytesLike } from "../utils/index.js";
import { version } from "../_version.js";
const defaultPath = "m/44'/60'/0'/0/0";
export type KeystoreAccountParams = {
privateKey: string;
address?: string;
mnemonic?: {
entropy: string;
path: string;
locale: string;
};
};
export type KeystoreAccount = {
address: string;
privateKey: string;
mnemonic?: {
path?: string;
locale?: string;
entropy: string;
path: string;
locale: string;
};
}
};
export type EncryptOptions = {
progressCallback?: ProgressCallback;
iv?: BytesLike;
entropy?: BytesLike;
client?: string;
@ -112,14 +104,16 @@ function getAccount(data: any, _key: string): KeystoreAccount {
return account;
}
type KdfParams = {
type ScryptParams = {
name: "scrypt";
salt: Uint8Array;
N: number;
r: number;
p: number;
dkLen: number;
} | {
};
type KdfParams = ScryptParams | {
name: "pbkdf2";
salt: Uint8Array;
count: number;
@ -127,7 +121,7 @@ type KdfParams = {
algorithm: "sha256" | "sha512";
};
function getKdfParams<T>(data: any): KdfParams {
function getDecryptKdfParams<T>(data: any): KdfParams {
const kdf = spelunk(data, "crypto.kdf:string");
if (kdf && typeof(kdf) === "string") {
const throwError = function(name: string, value: any): never {
@ -179,18 +173,18 @@ export function decryptKeystoreJsonSync(json: string, _password: string | Uint8A
const password = getPassword(_password);
const params = getKdfParams(data);
const params = getDecryptKdfParams(data);
if (params.name === "pbkdf2") {
const { salt, count, dkLen, algorithm } = params;
const key = pbkdf2(password, salt, count, dkLen, algorithm);
return getAccount(data, key);
} else if (params.name === "scrypt") {
const { salt, N, r, p, dkLen } = params;
const key = scryptSync(password, salt, N, r, p, dkLen);
return getAccount(data, key);
}
throw new Error("unreachable");
assert(params.name === "scrypt", "cannot be reached", "UNKNOWN_ERROR", { params })
const { salt, N, r, p, dkLen } = params;
const key = scryptSync(password, salt, N, r, p, dkLen);
return getAccount(data, key);
}
function stall(duration: number): Promise<void> {
@ -202,7 +196,7 @@ export async function decryptKeystoreJson(json: string, _password: string | Uint
const password = getPassword(_password);
const params = getKdfParams(data);
const params = getDecryptKdfParams(data);
if (params.name === "pbkdf2") {
if (progress) {
progress(0);
@ -215,69 +209,18 @@ export async function decryptKeystoreJson(json: string, _password: string | Uint
await stall(0);
}
return getAccount(data, key);
} else if (params.name === "scrypt") {
const { salt, N, r, p, dkLen } = params;
const key = await scrypt(password, salt, N, r, p, dkLen, progress);
return getAccount(data, key);
}
throw new Error("unreachable");
assert(params.name === "scrypt", "cannot be reached", "UNKNOWN_ERROR", { params })
const { salt, N, r, p, dkLen } = params;
const key = await scrypt(password, salt, N, r, p, dkLen, progress);
return getAccount(data, key);
}
export async function encryptKeystoreJson(account: KeystoreAccount, password: string | Uint8Array, options?: EncryptOptions, progressCallback?: ProgressCallback): Promise<string> {
// Check the address matches the private key
//if (getAddress(account.address) !== computeAddress(account.privateKey)) {
// throw new Error("address/privateKey mismatch");
//}
// Check the mnemonic (if any) matches the private key
/*
if (hasMnemonic(account)) {
const mnemonic = account.mnemonic;
const node = HDNode.fromMnemonic(mnemonic.phrase, null, mnemonic.locale).derivePath(mnemonic.path || defaultPath);
if (node.privateKey != account.privateKey) {
throw new Error("mnemonic mismatch");
}
}
*/
// The options are optional, so adjust the call as needed
if (typeof(options) === "function" && !progressCallback) {
progressCallback = options;
options = {};
}
if (!options) { options = {}; }
const privateKey = getBytes(account.privateKey, "privateKey");
const passwordBytes = getPassword(password);
/*
let mnemonic: null | Mnemonic = null;
let entropy: Uint8Array = null
let path: string = null;
let locale: string = null;
if (hasMnemonic(account)) {
const srcMnemonic = account.mnemonic;
entropy = arrayify(mnemonicToEntropy(srcMnemonic.phrase, srcMnemonic.locale || "en"));
path = srcMnemonic.path || defaultPath;
locale = srcMnemonic.locale || "en";
mnemonic = Mnemonic.from(
}
*/
function getEncryptKdfParams(options: EncryptOptions): ScryptParams {
// Check/generate the salt
const salt = (options.salt != null) ? getBytes(options.salt, "options.slat"): randomBytes(32);
// Override initialization vector
const iv = (options.iv != null) ? getBytes(options.iv, "options.iv"): randomBytes(16);
assertArgument(iv.length === 16, "invalid options.iv", "options.iv", options.iv);
// Override the uuid
const uuidRandom = (options.uuid != null) ? getBytes(options.uuid, "options.uuid"): randomBytes(16);
assertArgument(uuidRandom.length === 16, "invalid options.uuid", "options.uuid", options.iv);
if (uuidRandom.length !== 16) { throw new Error("invalid uuid"); }
const salt = (options.salt != null) ? getBytes(options.salt, "options.salt"): randomBytes(32);
// Override the scrypt password-based key derivation function parameters
let N = (1 << 17), r = 8, p = 1;
@ -286,14 +229,28 @@ export async function encryptKeystoreJson(account: KeystoreAccount, password: st
if (options.scrypt.r) { r = options.scrypt.r; }
if (options.scrypt.p) { p = options.scrypt.p; }
}
assertArgument(typeof(N) === "number" && Number.isSafeInteger(N) && (BigInt(N) & BigInt(N - 1)) === BigInt(0), "invalid scrypt N parameter", "options.N", N);
assertArgument(typeof(r) === "number" && Number.isSafeInteger(r), "invalid scrypt r parameter", "options.r", r);
assertArgument(typeof(p) === "number" && Number.isSafeInteger(p), "invalid scrypt p parameter", "options.p", p);
// We take 64 bytes:
// - 32 bytes As normal for the Web3 secret storage (derivedKey, macPrefix)
// - 32 bytes AES key to encrypt mnemonic with (required here to be Ethers Wallet)
const _key = await scrypt(passwordBytes, salt, N, r, p, 64, progressCallback);
const key = getBytes(_key);
return { name: "scrypt", dkLen: 32, salt, N, r, p };
}
export function _encryptKeystore(key: Uint8Array, kdf: ScryptParams, account: KeystoreAccount, options: EncryptOptions): any {
const privateKey = getBytes(account.privateKey, "privateKey");
// Override initialization vector
const iv = (options.iv != null) ? getBytes(options.iv, "options.iv"): randomBytes(16);
assertArgument(iv.length === 16, "invalid options.iv", "options.iv", options.iv);
// Override the uuid
const uuidRandom = (options.uuid != null) ? getBytes(options.uuid, "options.uuid"): randomBytes(16);
assertArgument(uuidRandom.length === 16, "invalid options.uuid", "options.uuid", options.iv);
// This will be used to encrypt the wallet (as per Web3 secret storage)
// - 32 bytes As normal for the Web3 secret storage (derivedKey, macPrefix)
// - 32 bytes AES key to encrypt mnemonic with (required here to be Ethers Wallet)
const derivedKey = key.slice(0, 16);
const macPrefix = key.slice(16, 32);
@ -317,11 +274,11 @@ export async function encryptKeystoreJson(account: KeystoreAccount, password: st
ciphertext: hexlify(ciphertext).substring(2),
kdf: "scrypt",
kdfparams: {
salt: hexlify(salt).substring(2),
n: N,
salt: hexlify(kdf.salt).substring(2),
n: kdf.N,
dklen: 32,
p: p,
r: r
p: kdf.p,
r: kdf.r
},
mac: mac.substring(2)
}
@ -360,3 +317,22 @@ export async function encryptKeystoreJson(account: KeystoreAccount, password: st
return JSON.stringify(data);
}
export function encryptKeystoreJsonSync(account: KeystoreAccount, password: string | Uint8Array, options?: EncryptOptions): string {
if (options == null) { options = { }; }
const passwordBytes = getPassword(password);
const kdf = getEncryptKdfParams(options);
const key = scryptSync(passwordBytes, kdf.salt, kdf.N, kdf.r, kdf.p, 64);
return _encryptKeystore(getBytes(key), kdf, account, options);
}
export async function encryptKeystoreJson(account: KeystoreAccount, password: string | Uint8Array, options?: EncryptOptions): Promise<string> {
if (options == null) { options = { }; }
const passwordBytes = getPassword(password);
const kdf = getEncryptKdfParams(options);
const key = await scrypt(passwordBytes, kdf.salt, kdf.N, kdf.r, kdf.p, 64, options.progressCallback);
return _encryptKeystore(getBytes(key), kdf, account, options);
}

View File

@ -1,116 +1,58 @@
import { randomBytes, SigningKey } from "../crypto/index.js";
import { computeAddress } from "../transaction/index.js";
import { isHexString, assertArgument } from "../utils/index.js";
import { SigningKey } from "../crypto/index.js";
import { assertArgument } from "../utils/index.js";
import { BaseWallet } from "./base-wallet.js";
import { HDNodeWallet } from "./hdwallet.js";
import { decryptCrowdsaleJson, isCrowdsaleJson } from "./json-crowdsale.js";
import {
decryptKeystoreJson, decryptKeystoreJsonSync, isKeystoreJson
decryptKeystoreJson, decryptKeystoreJsonSync,
isKeystoreJson
} from "./json-keystore.js";
import { Mnemonic } from "./mnemonic.js";
import type { ProgressCallback } from "../crypto/index.js";
import type { Provider } from "../providers/index.js";
import type { Wordlist } from "../wordlists/index.js";
import type { CrowdsaleAccount } from "./json-crowdsale.js";
import type { KeystoreAccount } from "./json-keystore.js";
function tryWallet(value: any): null | Wallet {
try {
if (!value || !value.signingKey) { return null; }
const key = trySigningKey(value.signingKey);
if (key == null || computeAddress(key.publicKey) !== value.address) { return null; }
if (value.mnemonic) {
const wallet = HDNodeWallet.fromMnemonic(value.mnemonic);
if (wallet.privateKey !== key.privateKey) { return null; }
}
return value;
} catch (e) { console.log(e); }
return null;
}
// Try using value as mnemonic to derive the defaultPath HDodeWallet
function tryMnemonic(value: any): null | HDNodeWallet {
try {
if (value == null || typeof(value.phrase) !== "string" ||
typeof(value.password) !== "string" ||
value.wordlist == null) { return null; }
return HDNodeWallet.fromPhrase(value.phrase, value.password, null, value.wordlist);
} catch (error) { console.log(error); }
return null;
}
function trySigningKey(value: any): null | SigningKey {
try {
if (!value || !isHexString(value.privateKey, 32)) { return null; }
const key = value.privateKey;
if (SigningKey.computePublicKey(key) !== value.publicKey) { return null; }
return new SigningKey(key);
} catch (e) { console.log(e); }
return null;
}
function stall(duration: number): Promise<void> {
return new Promise((resolve) => { setTimeout(() => { resolve(); }, duration); });
}
export class Wallet extends BaseWallet {
readonly #mnemonic: null | Mnemonic;
constructor(key: string | Mnemonic | SigningKey | BaseWallet, provider?: null | Provider) {
let signingKey: null | SigningKey = null;
let mnemonic: null | Mnemonic = null;
// A normal private key
if (typeof(key) === "string") { signingKey = new SigningKey(key); }
// Try Wallet
if (signingKey == null) {
const wallet = tryWallet(key);
if (wallet) {
signingKey = wallet.signingKey;
mnemonic = wallet.mnemonic || null;
}
}
// Try Mnemonic, with the defaultPath wallet
if (signingKey == null) {
const wallet = tryMnemonic(key);
if (wallet) {
signingKey = wallet.signingKey;
mnemonic = wallet.mnemonic || null;
}
}
// A signing key
if (signingKey == null) { signingKey = trySigningKey(key); }
assertArgument(signingKey != null, "invalid key", "key", "[ REDACTED ]");
super(signingKey as SigningKey, provider);
this.#mnemonic = mnemonic;
constructor(key: string | SigningKey, provider?: null | Provider) {
let signingKey = (typeof(key) === "string") ? new SigningKey(key): key;
super(signingKey, provider);
}
// Store this in a getter to reduce visibility in console.log
get mnemonic(): null | Mnemonic { return this.#mnemonic; }
connect(provider: null | Provider): Wallet {
return new Wallet(this, provider);
return new Wallet(this.signingKey, provider);
}
async encrypt(password: Uint8Array | string, options?: any, progressCallback?: ProgressCallback): Promise<string> {
throw new Error("TODO");
static #fromAccount(account: null | CrowdsaleAccount | KeystoreAccount): HDNodeWallet | Wallet {
assertArgument(account, "invalid JSON wallet", "json", "[ REDACTED ]");
if ("mnemonic" in account && account.mnemonic && account.mnemonic.locale === "en") {
const mnemonic = Mnemonic.fromEntropy(account.mnemonic.entropy);
const wallet = HDNodeWallet.fromMnemonic(mnemonic, account.mnemonic.path);
if (wallet.address === account.address && wallet.privateKey === account.privateKey) {
return wallet;
}
console.log("WARNING: JSON mismatch address/privateKey != mnemonic; fallback onto private key");
}
const wallet = new Wallet(account.privateKey);
assertArgument(wallet.address === account.address,
"address/privateKey mismatch", "json", "[ REDACTED ]");
return wallet;
}
encryptSync(password: Uint8Array | string, options?: any): Promise<string> {
throw new Error("TODO");
}
static async fromEncryptedJson(json: string, password: Uint8Array | string, progress?: ProgressCallback): Promise<Wallet> {
static async fromEncryptedJson(json: string, password: Uint8Array | string, progress?: ProgressCallback): Promise<HDNodeWallet | Wallet> {
let account: null | CrowdsaleAccount | KeystoreAccount = null;
if (isKeystoreJson(json)) {
account = await decryptKeystoreJson(json, password, progress);
@ -120,15 +62,9 @@ export class Wallet extends BaseWallet {
account = decryptCrowdsaleJson(json, password);
if (progress) { progress(1); await stall(0); }
} else {
assertArgument(false, "invalid JSON wallet", "json", "[ REDACTED ]");
}
const wallet = new Wallet(account.privateKey);
assertArgument(wallet.address === account.address,
"address/privateKey mismatch", "json", "[ REDACTED ]");
// @TODO: mnemonic
return wallet;
return Wallet.#fromAccount(account);
}
static fromEncryptedJsonSync(json: string, password: Uint8Array | string): Wallet {
@ -141,23 +77,18 @@ export class Wallet extends BaseWallet {
assertArgument(false, "invalid JSON wallet", "json", "[ REDACTED ]");
}
const wallet = new Wallet(account.privateKey);
assertArgument(wallet.address === account.address,
"address/privateKey mismatch", "json", "[ REDACTED ]");
// @TODO: mnemonic
return Wallet.#fromAccount(account);
}
static createRandom(provider?: null | Provider): HDNodeWallet {
const wallet = HDNodeWallet.createRandom();
if (provider) { return wallet.connect(provider); }
return wallet;
}
static createRandom(provider?: null | Provider, password?: null | string, wordlist?: null | Wordlist): Wallet {
return new Wallet(Mnemonic.fromEntropy(randomBytes(16), password, wordlist), provider);
}
static fromMnemonic(mnemonic: Mnemonic, provider?: null | Provider): Wallet {
return new Wallet(mnemonic, provider);
}
static fromPhrase(phrase: string, provider?: null | Provider, password?: string, wordlist?: Wordlist): Wallet {
if (password == null) { password = ""; }
return new Wallet(Mnemonic.fromPhrase(phrase, password, wordlist), provider);
static fromPhrase(phrase: string, provider?: Provider): HDNodeWallet {
const wallet = HDNodeWallet.fromPhrase(phrase);
if (provider) { return wallet.connect(provider); }
return wallet;
}
}