UNPKG

@hashgraph/cryptography

Version:

Cryptographic utilities and primitives for the Hiero SDK

449 lines (388 loc) 13.8 kB
import CACHE from "./Cache.js"; import Ed25519PrivateKey from "./Ed25519PrivateKey.js"; import BadMnemonicError from "./BadMnemonicError.js"; import BadMnemonicReason from "./BadMnemonicReason.js"; import legacyWords from "./words/legacy.js"; import bip39Words from "./words/bip39.js"; import nacl from "tweetnacl"; import * as sha256 from "./primitive/sha256.js"; import * as hmac from "./primitive/hmac.js"; import * as slip10 from "./primitive/slip10.js"; import * as bip32 from "./primitive/bip32.js"; import * as bip39 from "./primitive/bip39.js"; import * as entropy from "./util/entropy.js"; import * as random from "./primitive/random.js"; import EcdsaPrivateKey from "./EcdsaPrivateKey.js"; import PrivateKey from "./PrivateKey.js"; import * as ecdsa from "./primitive/ecdsa.js"; const ED25519_SEED_TEXT = "ed25519 seed"; const ECDSA_SEED_TEXT = "Bitcoin seed"; export const HARDENED = 0x80000000; /// m/44'/3030'/0'/0' - All paths in EdDSA derivation are implicitly hardened. export const HEDERA_PATH = [44, 3030, 0, 0]; /// m/44'/3030'/0'/0 export const SLIP44_ECDSA_HEDERA_PATH = [ 44 | HARDENED, 3030 | HARDENED, 0 | HARDENED, 0, ]; /// m/44'/60'/0'/0 export const SLIP44_ECDSA_ETH_PATH = [ 44 | HARDENED, 60 | HARDENED, 0 | HARDENED, 0, 0, ]; /** * Multi-word mnemonic phrase (BIP-39). * * Compatible with the official Hedera mobile * wallets (24-words or 22-words) and BRD (12-words). */ export default class Mnemonic { /** * @param {object} props * @param {string[]} props.words * @throws {BadMnemonicError} * @hideconstructor * @private */ constructor({ words }) { this.words = words; } /** * Returns a new random 24-word mnemonic from the BIP-39 * standard English word list. * @returns {Promise<Mnemonic>} */ static generate() { return Mnemonic._generate(24); } /** * Returns a new random 12-word mnemonic from the BIP-39 * standard English word list. * @returns {Promise<Mnemonic>} */ static generate12() { return Mnemonic._generate(12); } /** * @param {number} length * @returns {Promise<Mnemonic>} */ static async _generate(length) { // only 12-word or 24-word lengths are supported let neededEntropy; if (length === 12) neededEntropy = 16; else if (length === 24) neededEntropy = 32; else { throw new Error( `unsupported phrase length ${length}, only 12 or 24 are supported`, ); } // inlined from (ISC) with heavy alternations for modern crypto // https://github.com/bitcoinjs/bip39/blob/8461e83677a1d2c685d0d5a9ba2a76bd228f74c6/ts_src/index.ts#L125 const seed = await random.bytesAsync(neededEntropy); const entropyBits = bytesToBinary(Array.from(seed)); const checksumBits = await deriveChecksumBits(seed); const bits = entropyBits + checksumBits; const chunks = bits.match(/(.{1,11})/g); const words = (chunks != null ? chunks : []).map( (binary) => bip39Words[binaryToByte(binary)], ); return new Mnemonic({ words }); } /** * Construct a mnemonic from a list of words. Handles 12, 22 (legacy), and 24 words. * * An exception of BadMnemonicError will be thrown if the mnemonic * contains unknown words or fails the checksum. An invalid mnemonic * can still be used to create private keys, the exception will * contain the failing mnemonic in case you wish to ignore the * validation error and continue. * @param {string[]} words * @throws {BadMnemonicError} * @returns {Promise<Mnemonic>} */ static fromWords(words) { return new Mnemonic({ words, })._validate(); } /** * @deprecated - Use `toStandardEd25519PrivateKey()` or `toStandardECDSAsecp256k1PrivateKey()` instead * Recover a private key from this mnemonic phrase, with an * optional passphrase. * @param {string} [passphrase] * @returns {Promise<PrivateKey>} */ toPrivateKey(passphrase = "") { // eslint-disable-next-line deprecation/deprecation return this.toEd25519PrivateKey(passphrase); } /** * @deprecated - Use `toStandardEd25519PrivateKey()` or `toStandardECDSAsecp256k1PrivateKey()` instead * Recover an Ed25519 private key from this mnemonic phrase, with an * optional passphrase. * @param {string} [passphrase] * @param {number[]} [path] * @returns {Promise<PrivateKey>} */ async toEd25519PrivateKey(passphrase = "", path = HEDERA_PATH) { let { keyData, chainCode } = await this._toKeyData( passphrase, ED25519_SEED_TEXT, ); for (const index of path) { ({ keyData, chainCode } = await slip10.derive( keyData, chainCode, index, )); } const keyPair = nacl.sign.keyPair.fromSeed(keyData); if (CACHE.privateKeyConstructor == null) { throw new Error("PrivateKey not found in cache"); } return CACHE.privateKeyConstructor( new Ed25519PrivateKey(keyPair, chainCode), ); } /** * Recover an Ed25519 private key from this mnemonic phrase, with an * optional passphrase. * @param {string} [passphrase] * @param {number} [index] * @returns {Promise<PrivateKey>} */ async toStandardEd25519PrivateKey(passphrase = "", index) { const seed = await Mnemonic.toSeed(this.words, passphrase); let derivedKey = await PrivateKey.fromSeedED25519(seed); index = index == null ? 0 : index; for (const currentIndex of [44, 3030, 0, 0, index]) { derivedKey = await derivedKey.derive(currentIndex); } return derivedKey; } /** * @deprecated - Use `toStandardEd25519PrivateKey()` or `toStandardECDSAsecp256k1PrivateKey()` instead * Recover an ECDSA private key from this mnemonic phrase, with an * optional passphrase. * @param {string} [passphrase] * @param {number[]} [path] * @returns {Promise<PrivateKey>} */ async toEcdsaPrivateKey(passphrase = "", path = HEDERA_PATH) { let { keyData, chainCode } = await this._toKeyData( passphrase, ECDSA_SEED_TEXT, ); for (const index of path) { ({ keyData, chainCode } = await bip32.derive( keyData, chainCode, index, )); } if (CACHE.privateKeyConstructor == null) { throw new Error("PrivateKey not found in cache"); } return CACHE.privateKeyConstructor( new EcdsaPrivateKey(ecdsa.fromBytes(keyData), chainCode), ); } /** * Recover an ECDSA private key from this mnemonic phrase, with an * optional passphrase. * @param {string} [passphrase] * @param {number} [index] * @returns {Promise<PrivateKey>} */ async toStandardECDSAsecp256k1PrivateKey(passphrase = "", index) { const seed = await Mnemonic.toSeed(this.words, passphrase); let derivedKey = await PrivateKey.fromSeedECDSAsecp256k1(seed); index = index == null ? 0 : index; for (const currentIndex of [ bip32.toHardenedIndex(44), bip32.toHardenedIndex(3030), bip32.toHardenedIndex(0), 0, index, ]) { derivedKey = await derivedKey.derive(currentIndex); } return derivedKey; } /** * @param {string[]} words * @param {string} passphrase * @returns {Promise<Uint8Array>} */ static async toSeed(words, passphrase) { return await bip39.toSeed(words, passphrase); } /** * @param {string} passphrase * @param {string} seedText * @returns {Promise<{ keyData: Uint8Array; chainCode: Uint8Array }>} seedText */ async _toKeyData(passphrase, seedText) { const seed = await bip39.toSeed(this.words, passphrase); const digest = await hmac.hash( hmac.HashAlgorithm.Sha512, seedText, seed, ); return { keyData: digest.subarray(0, 32), chainCode: digest.subarray(32), }; } /** * Recover a mnemonic phrase from a string, splitting on spaces. Handles 12, 22 (legacy), and 24 words. * @param {string} mnemonic * @returns {Promise<Mnemonic>} */ static async fromString(mnemonic) { return Mnemonic.fromWords(mnemonic.split(/\s|,/)); } /** * @returns {Promise<Mnemonic>} * @private */ async _validate() { //NOSONAR // Validate that this is a valid BIP-39 mnemonic // as generated by BIP-39's rules. // Technically, invalid mnemonics can still be used to generate valid private keys, // but if they became invalid due to user error then it will be difficult for the user // to tell the difference unless they compare the generated keys. // During validation, the following conditions are checked in order // 1)) 24 or 12 words // 2) All strings in {@link this.words} exist in the BIP-39 // standard English word list (no normalization is done) // 3) The calculated checksum for the mnemonic equals the // checksum encoded in the mnemonic // If words count is 22, it means that this is a legacy private key if (this.words.length === 22) { const unknownWordIndices = this.words.reduce( (/** @type {number[]} */ unknowns, word, index) => legacyWords.includes(word.toLowerCase()) ? unknowns : [...unknowns, index], [], ); if (unknownWordIndices.length > 0) { throw new BadMnemonicError( this, BadMnemonicReason.UnknownWords, unknownWordIndices, ); } const [seed, checksum] = entropy.legacy1(this.words, legacyWords); const newChecksum = entropy.crc8(seed); if (checksum !== newChecksum) { throw new BadMnemonicError( this, BadMnemonicReason.ChecksumMismatch, [], ); } } else { if (!(this.words.length === 12 || this.words.length === 24)) { throw new BadMnemonicError( this, BadMnemonicReason.BadLength, [], ); } const unknownWordIndices = this.words.reduce( (/** @type {number[]} */ unknowns, word, index) => bip39Words.includes(word) ? unknowns : [...unknowns, index], [], ); if (unknownWordIndices.length > 0) { throw new BadMnemonicError( this, BadMnemonicReason.UnknownWords, unknownWordIndices, ); } // FIXME: calculate checksum and compare // https://github.com/bitcoinjs/bip39/blob/master/ts_src/index.ts#L112 const bits = this.words .map((word) => { return bip39Words .indexOf(word) .toString(2) .padStart(11, "0"); }) .join(""); const dividerIndex = Math.floor(bits.length / 33) * 32; const entropyBits = bits.slice(0, dividerIndex); const checksumBits = bits.slice(dividerIndex); const entropyBitsRegex = entropyBits.match(/(.{1,8})/g); const entropyBytes = /** @type {RegExpMatchArray} */ ( entropyBitsRegex ).map(binaryToByte); const newChecksum = await deriveChecksumBits( Uint8Array.from(entropyBytes), ); if (newChecksum !== checksumBits) { throw new BadMnemonicError( this, BadMnemonicReason.ChecksumMismatch, [], ); } } return this; } /** * @returns {Promise<PrivateKey>} */ async toLegacyPrivateKey() { let seed; if (this.words.length === 22) { [seed] = entropy.legacy1(this.words, legacyWords); } else { seed = await entropy.legacy2(this.words, bip39Words); } if (CACHE.privateKeyFromBytes == null) { throw new Error("PrivateKey not found in cache"); } return CACHE.privateKeyFromBytes(seed); } /** * @returns {string} */ toString() { return this.words.join(" "); } } /** * @param {string} bin * @returns {number} */ function binaryToByte(bin) { return parseInt(bin, 2); } /** * @param {number[]} bytes * @returns {string} */ function bytesToBinary(bytes) { return bytes.map((x) => x.toString(2).padStart(8, "0")).join(""); } /** * @param {Uint8Array} entropyBuffer * @returns {Promise<string>} */ async function deriveChecksumBits(entropyBuffer) { const ENT = entropyBuffer.length * 8; const CS = ENT / 32; const hash = await sha256.digest(entropyBuffer); return bytesToBinary(Array.from(hash)).slice(0, CS); }