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 */