diff --git a/src.ts/wallet/base-wallet.ts b/src.ts/wallet/base-wallet.ts index 01cf76dd5..72e75a1eb 100644 --- a/src.ts/wallet/base-wallet.ts +++ b/src.ts/wallet/base-wallet.ts @@ -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 { + 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 { // 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 } diff --git a/src.ts/wallet/hdwallet.ts b/src.ts/wallet/hdwallet.ts index 200e3aa07..00b310229 100644 --- a/src.ts/wallet/hdwallet.ts +++ b/src.ts/wallet/hdwallet.ts @@ -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 { diff --git a/src.ts/wallet/index.ts b/src.ts/wallet/index.ts index f081d2f81..75e2e6dbd 100644 --- a/src.ts/wallet/index.ts +++ b/src.ts/wallet/index.ts @@ -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" diff --git a/src.ts/wallet/json-keystore.ts b/src.ts/wallet/json-keystore.ts index 38a1d9b67..e1861c613 100644 --- a/src.ts/wallet/json-keystore.ts +++ b/src.ts/wallet/json-keystore.ts @@ -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(data: any): KdfParams { +function getDecryptKdfParams(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 { @@ -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 { - - // 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 { + 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); +} + diff --git a/src.ts/wallet/wallet.ts b/src.ts/wallet/wallet.ts index a58b63976..fc4602118 100644 --- a/src.ts/wallet/wallet.ts +++ b/src.ts/wallet/wallet.ts @@ -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 { 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 { - 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 { - throw new Error("TODO"); - } - - static async fromEncryptedJson(json: string, password: Uint8Array | string, progress?: ProgressCallback): Promise { + static async fromEncryptedJson(json: string, password: Uint8Array | string, progress?: ProgressCallback): Promise { 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; } }