From 8798e7046f0e09a61d57d2db78b536a796859f61 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 11:23:20 +0200 Subject: [PATCH 01/23] Add documentation to Argon2idOptions --- packages/crypto/src/libsodium.ts | 17 +++++++++++++---- packages/crypto/types/libsodium.d.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/crypto/src/libsodium.ts b/packages/crypto/src/libsodium.ts index 5f425fba..a68274cc 100644 --- a/packages/crypto/src/libsodium.ts +++ b/packages/crypto/src/libsodium.ts @@ -6,12 +6,21 @@ 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; } diff --git a/packages/crypto/types/libsodium.d.ts b/packages/crypto/types/libsodium.d.ts index 6ecd5ce7..00437296 100644 --- a/packages/crypto/types/libsodium.d.ts +++ b/packages/crypto/types/libsodium.d.ts @@ -1,6 +1,19 @@ 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 class Argon2id { From 2a47d8b5806026a5f7a85d0bdea7da1d0d081476 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 08:30:26 +0200 Subject: [PATCH 02/23] Add mnemonic storage to Secp256k1Wallet --- packages/sdk38/src/wallet.spec.ts | 9 ++++++--- packages/sdk38/src/wallet.ts | 16 ++++++++++++---- packages/sdk38/types/wallet.d.ts | 4 +++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/sdk38/src/wallet.spec.ts b/packages/sdk38/src/wallet.spec.ts index c016b1a8..d9992ad2 100644 --- a/packages/sdk38/src/wallet.spec.ts +++ b/packages/sdk38/src/wallet.spec.ts @@ -10,9 +10,12 @@ describe("Secp256k1Wallet", () => { const defaultPubkey = fromHex("02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6"); const defaultAddress = "cosmos1jhg0e7s6gn44tfc5k37kr04sznyhedtc9rzys5"; - it("can be constructed", async () => { - const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); - expect(wallet).toBeTruthy(); + describe("fromMnemonic", () => { + it("works", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); + expect(wallet).toBeTruthy(); + expect(wallet.mnemonic).toEqual(defaultMnemonic); + }); }); describe("getAccounts", () => { diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index c252d9a7..1efb4075 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -65,27 +65,35 @@ export function makeCosmoshubPath(a: number): readonly Slip10RawIndex[] { export class Secp256k1Wallet implements OfflineSigner { public static async fromMnemonic( - mnemonic: string, + mnemonicInput: string, hdPath: readonly Slip10RawIndex[] = makeCosmoshubPath(0), prefix = "cosmos", ): Promise { - const seed = await Bip39.mnemonicToSeed(new EnglishMnemonic(mnemonic)); + const mnemonic = new EnglishMnemonic(mnemonicInput); + const seed = await Bip39.mnemonicToSeed(mnemonic); const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, hdPath); const uncompressed = (await Secp256k1.makeKeypair(privkey)).pubkey; - return new Secp256k1Wallet(privkey, Secp256k1.compressPubkey(uncompressed), prefix); + return new Secp256k1Wallet(mnemonic, privkey, Secp256k1.compressPubkey(uncompressed), prefix); } + + private readonly mnemonicData: EnglishMnemonic; private readonly pubkey: Uint8Array; private readonly privkey: Uint8Array; private readonly prefix: string; private readonly algo: Algo = "secp256k1"; - private constructor(privkey: Uint8Array, pubkey: Uint8Array, prefix: string) { + private constructor(mnemonic: EnglishMnemonic, privkey: Uint8Array, pubkey: Uint8Array, prefix: string) { + this.mnemonicData = mnemonic; this.privkey = privkey; this.pubkey = pubkey; this.prefix = prefix; } + public get mnemonic(): string { + return this.mnemonicData.toString(); + } + private get address(): string { return rawSecp256k1PubkeyToAddress(this.pubkey, this.prefix); } diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 463ce554..0c29481d 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -24,15 +24,17 @@ export interface OfflineSigner { export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[]; export declare class Secp256k1Wallet implements OfflineSigner { static fromMnemonic( - mnemonic: string, + mnemonicInput: string, hdPath?: readonly Slip10RawIndex[], prefix?: string, ): Promise; + private readonly mnemonicData; private readonly pubkey; private readonly privkey; private readonly prefix; private readonly algo; private constructor(); + get mnemonic(): string; private get address(); getAccounts(): Promise; sign(address: string, message: Uint8Array, prehashType?: PrehashType): Promise; From 57914c2a61cf891fa7a748d4d7f6fb64b1587888 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 08:40:16 +0200 Subject: [PATCH 03/23] Add Secp256k1Wallet.generate --- packages/sdk38/src/wallet.spec.ts | 15 +++++++++++++++ packages/sdk38/src/wallet.ts | 18 ++++++++++++++++++ packages/sdk38/types/wallet.d.ts | 12 ++++++++++++ 3 files changed, 45 insertions(+) diff --git a/packages/sdk38/src/wallet.spec.ts b/packages/sdk38/src/wallet.spec.ts index d9992ad2..3e5322ae 100644 --- a/packages/sdk38/src/wallet.spec.ts +++ b/packages/sdk38/src/wallet.spec.ts @@ -18,6 +18,21 @@ describe("Secp256k1Wallet", () => { }); }); + 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("getAccounts", () => { it("resolves to a list of accounts if enabled", async () => { const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index 1efb4075..eb3059a3 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -1,6 +1,7 @@ import { Bip39, EnglishMnemonic, + Random, Secp256k1, Sha256, Sha512, @@ -76,6 +77,23 @@ export class Secp256k1Wallet implements OfflineSigner { return new Secp256k1Wallet(mnemonic, 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); + } private readonly mnemonicData: EnglishMnemonic; private readonly pubkey: Uint8Array; diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 0c29481d..0f295b04 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -28,6 +28,18 @@ export declare class Secp256k1Wallet implements OfflineSigner { 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; private readonly mnemonicData; private readonly pubkey; private readonly privkey; From c42b341fe4c542974960752d6045f16288f06073 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 08:40:28 +0200 Subject: [PATCH 04/23] Improve Secp256k1Wallet.fromMnemonic docs --- packages/sdk38/src/wallet.ts | 15 +++++++++++---- packages/sdk38/types/wallet.d.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index eb3059a3..1103f275 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -65,16 +65,23 @@ export function makeCosmoshubPath(a: number): readonly Slip10RawIndex[] { } 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( - mnemonicInput: string, + mnemonic: string, hdPath: readonly Slip10RawIndex[] = makeCosmoshubPath(0), prefix = "cosmos", ): Promise { - const mnemonic = new EnglishMnemonic(mnemonicInput); - const seed = await Bip39.mnemonicToSeed(mnemonic); + 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(mnemonic, privkey, Secp256k1.compressPubkey(uncompressed), prefix); + return new Secp256k1Wallet(mnemonicChecked, privkey, Secp256k1.compressPubkey(uncompressed), prefix); } /** diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 0f295b04..f37c6339 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -23,8 +23,15 @@ export interface OfflineSigner { */ export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[]; 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( - mnemonicInput: string, + mnemonic: string, hdPath?: readonly Slip10RawIndex[], prefix?: string, ): Promise; From bd6efee4f038c02e94d3f0efbf7011453b27952d Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 10:39:44 +0200 Subject: [PATCH 05/23] Add initial attempt of Secp256k1Wallet.save --- packages/sdk38/src/testutils.spec.ts | 2 + packages/sdk38/src/wallet.spec.ts | 14 +++ packages/sdk38/src/wallet.ts | 149 +++++++++++++++++++++++++-- packages/sdk38/types/wallet.d.ts | 49 ++++++++- 4 files changed, 201 insertions(+), 13 deletions(-) diff --git a/packages/sdk38/src/testutils.spec.ts b/packages/sdk38/src/testutils.spec.ts index 37356640..7845d0b4 100644 --- a/packages/sdk38/src/testutils.spec.ts +++ b/packages/sdk38/src/testutils.spec.ts @@ -19,6 +19,8 @@ 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}===))$/; // 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 index 3e5322ae..fd42768a 100644 --- a/packages/sdk38/src/wallet.spec.ts +++ b/packages/sdk38/src/wallet.spec.ts @@ -1,6 +1,7 @@ import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding"; +import { base64Matcher } from "./testutils.spec"; import { Secp256k1Wallet } from "./wallet"; describe("Secp256k1Wallet", () => { @@ -67,4 +68,17 @@ describe("Secp256k1Wallet", () => { expect(valid).toEqual(true); }); }); + + describe("save", () => { + it("can save with password", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); + const serialized = await wallet.save("123"); + expect(JSON.parse(serialized)).toEqual( + jasmine.objectContaining({ + type: "v1", + value: jasmine.stringMatching(base64Matcher), + }), + ); + }); + }); }); diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index 1103f275..1ca45eb6 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -1,6 +1,9 @@ import { + Argon2id, + Argon2idOptions, Bip39, EnglishMnemonic, + pathToString, Random, Secp256k1, Sha256, @@ -8,7 +11,11 @@ import { Slip10, Slip10Curve, Slip10RawIndex, + xchacha20NonceLength, + Xchacha20poly1305Ietf, } from "@cosmjs/crypto"; +import { toAscii, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; +import { isUint8Array } from "@cosmjs/utils"; import { rawSecp256k1PubkeyToAddress } from "./address"; import { encodeSecp256k1Signature } from "./signature"; @@ -64,6 +71,63 @@ export function makeCosmoshubPath(a: number): readonly Slip10RawIndex[] { ]; } +const serializationType1 = "v1"; + +/** + * A fixed salt is chosen to archive a deterministic password to key derivation. + * This reduces the scope of a potential rainbow attack to all Secp256k1Wallet v1 users. + * Must be 16 bytes due to implementation limitations. + */ +const secp256k1WalletSalt = toAscii("Secp256k1Wallet1"); + +/** + * Not great but can be used on the main thread + */ +const passwordHashingOptions: Argon2idOptions = { + outputLength: 32, + opsLimit: 11, + memLimitKib: 8 * 1024, +}; + +const algorithmIdXchacha20poly1305Ietf = "xchacha20poly1305-ietf"; + +/** + * This interface describes a JSON object holding the encrypted wallet and the meta data + */ +export interface EncryptedSecp256k1Wallet { + /** A format+version identifier for this serialization format */ + readonly type: string; + /** Information about the key derivation function (i.e. password to encrytion key) */ + readonly kdf: { + /** + * An algorithm identifier, such as "argon2id" or "scrypt". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params: Record; + }; + /** Information about the symmetric encryption */ + readonly encryption: { + /** + * An algorithm identifier, such as "xchacha20poly1305-ietf". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params: Record; + }; + /** base64 encoded enccrypted value */ + readonly value: string; +} + +export interface EncryptedSecp256k1WalletData { + readonly mnemonic: string; + readonly accounts: ReadonlyArray<{ + readonly algo: string; + readonly hdPath: string; + readonly prefix: string; + }>; +} + export class Secp256k1Wallet implements OfflineSigner { /** * Restores a wallet from the given BIP39 mnemonic. @@ -81,7 +145,13 @@ export class Secp256k1Wallet implements OfflineSigner { 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, privkey, Secp256k1.compressPubkey(uncompressed), prefix); + return new Secp256k1Wallet( + mnemonicChecked, + hdPath, + privkey, + Secp256k1.compressPubkey(uncompressed), + prefix, + ); } /** @@ -102,32 +172,50 @@ export class Secp256k1Wallet implements OfflineSigner { return Secp256k1Wallet.fromMnemonic(mnemonic.toString(), hdPath, prefix); } - private readonly mnemonicData: EnglishMnemonic; + /** Base secret */ + private readonly secret: EnglishMnemonic; + /** Derivation instrations */ + private readonly accounts: ReadonlyArray<{ + readonly algo: Algo; + readonly hdPath: readonly Slip10RawIndex[]; + readonly prefix: string; + }>; + /** Derived data */ private readonly pubkey: Uint8Array; private readonly privkey: Uint8Array; - private readonly prefix: string; - private readonly algo: Algo = "secp256k1"; - private constructor(mnemonic: EnglishMnemonic, privkey: Uint8Array, pubkey: Uint8Array, prefix: string) { - this.mnemonicData = mnemonic; + private constructor( + mnemonic: EnglishMnemonic, + hdPath: readonly Slip10RawIndex[], + privkey: Uint8Array, + pubkey: Uint8Array, + prefix: string, + ) { + this.secret = mnemonic; + this.accounts = [ + { + algo: "secp256k1", + hdPath: hdPath, + prefix: prefix, + }, + ]; this.privkey = privkey; this.pubkey = pubkey; - this.prefix = prefix; } public get mnemonic(): string { - return this.mnemonicData.toString(); + return this.secret.toString(); } private get address(): string { - return rawSecp256k1PubkeyToAddress(this.pubkey, this.prefix); + return rawSecp256k1PubkeyToAddress(this.pubkey, this.accounts[0].prefix); } public async getAccounts(): Promise { return [ { address: this.address, - algo: this.algo, + algo: this.accounts[0].algo, pubkey: this.pubkey, }, ]; @@ -146,4 +234,45 @@ export class Secp256k1Wallet implements OfflineSigner { const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]); return encodeSecp256k1Signature(this.pubkey, signatureBytes); } + + /** + * Generates an encrypted serialization of this wallet. + * + * @param secret If set to a string, a KDF runs internally. If set to an Uin8Array, this is used a the encryption key directly. + */ + public async save(secret: string | Uint8Array): Promise { + let encryptionKey: Uint8Array; + if (typeof secret === "string") { + encryptionKey = await Argon2id.execute(secret, secp256k1WalletSalt, passwordHashingOptions); + } else if (isUint8Array(secret)) { + encryptionKey = secret; + } else { + throw new Error("Unsupported type of encryption secret"); + } + + const encrytedData: EncryptedSecp256k1WalletData = { + mnemonic: this.mnemonic, + accounts: this.accounts.map((account) => ({ + algo: account.algo, + hdPath: pathToString(account.hdPath), + prefix: account.prefix, + })), + }; + const message = toUtf8(JSON.stringify(encrytedData)); + const nonce = Random.getBytes(xchacha20NonceLength); + const encrypted = await Xchacha20poly1305Ietf.encrypt(message, encryptionKey, nonce); + + const out: EncryptedSecp256k1Wallet = { + type: serializationType1, + kdf: { algorithm: "scrypt", params: {} }, + encryption: { + algorithm: algorithmIdXchacha20poly1305Ietf, + params: { + nonce: toHex(nonce), + }, + }, + value: toBase64(encrypted), + }; + return JSON.stringify(out); + } } diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index f37c6339..7c8d512c 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -22,6 +22,41 @@ export interface OfflineSigner { * with 0-based account index `a`. */ export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[]; +/** + * This interface describes a JSON object holding the encrypted wallet and the meta data + */ +export interface EncryptedSecp256k1Wallet { + /** A format+version identifier for this serialization format */ + readonly type: string; + /** Information about the key derivation function (i.e. password to encrytion key) */ + readonly kdf: { + /** + * An algorithm identifier, such as "argon2id" or "scrypt". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params: Record; + }; + /** Information about the symmetric encryption */ + readonly encryption: { + /** + * An algorithm identifier, such as "xchacha20poly1305-ietf". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params: Record; + }; + /** base64 encoded enccrypted value */ + readonly value: string; +} +export interface EncryptedSecp256k1WalletData { + readonly mnemonic: string; + readonly accounts: ReadonlyArray<{ + readonly algo: string; + readonly hdPath: string; + readonly prefix: string; + }>; +} export declare class Secp256k1Wallet implements OfflineSigner { /** * Restores a wallet from the given BIP39 mnemonic. @@ -47,14 +82,22 @@ export declare class Secp256k1Wallet implements OfflineSigner { hdPath?: readonly Slip10RawIndex[], prefix?: string, ): Promise; - private readonly mnemonicData; + /** Base secret */ + private readonly secret; + /** Derivation instrations */ + private readonly accounts; + /** Derived data */ private readonly pubkey; private readonly privkey; - private readonly prefix; - private readonly algo; 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 secret If set to a string, a KDF runs internally. If set to an Uin8Array, this is used a the encryption key directly. + */ + save(secret: string | Uint8Array): Promise; } From ed8497b0056b8dbfd17d9e0453208ead72e909e8 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 13:14:38 +0200 Subject: [PATCH 06/23] Split into save/saveWithEncryptionKey --- packages/sdk38/src/testutils.spec.ts | 1 + packages/sdk38/src/wallet.spec.ts | 48 ++++++++++++++++++++++++++-- packages/sdk38/src/wallet.ts | 42 ++++++++++++++---------- packages/sdk38/types/wallet.d.ts | 17 ++++++++-- 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/packages/sdk38/src/testutils.spec.ts b/packages/sdk38/src/testutils.spec.ts index 7845d0b4..5bccb7cc 100644 --- a/packages/sdk38/src/testutils.spec.ts +++ b/packages/sdk38/src/testutils.spec.ts @@ -21,6 +21,7 @@ export const dateTimeStampMatcher = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{ 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 index fd42768a..c83b07ac 100644 --- a/packages/sdk38/src/wallet.spec.ts +++ b/packages/sdk38/src/wallet.spec.ts @@ -1,7 +1,7 @@ -import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; +import { Argon2idOptions, Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding"; -import { base64Matcher } from "./testutils.spec"; +import { base64Matcher, hexMatcher } from "./testutils.spec"; import { Secp256k1Wallet } from "./wallet"; describe("Secp256k1Wallet", () => { @@ -76,6 +76,50 @@ describe("Secp256k1Wallet", () => { expect(JSON.parse(serialized)).toEqual( jasmine.objectContaining({ type: "v1", + kdf: { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 20, + memLimitKib: 12 * 1024, + }, + }, + encryption: { + algorithm: "xchacha20poly1305-ietf", + params: { + nonce: jasmine.stringMatching(hexMatcher), + }, + }, + value: jasmine.stringMatching(base64Matcher), + }), + ); + }); + }); + + describe("saveWithEncryptionKey", () => { + it("can save with password", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); + + const key = fromHex("aabb221100aabb332211aabb33221100aabb221100aabb332211aabb33221100"); + const customKdfParams: Argon2idOptions = { + outputLength: 32, + opsLimit: 321, + memLimitKib: 11 * 1024, + }; + const serialized = await wallet.saveWithEncryptionKey(key, customKdfParams); + expect(JSON.parse(serialized)).toEqual( + jasmine.objectContaining({ + type: "v1", + kdf: { + algorithm: "argon2id", + params: customKdfParams, + }, + encryption: { + algorithm: "xchacha20poly1305-ietf", + params: { + nonce: jasmine.stringMatching(hexMatcher), + }, + }, value: jasmine.stringMatching(base64Matcher), }), ); diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index 1ca45eb6..602d9484 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -15,7 +15,6 @@ import { Xchacha20poly1305Ietf, } from "@cosmjs/crypto"; import { toAscii, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; -import { isUint8Array } from "@cosmjs/utils"; import { rawSecp256k1PubkeyToAddress } from "./address"; import { encodeSecp256k1Signature } from "./signature"; @@ -81,12 +80,13 @@ const serializationType1 = "v1"; const secp256k1WalletSalt = toAscii("Secp256k1Wallet1"); /** - * Not great but can be used on the main thread + * 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 passwordHashingOptions: Argon2idOptions = { +const basicPasswordHashingOptions: Argon2idOptions = { outputLength: 32, - opsLimit: 11, - memLimitKib: 8 * 1024, + opsLimit: 20, + memLimitKib: 12 * 1024, }; const algorithmIdXchacha20poly1305Ietf = "xchacha20poly1305-ietf"; @@ -238,18 +238,28 @@ export class Secp256k1Wallet implements OfflineSigner { /** * Generates an encrypted serialization of this wallet. * - * @param secret If set to a string, a KDF runs internally. If set to an Uin8Array, this is used a the encryption key directly. + * @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 save(secret: string | Uint8Array): Promise { - let encryptionKey: Uint8Array; - if (typeof secret === "string") { - encryptionKey = await Argon2id.execute(secret, secp256k1WalletSalt, passwordHashingOptions); - } else if (isUint8Array(secret)) { - encryptionKey = secret; - } else { - throw new Error("Unsupported type of encryption secret"); - } + public async save(password: string): Promise { + const kdfOption = basicPasswordHashingOptions; + const encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, kdfOption); + return this.saveWithEncryptionKey(encryptionKey, kdfOption); + } + /** + * Generates an encrypted serialization of this wallet. + * + * This is an advanced alternative of calling `save(password)` directly, which allows you to + * offload the KDF execution to an 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 saveWithEncryptionKey( + encryptionKey: Uint8Array, + kdfOptions: Argon2idOptions, + ): Promise { const encrytedData: EncryptedSecp256k1WalletData = { mnemonic: this.mnemonic, accounts: this.accounts.map((account) => ({ @@ -264,7 +274,7 @@ export class Secp256k1Wallet implements OfflineSigner { const out: EncryptedSecp256k1Wallet = { type: serializationType1, - kdf: { algorithm: "scrypt", params: {} }, + kdf: { algorithm: "argon2id", params: { ...kdfOptions } }, encryption: { algorithm: algorithmIdXchacha20poly1305Ietf, params: { diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 7c8d512c..486513b5 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -1,4 +1,4 @@ -import { Slip10RawIndex } from "@cosmjs/crypto"; +import { Argon2idOptions, Slip10RawIndex } from "@cosmjs/crypto"; import { StdSignature } from "./types"; export declare type PrehashType = "sha256" | "sha512" | null; export declare type Algo = "secp256k1" | "ed25519" | "sr25519"; @@ -97,7 +97,18 @@ export declare class Secp256k1Wallet implements OfflineSigner { /** * Generates an encrypted serialization of this wallet. * - * @param secret If set to a string, a KDF runs internally. If set to an Uin8Array, this is used a the encryption key directly. + * @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). */ - save(secret: string | Uint8Array): Promise; + save(password: string): Promise; + /** + * Generates an encrypted serialization of this wallet. + * + * This is an advanced alternative of calling `save(password)` directly, which allows you to + * offload the KDF execution to an 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. + */ + saveWithEncryptionKey(encryptionKey: Uint8Array, kdfOptions: Argon2idOptions): Promise; } From 588b31fed6cf8eff0ce1214436adce6fadf8893f Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 14:08:51 +0200 Subject: [PATCH 07/23] Rename to serialize/serializeWithEncryptionKey --- packages/sdk38/src/wallet.spec.ts | 8 ++++---- packages/sdk38/src/wallet.ts | 8 ++++---- packages/sdk38/types/wallet.d.ts | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/sdk38/src/wallet.spec.ts b/packages/sdk38/src/wallet.spec.ts index c83b07ac..8217c95c 100644 --- a/packages/sdk38/src/wallet.spec.ts +++ b/packages/sdk38/src/wallet.spec.ts @@ -69,10 +69,10 @@ describe("Secp256k1Wallet", () => { }); }); - describe("save", () => { + describe("serialize", () => { it("can save with password", async () => { const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); - const serialized = await wallet.save("123"); + const serialized = await wallet.serialize("123"); expect(JSON.parse(serialized)).toEqual( jasmine.objectContaining({ type: "v1", @@ -96,7 +96,7 @@ describe("Secp256k1Wallet", () => { }); }); - describe("saveWithEncryptionKey", () => { + describe("serializeWithEncryptionKey", () => { it("can save with password", async () => { const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); @@ -106,7 +106,7 @@ describe("Secp256k1Wallet", () => { opsLimit: 321, memLimitKib: 11 * 1024, }; - const serialized = await wallet.saveWithEncryptionKey(key, customKdfParams); + const serialized = await wallet.serializeWithEncryptionKey(key, customKdfParams); expect(JSON.parse(serialized)).toEqual( jasmine.objectContaining({ type: "v1", diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index 602d9484..330e49a0 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -241,22 +241,22 @@ export class Secp256k1Wallet implements OfflineSigner { * @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 save(password: string): Promise { + public async serialize(password: string): Promise { const kdfOption = basicPasswordHashingOptions; const encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, kdfOption); - return this.saveWithEncryptionKey(encryptionKey, kdfOption); + return this.serializeWithEncryptionKey(encryptionKey, kdfOption); } /** * Generates an encrypted serialization of this wallet. * - * This is an advanced alternative of calling `save(password)` directly, which allows you to + * This is an advanced alternative of calling `serialize(password)` directly, which allows you to * offload the KDF execution to an 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 saveWithEncryptionKey( + public async serializeWithEncryptionKey( encryptionKey: Uint8Array, kdfOptions: Argon2idOptions, ): Promise { diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 486513b5..9cb46ede 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -100,15 +100,15 @@ export declare class Secp256k1Wallet implements OfflineSigner { * @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). */ - save(password: string): Promise; + serialize(password: string): Promise; /** * Generates an encrypted serialization of this wallet. * - * This is an advanced alternative of calling `save(password)` directly, which allows you to + * This is an advanced alternative of calling `serialize(password)` directly, which allows you to * offload the KDF execution to an 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. */ - saveWithEncryptionKey(encryptionKey: Uint8Array, kdfOptions: Argon2idOptions): Promise; + serializeWithEncryptionKey(encryptionKey: Uint8Array, kdfOptions: Argon2idOptions): Promise; } From b6c7b8b1d4bf4064b3df86090f86483eda4dd573 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 15:00:18 +0200 Subject: [PATCH 08/23] Add Secp256k1Wallet.deserialize and .deserializeWithEncryptionKey --- packages/sdk38/src/wallet.spec.ts | 52 +++++++++++++++- packages/sdk38/src/wallet.ts | 98 ++++++++++++++++++++++++++++++- packages/sdk38/types/wallet.d.ts | 12 ++++ 3 files changed, 158 insertions(+), 4 deletions(-) diff --git a/packages/sdk38/src/wallet.spec.ts b/packages/sdk38/src/wallet.spec.ts index 8217c95c..a8375d65 100644 --- a/packages/sdk38/src/wallet.spec.ts +++ b/packages/sdk38/src/wallet.spec.ts @@ -1,8 +1,8 @@ -import { Argon2idOptions, Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; +import { Argon2id, Argon2idOptions, Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding"; import { base64Matcher, hexMatcher } from "./testutils.spec"; -import { Secp256k1Wallet } from "./wallet"; +import { extractKdfParams, Secp256k1Wallet, secp256k1WalletSalt } from "./wallet"; describe("Secp256k1Wallet", () => { // m/44'/118'/0'/0/0 @@ -34,6 +34,54 @@ describe("Secp256k1Wallet", () => { }); }); + 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: Argon2idOptions = { + outputLength: 32, + opsLimit: 4, + memLimitKib: 3 * 1024, + }; + const encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, anyKdfParams); + serialized = await original.serializeWithEncryptionKey(encryptionKey, anyKdfParams); + } + + { + const kdfOptions: any = extractKdfParams(serialized); + const encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, kdfOptions); + 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); diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index 330e49a0..3b8b1124 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -11,10 +11,12 @@ import { Slip10, Slip10Curve, Slip10RawIndex, + stringToPath, xchacha20NonceLength, Xchacha20poly1305Ietf, } from "@cosmjs/crypto"; -import { toAscii, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; +import { fromBase64, fromHex, fromUtf8, toAscii, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; +import { assert, isNonNullObject } from "@cosmjs/utils"; import { rawSecp256k1PubkeyToAddress } from "./address"; import { encodeSecp256k1Signature } from "./signature"; @@ -77,7 +79,7 @@ const serializationType1 = "v1"; * This reduces the scope of a potential rainbow attack to all Secp256k1Wallet v1 users. * Must be 16 bytes due to implementation limitations. */ -const secp256k1WalletSalt = toAscii("Secp256k1Wallet1"); +export const secp256k1WalletSalt = toAscii("Secp256k1Wallet1"); /** * A KDF configuration that is not very strong but can be used on the main thread. @@ -128,6 +130,22 @@ export interface EncryptedSecp256k1WalletData { }>; } +function extractKdfParamsV1(document: any): Record { + return document.kdf.params; +} + +export function extractKdfParams(serialization: string): Record { + const root = JSON.parse(serialization); + if (!isNonNullObject(root)) throw new Error("Root document is not an onject."); + + switch ((root as any).type) { + case serializationType1: + return extractKdfParamsV1(root); + default: + throw new Error("Unsupported serialization type"); + } +} + export class Secp256k1Wallet implements OfflineSigner { /** * Restores a wallet from the given BIP39 mnemonic. @@ -172,6 +190,82 @@ export class Secp256k1Wallet implements OfflineSigner { return Secp256k1Wallet.fromMnemonic(mnemonic.toString(), hdPath, prefix); } + 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 onject."); + const untypedRoot: any = root; + switch (untypedRoot.type) { + case serializationType1: { + let encryptionKey: Uint8Array; + switch (untypedRoot.kdf.algorithm) { + case "argon2id": { + const kdfOptions = untypedRoot.kdf.params; + encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, kdfOptions); + break; + } + default: + throw new Error("Unsupported KDF algorithm"); + } + + const nonce = fromHex(untypedRoot.encryption.params.nonce); + const decryptedBytes = await Xchacha20poly1305Ietf.decrypt( + fromBase64(untypedRoot.value), + encryptionKey, + nonce, + ); + 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 (!isNonNullObject(account)) throw new Error("Account is not an onject."); + const { algo, hdPath, prefix } = account as any; + assert(algo === "secp256k1"); + assert(typeof hdPath === "string"); + assert(typeof prefix === "string"); + + return Secp256k1Wallet.fromMnemonic(mnemonic, stringToPath(hdPath), prefix); + } + default: + throw new Error("Unsupported serialization type"); + } + } + + 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 onject."); + const untypedRoot: any = root; + switch (untypedRoot.type) { + case serializationType1: { + const nonce = fromHex(untypedRoot.encryption.params.nonce); + const decryptedBytes = await Xchacha20poly1305Ietf.decrypt( + fromBase64(untypedRoot.value), + encryptionKey, + nonce, + ); + 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 (!isNonNullObject(account)) throw new Error("Account is not an onject."); + const { algo, hdPath, prefix } = account as any; + assert(algo === "secp256k1"); + assert(typeof hdPath === "string"); + assert(typeof prefix === "string"); + + return Secp256k1Wallet.fromMnemonic(mnemonic, stringToPath(hdPath), prefix); + } + default: + throw new Error("Unsupported serialization type"); + } + } + /** Base secret */ private readonly secret: EnglishMnemonic; /** Derivation instrations */ diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 9cb46ede..7cf18ff9 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -22,6 +22,12 @@ export interface OfflineSigner { * with 0-based account index `a`. */ export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[]; +/** + * A fixed salt is chosen to archive a deterministic password to key derivation. + * This reduces the scope of a potential rainbow attack to all Secp256k1Wallet v1 users. + * Must be 16 bytes due to implementation limitations. + */ +export declare const secp256k1WalletSalt: Uint8Array; /** * This interface describes a JSON object holding the encrypted wallet and the meta data */ @@ -57,6 +63,7 @@ export interface EncryptedSecp256k1WalletData { readonly prefix: string; }>; } +export declare function extractKdfParams(serialization: string): Record; export declare class Secp256k1Wallet implements OfflineSigner { /** * Restores a wallet from the given BIP39 mnemonic. @@ -82,6 +89,11 @@ export declare class Secp256k1Wallet implements OfflineSigner { hdPath?: readonly Slip10RawIndex[], prefix?: string, ): Promise; + static deserialize(serialization: string, password: string): Promise; + static deserializeWithEncryptionKey( + serialization: string, + encryptionKey: Uint8Array, + ): Promise; /** Base secret */ private readonly secret; /** Derivation instrations */ From 21202e821da071ef0fd36951f7875374477a887f Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 15:57:08 +0200 Subject: [PATCH 09/23] Various Secp256k1Wallet cleanups --- packages/sdk38/src/wallet.spec.ts | 8 +-- packages/sdk38/src/wallet.ts | 101 +++++++++++++++--------------- packages/sdk38/types/wallet.d.ts | 16 +++-- 3 files changed, 64 insertions(+), 61 deletions(-) diff --git a/packages/sdk38/src/wallet.spec.ts b/packages/sdk38/src/wallet.spec.ts index a8375d65..7e05c296 100644 --- a/packages/sdk38/src/wallet.spec.ts +++ b/packages/sdk38/src/wallet.spec.ts @@ -123,7 +123,7 @@ describe("Secp256k1Wallet", () => { const serialized = await wallet.serialize("123"); expect(JSON.parse(serialized)).toEqual( jasmine.objectContaining({ - type: "v1", + type: "secp256k1wallet-v1", kdf: { algorithm: "argon2id", params: { @@ -138,7 +138,7 @@ describe("Secp256k1Wallet", () => { nonce: jasmine.stringMatching(hexMatcher), }, }, - value: jasmine.stringMatching(base64Matcher), + data: jasmine.stringMatching(base64Matcher), }), ); }); @@ -157,7 +157,7 @@ describe("Secp256k1Wallet", () => { const serialized = await wallet.serializeWithEncryptionKey(key, customKdfParams); expect(JSON.parse(serialized)).toEqual( jasmine.objectContaining({ - type: "v1", + type: "secp256k1wallet-v1", kdf: { algorithm: "argon2id", params: customKdfParams, @@ -168,7 +168,7 @@ describe("Secp256k1Wallet", () => { nonce: jasmine.stringMatching(hexMatcher), }, }, - value: jasmine.stringMatching(base64Matcher), + data: jasmine.stringMatching(base64Matcher), }), ); }); diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index 3b8b1124..b97bd0bc 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -72,7 +72,7 @@ export function makeCosmoshubPath(a: number): readonly Slip10RawIndex[] { ]; } -const serializationType1 = "v1"; +const serializationTypeV1 = "secp256k1wallet-v1"; /** * A fixed salt is chosen to archive a deterministic password to key derivation. @@ -94,9 +94,10 @@ const basicPasswordHashingOptions: Argon2idOptions = { const algorithmIdXchacha20poly1305Ietf = "xchacha20poly1305-ietf"; /** - * This interface describes a JSON object holding the encrypted wallet and the meta data + * This interface describes a JSON object holding the encrypted wallet and the meta data. + * All fields in here must be JSON types. */ -export interface EncryptedSecp256k1Wallet { +export interface Secp256k1WalletSerialization { /** A format+version identifier for this serialization format */ readonly type: string; /** Information about the key derivation function (i.e. password to encrytion key) */ @@ -117,11 +118,15 @@ export interface EncryptedSecp256k1Wallet { /** A map of algorithm-specific parameters */ readonly params: Record; }; - /** base64 encoded enccrypted value */ - readonly value: string; + /** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ + readonly data: string; } -export interface EncryptedSecp256k1WalletData { +/** + * 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: ReadonlyArray<{ readonly algo: string; @@ -136,10 +141,10 @@ function extractKdfParamsV1(document: any): Record { export function extractKdfParams(serialization: string): Record { const root = JSON.parse(serialization); - if (!isNonNullObject(root)) throw new Error("Root document is not an onject."); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); switch ((root as any).type) { - case serializationType1: + case serializationTypeV1: return extractKdfParamsV1(root); default: throw new Error("Unsupported serialization type"); @@ -192,24 +197,27 @@ export class Secp256k1Wallet implements OfflineSigner { 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 onject."); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); + switch ((root as any).type) { + case serializationTypeV1: + return Secp256k1Wallet.deserializeType1(serialization, password); + default: + throw new Error("Unsupported serialization type"); + } + } + + 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 serializationType1: { - let encryptionKey: Uint8Array; - switch (untypedRoot.kdf.algorithm) { - case "argon2id": { - const kdfOptions = untypedRoot.kdf.params; - encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, kdfOptions); - break; - } - default: - throw new Error("Unsupported KDF algorithm"); - } - + case serializationTypeV1: { const nonce = fromHex(untypedRoot.encryption.params.nonce); const decryptedBytes = await Xchacha20poly1305Ietf.decrypt( - fromBase64(untypedRoot.value), + fromBase64(untypedRoot.data), encryptionKey, nonce, ); @@ -219,7 +227,7 @@ export class Secp256k1Wallet implements OfflineSigner { 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 (!isNonNullObject(account)) throw new Error("Account is not an onject."); + if (!isNonNullObject(account)) throw new Error("Account is not an object."); const { algo, hdPath, prefix } = account as any; assert(algo === "secp256k1"); assert(typeof hdPath === "string"); @@ -232,34 +240,23 @@ export class Secp256k1Wallet implements OfflineSigner { } } - public static async deserializeWithEncryptionKey( - serialization: string, - encryptionKey: Uint8Array, - ): Promise { + private static async deserializeType1(serialization: string, password: string): Promise { const root = JSON.parse(serialization); - if (!isNonNullObject(root)) throw new Error("Root document is not an onject."); + if (!isNonNullObject(root)) throw new Error("Root document is not an object."); const untypedRoot: any = root; switch (untypedRoot.type) { - case serializationType1: { - const nonce = fromHex(untypedRoot.encryption.params.nonce); - const decryptedBytes = await Xchacha20poly1305Ietf.decrypt( - fromBase64(untypedRoot.value), - encryptionKey, - nonce, - ); - 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 (!isNonNullObject(account)) throw new Error("Account is not an onject."); - const { algo, hdPath, prefix } = account as any; - assert(algo === "secp256k1"); - assert(typeof hdPath === "string"); - assert(typeof prefix === "string"); - - return Secp256k1Wallet.fromMnemonic(mnemonic, stringToPath(hdPath), prefix); + case serializationTypeV1: { + let encryptionKey: Uint8Array; + switch (untypedRoot.kdf.algorithm) { + case "argon2id": { + const kdfOptions = untypedRoot.kdf.params; + encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, kdfOptions); + break; + } + default: + throw new Error("Unsupported KDF algorithm"); + } + return Secp256k1Wallet.deserializeWithEncryptionKey(serialization, encryptionKey); } default: throw new Error("Unsupported serialization type"); @@ -354,7 +351,7 @@ export class Secp256k1Wallet implements OfflineSigner { encryptionKey: Uint8Array, kdfOptions: Argon2idOptions, ): Promise { - const encrytedData: EncryptedSecp256k1WalletData = { + const encrytedData: Secp256k1WalletData = { mnemonic: this.mnemonic, accounts: this.accounts.map((account) => ({ algo: account.algo, @@ -366,8 +363,8 @@ export class Secp256k1Wallet implements OfflineSigner { const nonce = Random.getBytes(xchacha20NonceLength); const encrypted = await Xchacha20poly1305Ietf.encrypt(message, encryptionKey, nonce); - const out: EncryptedSecp256k1Wallet = { - type: serializationType1, + const out: Secp256k1WalletSerialization = { + type: serializationTypeV1, kdf: { algorithm: "argon2id", params: { ...kdfOptions } }, encryption: { algorithm: algorithmIdXchacha20poly1305Ietf, @@ -375,7 +372,7 @@ export class Secp256k1Wallet implements OfflineSigner { nonce: toHex(nonce), }, }, - value: toBase64(encrypted), + data: toBase64(encrypted), }; return JSON.stringify(out); } diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 7cf18ff9..383cf916 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -29,9 +29,10 @@ export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[]; */ export declare const secp256k1WalletSalt: Uint8Array; /** - * This interface describes a JSON object holding the encrypted wallet and the meta data + * This interface describes a JSON object holding the encrypted wallet and the meta data. + * All fields in here must be JSON types. */ -export interface EncryptedSecp256k1Wallet { +export interface Secp256k1WalletSerialization { /** A format+version identifier for this serialization format */ readonly type: string; /** Information about the key derivation function (i.e. password to encrytion key) */ @@ -52,10 +53,14 @@ export interface EncryptedSecp256k1Wallet { /** A map of algorithm-specific parameters */ readonly params: Record; }; - /** base64 encoded enccrypted value */ - readonly value: string; + /** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ + readonly data: string; } -export interface EncryptedSecp256k1WalletData { +/** + * 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: ReadonlyArray<{ readonly algo: string; @@ -94,6 +99,7 @@ export declare class Secp256k1Wallet implements OfflineSigner { serialization: string, encryptionKey: Uint8Array, ): Promise; + private static deserializeType1; /** Base secret */ private readonly secret; /** Derivation instrations */ From 91aec706507892c9824bf8d9571e5d95f140d0ba Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 22 Jul 2020 16:15:45 +0200 Subject: [PATCH 10/23] Pull out executeKdf --- packages/sdk38/src/wallet.spec.ts | 39 ++++++++++-------- packages/sdk38/src/wallet.ts | 68 ++++++++++++++++++++----------- packages/sdk38/types/wallet.d.ts | 30 ++++++-------- 3 files changed, 78 insertions(+), 59 deletions(-) diff --git a/packages/sdk38/src/wallet.spec.ts b/packages/sdk38/src/wallet.spec.ts index 7e05c296..ebaafc82 100644 --- a/packages/sdk38/src/wallet.spec.ts +++ b/packages/sdk38/src/wallet.spec.ts @@ -1,8 +1,8 @@ -import { Argon2id, Argon2idOptions, Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; +import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding"; import { base64Matcher, hexMatcher } from "./testutils.spec"; -import { extractKdfParams, Secp256k1Wallet, secp256k1WalletSalt } from "./wallet"; +import { executeKdf, extractKdfConfiguration, KdfConfiguration, Secp256k1Wallet } from "./wallet"; describe("Secp256k1Wallet", () => { // m/44'/118'/0'/0/0 @@ -57,18 +57,21 @@ describe("Secp256k1Wallet", () => { let serialized: string; { const original = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); - const anyKdfParams: Argon2idOptions = { - outputLength: 32, - opsLimit: 4, - memLimitKib: 3 * 1024, + const anyKdfParams: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 4, + memLimitKib: 3 * 1024, + }, }; - const encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, anyKdfParams); + const encryptionKey = await executeKdf(password, anyKdfParams); serialized = await original.serializeWithEncryptionKey(encryptionKey, anyKdfParams); } { - const kdfOptions: any = extractKdfParams(serialized); - const encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, kdfOptions); + 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([ @@ -149,19 +152,19 @@ describe("Secp256k1Wallet", () => { const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); const key = fromHex("aabb221100aabb332211aabb33221100aabb221100aabb332211aabb33221100"); - const customKdfParams: Argon2idOptions = { - outputLength: 32, - opsLimit: 321, - memLimitKib: 11 * 1024, + const customKdfConfiguration: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 321, + memLimitKib: 11 * 1024, + }, }; - const serialized = await wallet.serializeWithEncryptionKey(key, customKdfParams); + const serialized = await wallet.serializeWithEncryptionKey(key, customKdfConfiguration); expect(JSON.parse(serialized)).toEqual( jasmine.objectContaining({ type: "secp256k1wallet-v1", - kdf: { - algorithm: "argon2id", - params: customKdfParams, - }, + kdf: customKdfConfiguration, encryption: { algorithm: "xchacha20poly1305-ietf", params: { diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index b97bd0bc..6930adff 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -76,19 +76,22 @@ const serializationTypeV1 = "secp256k1wallet-v1"; /** * A fixed salt is chosen to archive a deterministic password to key derivation. - * This reduces the scope of a potential rainbow attack to all Secp256k1Wallet v1 users. + * This reduces the scope of a potential rainbow attack to all CosmJS users. * Must be 16 bytes due to implementation limitations. */ -export const secp256k1WalletSalt = toAscii("Secp256k1Wallet1"); +const cosmjsSalt = toAscii("The CosmJS salt."); /** * 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: Argon2idOptions = { - outputLength: 32, - opsLimit: 20, - memLimitKib: 12 * 1024, +const basicPasswordHashingOptions: KdfConfiguration = { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 20, + memLimitKib: 12 * 1024, + }, }; const algorithmIdXchacha20poly1305Ietf = "xchacha20poly1305-ietf"; @@ -101,14 +104,7 @@ export interface Secp256k1WalletSerialization { /** A format+version identifier for this serialization format */ readonly type: string; /** Information about the key derivation function (i.e. password to encrytion key) */ - readonly kdf: { - /** - * An algorithm identifier, such as "argon2id" or "scrypt". - */ - readonly algorithm: string; - /** A map of algorithm-specific parameters */ - readonly params: Record; - }; + readonly kdf: KdfConfiguration; /** Information about the symmetric encryption */ readonly encryption: { /** @@ -135,22 +131,46 @@ export interface Secp256k1WalletData { }>; } -function extractKdfParamsV1(document: any): Record { - return document.kdf.params; +function extractKdfConfigurationV1(document: any): KdfConfiguration { + return document.kdf; } -export function extractKdfParams(serialization: string): Record { +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 extractKdfParamsV1(root); + return extractKdfConfigurationV1(root); default: throw new Error("Unsupported serialization type"); } } +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 { outputLength, opsLimit, memLimitKib } = configuration.params; + assert(typeof outputLength === "number"); + assert(typeof opsLimit === "number"); + assert(typeof memLimitKib === "number"); + const options: Argon2idOptions = { outputLength, opsLimit, memLimitKib }; + return Argon2id.execute(password, cosmjsSalt, options); + } + default: + throw new Error("Unsupported KDF algorithm"); + } +} + export class Secp256k1Wallet implements OfflineSigner { /** * Restores a wallet from the given BIP39 mnemonic. @@ -250,7 +270,7 @@ export class Secp256k1Wallet implements OfflineSigner { switch (untypedRoot.kdf.algorithm) { case "argon2id": { const kdfOptions = untypedRoot.kdf.params; - encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, kdfOptions); + encryptionKey = await Argon2id.execute(password, cosmjsSalt, kdfOptions); break; } default: @@ -333,9 +353,9 @@ export class Secp256k1Wallet implements OfflineSigner { * This is not normalized internally (see "Unicode normalization" to learn more). */ public async serialize(password: string): Promise { - const kdfOption = basicPasswordHashingOptions; - const encryptionKey = await Argon2id.execute(password, secp256k1WalletSalt, kdfOption); - return this.serializeWithEncryptionKey(encryptionKey, kdfOption); + const kdfConfiguration = basicPasswordHashingOptions; + const encryptionKey = await executeKdf(password, kdfConfiguration); + return this.serializeWithEncryptionKey(encryptionKey, kdfConfiguration); } /** @@ -349,7 +369,7 @@ export class Secp256k1Wallet implements OfflineSigner { */ public async serializeWithEncryptionKey( encryptionKey: Uint8Array, - kdfOptions: Argon2idOptions, + kdfConfiguration: KdfConfiguration, ): Promise { const encrytedData: Secp256k1WalletData = { mnemonic: this.mnemonic, @@ -365,7 +385,7 @@ export class Secp256k1Wallet implements OfflineSigner { const out: Secp256k1WalletSerialization = { type: serializationTypeV1, - kdf: { algorithm: "argon2id", params: { ...kdfOptions } }, + kdf: kdfConfiguration, encryption: { algorithm: algorithmIdXchacha20poly1305Ietf, params: { diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 383cf916..0d377686 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -1,4 +1,4 @@ -import { Argon2idOptions, Slip10RawIndex } from "@cosmjs/crypto"; +import { Slip10RawIndex } from "@cosmjs/crypto"; import { StdSignature } from "./types"; export declare type PrehashType = "sha256" | "sha512" | null; export declare type Algo = "secp256k1" | "ed25519" | "sr25519"; @@ -22,12 +22,6 @@ export interface OfflineSigner { * with 0-based account index `a`. */ export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[]; -/** - * A fixed salt is chosen to archive a deterministic password to key derivation. - * This reduces the scope of a potential rainbow attack to all Secp256k1Wallet v1 users. - * Must be 16 bytes due to implementation limitations. - */ -export declare const secp256k1WalletSalt: Uint8Array; /** * This interface describes a JSON object holding the encrypted wallet and the meta data. * All fields in here must be JSON types. @@ -36,14 +30,7 @@ export interface Secp256k1WalletSerialization { /** A format+version identifier for this serialization format */ readonly type: string; /** Information about the key derivation function (i.e. password to encrytion key) */ - readonly kdf: { - /** - * An algorithm identifier, such as "argon2id" or "scrypt". - */ - readonly algorithm: string; - /** A map of algorithm-specific parameters */ - readonly params: Record; - }; + readonly kdf: KdfConfiguration; /** Information about the symmetric encryption */ readonly encryption: { /** @@ -68,7 +55,16 @@ export interface Secp256k1WalletData { readonly prefix: string; }>; } -export declare function extractKdfParams(serialization: string): Record; +export declare function extractKdfConfiguration(serialization: string): KdfConfiguration; +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; export declare class Secp256k1Wallet implements OfflineSigner { /** * Restores a wallet from the given BIP39 mnemonic. @@ -128,5 +124,5 @@ export declare class Secp256k1Wallet implements OfflineSigner { * 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, kdfOptions: Argon2idOptions): Promise; + serializeWithEncryptionKey(encryptionKey: Uint8Array, kdfConfiguration: KdfConfiguration): Promise; } From 0ea2babd7445796ec4ba65468b2723acb8e33faa Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 28 Jul 2020 13:16:01 +0200 Subject: [PATCH 11/23] Pull out Secp256k1Wallet into separate module --- .../sdk38/src/cosmosclient.searchtx.spec.ts | 2 +- packages/sdk38/src/cosmosclient.spec.ts | 2 +- packages/sdk38/src/index.ts | 3 +- .../sdk38/src/lcdapi/distribution.spec.ts | 2 +- packages/sdk38/src/lcdapi/gov.spec.ts | 2 +- packages/sdk38/src/lcdapi/lcdclient.spec.ts | 3 +- packages/sdk38/src/lcdapi/staking.spec.ts | 2 +- ...wallet.spec.ts => secp256k1wallet.spec.ts} | 3 +- packages/sdk38/src/secp256k1wallet.ts | 332 ++++++++++++++++++ .../sdk38/src/signingcosmosclient.spec.ts | 2 +- packages/sdk38/src/wallet.ts | 321 +---------------- packages/sdk38/types/index.d.ts | 3 +- packages/sdk38/types/secp256k1wallet.d.ts | 98 ++++++ packages/sdk38/types/wallet.d.ts | 96 +---- 14 files changed, 449 insertions(+), 422 deletions(-) rename packages/sdk38/src/{wallet.spec.ts => secp256k1wallet.spec.ts} (97%) create mode 100644 packages/sdk38/src/secp256k1wallet.ts create mode 100644 packages/sdk38/types/secp256k1wallet.d.ts 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 4afe6c26..78cbf494 100644 --- a/packages/sdk38/src/index.ts +++ b/packages/sdk38/src/index.ts @@ -85,4 +85,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 } from "./wallet"; +export { 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/wallet.spec.ts b/packages/sdk38/src/secp256k1wallet.spec.ts similarity index 97% rename from packages/sdk38/src/wallet.spec.ts rename to packages/sdk38/src/secp256k1wallet.spec.ts index ebaafc82..feb4de62 100644 --- a/packages/sdk38/src/wallet.spec.ts +++ b/packages/sdk38/src/secp256k1wallet.spec.ts @@ -1,8 +1,9 @@ import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding"; +import { extractKdfConfiguration, Secp256k1Wallet } from "./secp256k1wallet"; import { base64Matcher, hexMatcher } from "./testutils.spec"; -import { executeKdf, extractKdfConfiguration, KdfConfiguration, Secp256k1Wallet } from "./wallet"; +import { executeKdf, KdfConfiguration } from "./wallet"; describe("Secp256k1Wallet", () => { // m/44'/118'/0'/0/0 diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts new file mode 100644 index 00000000..c12382c8 --- /dev/null +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -0,0 +1,332 @@ +import { + Argon2id, + Bip39, + EnglishMnemonic, + pathToString, + Random, + Secp256k1, + Slip10, + Slip10Curve, + Slip10RawIndex, + stringToPath, + xchacha20NonceLength, + Xchacha20poly1305Ietf, +} from "@cosmjs/crypto"; +import { fromBase64, fromHex, fromUtf8, toAscii, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; +import { assert, isNonNullObject } from "@cosmjs/utils"; + +import { rawSecp256k1PubkeyToAddress } from "./address"; +import { encodeSecp256k1Signature } from "./signature"; +import { StdSignature } from "./types"; +import { + AccountData, + Algo, + executeKdf, + KdfConfiguration, + makeCosmoshubPath, + OfflineSigner, + prehash, + PrehashType, +} from "./wallet"; + +const serializationTypeV1 = "secp256k1wallet-v1"; + +/** + * 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. + */ +const cosmjsSalt = toAscii("The CosmJS salt."); + +/** + * 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, + }, +}; + +const algorithmIdXchacha20poly1305Ietf = "xchacha20poly1305-ietf"; + +/** + * 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 encrytion key) */ + readonly kdf: KdfConfiguration; + /** Information about the symmetric encryption */ + readonly encryption: { + /** + * An algorithm identifier, such as "xchacha20poly1305-ietf". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params: Record; + }; + /** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ + readonly data: 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: ReadonlyArray<{ + readonly algo: string; + readonly hdPath: string; + readonly prefix: string; + }>; +} + +function extractKdfConfigurationV1(document: any): KdfConfiguration { + return document.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 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); + } + + 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.deserializeType1(serialization, password); + default: + throw new Error("Unsupported serialization type"); + } + } + + 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 nonce = fromHex(untypedRoot.encryption.params.nonce); + const decryptedBytes = await Xchacha20poly1305Ietf.decrypt( + fromBase64(untypedRoot.data), + encryptionKey, + nonce, + ); + 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 (!isNonNullObject(account)) throw new Error("Account is not an object."); + const { algo, hdPath, prefix } = account as any; + assert(algo === "secp256k1"); + assert(typeof hdPath === "string"); + assert(typeof prefix === "string"); + + return Secp256k1Wallet.fromMnemonic(mnemonic, stringToPath(hdPath), prefix); + } + default: + throw new Error("Unsupported serialization type"); + } + } + + private static async deserializeType1(serialization: string, password: string): 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: { + let encryptionKey: Uint8Array; + switch (untypedRoot.kdf.algorithm) { + case "argon2id": { + const kdfOptions = untypedRoot.kdf.params; + encryptionKey = await Argon2id.execute(password, cosmjsSalt, kdfOptions); + break; + } + default: + throw new Error("Unsupported KDF algorithm"); + } + return Secp256k1Wallet.deserializeWithEncryptionKey(serialization, encryptionKey); + } + default: + throw new Error("Unsupported serialization type"); + } + } + + /** Base secret */ + private readonly secret: EnglishMnemonic; + /** Derivation instrations */ + private readonly accounts: ReadonlyArray<{ + readonly algo: Algo; + readonly hdPath: readonly Slip10RawIndex[]; + readonly prefix: string; + }>; + /** 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 = [ + { + algo: "secp256k1", + 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 [ + { + address: this.address, + algo: this.accounts[0].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`); + } + 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 of calling `serialize(password)` directly, which allows you to + * offload the KDF execution to an 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 encrytedData: Secp256k1WalletData = { + mnemonic: this.mnemonic, + accounts: this.accounts.map((account) => ({ + algo: account.algo, + hdPath: pathToString(account.hdPath), + prefix: account.prefix, + })), + }; + const message = toUtf8(JSON.stringify(encrytedData)); + const nonce = Random.getBytes(xchacha20NonceLength); + const encrypted = await Xchacha20poly1305Ietf.encrypt(message, encryptionKey, nonce); + + const out: Secp256k1WalletSerialization = { + type: serializationTypeV1, + kdf: kdfConfiguration, + encryption: { + algorithm: algorithmIdXchacha20poly1305Ietf, + params: { + nonce: toHex(nonce), + }, + }, + data: toBase64(encrypted), + }; + 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/wallet.ts b/packages/sdk38/src/wallet.ts index 6930adff..d7b43262 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -1,25 +1,7 @@ -import { - Argon2id, - Argon2idOptions, - Bip39, - EnglishMnemonic, - pathToString, - Random, - Secp256k1, - Sha256, - Sha512, - Slip10, - Slip10Curve, - Slip10RawIndex, - stringToPath, - xchacha20NonceLength, - Xchacha20poly1305Ietf, -} from "@cosmjs/crypto"; -import { fromBase64, fromHex, fromUtf8, toAscii, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; -import { assert, isNonNullObject } from "@cosmjs/utils"; +import { Argon2id, Argon2idOptions, Sha256, Sha512, Slip10RawIndex } from "@cosmjs/crypto"; +import { toAscii } from "@cosmjs/encoding"; +import { assert } from "@cosmjs/utils"; -import { rawSecp256k1PubkeyToAddress } from "./address"; -import { encodeSecp256k1Signature } from "./signature"; import { StdSignature } from "./types"; export type PrehashType = "sha256" | "sha512" | null; @@ -45,7 +27,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]); @@ -72,8 +54,6 @@ export function makeCosmoshubPath(a: number): readonly Slip10RawIndex[] { ]; } -const serializationTypeV1 = "secp256k1wallet-v1"; - /** * 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. @@ -81,72 +61,6 @@ const serializationTypeV1 = "secp256k1wallet-v1"; */ const cosmjsSalt = toAscii("The CosmJS salt."); -/** - * 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, - }, -}; - -const algorithmIdXchacha20poly1305Ietf = "xchacha20poly1305-ietf"; - -/** - * 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 encrytion key) */ - readonly kdf: KdfConfiguration; - /** Information about the symmetric encryption */ - readonly encryption: { - /** - * An algorithm identifier, such as "xchacha20poly1305-ietf". - */ - readonly algorithm: string; - /** A map of algorithm-specific parameters */ - readonly params: Record; - }; - /** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ - readonly data: 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: ReadonlyArray<{ - readonly algo: string; - readonly hdPath: string; - readonly prefix: string; - }>; -} - -function extractKdfConfigurationV1(document: any): KdfConfiguration { - return document.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 KdfConfiguration { /** * An algorithm identifier, such as "argon2id" or "scrypt". @@ -170,230 +84,3 @@ export async function executeKdf(password: string, configuration: KdfConfigurati throw new Error("Unsupported KDF algorithm"); } } - -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); - } - - 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.deserializeType1(serialization, password); - default: - throw new Error("Unsupported serialization type"); - } - } - - 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 nonce = fromHex(untypedRoot.encryption.params.nonce); - const decryptedBytes = await Xchacha20poly1305Ietf.decrypt( - fromBase64(untypedRoot.data), - encryptionKey, - nonce, - ); - 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 (!isNonNullObject(account)) throw new Error("Account is not an object."); - const { algo, hdPath, prefix } = account as any; - assert(algo === "secp256k1"); - assert(typeof hdPath === "string"); - assert(typeof prefix === "string"); - - return Secp256k1Wallet.fromMnemonic(mnemonic, stringToPath(hdPath), prefix); - } - default: - throw new Error("Unsupported serialization type"); - } - } - - private static async deserializeType1(serialization: string, password: string): 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: { - let encryptionKey: Uint8Array; - switch (untypedRoot.kdf.algorithm) { - case "argon2id": { - const kdfOptions = untypedRoot.kdf.params; - encryptionKey = await Argon2id.execute(password, cosmjsSalt, kdfOptions); - break; - } - default: - throw new Error("Unsupported KDF algorithm"); - } - return Secp256k1Wallet.deserializeWithEncryptionKey(serialization, encryptionKey); - } - default: - throw new Error("Unsupported serialization type"); - } - } - - /** Base secret */ - private readonly secret: EnglishMnemonic; - /** Derivation instrations */ - private readonly accounts: ReadonlyArray<{ - readonly algo: Algo; - readonly hdPath: readonly Slip10RawIndex[]; - readonly prefix: string; - }>; - /** 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 = [ - { - algo: "secp256k1", - 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 [ - { - address: this.address, - algo: this.accounts[0].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`); - } - 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 of calling `serialize(password)` directly, which allows you to - * offload the KDF execution to an 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 encrytedData: Secp256k1WalletData = { - mnemonic: this.mnemonic, - accounts: this.accounts.map((account) => ({ - algo: account.algo, - hdPath: pathToString(account.hdPath), - prefix: account.prefix, - })), - }; - const message = toUtf8(JSON.stringify(encrytedData)); - const nonce = Random.getBytes(xchacha20NonceLength); - const encrypted = await Xchacha20poly1305Ietf.encrypt(message, encryptionKey, nonce); - - const out: Secp256k1WalletSerialization = { - type: serializationTypeV1, - kdf: kdfConfiguration, - encryption: { - algorithm: algorithmIdXchacha20poly1305Ietf, - params: { - nonce: toHex(nonce), - }, - }, - data: toBase64(encrypted), - }; - return JSON.stringify(out); - } -} diff --git a/packages/sdk38/types/index.d.ts b/packages/sdk38/types/index.d.ts index d84be9f2..5e37414e 100644 --- a/packages/sdk38/types/index.d.ts +++ b/packages/sdk38/types/index.d.ts @@ -83,4 +83,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 } from "./wallet"; +export { 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..e0020657 --- /dev/null +++ b/packages/sdk38/types/secp256k1wallet.d.ts @@ -0,0 +1,98 @@ +import { Slip10RawIndex } from "@cosmjs/crypto"; +import { StdSignature } from "./types"; +import { AccountData, 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 encrytion key) */ + readonly kdf: KdfConfiguration; + /** Information about the symmetric encryption */ + readonly encryption: { + /** + * An algorithm identifier, such as "xchacha20poly1305-ietf". + */ + readonly algorithm: string; + /** A map of algorithm-specific parameters */ + readonly params: Record; + }; + /** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ + readonly data: 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: ReadonlyArray<{ + readonly algo: string; + readonly hdPath: string; + readonly prefix: string; + }>; +} +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; + static deserialize(serialization: string, password: string): Promise; + static deserializeWithEncryptionKey( + serialization: string, + encryptionKey: Uint8Array, + ): Promise; + private static deserializeType1; + /** Base secret */ + private readonly secret; + /** Derivation instrations */ + 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 of calling `serialize(password)` directly, which allows you to + * offload the KDF execution to an 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; +} diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 0d377686..19fe5696 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -17,45 +17,12 @@ 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[]; -/** - * 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 encrytion key) */ - readonly kdf: KdfConfiguration; - /** Information about the symmetric encryption */ - readonly encryption: { - /** - * An algorithm identifier, such as "xchacha20poly1305-ietf". - */ - readonly algorithm: string; - /** A map of algorithm-specific parameters */ - readonly params: Record; - }; - /** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ - readonly data: 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: ReadonlyArray<{ - readonly algo: string; - readonly hdPath: string; - readonly prefix: string; - }>; -} -export declare function extractKdfConfiguration(serialization: string): KdfConfiguration; export interface KdfConfiguration { /** * An algorithm identifier, such as "argon2id" or "scrypt". @@ -65,64 +32,3 @@ export interface KdfConfiguration { readonly params: Record; } export declare function executeKdf(password: string, configuration: KdfConfiguration): Promise; -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; - static deserialize(serialization: string, password: string): Promise; - static deserializeWithEncryptionKey( - serialization: string, - encryptionKey: Uint8Array, - ): Promise; - private static deserializeType1; - /** Base secret */ - private readonly secret; - /** Derivation instrations */ - 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 of calling `serialize(password)` directly, which allows you to - * offload the KDF execution to an 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; -} From e949d920970e7b64a478c8113c6bec15775f0c4b Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 13:44:47 +0200 Subject: [PATCH 12/23] Reuse cosmjsSalt from wallet.ts --- packages/sdk38/src/secp256k1wallet.ts | 10 ++-------- packages/sdk38/src/wallet.ts | 2 +- packages/sdk38/types/wallet.d.ts | 6 ++++++ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts index c12382c8..ab50b477 100644 --- a/packages/sdk38/src/secp256k1wallet.ts +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -12,7 +12,7 @@ import { xchacha20NonceLength, Xchacha20poly1305Ietf, } from "@cosmjs/crypto"; -import { fromBase64, fromHex, fromUtf8, toAscii, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; +import { fromBase64, fromHex, fromUtf8, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; import { assert, isNonNullObject } from "@cosmjs/utils"; import { rawSecp256k1PubkeyToAddress } from "./address"; @@ -21,6 +21,7 @@ import { StdSignature } from "./types"; import { AccountData, Algo, + cosmjsSalt, executeKdf, KdfConfiguration, makeCosmoshubPath, @@ -31,13 +32,6 @@ import { const serializationTypeV1 = "secp256k1wallet-v1"; -/** - * 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. - */ -const cosmjsSalt = toAscii("The CosmJS salt."); - /** * 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. diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index d7b43262..7378636b 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -59,7 +59,7 @@ export function makeCosmoshubPath(a: number): readonly Slip10RawIndex[] { * This reduces the scope of a potential rainbow attack to all CosmJS users. * Must be 16 bytes due to implementation limitations. */ -const cosmjsSalt = toAscii("The CosmJS salt."); +export const cosmjsSalt = toAscii("The CosmJS salt."); export interface KdfConfiguration { /** diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 19fe5696..8075062c 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -23,6 +23,12 @@ export declare function prehash(bytes: Uint8Array, type: PrehashType): Uint8Arra * with 0-based account index `a`. */ export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[]; +/** + * 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". From efc9498fe786b948efe315bb8f36d5c6c5fb3163 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 14:03:16 +0200 Subject: [PATCH 13/23] Fix a bunch of typos in comments --- packages/sdk38/src/secp256k1wallet.ts | 10 +++++----- packages/sdk38/types/secp256k1wallet.d.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts index ab50b477..0594dd75 100644 --- a/packages/sdk38/src/secp256k1wallet.ts +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -54,7 +54,7 @@ const algorithmIdXchacha20poly1305Ietf = "xchacha20poly1305-ietf"; export interface Secp256k1WalletSerialization { /** A format+version identifier for this serialization format */ readonly type: string; - /** Information about the key derivation function (i.e. password to encrytion key) */ + /** Information about the key derivation function (i.e. password to encryption key) */ readonly kdf: KdfConfiguration; /** Information about the symmetric encryption */ readonly encryption: { @@ -212,7 +212,7 @@ export class Secp256k1Wallet implements OfflineSigner { /** Base secret */ private readonly secret: EnglishMnemonic; - /** Derivation instrations */ + /** Derivation instruction */ private readonly accounts: ReadonlyArray<{ readonly algo: Algo; readonly hdPath: readonly Slip10RawIndex[]; @@ -288,10 +288,10 @@ export class Secp256k1Wallet implements OfflineSigner { /** * Generates an encrypted serialization of this wallet. * - * This is an advanced alternative of calling `serialize(password)` directly, which allows you to - * offload the KDF execution to an non-UI thread (e.g. in a WebWorker). + * 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 + * 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( diff --git a/packages/sdk38/types/secp256k1wallet.d.ts b/packages/sdk38/types/secp256k1wallet.d.ts index e0020657..3bd3548a 100644 --- a/packages/sdk38/types/secp256k1wallet.d.ts +++ b/packages/sdk38/types/secp256k1wallet.d.ts @@ -8,7 +8,7 @@ import { AccountData, KdfConfiguration, OfflineSigner, PrehashType } from "./wal export interface Secp256k1WalletSerialization { /** A format+version identifier for this serialization format */ readonly type: string; - /** Information about the key derivation function (i.e. password to encrytion key) */ + /** Information about the key derivation function (i.e. password to encryption key) */ readonly kdf: KdfConfiguration; /** Information about the symmetric encryption */ readonly encryption: { @@ -68,7 +68,7 @@ export declare class Secp256k1Wallet implements OfflineSigner { private static deserializeType1; /** Base secret */ private readonly secret; - /** Derivation instrations */ + /** Derivation instruction */ private readonly accounts; /** Derived data */ private readonly pubkey; @@ -88,10 +88,10 @@ export declare class Secp256k1Wallet implements OfflineSigner { /** * Generates an encrypted serialization of this wallet. * - * This is an advanced alternative of calling `serialize(password)` directly, which allows you to - * offload the KDF execution to an non-UI thread (e.g. in a WebWorker). + * 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 + * 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; From d2f854d14059f27cc1498afe46ef360958ab8716 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 14:03:36 +0200 Subject: [PATCH 14/23] Improve readability of wallet code --- packages/sdk38/src/secp256k1wallet.ts | 38 ++++++++++------------- packages/sdk38/types/secp256k1wallet.d.ts | 2 +- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts index 0594dd75..fe58b65b 100644 --- a/packages/sdk38/src/secp256k1wallet.ts +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -82,8 +82,8 @@ export interface Secp256k1WalletData { }>; } -function extractKdfConfigurationV1(document: any): KdfConfiguration { - return document.kdf; +function extractKdfConfigurationV1(doc: any): KdfConfiguration { + return doc.kdf; } export function extractKdfConfiguration(serialization: string): KdfConfiguration { @@ -147,7 +147,7 @@ export class Secp256k1Wallet implements OfflineSigner { if (!isNonNullObject(root)) throw new Error("Root document is not an object."); switch ((root as any).type) { case serializationTypeV1: - return Secp256k1Wallet.deserializeType1(serialization, password); + return Secp256k1Wallet.deserializeTypeV1(serialization, password); default: throw new Error("Unsupported serialization type"); } @@ -187,27 +187,21 @@ export class Secp256k1Wallet implements OfflineSigner { } } - private static async deserializeType1(serialization: string, password: string): Promise { + 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 untypedRoot: any = root; - switch (untypedRoot.type) { - case serializationTypeV1: { - let encryptionKey: Uint8Array; - switch (untypedRoot.kdf.algorithm) { - case "argon2id": { - const kdfOptions = untypedRoot.kdf.params; - encryptionKey = await Argon2id.execute(password, cosmjsSalt, kdfOptions); - break; - } - default: - throw new Error("Unsupported KDF algorithm"); - } - return Secp256k1Wallet.deserializeWithEncryptionKey(serialization, encryptionKey); + let encryptionKey: Uint8Array; + switch (untypedRoot.kdf.algorithm) { + case "argon2id": { + const kdfOptions = untypedRoot.kdf.params; + encryptionKey = await Argon2id.execute(password, cosmjsSalt, kdfOptions); + break; } default: - throw new Error("Unsupported serialization type"); + throw new Error("Unsupported KDF algorithm"); } + return Secp256k1Wallet.deserializeWithEncryptionKey(serialization, encryptionKey); } /** Base secret */ @@ -298,7 +292,7 @@ export class Secp256k1Wallet implements OfflineSigner { encryptionKey: Uint8Array, kdfConfiguration: KdfConfiguration, ): Promise { - const encrytedData: Secp256k1WalletData = { + const dataToEncrypt: Secp256k1WalletData = { mnemonic: this.mnemonic, accounts: this.accounts.map((account) => ({ algo: account.algo, @@ -306,9 +300,9 @@ export class Secp256k1Wallet implements OfflineSigner { prefix: account.prefix, })), }; - const message = toUtf8(JSON.stringify(encrytedData)); + const dataToEncryptRaw = toUtf8(JSON.stringify(dataToEncrypt)); const nonce = Random.getBytes(xchacha20NonceLength); - const encrypted = await Xchacha20poly1305Ietf.encrypt(message, encryptionKey, nonce); + const encryptedData = await Xchacha20poly1305Ietf.encrypt(dataToEncryptRaw, encryptionKey, nonce); const out: Secp256k1WalletSerialization = { type: serializationTypeV1, @@ -319,7 +313,7 @@ export class Secp256k1Wallet implements OfflineSigner { nonce: toHex(nonce), }, }, - data: toBase64(encrypted), + data: toBase64(encryptedData), }; return JSON.stringify(out); } diff --git a/packages/sdk38/types/secp256k1wallet.d.ts b/packages/sdk38/types/secp256k1wallet.d.ts index 3bd3548a..bc63c5ce 100644 --- a/packages/sdk38/types/secp256k1wallet.d.ts +++ b/packages/sdk38/types/secp256k1wallet.d.ts @@ -65,7 +65,7 @@ export declare class Secp256k1Wallet implements OfflineSigner { serialization: string, encryptionKey: Uint8Array, ): Promise; - private static deserializeType1; + private static deserializeTypeV1; /** Base secret */ private readonly secret; /** Derivation instruction */ From 97c936e71fdd4bfd4da0566d6e7683d3c4b5b010 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 14:41:32 +0200 Subject: [PATCH 15/23] Add types Secp256k1Derivation and Secp256k1DerivationJson --- packages/sdk38/src/secp256k1wallet.ts | 60 +++++++++++++---------- packages/sdk38/types/secp256k1wallet.d.ts | 15 ++++-- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts index fe58b65b..59bbcef6 100644 --- a/packages/sdk38/src/secp256k1wallet.ts +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -20,7 +20,6 @@ import { encodeSecp256k1Signature } from "./signature"; import { StdSignature } from "./types"; import { AccountData, - Algo, cosmjsSalt, executeKdf, KdfConfiguration, @@ -69,17 +68,29 @@ export interface Secp256k1WalletSerialization { 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: ReadonlyArray<{ - readonly algo: string; - readonly hdPath: string; - readonly prefix: string; - }>; + readonly accounts: readonly Secp256k1DerivationJson[]; } function extractKdfConfigurationV1(doc: any): KdfConfiguration { @@ -98,6 +109,14 @@ export function extractKdfConfiguration(serialization: string): KdfConfiguration } } +/** + * 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. @@ -174,13 +193,8 @@ export class Secp256k1Wallet implements OfflineSigner { 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 (!isNonNullObject(account)) throw new Error("Account is not an object."); - const { algo, hdPath, prefix } = account as any; - assert(algo === "secp256k1"); - assert(typeof hdPath === "string"); - assert(typeof prefix === "string"); - - return Secp256k1Wallet.fromMnemonic(mnemonic, stringToPath(hdPath), prefix); + 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"); @@ -207,11 +221,7 @@ export class Secp256k1Wallet implements OfflineSigner { /** Base secret */ private readonly secret: EnglishMnemonic; /** Derivation instruction */ - private readonly accounts: ReadonlyArray<{ - readonly algo: Algo; - readonly hdPath: readonly Slip10RawIndex[]; - readonly prefix: string; - }>; + private readonly accounts: readonly Secp256k1Derivation[]; /** Derived data */ private readonly pubkey: Uint8Array; private readonly privkey: Uint8Array; @@ -226,7 +236,6 @@ export class Secp256k1Wallet implements OfflineSigner { this.secret = mnemonic; this.accounts = [ { - algo: "secp256k1", hdPath: hdPath, prefix: prefix, }, @@ -246,8 +255,8 @@ export class Secp256k1Wallet implements OfflineSigner { public async getAccounts(): Promise { return [ { + algo: "secp256k1", address: this.address, - algo: this.accounts[0].algo, pubkey: this.pubkey, }, ]; @@ -294,11 +303,12 @@ export class Secp256k1Wallet implements OfflineSigner { ): Promise { const dataToEncrypt: Secp256k1WalletData = { mnemonic: this.mnemonic, - accounts: this.accounts.map((account) => ({ - algo: account.algo, - hdPath: pathToString(account.hdPath), - prefix: account.prefix, - })), + accounts: this.accounts.map( + (account): Secp256k1DerivationJson => ({ + hdPath: pathToString(account.hdPath), + prefix: account.prefix, + }), + ), }; const dataToEncryptRaw = toUtf8(JSON.stringify(dataToEncrypt)); const nonce = Random.getBytes(xchacha20NonceLength); diff --git a/packages/sdk38/types/secp256k1wallet.d.ts b/packages/sdk38/types/secp256k1wallet.d.ts index bc63c5ce..32cffb3f 100644 --- a/packages/sdk38/types/secp256k1wallet.d.ts +++ b/packages/sdk38/types/secp256k1wallet.d.ts @@ -22,17 +22,21 @@ export interface Secp256k1WalletSerialization { /** 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: ReadonlyArray<{ - readonly algo: string; - readonly hdPath: string; - readonly prefix: string; - }>; + readonly accounts: readonly Secp256k1DerivationJson[]; } export declare function extractKdfConfiguration(serialization: string): KdfConfiguration; export declare class Secp256k1Wallet implements OfflineSigner { @@ -96,3 +100,4 @@ export declare class Secp256k1Wallet implements OfflineSigner { */ serializeWithEncryptionKey(encryptionKey: Uint8Array, kdfConfiguration: KdfConfiguration): Promise; } +export {}; From 35cf3e89db32457baec739d6ba94aafbcf8307fc Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 14:43:24 +0200 Subject: [PATCH 16/23] Deduplicate KDF execution --- packages/sdk38/src/secp256k1wallet.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts index 59bbcef6..3e48a07c 100644 --- a/packages/sdk38/src/secp256k1wallet.ts +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -1,5 +1,4 @@ import { - Argon2id, Bip39, EnglishMnemonic, pathToString, @@ -20,7 +19,6 @@ import { encodeSecp256k1Signature } from "./signature"; import { StdSignature } from "./types"; import { AccountData, - cosmjsSalt, executeKdf, KdfConfiguration, makeCosmoshubPath, @@ -204,17 +202,7 @@ export class Secp256k1Wallet implements OfflineSigner { 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 untypedRoot: any = root; - let encryptionKey: Uint8Array; - switch (untypedRoot.kdf.algorithm) { - case "argon2id": { - const kdfOptions = untypedRoot.kdf.params; - encryptionKey = await Argon2id.execute(password, cosmjsSalt, kdfOptions); - break; - } - default: - throw new Error("Unsupported KDF algorithm"); - } + const encryptionKey = await executeKdf(password, (root as any).kdf); return Secp256k1Wallet.deserializeWithEncryptionKey(serialization, encryptionKey); } From f108045d610f4cbf96bd8bde7e4551acb6a206cc Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 14:53:07 +0200 Subject: [PATCH 17/23] Make serialization tests stricter --- packages/sdk38/src/secp256k1wallet.spec.ts | 56 ++++++++++------------ 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/packages/sdk38/src/secp256k1wallet.spec.ts b/packages/sdk38/src/secp256k1wallet.spec.ts index feb4de62..82a61e9d 100644 --- a/packages/sdk38/src/secp256k1wallet.spec.ts +++ b/packages/sdk38/src/secp256k1wallet.spec.ts @@ -125,26 +125,24 @@ describe("Secp256k1Wallet", () => { it("can save with password", async () => { const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); const serialized = await wallet.serialize("123"); - expect(JSON.parse(serialized)).toEqual( - jasmine.objectContaining({ - type: "secp256k1wallet-v1", - kdf: { - algorithm: "argon2id", - params: { - outputLength: 32, - opsLimit: 20, - memLimitKib: 12 * 1024, - }, + expect(JSON.parse(serialized)).toEqual({ + type: "secp256k1wallet-v1", + kdf: { + algorithm: "argon2id", + params: { + outputLength: 32, + opsLimit: 20, + memLimitKib: 12 * 1024, }, - encryption: { - algorithm: "xchacha20poly1305-ietf", - params: { - nonce: jasmine.stringMatching(hexMatcher), - }, + }, + encryption: { + algorithm: "xchacha20poly1305-ietf", + params: { + nonce: jasmine.stringMatching(hexMatcher), }, - data: jasmine.stringMatching(base64Matcher), - }), - ); + }, + data: jasmine.stringMatching(base64Matcher), + }); }); }); @@ -162,19 +160,17 @@ describe("Secp256k1Wallet", () => { }, }; const serialized = await wallet.serializeWithEncryptionKey(key, customKdfConfiguration); - expect(JSON.parse(serialized)).toEqual( - jasmine.objectContaining({ - type: "secp256k1wallet-v1", - kdf: customKdfConfiguration, - encryption: { - algorithm: "xchacha20poly1305-ietf", - params: { - nonce: jasmine.stringMatching(hexMatcher), - }, + expect(JSON.parse(serialized)).toEqual({ + type: "secp256k1wallet-v1", + kdf: customKdfConfiguration, + encryption: { + algorithm: "xchacha20poly1305-ietf", + params: { + nonce: jasmine.stringMatching(hexMatcher), }, - data: jasmine.stringMatching(base64Matcher), - }), - ); + }, + data: jasmine.stringMatching(base64Matcher), + }); }); }); }); From 8fc6023b8a235488c610e14f944541debbf583e3 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 15:24:38 +0200 Subject: [PATCH 18/23] Move encryption/decryption to waller.ts --- packages/sdk38/src/secp256k1wallet.ts | 38 ++++++--------- packages/sdk38/src/wallet.ts | 58 ++++++++++++++++++++++- packages/sdk38/types/secp256k1wallet.d.ts | 11 +---- packages/sdk38/types/wallet.d.ts | 25 ++++++++++ 4 files changed, 98 insertions(+), 34 deletions(-) diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts index 3e48a07c..45c26924 100644 --- a/packages/sdk38/src/secp256k1wallet.ts +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -9,9 +9,8 @@ import { Slip10RawIndex, stringToPath, xchacha20NonceLength, - Xchacha20poly1305Ietf, } from "@cosmjs/crypto"; -import { fromBase64, fromHex, fromUtf8, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; +import { fromBase64, fromUtf8, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; import { assert, isNonNullObject } from "@cosmjs/utils"; import { rawSecp256k1PubkeyToAddress } from "./address"; @@ -19,12 +18,16 @@ 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"; @@ -42,8 +45,6 @@ const basicPasswordHashingOptions: KdfConfiguration = { }, }; -const algorithmIdXchacha20poly1305Ietf = "xchacha20poly1305-ietf"; - /** * This interface describes a JSON object holding the encrypted wallet and the meta data. * All fields in here must be JSON types. @@ -54,14 +55,7 @@ export interface Secp256k1WalletSerialization { /** Information about the key derivation function (i.e. password to encryption key) */ readonly kdf: KdfConfiguration; /** Information about the symmetric encryption */ - readonly encryption: { - /** - * An algorithm identifier, such as "xchacha20poly1305-ietf". - */ - readonly algorithm: string; - /** A map of algorithm-specific parameters */ - readonly params: Record; - }; + readonly encryption: EncryptionConfiguration; /** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ readonly data: string; } @@ -179,11 +173,10 @@ export class Secp256k1Wallet implements OfflineSigner { const untypedRoot: any = root; switch (untypedRoot.type) { case serializationTypeV1: { - const nonce = fromHex(untypedRoot.encryption.params.nonce); - const decryptedBytes = await Xchacha20poly1305Ietf.decrypt( + const decryptedBytes = await decrypt( fromBase64(untypedRoot.data), encryptionKey, - nonce, + untypedRoot.encryption, ); const decryptedDocument = JSON.parse(fromUtf8(decryptedBytes)); const { mnemonic, accounts } = decryptedDocument; @@ -299,18 +292,17 @@ export class Secp256k1Wallet implements OfflineSigner { ), }; const dataToEncryptRaw = toUtf8(JSON.stringify(dataToEncrypt)); - const nonce = Random.getBytes(xchacha20NonceLength); - const encryptedData = await Xchacha20poly1305Ietf.encrypt(dataToEncryptRaw, encryptionKey, nonce); + + const encryptionConfiguration: EncryptionConfiguration = { + algorithm: supportedAlgorithms.xchacha20poly1305Ietf, + params: { nonce: toHex(Random.getBytes(xchacha20NonceLength)) }, + }; + const encryptedData = await encrypt(dataToEncryptRaw, encryptionKey, encryptionConfiguration); const out: Secp256k1WalletSerialization = { type: serializationTypeV1, kdf: kdfConfiguration, - encryption: { - algorithm: algorithmIdXchacha20poly1305Ietf, - params: { - nonce: toHex(nonce), - }, - }, + encryption: encryptionConfiguration, data: toBase64(encryptedData), }; return JSON.stringify(out); diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index 7378636b..ff5496a7 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -1,5 +1,12 @@ -import { Argon2id, Argon2idOptions, Sha256, Sha512, Slip10RawIndex } from "@cosmjs/crypto"; -import { toAscii } from "@cosmjs/encoding"; +import { + Argon2id, + Argon2idOptions, + Sha256, + Sha512, + Slip10RawIndex, + Xchacha20poly1305Ietf, +} from "@cosmjs/crypto"; +import { fromHex, toAscii } from "@cosmjs/encoding"; import { assert } from "@cosmjs/utils"; import { StdSignature } from "./types"; @@ -84,3 +91,50 @@ export async function executeKdf(password: string, configuration: KdfConfigurati 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 = fromHex((config.params as any).nonce); + return 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 = fromHex((config.params as any).nonce); + return Xchacha20poly1305Ietf.decrypt(ciphertext, encryptionKey, nonce); + } + default: + throw new Error(`Unsupported encryption algorithm: '${config.algorithm}'`); + } +} diff --git a/packages/sdk38/types/secp256k1wallet.d.ts b/packages/sdk38/types/secp256k1wallet.d.ts index 32cffb3f..af84a682 100644 --- a/packages/sdk38/types/secp256k1wallet.d.ts +++ b/packages/sdk38/types/secp256k1wallet.d.ts @@ -1,6 +1,6 @@ import { Slip10RawIndex } from "@cosmjs/crypto"; import { StdSignature } from "./types"; -import { AccountData, KdfConfiguration, OfflineSigner, PrehashType } from "./wallet"; +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. @@ -11,14 +11,7 @@ export interface Secp256k1WalletSerialization { /** Information about the key derivation function (i.e. password to encryption key) */ readonly kdf: KdfConfiguration; /** Information about the symmetric encryption */ - readonly encryption: { - /** - * An algorithm identifier, such as "xchacha20poly1305-ietf". - */ - readonly algorithm: string; - /** A map of algorithm-specific parameters */ - readonly params: Record; - }; + readonly encryption: EncryptionConfiguration; /** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */ readonly data: string; } diff --git a/packages/sdk38/types/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index 8075062c..f2edb92d 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -38,3 +38,28 @@ export interface KdfConfiguration { 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; From a75be1406c057d540eed339dd0100e3ba3a3245b Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 15:33:38 +0200 Subject: [PATCH 19/23] Monce nonce from params to ciphertext --- packages/sdk38/src/secp256k1wallet.spec.ts | 8 +------- packages/sdk38/src/secp256k1wallet.ts | 4 +--- packages/sdk38/src/wallet.ts | 18 ++++++++++++------ packages/sdk38/types/wallet.d.ts | 2 +- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/sdk38/src/secp256k1wallet.spec.ts b/packages/sdk38/src/secp256k1wallet.spec.ts index 82a61e9d..e509e2e0 100644 --- a/packages/sdk38/src/secp256k1wallet.spec.ts +++ b/packages/sdk38/src/secp256k1wallet.spec.ts @@ -2,7 +2,7 @@ import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding"; import { extractKdfConfiguration, Secp256k1Wallet } from "./secp256k1wallet"; -import { base64Matcher, hexMatcher } from "./testutils.spec"; +import { base64Matcher } from "./testutils.spec"; import { executeKdf, KdfConfiguration } from "./wallet"; describe("Secp256k1Wallet", () => { @@ -137,9 +137,6 @@ describe("Secp256k1Wallet", () => { }, encryption: { algorithm: "xchacha20poly1305-ietf", - params: { - nonce: jasmine.stringMatching(hexMatcher), - }, }, data: jasmine.stringMatching(base64Matcher), }); @@ -165,9 +162,6 @@ describe("Secp256k1Wallet", () => { kdf: customKdfConfiguration, encryption: { algorithm: "xchacha20poly1305-ietf", - params: { - nonce: jasmine.stringMatching(hexMatcher), - }, }, data: jasmine.stringMatching(base64Matcher), }); diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts index 45c26924..65f09745 100644 --- a/packages/sdk38/src/secp256k1wallet.ts +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -8,9 +8,8 @@ import { Slip10Curve, Slip10RawIndex, stringToPath, - xchacha20NonceLength, } from "@cosmjs/crypto"; -import { fromBase64, fromUtf8, toBase64, toHex, toUtf8 } from "@cosmjs/encoding"; +import { fromBase64, fromUtf8, toBase64, toUtf8 } from "@cosmjs/encoding"; import { assert, isNonNullObject } from "@cosmjs/utils"; import { rawSecp256k1PubkeyToAddress } from "./address"; @@ -295,7 +294,6 @@ export class Secp256k1Wallet implements OfflineSigner { const encryptionConfiguration: EncryptionConfiguration = { algorithm: supportedAlgorithms.xchacha20poly1305Ietf, - params: { nonce: toHex(Random.getBytes(xchacha20NonceLength)) }, }; const encryptedData = await encrypt(dataToEncryptRaw, encryptionKey, encryptionConfiguration); diff --git a/packages/sdk38/src/wallet.ts b/packages/sdk38/src/wallet.ts index ff5496a7..0b7bba0a 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -1,12 +1,14 @@ import { Argon2id, Argon2idOptions, + Random, Sha256, Sha512, Slip10RawIndex, + xchacha20NonceLength, Xchacha20poly1305Ietf, } from "@cosmjs/crypto"; -import { fromHex, toAscii } from "@cosmjs/encoding"; +import { toAscii } from "@cosmjs/encoding"; import { assert } from "@cosmjs/utils"; import { StdSignature } from "./types"; @@ -102,7 +104,7 @@ export interface EncryptionConfiguration { */ readonly algorithm: string; /** A map of algorithm-specific parameters */ - readonly params: Record; + readonly params?: Record; } export const supportedAlgorithms = { @@ -116,8 +118,12 @@ export async function encrypt( ): Promise { switch (config.algorithm) { case supportedAlgorithms.xchacha20poly1305Ietf: { - const nonce = fromHex((config.params as any).nonce); - return Xchacha20poly1305Ietf.encrypt(plaintext, encryptionKey, nonce); + 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}'`); @@ -131,8 +137,8 @@ export async function decrypt( ): Promise { switch (config.algorithm) { case supportedAlgorithms.xchacha20poly1305Ietf: { - const nonce = fromHex((config.params as any).nonce); - return Xchacha20poly1305Ietf.decrypt(ciphertext, encryptionKey, nonce); + 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/wallet.d.ts b/packages/sdk38/types/wallet.d.ts index f2edb92d..c0818ef2 100644 --- a/packages/sdk38/types/wallet.d.ts +++ b/packages/sdk38/types/wallet.d.ts @@ -48,7 +48,7 @@ export interface EncryptionConfiguration { */ readonly algorithm: string; /** A map of algorithm-specific parameters */ - readonly params: Record; + readonly params?: Record; } export declare const supportedAlgorithms: { xchacha20poly1305Ietf: string; From ef7d171a6427649036f6da417a295f5949fc3a6e Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 16:15:28 +0200 Subject: [PATCH 20/23] Improve documentation of Secp256k1Wallet.serialize/deserialize --- packages/sdk38/src/index.ts | 4 ++-- packages/sdk38/src/secp256k1wallet.ts | 15 +++++++++++++++ packages/sdk38/types/index.d.ts | 4 ++-- packages/sdk38/types/secp256k1wallet.d.ts | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/sdk38/src/index.ts b/packages/sdk38/src/index.ts index 78cbf494..44f477b7 100644 --- a/packages/sdk38/src/index.ts +++ b/packages/sdk38/src/index.ts @@ -85,5 +85,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, makeCosmoshubPath } from "./wallet"; -export { Secp256k1Wallet } from "./secp256k1wallet"; +export { OfflineSigner, makeCosmoshubPath, executeKdf, KdfConfiguration } from "./wallet"; +export { extractKdfConfiguration, Secp256k1Wallet } from "./secp256k1wallet"; diff --git a/packages/sdk38/src/secp256k1wallet.ts b/packages/sdk38/src/secp256k1wallet.ts index 65f09745..6c393cfa 100644 --- a/packages/sdk38/src/secp256k1wallet.ts +++ b/packages/sdk38/src/secp256k1wallet.ts @@ -152,6 +152,12 @@ export class Secp256k1Wallet implements OfflineSigner { 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."); @@ -163,6 +169,15 @@ export class Secp256k1Wallet implements OfflineSigner { } } + /** + * 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, diff --git a/packages/sdk38/types/index.d.ts b/packages/sdk38/types/index.d.ts index 5e37414e..af666c9a 100644 --- a/packages/sdk38/types/index.d.ts +++ b/packages/sdk38/types/index.d.ts @@ -83,5 +83,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, makeCosmoshubPath } from "./wallet"; -export { Secp256k1Wallet } from "./secp256k1wallet"; +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 index af84a682..06e9510c 100644 --- a/packages/sdk38/types/secp256k1wallet.d.ts +++ b/packages/sdk38/types/secp256k1wallet.d.ts @@ -57,7 +57,22 @@ export declare class Secp256k1Wallet implements OfflineSigner { 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, From a1b86ade81fc5d4a06c5c3a828d8156dd9533f63 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Thu, 23 Jul 2020 16:21:40 +0200 Subject: [PATCH 21/23] Extract isArgon2idOptions --- packages/crypto/src/index.ts | 1 + packages/crypto/src/libsodium.ts | 9 +++++++++ packages/crypto/types/index.d.ts | 1 + packages/crypto/types/libsodium.d.ts | 1 + packages/sdk38/src/wallet.ts | 10 +++------- 5 files changed, 15 insertions(+), 7 deletions(-) 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 a68274cc..0776a0c6 100644 --- a/packages/crypto/src/libsodium.ts +++ b/packages/crypto/src/libsodium.ts @@ -3,6 +3,7 @@ // // libsodium.js API: https://gist.github.com/webmaster128/b2dbe6d54d36dd168c9fabf441b9b09c +import { isNonNullObject } from "@cosmjs/utils"; import sodium from "libsodium-wrappers"; export interface Argon2idOptions { @@ -24,6 +25,14 @@ export interface Argon2idOptions { 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 00437296..fbddb7f3 100644 --- a/packages/crypto/types/libsodium.d.ts +++ b/packages/crypto/types/libsodium.d.ts @@ -16,6 +16,7 @@ export interface Argon2idOptions { */ 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/wallet.ts b/packages/sdk38/src/wallet.ts index 0b7bba0a..f8e4b1ab 100644 --- a/packages/sdk38/src/wallet.ts +++ b/packages/sdk38/src/wallet.ts @@ -1,6 +1,6 @@ import { Argon2id, - Argon2idOptions, + isArgon2idOptions, Random, Sha256, Sha512, @@ -9,7 +9,6 @@ import { Xchacha20poly1305Ietf, } from "@cosmjs/crypto"; import { toAscii } from "@cosmjs/encoding"; -import { assert } from "@cosmjs/utils"; import { StdSignature } from "./types"; @@ -82,11 +81,8 @@ export interface KdfConfiguration { export async function executeKdf(password: string, configuration: KdfConfiguration): Promise { switch (configuration.algorithm) { case "argon2id": { - const { outputLength, opsLimit, memLimitKib } = configuration.params; - assert(typeof outputLength === "number"); - assert(typeof opsLimit === "number"); - assert(typeof memLimitKib === "number"); - const options: Argon2idOptions = { outputLength, opsLimit, memLimitKib }; + const options = configuration.params; + if (!isArgon2idOptions(options)) throw new Error("Invalid format of argon2id params"); return Argon2id.execute(password, cosmjsSalt, options); } default: From 2e4ed65f1a90a2aa3bbf765049def1fd3d900819 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 28 Jul 2020 13:16:55 +0200 Subject: [PATCH 22/23] Add CHANGELOG entries --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1768da89..6979ee35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,3 +22,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. From f2f3a62c71445c5a63a70bd441b9f335e59311f5 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Tue, 28 Jul 2020 13:10:26 +0200 Subject: [PATCH 23/23] Add .mergify.yml configuration --- .circleci/config.yml | 1 + .mergify.yml | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 .mergify.yml 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