feat(core): Bitcoin Signed Message primitives, checked against Electron Cash

Dapps have been asking for message signing to prove key control — identity
verification, SIWX-style login, and publishing a signed statement on chain. This
adds the construction and the verification; the protocol wiring follows
separately.

The known workaround is a dummy transaction: one input with a null outpoint, its
prevout script set to the P2PKH of the key being proven, one OP_RETURN output
carrying a server nonce, signed but never broadcast. It covers login, but not the
rest. A signature made that way is bound to a transaction, so verifying it means
rebuilding that exact transaction and knowing how it was serialised — it cannot
be published in an OP_RETURN and checked later by a third party holding only the
message, the signature and an address. It also asks a wallet to sign a real
transaction preimage, which is one bug away from signing a genuine spend.

So this produces the portable form: the standard "Bitcoin Signed Message"
construction. BCH wallets kept Bitcoin's magic string verbatim, so a signature
made here verifies in Electron Cash, Electrum and `bitcoin-cli verifymessage`.
The magic prefix also guarantees the digest can never coincide with a transaction
sighash, which makes signing a message categorically safer to approve than
signing a dummy transaction.

Address-level API, because that is what verification actually looks like
elsewhere: Electron Cash exposes only `verify_message(address, sig, message)`,
and a third party pulling a proof off an explorer has an address, not a public
key. verifyMessageSignatureForAddress accepts CashAddr with or without a prefix
and legacy base58, and rejects P2SH — no message signature can prove control of a
script hash.

recoverMessageSigner returns { publicKey, compressed } rather than a bare key.
The header byte declares which serialisation was used, and a key's compressed and
uncompressed forms hash to DIFFERENT addresses. Dropping that bit is how a
signature proving control of one address gets accepted as proof of another;
message-signing.test.ts pins the case in both directions.

signBitcoinMessage takes the private key as an argument and never retains it. It
exists so a wallet calls one function instead of reassembling the magic string,
both compactSize prefixes and the header byte — the parts third-party verifiers
check, and the parts covered by the tests here.

Testing: the byte layout is not asserted against our own reimplementation of the
spec, because that catches a coding mistake but not a misreading of it.

  message-signing.vectors.json holds 32 vectors generated by a real Electron Cash
  4.4.5 install (contrib/generate-message-signing-vectors.py) — two keys, both
  compression forms, eight messages including empty, multi-byte UTF-8, multi-line
  and the 252/253-byte compactSize boundary. Every preimage hash must match byte
  for byte, and every signature must verify. Signature bytes are NOT portable
  across implementations — Electron Cash and libauth derive the ECDSA nonce
  differently — so the reproducible quantity is the hash.

  message-signing.compat.test.ts drives `electron-cash verifymessage` directly,
  closing the loop that vectors cannot: that our OUTPUT is accepted. Not part of
  `npm test` (each assertion spawns a full Electron Cash process); run
  `npm run test:compat -w @wizardconnect/core`. Skips when Electron Cash is
  absent, so CI is unaffected.

77 tests, 426 in core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Håvard Kittelsen 2026-08-06 14:28:41 +02:00
parent 167ec21474
commit 763754b30e
11 changed files with 2285 additions and 2 deletions

View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
# Copyright (C) 2026 Whiterun LLC,
# This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
#
# Regenerates packages/core/src/protocols/message-signing.vectors.json.
#
# The `sign_message` extension exists to produce signatures that OTHER software
# accepts. Asserting our own construction against our own reimplementation of it
# proves nothing, so the vectors these tests run against are generated by a real
# Electron Cash install — its msg_magic, its signer, its address encoder.
#
# Usage:
# contrib/generate-message-signing-vectors.py [EC_SITE_PACKAGES] > \
# packages/core/src/protocols/message-signing.vectors.json
#
# EC_SITE_PACKAGES defaults to the AppImage-style layout. Electron Cash bundles
# its own interpreter, so run this with that interpreter, not the system one:
#
# APPDIR=/opt/electron-cash \
# LD_LIBRARY_PATH=/opt/electron-cash/usr/lib/:/opt/electron-cash/usr/lib/x86_64-linux-gnu \
# /opt/electron-cash/usr/bin/python3.11 -s contrib/generate-message-signing-vectors.py
import binascii
import json
import sys
import base64
DEFAULT_SITE_PACKAGES = "/opt/electron-cash/usr/lib/python3.11/site-packages"
site_packages = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_SITE_PACKAGES
sys.path.insert(0, site_packages)
from electroncash.bitcoin import regenerate_key, msg_magic, Hash # noqa: E402
from electroncash.address import Address # noqa: E402
from electroncash.version import PACKAGE_VERSION # noqa: E402
# Fixed test keys. Key 1 is the secp256k1 generator's scalar — convenient and
# stable; the second is arbitrary, to catch anything that only works for key 1.
KEYS = {
"one": "00" * 31 + "01",
"arbitrary": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
}
MESSAGES = [
("simple", "hello"),
("login", "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z"),
("empty", ""),
("multibyte", "Straße 日本語 \U0001f345"),
("multiline", "line one\nline two\n"),
("whitespace", " padded "),
# The compactSize length prefix widens from one byte to three at 253.
("compactsize_252", "a" * 252),
("compactsize_253", "a" * 253),
]
vectors = []
for key_name, key_hex in KEYS.items():
key = regenerate_key(binascii.unhexlify(key_hex))
for compressed in (True, False):
pubkey = key.get_public_key(compressed)
address = Address.from_pubkey(pubkey)
for message_name, message in MESSAGES:
signature = key.sign_message(message, compressed)
vectors.append(
{
"name": f"{key_name}/{message_name}/{'compressed' if compressed else 'uncompressed'}",
"privateKey": key_hex,
"message": message,
"compressed": compressed,
"publicKey": pubkey if isinstance(pubkey, str) else binascii.hexlify(pubkey).decode(),
"signature": base64.b64encode(signature).decode(),
"cashaddr": address.to_full_string(Address.FMT_CASHADDR),
"legacy": address.to_full_string(Address.FMT_LEGACY),
"preimageHash": binascii.hexlify(
Hash(msg_magic(message.encode("utf8")))
).decode(),
"header": signature[0],
}
)
print(
json.dumps(
{
"_provenance": {
"generatedBy": "Electron Cash",
"electronCashVersion": PACKAGE_VERSION,
"regenerate": "contrib/generate-message-signing-vectors.py",
"note": (
"Signatures, addresses and preimage hashes here are produced by "
"Electron Cash, not by this repository. They are the external "
"reference the sign_message extension must match. Signature bytes "
"are NOT portable across implementations (Electron Cash and libauth "
"derive the ECDSA nonce differently), so a conforming implementation "
"must reproduce preimageHash exactly and must VERIFY these "
"signatures — it will not reproduce them byte for byte."
),
},
"vectors": vectors,
},
indent=2,
ensure_ascii=False,
)
)

View file

