diff --git a/cashtab/extension/public/manifest.json b/cashtab/extension/public/manifest.json --- a/cashtab/extension/public/manifest.json +++ b/cashtab/extension/public/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Cashtab", "description": "A browser-integrated eCash wallet from Bitcoin ABC", - "version": "4.18.1", + "version": "4.19.0", "content_scripts": [ { "matches": ["file://*/*", "http://*/*", "https://*/*"], diff --git a/cashtab/package-lock.json b/cashtab/package-lock.json --- a/cashtab/package-lock.json +++ b/cashtab/package-lock.json @@ -1,19 +1,18 @@ { "name": "cashtab", - "version": "3.18.3", + "version": "3.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "3.18.3", + "version": "3.19.0", "dependencies": { "@bitgo/utxo-lib": "^11.0.0", "@zxing/browser": "^0.1.4", "bignumber.js": "^9.1.2", "bip39": "^3.0.2", "bip66": "^1.1.5", - "bitcoinjs-message": "^2.2.0", "chronik-client": "file:../modules/chronik-client", "ecash-agora": "file:../modules/ecash-agora", "ecash-lib": "file:../modules/ecash-lib", @@ -1779,7 +1778,7 @@ } }, "../modules/ecash-agora": { - "version": "2.0.0", + "version": "2.0.1", "license": "MIT", "dependencies": { "chronik-client": "file:../chronik-client", @@ -20739,13 +20738,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bindings": { - "version": "1.5.0", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bip174": { "name": "@bitgo-forks/bip174", "version": "3.1.0-master.4", @@ -20821,25 +20813,6 @@ "bs58check": "<3.0.0" } }, - "node_modules/bitcoinjs-message": { - "version": "2.2.0", - "license": "MIT", - "dependencies": { - "bech32": "^1.1.3", - "bs58check": "^2.1.2", - "buffer-equals": "^1.0.3", - "create-hash": "^1.1.2", - "secp256k1": "^3.0.1", - "varuint-bitcoin": "^1.0.1" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/bitcoinjs-message/node_modules/bech32": { - "version": "1.1.4", - "license": "MIT" - }, "node_modules/bluebird": { "version": "3.7.2", "dev": true, @@ -20936,6 +20909,7 @@ }, "node_modules/browserify-aes": { "version": "1.2.0", + "dev": true, "license": "MIT", "dependencies": { "buffer-xor": "^1.0.3", @@ -21086,13 +21060,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/buffer-equals": { - "version": "1.0.4", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "dev": true, @@ -21100,6 +21067,7 @@ }, "node_modules/buffer-xor": { "version": "1.0.3", + "dev": true, "license": "MIT" }, "node_modules/builtin-modules": { @@ -22525,18 +22493,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/drbg.js": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "browserify-aes": "^1.0.6", - "create-hash": "^1.1.2", - "create-hmac": "^1.1.4" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -23192,6 +23148,7 @@ }, "node_modules/evp_bytestokey": { "version": "1.0.3", + "dev": true, "license": "MIT", "dependencies": { "md5.js": "^1.3.4", @@ -23441,10 +23398,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/filelist": { "version": "1.0.4", "dev": true, @@ -26893,10 +26846,6 @@ "thenify-all": "^1.0.0" } }, - "node_modules/nan": { - "version": "2.22.0", - "license": "MIT" - }, "node_modules/nanoassert": { "version": "2.0.0", "license": "ISC" @@ -29872,28 +29821,6 @@ "dev": true, "license": "MIT" }, - "node_modules/secp256k1": { - "version": "3.8.1", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "bip66": "^1.1.5", - "bn.js": "^4.11.8", - "create-hash": "^1.2.0", - "drbg.js": "^1.0.1", - "elliptic": "^6.5.7", - "nan": "^2.14.0", - "safe-buffer": "^5.1.2" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/secp256k1/node_modules/bn.js": { - "version": "4.12.0", - "license": "MIT" - }, "node_modules/select-hose": { "version": "2.0.0", "dev": true, diff --git a/cashtab/package.json b/cashtab/package.json --- a/cashtab/package.json +++ b/cashtab/package.json @@ -1,6 +1,6 @@ { "name": "cashtab", - "version": "3.18.3", + "version": "3.19.0", "private": true, "scripts": { "start": "node scripts/start.js", @@ -31,7 +31,6 @@ "bignumber.js": "^9.1.2", "bip39": "^3.0.2", "bip66": "^1.1.5", - "bitcoinjs-message": "^2.2.0", "chronik-client": "file:../modules/chronik-client", "ecash-agora": "file:../modules/ecash-agora", "ecash-lib": "file:../modules/ecash-lib", diff --git a/cashtab/src/components/SignVerifyMsg/SignVerifyMsg.js b/cashtab/src/components/SignVerifyMsg/SignVerifyMsg.js --- a/cashtab/src/components/SignVerifyMsg/SignVerifyMsg.js +++ b/cashtab/src/components/SignVerifyMsg/SignVerifyMsg.js @@ -9,15 +9,13 @@ import { WalletContext } from 'wallet/context'; import CopyToClipboard from 'components/Common/CopyToClipboard'; import PrimaryButton, { SecondaryButton } from 'components/Common/Buttons'; -import xecMessage from 'bitcoinjs-message'; -import * as utxolib from '@bitgo/utxo-lib'; import { isValidCashAddress } from 'ecashaddrjs'; import { toast } from 'react-toastify'; import { theme } from 'assets/styles/theme'; import appConfig from 'config/app'; import { PageHeader } from 'components/Common/Atoms'; import { ThemedSignAndVerifyMsg } from 'components/Common/CustomIcons'; -import { Address } from 'ecash-lib'; +import { signMsg, verifyMsg } from 'ecash-lib'; const SignVerifyForm = styled.div` display: flex; @@ -76,29 +74,17 @@ const [signMsgMode, setSignMsgMode] = useState(true); const [messageSignature, setMessageSignature] = useState(''); - const signMsg = () => { + const handleUserSignature = () => { // We get the msgToSign from formData in state const { msgToSign } = formData; // Wrap signing in try...catch to handle any errors try { - // First, get required params - const keyPair = utxolib.ECPair.fromWIF( - wallet.paths.get(1899).wif, - utxolib.networks.ecash, - ); - - // Now you can get the local signature - const messageSignature = xecMessage - .sign( - msgToSign, - keyPair.__D, - keyPair.compressed, - utxolib.networks.ecash.messagePrefix, - ) - .toString('base64'); + // sign with path 1899 sk + const sk = wallet.paths.get(1899).sk; + const signature = signMsg(msgToSign, sk); - setMessageSignature(messageSignature); + setMessageSignature(signature); toast.success('Message Signed'); } catch (err) { toast.error(`${err}`); @@ -150,13 +136,10 @@ const verifyMessage = () => { let verification; try { - verification = xecMessage.verify( + verification = verifyMsg( formData.msgToVerify, - Address.fromCashAddress(formData.addressToVerify) - .legacy() - .toString(), formData.signatureToVerify, - utxolib.networks.ecash.messagePrefix, + formData.addressToVerify, ); } catch (err) { toast.error(`${err}`); @@ -204,7 +187,7 @@ </Row> <Row> <PrimaryButton - onClick={signMsg} + onClick={handleUserSignature} disabled={formData.msgToSign === ''} > Sign diff --git a/cashtab/src/components/SignVerifyMsg/__tests__/SignVerifyMsg.test.js b/cashtab/src/components/SignVerifyMsg/__tests__/SignVerifyMsg.test.js --- a/cashtab/src/components/SignVerifyMsg/__tests__/SignVerifyMsg.test.js +++ b/cashtab/src/components/SignVerifyMsg/__tests__/SignVerifyMsg.test.js @@ -78,7 +78,7 @@ expect( screen.getByText( - 'ILwXI6gAnIkh7nw3r/VDNYWiBxVoHYIjsk9lALezbYjGVXbFj0p4glXibHDQoJVll4+zTS5SWXsVAwhczh5GTts=', + 'H15QdmXfPFzMX+nDsoIGL51Nq3jkX/3RGmhwe87fIs9fLpvdHEflw+K9935GTU30Ids8J8Cdn1fV4uRJfUwYM8w=', ), ).toBeInTheDocument(); }); diff --git a/modules/ecash-lib/README.md b/modules/ecash-lib/README.md --- a/modules/ecash-lib/README.md +++ b/modules/ecash-lib/README.md @@ -92,3 +92,4 @@ - 2.0.0 - Remove `initWasm`, auto-load the WebAssembly instead. Remove unneeded `ecc` parameters, esp. in `TxBuilder.sign` and `HdNode.fromSeed` [D17639](https://reviews.bitcoinabc.org/D17639) [D17640](https://reviews.bitcoinabc.org/D17640) - 2.1.0 - Add `signRecoverable` and `recoverSig` to `Ecc` [D17667](https://reviews.bitcoinabc.org/D17667) - 3.0.0 - Improve types and shapes in line with chronik proto updates [D17650](https://reviews.bitcoinabc.org/D17650) +- 3.1.0 - Add methods for signing and verifying messages [D17778](https://reviews.bitcoinabc.org/D17778) diff --git a/modules/ecash-lib/package-lock.json b/modules/ecash-lib/package-lock.json --- a/modules/ecash-lib/package-lock.json +++ b/modules/ecash-lib/package-lock.json @@ -1,12 +1,12 @@ { "name": "ecash-lib", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ecash-lib", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { "b58-ts": "file:../b58-ts", diff --git a/modules/ecash-lib/package.json b/modules/ecash-lib/package.json --- a/modules/ecash-lib/package.json +++ b/modules/ecash-lib/package.json @@ -1,6 +1,6 @@ { "name": "ecash-lib", - "version": "3.0.0", + "version": "3.1.0", "description": "Library for eCash transaction building", "main": "./dist/indexNodeJs.js", "browser": "./dist/indexBrowser.js", diff --git a/modules/ecash-lib/src/index.ts b/modules/ecash-lib/src/index.ts --- a/modules/ecash-lib/src/index.ts +++ b/modules/ecash-lib/src/index.ts @@ -8,6 +8,7 @@ export * from './op.js'; export * from './opcode.js'; export * from './script.js'; +export * from './messages.js'; export * from './mnemonic.js'; export * from './hdwallet.js'; export * from './address/address.js'; diff --git a/modules/ecash-lib/src/messages.test.ts b/modules/ecash-lib/src/messages.test.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/messages.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2025 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import { expect } from 'chai'; +import { Ecc } from './ecc'; +import { fromHex, toHex } from './io/hex'; +import { magicHash, signMsg, verifyMsg } from './messages'; +import { Address } from './address/address'; +import { shaRmd160 } from './hash'; + +// Test data +const TEST_SECRET_KEY = fromHex( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', +); +const TEST_PUBLIC_KEY = new Ecc().derivePubkey(TEST_SECRET_KEY); +const TEST_PUBLIC_KEY_HASH = toHex(shaRmd160(TEST_PUBLIC_KEY)); +const TEST_ADDRESS = Address.p2pkh(TEST_PUBLIC_KEY_HASH).toString(); +const TEST_MESSAGE = 'Hello, world!'; + +// Precomputed magicHash for "Hello, world!" +const TEST_MESSAGE_HASH = fromHex( + '8f6b3f5a9e73fa9bcee1e28c749813665b94b4e9019d71844aee89f928af8fb3', +); + +describe('Messages', () => { + context('magicHash', () => { + it('Computes correct hash for a simple message', () => { + const result = magicHash(TEST_MESSAGE); + expect(toHex(result)).to.equal(toHex(TEST_MESSAGE_HASH)); + expect(result.length).to.equal(32); // SHA-256 output + }); + + it('Works with custom prefix', () => { + const customPrefix = '\x19Dogecoin Signed Message:\n'; + const result = magicHash(TEST_MESSAGE, customPrefix); + expect(result.length).to.equal(32); + expect(toHex(result)).to.not.equal(toHex(TEST_MESSAGE_HASH)); // Different prefix, different hash + }); + + it('Handles empty message', () => { + const result = magicHash(''); + expect(result.length).to.equal(32); + }); + }); + + context('signMsg', () => { + it('Signs a message and returns a base64 string', () => { + const signature = signMsg(TEST_MESSAGE, TEST_SECRET_KEY); + expect(signature.length).to.equal(88); // 65 bytes -> 88 base64 chars + expect(/^[A-Za-z0-9+/]+=$/.test(signature)).to.equal(true); // Valid base64 format + expect(signature).to.equal( + 'IEwA92jxphriBKyCd1RI4PM0uhbVUS8qW69h/tKIMrNpGRNOqfTlBATvylddM7H5dqsjkkOc72Zc0hNdOzUiIKI=', + ); + }); + + it('Produces consistent signatures with same input', () => { + const sig1 = signMsg(TEST_MESSAGE, TEST_SECRET_KEY); + const sig2 = signMsg(TEST_MESSAGE, TEST_SECRET_KEY); + expect(sig1).to.equal(sig2); // Deterministic for same key and message + }); + + it('Works with custom prefix', () => { + const signature = signMsg( + TEST_MESSAGE, + TEST_SECRET_KEY, + '\x19Dogecoin Signed Message:\n', + ); + expect(signature.length).to.equal(88); + }); + + it('Throws error for invalid secret key', () => { + const invalidSk = new Uint8Array(31); // Too short + expect(() => signMsg(TEST_MESSAGE, invalidSk)).to.throw(); + }); + }); + + context('verifyMsg', () => { + it('Verifies a valid signature for a message', () => { + const signature = signMsg(TEST_MESSAGE, TEST_SECRET_KEY); + const isValid = verifyMsg(TEST_MESSAGE, signature, TEST_ADDRESS); + expect(isValid).to.equal(true); + }); + + it('Rejects an invalid message with a valid signature', () => { + const signature = signMsg(TEST_MESSAGE, TEST_SECRET_KEY); + const isValid = verifyMsg('Wrong message', signature, TEST_ADDRESS); + expect(isValid).to.equal(false); + }); + + it('Rejects a valid signature with wrong address', () => { + const signature = signMsg(TEST_MESSAGE, TEST_SECRET_KEY); + const wrongAddress = Address.p2pkh('deadbeef'.repeat(5)).toString(); // Different hash + const isValid = verifyMsg(TEST_MESSAGE, signature, wrongAddress); + expect(isValid).to.equal(false); + }); + + it('Handles custom prefix correctly', () => { + const customPrefix = '\x19Dogecoin Signed Message:\n'; + const signature = signMsg( + TEST_MESSAGE, + TEST_SECRET_KEY, + customPrefix, + ); + const isValid = verifyMsg( + TEST_MESSAGE, + signature, + TEST_ADDRESS, + customPrefix, + ); + expect(isValid).to.equal(true); + }); + + it('Returns false for invalid base64 signature', () => { + const isValid = verifyMsg( + TEST_MESSAGE, + 'invalid#base64', + TEST_ADDRESS, + ); + expect(isValid).to.equal(false); + }); + + it('Returns false for signature of wrong length', () => { + const shortSig = btoa(String.fromCharCode(...new Uint8Array(64))); // Missing recovery byte + const isValid = verifyMsg(TEST_MESSAGE, shortSig, TEST_ADDRESS); + expect(isValid).to.equal(false); + }); + }); + + context('Round-trip signing and verification', () => { + it('Signs and verifies a message with default prefix', () => { + const signature = signMsg(TEST_MESSAGE, TEST_SECRET_KEY); + const isValid = verifyMsg(TEST_MESSAGE, signature, TEST_ADDRESS); + expect(isValid).to.equal(true); + }); + + it('Signs and verifies an empty message', () => { + const signature = signMsg('', TEST_SECRET_KEY); + const isValid = verifyMsg('', signature, TEST_ADDRESS); + expect(isValid).to.equal(true); + }); + + it('Signs and verifies with custom prefix', () => { + const customPrefix = '\x18Test Prefix:\n'; + const signature = signMsg( + TEST_MESSAGE, + TEST_SECRET_KEY, + customPrefix, + ); + const isValid = verifyMsg( + TEST_MESSAGE, + signature, + TEST_ADDRESS, + customPrefix, + ); + expect(isValid).to.equal(true); + }); + + it('Signs and verifies a message with default prefix generated on Cashtab.com using bitcoinjs-lib', () => { + const cashtabLegacyTestMsg = 'cashtab legacy test'; + const signature = signMsg( + cashtabLegacyTestMsg, + fromHex( + 'f510d9364db49dd8036202b9bdc9cfe3d5922e37e6a9583eed8d05c2a9010dd6', + ), + ); + const isValid = verifyMsg( + cashtabLegacyTestMsg, + signature, + 'ecash:qzxpm0sc2qnlh8j0ft75rz6jrehpyy36uy59ykm484', + ); + expect(isValid).to.equal(true); + }); + }); +}); diff --git a/modules/ecash-lib/src/messages.ts b/modules/ecash-lib/src/messages.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/messages.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2025 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import { sha256d, shaRmd160 } from './hash'; +import { Ecc } from './ecc'; +import { WriterBytes } from './io/writerbytes'; +import { writeVarSize } from './io/varsize'; +import { toHex } from './io/hex'; +import { Address } from './address/address'; + +/** + * messages.ts + * + * Sign and verify messages + */ + +const ECASH_MSG_SIGNING_PREFIX = '\x16eCash Signed Message:\n'; + +/** + * Messages are prepared in a standard way before signing and verifying + * + * - The raw message (e.g., "Hello, world!") is encoded as a UTF-8 byte array + * - The prefixed message is constructed as: + * + * [prefix][message_length][message] + * + * where message_length is a variable-length integer (varint) encoding the + * byte length of the message + * + * We keep the "magicHash" name used in bitcoinjs-message as we do the same thing here + * with eCash tools + * + * ref https://github.com/bitcoinjs/bitcoinjs-message/blob/master/index.js#L57 + */ +export const magicHash = ( + message: string, + messagePrefix = ECASH_MSG_SIGNING_PREFIX, +): Uint8Array => { + const encoder = new TextEncoder(); + + // Convert prefix to Uint8Array + const prefixBytes = encoder.encode(messagePrefix); + + // Convert message to Uint8Array + const messageBytes = encoder.encode(message); + + // Calculate the maximum possible size of the varint for message length + const maxVarintSize = + messageBytes.length <= 0xfc + ? 1 + : messageBytes.length <= 0xffff + ? 3 + : messageBytes.length <= 0xffffffff + ? 5 + : 9; + + // Create a WriterBytes instance with enough capacity + const writer = new WriterBytes( + prefixBytes.length + maxVarintSize + messageBytes.length, + ); + + // Write the prefix + writer.putBytes(prefixBytes); + + // Write the message length as a varint + writeVarSize(messageBytes.length, writer); + + // Write the message + writer.putBytes(messageBytes); + + // Return double SHA-256 hash + return sha256d(writer.data); +}; + +/** + * Sign a message + * + * While there is not an official BIP or spec here, there is + * a de facto standard + * + * See implementation in bitcoinjs-lib and electrum + */ +export const signMsg = ( + msg: string, + sk: Uint8Array, + prefix = ECASH_MSG_SIGNING_PREFIX, +): string => { + const preparedMsg = magicHash(msg, prefix); + const sig = new Ecc().signRecoverable(sk, preparedMsg); + + // Convert Uint8Array to binary string and encode with btoa + const binaryString = String.fromCharCode(...sig); + return btoa(binaryString); +}; + +/** + * Verify that a given message and signature + * came from a given address + */ +export const verifyMsg = ( + msg: string, + signature: string, + address: string, + prefix = ECASH_MSG_SIGNING_PREFIX, +): boolean => { + try { + const preparedMsg = magicHash(msg, prefix); + + // Decode base64 signature to binary string and convert to Uint8Array + const binaryString = atob(signature); + const sig = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + sig[i] = binaryString.charCodeAt(i); + } + const recoveredPk = new Ecc().recoverSig(sig, preparedMsg); + // Get recovered hash as a hex string and compare to tested hash + const recoveredHash = toHex(shaRmd160(recoveredPk)); + const testedHash = Address.fromCashAddress(address).hash; + return recoveredHash === testedHash; + } catch (err) { + console.error(`Error verifying signature`, err); + return false; + } +};