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:
parent
167ec21474
commit
763754b30e
11 changed files with 2285 additions and 2 deletions
103
contrib/generate-message-signing-vectors.py
Executable file
103
contrib/generate-message-signing-vectors.py
Executable 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,
|
||||
)
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
178
packages/core/src/protocols/message-signing.compat.test.ts
Normal file
178
packages/core/src/protocols/message-signing.compat.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
660
packages/core/src/protocols/message-signing.test.ts
Normal file
660
packages/core/src/protocols/message-signing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
701
packages/core/src/protocols/message-signing.ts
Normal file
701
packages/core/src/protocols/message-signing.ts
Normal 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 };
|
||||
}
|
||||
394
packages/core/src/protocols/message-signing.vectors.json
Normal file
394
packages/core/src/protocols/message-signing.vectors.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
219
packages/core/src/protocols/message-signing.vectors.test.ts
Normal file
219
packages/core/src/protocols/message-signing.vectors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
packages/core/vitest.compat.config.ts
Normal file
14
packages/core/vitest.compat.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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/**",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue