Merge pull request #310 from CosmWasm/secure-wallet

Add wallet serialization/deserialization
This commit is contained in:
Simon Warta 2020-07-28 13:31:07 +02:00 committed by GitHub
commit 63cbc42f7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 802 additions and 133 deletions

View File

@ -2,6 +2,7 @@ version: 2
workflows:
version: 2
workflow:
# Keep those job names in sync with .mergify.yml
jobs:
- build
- docs-build

19
.mergify.yml Normal file
View File

@ -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

View File

@ -29,3 +29,8 @@
abstraction between `SigningCosmosClient.sendTokens` and `.postTx`.
- @cosmjs/sdk38: Export `PostTxFailure`/`PostTxSuccess` and type checkers
`isPostTxFailure`/`isPostTxSuccess`; export `assertIsPostTxSuccess`.
- @cosmjs/sdk38: `Secp256k1Wallet`s can now be generated randomly with
`Secp256k1Wallet.generate(n)` where `n` is 12, 15, 18, 21 or 24 mnemonic
words.
- @cosmjs/sdk38: The new `Secp256k1Wallet.serialize` and `.deserialize` allow
encrypted serialization of the wallet.

View File

@ -8,6 +8,7 @@ export {
xchacha20NonceLength,
Argon2id,
Argon2idOptions,
isArgon2idOptions,
Ed25519,
Ed25519Keypair,
} from "./libsodium";

View File

@ -3,18 +3,36 @@
//
// libsodium.js API: https://gist.github.com/webmaster128/b2dbe6d54d36dd168c9fabf441b9b09c
import { isNonNullObject } from "@cosmjs/utils";
import sodium from "libsodium-wrappers";
export interface Argon2idOptions {
// in bytes
/** Output length in bytes */
readonly outputLength: number;
// integer between 1 and 4294967295
/**
* An integer between 1 and 4294967295 representing the computational difficulty.
*
* @see https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation
*/
readonly opsLimit: number;
// memory limit measured in KiB (like argon2 command line tool)
// Note: only ~ 16 MiB of memory are available using the non-sumo version of libsodium
/**
* Memory limit measured in KiB (like argon2 command line tool)
*
* Note: only approximately 16 MiB of memory are available using the non-sumo version of libsodium.js
*
* @see https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation
*/
readonly memLimitKib: number;
}
export function isArgon2idOptions(thing: unknown): thing is Argon2idOptions {
if (!isNonNullObject(thing)) return false;
if (typeof (thing as Argon2idOptions).outputLength !== "number") return false;
if (typeof (thing as Argon2idOptions).opsLimit !== "number") return false;
if (typeof (thing as Argon2idOptions).memLimitKib !== "number") return false;
return true;
}
export class Argon2id {
public static async execute(
password: string,

View File

@ -8,6 +8,7 @@ export {
xchacha20NonceLength,
Argon2id,
Argon2idOptions,
isArgon2idOptions,
Ed25519,
Ed25519Keypair,
} from "./libsodium";

View File

@ -1,8 +1,22 @@
export interface Argon2idOptions {
/** Output length in bytes */
readonly outputLength: number;
/**
* An integer between 1 and 4294967295 representing the computational difficulty.
*
* @see https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation
*/
readonly opsLimit: number;
/**
* Memory limit measured in KiB (like argon2 command line tool)
*
* Note: only approximately 16 MiB of memory are available using the non-sumo version of libsodium.js
*
* @see https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation
*/
readonly memLimitKib: number;
}
export declare function isArgon2idOptions(thing: unknown): thing is Argon2idOptions;
export declare class Argon2id {
static execute(password: string, salt: Uint8Array, options: Argon2idOptions): Promise<Uint8Array>;
}

View File

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

View File

@ -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

View File

@ -89,4 +89,5 @@ export { findSequenceForSignedTx } from "./sequence";
export { encodeSecp256k1Signature, decodeSignature } from "./signature";
export { FeeTable, SigningCosmosClient } from "./signingcosmosclient";
export { isStdTx, pubkeyType, CosmosSdkTx, PubKey, StdFee, StdSignature, StdTx } from "./types";
export { OfflineSigner, Secp256k1Wallet, makeCosmoshubPath } from "./wallet";
export { OfflineSigner, makeCosmoshubPath, executeKdf, KdfConfiguration } from "./wallet";
export { extractKdfConfiguration, Secp256k1Wallet } from "./secp256k1wallet";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -0,0 +1,170 @@
import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto";
import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding";
import { extractKdfConfiguration, Secp256k1Wallet } from "./secp256k1wallet";
import { base64Matcher } from "./testutils.spec";
import { executeKdf, KdfConfiguration } from "./wallet";
describe("Secp256k1Wallet", () => {
// m/44'/118'/0'/0/0
// pubkey: 02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6
const defaultMnemonic = "special sign fit simple patrol salute grocery chicken wheat radar tonight ceiling";
const defaultPubkey = fromHex("02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6");
const defaultAddress = "cosmos1jhg0e7s6gn44tfc5k37kr04sznyhedtc9rzys5";
describe("fromMnemonic", () => {
it("works", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
expect(wallet).toBeTruthy();
expect(wallet.mnemonic).toEqual(defaultMnemonic);
});
});
describe("generate", () => {
it("defaults to 12 words", async () => {
const wallet = await Secp256k1Wallet.generate();
expect(wallet.mnemonic.split(" ").length).toEqual(12);
});
it("can use different mnemonic lengths", async () => {
expect((await Secp256k1Wallet.generate(12)).mnemonic.split(" ").length).toEqual(12);
expect((await Secp256k1Wallet.generate(15)).mnemonic.split(" ").length).toEqual(15);
expect((await Secp256k1Wallet.generate(18)).mnemonic.split(" ").length).toEqual(18);
expect((await Secp256k1Wallet.generate(21)).mnemonic.split(" ").length).toEqual(21);
expect((await Secp256k1Wallet.generate(24)).mnemonic.split(" ").length).toEqual(24);
});
});
describe("deserialize", () => {
it("can restore", async () => {
const original = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
const password = "123";
const serialized = await original.serialize(password);
const deserialized = await Secp256k1Wallet.deserialize(serialized, password);
expect(deserialized.mnemonic).toEqual(defaultMnemonic);
expect(await deserialized.getAccounts()).toEqual([
{
algo: "secp256k1",
address: defaultAddress,
pubkey: defaultPubkey,
},
]);
});
});
describe("deserializeWithEncryptionKey", () => {
it("can restore", async () => {
const password = "123";
let serialized: string;
{
const original = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
const anyKdfParams: KdfConfiguration = {
algorithm: "argon2id",
params: {
outputLength: 32,
opsLimit: 4,
memLimitKib: 3 * 1024,
},
};
const encryptionKey = await executeKdf(password, anyKdfParams);
serialized = await original.serializeWithEncryptionKey(encryptionKey, anyKdfParams);
}
{
const kdfConfiguration = extractKdfConfiguration(serialized);
const encryptionKey = await executeKdf(password, kdfConfiguration);
const deserialized = await Secp256k1Wallet.deserializeWithEncryptionKey(serialized, encryptionKey);
expect(deserialized.mnemonic).toEqual(defaultMnemonic);
expect(await deserialized.getAccounts()).toEqual([
{
algo: "secp256k1",
address: defaultAddress,
pubkey: defaultPubkey,
},
]);
}
});
});
describe("getAccounts", () => {
it("resolves to a list of accounts if enabled", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
const accounts = await wallet.getAccounts();
expect(accounts.length).toEqual(1);
expect(accounts[0]).toEqual({
address: defaultAddress,
algo: "secp256k1",
pubkey: defaultPubkey,
});
});
it("creates the same address as Go implementation", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(
"oyster design unusual machine spread century engine gravity focus cave carry slot",
);
const [{ address }] = await wallet.getAccounts();
expect(address).toEqual("cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u");
});
});
describe("sign", () => {
it("resolves to valid signature if enabled", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
const message = toAscii("foo bar");
const signature = await wallet.sign(defaultAddress, message);
const valid = await Secp256k1.verifySignature(
Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)),
new Sha256(message).digest(),
defaultPubkey,
);
expect(valid).toEqual(true);
});
});
describe("serialize", () => {
it("can save with password", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
const serialized = await wallet.serialize("123");
expect(JSON.parse(serialized)).toEqual({
type: "secp256k1wallet-v1",
kdf: {
algorithm: "argon2id",
params: {
outputLength: 32,
opsLimit: 20,
memLimitKib: 12 * 1024,
},
},
encryption: {
algorithm: "xchacha20poly1305-ietf",
},
data: jasmine.stringMatching(base64Matcher),
});
});
});
describe("serializeWithEncryptionKey", () => {
it("can save with password", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
const key = fromHex("aabb221100aabb332211aabb33221100aabb221100aabb332211aabb33221100");
const customKdfConfiguration: KdfConfiguration = {
algorithm: "argon2id",
params: {
outputLength: 32,
opsLimit: 321,
memLimitKib: 11 * 1024,
},
};
const serialized = await wallet.serializeWithEncryptionKey(key, customKdfConfiguration);
expect(JSON.parse(serialized)).toEqual({
type: "secp256k1wallet-v1",
kdf: customKdfConfiguration,
encryption: {
algorithm: "xchacha20poly1305-ietf",
},
data: jasmine.stringMatching(base64Matcher),
});
});
});
});

