diff --git a/.circleci/config.yml b/.circleci/config.yml index 367190d3..df705c05 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,6 +2,7 @@ version: 2 workflows: version: 2 workflow: + # Keep those job names in sync with .mergify.yml jobs: - build - docs-build diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 00000000..b39accc3 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,19 @@ +# See https://doc.mergify.io/configuration.html + +pull_request_rules: + - name: automerge to master with label automerge + conditions: + - "#approved-reviews-by>1" + - base=master + - label=automerge + # We need to list them all individually. Here is why: https://doc.mergify.io/conditions.html#validating-all-status-check + - "status-success=ci/circleci: build" + - "status-success=ci/circleci: coverage" + - "status-success=ci/circleci: docs-build" + - "status-success=ci/circleci: lint" + - "status-success=ci/circleci: test" + - "status-success=ci/circleci: test-chrome" + actions: + merge: + method: merge + strict: false diff --git a/CHANGELOG.md b/CHANGELOG.md index f5305260..92c48ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,3 +29,8 @@ abstraction between `SigningCosmosClient.sendTokens` and `.postTx`. - @cosmjs/sdk38: Export `PostTxFailure`/`PostTxSuccess` and type checkers `isPostTxFailure`/`isPostTxSuccess`; export `assertIsPostTxSuccess`. +- @cosmjs/sdk38: `Secp256k1Wallet`s can now be generated randomly with + `Secp256k1Wallet.generate(n)` where `n` is 12, 15, 18, 21 or 24 mnemonic + words. +- @cosmjs/sdk38: The new `Secp256k1Wallet.serialize` and `.deserialize` allow + encrypted serialization of the wallet. diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts index 9365daa2..925865b3 100644 --- a/packages/crypto/src/index.ts +++ b/packages/crypto/src/index.ts @@ -8,6 +8,7 @@ export { xchacha20NonceLength, Argon2id, Argon2idOptions, + isArgon2idOptions, Ed25519, Ed25519Keypair, } from "./libsodium"; diff --git a/packages/crypto/src/libsodium.ts b/packages/crypto/src/libsodium.ts index 5f425fba..0776a0c6 100644 --- a/packages/crypto/src/libsodium.ts +++ b/packages/crypto/src/libsodium.ts @@ -3,18 +3,36 @@ // // libsodium.js API: https://gist.github.com/webmaster128/b2dbe6d54d36dd168c9fabf441b9b09c +import { isNonNullObject } from "@cosmjs/utils"; import sodium from "libsodium-wrappers"; export interface Argon2idOptions { - // in bytes + /** Output length in bytes */ readonly outputLength: number; - // integer between 1 and 4294967295 + /** + * An integer between 1 and 4294967295 representing the computational difficulty. + * + * @see https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation + */ readonly opsLimit: number; - // memory limit measured in KiB (like argon2 command line tool) - // Note: only ~ 16 MiB of memory are available using the non-sumo version of libsodium + /** + * Memory limit measured in KiB (like argon2 command line tool) + * + * Note: only approximately 16 MiB of memory are available using the non-sumo version of libsodium.js + * + * @see https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation + */ readonly memLimitKib: number; } +export function isArgon2idOptions(thing: unknown): thing is Argon2idOptions { + if (!isNonNullObject(thing)) return false; + if (typeof (thing as Argon2idOptions).outputLength !== "number") return false; + if (typeof (thing as Argon2idOptions).opsLimit !== "number") return false; + if (typeof (thing as Argon2idOptions).memLimitKib !== "number") return false; + return true; +} + export class Argon2id { public static async execute( password: string, diff --git a/packages/crypto/types/index.d.ts b/packages/crypto/types/index.d.ts index 9365daa2..925865b3 100644 --- a/packages/crypto/types/index.d.ts +++ b/packages/crypto/types/index.d.ts @@ -8,6 +8,7 @@ export { xchacha20NonceLength, Argon2id, Argon2idOptions, + isArgon2idOptions, Ed25519, Ed25519Keypair, } from "./libsodium"; diff --git a/packages/crypto/types/libsodium.d.ts b/packages/crypto/types/libsodium.d.ts index 6ecd5ce7..fbddb7f3 100644 --- a/packages/crypto/types/libsodium.d.ts +++ b/packages/crypto/types/libsodium.d.ts @@ -1,8 +1,22 @@ export interface Argon2idOptions { + /** Output length in bytes */ readonly outputLength: number; + /** + * An integer between 1 and 4294967295 representing the computational difficulty. + * + * @see https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation + */ readonly opsLimit: number; + /** + * Memory limit measured in KiB (like argon2 command line tool) + * + * Note: only approximately 16 MiB of memory are available using the non-sumo version of libsodium.js + * + * @see https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation + */ readonly memLimitKib: number; } +export declare function isArgon2idOptions(thing: unknown): thing is Argon2idOptions; export declare class Argon2id { static execute(password: string, salt: Uint8Array, options: Argon2idOptions): Promise; } diff --git a/packages/sdk38/src/cosmosclient.searchtx.spec.ts b/packages/sdk38/src/cosmosclient.searchtx.spec.ts index 342db6e7..e86b8534 100644 --- a/packages/sdk38/src/cosmosclient.searchtx.spec.ts +++ b/packages/sdk38/src/cosmosclient.searchtx.spec.ts @@ -6,6 +6,7 @@ import { CosmosClient, isPostTxFailure } from "./cosmosclient"; import { makeSignBytes } from "./encoding"; import { LcdClient } from "./lcdapi"; import { isMsgSend, MsgSend } from "./msgs"; +import { Secp256k1Wallet } from "./secp256k1wallet"; import { SigningCosmosClient } from "./signingcosmosclient"; import { faucet, @@ -16,7 +17,6 @@ import { wasmdEnabled, } from "./testutils.spec"; import { CosmosSdkTx } from "./types"; -import { Secp256k1Wallet } from "./wallet"; interface TestTxSend { readonly sender: string; diff --git a/packages/sdk38/src/cosmosclient.spec.ts b/packages/sdk38/src/cosmosclient.spec.ts index 6087e967..34124d71 100644 --- a/packages/sdk38/src/cosmosclient.spec.ts +++ b/packages/sdk38/src/cosmosclient.spec.ts @@ -6,6 +6,7 @@ import { assertIsPostTxSuccess, CosmosClient, PrivateCosmWasmClient } from "./co import { makeSignBytes } from "./encoding"; import { findAttribute } from "./logs"; import { MsgSend } from "./msgs"; +import { Secp256k1Wallet } from "./secp256k1wallet"; import cosmoshub from "./testdata/cosmoshub.json"; import { faucet, @@ -16,7 +17,6 @@ import { wasmd, } from "./testutils.spec"; import { StdFee } from "./types"; -import { Secp256k1Wallet } from "./wallet"; const blockTime = 1_000; // ms diff --git a/packages/sdk38/src/index.ts b/packages/sdk38/src/index.ts index 05adf9ce..c5b7b3e2 100644 --- a/packages/sdk38/src/index.ts +++ b/packages/sdk38/src/index.ts @@ -89,4 +89,5 @@ export { findSequenceForSignedTx } from "./sequence"; export { encodeSecp256k1Signature, decodeSignature } from "./signature"; export { FeeTable, SigningCosmosClient } from "./signingcosmosclient"; export { isStdTx, pubkeyType, CosmosSdkTx, PubKey, StdFee, StdSignature, StdTx } from "./types"; -export { OfflineSigner, Secp256k1Wallet, makeCosmoshubPath } from "./wallet"; +export { OfflineSigner, makeCosmoshubPath, executeKdf, KdfConfiguration } from "./wallet"; +export { extractKdfConfiguration, Secp256k1Wallet } from "./secp256k1wallet"; diff --git a/packages/sdk38/src/lcdapi/distribution.spec.ts b/packages/sdk38/src/lcdapi/distribution.spec.ts index af63253f..4d2dd774 100644 --- a/packages/sdk38/src/lcdapi/distribution.spec.ts +++ b/packages/sdk38/src/lcdapi/distribution.spec.ts @@ -6,6 +6,7 @@ import { coin, coins } from "../coins"; import { assertIsPostTxSuccess } from "../cosmosclient"; import { makeSignBytes } from "../encoding"; import { MsgDelegate } from "../msgs"; +import { Secp256k1Wallet } from "../secp256k1wallet"; import { SigningCosmosClient } from "../signingcosmosclient"; import { bigDecimalMatcher, @@ -16,7 +17,6 @@ import { wasmd, wasmdEnabled, } from "../testutils.spec"; -import { Secp256k1Wallet } from "../wallet"; import { DistributionExtension, setupDistributionExtension } from "./distribution"; import { LcdClient } from "./lcdclient"; diff --git a/packages/sdk38/src/lcdapi/gov.spec.ts b/packages/sdk38/src/lcdapi/gov.spec.ts index 3cb35162..17dc66d1 100644 --- a/packages/sdk38/src/lcdapi/gov.spec.ts +++ b/packages/sdk38/src/lcdapi/gov.spec.ts @@ -4,6 +4,7 @@ import { sleep } from "@cosmjs/utils"; import { coins } from "../coins"; import { assertIsPostTxSuccess } from "../cosmosclient"; import { makeSignBytes } from "../encoding"; +import { Secp256k1Wallet } from "../secp256k1wallet"; import { SigningCosmosClient } from "../signingcosmosclient"; import { dateTimeStampMatcher, @@ -13,7 +14,6 @@ import { wasmd, wasmdEnabled, } from "../testutils.spec"; -import { Secp256k1Wallet } from "../wallet"; import { GovExtension, GovParametersType, setupGovExtension } from "./gov"; import { LcdClient } from "./lcdclient"; diff --git a/packages/sdk38/src/lcdapi/lcdclient.spec.ts b/packages/sdk38/src/lcdapi/lcdclient.spec.ts index 2bc4f263..074e842d 100644 --- a/packages/sdk38/src/lcdapi/lcdclient.spec.ts +++ b/packages/sdk38/src/lcdapi/lcdclient.spec.ts @@ -6,6 +6,7 @@ import { isPostTxFailure } from "../cosmosclient"; import { makeSignBytes } from "../encoding"; import { parseLogs } from "../logs"; import { MsgSend } from "../msgs"; +import { Secp256k1Wallet } from "../secp256k1wallet"; import { SigningCosmosClient } from "../signingcosmosclient"; import cosmoshub from "../testdata/cosmoshub.json"; import { @@ -19,7 +20,7 @@ import { wasmdEnabled, } from "../testutils.spec"; import { StdFee } from "../types"; -import { makeCosmoshubPath, Secp256k1Wallet } from "../wallet"; +import { makeCosmoshubPath } from "../wallet"; import { setupAuthExtension } from "./auth"; import { TxsResponse } from "./base"; import { LcdApiArray, LcdClient, normalizeLcdApiArray } from "./lcdclient"; diff --git a/packages/sdk38/src/lcdapi/staking.spec.ts b/packages/sdk38/src/lcdapi/staking.spec.ts index a3c28ace..5575ea6a 100644 --- a/packages/sdk38/src/lcdapi/staking.spec.ts +++ b/packages/sdk38/src/lcdapi/staking.spec.ts @@ -5,6 +5,7 @@ import { coin, coins } from "../coins"; import { assertIsPostTxSuccess } from "../cosmosclient"; import { makeSignBytes } from "../encoding"; import { MsgDelegate, MsgUndelegate } from "../msgs"; +import { Secp256k1Wallet } from "../secp256k1wallet"; import { SigningCosmosClient } from "../signingcosmosclient"; import { bigDecimalMatcher, @@ -16,7 +17,6 @@ import { wasmd, wasmdEnabled, } from "../testutils.spec"; -import { Secp256k1Wallet } from "../wallet"; import { LcdClient } from "./lcdclient"; import { BondStatus, setupStakingExtension, StakingExtension } from "./staking"; diff --git a/packages/sdk38/src/secp256k1wallet.spec.ts b/packages/sdk38/src/secp256k1wallet.spec.ts new file mode 100644 index 00000000..e509e2e0 --- /dev/null +++ b/packages/sdk38/src/secp256k1wallet.spec.ts @@ -0,0 +1,170 @@ +import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; +import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding"; + +import { extractKdfConfiguration, Secp256k1Wallet } from "./secp256k1wallet"; +import { base64Matcher } from "./testutils.spec"; +import { executeKdf, KdfConfiguration } from "./wallet"; + +describe("Secp256k1Wallet", () => { + // m/44'/118'/0'/0/0 + // pubkey: 02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6 + const defaultMnemonic = "special sign fit simple patrol salute grocery chicken wheat radar tonight ceiling"; + const defaultPubkey = fromHex("02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6"); + const defaultAddress = "cosmos1jhg0e7s6gn44tfc5k37kr04sznyhedtc9rzys5"; + + describe("fromMnemonic", () => { + it("works", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); + expect(wallet).toBeTruthy(); + expect(wallet.mnemonic).toEqual(defaultMnemonic); + }); + }); + + describe("generate", () => { + it("defaults to 12 words", async () => { + const wallet = await Secp256k1Wallet.generate(); + expect(wallet.mnemonic.split(" ").length).toEqual(12); + }); + + it("can use different mnemonic lengths", async () => { + expect((await Secp256k1Wallet.generate(12)).mnemonic.split(" ").length).toEqual(12); + expect((await Secp256k1Wallet.generate(15)).mnemonic.split(" ").length).toEqual(15); + expect((await Secp256k1Wallet.generate(18)).mnemonic.split(" ").length).toEqual(18); + expect((await Secp256k1Wallet.generate(21)).mnemonic.split(" ").length).toEqual(21); + expect((await Secp256k1Wallet.generate(24)).mnemonic.split(" ").length).toEqual(24); + }); + }); + + describe("deserialize", () => { + it("can restore", async () => { + const original = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); + const password = "123"; + const serialized = await original.serialize(password); + const deserialized = await Secp256k1Wallet.deserialize(serialized, password); + expect(deserialized.mnemonic).toEqual(defaultMnemonic); + expect(await deserialized.getAccounts()).toEqual([ + { + algo: "secp256k1", + address: defaultAddress, + pubkey: defaultPubkey, + }, + ]); + }); + }); + + describe("deserializeWithEncryptionKey", () => { + it("can restore", async () => { + const password = "123"; + let serialized: string; + { + const original = await Secp256k1Wallet.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 Secp256k1Wallet.deserializeWithEncryptionKey(serialized, encryptionKey); + expect(deserialized.mnemonic).toEqual(defaultMnemonic); + expect(await deserialized.getAccounts()).toEqual([ + { + algo: "secp256k1", + address: defaultAddress, + pubkey: defaultPubkey, + }, + ]); + } + }); + }); + + describe("getAccounts", () => { + it("resolves to a list of accounts if enabled", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); + const accounts = await wallet.getAccounts(); + expect(accounts.length).toEqual(1); + expect(accounts[0]).toEqual({ + address: defaultAddress, + algo: "secp256k1", + pubkey: defaultPubkey, + }); + }); + + it("creates the same address as Go implementation", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic( + "oyster design unusual machine spread century engine gravity focus cave carry slot", + ); + const [{ address }] = await wallet.getAccounts(); + expect(address).toEqual("cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u"); + }); + }); + + describe("sign", () => { + it("resolves to valid signature if enabled", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); + const message = toAscii("foo bar"); + const signature = await wallet.sign(defaultAddress, message); + const valid = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)), + new Sha256(message).digest(), + defaultPubkey, + ); + expect(valid).toEqual(true); + }); + }); + + describe("serialize", () => { + it("can save with password", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); + const serialized = await wallet.serialize("123"); + expect(JSON.parse(serialized)).toEqual({ + type: "secp256k1wallet-v1", + kdf: { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 20, + memLimitKib: 12 * 1024, + }, + }, + encryption: { + algorithm: "xchacha20poly1305-ietf", + }, + data: jasmine.stringMatching(base64Matcher), + }); + }); + }); + + describe("serializeWithEncryptionKey", () => { + it("can save with password", async () => { + const wallet = await Secp256k1Wallet.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: "secp256k1wallet-v1", + kdf: customKdfConfiguration, + encryption: { + algorithm: "xchacha20poly1305-ietf", + }, + data: jasmine.stringMatching(base64Matcher), + }); + }); + }); +}); diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts new file mode 100644 index 00000000..6c393cfa --- /dev/null +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -0,0 +1,323 @@ +import { + Bip39, + EnglishMnemonic, + pathToString, + Random, + Secp256k1, + Slip10, + Slip10Curve, + Slip10RawIndex, + stringToPath, +} from "@cosmjs/crypto"; +import { fromBase64, fromUtf8, toBase64, toUtf8 } from "@cosmjs/encoding"; +import { assert, isNonNullObject } from "@cosmjs/utils"; + +import { rawSecp256k1PubkeyToAddress } from "./address"; +import { encodeSecp256k1Signature } from "./signature"; +import { StdSignature } from "./types"; +import { + AccountData, + decrypt, + encrypt, + EncryptionConfiguration, + executeKdf, + KdfConfiguration, + makeCosmoshubPath, + OfflineSigner, + prehash, + PrehashType, + supportedAlgorithms, +} from "./wallet"; + +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. + */ +const basicPasswordHashingOptions: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 20, + 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 Secp256k1WalletSerialization { + /** 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 Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ + readonly data: string; +} + +/** + * Derivation information required to derive a keypair and an address from a mnemonic. + * All fields in here must be JSON types. + */ +interface Secp256k1DerivationJson { + readonly hdPath: string; + readonly prefix: string; +} + +function isSecp256k1DerivationJson(thing: unknown): thing is Secp256k1DerivationJson { + if (!isNonNullObject(thing)) return false; + if (typeof (thing as Secp256k1DerivationJson).hdPath !== "string") return false; + if (typeof (thing as Secp256k1DerivationJson).prefix !== "string") return false; + return true; +} + +/** + * The data of a wallet serialization that is encrypted. + * All fields in here must be JSON types. + */ +export interface Secp256k1WalletData { + readonly mnemonic: string; + readonly accounts: readonly Secp256k1DerivationJson[]; +} + +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"); + } +} + +/** + * Derivation information required to derive a keypair and an address from a mnemonic. + */ +interface Secp256k1Derivation { + readonly hdPath: readonly Slip10RawIndex[]; + readonly prefix: string; +} + +export class Secp256k1Wallet implements OfflineSigner { + /** + * Restores a wallet from the given BIP39 mnemonic. + * + * @param mnemonic Any valid English mnemonic. + * @param hdPath The BIP-32/SLIP-10 derivation path. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`. + * @param prefix The bech32 address prefix (human readable part). Defaults to "cosmos". + */ + public static async fromMnemonic( + mnemonic: string, + hdPath: readonly Slip10RawIndex[] = makeCosmoshubPath(0), + prefix = "cosmos", + ): Promise { + const mnemonicChecked = new EnglishMnemonic(mnemonic); + const seed = await Bip39.mnemonicToSeed(mnemonicChecked); + const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, hdPath); + const uncompressed = (await Secp256k1.makeKeypair(privkey)).pubkey; + return new Secp256k1Wallet( + mnemonicChecked, + hdPath, + privkey, + Secp256k1.compressPubkey(uncompressed), + prefix, + ); + } + + /** + * Generates a new wallet with a BIP39 mnemonic of the given length. + * + * @param length The number of words in the mnemonic (12, 15, 18, 21 or 24). + * @param hdPath The BIP-32/SLIP-10 derivation path. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`. + * @param prefix The bech32 address prefix (human readable part). Defaults to "cosmos". + */ + public static async generate( + length: 12 | 15 | 18 | 21 | 24 = 12, + hdPath: readonly Slip10RawIndex[] = makeCosmoshubPath(0), + prefix = "cosmos", + ): Promise { + const entropyLength = 4 * Math.floor((11 * length) / 33); + const entropy = Random.getBytes(entropyLength); + const mnemonic = Bip39.encode(entropy); + return Secp256k1Wallet.fromMnemonic(mnemonic.toString(), hdPath, prefix); + } + + /** + * 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 Secp256k1Wallet.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.length !== 1) throw new Error("Property 'accounts' only supports one entry"); + const account = accounts[0]; + if (!isSecp256k1DerivationJson(account)) throw new Error("Account is not in the correct format."); + return Secp256k1Wallet.fromMnemonic(mnemonic, stringToPath(account.hdPath), account.prefix); + } + 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 Secp256k1Wallet.deserializeWithEncryptionKey(serialization, encryptionKey); + } + + /** Base secret */ + private readonly secret: EnglishMnemonic; + /** Derivation instruction */ + private readonly accounts: readonly Secp256k1Derivation[]; + /** Derived data */ + private readonly pubkey: Uint8Array; + private readonly privkey: Uint8Array; + + private constructor( + mnemonic: EnglishMnemonic, + hdPath: readonly Slip10RawIndex[], + privkey: Uint8Array, + pubkey: Uint8Array, + prefix: string, + ) { + this.secret = mnemonic; + this.accounts = [ + { + hdPath: hdPath, + prefix: prefix, + }, + ]; + this.privkey = privkey; + this.pubkey = pubkey; + } + + public get mnemonic(): string { + return this.secret.toString(); + } + + private get address(): string { + return rawSecp256k1PubkeyToAddress(this.pubkey, this.accounts[0].prefix); + } + + public async getAccounts(): Promise { + return [ + { + algo: "secp256k1", + address: this.address, + pubkey: this.pubkey, + }, + ]; + } + + public async sign( + address: string, + message: Uint8Array, + prehashType: PrehashType = "sha256", + ): Promise { + if (address !== this.address) { + throw new Error(`Address ${address} not found in wallet`); + } + const hashedMessage = prehash(message, prehashType); + const signature = await Secp256k1.createSignature(hashedMessage, this.privkey); + const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]); + return encodeSecp256k1Signature(this.pubkey, signatureBytes); + } + + /** + * 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: Secp256k1WalletData = { + mnemonic: this.mnemonic, + accounts: this.accounts.map( + (account): Secp256k1DerivationJson => ({ + hdPath: pathToString(account.hdPath), + prefix: account.prefix, + }), + ), + }; + const dataToEncryptRaw = toUtf8(JSON.stringify(dataToEncrypt)); + + const encryptionConfiguration: EncryptionConfiguration = { + algorithm: supportedAlgorithms.xchacha20poly1305Ietf, + }; + const encryptedData = await encrypt(dataToEncryptRaw, encryptionKey, encryptionConfiguration); + + const out: Secp256k1WalletSerialization = { + type: serializationTypeV1, + kdf: kdfConfiguration, + encryption: encryptionConfiguration, + data: toBase64(encryptedData), + }; + return JSON.stringify(out); + } +} diff --git a/packages/sdk38/src/signingcosmosclient.spec.ts b/packages/sdk38/src/signingcosmosclient.spec.ts index 63952110..425458df 100644 --- a/packages/sdk38/src/signingcosmosclient.spec.ts +++ b/packages/sdk38/src/signingcosmosclient.spec.ts @@ -4,9 +4,9 @@ import { assert } from "@cosmjs/utils"; import { Coin, coin, coins } from "./coins"; import { assertIsPostTxSuccess, PrivateCosmWasmClient } from "./cosmosclient"; import { MsgDelegate } from "./msgs"; +import { Secp256k1Wallet } from "./secp256k1wallet"; import { SigningCosmosClient } from "./signingcosmosclient"; import { makeRandomAddress, pendingWithoutWasmd, validatorAddress } from "./testutils.spec"; -import { Secp256k1Wallet } from "./wallet"; const httpUrl = "http://localhost:1317"; diff --git a/packages/sdk38/src/testutils.spec.ts b/packages/sdk38/src/testutils.spec.ts index 37356640..5bccb7cc 100644 --- a/packages/sdk38/src/testutils.spec.ts +++ b/packages/sdk38/src/testutils.spec.ts @@ -19,6 +19,9 @@ export const tendermintAddressMatcher = /^[0-9A-F]{40}$/; export const tendermintShortHashMatcher = /^[0-9a-f]{40}$/; export const dateTimeStampMatcher = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/; export const semverMatcher = /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/; +/** @see https://rgxdb.com/r/1NUN74O6 */ +export const base64Matcher = /^(?:[a-zA-Z0-9+/]{4})*(?:|(?:[a-zA-Z0-9+/]{3}=)|(?:[a-zA-Z0-9+/]{2}==)|(?:[a-zA-Z0-9+/]{1}===))$/; +export const hexMatcher = /^([0-9a-fA-F][0-9a-fA-F])*$/; // https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32 export const bech32AddressMatcher = /^[\x21-\x7e]{1,83}1[02-9ac-hj-np-z]{38}$/; diff --git a/packages/sdk38/src/wallet.spec.ts b/packages/sdk38/src/wallet.spec.ts deleted file mode 100644 index c016b1a8..00000000 --- a/packages/sdk38/src/wallet.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; -import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding"; - -import { Secp256k1Wallet } from "./wallet"; - -describe("Secp256k1Wallet", () => { - // m/44'/118'/0'/0/0 - // pubkey: 02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6 - const defaultMnemonic = "special sign fit simple patrol salute grocery chicken wheat radar tonight ceiling"; - const defaultPubkey = fromHex("02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6"); - const defaultAddress = "cosmos1jhg0e7s6gn44tfc5k37kr04sznyhedtc9rzys5"; - - it("can be constructed", async () => { - const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); - expect(wallet).toBeTruthy(); - }); - - describe("getAccounts", () => { - it("resolves to a list of accounts if enabled", async () => { - const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); - const accounts = await wallet.getAccounts(); - expect(accounts.length).toEqual(1); - expect(accounts[0]).toEqual({ - address: defaultAddress, - algo: "secp256k1", - pubkey: defaultPubkey, - }); - }); - - it("creates the same address as Go implementation", async () => { - const wallet = await Secp256k1Wallet.fromMnemonic( - "oyster design unusual machine spread century engine gravity focus cave carry slot", - ); - const [{ address }] = await wallet.getAccounts(); - expect(address).toEqual("cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u"); - }); - }); - - describe("sign", () => { - it("resolves to valid signature if enabled", async () => { - const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); - const message = toAscii("foo bar"); - const signature = await wallet.sign(defaultAddress, message); - const valid = await Secp256k1.verifySignature( - Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)), - new Sha256(message).digest(), - defaultPubkey, - ); - expect(valid).toEqual(true); - }); - }); -}); diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index c252d9a7..f8e4b1ab 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -1,16 +1,15 @@ import { - Bip39, - EnglishMnemonic, - Secp256k1, + Argon2id, + isArgon2idOptions, + Random, Sha256, Sha512, - Slip10, - Slip10Curve, Slip10RawIndex, + xchacha20NonceLength, + Xchacha20poly1305Ietf, } from "@cosmjs/crypto"; +import { toAscii } from "@cosmjs/encoding"; -import { rawSecp256k1PubkeyToAddress } from "./address"; -import { encodeSecp256k1Signature } from "./signature"; import { StdSignature } from "./types"; export type PrehashType = "sha256" | "sha512" | null; @@ -36,7 +35,7 @@ export interface OfflineSigner { readonly sign: (address: string, message: Uint8Array, prehashType?: PrehashType) => Promise; } -function prehash(bytes: Uint8Array, type: PrehashType): Uint8Array { +export function prehash(bytes: Uint8Array, type: PrehashType): Uint8Array { switch (type) { case null: return new Uint8Array([...bytes]); @@ -63,54 +62,81 @@ export function makeCosmoshubPath(a: number): readonly Slip10RawIndex[] { ]; } -export class Secp256k1Wallet implements OfflineSigner { - public static async fromMnemonic( - mnemonic: string, - hdPath: readonly Slip10RawIndex[] = makeCosmoshubPath(0), - prefix = "cosmos", - ): Promise { - const seed = await Bip39.mnemonicToSeed(new EnglishMnemonic(mnemonic)); - const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, hdPath); - const uncompressed = (await Secp256k1.makeKeypair(privkey)).pubkey; - return new Secp256k1Wallet(privkey, Secp256k1.compressPubkey(uncompressed), prefix); - } +/** + * 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."); - private readonly pubkey: Uint8Array; - private readonly privkey: Uint8Array; - private readonly prefix: string; - private readonly algo: Algo = "secp256k1"; +export interface KdfConfiguration { + /** + * An algorithm identifier, such as "argon2id" or "scrypt". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params: Record; +} - private constructor(privkey: Uint8Array, pubkey: Uint8Array, prefix: string) { - this.privkey = privkey; - this.pubkey = pubkey; - this.prefix = prefix; - } - - private get address(): string { - return rawSecp256k1PubkeyToAddress(this.pubkey, this.prefix); - } - - public async getAccounts(): Promise { - return [ - { - address: this.address, - algo: this.algo, - pubkey: this.pubkey, - }, - ]; - } - - public async sign( - address: string, - message: Uint8Array, - prehashType: PrehashType = "sha256", - ): Promise { - if (address !== this.address) { - throw new Error(`Address ${address} not found in wallet`); +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); } - const hashedMessage = prehash(message, prehashType); - const signature = await Secp256k1.createSignature(hashedMessage, this.privkey); - const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]); - return encodeSecp256k1Signature(this.pubkey, signatureBytes); + 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}'`); } } diff --git a/packages/sdk38/types/index.d.ts b/packages/sdk38/types/index.d.ts index b136e0d0..fd558f55 100644 --- a/packages/sdk38/types/index.d.ts +++ b/packages/sdk38/types/index.d.ts @@ -87,4 +87,5 @@ export { findSequenceForSignedTx } from "./sequence"; export { encodeSecp256k1Signature, decodeSignature } from "./signature"; export { FeeTable, SigningCosmosClient } from "./signingcosmosclient"; export { isStdTx, pubkeyType, CosmosSdkTx, PubKey, StdFee, StdSignature, StdTx } from "./types"; -export { OfflineSigner, Secp256k1Wallet, makeCosmoshubPath } from "./wallet"; +export { OfflineSigner, makeCosmoshubPath, executeKdf, KdfConfiguration } from "./wallet"; +export { extractKdfConfiguration, Secp256k1Wallet } from "./secp256k1wallet"; diff --git a/packages/sdk38/types/secp256k1wallet.d.ts b/packages/sdk38/types/secp256k1wallet.d.ts new file mode 100644 index 00000000..06e9510c --- /dev/null +++ b/packages/sdk38/types/secp256k1wallet.d.ts @@ -0,0 +1,111 @@ +import { Slip10RawIndex } from "@cosmjs/crypto"; +import { StdSignature } from "./types"; +import { AccountData, EncryptionConfiguration, KdfConfiguration, OfflineSigner, PrehashType } from "./wallet"; +/** + * This interface describes a JSON object holding the encrypted wallet and the meta data. + * All fields in here must be JSON types. + */ +export interface Secp256k1WalletSerialization { + /** 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 Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ + readonly data: string; +} +/** + * Derivation information required to derive a keypair and an address from a mnemonic. + * All fields in here must be JSON types. + */ +interface Secp256k1DerivationJson { + readonly hdPath: string; + readonly prefix: string; +} +/** + * The data of a wallet serialization that is encrypted. + * All fields in here must be JSON types. + */ +export interface Secp256k1WalletData { + readonly mnemonic: string; + readonly accounts: readonly Secp256k1DerivationJson[]; +} +export declare function extractKdfConfiguration(serialization: string): KdfConfiguration; +export declare class Secp256k1Wallet implements OfflineSigner { + /** + * Restores a wallet from the given BIP39 mnemonic. + * + * @param mnemonic Any valid English mnemonic. + * @param hdPath The BIP-32/SLIP-10 derivation path. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`. + * @param prefix The bech32 address prefix (human readable part). Defaults to "cosmos". + */ + static fromMnemonic( + mnemonic: string, + hdPath?: readonly Slip10RawIndex[], + prefix?: string, + ): Promise; + /** + * Generates a new wallet with a BIP39 mnemonic of the given length. + * + * @param length The number of words in the mnemonic (12, 15, 18, 21 or 24). + * @param hdPath The BIP-32/SLIP-10 derivation path. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`. + * @param prefix The bech32 address prefix (human readable part). Defaults to "cosmos". + */ + static generate( + length?: 12 | 15 | 18 | 21 | 24, + hdPath?: readonly Slip10RawIndex[], + prefix?: string, + ): Promise; + /** + * 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). + */ + static deserialize(serialization: string, password: string): Promise; + /** + * 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. + */ + static deserializeWithEncryptionKey( + serialization: string, + encryptionKey: Uint8Array, + ): Promise; + private static deserializeTypeV1; + /** Base secret */ + private readonly secret; + /** Derivation instruction */ + private readonly accounts; + /** Derived data */ + private readonly pubkey; + private readonly privkey; + private constructor(); + get mnemonic(): string; + private get address(); + getAccounts(): Promise; + sign(address: string, message: Uint8Array, prehashType?: PrehashType): Promise; + /** + * 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). + */ + serialize(password: string): Promise; + /** + * 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. + */ + serializeWithEncryptionKey(encryptionKey: Uint8Array, kdfConfiguration: KdfConfiguration): Promise; +} +export {}; diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 463ce554..c0818ef2 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -17,23 +17,49 @@ export interface OfflineSigner { */ readonly sign: (address: string, message: Uint8Array, prehashType?: PrehashType) => Promise; } +export declare function prehash(bytes: Uint8Array, type: PrehashType): Uint8Array; /** * The Cosmoshub derivation path in the form `m/44'/118'/0'/0/a` * with 0-based account index `a`. */ export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[]; -export declare class Secp256k1Wallet implements OfflineSigner { - static fromMnemonic( - mnemonic: string, - hdPath?: readonly Slip10RawIndex[], - prefix?: string, - ): Promise; - private readonly pubkey; - private readonly privkey; - private readonly prefix; - private readonly algo; - private constructor(); - private get address(); - getAccounts(): Promise; - sign(address: string, message: Uint8Array, prehashType?: PrehashType): Promise; +/** + * 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 declare const cosmjsSalt: Uint8Array; +export interface KdfConfiguration { + /** + * An algorithm identifier, such as "argon2id" or "scrypt". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params: Record; } +export declare function executeKdf(password: string, configuration: KdfConfiguration): Promise; +/** + * 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 declare const supportedAlgorithms: { + xchacha20poly1305Ietf: string; +}; +export declare function encrypt( + plaintext: Uint8Array, + encryptionKey: Uint8Array, + config: EncryptionConfiguration, +): Promise; +export declare function decrypt( + ciphertext: Uint8Array, + encryptionKey: Uint8Array, + config: EncryptionConfiguration, +): Promise;