From 7004670b581a02ab6fe75e4f6ce9c91525b03543 Mon Sep 17 00:00:00 2001 From: peachbits Date: Mon, 3 Aug 2026 14:12:16 -0700 Subject: [PATCH 1/2] Audit and correct coin messagePrefix magic strings These values have been inert until now: bitcoinjs-lib never reads network.messagePrefix, and signMessageBase64 passed no prefix, so every coin signed with bitcoinjs-message's Bitcoin default. PR #454 wires messagePrefix into the magic hash, which makes 14 of them wrong in a way that silently produces unverifiable signatures. bitcoinjs-message copies the prefix into the hash verbatim and only varint-encodes the message length, so the prefix must carry its own leading CompactSize byte. This mirrors `ss << strMessageMagic` in the coins' C++, which serializes the std::string as CompactSize(len) + bytes. Two failure classes: Wrong length byte (string was right): litecoin \x18 -> \x19 dogecoin \x18 -> \x19 feathercoin \x18 -> \x1c zcoin \x18 -> \x16 bitcoingold \x18 -> \x1d bitcoingoldtestnet \x18 -> \x1d Wrong string (mostly Bitcoin's default left in place): dash 'unused' -> '\x19DarkCoin Signed Message:\n' pivx 'PIVX Signed Message:\n' -> '\x18DarkNet Signed Message:\n' ecash 'Bitcoin Signed Message::\n' -> '\x16eCash Signed Message:\n' qtum 'Bitcoin Signed Message:\n' -> '\x15Qtum Signed Message:\n' ravencoin 'Bitcoin Signed Message:\n' -> '\x16Raven Signed Message:\n' smartcash 'Bitcoin Signed Message:\n' -> '\x1aSmartCash Signed Message:\n' ufo 'Bitcoin Signed Message:\n' -> '\x14UFO Signed Message:\n' digibyte '\x18Digibyte...' -> '\x19DigiByte Signed Message:\n' Dash's DarkCoin and PIVX's DarkNet are pre-rebrand names their Core still uses, and Vertcoin genuinely signs with Bitcoin's magic; all three are commented so they don't get "fixed" back. Sources (MESSAGE_MAGIC / strMessageMagic in each coin's Core): dash https://github.com/dashpay/dash/blob/master/src/util/message.cpp pivx https://github.com/PIVX-Project/PIVX/blob/master/src/util/validation.cpp#L27 zcoin https://github.com/firoorg/firo/blob/master/src/validation.cpp litecoin https://github.com/litecoin-project/litecoin/blob/master/src/util/message.cpp dogecoin https://github.com/dogecoin/dogecoin/blob/master/src/validation.cpp digibyte https://github.com/digibyte-core/digibyte/blob/master/src/util/message.cpp feathercoin https://github.com/FeatherCoin/Feathercoin/blob/master/src/util/message.cpp bitcoingold https://github.com/BTCGPU/BTCGPU/blob/master/src/util/message.cpp qtum https://github.com/qtumproject/qtum/blob/master/src/util/signstr.h ravencoin https://github.com/RavenProject/Ravencoin/blob/master/src/validation.cpp smartcash https://github.com/SmartCash/Core-Smart/blob/master/src/validation.cpp ufo https://github.com/UFOCoins/ufo/blob/master/src/validation.cpp ecash https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/src/common/signmessage.cpp Verified correct and left alone: bitcoin (+testnets), bitcoincash (+testnet), bitcoinsv, badcoin, groestlcoin and vertcoin. groestlcoin https://github.com/Groestlcoin/groestlcoin/blob/master/src/common/signmessage.cpp vertcoin https://github.com/vertcoin-project/vertcoin-core/blob/master/src/util/message.cpp bitcoinsv https://github.com/bitcoin-sv/bitcoin-sv/blob/master/src/validation.cpp bitcoincash https://github.com/bitcoin-cash-node/bitcoin-cash-node/blob/master/src/validation.cpp eboost is left as-is: the project is dead and its source is gone, so its magic could not be verified. Adds a test asserting every messagePrefix's leading byte matches its own length, since a mismatch is invisible until someone tries to verify a signature. Confirmed it fails when litecoin is reverted to \x18. --- CHANGELOG.md | 2 ++ src/common/utxobased/info/bitcoingold.ts | 2 +- .../utxobased/info/bitcoingoldtestnet.ts | 2 +- src/common/utxobased/info/dash.ts | 3 ++- src/common/utxobased/info/digibyte.ts | 2 +- src/common/utxobased/info/dogecoin.ts | 2 +- src/common/utxobased/info/ecash.ts | 2 +- src/common/utxobased/info/feathercoin.ts | 2 +- src/common/utxobased/info/litecoin.ts | 2 +- src/common/utxobased/info/pivx.ts | 3 ++- src/common/utxobased/info/qtum.ts | 2 +- src/common/utxobased/info/ravencoin.ts | 2 +- src/common/utxobased/info/smartcash.ts | 2 +- src/common/utxobased/info/ufo.ts | 2 +- src/common/utxobased/info/vertcoin.ts | 1 + src/common/utxobased/info/zcoin.ts | 2 +- .../keymanager/messagePrefix.spec.ts | 27 +++++++++++++++++++ 17 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 test/common/utxobased/keymanager/messagePrefix.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a32d680f..ae1cb9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- fixed: Correct the `messagePrefix` magic strings for 14 coins so signed messages verify against each chain's own node. Affects dash, pivx, qtum, ravencoin, smartcash, uniformfiscalobject, ecash, zcoin, litecoin, dogecoin, digibyte, feathercoin, bitcoingold and bitcoingoldtestnet. + ## 3.11.0 (2026-07-13) - added: Support the `-wif:` protohandler prefix (e.g. `bch-wif:`) in `parseUri` so CashStamps private keys can be swept. diff --git a/src/common/utxobased/info/bitcoingold.ts b/src/common/utxobased/info/bitcoingold.ts index 82f532ca..df5c4c88 100644 --- a/src/common/utxobased/info/bitcoingold.ts +++ b/src/common/utxobased/info/bitcoingold.ts @@ -73,7 +73,7 @@ export const coinInfo: CoinInfo = { coinType: 156, prefixes: { - messagePrefix: ['\x18Bitcoin Gold Signed Message:\n'], + messagePrefix: ['\x1dBitcoin Gold Signed Message:\n'], wif: [0x80], legacyXPriv: [0x0488ade4], legacyXPub: [0x0488b21e], diff --git a/src/common/utxobased/info/bitcoingoldtestnet.ts b/src/common/utxobased/info/bitcoingoldtestnet.ts index 2f8c1523..2510d756 100644 --- a/src/common/utxobased/info/bitcoingoldtestnet.ts +++ b/src/common/utxobased/info/bitcoingoldtestnet.ts @@ -65,7 +65,7 @@ export const coinInfo: CoinInfo = { coinType: 1, prefixes: { - messagePrefix: ['\x18Bitcoin Gold Signed Message:\n'], + messagePrefix: ['\x1dBitcoin Gold Signed Message:\n'], wif: [0xef], legacyXPriv: [0x04358394], legacyXPub: [0x043587cf], diff --git a/src/common/utxobased/info/dash.ts b/src/common/utxobased/info/dash.ts index e34e6c96..ada0c44c 100644 --- a/src/common/utxobased/info/dash.ts +++ b/src/common/utxobased/info/dash.ts @@ -99,7 +99,8 @@ export const coinInfo: CoinInfo = { segwit: false, coinType: 5, prefixes: { - messagePrefix: ['unused'], + // Dash kept its pre-rebrand DarkCoin magic; not a typo. + messagePrefix: ['\x19DarkCoin Signed Message:\n'], wif: [0xcc], legacyXPriv: [0x02fe52f8], legacyXPub: [0x02fe52cc], diff --git a/src/common/utxobased/info/digibyte.ts b/src/common/utxobased/info/digibyte.ts index ef30fea1..bd0ebc3d 100644 --- a/src/common/utxobased/info/digibyte.ts +++ b/src/common/utxobased/info/digibyte.ts @@ -79,7 +79,7 @@ export const coinInfo: CoinInfo = { segwit: true, coinType: 20, prefixes: { - messagePrefix: ['\x18Digibyte Signed Message:\n'], + messagePrefix: ['\x19DigiByte Signed Message:\n'], wif: [0x80, 0x9e], legacyXPriv: [0x0488ade4], legacyXPub: [0x0488b21e], diff --git a/src/common/utxobased/info/dogecoin.ts b/src/common/utxobased/info/dogecoin.ts index 156f22a0..5764aa0d 100644 --- a/src/common/utxobased/info/dogecoin.ts +++ b/src/common/utxobased/info/dogecoin.ts @@ -81,7 +81,7 @@ export const coinInfo: CoinInfo = { coinType: 3, utxoPicker: makeDogeUtxoPicker(), prefixes: { - messagePrefix: ['\x18Dogecoin Signed Message:\n'], + messagePrefix: ['\x19Dogecoin Signed Message:\n'], wif: [0x9e], legacyXPriv: [0x02fac398], legacyXPub: [0x02facafd], diff --git a/src/common/utxobased/info/ecash.ts b/src/common/utxobased/info/ecash.ts index 9325b0fe..4137a7e9 100644 --- a/src/common/utxobased/info/ecash.ts +++ b/src/common/utxobased/info/ecash.ts @@ -80,7 +80,7 @@ export const coinInfo: CoinInfo = { includeCashaddrPrefix: true, prefixes: { - messagePrefix: ['\x18Bitcoin Signed Message::\n'], + messagePrefix: ['\x16eCash Signed Message:\n'], wif: [0x80], legacyXPriv: [0x0488ade4], legacyXPub: [0x0488b21e], diff --git a/src/common/utxobased/info/feathercoin.ts b/src/common/utxobased/info/feathercoin.ts index 0b247530..c98346ba 100644 --- a/src/common/utxobased/info/feathercoin.ts +++ b/src/common/utxobased/info/feathercoin.ts @@ -63,7 +63,7 @@ export const coinInfo: CoinInfo = { segwit: true, coinType: 8, prefixes: { - messagePrefix: ['\x18Feathercoin Signed Message:\n'], + messagePrefix: ['\x1cFeathercoin Signed Message:\n'], wif: [0x8e], legacyXPriv: [0x0488daee], legacyXPub: [0x0488bc26], diff --git a/src/common/utxobased/info/litecoin.ts b/src/common/utxobased/info/litecoin.ts index 0832d92e..f23a1719 100644 --- a/src/common/utxobased/info/litecoin.ts +++ b/src/common/utxobased/info/litecoin.ts @@ -83,7 +83,7 @@ export const coinInfo: CoinInfo = { segwit: true, coinType: 2, prefixes: { - messagePrefix: ['\x18Litecoin Signed Message:\n'], + messagePrefix: ['\x19Litecoin Signed Message:\n'], wif: [0xb0], legacyXPriv: [0x0488ade4], legacyXPub: [0x0488b21e], diff --git a/src/common/utxobased/info/pivx.ts b/src/common/utxobased/info/pivx.ts index 0fc01ddc..245b28c5 100644 --- a/src/common/utxobased/info/pivx.ts +++ b/src/common/utxobased/info/pivx.ts @@ -79,7 +79,8 @@ export const coinInfo: CoinInfo = { /* [Prefix reference](https://github.com/PIVX-Project/PIVX/blob/b89a9fc04e9e31bb3fd009df0506c957a00ad536/src/chainparams.cpp#L329) */ - messagePrefix: ['PIVX Signed Message:\n'], + // PIVX inherited DarkNet from its Dash fork ancestry; not a typo. + messagePrefix: ['\x18DarkNet Signed Message:\n'], wif: [0xd4], legacyXPriv: [0x0221312b], legacyXPub: [0x022d2533], diff --git a/src/common/utxobased/info/qtum.ts b/src/common/utxobased/info/qtum.ts index d6f2252c..ad27cdbe 100644 --- a/src/common/utxobased/info/qtum.ts +++ b/src/common/utxobased/info/qtum.ts @@ -70,7 +70,7 @@ export const coinInfo: CoinInfo = { segwit: false, coinType: 2301, prefixes: { - messagePrefix: ['\x18Bitcoin Signed Message:\n'], + messagePrefix: ['\x15Qtum Signed Message:\n'], wif: [0x80], legacyXPriv: [0x0488ade4], legacyXPub: [0x0488b21e], diff --git a/src/common/utxobased/info/ravencoin.ts b/src/common/utxobased/info/ravencoin.ts index 2df7fd57..748f07fc 100644 --- a/src/common/utxobased/info/ravencoin.ts +++ b/src/common/utxobased/info/ravencoin.ts @@ -60,7 +60,7 @@ export const coinInfo: CoinInfo = { segwit: false, coinType: 175, prefixes: { - messagePrefix: ['\x18Bitcoin Signed Message:\n'], + messagePrefix: ['\x16Raven Signed Message:\n'], wif: [0x80], legacyXPriv: [0x0488ade4], legacyXPub: [0x0488b21e], diff --git a/src/common/utxobased/info/smartcash.ts b/src/common/utxobased/info/smartcash.ts index e0bab880..bd69af32 100644 --- a/src/common/utxobased/info/smartcash.ts +++ b/src/common/utxobased/info/smartcash.ts @@ -71,7 +71,7 @@ export const coinInfo: CoinInfo = { bs58EncodeFunc: base58smart.encode, wifEncodeFunc: wifsmart.encode, prefixes: { - messagePrefix: ['\x18Bitcoin Signed Message:\n'], + messagePrefix: ['\x1aSmartCash Signed Message:\n'], wif: [0xbf], legacyXPriv: [0x0488ade4], legacyXPub: [0x0488b21e], diff --git a/src/common/utxobased/info/ufo.ts b/src/common/utxobased/info/ufo.ts index f344a148..1e093258 100644 --- a/src/common/utxobased/info/ufo.ts +++ b/src/common/utxobased/info/ufo.ts @@ -63,7 +63,7 @@ export const coinInfo: CoinInfo = { segwit: true, coinType: 202, prefixes: { - messagePrefix: ['\x18Bitcoin Signed Message:\n'], + messagePrefix: ['\x14UFO Signed Message:\n'], wif: [0x9b], legacyXPriv: [0x0488ade4], legacyXPub: [0x0488b21e], diff --git a/src/common/utxobased/info/vertcoin.ts b/src/common/utxobased/info/vertcoin.ts index 40fca354..1884cac2 100644 --- a/src/common/utxobased/info/vertcoin.ts +++ b/src/common/utxobased/info/vertcoin.ts @@ -77,6 +77,7 @@ export const coinInfo: CoinInfo = { segwit: true, coinType: 28, prefixes: { + // Vertcoin Core genuinely uses Bitcoin's magic; not an oversight. messagePrefix: ['\x18Bitcoin Signed Message:\n'], wif: [0x80], legacyXPriv: [0x0488ade4], diff --git a/src/common/utxobased/info/zcoin.ts b/src/common/utxobased/info/zcoin.ts index ca9af238..1e111c02 100644 --- a/src/common/utxobased/info/zcoin.ts +++ b/src/common/utxobased/info/zcoin.ts @@ -78,7 +78,7 @@ export const coinInfo: CoinInfo = { segwit: false, coinType: 136, prefixes: { - messagePrefix: ['\x18Zcoin Signed Message:\n'], + messagePrefix: ['\x16Zcoin Signed Message:\n'], wif: [0xd2], legacyXPriv: [0x0488ade4], legacyXPub: [0x0488b21e], diff --git a/test/common/utxobased/keymanager/messagePrefix.spec.ts b/test/common/utxobased/keymanager/messagePrefix.spec.ts new file mode 100644 index 00000000..57ee24d5 --- /dev/null +++ b/test/common/utxobased/keymanager/messagePrefix.spec.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { all } from '../../../../src/common/utxobased/info/all' + +// `bitcoinjs-message` copies `messagePrefix` into the magic hash verbatim and +// only varint-encodes the message length, so the prefix must carry its own +// leading CompactSize byte. This mirrors `ss << strMessageMagic` in the coins' +// C++, which serializes the std::string as CompactSize(len) + bytes. A prefix +// whose leading byte disagrees with its length produces a hash no wallet or +// node will verify against, and the failure is silent at signing time. +describe('coin messagePrefix', () => { + all.forEach(info => { + const { name, prefixes } = info.coinInfo + prefixes.messagePrefix.forEach((prefix, index) => { + it(`${name} messagePrefix[${index}] length byte matches`, () => { + const buf = Buffer.from(prefix, 'utf8') + expect(buf[0]).to.equal( + buf.length - 1, + `expected leading byte 0x${(buf.length - 1).toString( + 16 + )} for ${JSON.stringify(prefix)}` + ) + }) + }) + }) +}) From 10d018f9118b247e9fff1ffac6e94b5ab2edbb1b Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 8 Jul 2026 15:58:58 -0700 Subject: [PATCH 2/2] Encode BIP137 SegWit message signature headers signMessageBase64 always emitted a legacy header byte (27-34) regardless of the signing address type, so BIP137 verifiers (e.g. Bringin) rejected signatures from SegWit addresses. Thread the address format through and select the matching segwitType: bip49 -> p2sh(p2wpkh) (header 35-38), bip84 -> p2wpkh (header 39-42), bip44/bip32 unchanged (31-34). Also magic-hash with each coin's own messagePrefix rather than always Bitcoin's, so the signature verifies against that coin's addresses. --- CHANGELOG.md | 3 + src/common/utxobased/engine/UtxoEngine.ts | 23 +++- .../utxobased/engine/UtxoWalletTools.ts | 17 ++- src/common/utxobased/engine/types.ts | 8 +- src/common/utxobased/keymanager/keymanager.ts | 62 ++++++++++- src/common/utxobased/keymanager/types.ts | 29 ++++- .../keymanager/coins/altcointestfixtures.ts | 102 ++++++++++++++++++ .../keymanager/coins/keymanagertest.spec.ts | 14 ++- 8 files changed, 243 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae1cb9eb..41462826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## Unreleased +- added: `signatureFormat` option on `signMessage`, accepting `electrum` (the default) or `bip137`. Existing callers keep the legacy Electrum header byte; BIP137 is opt-in. +- changed: `signMessage` throws a named `AddressNotOwnedError` when the wallet cannot sign for the requested address, so callers no longer match on error message text. - fixed: Correct the `messagePrefix` magic strings for 14 coins so signed messages verify against each chain's own node. Affects dash, pivx, qtum, ravencoin, smartcash, uniformfiscalobject, ecash, zcoin, litecoin, dogecoin, digibyte, feathercoin, bitcoingold and bitcoingoldtestnet. +- fixed: Encode BIP137 message signatures with the correct header byte for SegWit addresses (bip49 nested SegWit and bip84 native SegWit) and with each coin's own message prefix, so signatures verify on BIP137-compliant platforms across all UTXO currencies. ## 3.11.0 (2026-07-13) diff --git a/src/common/utxobased/engine/UtxoEngine.ts b/src/common/utxobased/engine/UtxoEngine.ts index cc4ff6bd..7fa96f35 100644 --- a/src/common/utxobased/engine/UtxoEngine.ts +++ b/src/common/utxobased/engine/UtxoEngine.ts @@ -45,7 +45,10 @@ import { scriptPubkeyToAddress, signTx } from '../keymanager/keymanager' -import { asMaybeInsufficientFundsErrorPlus } from '../keymanager/types' +import { + AddressNotOwnedError, + asMaybeInsufficientFundsErrorPlus +} from '../keymanager/types' import { transactionSizeFromHex } from '../keymanager/utxopicker/utils' import { createPayment, getPaymentDetails, sendPayment } from './paymentRequest' import { @@ -797,11 +800,20 @@ export async function makeUtxoEngine( opts: EdgeSignMessageOptions ): Promise { const otherParams = asUtxoSignMessageOtherParams(opts.otherParams) - const { publicAddress } = otherParams - const scriptPubkey = walletTools.addressToScriptPubkey(publicAddress) + const { publicAddress, signatureFormat } = otherParams + // An address the wallet cannot sign for reaches us two ways: it parses + // but is not ours, or it is not an address of this chain at all and + // `addressToScriptPubkey` throws. Both are the same thing to a caller, + // so give them one named error rather than assorted parser prose. + let scriptPubkey: string + try { + scriptPubkey = walletTools.addressToScriptPubkey(publicAddress) + } catch { + throw new AddressNotOwnedError() + } const addressData = await dataLayer.fetchAddress(scriptPubkey) if (addressData?.path == null) { - throw new Error('Missing data-layer address to sign with') + throw new AddressNotOwnedError() } const privateKey = asMaybeCurrencyPrivateKey(privateKeys) @@ -818,7 +830,8 @@ export async function makeUtxoEngine( const signature = await walletTools.signMessageBase64({ path: addressData?.path, message, - xprivKeys + xprivKeys, + signatureFormat }) return signature }, diff --git a/src/common/utxobased/engine/UtxoWalletTools.ts b/src/common/utxobased/engine/UtxoWalletTools.ts index 13575630..858c2ce5 100644 --- a/src/common/utxobased/engine/UtxoWalletTools.ts +++ b/src/common/utxobased/engine/UtxoWalletTools.ts @@ -19,6 +19,7 @@ import { xprivToPrivateKey, xpubToPubkey } from '../keymanager/keymanager' +import { UtxoSignatureFormat } from '../keymanager/types' import { CurrencyFormatKeys, currencyFormatToPurposeType, @@ -92,6 +93,7 @@ interface SignMessageArgs { path: AddressPath message: string xprivKeys: CurrencyFormatKeys + signatureFormat: UtxoSignatureFormat } export function makeUtxoWalletTools( @@ -236,12 +238,23 @@ export function makeUtxoWalletTools( return { address, scriptPubkey, redeemScript } }, - signMessageBase64({ path, message, xprivKeys }: SignMessageArgs): string { + signMessageBase64({ + path, + message, + xprivKeys, + signatureFormat + }: SignMessageArgs): string { const privKey = fns.getPrivateKey({ path, xprivKeys }) - return signMessageBase64(message, privKey) + return signMessageBase64( + message, + privKey, + path.format, + coin, + signatureFormat + ) } } diff --git a/src/common/utxobased/engine/types.ts b/src/common/utxobased/engine/types.ts index f5c179dd..98626d34 100644 --- a/src/common/utxobased/engine/types.ts +++ b/src/common/utxobased/engine/types.ts @@ -11,6 +11,7 @@ import { import { EdgeSpendInfo } from 'edge-core-js/types' import { asTxOptions } from '../../plugin/types' +import { asUtxoSignatureFormat } from '../keymanager/types' import { Input, Output } from '../keymanager/utxopicker/types' export interface UtxoInitOptions { @@ -42,7 +43,12 @@ export type UtxoSignMessageOtherParams = ReturnType< typeof asUtxoSignMessageOtherParams > export const asUtxoSignMessageOtherParams = asObject({ - publicAddress: asString + publicAddress: asString, + // Defaults to the legacy Electrum encoding so existing callers keep the + // format they already produce. BIP-137 is opt-in, since emitting a BIP-137 + // header to a verifier expecting the legacy one is just as broken as the + // reverse. + signatureFormat: asMaybe(asUtxoSignatureFormat, 'electrum') }) const asOutputSort = asValue('bip69', 'targets') diff --git a/src/common/utxobased/keymanager/keymanager.ts b/src/common/utxobased/keymanager/keymanager.ts index 17a84718..ef5600e6 100644 --- a/src/common/utxobased/keymanager/keymanager.ts +++ b/src/common/utxobased/keymanager/keymanager.ts @@ -18,7 +18,13 @@ import { EdgeLog, EdgeMemo } from 'edge-core-js/types' import { indexAtProtected } from '../../../util/indexAtProtected' import { undefinedIfEmptyString } from '../../../util/undefinedIfEmptyString' -import { ChangePath, CoinInfo, CoinPrefixes, FeeInfo } from '../../plugin/types' +import { + ChangePath, + CoinInfo, + CoinPrefixes, + CurrencyFormat, + FeeInfo +} from '../../plugin/types' import { UtxoData } from '../db/types' import { ScriptTemplate, ScriptTemplates } from '../info/scriptTemplates/types' import { sortInputs, sortOutputs } from './bip69' @@ -28,7 +34,7 @@ import { hashToCashAddress } from './bitcoincashUtils/cashAddress' import { getCoinFromString } from './coinmapper' -import { InsufficientFundsErrorPlus } from './types' +import { InsufficientFundsErrorPlus, UtxoSignatureFormat } from './types' import * as utxopicker from './utxopicker' import * as pickerUtils from './utxopicker/utils' @@ -938,14 +944,62 @@ export function privateKeyEncodingToPubkey( }).publicKey.toString('hex') } -export function signMessageBase64(message: string, privateKey: string): string { +/** + * The `segwitType` to hand `bitcoinjs-message`, or `undefined` to leave the + * header byte in the legacy Electrum range. Only the BIP137 encoding cares + * about the derivation purpose, so only it resolves one. + */ +function getBip137SegwitType( + signatureFormat: UtxoSignatureFormat, + format: CurrencyFormat +): 'p2wpkh' | 'p2sh(p2wpkh)' | undefined { + if (signatureFormat !== 'bip137') return undefined + const purposeType = bip43PurposeNumberToTypeEnum( + parseInt(format.replace('bip', '')) + ) + switch (purposeType) { + case BIP43PurposeTypeEnum.Segwit: + return 'p2wpkh' + case BIP43PurposeTypeEnum.WrappedSegwit: + return 'p2sh(p2wpkh)' + default: + return undefined + } +} + +export function signMessageBase64( + message: string, + privateKey: string, + format: CurrencyFormat, + coin: string, + signatureFormat: UtxoSignatureFormat = 'electrum' +): string { const ECPair = getECPair() const keyPair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex')) if (keyPair.privateKey == null) { throw new Error('Address could not sign message') } + // BIP137 encodes the address type in the signature's header byte. Native + // SegWit (bip84 / P2WPKH) and nested SegWit (bip49 / P2SH-P2WPKH) each have + // their own header range; verifiers such as Bringin reject a SegWit address + // whose message was signed with a legacy (bip44) header byte. The derivation + // path is what tells us the script type, so no caller has to infer it from + // the address string. Callers opt in, since emitting a BIP137 header to a + // verifier that expects the legacy Electrum encoding is just as broken. + // The purpose lookup stays inside the BIP137 branch on purpose: + // `bip43PurposeNumberToTypeEnum` throws on a purpose it does not map, so + // computing it unconditionally would let a future `CurrencyFormat` (bip86 + // Taproot being the obvious candidate) break the default Electrum path, + // which never needs it. + const segwitType = getBip137SegwitType(signatureFormat, format) + // Magic-hash the message with the coin's own prefix (e.g. "Litecoin Signed + // Message:\n") so the signature verifies against that coin's addresses. + // Every UTXO coin routes through here, not just Bitcoin. + const messagePrefix = getCoinFromString(coin).prefixes.messagePrefix[0] return bitcoinMessage - .sign(message, keyPair.privateKey, keyPair.compressed) + .sign(message, keyPair.privateKey, keyPair.compressed, messagePrefix, { + segwitType + }) .toString('base64') } diff --git a/src/common/utxobased/keymanager/types.ts b/src/common/utxobased/keymanager/types.ts index 81ef731b..97f07f14 100644 --- a/src/common/utxobased/keymanager/types.ts +++ b/src/common/utxobased/keymanager/types.ts @@ -1,6 +1,17 @@ -import { Cleaner } from 'cleaners' +import { asValue, Cleaner } from 'cleaners' import { EdgeTokenId, InsufficientFundsError } from 'edge-core-js/types' +/** + * How to encode the header byte of a signed message. + * - `electrum`: the legacy format, whose header carries only the recovery id + * and key compression. Every verifier understands it, but it says nothing + * about the script type. + * - `bip137`: additionally encodes the signing address' script type, which + * verifiers such as Bringin require for SegWit addresses. + */ +export const asUtxoSignatureFormat = asValue('electrum', 'bip137') +export type UtxoSignatureFormat = ReturnType + interface InsufficientFundsErrorOptsPlus { // The currency we need more of: tokenId: EdgeTokenId @@ -27,6 +38,22 @@ function asMaybeError(name: string): Cleaner { } } +/** + * The wallet cannot sign for the requested address: either it does not derive + * it, or the string is not an address of this chain at all. Callers get a + * stable `name` to branch on instead of matching against the message prose. + */ +export class AddressNotOwnedError extends Error { + constructor() { + super('Wallet does not own the address to sign with') + this.name = 'AddressNotOwnedError' + } +} + +export const asMaybeAddressNotOwnedError = asMaybeError( + 'AddressNotOwnedError' +) + export const asMaybeInsufficientFundsErrorPlus = asMaybeError( // Share the same name because this error type is a subtype of // InsufficientFundsError and therefore backwards compatible diff --git a/test/common/utxobased/keymanager/coins/altcointestfixtures.ts b/test/common/utxobased/keymanager/coins/altcointestfixtures.ts index 17d85409..a38342ad 100644 --- a/test/common/utxobased/keymanager/coins/altcointestfixtures.ts +++ b/test/common/utxobased/keymanager/coins/altcointestfixtures.ts @@ -1,8 +1,10 @@ +import { CurrencyFormat } from '../../../../../src/common/plugin/types' import { AddressTypeEnum, BIP43PurposeTypeEnum, ScriptTypeEnum } from '../../../../../src/common/utxobased/keymanager/keymanager' +import { UtxoSignatureFormat } from '../../../../../src/common/utxobased/keymanager/types' interface SeedToXPrivTests { xpriv: string @@ -45,6 +47,9 @@ interface XPubToPubkeyTests { interface SignMessageTests { wif: string message: string + format: CurrencyFormat + /** Omitted to exercise the default, which must stay `electrum`. */ + signatureFormat?: UtxoSignatureFormat signature: string } @@ -842,6 +847,61 @@ export const fixtures: Fixture = { address: 'ltc1qjmxnz78nmc8nq77wuxh25n2es7rzm5c2rkk4wh', scriptPubkey: '001496cd3178f3de0f307bcee1aeaa4d5987862dd30a' } + ], + // Signatures use Litecoin's own '\x19Litecoin Signed Message:\n' prefix, + // so they differ from the Bitcoin vectors above for the same key and + // message, and verify against Litecoin addresses only. Each was checked + // with bitcoinMessage.verify against the derived legacy / p2sh-p2wpkh / + // bech32 address, and confirmed NOT to verify under Bitcoin's prefix. + // The leading base64 char (H/I/J) encodes the BIP137 address type; the + // default-format cases stay on H for every derivation path, which is + // what keeps existing callers on the encoding they already produce. + signMessageTests: [ + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip44', + signature: + 'H21RXhfhR3uOi9zqIi3dTVopWSmAzC9fBuEo4S+ZHkfsHPkDnLUM2t7BBTNgVQhyofQXU7zQgZdO0GV3pRhwNfY=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip49', + signature: + 'H21RXhfhR3uOi9zqIi3dTVopWSmAzC9fBuEo4S+ZHkfsHPkDnLUM2t7BBTNgVQhyofQXU7zQgZdO0GV3pRhwNfY=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip84', + signature: + 'H21RXhfhR3uOi9zqIi3dTVopWSmAzC9fBuEo4S+ZHkfsHPkDnLUM2t7BBTNgVQhyofQXU7zQgZdO0GV3pRhwNfY=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip44', + signatureFormat: 'bip137', + signature: + 'H21RXhfhR3uOi9zqIi3dTVopWSmAzC9fBuEo4S+ZHkfsHPkDnLUM2t7BBTNgVQhyofQXU7zQgZdO0GV3pRhwNfY=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip49', + signatureFormat: 'bip137', + signature: + 'I21RXhfhR3uOi9zqIi3dTVopWSmAzC9fBuEo4S+ZHkfsHPkDnLUM2t7BBTNgVQhyofQXU7zQgZdO0GV3pRhwNfY=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip84', + signatureFormat: 'bip137', + signature: + 'J21RXhfhR3uOi9zqIi3dTVopWSmAzC9fBuEo4S+ZHkfsHPkDnLUM2t7BBTNgVQhyofQXU7zQgZdO0GV3pRhwNfY=' + } ] }, { @@ -1348,12 +1408,54 @@ export const fixtures: Fixture = { '5120a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c' } ], + // The default (Electrum) encoding keeps the same 'H' header byte for + // every derivation path; only an explicit BIP-137 request shifts it into + // the nested-SegWit ('I') and native-SegWit ('J') ranges. signMessageTests: [ { wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', message: 'This is an example of a signed message.', + format: 'bip44', + signature: + 'H9L5yLFjti0QTHhPyFrZCT1V/MMnBtXKmoiKDZ78NDBjERki6ZTQZdSMCtkgoNmp17By9ItJr8o7ChX0XxY91nk=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip49', + signature: + 'H9L5yLFjti0QTHhPyFrZCT1V/MMnBtXKmoiKDZ78NDBjERki6ZTQZdSMCtkgoNmp17By9ItJr8o7ChX0XxY91nk=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip84', + signature: + 'H9L5yLFjti0QTHhPyFrZCT1V/MMnBtXKmoiKDZ78NDBjERki6ZTQZdSMCtkgoNmp17By9ItJr8o7ChX0XxY91nk=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip44', + signatureFormat: 'bip137', signature: 'H9L5yLFjti0QTHhPyFrZCT1V/MMnBtXKmoiKDZ78NDBjERki6ZTQZdSMCtkgoNmp17By9ItJr8o7ChX0XxY91nk=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip49', + signatureFormat: 'bip137', + signature: + 'I9L5yLFjti0QTHhPyFrZCT1V/MMnBtXKmoiKDZ78NDBjERki6ZTQZdSMCtkgoNmp17By9ItJr8o7ChX0XxY91nk=' + }, + { + wif: 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + message: 'This is an example of a signed message.', + format: 'bip84', + signatureFormat: 'bip137', + signature: + 'J9L5yLFjti0QTHhPyFrZCT1V/MMnBtXKmoiKDZ78NDBjERki6ZTQZdSMCtkgoNmp17By9ItJr8o7ChX0XxY91nk=' } ] }, diff --git a/test/common/utxobased/keymanager/coins/keymanagertest.spec.ts b/test/common/utxobased/keymanager/coins/keymanagertest.spec.ts index 30c5b4af..b1bc8c7f 100644 --- a/test/common/utxobased/keymanager/coins/keymanagertest.spec.ts +++ b/test/common/utxobased/keymanager/coins/keymanagertest.spec.ts @@ -130,12 +130,22 @@ describe('altcoin test fixtures', () => { } if (f.signMessageTests != null) { f.signMessageTests.forEach(j => { - it(`${f.name} sign message test`, () => { + const formatName = j.signatureFormat ?? 'default' + it(`${f.name} sign message test (${j.format}, ${formatName})`, () => { const privateKey = ECPair.fromWIF(j.wif).privateKey?.toString('hex') if (privateKey == null) { throw new Error('private key cannot be null') } - const signature = signMessageBase64(j.message, privateKey) + const signature = + j.signatureFormat == null + ? signMessageBase64(j.message, privateKey, j.format, f.name) + : signMessageBase64( + j.message, + privateKey, + j.format, + f.name, + j.signatureFormat + ) expect(signature).to.eqls(j.signature) }) })