@ -26,6 +26,7 @@
"scripts": {
"build": "tsc",
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
"test:compat": "vitest --config vitest.compat.config.ts --run",
"lint:prettier": "prettier --ignore-path ../../.gitignore . --list-different",
"lint:eslint": "eslint .",
"lint": "npm run lint:eslint && npm run lint:prettier",

View file

@ -30,6 +30,7 @@ export type {
KeyExchangeURIResult,
} from "./key-exchange.js";
export * from "./protocols/hdwalletv1.js";
export * from "./protocols/message-signing.js";
export * from "./protocols/base.js";
export {
CHUNK_EXTENSION_NAME,

View file

@ -21,8 +21,15 @@ export enum RelayMsgAction {
/// Request: The dapp wants wallet to sign a transaction.
SignTransactionRequest = "sign_transaction_request",
SignTransactionResponse = "sign_transaction_response",
/// Dapp-only: cancels an in-flight sign_transaction_request.
/// Dapp-only: cancels an in-flight sign_transaction_request or
/// sign_message_request. Sequences are unique across both, so one action
/// cancels either.
SignCancel = "sign_cancel",
/// Request: The dapp wants the wallet to sign a plain message, proving control
/// of a key without a transaction. Gated on the `sign_message` extension —
/// see message-signing.ts and docs/extensions.md.
SignMessageRequest = "sign_message_request",
SignMessageResponse = "sign_message_response",
/// Courtesy notification: one side is closing the connection.
Disconnect = "disconnect",
/// Transport-level: carries one slice of a message that exceeds NIP-44's

View file

@ -0,0 +1,178 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* The live half of the compatibility claim: signatures made by THIS code, handed
* to Electron Cash's own verifier.
*
* message-signing.vectors.test.ts proves we agree with Electron Cash's hashes and
* accept its signatures. That is most of the story but not all of it it cannot
* catch a fault that makes our *output* unacceptable while our input handling
* stays correct. This file closes the loop by shelling out to
* `electron-cash verifymessage`, which is exactly the "paste it into Verify
* Message" check a user would do.
*
* Not part of `npm test`: each invocation starts a full Electron Cash process, so
* this runs on demand.
*
* npm run test:compat --workspace @wizardconnect/core
*
* Skips itself when Electron Cash is not on PATH, so it is safe in CI.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { afterAll, describe, expect, it } from "vitest";
import {
encodeCashAddress,
hash160,
hexToBin,
secp256k1,
} from "@bitauth/libauth";
import {
messageSignatureAddress,
signBitcoinMessage,
} from "./message-signing.js";
const EC = "electron-cash";
function electronCashAvailable(): boolean {
const probe = spawnSync(EC, ["version"], {
encoding: "utf8",
timeout: 60000,
});
return probe.status === 0;
}
const available = electronCashAvailable();
if (!available) {
console.warn(
`[message-signing.compat] '${EC}' not on PATH — skipping live compatibility checks.`,
);
}
// Its own data directory: verifymessage needs no wallet, but Electron Cash will
// happily create config in the user's real directory if we let it.
const dataDir = available
? mkdtempSync(join(tmpdir(), "wizardconnect-ec-compat-"))
: "";
afterAll(() => {
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
});
/** Ask Electron Cash whether it accepts this signature. */
function ecVerify(
address: string,
signature: string,
message: string,
): boolean {
const result = spawnSync(
EC,
["-D", dataDir, "verifymessage", address, signature, message],
{ encoding: "utf8", timeout: 120000 },
);
if (result.error) {
throw new Error(`Could not run ${EC}: ${result.error.message}`);
}
const stdout = result.stdout?.trim();
if (stdout === "true") return true;
if (stdout === "false") return false;
// Electron Cash rejects some malformed input by erroring rather than printing
// false. For our purposes that is still "did not verify".
return false;
}
const PRIVATE_KEY = hexToBin("00".repeat(31) + "01");
function addressFor(compressed: boolean): string {
const publicKey = (
compressed
? secp256k1.derivePublicKeyCompressed(PRIVATE_KEY)
: secp256k1.derivePublicKeyUncompressed(PRIVATE_KEY)
) as Uint8Array;
return encodeCashAddress({
payload: hash160(publicKey),
prefix: "bitcoincash",
type: "p2pkh",
}).address;
}
const COMPRESSED_ADDRESS = addressFor(true);
const UNCOMPRESSED_ADDRESS = addressFor(false);
const MESSAGES: [name: string, message: string][] = [
["simple", "hello"],
["login", "wizardconnect login nonce=8f3a21c0d4"],
["empty", ""],
["multibyte", "Straße 日本語 🍅"],
["multiline", "line one\nline two\n"],
// Crosses the compactSize length-prefix boundary.
["long", "a".repeat(253)],
];
describe.skipIf(!available)("Electron Cash accepts our signatures", () => {
it.each(MESSAGES)(
"verifymessage accepts a compressed signature over %s",
(_name, message) => {
const signature = signBitcoinMessage(message, PRIVATE_KEY);
// Sanity: we and Electron Cash must agree on the address before the
// verdict below means anything.
expect(messageSignatureAddress(message, signature)).toBe(
COMPRESSED_ADDRESS,
);
expect(ecVerify(COMPRESSED_ADDRESS, signature, message)).toBe(true);
},
);
it("verifymessage accepts an uncompressed signature for its own address", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(ecVerify(UNCOMPRESSED_ADDRESS, signature, "hello")).toBe(true);
});
it("verifymessage refuses an uncompressed signature for the compressed address", () => {
// The external confirmation that these two addresses are not
// interchangeable — the assumption behind honouring the header's compression
// bit in recoverMessageSigner.
const signature = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(ecVerify(COMPRESSED_ADDRESS, signature, "hello")).toBe(false);
});
});
describe.skipIf(!available)("Electron Cash rejects what it should", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
it("rejects a flipped signature byte", () => {
const raw = Buffer.from(signature, "base64");
raw[10] ^= 0x01;
expect(ecVerify(COMPRESSED_ADDRESS, raw.toString("base64"), "hello")).toBe(
false,
);
});
it("rejects an altered message", () => {
expect(ecVerify(COMPRESSED_ADDRESS, signature, "hello!")).toBe(false);
expect(ecVerify(COMPRESSED_ADDRESS, signature, "hell")).toBe(false);
});
it("rejects a different address", () => {
const other = encodeCashAddress({
payload: hash160(
secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array,
),
prefix: "bitcoincash",
type: "p2pkh",
}).address;
expect(ecVerify(other, signature, "hello")).toBe(false);
});
});

View file

@ -0,0 +1,660 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Tests for the `sign_message` extension primitives.
*
* The value of this feature is that a signature made here verifies in OTHER
* software. That property lives entirely in the byte layout of the hash preimage
* and the signature encoding, so those are pinned against fixed vectors rather
* than round-tripped against ourselves: a round-trip would still pass if we got
* the magic string or the length prefixes wrong, and the feature would be
* silently useless.
*
* The claim that this actually matches Electron Cash is not asserted here it
* is checked against a real Electron Cash install in
* message-signing.compat.test.ts.
*/
import { describe, expect, it } from "vitest";
import {
bigIntToCompactUint,
binToHex,
encodeBase58Address,
encodeCashAddress,
hash160,
hash256,
hexToBin,
secp256k1,
utf8ToBin,
} from "@bitauth/libauth";
import {
bitcoinSignedMessageHash,
checkSignMessageResponse,
decodeMessageSignature,
encodeMessageSignature,
isSignMessageFailure,
isSignMessageRequest,
isSignMessageResponse,
messageSignatureAddress,
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
peerSignMessageInfo,
peerSupportsSignMessage,
recoverMessageSigner,
SCHEME_BITCOIN_SIGNED_MESSAGE,
SIGN_MESSAGE_EXTENSION,
signBitcoinMessage,
signMessageExtensionAdvertisement,
signMessageRequestMode,
verifyMessageSignature,
verifyMessageSignatureForAddress,
type SignMessageSuccess,
} from "./message-signing.js";
import { RelayMsgAction } from "./hdwalletv1.js";
/** Deterministic key so vectors below are stable. */
const PRIVATE_KEY = hexToBin(
"0000000000000000000000000000000000000000000000000000000000000001",
);
const PUBLIC_KEY = secp256k1.derivePublicKeyCompressed(
PRIVATE_KEY,
) as Uint8Array;
const PUBLIC_KEY_UNCOMPRESSED = secp256k1.derivePublicKeyUncompressed(
PRIVATE_KEY,
) as Uint8Array;
function cashaddr(publicKey: Uint8Array): string {
const encoded = encodeCashAddress({
payload: hash160(publicKey),
prefix: "bitcoincash",
type: "p2pkh",
});
return encoded.address;
}
const COMPRESSED_ADDRESS = cashaddr(PUBLIC_KEY);
const UNCOMPRESSED_ADDRESS = cashaddr(PUBLIC_KEY_UNCOMPRESSED);
describe("bitcoinSignedMessageHash", () => {
it("builds the preimage as compactSize(magic) || compactSize(message), double-SHA256", () => {
// Independent reconstruction. If the implementation ever stops matching
// this, signatures stop verifying in other wallets.
const magic = utf8ToBin("Bitcoin Signed Message:\n");
const body = utf8ToBin("hello");
const expected = hash256(
new Uint8Array([
...bigIntToCompactUint(BigInt(magic.length)),
...magic,
...bigIntToCompactUint(BigInt(body.length)),
...body,
]),
);
expect(binToHex(bitcoinSignedMessageHash("hello"))).toBe(
binToHex(expected),
);
});
it("uses the 0x18-prefixed Bitcoin magic, not a BCH-specific one", () => {
// BCH wallets kept Bitcoin's string verbatim; a "Bitcoin Cash Signed
// Message" variant would be incompatible with every existing verifier.
const magic = utf8ToBin("Bitcoin Signed Message:\n");
expect(magic.length).toBe(0x18);
expect(binToHex(bigIntToCompactUint(BigInt(magic.length)))).toBe("18");
});
it("length-prefixes the message so concatenations cannot collide", () => {
// Without prefixes, "ab"+"c" and "a"+"bc" would hash the same and a
// signature over one message could be presented as another.
expect(binToHex(bitcoinSignedMessageHash("abc"))).not.toBe(
binToHex(bitcoinSignedMessageHash("ab")),
);
expect(binToHex(bitcoinSignedMessageHash("a"))).not.toBe(
binToHex(bitcoinSignedMessageHash("ab")),
);
});
it("is 32 bytes and stable", () => {
const a = bitcoinSignedMessageHash("same input");
const b = bitcoinSignedMessageHash("same input");
expect(a.length).toBe(32);
expect(binToHex(a)).toBe(binToHex(b));
});
it("signs the message bytes verbatim — no trimming or normalising", () => {
// The wallet must sign exactly what the dapp displayed.
expect(binToHex(bitcoinSignedMessageHash(" x "))).not.toBe(
binToHex(bitcoinSignedMessageHash("x")),
);
// NFC vs NFD of the same grapheme must not collide either.
expect(binToHex(bitcoinSignedMessageHash("Straße"))).not.toBe(
binToHex(bitcoinSignedMessageHash("Strasse")),
);
expect(binToHex(bitcoinSignedMessageHash("é"))).not.toBe(
binToHex(bitcoinSignedMessageHash("é")),
);
});
it("handles an empty message and multi-byte UTF-8", () => {
expect(bitcoinSignedMessageHash("").length).toBe(32);
expect(bitcoinSignedMessageHash("日本語 🍅").length).toBe(32);
});
it("counts UTF-8 bytes, not characters, in the length prefix", () => {
// "🍅" is one character but four bytes. Prefixing the character count would
// produce a preimage no other implementation agrees with.
const message = "🍅";
const body = utf8ToBin(message);
expect(body.length).toBe(4);
const expected = hash256(
new Uint8Array([
0x18,
...utf8ToBin("Bitcoin Signed Message:\n"),
0x04,
...body,
]),
);
expect(binToHex(bitcoinSignedMessageHash(message))).toBe(
binToHex(expected),
);
});
it("switches to a 3-byte compactSize past 252 bytes", () => {
// Boundary in the length prefix itself — get this wrong and long messages
// silently produce signatures no other wallet accepts.
expect(bigIntToCompactUint(BigInt(252)).length).toBe(1);
expect(bigIntToCompactUint(BigInt(253)).length).toBe(3);
for (const length of [252, 253, 254]) {
const message = "a".repeat(length);
const expected = hash256(
new Uint8Array([
0x18,
...utf8ToBin("Bitcoin Signed Message:\n"),
...bigIntToCompactUint(BigInt(length)),
...utf8ToBin(message),
]),
);
expect(binToHex(bitcoinSignedMessageHash(message))).toBe(
binToHex(expected),
);
}
});
});
describe("encodeMessageSignature / decodeMessageSignature", () => {
const compact = new Uint8Array(64).fill(7);
it("produces header 27+recid+4 when compressed", () => {
for (const recoveryId of [0, 1, 2, 3]) {
const encoded = encodeMessageSignature(recoveryId, compact, true);
const decoded = decodeMessageSignature(encoded);
expect(decoded).toBeDefined();
expect(decoded!.recoveryId).toBe(recoveryId);
expect(decoded!.compressed).toBe(true);
expect(binToHex(decoded!.compactSignature)).toBe(binToHex(compact));
}
});
it("produces header 27+recid when uncompressed", () => {
for (const recoveryId of [0, 1, 2, 3]) {
const encoded = encodeMessageSignature(recoveryId, compact, false);
const decoded = decodeMessageSignature(encoded);
expect(decoded!.recoveryId).toBe(recoveryId);
expect(decoded!.compressed).toBe(false);
}
});
it("rejects a compact signature that is not 64 bytes", () => {
expect(() => encodeMessageSignature(0, new Uint8Array(63))).toThrow();
expect(() => encodeMessageSignature(0, new Uint8Array(65))).toThrow();
});
it("rejects a recovery id outside 0..3", () => {
expect(() => encodeMessageSignature(-1, compact)).toThrow();
expect(() => encodeMessageSignature(4, compact)).toThrow();
expect(() => encodeMessageSignature(1.5, compact)).toThrow();
});
it("rejects malformed signatures rather than guessing", () => {
expect(decodeMessageSignature("")).toBeUndefined();
expect(decodeMessageSignature("not base64 at all !!!")).toBeUndefined();
// 64 bytes — one short of a header plus signature.
expect(
decodeMessageSignature(encodeMessageSignature(0, compact).slice(0, 20)),
).toBeUndefined();
});
it("rejects header bytes outside the 27..34 range", () => {
for (const header of [0, 26, 35, 255]) {
const bytes = new Uint8Array(65);
bytes[0] = header;
const base64 = Buffer.from(bytes).toString("base64");
expect(decodeMessageSignature(base64)).toBeUndefined();
}
});
});
describe("signBitcoinMessage", () => {
it("round-trips through verification", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
expect(verifyMessageSignature("hello", signature, PUBLIC_KEY)).toBe(true);
expect(
verifyMessageSignatureForAddress("hello", signature, COMPRESSED_ADDRESS),
).toBe(true);
});
it("is deterministic (RFC 6979), so the same input gives the same bytes", () => {
expect(signBitcoinMessage("hello", PRIVATE_KEY)).toBe(
signBitcoinMessage("hello", PRIVATE_KEY),
);
});
it("declares compression per the option, selecting which address it proves", () => {
const compressed = signBitcoinMessage("hello", PRIVATE_KEY);
const uncompressed = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(decodeMessageSignature(compressed)!.compressed).toBe(true);
expect(decodeMessageSignature(uncompressed)!.compressed).toBe(false);
expect(messageSignatureAddress("hello", compressed)).toBe(
COMPRESSED_ADDRESS,
);
expect(messageSignatureAddress("hello", uncompressed)).toBe(
UNCOMPRESSED_ADDRESS,
);
expect(COMPRESSED_ADDRESS).not.toBe(UNCOMPRESSED_ADDRESS);
});
it("signs an empty message", () => {
const signature = signBitcoinMessage("", PRIVATE_KEY);
expect(
verifyMessageSignatureForAddress("", signature, COMPRESSED_ADDRESS),
).toBe(true);
});
it("fails loudly on an invalid private key", () => {
expect(() => signBitcoinMessage("hello", new Uint8Array(32))).toThrow();
});
});
describe("recoverMessageSigner", () => {
it("returns the key in the serialisation the header declares", () => {
const compressed = recoverMessageSigner(
"hello",
signBitcoinMessage("hello", PRIVATE_KEY),
);
expect(compressed!.compressed).toBe(true);
expect(compressed!.publicKey.length).toBe(33);
expect(binToHex(compressed!.publicKey)).toBe(binToHex(PUBLIC_KEY));
const uncompressed = recoverMessageSigner(
"hello",
signBitcoinMessage("hello", PRIVATE_KEY, { compressed: false }),
);
expect(uncompressed!.compressed).toBe(false);
expect(uncompressed!.publicKey.length).toBe(65);
expect(binToHex(uncompressed!.publicKey)).toBe(
binToHex(PUBLIC_KEY_UNCOMPRESSED),
);
});
it("returns undefined for a malformed signature", () => {
expect(recoverMessageSigner("hello", "garbage")).toBeUndefined();
});
it("returns undefined rather than a wrong key when recovery fails", () => {
// All-zero r/s cannot be recovered to a point.
const impossible = encodeMessageSignature(0, new Uint8Array(64), true);
expect(recoverMessageSigner("hello", impossible)).toBeUndefined();
});
});
describe("verifyMessageSignatureForAddress", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
it("accepts the address the signature actually proves", () => {
expect(
verifyMessageSignatureForAddress("hello", signature, COMPRESSED_ADDRESS),
).toBe(true);
});
it("rejects a signature presented for the wrong address", () => {
const other = cashaddr(
secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array,
);
expect(verifyMessageSignatureForAddress("hello", signature, other)).toBe(
false,
);
});
it("rejects a tampered message or signature", () => {
expect(
verifyMessageSignatureForAddress("hello!", signature, COMPRESSED_ADDRESS),
).toBe(false);
const raw = Buffer.from(signature, "base64");
raw[10] ^= 0x01;
expect(
verifyMessageSignatureForAddress(
"hello",
raw.toString("base64"),
COMPRESSED_ADDRESS,
),
).toBe(false);
});
it("accepts CashAddr without a prefix", () => {
const bare = COMPRESSED_ADDRESS.split(":")[1];
expect(bare).not.toContain(":");
expect(verifyMessageSignatureForAddress("hello", signature, bare)).toBe(
true,
);
});
it("accepts a legacy base58 address", () => {
const legacy = encodeBase58Address("p2pkh", hash160(PUBLIC_KEY));
expect(verifyMessageSignatureForAddress("hello", signature, legacy)).toBe(
true,
);
});
it("accepts a token-aware P2PKH address — same public key hash", () => {
const tokenAware = encodeCashAddress({
payload: hash160(PUBLIC_KEY),
prefix: "bitcoincash",
type: "p2pkhWithTokens",
}).address;
expect(
verifyMessageSignatureForAddress("hello", signature, tokenAware),
).toBe(true);
});
it("rejects a P2SH address — a script hash is not a public key hash", () => {
const p2sh = encodeCashAddress({
payload: hash160(PUBLIC_KEY),
prefix: "bitcoincash",
type: "p2sh",
}).address;
expect(verifyMessageSignatureForAddress("hello", signature, p2sh)).toBe(
false,
);
});
it("rejects nonsense addresses", () => {
for (const address of ["", "bitcoincash:qqqq", "not-an-address", "1234"]) {
expect(
verifyMessageSignatureForAddress("hello", signature, address),
).toBe(false);
}
});
// The regression that matters most. A signature made with an uncompressed
// header is a statement about a DIFFERENT address than the compressed one, so
// accepting it for the compressed address would let a proof of control over
// one address stand in for the other.
it("does not accept an uncompressed-header signature for the compressed address", () => {
const uncompressed = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(
verifyMessageSignatureForAddress(
"hello",
uncompressed,
UNCOMPRESSED_ADDRESS,
),
).toBe(true);
expect(
verifyMessageSignatureForAddress(
"hello",
uncompressed,
COMPRESSED_ADDRESS,
),
).toBe(false);
// ...and symmetrically.
expect(
verifyMessageSignatureForAddress(
"hello",
signature,
UNCOMPRESSED_ADDRESS,
),
).toBe(false);
});
});
describe("verifyMessageSignature (public key form)", () => {
it("answers 'did this key sign', so it accepts either serialisation", () => {
const compressed = signBitcoinMessage("hello", PRIVATE_KEY);
const uncompressed = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(verifyMessageSignature("hello", compressed, PUBLIC_KEY)).toBe(true);
expect(
verifyMessageSignature("hello", compressed, PUBLIC_KEY_UNCOMPRESSED),
).toBe(true);
expect(verifyMessageSignature("hello", uncompressed, PUBLIC_KEY)).toBe(
true,
);
expect(
verifyMessageSignature("hello", uncompressed, PUBLIC_KEY_UNCOMPRESSED),
).toBe(true);
});
it("rejects a different key, a tampered message, and garbage", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
const otherKey = secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array;
expect(verifyMessageSignature("hello", signature, otherKey)).toBe(false);
expect(verifyMessageSignature("hello!", signature, PUBLIC_KEY)).toBe(false);
expect(verifyMessageSignature("hello", "garbage", PUBLIC_KEY)).toBe(false);
expect(verifyMessageSignature("hello", signature, new Uint8Array(33))).toBe(
false,
);
});
});
describe("checkSignMessageResponse", () => {
function success(overrides: Partial<SignMessageSuccess> = {}) {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
const response: SignMessageSuccess = {
action: RelayMsgAction.SignMessageResponse,
sequence: 1,
time: 0,
signature,
publicKey: binToHex(PUBLIC_KEY),
address: COMPRESSED_ADDRESS,
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
...overrides,
};
return response;
}
it("accepts a self-consistent response and returns the signer", () => {
const result = checkSignMessageResponse("hello", success());
expect(result.ok).toBe(true);
if (result.ok) {
expect(binToHex(result.signer.publicKey)).toBe(binToHex(PUBLIC_KEY));
}
});
it("accepts an upper-case hex public key", () => {
const result = checkSignMessageResponse(
"hello",
success({ publicKey: binToHex(PUBLIC_KEY).toUpperCase() }),
);
expect(result.ok).toBe(true);
});
it("rejects a response whose public key did not sign", () => {
const otherKey = secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array;
const result = checkSignMessageResponse(
"hello",
success({ publicKey: binToHex(otherKey) }),
);
expect(result.ok).toBe(false);
});
it("rejects a response whose address is not the signing key's", () => {
const otherAddress = cashaddr(
secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array,
);
const result = checkSignMessageResponse(
"hello",
success({ address: otherAddress }),
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toContain("address");
});
it("rejects a malformed signature", () => {
const result = checkSignMessageResponse(
"hello",
success({ signature: "garbage" }),
);
expect(result.ok).toBe(false);
});
it("rejects a response for a different message", () => {
expect(checkSignMessageResponse("goodbye", success()).ok).toBe(false);
});
});
describe("request and response guards", () => {
const base = {
action: RelayMsgAction.SignMessageRequest,
sequence: 1,
time: 0,
message: "hello",
};
it("accepts a wallet-choice request with no key selection", () => {
expect(isSignMessageRequest(base)).toBe(true);
expect(signMessageRequestMode(base as never)).toBe(MODE_WALLET_CHOICE);
});
it("accepts a dapp-path request with both halves", () => {
const request = { ...base, path: "receive", addressIndex: 0 };
expect(isSignMessageRequest(request)).toBe(true);
expect(signMessageRequestMode(request)).toBe(MODE_DAPP_PATH);
});
it("rejects a request with only half the key selection", () => {
// Ambiguous between the two modes — guessing would sign with the wrong key.
expect(isSignMessageRequest({ ...base, path: "receive" })).toBe(false);
expect(isSignMessageRequest({ ...base, addressIndex: 0 })).toBe(false);
});
it("rejects wrong shapes", () => {
expect(isSignMessageRequest(undefined)).toBe(false);
expect(isSignMessageRequest({})).toBe(false);
expect(isSignMessageRequest({ ...base, message: 42 })).toBe(false);
expect(isSignMessageRequest({ ...base, sequence: "1" })).toBe(false);
expect(isSignMessageRequest({ ...base, action: RelayMsgAction.Ping })).toBe(
false,
);
expect(isSignMessageRequest({ ...base, path: 7, addressIndex: 0 })).toBe(
false,
);
});
it("distinguishes success from failure responses", () => {
const failure = {
action: RelayMsgAction.SignMessageResponse,
sequence: 1,
time: 0,
error: "User rejected",
};
expect(isSignMessageResponse(failure)).toBe(true);
expect(isSignMessageFailure(failure as never)).toBe(true);
const success = {
action: RelayMsgAction.SignMessageResponse,
sequence: 1,
time: 0,
signature: "sig",
publicKey: "02aa",
address: "bitcoincash:q...",
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
};
expect(isSignMessageResponse(success)).toBe(true);
expect(isSignMessageFailure(success as never)).toBe(false);
});
it("rejects a success response missing the key or address", () => {
// Both are required precisely so a dapp can always verify; a response
// without them cannot be checked and must not pass as one that can.
expect(
isSignMessageResponse({
action: RelayMsgAction.SignMessageResponse,
sequence: 1,
time: 0,
signature: "sig",
}),
).toBe(false);
});
});
describe("extension advertisement", () => {
it("advertises schemes and modes under the extension key", () => {
const advert = signMessageExtensionAdvertisement([
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
]);
expect(advert[SIGN_MESSAGE_EXTENSION]).toEqual({
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
modes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
});
});
it("gates on the mode, not just the extension's presence", () => {
const dappPathOnly = signMessageExtensionAdvertisement([MODE_DAPP_PATH]);
expect(peerSupportsSignMessage(dappPathOnly, MODE_DAPP_PATH)).toBe(true);
// The point of advertising modes: a dapp discovers this now rather than
// after showing the user a login button.
expect(peerSupportsSignMessage(dappPathOnly, MODE_WALLET_CHOICE)).toBe(
false,
);
});
it("gates on the scheme", () => {
const advert = signMessageExtensionAdvertisement([MODE_DAPP_PATH]);
expect(
peerSupportsSignMessage(advert, MODE_DAPP_PATH, "bip322" as never),
).toBe(false);
});
it("reports no support when the extension is absent", () => {
expect(peerSupportsSignMessage(undefined)).toBe(false);
expect(peerSupportsSignMessage({})).toBe(false);
expect(peerSignMessageInfo({})).toBeUndefined();
});
it("treats a detail-free advertisement as the original behaviour", () => {
// A wallet that advertised `sign_message: {}` before modes existed still
// supports a dapp-named path signed as BSM.
const info = peerSignMessageInfo({ [SIGN_MESSAGE_EXTENSION]: {} });
expect(info).toEqual({
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
modes: [MODE_DAPP_PATH],
});
expect(
peerSupportsSignMessage(
{ [SIGN_MESSAGE_EXTENSION]: {} },
MODE_WALLET_CHOICE,
),
).toBe(false);
});
});

View file

@ -0,0 +1,701 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Message signing the `sign_message` hdwalletv1 extension.
*
* WHY THIS EXISTS
*
* Dapps need a portable proof that someone controls a key: identity
* verification, SIWX-style login, and publishing a signed statement on chain.
*
* The known workaround is a dummy transaction one input with a null outpoint,
* its prevout script set to the P2PKH of the key being proven, one OP_RETURN
* output carrying a server nonce, signed but never broadcast. That does work for
* *login*, because the dapp reconstructs the sighash and checks the signature in
* the input's unlocking script. Two things it does not do:
*
* 1. It is not portable. The signature is bound to a transaction, so verifying
* it means rebuilding that exact dummy transaction and knowing how it was
* serialised. You cannot publish it in an OP_RETURN and have a third party
* check it later holding only the message, the signature and an address.
*
* 2. It asks a wallet to sign a real transaction preimage. A wallet that does
* not verify for itself that the outpoint is null is one bug away from
* signing a genuine spend. The construction below cannot be abused that way:
* the magic prefix guarantees the digest can never coincide with a
* transaction sighash, which is the property that makes blind-signing a
* message categorically safer than blind-signing a dummy transaction.
*
* So this extension produces the standard "Bitcoin Signed Message" signature.
* BCH wallets kept Bitcoin's magic string verbatim, so a signature made here
* verifies in Electron Cash, Electrum and `bitcoin-cli verifymessage`. That
* compatibility IS the feature: the magic string and both compactSize length
* prefixes are load-bearing, and message-signing.compat.test.ts checks them
* against a real Electron Cash install rather than against our own beliefs.
*
* SCOPE
*
* This module owns the construction and the verification. Verification lives
* here because both sides and third parties must agree on it byte for byte.
*
* WHAT A SIGNATURE DOES NOT PROVE
*
* It proves the holder of a key signed that exact text. Nothing about freshness,
* and nothing about audience. A captured signature is replayable forever unless
* the verifier makes the text single-use, so a login flow MUST put a server
* nonce in the message and retire it after one use. No amount of care in this
* module can supply that; see docs/dapp.md.
*/
import type { RecoveryId } from "@bitauth/libauth";
import type { WcSignMessageRequest } from "@bch-wc2/interfaces";
import {
base64ToBin,
bigIntToCompactUint,
binToBase64,
binToHex,
decodeBase58Address,
decodeCashAddress,
encodeCashAddress,
hash160,
hash256,
secp256k1,
utf8ToBin,
} from "@bitauth/libauth";
import { PathName, ProtocolMessage, RelayMsgAction } from "./hdwalletv1.js";
/// Capability advertisement
/**
* Extension name advertised in `wallet_ready` under
* `session["hdwalletv1"].extensions`.
*
* A wallet that implements `WalletAdapter.signMessage` advertises this key, so a
* dapp can hide a login button instead of offering one that fails. Wallets that
* do not are unaffected.
*
* Note this extension is unlike the ones in docs/extensions.md § 3: its actions
* live in `RelayMsgAction` and are handled by the connection managers, rather
* than riding the generic `message` events. That is deliberate for a capability
* shipped by this library see docs/extensions.md § First-party extensions.
*/
export const SIGN_MESSAGE_EXTENSION = "sign_message" as const;
/**
* The only scheme defined today: the "Bitcoin Signed Message" construction, as
* produced by Electron Cash's Sign/Verify Message and Bitcoin Core's
* `signmessage`. Recoverable compact signature, base64 encoded.
*
* The field exists so a future scheme (BIP-322, say) can be negotiated rather
* than requiring a breaking change.
*/
export const SCHEME_BITCOIN_SIGNED_MESSAGE = "bitcoin_signed_message" as const;
export type MessageSignatureScheme = typeof SCHEME_BITCOIN_SIGNED_MESSAGE;
/**
* The dapp names the key: it sends `path` and `addressIndex`, which it can only
* do if it already holds the xpub for that path. Use when the dapp needs a
* signature from one specific address it already knows about.
*/
export const MODE_DAPP_PATH = "dapp_path" as const;
/**
* The wallet chooses the key and returns it. The dapp needs no xpub at all,
* which makes this the privacy-preserving option for pure identity checks, and
* the only one that works before any xpub has been shared.
*
* A wallet supporting this mode MUST choose deterministically, returning the
* same key for the same wallet every time. A dapp uses the returned address as a
* stable identity; a wallet that picks a fresh key per connection makes a
* returning user unrecognisable and breaks login.
*/
export const MODE_WALLET_CHOICE = "wallet_choice" as const;
export type MessageSigningMode =
| typeof MODE_DAPP_PATH
| typeof MODE_WALLET_CHOICE;
/** Handshake payload advertised under the `sign_message` extension key. */
export interface SignMessageExtensionInfo {
/** Signature schemes the wallet can produce. */
schemes: MessageSignatureScheme[];
/**
* Key-selection modes the wallet supports. Advertised separately from
* `schemes` because they are independent capabilities: a wallet may be able to
* sign with a dapp-named path but have no notion of a stable identity key. A
* dapp that checked only for the extension would discover that at request
* time, after showing the user a login button.
*/
modes: MessageSigningMode[];
}
/** The advertisement a wallet with these capabilities publishes. */
export function signMessageExtensionAdvertisement(
modes: MessageSigningMode[],
schemes: MessageSignatureScheme[] = [SCHEME_BITCOIN_SIGNED_MESSAGE],
): Record<string, SignMessageExtensionInfo> {
return { [SIGN_MESSAGE_EXTENSION]: { schemes, modes } };
}
/** Read a peer's `sign_message` advertisement, if any. */
export function peerSignMessageInfo(
extensions: Record<string, unknown> | undefined,
): SignMessageExtensionInfo | undefined {
const info = extensions?.[SIGN_MESSAGE_EXTENSION];
if (!info || typeof info !== "object") return undefined;
const { schemes, modes } = info as Partial<SignMessageExtensionInfo>;
return {
// Tolerate a peer that omits either list rather than throwing: an older
// wallet that advertised the extension with no detail still supports the
// original behaviour, which is a dapp-named path signed as BSM.
schemes: Array.isArray(schemes) ? schemes : [SCHEME_BITCOIN_SIGNED_MESSAGE],
modes: Array.isArray(modes) ? modes : [MODE_DAPP_PATH],
};
}
/**
* Whether a peer can serve this request shape.
*
* @param mode - Which key-selection mode the dapp intends to use.
*/
export function peerSupportsSignMessage(
extensions: Record<string, unknown> | undefined,
mode: MessageSigningMode = MODE_DAPP_PATH,
scheme: MessageSignatureScheme = SCHEME_BITCOIN_SIGNED_MESSAGE,
): boolean {
const info = peerSignMessageInfo(extensions);
if (!info) return false;
return info.schemes.includes(scheme) && info.modes.includes(mode);
}
/// Dapp -> Wallet
/**
* Ask the wallet to sign a plain message.
*
* Extends `WcSignMessageRequest` from `@bch-wc2/interfaces` the same interface
* wallets already implement for WalletConnect so this object can be handed
* straight to an existing WC2 `signMessage` handler. hdwalletv1 adds only the
* optional key selection, mirroring how `SignTransactionRequest` wraps
* `WcSignTransactionRequest` and adds `inputPaths`.
*/
export interface SignMessageRequest
extends ProtocolMessage, WcSignMessageRequest {
action: RelayMsgAction.SignMessageRequest;
sequence: number;
/**
* The exact message to sign, as the user should see it. Signed as UTF-8; the
* wallet must not trim, normalise or re-encode it, or the signature will not
* verify against what the dapp displayed.
*
* Inherited from WcSignMessageRequest; restated because it is load-bearing.
*/
message: string;
/**
* Optional dapp-supplied context for the wallet's prompt ("Sign in to
* example.com"). Inherited from WcSignMessageRequest.
*
* This is NOT signed and it is NOT trustworthy. A wallet must render it as
* clearly subordinate to `message`, or a dapp can caption a hostile message
* with a reassuring prompt. See docs/wallet.md.
*/
userPrompt?: string;
/**
* Which HD path signs. Same vocabulary as SignTransactionRequest.inputPaths.
* Omit together with `addressIndex` to let the wallet choose the key
* (MODE_WALLET_CHOICE).
*/
path?: PathName;
/** Address index within `path`. Omit together with `path`. */
addressIndex?: number;
/** Defaults to bitcoin_signed_message when absent. */
scheme?: MessageSignatureScheme;
}
/** Which key-selection mode a request is using. */
export function signMessageRequestMode(
request: Pick<SignMessageRequest, "path" | "addressIndex">,
): MessageSigningMode {
return request.path === undefined && request.addressIndex === undefined
? MODE_WALLET_CHOICE
: MODE_DAPP_PATH;
}
/// Wallet -> Dapp
interface SignMessageResponseBase extends ProtocolMessage {
action: RelayMsgAction.SignMessageResponse;
sequence: number;
}
export interface SignMessageSuccess extends SignMessageResponseBase {
/**
* Base64 recoverable compact signature (65 bytes decoded). This is exactly a
* `WcSignMessageResponse`, so a wallet's existing WalletConnect handler's
* return value drops in here unchanged.
*/
signature: string;
/**
* Public key that produced the signature, hex.
*
* Serialised the way the signature's header byte declares 33 bytes for a
* compressed header, 65 for an uncompressed one. Those two forms hash to
* DIFFERENT addresses, so a wallet that reports the wrong one is claiming a
* proof about an address it did not prove.
*/
publicKey: string;
/**
* CashAddr of `publicKey`, for display and for explorer-side verification.
*
* Required rather than optional: under MODE_WALLET_CHOICE this is the dapp's
* only way to learn which key answered, and it is the identity a dapp stores.
*/
address: string;
scheme: MessageSignatureScheme;
/**
* The path and index the wallet actually used, when it can express them.
* Echoed back so MODE_WALLET_CHOICE is transparent rather than opaque; omitted
* when the wallet signed with a key outside any advertised path.
*/
path?: PathName;
addressIndex?: number;
error?: undefined;
}
export interface SignMessageFailure extends SignMessageResponseBase {
/** Why the message was not signed. Non-empty. */
error: string;
}
/**
* A discriminated union rather than one shape with optional fields: narrowing on
* `error` is what stops a caller reading `.address` off a rejection and treating
* an empty string as an identity.
*/
export type SignMessageResponse = SignMessageSuccess | SignMessageFailure;
export function isSignMessageRequest(msg: unknown): msg is SignMessageRequest {
const m = msg as SignMessageRequest;
if (
!m ||
typeof m !== "object" ||
m.action !== RelayMsgAction.SignMessageRequest ||
typeof m.sequence !== "number" ||
typeof m.message !== "string"
) {
return false;
}
// Key selection is all-or-nothing. A request with only one half is ambiguous
// between the two modes, so reject it rather than guess.
const hasPath = m.path !== undefined;
const hasIndex = m.addressIndex !== undefined;
if (hasPath !== hasIndex) return false;
if (
hasPath &&
(typeof m.path !== "string" || typeof m.addressIndex !== "number")
) {
return false;
}
return true;
}
export function isSignMessageResponse(
msg: unknown,
): msg is SignMessageResponse {
const m = msg as SignMessageResponse;
if (
!m ||
typeof m !== "object" ||
m.action !== RelayMsgAction.SignMessageResponse ||
typeof m.sequence !== "number"
) {
return false;
}
if (isSignMessageFailure(m)) return true;
const s = m as SignMessageSuccess;
return (
typeof s.signature === "string" &&
typeof s.publicKey === "string" &&
typeof s.address === "string"
);
}
export function isSignMessageFailure(
msg: SignMessageResponse,
): msg is SignMessageFailure {
return typeof (msg as SignMessageFailure).error === "string";
}
/// Canonical hashing
/**
* The magic prefix. Bitcoin Cash wallets kept Bitcoin's string verbatim, so a
* signature made here verifies in Electron Cash, Electrum, and anything else
* implementing `verifymessage`. Changing this byte string breaks that
* compatibility, which is the entire point of the feature.
*
* Electron Cash's own definition, for reference (electroncash/bitcoin.py):
* b"\x18Bitcoin Signed Message:\n" + var_int(len(message)) + message
* where 0x18 is the compactSize length of the magic itself.
*/
const MESSAGE_MAGIC = "Bitcoin Signed Message:\n";
/** Length-prefix a byte string the way Bitcoin's message serialisation does. */
function compactSizePrefixed(bytes: Uint8Array): Uint8Array {
const prefix = bigIntToCompactUint(BigInt(bytes.length));
const out = new Uint8Array(prefix.length + bytes.length);
out.set(prefix, 0);
out.set(bytes, prefix.length);
return out;
}
/**
* Double-SHA256 of the magic-prefixed message the digest that actually gets
* signed.
*
* Both the magic and the message are compact-size length-prefixed. Without the
* prefixes, `"ab" + "c"` and `"a" + "bc"` would hash identically, so a
* signature over one message could be presented as a signature over another.
*/
export function bitcoinSignedMessageHash(message: string): Uint8Array {
const magic = compactSizePrefixed(utf8ToBin(MESSAGE_MAGIC));
const body = compactSizePrefixed(utf8ToBin(message));
const preimage = new Uint8Array(magic.length + body.length);
preimage.set(magic, 0);
preimage.set(body, magic.length);
return hash256(preimage);
}
/// Signature encoding
/** Encode a recoverable signature the way `signmessage` does. */
export function encodeMessageSignature(
recoveryId: number,
compactSignature: Uint8Array,
compressed = true,
): string {
if (compactSignature.length !== 64) {
throw new Error(
`Compact signature must be 64 bytes, got ${compactSignature.length}`,
);
}
if (!Number.isInteger(recoveryId) || recoveryId < 0 || recoveryId > 3) {
throw new Error(`Recovery id must be 0..3, got ${recoveryId}`);
}
// 27 = uncompressed base, +4 when the pubkey is compressed. Matches Bitcoin
// Core and Electron Cash so third-party verifiers accept it.
const header = 27 + recoveryId + (compressed ? 4 : 0);
const out = new Uint8Array(65);
out[0] = header;
out.set(compactSignature, 1);
return binToBase64(out);
}
export interface DecodedMessageSignature {
recoveryId: RecoveryId;
compactSignature: Uint8Array;
/**
* Whether the header declares a compressed public key. This is not cosmetic:
* the compressed and uncompressed serialisations of one key hash to two
* different addresses, so this bit decides which address the signature is a
* proof about.
*/
compressed: boolean;
}
/** Inverse of encodeMessageSignature. Returns undefined if malformed. */
export function decodeMessageSignature(
signature: string,
): DecodedMessageSignature | undefined {
let bytes: Uint8Array;
try {
const decoded = base64ToBin(signature);
if (typeof decoded === "string") return undefined;
bytes = decoded;
} catch {
return undefined;
}
if (bytes.length !== 65) return undefined;
const header = bytes[0];
if (header < 27 || header > 34) return undefined;
const compressed = header >= 31;
const recoveryId = header - 27 - (compressed ? 4 : 0);
// Narrow to libauth's RecoveryId union by checking rather than casting: the
// header range above already implies 0..3, but an explicit check means a
// future header change cannot silently hand secp256k1 an out-of-range id.
if (
recoveryId !== 0 &&
recoveryId !== 1 &&
recoveryId !== 2 &&
recoveryId !== 3
) {
return undefined;
}
return { recoveryId, compactSignature: bytes.slice(1), compressed };
}
/// Signing
export interface SignBitcoinMessageOptions {
/**
* Whether to declare a compressed public key, which selects which of the
* key's two addresses the signature proves control of. Defaults to true;
* essentially every modern wallet uses compressed keys.
*/
compressed?: boolean;
}
/**
* Produce a "Bitcoin Signed Message" signature over `message`.
*
* Takes the private key as an argument and never retains it this module holds
* no key material and has no state. It exists so that a wallet implementing
* `WalletAdapter.signMessage` calls one function instead of reassembling the
* magic string, both length prefixes and the header byte itself. Those bytes are
* what third-party verifiers check, and they are covered by this package's
* conformance test; a wallet that reimplements them is not.
*/
export function signBitcoinMessage(
message: string,
privateKey: Uint8Array,
options: SignBitcoinMessageOptions = {},
): string {
const { compressed = true } = options;
const hash = bitcoinSignedMessageHash(message);
const recoverable = secp256k1.signMessageHashRecoverableCompact(
privateKey,
hash,
);
if (typeof recoverable === "string") {
throw new Error(`Could not sign message: ${recoverable}`);
}
return encodeMessageSignature(
recoverable.recoveryId,
recoverable.signature,
compressed,
);
}
/// Recovery and verification
export interface RecoveredSigner {
/**
* The recovered key, serialised as the signature's header declares: 33 bytes
* when `compressed`, 65 when not.
*/
publicKey: Uint8Array;
compressed: boolean;
}
/**
* Recover the public key that signed `message`.
*
* Returns the key together with the serialisation its header declared, because
* the two are inseparable: the same key yields two different addresses in its
* compressed and uncompressed forms, and dropping the flag is how a signature
* proving control of one address gets accepted as proof of the other.
*
* Returns undefined when the signature is malformed or recovery fails, so a
* caller cannot mistake a broken signature for a valid one from an unexpected
* key.
*/
export function recoverMessageSigner(
message: string,
signature: string,
): RecoveredSigner | undefined {
const decoded = decodeMessageSignature(signature);
if (!decoded) return undefined;
const hash = bitcoinSignedMessageHash(message);
const recovered = decoded.compressed
? secp256k1.recoverPublicKeyCompressed(
decoded.compactSignature,
decoded.recoveryId,
hash,
)
: secp256k1.recoverPublicKeyUncompressed(
decoded.compactSignature,
decoded.recoveryId,
hash,
);
if (typeof recovered === "string") return undefined;
return { publicKey: recovered, compressed: decoded.compressed };
}
/** Fixed-length compare. Both values are public; this just avoids an early-exit
* habit leaking somewhere it would matter. */
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
/**
* The P2PKH CashAddr the signature is a proof about.
*
* This is the primary verification entry point for third parties, and the shape
* Ron's use case needs: pull a message and signature off an explorer, derive the
* address, compare it to the identity being claimed. Electron Cash exposes only
* an address-based `verify_message` for the same reason.
*/
export function messageSignatureAddress(
message: string,
signature: string,
prefix: CashAddrPrefix = "bitcoincash",
): string | undefined {
const recovered = recoverMessageSigner(message, signature);
if (!recovered) return undefined;
const encoded = encodeCashAddress({
payload: hash160(recovered.publicKey),
prefix,
throwErrors: false,
type: "p2pkh",
});
return typeof encoded === "string" ? undefined : encoded.address;
}
/** Networks whose prefix we will try when an address arrives without one. */
const CASHADDR_PREFIXES = ["bitcoincash", "bchtest", "bchreg"] as const;
/** A CashAddr network prefix, as libauth's encoder accepts it. */
export type CashAddrPrefix = (typeof CASHADDR_PREFIXES)[number];
/** Public-key-hash of a P2PKH address, in any encoding a user might paste. */
function addressPublicKeyHash(address: string): Uint8Array | undefined {
// Re-attach a prefix rather than decoding the bare form, so the `type` field
// does the p2pkh check for us instead of version-byte arithmetic here.
const candidates = address.includes(":")
? [address]
: CASHADDR_PREFIXES.map((prefix) => `${prefix}:${address}`);
for (const candidate of candidates) {
const decoded = decodeCashAddress(candidate);
if (typeof decoded === "string") continue;
// p2pkhWithTokens carries the same pubkey hash, so a token-aware address is
// still a valid statement about this key. p2sh is not: a script hash is not
// a public key hash, and no message signature can prove control of it.
return decoded.type === "p2pkh" || decoded.type === "p2pkhWithTokens"
? decoded.payload
: undefined;
}
// Legacy base58 — still what some explorers and older tools display.
const legacy = decodeBase58Address(address);
if (typeof legacy !== "string") {
// 0 = mainnet P2PKH, 111 = testnet P2PKH. Anything else (P2SH, WIF) cannot
// be the subject of a message signature.
return legacy.version === 0 || legacy.version === 111
? legacy.payload
: undefined;
}
return undefined;
}
/**
* Whether two P2PKH addresses denote the same key, across encodings.
*
* Compares decoded public key hashes, so prefixed CashAddr, bare CashAddr, the
* token-aware form and legacy base58 all compare equal when they describe the
* same key. Returns false if either side is not a P2PKH address.
*/
export function addressesEqual(a: string, b: string): boolean {
const hashA = addressPublicKeyHash(a);
const hashB = addressPublicKeyHash(b);
return hashA !== undefined && hashB !== undefined && bytesEqual(hashA, hashB);
}
/**
* Verify a signature against an address the Electron Cash-equivalent check,
* and the one to use for identity.
*
* Accepts CashAddr with or without a prefix, and legacy base58. Rejects P2SH:
* no message signature can prove control of a script hash.
*
* Proves only that the holder of the key behind `address` signed this exact
* message. Nothing about freshness see the note at the top of this file.
*/
export function verifyMessageSignatureForAddress(
message: string,
signature: string,
address: string,
): boolean {
const expected = addressPublicKeyHash(address);
if (!expected) return false;
const recovered = recoverMessageSigner(message, signature);
if (!recovered) return false;
return bytesEqual(hash160(recovered.publicKey), expected);
}
/**
* Verify a signature against an expected public key.
*
* Answers "did the holder of this key sign this message", and so accepts either
* serialisation a key is a key. That is NOT the same question as "does this
* prove control of address A", because one key has two addresses. For identity,
* use verifyMessageSignatureForAddress.
*/
export function verifyMessageSignature(
message: string,
signature: string,
expectedPublicKey: Uint8Array,
): boolean {
const recovered = recoverMessageSigner(message, signature);
if (!recovered) return false;
// Normalise both sides to the expected serialisation before comparing, so the
// answer does not depend on which form the signature's header happened to
// declare.
if (recovered.publicKey.length === expectedPublicKey.length) {
return bytesEqual(recovered.publicKey, expectedPublicKey);
}
const normalised =
expectedPublicKey.length === 33
? secp256k1.compressPublicKey(recovered.publicKey)
: secp256k1.uncompressPublicKey(recovered.publicKey);
if (typeof normalised === "string") return false;
return bytesEqual(normalised, expectedPublicKey);
}
/**
* Check a wallet's response is internally consistent before trusting any of it.
*
* A response asserts three things a signature, the key that made it, and that
* key's address and a dapp that reads `address` without checking it against
* the signature is trusting the wallet's arithmetic. Returns the recovered
* signer so a caller can go on to compare it with a key it derived itself.
*/
export function checkSignMessageResponse(
message: string,
response: SignMessageSuccess,
): { ok: true; signer: RecoveredSigner } | { ok: false; reason: string } {
const signer = recoverMessageSigner(message, response.signature);
if (!signer) {
return { ok: false, reason: "signature is malformed or does not recover" };
}
if (binToHex(signer.publicKey) !== response.publicKey.toLowerCase()) {
return {
ok: false,
reason: `response public key ${response.publicKey} is not the key that signed (${binToHex(signer.publicKey)})`,
};
}
if (
!verifyMessageSignatureForAddress(
message,
response.signature,
response.address,
)
) {
return {
ok: false,
reason: `response address ${response.address} is not the address of the signing key`,
};
}
return { ok: true, signer };
}

View file

@ -0,0 +1,394 @@
{
"_provenance": {
"generatedBy": "Electron Cash",
"electronCashVersion": "4.4.5",
"regenerate": "contrib/generate-message-signing-vectors.py",
"note": "Signatures, addresses and preimage hashes here are produced by Electron Cash, not by this repository. They are the external reference the sign_message extension must match. Signature bytes are NOT portable across implementations (Electron Cash and libauth derive the ECDSA nonce differently), so a conforming implementation must reproduce preimageHash exactly and must VERIFY these signatures — it will not reproduce them byte for byte."
},
"vectors": [
{
"name": "one/simple/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "hello",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "H/mzy5+5Fm+EooCaer1daNEoxZIySWMG5z0nltbDsTQkXLtxnEbYsx50nq6Jn82QZkuEPAeOX8AiOmb2sIdSs7M=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "cf0447ec85f0ce7150a257db32ebfcb7523dae17c36dbd1be598779fec0484f4",
"header": 31
},
{
"name": "one/login/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IEdtg5x1UJpFg3d80PClk5yRcvyIqkPn5EASet/kIy79TKXqKJhzCEbneDWwXznsoEiXNpVFkPzlbMZBNjY0Zic=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "c8deff901075b37d065a96600a39e780cd5b911e6b09bef3a44df1913ac00977",
"header": 32
},
{
"name": "one/empty/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IMxPYD+3770VTikCiJlecbBQlSQJ4tDQ+NoN+H8y98VkcUvzCYA55DiRM2dFtuy4gVmdI3g8vYIU9X+nejVIbBk=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "80e795d4a4caadd7047af389d9f7f220562feb6196032e2131e10563352c4bcc",
"header": 32
},
{
"name": "one/multibyte/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "Straße 日本語 🍅",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IH8QSqilrlNBn/xipxitaMZVeCdfVOIvgao+vMws/gGREFeq/R5h+d4gTZDnHFmnaMVWryCGPVmYwV6Q4HA8C1E=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "565da30e2c171eb852acd72f5db4dfe0e9ed19b1b93604434a86fcd02c1bdfe2",
"header": 32
},
{
"name": "one/multiline/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "line one\nline two\n",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IHT5+p+qLR6YjjCpzy9JasLO1y2+eoVcaTrB41wgNLAZbaPNgF/1DpKUnZ+ai3ewnKq/gNGSKiHoP1qL+rTXQhk=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "64bcb7ef4eac20670b4391d659189bb5db1e801dcc0c2fbf177a8abaef27d541",
"header": 32
},
{
"name": "one/whitespace/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": " padded ",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "HzcLD0+z3ALNsSkU3UH722aYTygCMi6Fz7wugvpBUfNOCB1oXxMAWYKcszFZYwHNSMRmAYa9Xw5N2DnbZrIOCB8=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "dd1eccdff8f4766ac4ca2bc5166ea4a93129fb43e8e628f33224b63d3ae17b6d",
"header": 31
},
{
"name": "one/compactsize_252/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IC+K/+WtHc+Sbevjiwz/wE+sV+RDQDcnDTJdUuv3zSBgaGlO9C2kPEmmnOwUBNoiUitWvzAcdl5/esVgk7snC/A=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "b7b164ef991d52735c6bb888642ad7eb6b6939dc984a7fceff4376be041d142f",
"header": 32
},
{
"name": "one/compactsize_253/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "H+UwklDorwkmZoSR/lrnY2NHcBwR19Wp2ZRDEa/EWn+qbNy93haG6MtokLXI3kSxbNg+XE7F6NcE0gjGFKnVsaU=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "df167ad249ff5837e6acada677118b2ecc6757ab4cdade39caead99ef0220230",
"header": 31
},
{
"name": "one/simple/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "hello",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "G/mzy5+5Fm+EooCaer1daNEoxZIySWMG5z0nltbDsTQkXLtxnEbYsx50nq6Jn82QZkuEPAeOX8AiOmb2sIdSs7M=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "cf0447ec85f0ce7150a257db32ebfcb7523dae17c36dbd1be598779fec0484f4",
"header": 27
},
{
"name": "one/login/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HEdtg5x1UJpFg3d80PClk5yRcvyIqkPn5EASet/kIy79TKXqKJhzCEbneDWwXznsoEiXNpVFkPzlbMZBNjY0Zic=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "c8deff901075b37d065a96600a39e780cd5b911e6b09bef3a44df1913ac00977",
"header": 28
},
{
"name": "one/empty/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HMxPYD+3770VTikCiJlecbBQlSQJ4tDQ+NoN+H8y98VkcUvzCYA55DiRM2dFtuy4gVmdI3g8vYIU9X+nejVIbBk=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "80e795d4a4caadd7047af389d9f7f220562feb6196032e2131e10563352c4bcc",
"header": 28
},
{
"name": "one/multibyte/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "Straße 日本語 🍅",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HH8QSqilrlNBn/xipxitaMZVeCdfVOIvgao+vMws/gGREFeq/R5h+d4gTZDnHFmnaMVWryCGPVmYwV6Q4HA8C1E=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "565da30e2c171eb852acd72f5db4dfe0e9ed19b1b93604434a86fcd02c1bdfe2",
"header": 28
},
{
"name": "one/multiline/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "line one\nline two\n",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HHT5+p+qLR6YjjCpzy9JasLO1y2+eoVcaTrB41wgNLAZbaPNgF/1DpKUnZ+ai3ewnKq/gNGSKiHoP1qL+rTXQhk=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "64bcb7ef4eac20670b4391d659189bb5db1e801dcc0c2fbf177a8abaef27d541",
"header": 28
},
{
"name": "one/whitespace/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": " padded ",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "GzcLD0+z3ALNsSkU3UH722aYTygCMi6Fz7wugvpBUfNOCB1oXxMAWYKcszFZYwHNSMRmAYa9Xw5N2DnbZrIOCB8=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "dd1eccdff8f4766ac4ca2bc5166ea4a93129fb43e8e628f33224b63d3ae17b6d",
"header": 27
},
{
"name": "one/compactsize_252/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HC+K/+WtHc+Sbevjiwz/wE+sV+RDQDcnDTJdUuv3zSBgaGlO9C2kPEmmnOwUBNoiUitWvzAcdl5/esVgk7snC/A=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "b7b164ef991d52735c6bb888642ad7eb6b6939dc984a7fceff4376be041d142f",
"header": 28
},
{
"name": "one/compactsize_253/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "G+UwklDorwkmZoSR/lrnY2NHcBwR19Wp2ZRDEa/EWn+qbNy93haG6MtokLXI3kSxbNg+XE7F6NcE0gjGFKnVsaU=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "df167ad249ff5837e6acada677118b2ecc6757ab4cdade39caead99ef0220230",
"header": 27
},
{
"name": "arbitrary/simple/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "hello",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "IJRZMoi3AsjiH0CPOhj/zRWKP08pEf2S6az/WON3MCPjA/OElOO7WkevxPEOiRJIEXlOmaxfcwJABcrN30ZDxis=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "cf0447ec85f0ce7150a257db32ebfcb7523dae17c36dbd1be598779fec0484f4",
"header": 32
},
{
"name": "arbitrary/login/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "H50xakGxLaL7N5O2tmYci2QrAMRD+f5qQagc+gCc9fLNEIVDPwvA/gqVa87EMaas1OXxauEuHStD5wUEKV4JkdM=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "c8deff901075b37d065a96600a39e780cd5b911e6b09bef3a44df1913ac00977",
"header": 31
},
{
"name": "arbitrary/empty/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "IChpxZELMRKQbs07z68fd/rh2uczOArbCf+TvdldC0T8LYzqzadA7ch5FEaBNMuuuslJ9sfOdqn7kpHykiZ9bWA=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "80e795d4a4caadd7047af389d9f7f220562feb6196032e2131e10563352c4bcc",
"header": 32
},
{
"name": "arbitrary/multibyte/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "Straße 日本語 🍅",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "H0pkdN6MFy5qujPr5YKG85/X+D8JnVHctpuuci7XvyyVQwU4RveZ8K1wJzVr12L5mQRIKZxG3miTkDCYyPCaF50=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "565da30e2c171eb852acd72f5db4dfe0e9ed19b1b93604434a86fcd02c1bdfe2",
"header": 31
},
{
"name": "arbitrary/multiline/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "line one\nline two\n",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "IPCkRcPcJ5BdBseCSKTElT8VEcFZLwPPqtEP9b79sphsMWSsHw6j9QrA58+jwTUijIycjlTPgifI3jdSJN6Y7gA=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "64bcb7ef4eac20670b4391d659189bb5db1e801dcc0c2fbf177a8abaef27d541",
"header": 32
},
{
"name": "arbitrary/whitespace/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": " padded ",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "H0lOT0KH3MtY5dTXY7HiBVj3o3bMB2tzV05SjUs9shMnUWAtyFyk/w9uTy4OJNUetwStY4hgZsZhwo8KiZgOt74=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "dd1eccdff8f4766ac4ca2bc5166ea4a93129fb43e8e628f33224b63d3ae17b6d",
"header": 31
},
{
"name": "arbitrary/compactsize_252/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "IBZqSx7Kshur6V+YED2A4twtzjHCrVOSln4WZQmZ60oLKfeiwGD4HnEqX/R8AZSXDsZZ6UUnZC6uvSSceTAMuts=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "b7b164ef991d52735c6bb888642ad7eb6b6939dc984a7fceff4376be041d142f",
"header": 32
},
{
"name": "arbitrary/compactsize_253/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "H9scO+W8u2f0Kd/6LEgh4KnXlsFBBdPoX6US1ro25/pXeT07O2QpWobzJa//ELH7Ij0IRvcbzKCGtss21s9gM34=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "df167ad249ff5837e6acada677118b2ecc6757ab4cdade39caead99ef0220230",
"header": 31
},
{
"name": "arbitrary/simple/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "hello",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "HJRZMoi3AsjiH0CPOhj/zRWKP08pEf2S6az/WON3MCPjA/OElOO7WkevxPEOiRJIEXlOmaxfcwJABcrN30ZDxis=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "cf0447ec85f0ce7150a257db32ebfcb7523dae17c36dbd1be598779fec0484f4",
"header": 28
},
{
"name": "arbitrary/login/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "G50xakGxLaL7N5O2tmYci2QrAMRD+f5qQagc+gCc9fLNEIVDPwvA/gqVa87EMaas1OXxauEuHStD5wUEKV4JkdM=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "c8deff901075b37d065a96600a39e780cd5b911e6b09bef3a44df1913ac00977",
"header": 27
},
{
"name": "arbitrary/empty/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "HChpxZELMRKQbs07z68fd/rh2uczOArbCf+TvdldC0T8LYzqzadA7ch5FEaBNMuuuslJ9sfOdqn7kpHykiZ9bWA=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "80e795d4a4caadd7047af389d9f7f220562feb6196032e2131e10563352c4bcc",
"header": 28
},
{
"name": "arbitrary/multibyte/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "Straße 日本語 🍅",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "G0pkdN6MFy5qujPr5YKG85/X+D8JnVHctpuuci7XvyyVQwU4RveZ8K1wJzVr12L5mQRIKZxG3miTkDCYyPCaF50=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "565da30e2c171eb852acd72f5db4dfe0e9ed19b1b93604434a86fcd02c1bdfe2",
"header": 27
},
{
"name": "arbitrary/multiline/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "line one\nline two\n",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "HPCkRcPcJ5BdBseCSKTElT8VEcFZLwPPqtEP9b79sphsMWSsHw6j9QrA58+jwTUijIycjlTPgifI3jdSJN6Y7gA=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "64bcb7ef4eac20670b4391d659189bb5db1e801dcc0c2fbf177a8abaef27d541",
"header": 28
},
{
"name": "arbitrary/whitespace/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": " padded ",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "G0lOT0KH3MtY5dTXY7HiBVj3o3bMB2tzV05SjUs9shMnUWAtyFyk/w9uTy4OJNUetwStY4hgZsZhwo8KiZgOt74=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "dd1eccdff8f4766ac4ca2bc5166ea4a93129fb43e8e628f33224b63d3ae17b6d",
"header": 27
},
{
"name": "arbitrary/compactsize_252/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "HBZqSx7Kshur6V+YED2A4twtzjHCrVOSln4WZQmZ60oLKfeiwGD4HnEqX/R8AZSXDsZZ6UUnZC6uvSSceTAMuts=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "b7b164ef991d52735c6bb888642ad7eb6b6939dc984a7fceff4376be041d142f",
"header": 28
},
{
"name": "arbitrary/compactsize_253/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "G9scO+W8u2f0Kd/6LEgh4KnXlsFBBdPoX6US1ro25/pXeT07O2QpWobzJa//ELH7Ij0IRvcbzKCGtss21s9gM34=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "df167ad249ff5837e6acada677118b2ecc6757ab4cdade39caead99ef0220230",
"header": 27
}
]
}

View file

@ -0,0 +1,219 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Conformance against Electron Cash, using vectors Electron Cash produced.
*
* message-signing.test.ts checks the construction against an independent
* reconstruction of the spec which catches a coding mistake but not a
* misreading of the spec. These vectors close that gap: every hash, signature
* and address here came out of a real Electron Cash install (see
* contrib/generate-message-signing-vectors.py), so agreeing with them means
* agreeing with the software users will actually verify in.
*
* Note we can only VERIFY Electron Cash's signatures, not reproduce them: it and
* libauth derive the ECDSA nonce differently, so the signature bytes differ even
* though both are valid. The portable, reproducible quantity is the preimage
* hash, and that is asserted byte for byte.
*
* For the live round trip our signatures fed to Electron Cash's own verifier
* see message-signing.compat.test.ts.
*/
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { binToHex, hexToBin } from "@bitauth/libauth";
import {
bitcoinSignedMessageHash,
decodeMessageSignature,
messageSignatureAddress,
recoverMessageSigner,
signBitcoinMessage,
verifyMessageSignature,
verifyMessageSignatureForAddress,
} from "./message-signing.js";
interface Vector {
name: string;
privateKey: string;
message: string;
compressed: boolean;
publicKey: string;
signature: string;
cashaddr: string;
legacy: string;
preimageHash: string;
header: number;
}
const fixture = JSON.parse(
readFileSync(
new URL("./message-signing.vectors.json", import.meta.url),
"utf8",
),
) as {
_provenance: { electronCashVersion: string };
vectors: Vector[];
};
const VECTORS = fixture.vectors;
describe("Electron Cash vector fixture", () => {
it("is present and records its provenance", () => {
// Guards against a truncated or half-written regeneration silently reducing
// this file to a no-op.
expect(fixture._provenance.electronCashVersion).toMatch(/^\d+\.\d+/);
expect(VECTORS.length).toBeGreaterThanOrEqual(16);
});
it("covers both compression forms and the compactSize boundary", () => {
expect(VECTORS.some((v) => v.compressed)).toBe(true);
expect(VECTORS.some((v) => !v.compressed)).toBe(true);
expect(VECTORS.some((v) => v.message.length === 252)).toBe(true);
expect(VECTORS.some((v) => v.message.length === 253)).toBe(true);
expect(VECTORS.some((v) => v.message === "")).toBe(true);
});
});
describe.each(VECTORS)("Electron Cash vector: $name", (vector) => {
it("our preimage hash matches Electron Cash's byte for byte", () => {
// The single most load-bearing assertion in this package: it pins the magic
// string and both compactSize prefixes against the reference implementation.
expect(binToHex(bitcoinSignedMessageHash(vector.message))).toBe(
vector.preimageHash,
);
});
it("recovers the public key Electron Cash signed with, in the declared form", () => {
const recovered = recoverMessageSigner(vector.message, vector.signature);
expect(recovered).toBeDefined();
expect(recovered!.compressed).toBe(vector.compressed);
expect(binToHex(recovered!.publicKey)).toBe(vector.publicKey);
expect(recovered!.publicKey.length).toBe(vector.compressed ? 33 : 65);
});
it("decodes the header Electron Cash wrote", () => {
const decoded = decodeMessageSignature(vector.signature);
expect(decoded).toBeDefined();
expect(decoded!.compressed).toBe(vector.compressed);
expect(27 + decoded!.recoveryId + (decoded!.compressed ? 4 : 0)).toBe(
vector.header,
);
});
it("verifies against the CashAddr and the legacy address", () => {
expect(
verifyMessageSignatureForAddress(
vector.message,
vector.signature,
vector.cashaddr,
),
).toBe(true);
expect(
verifyMessageSignatureForAddress(
vector.message,
vector.signature,
vector.legacy,
),
).toBe(true);
// ...and without the CashAddr prefix, the form users paste most often.
expect(
verifyMessageSignatureForAddress(
vector.message,
vector.signature,
vector.cashaddr.split(":")[1],
),
).toBe(true);
});
it("derives the same address Electron Cash derived", () => {
expect(messageSignatureAddress(vector.message, vector.signature)).toBe(
vector.cashaddr,
);
});
it("verifies against the expected public key", () => {
expect(
verifyMessageSignature(
vector.message,
vector.signature,
hexToBin(vector.publicKey),
),
).toBe(true);
});
it("our own signature proves the same address as Electron Cash's", () => {
// Both implementations produce different bytes; what must agree is which
// address the resulting proof is about.
const ours = signBitcoinMessage(
vector.message,
hexToBin(vector.privateKey),
{
compressed: vector.compressed,
},
);
expect(messageSignatureAddress(vector.message, ours)).toBe(vector.cashaddr);
expect(
verifyMessageSignatureForAddress(vector.message, ours, vector.cashaddr),
).toBe(true);
});
it("rejects a flipped signature byte and an altered message", () => {
const raw = Buffer.from(vector.signature, "base64");
raw[10] ^= 0x01;
expect(
verifyMessageSignatureForAddress(
vector.message,
raw.toString("base64"),
vector.cashaddr,
),
).toBe(false);
expect(
verifyMessageSignatureForAddress(
`${vector.message}x`,
vector.signature,
vector.cashaddr,
),
).toBe(false);
});
});
describe("compression is not interchangeable", () => {
it("a signature never verifies for the other form's address", () => {
// Electron Cash signed the same message with the same key in both forms, so
// these pair up: two valid signatures, two different addresses, and neither
// is a proof about the other's.
const byKeyAndMessage = new Map<string, Vector[]>();
for (const vector of VECTORS) {
const key = `${vector.privateKey}|${vector.message}`;
byKeyAndMessage.set(key, [...(byKeyAndMessage.get(key) ?? []), vector]);
}
let pairs = 0;
for (const group of byKeyAndMessage.values()) {
const compressed = group.find((v) => v.compressed);
const uncompressed = group.find((v) => !v.compressed);
if (!compressed || !uncompressed) continue;
pairs++;
expect(compressed.cashaddr).not.toBe(uncompressed.cashaddr);
expect(
verifyMessageSignatureForAddress(
compressed.message,
compressed.signature,
uncompressed.cashaddr,
),
).toBe(false);
expect(
verifyMessageSignatureForAddress(
uncompressed.message,
uncompressed.signature,
compressed.cashaddr,
),
).toBe(false);
}
expect(pairs).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,14 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.compat.test.ts"],
// Every assertion spawns a full Electron Cash process.
testTimeout: 120000,
hookTimeout: 120000,
},
});

View file

@ -6,6 +6,11 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
// *.compat.test.ts shells out to Electron Cash — see vitest.compat.config.ts.
exclude: [
"**/*.integration.test.ts",
"**/*.compat.test.ts",
"**/node_modules/**",
],
},
});