171 lines
6.3 KiB
JavaScript
171 lines
6.3 KiB
JavaScript
// Wallet operations: mnemonic generation, HD derivation, signing.
|
|
// All crypto delegated to ethers.js.
|
|
|
|
const { Mnemonic, HDNodeWallet, Wallet } = require("ethers");
|
|
const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants");
|
|
|
|
function generateMnemonic() {
|
|
// This must stay the compile-time DEBUG constant. Do NOT switch it to
|
|
// isDebug() from log.js: that also ORs in the runtime debugMode flag the
|
|
// settings toggle drives, which would let a user of a release build turn
|
|
// the hardcoded, publicly known test phrase back on for real wallets.
|
|
if (DEBUG) return DEBUG_MNEMONIC;
|
|
const m = Mnemonic.fromEntropy(
|
|
globalThis.crypto.getRandomValues(new Uint8Array(16)),
|
|
);
|
|
return m.phrase;
|
|
}
|
|
|
|
// Every extended key (xprv or xpub) entering the app goes through this.
|
|
//
|
|
// ethers' HDNodeWallet.fromExtendedKey does NOT verify the base58 checksum
|
|
// when the decoded payload is the usual 82 bytes, which is exactly the case
|
|
// the checksum exists to catch: a key with a one-character typo parses into a
|
|
// *different* wallet instead of being rejected. Re-encoding the parsed node
|
|
// reproduces a well-formed key byte for byte, checksum included, so comparing
|
|
// the round trip against the input rejects any altered character. Measured by
|
|
// the sweep in tests/wallet.test.js over every single-character substitution
|
|
// of the BIP-32 vector 1 master key: 199 parse without the round-trip
|
|
// comparison, 0 with it.
|
|
//
|
|
// Returns the parsed node, or null if the key is not a well-formed extended
|
|
// key. Callers turn null into a user-facing error; none of them may fall back
|
|
// to fromExtendedKey directly.
|
|
function parseExtendedKey(key) {
|
|
if (typeof key !== "string") return null;
|
|
try {
|
|
const node = HDNodeWallet.fromExtendedKey(key);
|
|
return node.extendedKey === key ? node : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// A master key is at depth 0. Only from there is BIP44_ETH_PATH the absolute
|
|
// path it names; deriving it under an account-level or child key yields
|
|
// addresses that correspond to nothing the user holds.
|
|
const MASTER_DEPTH = 0;
|
|
|
|
// Parse an extended private key that the BIP-44 Ethereum account path can be
|
|
// derived from, or throw. Both callers derive BIP44_ETH_PATH from the result.
|
|
function masterXprvOrThrow(key) {
|
|
const node = parseExtendedKey(key);
|
|
if (!node) {
|
|
throw new Error("Not a valid extended private key (xprv).");
|
|
}
|
|
if (!node.privateKey) {
|
|
throw new Error("Not an extended private key (xprv).");
|
|
}
|
|
if (node.depth !== MASTER_DEPTH) {
|
|
throw new Error(
|
|
"Not a master extended private key (xprv): an account-level or " +
|
|
"child key cannot be imported.",
|
|
);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function deriveAddressFromXpub(xpub, index) {
|
|
const node = parseExtendedKey(xpub);
|
|
if (!node) {
|
|
throw new Error("Not a valid extended key.");
|
|
}
|
|
return node.deriveChild(index).address;
|
|
}
|
|
|
|
function hdWalletFromMnemonic(mnemonic) {
|
|
const node = HDNodeWallet.fromPhrase(mnemonic, "", BIP44_ETH_PATH);
|
|
const xpub = node.neuter().extendedKey;
|
|
const firstAddress = node.deriveChild(0).address;
|
|
return { xpub, firstAddress };
|
|
}
|
|
|
|
function hdWalletFromXprv(xprv) {
|
|
// BIP44_ETH_PATH is absolute ("m/..."), which ethers will only derive from
|
|
// a depth-0 node. The relative form this used to derive would have been
|
|
// applied *beneath* an account-level key instead of being refused.
|
|
const node = masterXprvOrThrow(xprv).derivePath(BIP44_ETH_PATH);
|
|
const xpub = node.neuter().extendedKey;
|
|
const firstAddress = node.deriveChild(0).address;
|
|
return { xpub, firstAddress };
|
|
}
|
|
|
|
// Well-formed extended private key. Says nothing about depth: the import view
|
|
// reports a non-master key separately, since "check it for a typo" is the
|
|
// wrong advice for a key the user copied correctly.
|
|
function isValidXprv(key) {
|
|
const node = parseExtendedKey(key);
|
|
return !!(node && node.privateKey);
|
|
}
|
|
|
|
// Whether an extended key is a master key, i.e. the one BIP44_ETH_PATH can be
|
|
// derived from. False for anything parseExtendedKey rejects.
|
|
function isMasterExtendedKey(key) {
|
|
const node = parseExtendedKey(key);
|
|
return !!node && node.depth === MASTER_DEPTH;
|
|
}
|
|
|
|
function addressFromPrivateKey(key) {
|
|
const w = new Wallet(key);
|
|
return w.address;
|
|
}
|
|
|
|
function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
|
|
if (walletData.type === "hd") {
|
|
const node = HDNodeWallet.fromPhrase(
|
|
decryptedSecret,
|
|
"",
|
|
BIP44_ETH_PATH,
|
|
);
|
|
return node.deriveChild(addrIndex);
|
|
}
|
|
if (walletData.type === "xprv") {
|
|
// Checked here rather than through masterXprvOrThrow so the message
|
|
// fits the situation: nobody is importing anything at signing time,
|
|
// and this wallet is already in storage. src/shared/walletDefects.js
|
|
// catches it at list-render time; this is the backstop behind that.
|
|
const node = parseExtendedKey(decryptedSecret);
|
|
if (!node || !node.privateKey) {
|
|
throw new Error(
|
|
"This wallet's stored key is not a valid extended private " +
|
|
"key, so it cannot sign.",
|
|
);
|
|
}
|
|
if (node.depth !== MASTER_DEPTH) {
|
|
throw new Error(
|
|
"This wallet was imported from an extended private key that " +
|
|
"is not a master key, so it cannot sign.",
|
|
);
|
|
}
|
|
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
|
|
}
|
|
return new Wallet(decryptedSecret);
|
|
}
|
|
|
|
function isValidMnemonic(mnemonic) {
|
|
return Mnemonic.isValidMnemonic(mnemonic);
|
|
}
|
|
|
|
// Only an HD wallet has a recovery phrase. A "key" wallet holds a bare
|
|
// private key and an "xprv" wallet an extended private key; neither can be
|
|
// turned back into words, so neither may ever be offered the phrase display.
|
|
// Written as an allowlist on purpose: a wallet type added later is excluded
|
|
// until someone decides otherwise.
|
|
function walletHasRecoveryPhrase(walletData) {
|
|
return !!walletData && walletData.type === "hd";
|
|
}
|
|
|
|
module.exports = {
|
|
generateMnemonic,
|
|
parseExtendedKey,
|
|
deriveAddressFromXpub,
|
|
hdWalletFromMnemonic,
|
|
hdWalletFromXprv,
|
|
isValidXprv,
|
|
isMasterExtendedKey,
|
|
addressFromPrivateKey,
|
|
getSignerForAddress,
|
|
isValidMnemonic,
|
|
walletHasRecoveryPhrase,
|
|
};
|