diff --git a/CHANGELOG.md b/CHANGELOG.md index a32d680f..41462826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 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) - 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/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/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/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) }) }) 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)}` + ) + }) + }) + }) +})