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; }