View File

@ -0,0 +1,323 @@
import {
Bip39,
EnglishMnemonic,
pathToString,
Random,
Secp256k1,
Slip10,
Slip10Curve,
Slip10RawIndex,
stringToPath,
} from "@cosmjs/crypto";
import { fromBase64, fromUtf8, toBase64, toUtf8 } from "@cosmjs/encoding";
import { assert, isNonNullObject } from "@cosmjs/utils";
import { rawSecp256k1PubkeyToAddress } from "./address";
import { encodeSecp256k1Signature } from "./signature";
import { StdSignature } from "./types";
import {
AccountData,
decrypt,
encrypt,
EncryptionConfiguration,
executeKdf,
KdfConfiguration,
makeCosmoshubPath,
OfflineSigner,
prehash,
PrehashType,
supportedAlgorithms,
} from "./wallet";
const serializationTypeV1 = "secp256k1wallet-v1";
/**
* A KDF configuration that is not very strong but can be used on the main thread.
* It takes about 1 second in Node.js 12.15 and should have similar runtimes in other modern Wasm hosts.
*/
const basicPasswordHashingOptions: KdfConfiguration = {
algorithm: "argon2id",
params: {
outputLength: 32,
opsLimit: 20,
memLimitKib: 12 * 1024,
},
};
/**
* This interface describes a JSON object holding the encrypted wallet and the meta data.
* All fields in here must be JSON types.
*/
export interface Secp256k1WalletSerialization {
/** A format+version identifier for this serialization format */
readonly type: string;
/** Information about the key derivation function (i.e. password to encryption key) */
readonly kdf: KdfConfiguration;
/** Information about the symmetric encryption */
readonly encryption: EncryptionConfiguration;
/** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */
readonly data: string;
}
/**
* Derivation information required to derive a keypair and an address from a mnemonic.
* All fields in here must be JSON types.
*/
interface Secp256k1DerivationJson {
readonly hdPath: string;
readonly prefix: string;
}
function isSecp256k1DerivationJson(thing: unknown): thing is Secp256k1DerivationJson {
if (!isNonNullObject(thing)) return false;
if (typeof (thing as Secp256k1DerivationJson).hdPath !== "string") return false;
if (typeof (thing as Secp256k1DerivationJson).prefix !== "string") return false;
return true;
}
/**
* The data of a wallet serialization that is encrypted.
* All fields in here must be JSON types.
*/
export interface Secp256k1WalletData {
readonly mnemonic: string;
readonly accounts: readonly Secp256k1DerivationJson[];
}
function extractKdfConfigurationV1(doc: any): KdfConfiguration {
return doc.kdf;
}
export function extractKdfConfiguration(serialization: string): KdfConfiguration {
const root = JSON.parse(serialization);
if (!isNonNullObject(root)) throw new Error("Root document is not an object.");
switch ((root as any).type) {
case serializationTypeV1:
return extractKdfConfigurationV1(root);
default:
throw new Error("Unsupported serialization type");
}
}
/**
* Derivation information required to derive a keypair and an address from a mnemonic.
*/
interface Secp256k1Derivation {
readonly hdPath: readonly Slip10RawIndex[];
readonly prefix: string;
}
export class Secp256k1Wallet implements OfflineSigner {
/**
* Restores a wallet from the given BIP39 mnemonic.
*
* @param mnemonic Any valid English mnemonic.
* @param hdPath The BIP-32/SLIP-10 derivation path. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`.
* @param prefix The bech32 address prefix (human readable part). Defaults to "cosmos".
*/
public static async fromMnemonic(
mnemonic: string,
hdPath: readonly Slip10RawIndex[] = makeCosmoshubPath(0),
prefix = "cosmos",
): Promise<Secp256k1Wallet> {
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<Secp256k1Wallet> {
const entropyLength = 4 * Math.floor((11 * length) / 33);
const entropy = Random.getBytes(entropyLength);
const mnemonic = Bip39.encode(entropy);
return Secp256k1Wallet.fromMnemonic(mnemonic.toString(), hdPath, prefix);
}
/**
* Restores a wallet from an encrypted serialization.
*
* @param password The user provided password used to generate an encryption key via a KDF.
* This is not normalized internally (see "Unicode normalization" to learn more).
*/
public static async deserialize(serialization: string, password: string): Promise<Secp256k1Wallet> {
const root = JSON.parse(serialization);
if (!isNonNullObject(root)) throw new Error("Root document is not an object.");
switch ((root as any).type) {
case serializationTypeV1:
return Secp256k1Wallet.deserializeTypeV1(serialization, password);
default:
throw new Error("Unsupported serialization type");
}
}
/**
* Restores a wallet from an encrypted serialization.
*
* This is an advanced alternative to calling `deserialize(serialization, password)` directly, which allows
* you to offload the KDF execution to a non-UI thread (e.g. in a WebWorker).
*
* The caller is responsible for ensuring the key was derived with the given KDF configuration. This can be
* done using `extractKdfConfiguration(serialization)` and `executeKdf(password, kdfConfiguration)` from this package.
*/
public static async deserializeWithEncryptionKey(
serialization: string,
encryptionKey: Uint8Array,
): Promise<Secp256k1Wallet> {
const root = JSON.parse(serialization);
if (!isNonNullObject(root)) throw new Error("Root document is not an object.");
const untypedRoot: any = root;
switch (untypedRoot.type) {
case serializationTypeV1: {
const decryptedBytes = await decrypt(
fromBase64(untypedRoot.data),
encryptionKey,
untypedRoot.encryption,
);
const decryptedDocument = JSON.parse(fromUtf8(decryptedBytes));
const { mnemonic, accounts } = decryptedDocument;
assert(typeof mnemonic === "string");
if (!Array.isArray(accounts)) throw new Error("Property 'accounts' is not an array");
if (accounts.length !== 1) throw new Error("Property 'accounts' only supports one entry");
const account = accounts[0];
if (!isSecp256k1DerivationJson(account)) throw new Error("Account is not in the correct format.");
return Secp256k1Wallet.fromMnemonic(mnemonic, stringToPath(account.hdPath), account.prefix);
}
default:
throw new Error("Unsupported serialization type");
}
}
private static async deserializeTypeV1(serialization: string, password: string): Promise<Secp256k1Wallet> {
const root = JSON.parse(serialization);
if (!isNonNullObject(root)) throw new Error("Root document is not an object.");
const encryptionKey = await executeKdf(password, (root as any).kdf);
return Secp256k1Wallet.deserializeWithEncryptionKey(serialization, encryptionKey);
}
/** Base secret */
private readonly secret: EnglishMnemonic;
/** Derivation instruction */
private readonly accounts: readonly Secp256k1Derivation[];
/** Derived data */
private readonly pubkey: Uint8Array;
private readonly privkey: Uint8Array;
private constructor(
mnemonic: EnglishMnemonic,
hdPath: readonly Slip10RawIndex[],
privkey: Uint8Array,
pubkey: Uint8Array,
prefix: string,
) {
this.secret = mnemonic;
this.accounts = [
{
hdPath: hdPath,
prefix: prefix,
},
];
this.privkey = privkey;
this.pubkey = pubkey;
}
public get mnemonic(): string {
return this.secret.toString();
}
private get address(): string {
return rawSecp256k1PubkeyToAddress(this.pubkey, this.accounts[0].prefix);
}
public async getAccounts(): Promise<readonly AccountData[]> {
return [
{
algo: "secp256k1",
address: this.address,
pubkey: this.pubkey,
},
];
}
public async sign(
address: string,
message: Uint8Array,
prehashType: PrehashType = "sha256",
): Promise<StdSignature> {
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<string> {
const kdfConfiguration = basicPasswordHashingOptions;
const encryptionKey = await executeKdf(password, kdfConfiguration);
return this.serializeWithEncryptionKey(encryptionKey, kdfConfiguration);
}
/**
* Generates an encrypted serialization of this wallet.
*
* This is an advanced alternative to calling `serialize(password)` directly, which allows you to
* offload the KDF execution to a non-UI thread (e.g. in a WebWorker).
*
* The caller is responsible for ensuring the key was derived with the given KDF options. If this
* is not the case, the wallet cannot be restored with the original password.
*/
public async serializeWithEncryptionKey(
encryptionKey: Uint8Array,
kdfConfiguration: KdfConfiguration,
): Promise<string> {
const dataToEncrypt: Secp256k1WalletData = {
mnemonic: this.mnemonic,
accounts: this.accounts.map(
(account): Secp256k1DerivationJson => ({
hdPath: pathToString(account.hdPath),
prefix: account.prefix,
}),
),
};
const dataToEncryptRaw = toUtf8(JSON.stringify(dataToEncrypt));
const encryptionConfiguration: EncryptionConfiguration = {
algorithm: supportedAlgorithms.xchacha20poly1305Ietf,
};
const encryptedData = await encrypt(dataToEncryptRaw, encryptionKey, encryptionConfiguration);
const out: Secp256k1WalletSerialization = {
type: serializationTypeV1,
kdf: kdfConfiguration,
encryption: encryptionConfiguration,
data: toBase64(encryptedData),
};
return JSON.stringify(out);
}
}

View File

@ -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";

View File

@ -19,6 +19,9 @@ export const tendermintAddressMatcher = /^[0-9A-F]{40}$/;
export const tendermintShortHashMatcher = /^[0-9a-f]{40}$/;
export const dateTimeStampMatcher = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/;
export const semverMatcher = /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
/** @see https://rgxdb.com/r/1NUN74O6 */
export const base64Matcher = /^(?:[a-zA-Z0-9+/]{4})*(?:|(?:[a-zA-Z0-9+/]{3}=)|(?:[a-zA-Z0-9+/]{2}==)|(?:[a-zA-Z0-9+/]{1}===))$/;
export const hexMatcher = /^([0-9a-fA-F][0-9a-fA-F])*$/;
// https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
export const bech32AddressMatcher = /^[\x21-\x7e]{1,83}1[02-9ac-hj-np-z]{38}$/;

View File

@ -1,52 +0,0 @@
import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto";
import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding";
import { Secp256k1Wallet } from "./wallet";
describe("Secp256k1Wallet", () => {
// m/44'/118'/0'/0/0
// pubkey: 02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6
const defaultMnemonic = "special sign fit simple patrol salute grocery chicken wheat radar tonight ceiling";
const defaultPubkey = fromHex("02baa4ef93f2ce84592a49b1d729c074eab640112522a7a89f7d03ebab21ded7b6");
const defaultAddress = "cosmos1jhg0e7s6gn44tfc5k37kr04sznyhedtc9rzys5";
it("can be constructed", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
expect(wallet).toBeTruthy();
});
describe("getAccounts", () => {
it("resolves to a list of accounts if enabled", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
const accounts = await wallet.getAccounts();
expect(accounts.length).toEqual(1);
expect(accounts[0]).toEqual({
address: defaultAddress,
algo: "secp256k1",
pubkey: defaultPubkey,
});
});
it("creates the same address as Go implementation", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(
"oyster design unusual machine spread century engine gravity focus cave carry slot",
);
const [{ address }] = await wallet.getAccounts();
expect(address).toEqual("cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u");
});
});
describe("sign", () => {
it("resolves to valid signature if enabled", async () => {
const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic);
const message = toAscii("foo bar");
const signature = await wallet.sign(defaultAddress, message);
const valid = await Secp256k1.verifySignature(
Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)),
new Sha256(message).digest(),
defaultPubkey,
);
expect(valid).toEqual(true);
});
});
});

