diff --git a/CHANGELOG.md b/CHANGELOG.md index d14b5953..f7670e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## `v0.0.49` + +### Features + +- Added the `verifyArbitrary` helper function (exported by `cosmes/wallet`) to verify signatures signed using `ConnectedWallet.signArbitrary` + +### Fixes + +- Fixed the `recoverPubKeyFromEthSignature` helper function to calculate and use the correct recovery bit when generating the `secp256k1` model + ## `v0.0.48` ### Features diff --git a/package.json b/package.json index 38fdd052..cd2b2b56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cosmes", - "version": "0.0.48", + "version": "0.0.49", "private": false, "packageManager": "pnpm@8.3.0", "sideEffects": false, diff --git a/src/codec/index.ts b/src/codec/index.ts index a04a4f5f..bf79e597 100644 --- a/src/codec/index.ts +++ b/src/codec/index.ts @@ -11,3 +11,4 @@ export { signAmino, signDirect, } from "./sign"; +export { verifyADR36, verifyECDSA, verifyEIP191 } from "./verify"; diff --git a/src/codec/sign.ts b/src/codec/sign.ts index d4eafbf9..117027e9 100644 --- a/src/codec/sign.ts +++ b/src/codec/sign.ts @@ -63,9 +63,14 @@ export function recoverPubKeyFromEthSignature( if (signature.length !== 65) { throw new Error("Invalid signature"); } - const digest = hashEthArbitraryMessage(message); + const r = signature.slice(0, 32); + const s = signature.slice(32, 64); + const v = signature[64]; + // Adapted from https://github.com/ethers-io/ethers.js/blob/6017d3d39a4d428793bddae33d82fd814cacd878/src.ts/crypto/signature.ts#L255-L265 + const yParity = v <= 1 ? v : (v + 1) % 2; const secpSignature = secp256k1.Signature.fromCompact( - Uint8Array.from([...signature.slice(0, 32), ...signature.slice(32, 64)]) - ).addRecoveryBit(1); + Uint8Array.from([...r, ...s]) + ).addRecoveryBit(yParity); + const digest = hashEthArbitraryMessage(message); return secpSignature.recoverPublicKey(digest).toRawBytes(true); } diff --git a/src/codec/verify.test.ts b/src/codec/verify.test.ts new file mode 100644 index 00000000..a2b02984 --- /dev/null +++ b/src/codec/verify.test.ts @@ -0,0 +1,100 @@ +import { base64, utf8 } from "@scure/base"; +import { describe, expect, it } from "vitest"; + +import { verifyADR36, verifyECDSA, verifyEIP191 } from "./verify"; + +const DATA = utf8.decode( + "Hi from CosmeES! This is a test message just to prove that the wallet is working." +); +// Generated using coin type "330" and seed phrase "poverty flat amazing draw goose clay sorry nothing erase switch law intact only invest find memory what weasel fan connect tilt detect trap viable" +const VALID_PUBKEY_1 = base64.decode( + "Ai7ZXTtRWFte/tX7Z6MlKWVd9XA49p3cDNqd61RuKTdT" +); +// Generated using coin type "118" and seed phrase "poverty flat amazing draw goose clay sorry nothing erase switch law intact only invest find memory what weasel fan connect tilt detect trap viable" +const VALID_PUBKEY_2 = base64.decode( + "A8i9vMNUGcTtUgpbmiZqcFtsIrPZ6n8ZYN4/PVRlQvGr" +); +// Generated using coin type "60" and seed phrase "poverty flat amazing draw goose clay sorry nothing erase switch law intact only invest find memory what weasel fan connect tilt detect trap viable" +const VALID_PUBKEY_3 = base64.decode( + "AmGjuPKUsuIAuGgJ3xH7KGWlSU9cwVnsesrwWwyYLbMg" +); + +describe("verifyECDSA", () => { + it("should verify correctly", () => { + // Signed using Station wallet on Terra + const signature = base64.decode( + "Od87qNoOyXuDOVdLCGTXB6dFN7U0XF9Oegc8KDa+AWwX3jkrDXG++2nlPfsF4VJzlDHsoikPeZpxrB7v9PINnw==" + ); + const res1 = verifyECDSA({ + pubKey: VALID_PUBKEY_1, + data: DATA, + signature, + }); + expect(res1).toBe(true); + + // Different pub key + const res2 = verifyECDSA({ + pubKey: VALID_PUBKEY_2, + data: DATA, + signature, + }); + expect(res2).toBe(false); + }); +}); + +describe("verifyADR36", () => { + it("should verify correctly", () => { + // Signed using Keplr wallet on Osmosis + const signature = base64.decode( + "nvlcV0x0Ge8ADXLSAQGtfMw6EJkOfpmkDxgP7UI79uR8MhnAOp9T+e+ofgW9kY4bEIr0yhyBG+vSVAZRv9uCxA==" + ); + const res1 = verifyADR36({ + bech32Prefix: "osmo", + pubKey: VALID_PUBKEY_2, + data: DATA, + signature, + }); + expect(res1).toBe(true); + + // Different bech32 prefix + const res2 = verifyADR36({ + bech32Prefix: "terra", + pubKey: VALID_PUBKEY_2, + data: DATA, + signature, + }); + expect(res2).toBe(false); + + // Different pub key + const res3 = verifyADR36({ + bech32Prefix: "osmo", + pubKey: VALID_PUBKEY_1, + data: DATA, + signature, + }); + expect(res3).toBe(false); + }); +}); + +describe("verifyEIP191", () => { + it("should verify correctly", () => { + // Signed using MetaMask wallet on Injective + const signature = base64.decode( + "MpriWY0Kq7C+/jR3eOfNB5vUQM144tQk7KkzKyYCTFB5QHGLZjzJyeOSr8/ENFES0k+aaEF47Wepk7OHoZuLzxs=" + ); + const res1 = verifyEIP191({ + pubKey: VALID_PUBKEY_3, + data: DATA, + signature, + }); + expect(res1).toBe(true); + + // Different pub key + const res2 = verifyEIP191({ + pubKey: VALID_PUBKEY_2, + data: DATA, + signature, + }); + expect(res2).toBe(false); + }); +}); diff --git a/src/codec/verify.ts b/src/codec/verify.ts new file mode 100644 index 00000000..51d7d9e8 --- /dev/null +++ b/src/codec/verify.ts @@ -0,0 +1,70 @@ +import { sha256 } from "@noble/hashes/sha256"; +import * as secp256k1 from "@noble/secp256k1"; +import { base64 } from "@scure/base"; + +import { resolveBech32Address } from "./address"; +import { serialiseSignDoc } from "./serialise"; +import { recoverPubKeyFromEthSignature } from "./sign"; + +type VerifyArbitraryParams = { + /** The public key which created the signature */ + pubKey: Uint8Array; + /** The bech32 account address prefix of the signer */ + bech32Prefix: string; + /** The arbitrary bytes that was signed */ + data: Uint8Array; + /** The signature bytes */ + signature: Uint8Array; +}; + +export function verifyECDSA({ + pubKey, + data, + signature, +}: Omit): boolean { + return secp256k1.verify(signature, sha256(data), pubKey); +} + +export function verifyADR36({ + pubKey, + bech32Prefix, + data, + signature, +}: VerifyArbitraryParams): boolean { + const msg = serialiseSignDoc({ + chain_id: "", + account_number: "0", + sequence: "0", + fee: { + gas: "0", + amount: [], + }, + msgs: [ + { + type: "sign/MsgSignData", + value: { + signer: resolveBech32Address(pubKey, bech32Prefix), + data: base64.encode(data), + }, + }, + ], + memo: "", + }); + return verifyECDSA({ + pubKey, + data: msg, + signature, + }); +} + +export function verifyEIP191({ + pubKey, + data, + signature, +}: Omit): boolean { + const recoveredPubKey = recoverPubKeyFromEthSignature(data, signature); + return ( + pubKey.length === recoveredPubKey.length && + pubKey.every((v, i) => v === recoveredPubKey[i]) + ); +} diff --git a/src/wallet/index.ts b/src/wallet/index.ts index e4b60d46..6b5d2d10 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -1,6 +1,7 @@ export { WalletName } from "./constants/WalletName"; export { WalletType } from "./constants/WalletType"; export { isAndroid, isIOS, isMobile } from "./utils/os"; +export { verifyArbitrary } from "./utils/verify"; export { ConnectedWallet, type PollTxOptions, diff --git a/src/wallet/utils/verify.ts b/src/wallet/utils/verify.ts new file mode 100644 index 00000000..ee494884 --- /dev/null +++ b/src/wallet/utils/verify.ts @@ -0,0 +1,63 @@ +import { + base64, + utf8, + verifyADR36, + verifyECDSA, + verifyEIP191, +} from "cosmes/codec"; + +import { WalletName } from "../constants/WalletName"; + +type VerifyArbitraryParams = { + /** The identifier of the wallet which created the signature */ + wallet: WalletName; + /** The base64 encoded public key which created the signature */ + pubKey: string; + /** The bech32 account address prefix of the signer */ + bech32Prefix: string; + /** The utf-8 encoded arbitrary string that was signed */ + data: string; + /** The base64 encoded string of the signature */ + signature: string; +}; + +/** + * Verifies the signature output of a valid call to `ConnectedWallet.signArbitrary`. + * Returns `true` if and only if the signature is valid. + * + * @param wallet The identifier of the wallet which created the signature + * @param pubKey The base64 encoded public key which created the signature + * @param bech32Prefix The bech32 account address prefix of the signer + * @param data The utf-8 encoded arbitrary string that was signed + * @param signature The base64 encoded string of the signature + */ +export function verifyArbitrary({ + wallet, + pubKey, + bech32Prefix, + data, + signature, +}: VerifyArbitraryParams): boolean { + const params = { + wallet, + pubKey: base64.decode(pubKey), + bech32Prefix, + data: utf8.decode(data), + signature: utf8.decode(signature), + }; + try { + switch (wallet) { + case WalletName.STATION: + return verifyECDSA(params); + case WalletName.COMPASS: + case WalletName.COSMOSTATION: + case WalletName.KEPLR: + case WalletName.LEAP: + return verifyADR36(params); + case WalletName.METAMASK_INJECTIVE: + return verifyEIP191(params); + } + } catch (err) { + return false; + } +}