diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aba247c..78dd8c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to ### Added +- @cosmjs/proto-signing: Add `serialize` and `serializeWithEncryptionKey` + methods to `DirectSecp256k1HdWallet`. Also add `deserialize` and + `deserializeWithEncryptionKey` static methods. +- @cosmjs/proto-signing: Export `extractKdfConfiguration` and `executeKdf` + helper functions and `KdfConfiguration` type. - @cosmjs/proto-signing: Export `makeCosmoshubPath` helper. - @cosmjs/stargate: Export `makeCosmoshubPath` helper. diff --git a/packages/amino/src/secp256k1hdwallet.spec.ts b/packages/amino/src/secp256k1hdwallet.spec.ts index c55085d1..493e12bd 100644 --- a/packages/amino/src/secp256k1hdwallet.spec.ts +++ b/packages/amino/src/secp256k1hdwallet.spec.ts @@ -275,7 +275,7 @@ describe("Secp256k1HdWallet", () => { algorithm: "argon2id", params: { outputLength: 32, - opsLimit: 20, + opsLimit: 24, memLimitKib: 12 * 1024, }, }, diff --git a/packages/amino/src/secp256k1hdwallet.ts b/packages/amino/src/secp256k1hdwallet.ts index 2c224e31..07d4103b 100644 --- a/packages/amino/src/secp256k1hdwallet.ts +++ b/packages/amino/src/secp256k1hdwallet.ts @@ -36,13 +36,13 @@ const serializationTypeV1 = "secp256k1wallet-v1"; /** * A KDF configuration that is not very strong but can be used on the main thread. - * It takes about 1 second in Node.js 12.15 and should have similar runtimes in other modern Wasm hosts. + * It takes about 1 second in Node.js 16.0.0 and should have similar runtimes in other modern Wasm hosts. */ const basicPasswordHashingOptions: KdfConfiguration = { algorithm: "argon2id", params: { outputLength: 32, - opsLimit: 20, + opsLimit: 24, memLimitKib: 12 * 1024, }, }; diff --git a/packages/proto-signing/src/directsecp256k1hdwallet.spec.ts b/packages/proto-signing/src/directsecp256k1hdwallet.spec.ts index 537b9bc9..2800ec97 100644 --- a/packages/proto-signing/src/directsecp256k1hdwallet.spec.ts +++ b/packages/proto-signing/src/directsecp256k1hdwallet.spec.ts @@ -2,9 +2,10 @@ import { coins, makeCosmoshubPath } from "@cosmjs/amino"; import { Secp256k1, Secp256k1Signature, sha256 } from "@cosmjs/crypto"; import { fromBase64, fromHex } from "@cosmjs/encoding"; -import { DirectSecp256k1HdWallet } from "./directsecp256k1hdwallet"; +import { DirectSecp256k1HdWallet, extractKdfConfiguration } from "./directsecp256k1hdwallet"; import { makeAuthInfoBytes, makeSignBytes, makeSignDoc } from "./signing"; -import { faucet, testVectors } from "./testutils.spec"; +import { base64Matcher, faucet, testVectors } from "./testutils.spec"; +import { executeKdf, KdfConfiguration } from "./wallet"; describe("DirectSecp256k1HdWallet", () => { // m/44'/118'/0'/0/0 @@ -60,6 +61,175 @@ describe("DirectSecp256k1HdWallet", () => { }); }); + describe("deserialize", () => { + it("can restore", async () => { + const original = await DirectSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + const password = "123"; + const serialized = await original.serialize(password); + const deserialized = await DirectSecp256k1HdWallet.deserialize(serialized, password); + const accounts = await deserialized.getAccounts(); + + expect(deserialized.mnemonic).toEqual(defaultMnemonic); + expect(accounts).toEqual([ + { + algo: "secp256k1", + address: defaultAddress, + pubkey: defaultPubkey, + }, + ]); + }); + + it("can restore multiple accounts", async () => { + const mnemonic = + "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone"; + const prefix = "wasm"; + const accountNumbers = [0, 1, 2, 3, 4]; + const hdPaths = accountNumbers.map(makeCosmoshubPath); + const original = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { + hdPaths: hdPaths, + prefix: prefix, + }); + const password = "123"; + const serialized = await original.serialize(password); + const deserialized = await DirectSecp256k1HdWallet.deserialize(serialized, password); + const accounts = await deserialized.getAccounts(); + + expect(deserialized.mnemonic).toEqual(mnemonic); + // These values are taken from the generate_addresses.js script in the scripts/wasmd directory + expect(accounts).toEqual([ + { + algo: "secp256k1", + pubkey: fromBase64("A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ"), + address: "wasm1pkptre7fdkl6gfrzlesjjvhxhlc3r4gm32kke3", + }, + { + algo: "secp256k1", + pubkey: fromBase64("AiDosfIbBi54XJ1QjCeApumcy/FjdtF+YhywPf3DKTx7"), + address: "wasm10dyr9899g6t0pelew4nvf4j5c3jcgv0r5d3a5l", + }, + { + algo: "secp256k1", + pubkey: fromBase64("AzQg33JZqH7vSsm09esZY5bZvmzYwE/SY78cA0iLxpD7"), + address: "wasm1xy4yqngt0nlkdcenxymg8tenrghmek4n3u2lwa", + }, + { + algo: "secp256k1", + pubkey: fromBase64("A3gOAlB6aiRTCPvWMQg2+ZbGYNsLd8qlvV28m8p2UhY2"), + address: "wasm142u9fgcjdlycfcez3lw8x6x5h7rfjlnfaallkd", + }, + { + algo: "secp256k1", + pubkey: fromBase64("Aum2063ub/ErUnIUB36sK55LktGUStgcbSiaAnL1wadu"), + address: "wasm1hsm76p4ahyhl5yh3ve9ur49r5kemhp2r93f89d", + }, + ]); + }); + }); + + describe("deserializeWithEncryptionKey", () => { + it("can restore", async () => { + const password = "123"; + let serialized: string; + { + const original = await DirectSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + const anyKdfParams: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 4, + memLimitKib: 3 * 1024, + }, + }; + const encryptionKey = await executeKdf(password, anyKdfParams); + serialized = await original.serializeWithEncryptionKey(encryptionKey, anyKdfParams); + } + + { + const kdfConfiguration = extractKdfConfiguration(serialized); + const encryptionKey = await executeKdf(password, kdfConfiguration); + const deserialized = await DirectSecp256k1HdWallet.deserializeWithEncryptionKey( + serialized, + encryptionKey, + ); + expect(deserialized.mnemonic).toEqual(defaultMnemonic); + expect(await deserialized.getAccounts()).toEqual([ + { + algo: "secp256k1", + address: defaultAddress, + pubkey: defaultPubkey, + }, + ]); + } + }); + + it("can restore multiple accounts", async () => { + const mnemonic = + "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone"; + const prefix = "wasm"; + const password = "123"; + const accountNumbers = [0, 1, 2, 3, 4]; + const hdPaths = accountNumbers.map(makeCosmoshubPath); + let serialized: string; + { + const original = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { + prefix: prefix, + hdPaths: hdPaths, + }); + const anyKdfParams: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 4, + memLimitKib: 3 * 1024, + }, + }; + const encryptionKey = await executeKdf(password, anyKdfParams); + serialized = await original.serializeWithEncryptionKey(encryptionKey, anyKdfParams); + } + + { + const kdfConfiguration = extractKdfConfiguration(serialized); + const encryptionKey = await executeKdf(password, kdfConfiguration); + const deserialized = await DirectSecp256k1HdWallet.deserializeWithEncryptionKey( + serialized, + encryptionKey, + ); + const accounts = await deserialized.getAccounts(); + + expect(deserialized.mnemonic).toEqual(mnemonic); + expect(deserialized.mnemonic).toEqual(mnemonic); + // These values are taken from the generate_addresses.js script in the scripts/wasmd directory + expect(accounts).toEqual([ + { + algo: "secp256k1", + pubkey: fromBase64("A08EGB7ro1ORuFhjOnZcSgwYlpe0DSFjVNUIkNNQxwKQ"), + address: "wasm1pkptre7fdkl6gfrzlesjjvhxhlc3r4gm32kke3", + }, + { + algo: "secp256k1", + pubkey: fromBase64("AiDosfIbBi54XJ1QjCeApumcy/FjdtF+YhywPf3DKTx7"), + address: "wasm10dyr9899g6t0pelew4nvf4j5c3jcgv0r5d3a5l", + }, + { + algo: "secp256k1", + pubkey: fromBase64("AzQg33JZqH7vSsm09esZY5bZvmzYwE/SY78cA0iLxpD7"), + address: "wasm1xy4yqngt0nlkdcenxymg8tenrghmek4n3u2lwa", + }, + { + algo: "secp256k1", + pubkey: fromBase64("A3gOAlB6aiRTCPvWMQg2+ZbGYNsLd8qlvV28m8p2UhY2"), + address: "wasm142u9fgcjdlycfcez3lw8x6x5h7rfjlnfaallkd", + }, + { + algo: "secp256k1", + pubkey: fromBase64("Aum2063ub/ErUnIUB36sK55LktGUStgcbSiaAnL1wadu"), + address: "wasm1hsm76p4ahyhl5yh3ve9ur49r5kemhp2r93f89d", + }, + ]); + } + }); + }); + describe("getAccounts", () => { it("resolves to a list of accounts", async () => { const wallet = await DirectSecp256k1HdWallet.fromMnemonic(defaultMnemonic); @@ -108,4 +278,51 @@ describe("DirectSecp256k1HdWallet", () => { expect(valid).toEqual(true); }); }); + + describe("serialize", () => { + it("can save with password", async () => { + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + const serialized = await wallet.serialize("123"); + expect(JSON.parse(serialized)).toEqual({ + type: "directsecp256k1hdwallet-v1", + kdf: { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 24, + memLimitKib: 12 * 1024, + }, + }, + encryption: { + algorithm: "xchacha20poly1305-ietf", + }, + data: jasmine.stringMatching(base64Matcher), + }); + }); + }); + + describe("serializeWithEncryptionKey", () => { + it("can save with password", async () => { + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(defaultMnemonic); + + const key = fromHex("aabb221100aabb332211aabb33221100aabb221100aabb332211aabb33221100"); + const customKdfConfiguration: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 321, + memLimitKib: 11 * 1024, + }, + }; + const serialized = await wallet.serializeWithEncryptionKey(key, customKdfConfiguration); + expect(JSON.parse(serialized)).toEqual({ + type: "directsecp256k1hdwallet-v1", + kdf: customKdfConfiguration, + encryption: { + algorithm: "xchacha20poly1305-ietf", + }, + data: jasmine.stringMatching(base64Matcher), + }); + }); + }); }); diff --git a/packages/proto-signing/src/directsecp256k1hdwallet.ts b/packages/proto-signing/src/directsecp256k1hdwallet.ts index b1e23a82..d2dac3cf 100644 --- a/packages/proto-signing/src/directsecp256k1hdwallet.ts +++ b/packages/proto-signing/src/directsecp256k1hdwallet.ts @@ -3,23 +3,64 @@ import { Bip39, EnglishMnemonic, HdPath, + pathToString, Random, Secp256k1, Secp256k1Keypair, sha256, Slip10, Slip10Curve, + stringToPath, } from "@cosmjs/crypto"; -import { Bech32 } from "@cosmjs/encoding"; +import { Bech32, fromBase64, fromUtf8, toBase64, toUtf8 } from "@cosmjs/encoding"; +import { assert, isNonNullObject } from "@cosmjs/utils/build"; import { SignDoc } from "./codec/cosmos/tx/v1beta1/tx"; import { AccountData, DirectSignResponse, OfflineDirectSigner } from "./signer"; import { makeSignBytes } from "./signing"; +import { + decrypt, + encrypt, + EncryptionConfiguration, + executeKdf, + KdfConfiguration, + supportedAlgorithms, +} from "./wallet"; interface AccountDataWithPrivkey extends AccountData { readonly privkey: Uint8Array; } +const serializationTypeV1 = "directsecp256k1hdwallet-v1"; + +/** + * A KDF configuration that is not very strong but can be used on the main thread. + * It takes about 1 second in Node.js 16.0.0 and should have similar runtimes in other modern Wasm hosts. + */ +const basicPasswordHashingOptions: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 24, + memLimitKib: 12 * 1024, + }, +}; + +/** + * This interface describes a JSON object holding the encrypted wallet and the meta data. + * All fields in here must be JSON types. + */ +export interface DirectSecp256k1HdWalletSerialization { + /** A format+version identifier for this serialization format */ + readonly type: string; + /** Information about the key derivation function (i.e. password to encryption key) */ + readonly kdf: KdfConfiguration; + /** Information about the symmetric encryption */ + readonly encryption: EncryptionConfiguration; + /** An instance of Secp256k1HdWalletData, which is stringified, encrypted and base64 encoded. */ + readonly data: string; +} + /** * Derivation information required to derive a keypair and an address from a mnemonic. */ @@ -28,6 +69,47 @@ interface Secp256k1Derivation { readonly prefix: string; } +/** + * Derivation information required to derive a keypair and an address from a mnemonic. + * All fields in here must be JSON types. + */ +interface DerivationInfoJson { + readonly hdPath: string; + readonly prefix: string; +} + +function isDerivationJson(thing: unknown): thing is DerivationInfoJson { + if (!isNonNullObject(thing)) return false; + if (typeof (thing as DerivationInfoJson).hdPath !== "string") return false; + if (typeof (thing as DerivationInfoJson).prefix !== "string") return false; + return true; +} + +/** + * The data of a wallet serialization that is encrypted. + * All fields in here must be JSON types. + */ +interface DirectSecp256k1HdWalletData { + readonly mnemonic: string; + readonly accounts: readonly DerivationInfoJson[]; +} + +function extractKdfConfigurationV1(doc: any): KdfConfiguration { + return doc.kdf; +} + +export function extractKdfConfiguration(serialization: string): KdfConfiguration { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + + switch ((root as any).type) { + case serializationTypeV1: + return extractKdfConfigurationV1(root); + default: + throw new Error("Unsupported serialization type"); + } +} + export interface DirectSecp256k1HdWalletOptions { /** The password to use when deriving a BIP39 seed from a mnemonic. */ readonly bip39Password: string; @@ -83,6 +165,77 @@ export class DirectSecp256k1HdWallet implements OfflineDirectSigner { return DirectSecp256k1HdWallet.fromMnemonic(mnemonic.toString(), options); } + /** + * Restores a wallet from an encrypted serialization. + * + * @param password The user provided password used to generate an encryption key via a KDF. + * This is not normalized internally (see "Unicode normalization" to learn more). + */ + public static async deserialize(serialization: string, password: string): Promise { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + switch ((root as any).type) { + case serializationTypeV1: + return DirectSecp256k1HdWallet.deserializeTypeV1(serialization, password); + default: + throw new Error("Unsupported serialization type"); + } + } + /** + * Restores a wallet from an encrypted serialization. + * + * This is an advanced alternative to calling `deserialize(serialization, password)` directly, which allows + * you to offload the KDF execution to a non-UI thread (e.g. in a WebWorker). + * + * The caller is responsible for ensuring the key was derived with the given KDF configuration. This can be + * done using `extractKdfConfiguration(serialization)` and `executeKdf(password, kdfConfiguration)` from this package. + */ + public static async deserializeWithEncryptionKey( + serialization: string, + encryptionKey: Uint8Array, + ): Promise { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + const untypedRoot: any = root; + switch (untypedRoot.type) { + case serializationTypeV1: { + const decryptedBytes = await decrypt( + fromBase64(untypedRoot.data), + encryptionKey, + untypedRoot.encryption, + ); + const decryptedDocument = JSON.parse(fromUtf8(decryptedBytes)); + const { mnemonic, accounts } = decryptedDocument; + assert(typeof mnemonic === "string"); + if (!Array.isArray(accounts)) throw new Error("Property 'accounts' is not an array"); + if (!accounts.every((account) => isDerivationJson(account))) { + throw new Error("Account is not in the correct format."); + } + const firstPrefix = accounts[0].prefix; + if (!accounts.every(({ prefix }) => prefix === firstPrefix)) { + throw new Error("Accounts do not all have the same prefix"); + } + const hdPaths = accounts.map(({ hdPath }) => stringToPath(hdPath)); + return DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { + hdPaths: hdPaths, + prefix: firstPrefix, + }); + } + default: + throw new Error("Unsupported serialization type"); + } + } + + private static async deserializeTypeV1( + serialization: string, + password: string, + ): Promise { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + const encryptionKey = await executeKdf(password, (root as any).kdf); + return DirectSecp256k1HdWallet.deserializeWithEncryptionKey(serialization, encryptionKey); + } + /** Base secret */ private readonly secret: EnglishMnemonic; /** BIP39 seed */ @@ -132,6 +285,54 @@ export class DirectSecp256k1HdWallet implements OfflineDirectSigner { }; } + /** + * Generates an encrypted serialization of this wallet. + * + * @param password The user provided password used to generate an encryption key via a KDF. + * This is not normalized internally (see "Unicode normalization" to learn more). + */ + public async serialize(password: string): Promise { + const kdfConfiguration = basicPasswordHashingOptions; + const encryptionKey = await executeKdf(password, kdfConfiguration); + return this.serializeWithEncryptionKey(encryptionKey, kdfConfiguration); + } + + /** + * Generates an encrypted serialization of this wallet. + * + * This is an advanced alternative to calling `serialize(password)` directly, which allows you to + * offload the KDF execution to a non-UI thread (e.g. in a WebWorker). + * + * The caller is responsible for ensuring the key was derived with the given KDF options. If this + * is not the case, the wallet cannot be restored with the original password. + */ + public async serializeWithEncryptionKey( + encryptionKey: Uint8Array, + kdfConfiguration: KdfConfiguration, + ): Promise { + const dataToEncrypt: DirectSecp256k1HdWalletData = { + mnemonic: this.mnemonic, + accounts: this.accounts.map(({ hdPath, prefix }) => ({ + hdPath: pathToString(hdPath), + prefix: prefix, + })), + }; + const dataToEncryptRaw = toUtf8(JSON.stringify(dataToEncrypt)); + + const encryptionConfiguration: EncryptionConfiguration = { + algorithm: supportedAlgorithms.xchacha20poly1305Ietf, + }; + const encryptedData = await encrypt(dataToEncryptRaw, encryptionKey, encryptionConfiguration); + + const out: DirectSecp256k1HdWalletSerialization = { + type: serializationTypeV1, + kdf: kdfConfiguration, + encryption: encryptionConfiguration, + data: toBase64(encryptedData), + }; + return JSON.stringify(out); + } + private async getKeyPair(hdPath: HdPath): Promise { const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, this.seed, hdPath); const { pubkey } = await Secp256k1.makeKeypair(privkey); diff --git a/packages/proto-signing/src/index.ts b/packages/proto-signing/src/index.ts index 3136762c..fe7ebe9f 100644 --- a/packages/proto-signing/src/index.ts +++ b/packages/proto-signing/src/index.ts @@ -14,7 +14,11 @@ export { TsProtoGeneratedType, TxBodyEncodeObject, } from "./registry"; -export { DirectSecp256k1HdWallet, DirectSecp256k1HdWalletOptions } from "./directsecp256k1hdwallet"; +export { + extractKdfConfiguration, + DirectSecp256k1HdWallet, + DirectSecp256k1HdWalletOptions, +} from "./directsecp256k1hdwallet"; export { DirectSecp256k1Wallet } from "./directsecp256k1wallet"; export { makeCosmoshubPath } from "./paths"; export { decodePubkey, encodePubkey } from "./pubkey"; @@ -27,3 +31,4 @@ export { OfflineSigner, } from "./signer"; export { makeAuthInfoBytes, makeSignBytes, makeSignDoc } from "./signing"; +export { executeKdf, KdfConfiguration } from "./wallet"; diff --git a/packages/proto-signing/src/wallet.ts b/packages/proto-signing/src/wallet.ts new file mode 100644 index 00000000..dc85068a --- /dev/null +++ b/packages/proto-signing/src/wallet.ts @@ -0,0 +1,87 @@ +import { + Argon2id, + isArgon2idOptions, + Random, + xchacha20NonceLength, + Xchacha20poly1305Ietf, +} from "@cosmjs/crypto"; +import { toAscii } from "@cosmjs/encoding"; + +/** + * A fixed salt is chosen to archive a deterministic password to key derivation. + * This reduces the scope of a potential rainbow attack to all CosmJS users. + * Must be 16 bytes due to implementation limitations. + */ +export const cosmjsSalt = toAscii("The CosmJS salt."); + +export interface KdfConfiguration { + /** + * An algorithm identifier, such as "argon2id" or "scrypt". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params: Record; +} + +export async function executeKdf(password: string, configuration: KdfConfiguration): Promise { + switch (configuration.algorithm) { + case "argon2id": { + const options = configuration.params; + if (!isArgon2idOptions(options)) throw new Error("Invalid format of argon2id params"); + return Argon2id.execute(password, cosmjsSalt, options); + } + default: + throw new Error("Unsupported KDF algorithm"); + } +} + +/** + * Configuration how to encrypt data or how data was encrypted. + * This is stored as part of the wallet serialization and must only contain JSON types. + */ +export interface EncryptionConfiguration { + /** + * An algorithm identifier, such as "xchacha20poly1305-ietf". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params?: Record; +} + +export const supportedAlgorithms = { + xchacha20poly1305Ietf: "xchacha20poly1305-ietf", +}; + +export async function encrypt( + plaintext: Uint8Array, + encryptionKey: Uint8Array, + config: EncryptionConfiguration, +): Promise { + switch (config.algorithm) { + case supportedAlgorithms.xchacha20poly1305Ietf: { + const nonce = Random.getBytes(xchacha20NonceLength); + // Prepend fixed-length nonce to ciphertext as suggested in the example from https://github.com/jedisct1/libsodium.js#api + return new Uint8Array([ + ...nonce, + ...(await Xchacha20poly1305Ietf.encrypt(plaintext, encryptionKey, nonce)), + ]); + } + default: + throw new Error(`Unsupported encryption algorithm: '${config.algorithm}'`); + } +} + +export async function decrypt( + ciphertext: Uint8Array, + encryptionKey: Uint8Array, + config: EncryptionConfiguration, +): Promise { + switch (config.algorithm) { + case supportedAlgorithms.xchacha20poly1305Ietf: { + const nonce = ciphertext.slice(0, xchacha20NonceLength); + return Xchacha20poly1305Ietf.decrypt(ciphertext.slice(xchacha20NonceLength), encryptionKey, nonce); + } + default: + throw new Error(`Unsupported encryption algorithm: '${config.algorithm}'`); + } +}