View File

@ -1,16 +1,15 @@
import {
Bip39,
EnglishMnemonic,
Secp256k1,
Argon2id,
isArgon2idOptions,
Random,
Sha256,
Sha512,
Slip10,
Slip10Curve,
Slip10RawIndex,
xchacha20NonceLength,
Xchacha20poly1305Ietf,
} from "@cosmjs/crypto";
import { toAscii } from "@cosmjs/encoding";
import { rawSecp256k1PubkeyToAddress } from "./address";
import { encodeSecp256k1Signature } from "./signature";
import { StdSignature } from "./types";
export type PrehashType = "sha256" | "sha512" | null;
@ -36,7 +35,7 @@ export interface OfflineSigner {
readonly sign: (address: string, message: Uint8Array, prehashType?: PrehashType) => Promise<StdSignature>;
}
function prehash(bytes: Uint8Array, type: PrehashType): Uint8Array {
export function prehash(bytes: Uint8Array, type: PrehashType): Uint8Array {
switch (type) {
case null:
return new Uint8Array([...bytes]);
@ -63,54 +62,81 @@ export function makeCosmoshubPath(a: number): readonly Slip10RawIndex[] {
];
}
export class Secp256k1Wallet implements OfflineSigner {
public static async fromMnemonic(
mnemonic: string,
hdPath: readonly Slip10RawIndex[] = makeCosmoshubPath(0),
prefix = "cosmos",
): Promise<Secp256k1Wallet> {
const seed = await Bip39.mnemonicToSeed(new EnglishMnemonic(mnemonic));
const { privkey } = Slip10.derivePath(Slip10Curve.Secp256k1, seed, hdPath);
const uncompressed = (await Secp256k1.makeKeypair(privkey)).pubkey;
return new Secp256k1Wallet(privkey, Secp256k1.compressPubkey(uncompressed), prefix);
}
/**
* A fixed salt is chosen to archive a deterministic password to key derivation.
* This reduces the scope of a potential rainbow attack to all CosmJS users.
* Must be 16 bytes due to implementation limitations.
*/
export const cosmjsSalt = toAscii("The CosmJS salt.");
private readonly pubkey: Uint8Array;
private readonly privkey: Uint8Array;
private readonly prefix: string;
private readonly algo: Algo = "secp256k1";
export interface KdfConfiguration {
/**
* An algorithm identifier, such as "argon2id" or "scrypt".
*/
readonly algorithm: string;
/** A map of algorithm-specific parameters */
readonly params: Record<string, unknown>;
}
private constructor(privkey: Uint8Array, pubkey: Uint8Array, prefix: string) {
this.privkey = privkey;
this.pubkey = pubkey;
this.prefix = prefix;
}
private get address(): string {
return rawSecp256k1PubkeyToAddress(this.pubkey, this.prefix);
}
public async getAccounts(): Promise<readonly AccountData[]> {
return [
{
address: this.address,
algo: this.algo,
pubkey: this.pubkey,
},
];
}
public async sign(
address: string,
message: Uint8Array,
prehashType: PrehashType = "sha256",
): Promise<StdSignature> {
if (address !== this.address) {
throw new Error(`Address ${address} not found in wallet`);
export async function executeKdf(password: string, configuration: KdfConfiguration): Promise<Uint8Array> {
switch (configuration.algorithm) {
case "argon2id": {
const options = configuration.params;
if (!isArgon2idOptions(options)) throw new Error("Invalid format of argon2id params");
return Argon2id.execute(password, cosmjsSalt, options);
}
const hashedMessage = prehash(message, prehashType);
const signature = await Secp256k1.createSignature(hashedMessage, this.privkey);
const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]);
return encodeSecp256k1Signature(this.pubkey, signatureBytes);
default:
throw new Error("Unsupported KDF algorithm");
}
}
/**
* Configuration how to encrypt data or how data was encrypted.
* This is stored as part of the wallet serialization and must only contain JSON types.
*/
export interface EncryptionConfiguration {
/**
* An algorithm identifier, such as "xchacha20poly1305-ietf".
*/
readonly algorithm: string;
/** A map of algorithm-specific parameters */
readonly params?: Record<string, unknown>;
}
export const supportedAlgorithms = {
xchacha20poly1305Ietf: "xchacha20poly1305-ietf",
};
export async function encrypt(
plaintext: Uint8Array,
encryptionKey: Uint8Array,
config: EncryptionConfiguration,
): Promise<Uint8Array> {
switch (config.algorithm) {
case supportedAlgorithms.xchacha20poly1305Ietf: {
const nonce = Random.getBytes(xchacha20NonceLength);
// Prepend fixed-length nonce to ciphertext as suggested in the example from https://github.com/jedisct1/libsodium.js#api
return new Uint8Array([
...nonce,
...(await Xchacha20poly1305Ietf.encrypt(plaintext, encryptionKey, nonce)),
]);
}
default:
throw new Error(`Unsupported encryption algorithm: '${config.algorithm}'`);
}
}
export async function decrypt(
ciphertext: Uint8Array,
encryptionKey: Uint8Array,
config: EncryptionConfiguration,
): Promise<Uint8Array> {
switch (config.algorithm) {
case supportedAlgorithms.xchacha20poly1305Ietf: {
const nonce = ciphertext.slice(0, xchacha20NonceLength);
return Xchacha20poly1305Ietf.decrypt(ciphertext.slice(xchacha20NonceLength), encryptionKey, nonce);
}
default:
throw new Error(`Unsupported encryption algorithm: '${config.algorithm}'`);
}
}

View File

@ -87,4 +87,5 @@ export { findSequenceForSignedTx } from "./sequence";
export { encodeSecp256k1Signature, decodeSignature } from "./signature";
export { FeeTable, SigningCosmosClient } from "./signingcosmosclient";
export { isStdTx, pubkeyType, CosmosSdkTx, PubKey, StdFee, StdSignature, StdTx } from "./types";
export { OfflineSigner, Secp256k1Wallet, makeCosmoshubPath } from "./wallet";
export { OfflineSigner, makeCosmoshubPath, executeKdf, KdfConfiguration } from "./wallet";
export { extractKdfConfiguration, Secp256k1Wallet } from "./secp256k1wallet";

View File

@ -0,0 +1,111 @@
import { Slip10RawIndex } from "@cosmjs/crypto";
import { StdSignature } from "./types";
import { AccountData, EncryptionConfiguration, KdfConfiguration, OfflineSigner, PrehashType } from "./wallet";
/**
* This interface describes a JSON object holding the encrypted wallet and the meta data.
* All fields in here must be JSON types.
*/
export interface Secp256k1WalletSerialization {
/** A format+version identifier for this serialization format */
readonly type: string;
/** Information about the key derivation function (i.e. password to encryption key) */
readonly kdf: KdfConfiguration;
/** Information about the symmetric encryption */
readonly encryption: EncryptionConfiguration;
/** An instance of Secp256k1WalletData, which is stringified, encrypted and base64 encoded. */
readonly data: string;
}
/**
* Derivation information required to derive a keypair and an address from a mnemonic.
* All fields in here must be JSON types.
*/
interface Secp256k1DerivationJson {
readonly hdPath: string;
readonly prefix: string;
}
/**
* The data of a wallet serialization that is encrypted.
* All fields in here must be JSON types.
*/
export interface Secp256k1WalletData {
readonly mnemonic: string;
readonly accounts: readonly Secp256k1DerivationJson[];
}
export declare function extractKdfConfiguration(serialization: string): KdfConfiguration;
export declare class Secp256k1Wallet implements OfflineSigner {
/**
* Restores a wallet from the given BIP39 mnemonic.
*
* @param mnemonic Any valid English mnemonic.
* @param hdPath The BIP-32/SLIP-10 derivation path. Defaults to the Cosmos Hub/ATOM path `m/44'/118'/0'/0/0`.
* @param prefix The bech32 address prefix (human readable part). Defaults to "cosmos".
*/
static fromMnemonic(
mnemonic: string,
hdPath?: readonly Slip10RawIndex[],
prefix?: string,
): Promise<Secp256k1Wallet>;
/**
* 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<Secp256k1Wallet>;
/**
* 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<Secp256k1Wallet>;
/**
* Restores a wallet from an encrypted serialization.
*
* This is an advanced alternative to calling `deserialize(serialization, password)` directly, which allows
* you to offload the KDF execution to a non-UI thread (e.g. in a WebWorker).
*
* The caller is responsible for ensuring the key was derived with the given KDF configuration. This can be
* done using `extractKdfConfiguration(serialization)` and `executeKdf(password, kdfConfiguration)` from this package.
*/
static deserializeWithEncryptionKey(
serialization: string,
encryptionKey: Uint8Array,
): Promise<Secp256k1Wallet>;
private static deserializeTypeV1;
/** Base secret */
private readonly secret;
/** Derivation instruction */
private readonly accounts;
/** Derived data */
private readonly pubkey;
private readonly privkey;
private constructor();
get mnemonic(): string;
private get address();
getAccounts(): Promise<readonly AccountData[]>;
sign(address: string, message: Uint8Array, prehashType?: PrehashType): Promise<StdSignature>;
/**
* 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<string>;
/**
* Generates an encrypted serialization of this wallet.
*
* This is an advanced alternative to calling `serialize(password)` directly, which allows you to
* offload the KDF execution to a non-UI thread (e.g. in a WebWorker).
*
* The caller is responsible for ensuring the key was derived with the given KDF options. If this
* is not the case, the wallet cannot be restored with the original password.
*/
serializeWithEncryptionKey(encryptionKey: Uint8Array, kdfConfiguration: KdfConfiguration): Promise<string>;
}
export {};

View File

@ -17,23 +17,49 @@ export interface OfflineSigner {
*/
readonly sign: (address: string, message: Uint8Array, prehashType?: PrehashType) => Promise<StdSignature>;
}
export declare function prehash(bytes: Uint8Array, type: PrehashType): Uint8Array;
/**
* The Cosmoshub derivation path in the form `m/44'/118'/0'/0/a`
* with 0-based account index `a`.
*/
export declare function makeCosmoshubPath(a: number): readonly Slip10RawIndex[];
export declare class Secp256k1Wallet implements OfflineSigner {
static fromMnemonic(
mnemonic: string,
hdPath?: readonly Slip10RawIndex[],
prefix?: string,
): Promise<Secp256k1Wallet>;
private readonly pubkey;
private readonly privkey;
private readonly prefix;
private readonly algo;
private constructor();
private get address();
getAccounts(): Promise<readonly AccountData[]>;
sign(address: string, message: Uint8Array, prehashType?: PrehashType): Promise<StdSignature>;
/**
* A fixed salt is chosen to archive a deterministic password to key derivation.
* This reduces the scope of a potential rainbow attack to all CosmJS users.
* Must be 16 bytes due to implementation limitations.
*/
export declare const cosmjsSalt: Uint8Array;
export interface KdfConfiguration {
/**
* An algorithm identifier, such as "argon2id" or "scrypt".
*/
readonly algorithm: string;
/** A map of algorithm-specific parameters */
readonly params: Record<string, unknown>;
}
export declare function executeKdf(password: string, configuration: KdfConfiguration): Promise<Uint8Array>;
/**
* 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<string, unknown>;
}
export declare const supportedAlgorithms: {
xchacha20poly1305Ietf: string;
};
export declare function encrypt(
plaintext: Uint8Array,
encryptionKey: Uint8Array,
config: EncryptionConfiguration,
): Promise<Uint8Array>;
export declare function decrypt(
ciphertext: Uint8Array,
encryptionKey: Uint8Array,
config: EncryptionConfiguration,
): Promise<Uint8Array>;