From db1f183247535adfd4663169b52668b26365164a Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 23 Sep 2020 15:12:20 +0200 Subject: [PATCH 1/2] Create DirectSecp256k1Wallet --- packages/proto-signing/package.json | 2 +- .../src/directsecp256k1wallet.spec.ts | 70 ++++++++++ .../src/directsecp256k1wallet.ts | 125 ++++++++++++++++++ packages/proto-signing/src/index.ts | 1 + packages/proto-signing/src/signing.spec.ts | 6 +- packages/proto-signing/src/testutils.spec.ts | 2 + .../types/directsecp256k1wallet.d.ts | 37 ++++++ packages/proto-signing/types/index.d.ts | 1 + .../src/stargateclient.searchtx.spec.ts | 8 +- packages/stargate/src/stargateclient.spec.ts | 5 +- 10 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 packages/proto-signing/src/directsecp256k1wallet.spec.ts create mode 100644 packages/proto-signing/src/directsecp256k1wallet.ts create mode 100644 packages/proto-signing/src/testutils.spec.ts create mode 100644 packages/proto-signing/types/directsecp256k1wallet.d.ts diff --git a/packages/proto-signing/package.json b/packages/proto-signing/package.json index 447e4587..e2613da6 100644 --- a/packages/proto-signing/package.json +++ b/packages/proto-signing/package.json @@ -46,12 +46,12 @@ "postdefine-proto": "prettier --write \"src/codec/generated/codecimpl.*\"" }, "dependencies": { + "@cosmjs/launchpad": "^0.22.3", "long": "^4.0.0", "protobufjs": "~6.10.0" }, "devDependencies": { "@cosmjs/encoding": "^0.22.3", - "@cosmjs/launchpad": "^0.22.3", "@cosmjs/utils": "^0.22.3" } } diff --git a/packages/proto-signing/src/directsecp256k1wallet.spec.ts b/packages/proto-signing/src/directsecp256k1wallet.spec.ts new file mode 100644 index 00000000..9f87b6d7 --- /dev/null +++ b/packages/proto-signing/src/directsecp256k1wallet.spec.ts @@ -0,0 +1,70 @@ +import { Secp256k1, Secp256k1Signature, Sha256 } from "@cosmjs/crypto"; +import { fromBase64, fromHex, toAscii } from "@cosmjs/encoding"; + +import { DirectSecp256k1Wallet } from "./directsecp256k1wallet"; + +describe("DirectSecp256k1Wallet", () => { + // 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 DirectSecp256k1Wallet.fromMnemonic(defaultMnemonic); + expect(wallet).toBeTruthy(); + expect(wallet.mnemonic).toEqual(defaultMnemonic); + }); + }); + + describe("generate", () => { + it("defaults to 12 words", async () => { + const wallet = await DirectSecp256k1Wallet.generate(); + expect(wallet.mnemonic.split(" ").length).toEqual(12); + }); + + it("can use different mnemonic lengths", async () => { + expect((await DirectSecp256k1Wallet.generate(12)).mnemonic.split(" ").length).toEqual(12); + expect((await DirectSecp256k1Wallet.generate(15)).mnemonic.split(" ").length).toEqual(15); + expect((await DirectSecp256k1Wallet.generate(18)).mnemonic.split(" ").length).toEqual(18); + expect((await DirectSecp256k1Wallet.generate(21)).mnemonic.split(" ").length).toEqual(21); + expect((await DirectSecp256k1Wallet.generate(24)).mnemonic.split(" ").length).toEqual(24); + }); + }); + + describe("getAccounts", () => { + it("resolves to a list of accounts if enabled", async () => { + const wallet = await DirectSecp256k1Wallet.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 DirectSecp256k1Wallet.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 DirectSecp256k1Wallet.fromMnemonic(defaultMnemonic); + const message = toAscii("foo bar"); + const signature = await wallet.sign(defaultAddress, message); + const valid = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)), + new Sha256(message).digest(), + defaultPubkey, + ); + expect(valid).toEqual(true); + }); + }); +}); diff --git a/packages/proto-signing/src/directsecp256k1wallet.ts b/packages/proto-signing/src/directsecp256k1wallet.ts new file mode 100644 index 00000000..921b25ec --- /dev/null +++ b/packages/proto-signing/src/directsecp256k1wallet.ts @@ -0,0 +1,125 @@ +import { + Bip39, + EnglishMnemonic, + HdPath, + Random, + Secp256k1, + Sha256, + Slip10, + Slip10Curve, +} from "@cosmjs/crypto"; +import { + AccountData, + encodeSecp256k1Signature, + makeCosmoshubPath, + rawSecp256k1PubkeyToAddress, + StdSignature, +} from "@cosmjs/launchpad"; + +/** + * Derivation information required to derive a keypair and an address from a mnemonic. + */ +interface Secp256k1Derivation { + readonly hdPath: HdPath; + readonly prefix: string; +} + +/** A wallet for protobuf based signing using SIGN_MODE_DIRECT */ +export class DirectSecp256k1Wallet { + /** + * 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: HdPath = 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 DirectSecp256k1Wallet( + 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: HdPath = makeCosmoshubPath(0), + prefix = "cosmos", + ): Promise { + const entropyLength = 4 * Math.floor((11 * length) / 33); + const entropy = Random.getBytes(entropyLength); + const mnemonic = Bip39.encode(entropy); + return DirectSecp256k1Wallet.fromMnemonic(mnemonic.toString(), hdPath, prefix); + } + + /** 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: HdPath, + privkey: Uint8Array, + pubkey: Uint8Array, + prefix: string, + ) { + this.secret = mnemonic; + this.accounts = [ + { + hdPath: hdPath, + prefix: prefix, + }, + ]; + this.privkey = privkey; + this.pubkey = pubkey; + } + + public get mnemonic(): string { + return this.secret.toString(); + } + + private get address(): string { + return rawSecp256k1PubkeyToAddress(this.pubkey, this.accounts[0].prefix); + } + + public async getAccounts(): Promise { + return [ + { + algo: "secp256k1", + address: this.address, + pubkey: this.pubkey, + }, + ]; + } + + public async sign(address: string, message: Uint8Array): Promise { + if (address !== this.address) { + throw new Error(`Address ${address} not found in wallet`); + } + const hashedMessage = new Sha256(message).digest(); + const signature = await Secp256k1.createSignature(hashedMessage, this.privkey); + const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]); + return encodeSecp256k1Signature(this.pubkey, signatureBytes); + } +} diff --git a/packages/proto-signing/src/index.ts b/packages/proto-signing/src/index.ts index 8b2788e7..c76ba594 100644 --- a/packages/proto-signing/src/index.ts +++ b/packages/proto-signing/src/index.ts @@ -1,4 +1,5 @@ export { Coin } from "./msgs"; export { cosmosField } from "./decorator"; export { Registry } from "./registry"; +export { DirectSecp256k1Wallet } from "./directsecp256k1wallet"; export { makeAuthInfo, makeSignBytes } from "./signing"; diff --git a/packages/proto-signing/src/signing.spec.ts b/packages/proto-signing/src/signing.spec.ts index 36508e8d..4de71128 100644 --- a/packages/proto-signing/src/signing.spec.ts +++ b/packages/proto-signing/src/signing.spec.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Bech32, fromBase64, fromHex, toHex } from "@cosmjs/encoding"; -import { Secp256k1Wallet } from "@cosmjs/launchpad"; import { cosmos } from "./codec"; +import { DirectSecp256k1Wallet } from "./directsecp256k1wallet"; import { defaultRegistry } from "./msgs"; import { Registry, TxBodyValue } from "./registry"; import { makeAuthInfo, makeSignBytes } from "./signing"; @@ -69,7 +69,7 @@ describe("signing", () => { const gasLimit = 200000; it("correctly parses test vectors", async () => { - const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); + const wallet = await DirectSecp256k1Wallet.fromMnemonic(faucet.mnemonic); const [{ address, pubkey: pubkeyBytes }] = await wallet.getAccounts(); testVectors.forEach(({ signedTxBytes }) => { @@ -100,7 +100,7 @@ describe("signing", () => { it("correctly generates test vectors", async () => { const myRegistry = new Registry(); - const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); + const wallet = await DirectSecp256k1Wallet.fromMnemonic(faucet.mnemonic); const [{ address, pubkey: pubkeyBytes }] = await wallet.getAccounts(); const publicKey = PublicKey.create({ secp256k1: pubkeyBytes, diff --git a/packages/proto-signing/src/testutils.spec.ts b/packages/proto-signing/src/testutils.spec.ts new file mode 100644 index 00000000..a00d8f8d --- /dev/null +++ b/packages/proto-signing/src/testutils.spec.ts @@ -0,0 +1,2 @@ +/** @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}===))$/; diff --git a/packages/proto-signing/types/directsecp256k1wallet.d.ts b/packages/proto-signing/types/directsecp256k1wallet.d.ts new file mode 100644 index 00000000..0adc32e3 --- /dev/null +++ b/packages/proto-signing/types/directsecp256k1wallet.d.ts @@ -0,0 +1,37 @@ +import { HdPath } from "@cosmjs/crypto"; +import { AccountData, StdSignature } from "@cosmjs/launchpad"; +/** A wallet for protobuf based signing using SIGN_MODE_DIRECT */ +export declare class DirectSecp256k1Wallet { + /** + * 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?: HdPath, 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?: HdPath, + prefix?: string, + ): Promise; + /** Base secret */ + private readonly secret; + /** Derivation instruction */ + private readonly accounts; + /** Derived data */ + private readonly pubkey; + private readonly privkey; + private constructor(); + get mnemonic(): string; + private get address(); + getAccounts(): Promise; + sign(address: string, message: Uint8Array): Promise; +} diff --git a/packages/proto-signing/types/index.d.ts b/packages/proto-signing/types/index.d.ts index 8b2788e7..c76ba594 100644 --- a/packages/proto-signing/types/index.d.ts +++ b/packages/proto-signing/types/index.d.ts @@ -1,4 +1,5 @@ export { Coin } from "./msgs"; export { cosmosField } from "./decorator"; export { Registry } from "./registry"; +export { DirectSecp256k1Wallet } from "./directsecp256k1wallet"; export { makeAuthInfo, makeSignBytes } from "./signing"; diff --git a/packages/stargate/src/stargateclient.searchtx.spec.ts b/packages/stargate/src/stargateclient.searchtx.spec.ts index be7c2893..b3850f30 100644 --- a/packages/stargate/src/stargateclient.searchtx.spec.ts +++ b/packages/stargate/src/stargateclient.searchtx.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Bech32, fromBase64 } from "@cosmjs/encoding"; -import { Coin, coins, Secp256k1Wallet } from "@cosmjs/launchpad"; -import { makeAuthInfo, makeSignBytes, Registry } from "@cosmjs/proto-signing"; +import { Coin, coins } from "@cosmjs/launchpad"; +import { DirectSecp256k1Wallet, makeAuthInfo, makeSignBytes, Registry } from "@cosmjs/proto-signing"; import { assert, sleep } from "@cosmjs/utils"; import { cosmos } from "./codec"; @@ -27,7 +27,7 @@ interface TestTxSend { async function sendTokens( client: StargateClient, registry: Registry, - wallet: Secp256k1Wallet, + wallet: DirectSecp256k1Wallet, recipient: string, amount: readonly Coin[], memo: string, @@ -83,7 +83,7 @@ describe("StargateClient.searchTx", () => { beforeAll(async () => { if (simappEnabled()) { - const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); + const wallet = await DirectSecp256k1Wallet.fromMnemonic(faucet.mnemonic); const client = await StargateClient.connect(simapp.tendermintUrl); const unsuccessfulRecipient = makeRandomAddress(); const successfulRecipient = makeRandomAddress(); diff --git a/packages/stargate/src/stargateclient.spec.ts b/packages/stargate/src/stargateclient.spec.ts index c8682f4f..6005ee4d 100644 --- a/packages/stargate/src/stargateclient.spec.ts +++ b/packages/stargate/src/stargateclient.spec.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Bech32, fromBase64 } from "@cosmjs/encoding"; -import { Secp256k1Wallet } from "@cosmjs/launchpad"; -import { makeAuthInfo, makeSignBytes, Registry } from "@cosmjs/proto-signing"; +import { DirectSecp256k1Wallet, makeAuthInfo, makeSignBytes, Registry } from "@cosmjs/proto-signing"; import { assert, sleep } from "@cosmjs/utils"; import { ReadonlyDate } from "readonly-date"; @@ -252,7 +251,7 @@ describe("StargateClient", () => { it("broadcasts a transaction", async () => { pendingWithoutSimapp(); const client = await StargateClient.connect(simapp.tendermintUrl); - const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); + const wallet = await DirectSecp256k1Wallet.fromMnemonic(faucet.mnemonic); const [{ address, pubkey: pubkeyBytes }] = await wallet.getAccounts(); const publicKey = PublicKey.create({ secp256k1: pubkeyBytes }); const registry = new Registry(); From c0e9f96fa119e9200674f52e23571bab7a5fccf9 Mon Sep 17 00:00:00 2001 From: Simon Warta Date: Wed, 23 Sep 2020 16:17:28 +0200 Subject: [PATCH 2/2] Remove stray "if enabled" from test text --- packages/launchpad/src/secp256k1wallet.spec.ts | 2 +- packages/proto-signing/src/directsecp256k1wallet.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/launchpad/src/secp256k1wallet.spec.ts b/packages/launchpad/src/secp256k1wallet.spec.ts index e509e2e0..a16e8534 100644 --- a/packages/launchpad/src/secp256k1wallet.spec.ts +++ b/packages/launchpad/src/secp256k1wallet.spec.ts @@ -87,7 +87,7 @@ describe("Secp256k1Wallet", () => { }); describe("getAccounts", () => { - it("resolves to a list of accounts if enabled", async () => { + it("resolves to a list of accounts", async () => { const wallet = await Secp256k1Wallet.fromMnemonic(defaultMnemonic); const accounts = await wallet.getAccounts(); expect(accounts.length).toEqual(1); diff --git a/packages/proto-signing/src/directsecp256k1wallet.spec.ts b/packages/proto-signing/src/directsecp256k1wallet.spec.ts index 9f87b6d7..6f0fbcac 100644 --- a/packages/proto-signing/src/directsecp256k1wallet.spec.ts +++ b/packages/proto-signing/src/directsecp256k1wallet.spec.ts @@ -34,7 +34,7 @@ describe("DirectSecp256k1Wallet", () => { }); describe("getAccounts", () => { - it("resolves to a list of accounts if enabled", async () => { + it("resolves to a list of accounts", async () => { const wallet = await DirectSecp256k1Wallet.fromMnemonic(defaultMnemonic); const accounts = await wallet.getAccounts(); expect(accounts.length).toEqual(1);