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>
103 lines
4.3 KiB
Python
Executable file
103 lines
4.3 KiB
Python
Executable file
#!/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,
|
|
)
|
|
)
|