WizardConnect/contrib/generate-message-signing-vectors.py
Håvard Kittelsen 763754b30e 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>
2026-08-06 14:28:41 +02:00

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,
)
)