Merge branch 'feat/message-signing' into 'master'
Add message signing (sign_message extension) — verified against Electron Cash See merge request riftenlabs/lib/wizardconnect!31
This commit is contained in:
commit
7f1c623114
29 changed files with 6112 additions and 14 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
195
docs/dapp.md
195
docs/dapp.md
|
|
@ -38,9 +38,24 @@ class DappConnectionManager extends EventEmitter {
|
||||||
/** Low-level: send a fully constructed sign request. */
|
/** Low-level: send a fully constructed sign request. */
|
||||||
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
|
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
|
||||||
|
|
||||||
/** Cancel an in-flight sign request by sequence number. */
|
/** Cancel an in-flight sign request by sequence number. Works for both
|
||||||
|
* sign_transaction_request and sign_message_request. */
|
||||||
sendSignCancel(sequence: number, reason?: string): Promise<void>
|
sendSignCancel(sequence: number, reason?: string): Promise<void>
|
||||||
|
|
||||||
|
/** Ask the wallet to sign a plain message, proving key control. Resolves only
|
||||||
|
* after the result is verified. See "signMessage" below. */
|
||||||
|
signMessage(
|
||||||
|
request: { message: string; path?: PathName; addressIndex?: number;
|
||||||
|
userPrompt?: string; scheme?: MessageSignatureScheme },
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
): Promise<VerifiedMessageSignature>
|
||||||
|
|
||||||
|
/** Whether the wallet advertised sign_message for this mode and scheme. */
|
||||||
|
walletSupportsSignMessage(mode?: MessageSigningMode, scheme?: MessageSignatureScheme): boolean
|
||||||
|
|
||||||
|
/** Low-level: send a sign_message request and get the raw, UNVERIFIED response. */
|
||||||
|
sendSignMessageRequest(request: SignMessageRequest): Promise<SignMessageSuccess>
|
||||||
|
|
||||||
/** Get the next sequence number (for manual request construction). */
|
/** Get the next sequence number (for manual request construction). */
|
||||||
nextSequence(): number
|
nextSequence(): number
|
||||||
|
|
||||||
|
|
@ -327,6 +342,184 @@ dapp before the wallet app is open — the relay's time filter would otherwise d
|
||||||
original request. Dapps do not need to handle this manually; `sendSignRequest` promises remain
|
original request. Dapps do not need to handle this manually; `sendSignRequest` promises remain
|
||||||
valid across reconnects.
|
valid across reconnects.
|
||||||
|
|
||||||
|
## signMessage
|
||||||
|
|
||||||
|
Proves the user controls a key, without a transaction. The signature is a standard "Bitcoin Signed
|
||||||
|
Message" signature, so anyone can verify it from the message, the signature and the address alone —
|
||||||
|
including from an OP_RETURN via a block explorer, or by pasting it into Electron Cash's Verify
|
||||||
|
Message.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MODE_WALLET_CHOICE } from "@wizardconnect/core";
|
||||||
|
|
||||||
|
// 1. Check support before offering the feature.
|
||||||
|
if (!manager.walletSupportsSignMessage(MODE_WALLET_CHOICE)) {
|
||||||
|
return; // hide the login button rather than offer one that fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get a single-use nonce from your server. See "Replay" below — this is not optional.
|
||||||
|
const nonce = await fetch("/auth/nonce").then((r) => r.text());
|
||||||
|
|
||||||
|
// 3. Ask the wallet. Omitting path/addressIndex lets the wallet pick the key,
|
||||||
|
// so no xpub is needed.
|
||||||
|
const result = await manager.signMessage({
|
||||||
|
message: `${location.host} wants you to sign in.\nnonce=${nonce}`,
|
||||||
|
userPrompt: "Sign in",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. result is already verified. Send it to your server, which retires the nonce.
|
||||||
|
await fetch("/auth/verify", { method: "POST", body: JSON.stringify(result) });
|
||||||
|
```
|
||||||
|
|
||||||
|
### What is verified before it resolves
|
||||||
|
|
||||||
|
`signMessage()` never hands back what the wallet said unchecked. It rejects unless:
|
||||||
|
|
||||||
|
1. the signature recovers over the message that was actually sent;
|
||||||
|
2. `publicKey` is the key that signed it;
|
||||||
|
3. `address` is that key's address;
|
||||||
|
4. and, when the dapp named a path it can derive, the signer is **exactly** the key it asked for.
|
||||||
|
|
||||||
|
Without (4) a wallet could answer with a signature from any key it liked and a naive dapp would
|
||||||
|
accept it as the identity it asked about. It is the library's job, not each integrator's.
|
||||||
|
|
||||||
|
### keyBinding
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type MessageKeyBinding =
|
||||||
|
| { checked: true; path: PathName; addressIndex: number }
|
||||||
|
| { checked: false; reason: "wallet_chose_key" | "path_not_derivable" };
|
||||||
|
```
|
||||||
|
|
||||||
|
`checked: false` does **not** mean unverified — the signature always is. It means there was no
|
||||||
|
dapp-chosen key to compare against, so the proven address is the wallet's choice rather than the
|
||||||
|
dapp's selection:
|
||||||
|
|
||||||
|
- `wallet_chose_key` — the dapp omitted `path`/`addressIndex`. Normal for identity flows; the address
|
||||||
|
in the result *is* the identity.
|
||||||
|
- `path_not_derivable` — the dapp named an extension path (`stealth_scan`, say) that it has no
|
||||||
|
derivation rule for.
|
||||||
|
|
||||||
|
A dapp storing an identity should care about the difference. Naming a derivable path with no xpub
|
||||||
|
available is an error, not an unchecked result — call `signMessage` after `wallet_ready`.
|
||||||
|
|
||||||
|
### Replay — use the login challenge helpers
|
||||||
|
|
||||||
|
A signature proves key control over that exact text. It has **no freshness and no audience**: it is
|
||||||
|
valid forever, to everyone, and a captured one is replayable indefinitely. A login built on a bare
|
||||||
|
`signMessage` call works perfectly in every manual test and is a password that never expires.
|
||||||
|
|
||||||
|
So don't hand-roll the message. `@wizardconnect/core` provides a format that closes the three holes
|
||||||
|
that matter — single-use nonce, domain binding, expiry — and an API shaped so you cannot skip them.
|
||||||
|
|
||||||
|
**Server, issuing a challenge:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createLoginNonce } from "@wizardconnect/core";
|
||||||
|
|
||||||
|
const nonce = createLoginNonce(); // 16 random bytes, hex
|
||||||
|
await db.nonces.insert({ nonce, expiresAt: Date.now() + 300_000 });
|
||||||
|
return { nonce };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dapp, asking for the signature:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createLoginChallenge } from "@wizardconnect/core";
|
||||||
|
|
||||||
|
const message = createLoginChallenge({
|
||||||
|
domain: location.host,
|
||||||
|
nonce, // from the server — never generated here
|
||||||
|
expiresInSeconds: 300,
|
||||||
|
statement: "Sign in to view your positions.",
|
||||||
|
// address: known only in dapp_path mode; include it when you have it
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await manager.signMessage({ message, userPrompt: "Sign in" });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server, verifying:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { verifyLoginChallenge } from "@wizardconnect/core";
|
||||||
|
|
||||||
|
const verification = await verifyLoginChallenge(result.message, result.signature, {
|
||||||
|
domain: "app.example.com",
|
||||||
|
// MUST be atomic — two replays arrive together and only one may be told true.
|
||||||
|
consumeNonce: (nonce) => db.nonces.deleteAndReport(nonce),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verification.ok) return unauthorized(verification.reason);
|
||||||
|
session.user = verification.address; // only reachable through the ok branch
|
||||||
|
```
|
||||||
|
|
||||||
|
`domain` and `consumeNonce` are **required parameters**. There is no overload without them, because
|
||||||
|
verifying a login without single-use enforcement and audience binding is not a thing this API can
|
||||||
|
express. `createLoginChallenge` likewise refuses a nonce under 16 characters, and refuses a line
|
||||||
|
break in any field so no value can inject its own `Nonce:` line.
|
||||||
|
|
||||||
|
Checks run in a deliberate order — parse, domain, expiry, signature, **then** consume the nonce. A
|
||||||
|
bad signature therefore cannot burn a nonce the real user is still using.
|
||||||
|
|
||||||
|
The message format is deliberately similar to Sign-In With Ethereum, but it is **not** SIWE or
|
||||||
|
CAIP-122 and does not claim compatibility; there is no agreed SIWX profile for Bitcoin Cash. It is
|
||||||
|
plain text, so any wallet that can sign a message can sign it:
|
||||||
|
|
||||||
|
```
|
||||||
|
app.example.com wants you to sign in.
|
||||||
|
|
||||||
|
Address: bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h
|
||||||
|
Nonce: 8f3a21c0d4b57e69
|
||||||
|
Issued At: 2026-08-06T12:00:00Z
|
||||||
|
Expires At: 2026-08-06T12:05:00Z
|
||||||
|
Statement: Sign in to view your positions.
|
||||||
|
```
|
||||||
|
|
||||||
|
`Address` is omitted under `wallet_choice`, where the dapp does not yet know which key will answer —
|
||||||
|
pass the address from the result to `verifyLoginChallenge` instead. When the line *is* present, the
|
||||||
|
proof is self-describing (a third party reading the message alone sees which address was claimed) and
|
||||||
|
verification requires the recovered address to match it.
|
||||||
|
|
||||||
|
`createInMemoryNonceStore()` exists for development. It is per-process, so two servers behind a load
|
||||||
|
balancer will each honour the same signature once — use your database in production.
|
||||||
|
|
||||||
|
If you need a different message format, build it yourself and verify with
|
||||||
|
`verifyMessageSignatureForAddress` — but then the nonce, the domain and the expiry are all yours to
|
||||||
|
get right.
|
||||||
|
|
||||||
|
### Cancellation
|
||||||
|
|
||||||
|
Pass an `AbortSignal`, exactly as with `signTransaction`. There is no default timeout: a user
|
||||||
|
approving on a phone may take a while, and picking an arbitrary deadline for them is worse than
|
||||||
|
letting the dapp decide.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const controller = new AbortController();
|
||||||
|
const promise = manager.signMessage({ message }, { signal: controller.signal });
|
||||||
|
// user closes the dialog:
|
||||||
|
controller.abort("User cancelled");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying elsewhere
|
||||||
|
|
||||||
|
`@wizardconnect/core` exports the verification helpers, so a server (or any third party) can check a
|
||||||
|
signature without a session:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
verifyMessageSignatureForAddress,
|
||||||
|
messageSignatureAddress,
|
||||||
|
} from "@wizardconnect/core";
|
||||||
|
|
||||||
|
verifyMessageSignatureForAddress(message, signature, address); // the Electron Cash-equivalent check
|
||||||
|
messageSignatureAddress(message, signature); // recover the address instead
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts CashAddr with or without a prefix, and legacy base58. Rejects P2SH — no message signature can
|
||||||
|
prove control of a script hash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Using initiateDappRelay without DappConnectionManager
|
## Using initiateDappRelay without DappConnectionManager
|
||||||
|
|
||||||
If you need lower-level control (e.g., in the test-cli), you can work directly with the
|
If you need lower-level control (e.g., in the test-cli), you can work directly with the
|
||||||
|
|
|
||||||
|
|
@ -209,5 +209,111 @@ wallet doesn't support, inform the user rather than failing silently.
|
||||||
| `bch_stealth_bip352` | `stealth_spend`, `stealth_scan` | `m/352'/145'/0'/0'`, `m/352'/145'/0'/1'` | BCH stealth addresses (BIP352 structure). Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
|
| `bch_stealth_bip352` | `stealth_spend`, `stealth_scan` | `m/352'/145'/0'/0'`, `m/352'/145'/0'/1'` | BCH stealth addresses (BIP352 structure). Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
|
||||||
| `rpa_bip47` | `rpa_spend`, `rpa_scan` | `m/47'/145'/0'/0'`, `m/47'/145'/0'/1'` | BIP47 reusable payment addresses. Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
|
| `rpa_bip47` | `rpa_spend`, `rpa_scan` | `m/47'/145'/0'/0'`, `m/47'/145'/0'/1'` | BIP47 reusable payment addresses. Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
|
||||||
| `decrypt` | — | — | Dapp-side encrypted storage. Wallet provides a public key; dapp encrypts data for storage and sends `decrypt_request` messages when the data is needed. | Proposed |
|
| `decrypt` | — | — | Dapp-side encrypted storage. Wallet provides a public key; dapp encrypts data for storage and sends `decrypt_request` messages when the data is needed. | Proposed |
|
||||||
|
| `sign_message` | — | — | Sign a plain message to prove key control, without a transaction. Portable "Bitcoin Signed Message" signature — verifiable in Electron Cash, Electrum and `bitcoin-cli verifymessage`. | Implemented — see below |
|
||||||
|
|
||||||
See the discussions and specifications for each extension as they are formalized.
|
See the discussions and specifications for each extension as they are formalized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First-party extensions
|
||||||
|
|
||||||
|
`sign_message` does not follow § 3 above. Its actions live in the `RelayMsgAction` enum and are
|
||||||
|
handled by `WalletConnectionManager` and `DappConnectionManager` directly, rather than riding the
|
||||||
|
generic `message` / `messagereceived` events.
|
||||||
|
|
||||||
|
That is a **new pattern**, not an existing convention — the only prior enum-plus-advertisement
|
||||||
|
capability is `chunk`, which is transport-level and so lives outside the hdwalletv1 extension system
|
||||||
|
entirely. The distinction is about who implements the extension:
|
||||||
|
|
||||||
|
- **Third-party extensions** define their own action strings and are handled by the host app through
|
||||||
|
the generic message events (§ 3). This library never needs to know they exist.
|
||||||
|
- **First-party extensions** ship in this library with manager support, verification helpers and
|
||||||
|
tests. Routing those through the generic events would force every consumer to hand-roll the
|
||||||
|
request/response plumbing for a capability the library already implements.
|
||||||
|
|
||||||
|
Support is still advertised through the same `session.extensions` mechanism (§ 1), so discovery is
|
||||||
|
uniform and a wallet that does not implement the capability is unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `sign_message`
|
||||||
|
|
||||||
|
Advertised under the `sign_message` key:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extensions": {
|
||||||
|
"sign_message": {
|
||||||
|
"schemes": ["bitcoin_signed_message"],
|
||||||
|
"modes": ["dapp_path", "wallet_choice"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`schemes` and `modes` are advertised separately 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, and a dapp
|
||||||
|
that checked only for the extension's presence would discover that at request time — after the user
|
||||||
|
had already clicked a login button.
|
||||||
|
|
||||||
|
| Mode | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `dapp_path` | The dapp sends `path` + `addressIndex`. Requires the dapp to hold that path's xpub. |
|
||||||
|
| `wallet_choice` | The dapp sends neither; the wallet picks the key and returns its address. No xpub needed. |
|
||||||
|
|
||||||
|
An older wallet that advertised `"sign_message": {}` before these fields existed is read as
|
||||||
|
`{schemes: ["bitcoin_signed_message"], modes: ["dapp_path"]}` — the original behaviour.
|
||||||
|
|
||||||
|
### Wallet side
|
||||||
|
|
||||||
|
Implement `WalletAdapter.signMessage` — that alone is what advertises the extension, so the handshake
|
||||||
|
cannot claim support an adapter does not have. Declare `signMessageModes()` if the wallet supports
|
||||||
|
`wallet_choice`. An adapter that returns its own `sign_message` entry from `getExtensions()` wins;
|
||||||
|
the automatic advertisement never overwrites an explicit one.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { signBitcoinMessage, MODE_DAPP_PATH, MODE_WALLET_CHOICE } from "@wizardconnect/core";
|
||||||
|
|
||||||
|
const adapter: WalletAdapter = {
|
||||||
|
// ... core implementation ...
|
||||||
|
|
||||||
|
signMessageModes: () => [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
|
||||||
|
|
||||||
|
async signMessage(request) {
|
||||||
|
const index = request.addressIndex ?? 0;
|
||||||
|
const privateKey = derivePrivateKey(request.path ?? "receive", index);
|
||||||
|
return {
|
||||||
|
signature: signBitcoinMessage(request.message, privateKey),
|
||||||
|
path: request.path ?? "receive",
|
||||||
|
addressIndex: index,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `signBitcoinMessage()` rather than assembling the construction. The magic string and both
|
||||||
|
compactSize length prefixes are what third-party verifiers check, and they are covered by this
|
||||||
|
repository's conformance tests against a real Electron Cash install; a reimplementation is not.
|
||||||
|
|
||||||
|
### Dapp side
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createLoginChallenge, MODE_WALLET_CHOICE } from "@wizardconnect/core";
|
||||||
|
|
||||||
|
if (!manager.walletSupportsSignMessage(MODE_WALLET_CHOICE)) {
|
||||||
|
// Hide the login button rather than offering one that fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the message with the login-challenge helper, not by hand — it carries the
|
||||||
|
// single-use nonce, the domain and the expiry that stop a signature being replayed.
|
||||||
|
const result = await manager.signMessage({
|
||||||
|
message: createLoginChallenge({ domain: location.host, nonce, expiresInSeconds: 300 }),
|
||||||
|
userPrompt: "Sign in",
|
||||||
|
});
|
||||||
|
// result is already verified; result.address is the proven identity. The server then
|
||||||
|
// calls verifyLoginChallenge(), which retires the nonce.
|
||||||
|
```
|
||||||
|
|
||||||
|
See [protocol.md § sign_message](protocol.md#sign_message) for the wire format,
|
||||||
|
[wallet.md](wallet.md#signmessage) for the adapter contract and display requirements, and
|
||||||
|
[dapp.md](dapp.md#signmessage) for what is verified and why replay is the dapp's job.
|
||||||
|
|
|
||||||
128
docs/protocol.md
128
docs/protocol.md
|
|
@ -35,6 +35,11 @@ wallet_ready — wallet → dapp, signals wallet is alive + deliver
|
||||||
sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
|
sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
|
||||||
sign_transaction_response — wallet → dapp, returns signed tx or error
|
sign_transaction_response — wallet → dapp, returns signed tx or error
|
||||||
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
|
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
|
||||||
|
or sign_message_request
|
||||||
|
sign_message_request — dapp → wallet, asks wallet to sign a plain message, proving key
|
||||||
|
control without a transaction. Gated on the `sign_message`
|
||||||
|
extension. See extensions.md.
|
||||||
|
sign_message_response — wallet → dapp, returns the signature, the signing key and its address
|
||||||
disconnect — either → either, courtesy notification before tearing down
|
disconnect — either → either, courtesy notification before tearing down
|
||||||
chunk — either → either, transport-level. Carries one slice of a larger message
|
chunk — either → either, transport-level. Carries one slice of a larger message
|
||||||
that exceeds NIP-44's 65,535-byte plaintext ceiling. Not tied to
|
that exceeds NIP-44's 65,535-byte plaintext ceiling. Not tied to
|
||||||
|
|
@ -335,12 +340,20 @@ interface SignCancelMessage {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Sent by the **dapp only** to cancel an in-flight `sign_transaction_request`. The wallet should
|
Sent by the **dapp only** to cancel an in-flight `sign_transaction_request` **or
|
||||||
dismiss the corresponding sign dialog immediately upon receipt.
|
`sign_message_request`**. The wallet should dismiss the corresponding dialog immediately upon
|
||||||
|
receipt.
|
||||||
|
|
||||||
|
One `sign_cancel` unambiguously names one request of either kind, because both draw their
|
||||||
|
`sequence` from a single per-connection counter (`RelayClient.nextSequence`). That shared sequence
|
||||||
|
space is also why the wallet's dedup guard is shared: a sequence identifies a request regardless of
|
||||||
|
its action.
|
||||||
|
|
||||||
Use cases:
|
Use cases:
|
||||||
- User presses cancel on the dapp side while waiting for the wallet to sign.
|
- User presses cancel on the dapp side while waiting for the wallet to sign.
|
||||||
- Dapp replaces a stale request with a new one (e.g., trade price has changed).
|
- Dapp replaces a stale request with a new one (e.g., trade price has changed).
|
||||||
|
- A login prompt the user never answered. `sign_message` has no timeout by design — cancellation is
|
||||||
|
explicit, matching `sign_transaction_request`.
|
||||||
|
|
||||||
**Dapp side** (`DappConnectionManager`):
|
**Dapp side** (`DappConnectionManager`):
|
||||||
- `sendSignCancel(sequence, reason?)` — immediately rejects the pending Promise for that sequence,
|
- `sendSignCancel(sequence, reason?)` — immediately rejects the pending Promise for that sequence,
|
||||||
|
|
@ -352,6 +365,117 @@ Use cases:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## sign_message
|
||||||
|
|
||||||
|
Proves control of a key without a transaction: identity verification, SIWX-style login, or a signed
|
||||||
|
statement that can be published on chain. Gated on the `sign_message` extension — see
|
||||||
|
[extensions.md](extensions.md#sign_message).
|
||||||
|
|
||||||
|
The signature is the standard **"Bitcoin Signed Message"** construction, so it verifies in Electron
|
||||||
|
Cash, Electrum and `bitcoin-cli verifymessage`. That portability is the point: a third party holding
|
||||||
|
only the message, the signature and an address can check it, with no knowledge of this protocol.
|
||||||
|
|
||||||
|
### sign_message_request
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SignMessageRequest {
|
||||||
|
action: "sign_message_request";
|
||||||
|
sequence: number;
|
||||||
|
message: string; // exact UTF-8 to sign — never trimmed or normalised
|
||||||
|
userPrompt?: string; // dapp-supplied context for the wallet's prompt; NOT signed
|
||||||
|
path?: PathName; // omit both to let the wallet choose the key
|
||||||
|
addressIndex?: number;
|
||||||
|
scheme?: "bitcoin_signed_message"; // default when absent
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SignMessageRequest` extends `WcSignMessageRequest` from `@bch-wc2/interfaces` — the interface
|
||||||
|
wallets already implement for WalletConnect — so the object can be passed straight to an existing
|
||||||
|
WC2 `signMessage` handler. hdwalletv1 adds only the optional key selection, mirroring how
|
||||||
|
`SignTransactionRequest` wraps `WcSignTransactionRequest` and adds `inputPaths`.
|
||||||
|
|
||||||
|
**Key selection is all-or-nothing.** `path` and `addressIndex` must both be present or both absent;
|
||||||
|
half of one is ambiguous between the two modes and is rejected.
|
||||||
|
|
||||||
|
| Mode | Request | Who picks the key | Needs an xpub? |
|
||||||
|
|------|---------|-------------------|----------------|
|
||||||
|
| `dapp_path` | `path` + `addressIndex` set | dapp | yes |
|
||||||
|
| `wallet_choice` | both omitted | wallet | no |
|
||||||
|
|
||||||
|
`wallet_choice` exists for pure identity checks, where requiring an xpub would mean sharing the
|
||||||
|
user's whole address history to prove control of one key. A wallet advertising it **must** choose
|
||||||
|
deterministically: a dapp treats the returned address as a durable identity, so a fresh key per
|
||||||
|
connection makes a returning user unrecognisable.
|
||||||
|
|
||||||
|
### sign_message_response
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Success
|
||||||
|
interface SignMessageSuccess {
|
||||||
|
action: "sign_message_response";
|
||||||
|
sequence: number;
|
||||||
|
signature: string; // base64, 65 bytes decoded — a WcSignMessageResponse
|
||||||
|
publicKey: string; // hex, in the serialisation the signature's header declares
|
||||||
|
address: string; // CashAddr of publicKey
|
||||||
|
scheme: "bitcoin_signed_message";
|
||||||
|
path?: PathName; // echoed: what the wallet actually used
|
||||||
|
addressIndex?: number;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure
|
||||||
|
interface SignMessageFailure {
|
||||||
|
action: "sign_message_response";
|
||||||
|
sequence: number;
|
||||||
|
error: string;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`publicKey` and `address` are required on success. Under `wallet_choice` they are the dapp's only
|
||||||
|
way to learn which key answered, and requiring them means a dapp can always verify rather than
|
||||||
|
sometimes.
|
||||||
|
|
||||||
|
**The compression bit is load-bearing.** The signature's header byte declares whether the public key
|
||||||
|
is compressed, and a key's compressed and uncompressed forms hash to **two different addresses**. A
|
||||||
|
response must report the form its header declares, or it is claiming a proof about an address it did
|
||||||
|
not prove. `recoverMessageSigner()` returns `{ publicKey, compressed }` together for this reason.
|
||||||
|
|
||||||
|
### What the wallet checks
|
||||||
|
|
||||||
|
`WalletConnectionManager.sendSignMessageResponse()` derives `publicKey` and `address` from the
|
||||||
|
signature by recovery rather than accepting them from the adapter, so the three can never disagree.
|
||||||
|
It then compares the recovered key against the adapter's own key for the path, and throws on
|
||||||
|
mismatch. Recovery alone cannot detect a signature over the wrong text — it succeeds and yields some
|
||||||
|
other key — so this comparison is what turns a wallet-side bug into an error at the call site
|
||||||
|
instead of an opaque rejection across the relay.
|
||||||
|
|
||||||
|
### What the dapp checks
|
||||||
|
|
||||||
|
`DappConnectionManager.signMessage()` resolves only after verifying that the signature recovers over
|
||||||
|
the message it sent, that `publicKey` is the key that signed, that `address` is that key's address,
|
||||||
|
and — when the dapp named a path it can derive — that the signer is exactly the key it asked for.
|
||||||
|
Anything inconsistent rejects. See [dapp.md](dapp.md#signmessage).
|
||||||
|
|
||||||
|
### Replay is the verifier's responsibility
|
||||||
|
|
||||||
|
A signature proves key control over that exact text. It carries **no freshness and no audience**: it
|
||||||
|
is valid forever and to everyone. A login flow must put a single-use, server-issued nonce in
|
||||||
|
`message` and retire it after one use; nothing at the protocol level can enforce that.
|
||||||
|
|
||||||
|
Use `createLoginChallenge()` / `verifyLoginChallenge()` from `@wizardconnect/core` rather than
|
||||||
|
composing the text yourself — `verifyLoginChallenge` cannot be called without a nonce-consuming
|
||||||
|
callback and an expected domain, which is the enforcement this layer cannot provide. See
|
||||||
|
[dapp.md § Replay](dapp.md#replay--use-the-login-challenge-helpers).
|
||||||
|
|
||||||
|
### Re-delivery on reconnect
|
||||||
|
|
||||||
|
Handled exactly like `sign_transaction_request`: the dapp re-sends pending requests on
|
||||||
|
`wallet_ready`, and the wallet's shared dedup guard means a replay does not re-prompt the user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## disconnect
|
## disconnect
|
||||||
|
|
||||||
Either side may send a `disconnect` message before tearing down the relay connection. This is a
|
Either side may send a `disconnect` message before tearing down the relay connection. This is a
|
||||||
|
|
|
||||||
105
docs/wallet.md
105
docs/wallet.md
|
|
@ -34,6 +34,13 @@ interface WalletAdapter {
|
||||||
|
|
||||||
/** Optional: extension data for the session handshake. */
|
/** Optional: extension data for the session handshake. */
|
||||||
getExtensions?(): Record<string, unknown>;
|
getExtensions?(): Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Optional: sign a plain message to prove key control. Implementing this is what
|
||||||
|
* advertises the `sign_message` extension to dapps. See § signMessage below. */
|
||||||
|
signMessage?(request: SignMessageRequest): Promise<SignMessageResult>;
|
||||||
|
|
||||||
|
/** Optional: key-selection modes signMessage supports. Defaults to [MODE_DAPP_PATH]. */
|
||||||
|
signMessageModes?(): MessageSigningMode[];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -58,6 +65,22 @@ interface SignTransactionResult {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### SignMessageResult
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SignMessageResult {
|
||||||
|
signature: string; // base64 — build it with signBitcoinMessage()
|
||||||
|
addressPrefix?: CashAddrPrefix; // default "bitcoincash"; set on testnet
|
||||||
|
path?: PathName; // which key was used, when the wallet can say
|
||||||
|
addressIndex?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Deliberately just the signature. The public key and address are **not** asked for, because both are
|
||||||
|
recoverable from the signature and `WalletConnectionManager` derives them that way — so the three
|
||||||
|
values in the response can never disagree, and an adapter cannot accidentally claim a proof about an
|
||||||
|
address it did not prove.
|
||||||
|
|
||||||
## WalletConnectionManager
|
## WalletConnectionManager
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -83,9 +106,17 @@ class WalletConnectionManager extends EventEmitter {
|
||||||
// Send an error back to the dapp (user rejected, signing failed, etc.)
|
// Send an error back to the dapp (user rejected, signing failed, etc.)
|
||||||
sendSignError(connectionId: string, sequence: number, errorMessage: string): Promise<void>
|
sendSignError(connectionId: string, sequence: number, errorMessage: string): Promise<void>
|
||||||
|
|
||||||
|
// Send a message signature back to the dapp. Derives the public key and address
|
||||||
|
// from the signature; throws if it does not match the requested message and key.
|
||||||
|
sendSignMessageResponse(connectionId: string, sequence: number, result: SignMessageResult): Promise<void>
|
||||||
|
|
||||||
|
// Refuse a message-signing request (user rejected, unsupported, etc.)
|
||||||
|
sendSignMessageError(connectionId: string, sequence: number, errorMessage: string): Promise<void>
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
on("connectionStatusChanged", (id: string, status: RelayStatus) => void)
|
on("connectionStatusChanged", (id: string, status: RelayStatus) => void)
|
||||||
on("pendingSignRequest", (req: PendingSignRequest) => void)
|
on("pendingSignRequest", (req: PendingSignRequest) => void)
|
||||||
|
on("pendingSignMessageRequest", (req: PendingSignMessageRequest) => void)
|
||||||
on("connectionsChanged", () => void)
|
on("connectionsChanged", () => void)
|
||||||
on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void)
|
on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void)
|
||||||
on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages
|
on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages
|
||||||
|
|
@ -185,6 +216,80 @@ docs for the security rationale. Because the dapp specifies `inputPaths`, the wa
|
||||||
dapp's key selection — `SIGHASH_ALL` is what makes this safe (a wrong-key signature is simply
|
dapp's key selection — `SIGHASH_ALL` is what makes this safe (a wrong-key signature is simply
|
||||||
invalid and cannot be repurposed).
|
invalid and cannot be repurposed).
|
||||||
|
|
||||||
|
## signMessage
|
||||||
|
|
||||||
|
Optional. Implementing it advertises the `sign_message` extension; omitting it leaves the wallet
|
||||||
|
working exactly as before, and a dapp that asks anyway gets an explicit error rather than silence.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { signBitcoinMessage, MODE_DAPP_PATH, MODE_WALLET_CHOICE } from "@wizardconnect/core";
|
||||||
|
|
||||||
|
signMessageModes: () => [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
|
||||||
|
|
||||||
|
async signMessage(request) {
|
||||||
|
const index = request.addressIndex ?? 0;
|
||||||
|
const privateKey = derivePrivateKey(request.path ?? "receive", index);
|
||||||
|
return {
|
||||||
|
signature: signBitcoinMessage(request.message, privateKey),
|
||||||
|
path: request.path ?? "receive",
|
||||||
|
addressIndex: index,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Then approve it like a transaction:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
manager.on("pendingSignMessageRequest", async ({ connectionId, request }) => {
|
||||||
|
const approved = await showMessageSigningDialog(request); // your UI
|
||||||
|
if (!approved) {
|
||||||
|
await manager.sendSignMessageError(connectionId, request.sequence, "User rejected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await adapter.signMessage!(request);
|
||||||
|
await manager.sendSignMessageResponse(connectionId, request.sequence, result);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
**Sign the message verbatim.** UTF-8, no trimming, no Unicode normalisation. The signature must
|
||||||
|
verify against exactly the text the dapp displayed; re-encoding it produces a signature that
|
||||||
|
verifies against nothing.
|
||||||
|
|
||||||
|
**Use `signBitcoinMessage()`.** The magic string and both compactSize length prefixes are what
|
||||||
|
third-party verifiers check. This repository tests them against a real Electron Cash install
|
||||||
|
(`npm run test:compat --workspace @wizardconnect/core`) and against committed vectors Electron Cash
|
||||||
|
generated; a hand-rolled reimplementation in a wallet gets neither.
|
||||||
|
|
||||||
|
**Choose deterministically under `wallet_choice`.** A dapp treats the returned address as a durable
|
||||||
|
identity. If the wallet picks a different key per connection, a returning user is unrecognisable and
|
||||||
|
login breaks. Only advertise `MODE_WALLET_CHOICE` if the choice is stable across restarts. Consider a
|
||||||
|
dedicated identity key rather than `receive/0`, so proving identity does not link it to the user's
|
||||||
|
main address history.
|
||||||
|
|
||||||
|
**Echo the path used.** It is how a dapp learns which key answered under `wallet_choice`, and it lets
|
||||||
|
`sendSignMessageResponse` check the signature against the key that should have produced it.
|
||||||
|
|
||||||
|
### Display requirements
|
||||||
|
|
||||||
|
`sendSignMessageResponse` verifies the cryptography. It cannot verify that the user understood what
|
||||||
|
they signed, which is the wallet's job:
|
||||||
|
|
||||||
|
- **Show `request.message` in full, verbatim.** Do not truncate it. The user is signing every byte,
|
||||||
|
including trailing whitespace and newlines.
|
||||||
|
- **`request.userPrompt` is dapp-supplied and unsigned.** Render it as clearly subordinate to the
|
||||||
|
message and attribute it to the dapp. Presented as equal, it lets a dapp caption hostile text
|
||||||
|
reassuringly.
|
||||||
|
- **Flag adversarial text.** Bidirectional overrides, zero-width characters, control characters and
|
||||||
|
very long messages can all make signed text display as something other than what it is.
|
||||||
|
- Message signing is safer to approve than a dummy transaction — the magic prefix guarantees the
|
||||||
|
digest can never coincide with a transaction sighash, so no message signature can ever authorise a
|
||||||
|
spend. That is a reason to prefer it over "sign this unspendable transaction" patterns, not a
|
||||||
|
reason to show the user less.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Minimal example
|
## Minimal example
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
|
"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:prettier": "prettier --ignore-path ../../.gitignore . --list-different",
|
||||||
"lint:eslint": "eslint .",
|
"lint:eslint": "eslint .",
|
||||||
"lint": "npm run lint:eslint && npm run lint:prettier",
|
"lint": "npm run lint:eslint && npm run lint:prettier",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export type {
|
||||||
KeyExchangeURIResult,
|
KeyExchangeURIResult,
|
||||||
} from "./key-exchange.js";
|
} from "./key-exchange.js";
|
||||||
export * from "./protocols/hdwalletv1.js";
|
export * from "./protocols/hdwalletv1.js";
|
||||||
|
export * from "./protocols/message-signing.js";
|
||||||
|
export * from "./protocols/login-challenge.js";
|
||||||
export * from "./protocols/base.js";
|
export * from "./protocols/base.js";
|
||||||
export {
|
export {
|
||||||
CHUNK_EXTENSION_NAME,
|
CHUNK_EXTENSION_NAME,
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,15 @@ export enum RelayMsgAction {
|
||||||
/// Request: The dapp wants wallet to sign a transaction.
|
/// Request: The dapp wants wallet to sign a transaction.
|
||||||
SignTransactionRequest = "sign_transaction_request",
|
SignTransactionRequest = "sign_transaction_request",
|
||||||
SignTransactionResponse = "sign_transaction_response",
|
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",
|
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.
|
/// Courtesy notification: one side is closing the connection.
|
||||||
Disconnect = "disconnect",
|
Disconnect = "disconnect",
|
||||||
/// Transport-level: carries one slice of a message that exceeds NIP-44's
|
/// Transport-level: carries one slice of a message that exceeds NIP-44's
|
||||||
|
|
|
||||||
453
packages/core/src/protocols/login-challenge.test.ts
Normal file
453
packages/core/src/protocols/login-challenge.test.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login challenge tests.
|
||||||
|
*
|
||||||
|
* These exist because the failure mode is silent: a login built without single-use
|
||||||
|
* nonce enforcement, domain binding or expiry works perfectly in every manual test
|
||||||
|
* and is permanently replayable in production. So the tests here are mostly about
|
||||||
|
* what must be REFUSED — the replay, the wrong site, the stale challenge, the
|
||||||
|
* injected field — rather than the happy path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
encodeBase58Address,
|
||||||
|
encodeCashAddress,
|
||||||
|
hash160,
|
||||||
|
hexToBin,
|
||||||
|
secp256k1,
|
||||||
|
} from "@bitauth/libauth";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createInMemoryNonceStore,
|
||||||
|
createLoginChallenge,
|
||||||
|
createLoginNonce,
|
||||||
|
DEFAULT_MAX_AGE_SECONDS,
|
||||||
|
MIN_NONCE_LENGTH,
|
||||||
|
parseLoginChallenge,
|
||||||
|
verifyLoginChallenge,
|
||||||
|
} from "./login-challenge.js";
|
||||||
|
import { signBitcoinMessage } from "./message-signing.js";
|
||||||
|
|
||||||
|
const PRIVATE_KEY = hexToBin("00".repeat(31) + "01");
|
||||||
|
const OTHER_KEY = hexToBin("00".repeat(31) + "02");
|
||||||
|
|
||||||
|
function addressOf(privateKey: Uint8Array): string {
|
||||||
|
const publicKey = secp256k1.derivePublicKeyCompressed(
|
||||||
|
privateKey,
|
||||||
|
) as Uint8Array;
|
||||||
|
return encodeCashAddress({
|
||||||
|
payload: hash160(publicKey),
|
||||||
|
prefix: "bitcoincash",
|
||||||
|
type: "p2pkh",
|
||||||
|
}).address;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADDRESS = addressOf(PRIVATE_KEY);
|
||||||
|
const OTHER_ADDRESS = addressOf(OTHER_KEY);
|
||||||
|
|
||||||
|
const DOMAIN = "app.example.com";
|
||||||
|
const NONCE = "8f3a21c0d4b57e69";
|
||||||
|
const NOW = Date.parse("2026-08-06T12:00:00Z");
|
||||||
|
const ISSUED_AT = "2026-08-06T12:00:00Z";
|
||||||
|
|
||||||
|
/** A challenge plus its signature, and a store that has issued the nonce. */
|
||||||
|
function scenario(
|
||||||
|
overrides: Partial<Parameters<typeof createLoginChallenge>[0]> = {},
|
||||||
|
privateKey = PRIVATE_KEY,
|
||||||
|
) {
|
||||||
|
const message = createLoginChallenge({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: NONCE,
|
||||||
|
address: ADDRESS,
|
||||||
|
issuedAt: ISSUED_AT,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const store = createInMemoryNonceStore();
|
||||||
|
store.issue(overrides.nonce ?? NONCE);
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
signature: signBitcoinMessage(message, privateKey),
|
||||||
|
store,
|
||||||
|
verify: (options: Record<string, unknown> = {}) =>
|
||||||
|
verifyLoginChallenge(message, signBitcoinMessage(message, privateKey), {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: store.consume,
|
||||||
|
now: NOW,
|
||||||
|
...options,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createLoginNonce", () => {
|
||||||
|
it("produces distinct, long-enough nonces", () => {
|
||||||
|
const a = createLoginNonce();
|
||||||
|
const b = createLoginNonce();
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(a.length).toBeGreaterThanOrEqual(MIN_NONCE_LENGTH);
|
||||||
|
expect(a).toMatch(/^[0-9a-f]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a size that would be guessable", () => {
|
||||||
|
expect(() => createLoginNonce(4)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createLoginChallenge", () => {
|
||||||
|
it("produces the documented layout", () => {
|
||||||
|
expect(
|
||||||
|
createLoginChallenge({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: NONCE,
|
||||||
|
address: ADDRESS,
|
||||||
|
issuedAt: ISSUED_AT,
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
`${DOMAIN} wants you to sign in.\n` +
|
||||||
|
`\n` +
|
||||||
|
`Address: ${ADDRESS}\n` +
|
||||||
|
`Nonce: ${NONCE}\n` +
|
||||||
|
`Issued At: ${ISSUED_AT}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the address when the dapp does not know it yet", () => {
|
||||||
|
// MODE_WALLET_CHOICE: the wallet has not told us which key it will use.
|
||||||
|
const message = createLoginChallenge({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: NONCE,
|
||||||
|
issuedAt: ISSUED_AT,
|
||||||
|
});
|
||||||
|
expect(message).not.toContain("Address:");
|
||||||
|
expect(parseLoginChallenge(message)?.address).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes an expiry and a statement when asked", () => {
|
||||||
|
const message = createLoginChallenge({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: NONCE,
|
||||||
|
issuedAt: ISSUED_AT,
|
||||||
|
expiresInSeconds: 300,
|
||||||
|
statement: "Sign in to view your positions.",
|
||||||
|
});
|
||||||
|
const parsed = parseLoginChallenge(message)!;
|
||||||
|
expect(parsed.expiresAt).toBe("2026-08-06T12:05:00Z");
|
||||||
|
expect(parsed.statement).toBe("Sign in to view your positions.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a nonce short enough to guess", () => {
|
||||||
|
// The difference between a real nonce and a counter is invisible at the call
|
||||||
|
// site, so it is checked here rather than trusted.
|
||||||
|
expect(() =>
|
||||||
|
createLoginChallenge({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: "123",
|
||||||
|
issuedAt: ISSUED_AT,
|
||||||
|
}),
|
||||||
|
).toThrow(/at least 16 characters/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a line break in any field, so no field can inject another", () => {
|
||||||
|
for (const bad of [
|
||||||
|
{ domain: `evil\nNonce: ${NONCE}` },
|
||||||
|
{ nonce: `${NONCE}\nExpires At: 2099-01-01T00:00:00Z` },
|
||||||
|
{ address: `${ADDRESS}\nAddress: ${OTHER_ADDRESS}` },
|
||||||
|
{ statement: "hi\nNonce: attacker" },
|
||||||
|
]) {
|
||||||
|
expect(() =>
|
||||||
|
createLoginChallenge({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: NONCE,
|
||||||
|
issuedAt: ISSUED_AT,
|
||||||
|
...bad,
|
||||||
|
}),
|
||||||
|
).toThrow(/line break/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an empty domain and a bad date", () => {
|
||||||
|
expect(() => createLoginChallenge({ domain: "", nonce: NONCE })).toThrow(
|
||||||
|
/domain/,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
createLoginChallenge({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: NONCE,
|
||||||
|
issuedAt: "not a date",
|
||||||
|
}),
|
||||||
|
).toThrow(/valid date/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseLoginChallenge", () => {
|
||||||
|
it("round-trips what createLoginChallenge produced", () => {
|
||||||
|
const message = createLoginChallenge({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: NONCE,
|
||||||
|
address: ADDRESS,
|
||||||
|
issuedAt: ISSUED_AT,
|
||||||
|
expiresInSeconds: 60,
|
||||||
|
statement: "hello",
|
||||||
|
});
|
||||||
|
expect(parseLoginChallenge(message)).toEqual({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: NONCE,
|
||||||
|
address: ADDRESS,
|
||||||
|
issuedAt: ISSUED_AT,
|
||||||
|
expiresAt: "2026-08-06T12:01:00Z",
|
||||||
|
statement: "hello",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects anything that is not this exact shape", () => {
|
||||||
|
const valid = createLoginChallenge({
|
||||||
|
domain: DOMAIN,
|
||||||
|
nonce: NONCE,
|
||||||
|
issuedAt: ISSUED_AT,
|
||||||
|
});
|
||||||
|
const cases: [string, string][] = [
|
||||||
|
["arbitrary text", "hello world"],
|
||||||
|
["missing blank line", valid.replace("\n\n", "\n")],
|
||||||
|
[
|
||||||
|
"missing nonce",
|
||||||
|
`${DOMAIN} wants you to sign in.\n\nIssued At: ${ISSUED_AT}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"missing issued at",
|
||||||
|
`${DOMAIN} wants you to sign in.\n\nNonce: ${NONCE}`,
|
||||||
|
],
|
||||||
|
["unknown field", `${valid}\nAdmin: true`],
|
||||||
|
["duplicate nonce", `${valid}\nNonce: other-nonce-1234567`],
|
||||||
|
[
|
||||||
|
"fields out of order",
|
||||||
|
`${DOMAIN} wants you to sign in.\n\nIssued At: ${ISSUED_AT}\nNonce: ${NONCE}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"empty value",
|
||||||
|
`${DOMAIN} wants you to sign in.\n\nNonce: \nIssued At: x`,
|
||||||
|
],
|
||||||
|
["bad date", valid.replace(ISSUED_AT, "yesterday")],
|
||||||
|
[
|
||||||
|
"no domain",
|
||||||
|
` wants you to sign in.\n\nNonce: ${NONCE}\nIssued At: ${ISSUED_AT}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"wrong first line",
|
||||||
|
valid.replace("wants you to sign in.", "says hello"),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [name, message] of cases) {
|
||||||
|
expect(parseLoginChallenge(message), name).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyLoginChallenge", () => {
|
||||||
|
it("accepts a fresh, correctly signed challenge and returns the address", async () => {
|
||||||
|
const result = await scenario().verify();
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.address).toBe(ADDRESS);
|
||||||
|
expect(result.challenge.nonce).toBe(NONCE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retires the nonce, so the same signature cannot be used twice", async () => {
|
||||||
|
// The whole reason this module exists.
|
||||||
|
const s = scenario();
|
||||||
|
const first = await verifyLoginChallenge(s.message, s.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: s.store.consume,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
|
||||||
|
const replay = await verifyLoginChallenge(s.message, s.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: s.store.consume,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(replay.ok).toBe(false);
|
||||||
|
if (!replay.ok) expect(replay.reason).toMatch(/nonce was not outstanding/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a nonce the server never issued", async () => {
|
||||||
|
const s = scenario();
|
||||||
|
const empty = createInMemoryNonceStore();
|
||||||
|
const result = await verifyLoginChallenge(s.message, s.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: empty.consume,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not spend the nonce when the signature is bad", async () => {
|
||||||
|
// Otherwise anyone who sniffs a nonce can burn it before the real user
|
||||||
|
// finishes signing.
|
||||||
|
const s = scenario();
|
||||||
|
const tampered = Buffer.from(s.signature, "base64");
|
||||||
|
tampered[10] ^= 0x01;
|
||||||
|
|
||||||
|
const result = await verifyLoginChallenge(
|
||||||
|
s.message,
|
||||||
|
tampered.toString("base64"),
|
||||||
|
{ domain: DOMAIN, consumeNonce: s.store.consume, now: NOW },
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(s.store.outstanding()).toBe(1);
|
||||||
|
|
||||||
|
// ...and the real user can still complete the login afterwards.
|
||||||
|
const genuine = await verifyLoginChallenge(s.message, s.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: s.store.consume,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(genuine.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a challenge addressed to another site", async () => {
|
||||||
|
// A signature the user made for evil.example is valid; it is just not for us.
|
||||||
|
const s = scenario({ domain: "evil.example" });
|
||||||
|
const result = await verifyLoginChallenge(s.message, s.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: s.store.consume,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok)
|
||||||
|
expect(result.reason).toMatch(/addressed to "evil.example"/);
|
||||||
|
expect(s.store.outstanding()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a challenge older than the max age", async () => {
|
||||||
|
const result = await scenario().verify({
|
||||||
|
now: NOW + (DEFAULT_MAX_AGE_SECONDS + 120) * 1000,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/older than/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an explicit expiry before the max age", async () => {
|
||||||
|
const s = scenario({ expiresInSeconds: 60 });
|
||||||
|
const stillValid = await s.verify({ now: NOW + 30_000 });
|
||||||
|
expect(stillValid.ok).toBe(true);
|
||||||
|
|
||||||
|
const expired = await scenario({ expiresInSeconds: 60 }).verify({
|
||||||
|
now: NOW + 300_000,
|
||||||
|
});
|
||||||
|
expect(expired.ok).toBe(false);
|
||||||
|
if (!expired.ok) expect(expired.reason).toMatch(/expired/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a challenge issued in the future beyond clock tolerance", async () => {
|
||||||
|
const result = await scenario().verify({ now: NOW - 3600_000 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/issued in the future/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates small clock skew", async () => {
|
||||||
|
const result = await scenario().verify({ now: NOW - 30_000 });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a signature from a key other than the one the message names", async () => {
|
||||||
|
const s = scenario({}, OTHER_KEY);
|
||||||
|
const result = await verifyLoginChallenge(s.message, s.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: s.store.consume,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/not valid for/);
|
||||||
|
expect(s.store.outstanding()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the caller expected a different address than the message names", async () => {
|
||||||
|
const result = await scenario().verify({ address: OTHER_ADDRESS });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/but .* was expected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the expected address in a different encoding", async () => {
|
||||||
|
// Legacy base58 for the same key must not read as a mismatch.
|
||||||
|
const publicKey = secp256k1.derivePublicKeyCompressed(
|
||||||
|
PRIVATE_KEY,
|
||||||
|
) as Uint8Array;
|
||||||
|
const legacy = encodeBase58Address("p2pkh", hash160(publicKey));
|
||||||
|
const result = await scenario().verify({ address: legacy });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies against the caller's address when the message names none", async () => {
|
||||||
|
// MODE_WALLET_CHOICE: the dapp learned the address from the response and can
|
||||||
|
// pass it here.
|
||||||
|
const s = scenario({ address: undefined });
|
||||||
|
const ok = await verifyLoginChallenge(s.message, s.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: s.store.consume,
|
||||||
|
now: NOW,
|
||||||
|
address: ADDRESS,
|
||||||
|
});
|
||||||
|
expect(ok.ok).toBe(true);
|
||||||
|
|
||||||
|
const s2 = scenario({ address: undefined });
|
||||||
|
const wrong = await verifyLoginChallenge(s2.message, s2.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: s2.store.consume,
|
||||||
|
now: NOW,
|
||||||
|
address: OTHER_ADDRESS,
|
||||||
|
});
|
||||||
|
expect(wrong.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still recovers an address when neither side named one", async () => {
|
||||||
|
const s = scenario({ address: undefined });
|
||||||
|
const result = await verifyLoginChallenge(s.message, s.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: s.store.consume,
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) expect(result.address).toBe(ADDRESS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a message that is not a login challenge at all", async () => {
|
||||||
|
const message = "just sign this please";
|
||||||
|
const store = createInMemoryNonceStore();
|
||||||
|
const result = await verifyLoginChallenge(
|
||||||
|
message,
|
||||||
|
signBitcoinMessage(message, PRIVATE_KEY),
|
||||||
|
{ domain: DOMAIN, consumeNonce: store.consume, now: NOW },
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason).toMatch(/well-formed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("awaits an async nonce store", async () => {
|
||||||
|
const s = scenario();
|
||||||
|
const result = await verifyLoginChallenge(s.message, s.signature, {
|
||||||
|
domain: DOMAIN,
|
||||||
|
consumeNonce: async (nonce) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 1));
|
||||||
|
return s.store.consume(nonce);
|
||||||
|
},
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createInMemoryNonceStore", () => {
|
||||||
|
it("consumes each nonce exactly once", () => {
|
||||||
|
const store = createInMemoryNonceStore();
|
||||||
|
store.issue("a");
|
||||||
|
expect(store.consume("a")).toBe(true);
|
||||||
|
expect(store.consume("a")).toBe(false);
|
||||||
|
expect(store.consume("never-issued")).toBe(false);
|
||||||
|
expect(store.outstanding()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
408
packages/core/src/protocols/login-challenge.ts
Normal file
408
packages/core/src/protocols/login-challenge.ts
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login challenges — a safe default message format for `sign_message` logins.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
*
|
||||||
|
* A message signature proves key control over an exact string. It carries no
|
||||||
|
* freshness and no audience, so on its own it is a password that never expires
|
||||||
|
* and works everywhere. Telling integrators "use a single-use nonce" in the docs
|
||||||
|
* does not stop anyone building a permanently replayable login; it just means the
|
||||||
|
* broken version is their fault instead of ours.
|
||||||
|
*
|
||||||
|
* So the two failures that matter are structural here rather than advisory:
|
||||||
|
*
|
||||||
|
* - `verifyLoginChallenge` cannot be called without a `consumeNonce` callback
|
||||||
|
* and an expected `domain`. There is no overload that skips them. Verifying a
|
||||||
|
* login without single-use enforcement is not something this API can express.
|
||||||
|
*
|
||||||
|
* - `createLoginChallenge` refuses a nonce shorter than
|
||||||
|
* MIN_NONCE_LENGTH and refuses any field containing a line break, so a
|
||||||
|
* guessable nonce or an injected `Nonce:` line cannot get into a message.
|
||||||
|
*
|
||||||
|
* This is a message FORMAT, not a wire protocol message: the result is the
|
||||||
|
* `message` field of a normal sign_message_request, and any wallet that can sign
|
||||||
|
* text can sign it.
|
||||||
|
*
|
||||||
|
* NOT SIWE. The layout is deliberately similar to Sign-In With Ethereum
|
||||||
|
* (EIP-4361) so it reads familiarly, but it is not that format and does not claim
|
||||||
|
* compatibility with it or with CAIP-122 — there is no agreed SIWX profile for
|
||||||
|
* Bitcoin Cash to conform to. If one lands, it belongs beside this as a second
|
||||||
|
* format, not as a silent change to this one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { binToHex, generateRandomBytes } from "@bitauth/libauth";
|
||||||
|
|
||||||
|
import {
|
||||||
|
addressesEqual,
|
||||||
|
messageSignatureAddress,
|
||||||
|
recoverMessageSigner,
|
||||||
|
verifyMessageSignatureForAddress,
|
||||||
|
type CashAddrPrefix,
|
||||||
|
} from "./message-signing.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortest nonce `createLoginChallenge` will accept, in characters.
|
||||||
|
*
|
||||||
|
* 16 hex characters is 64 bits, which is far past guessable for a value that
|
||||||
|
* lives for minutes. The check exists because the difference between a real nonce
|
||||||
|
* and `Date.now()` is invisible in a code review of the calling site.
|
||||||
|
*/
|
||||||
|
export const MIN_NONCE_LENGTH = 16;
|
||||||
|
|
||||||
|
/** Default window between `Issued At` and verification. */
|
||||||
|
export const DEFAULT_MAX_AGE_SECONDS = 600;
|
||||||
|
|
||||||
|
/** Allowance for clock skew between the signer and the verifier. */
|
||||||
|
export const DEFAULT_CLOCK_TOLERANCE_SECONDS = 60;
|
||||||
|
|
||||||
|
const FIRST_LINE_SUFFIX = " wants you to sign in.";
|
||||||
|
|
||||||
|
/** Fields of a login challenge, in the order they appear in the message. */
|
||||||
|
const FIELD_ORDER = [
|
||||||
|
"Address",
|
||||||
|
"Nonce",
|
||||||
|
"Issued At",
|
||||||
|
"Expires At",
|
||||||
|
"Statement",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type FieldName = (typeof FIELD_ORDER)[number];
|
||||||
|
|
||||||
|
export interface LoginChallenge {
|
||||||
|
/** The site the user is signing in to, e.g. "app.example.com". */
|
||||||
|
domain: string;
|
||||||
|
/** Single-use, server-issued value. */
|
||||||
|
nonce: string;
|
||||||
|
/** ISO 8601, seconds precision, UTC. */
|
||||||
|
issuedAt: string;
|
||||||
|
/** The address being claimed, when the dapp knew it in advance. */
|
||||||
|
address?: string;
|
||||||
|
/** ISO 8601 hard expiry, independent of the verifier's max age. */
|
||||||
|
expiresAt?: string;
|
||||||
|
/** Human-readable purpose, shown to the user as part of the signed text. */
|
||||||
|
statement?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a nonce.
|
||||||
|
*
|
||||||
|
* MUST be called by the server that will later verify the signature, and stored
|
||||||
|
* before it is handed out. A nonce the client invents is not a nonce — the client
|
||||||
|
* is the party a replay attack impersonates, so it cannot also be the party that
|
||||||
|
* decides what counts as fresh.
|
||||||
|
*/
|
||||||
|
export function createLoginNonce(bytes = 16): string {
|
||||||
|
if (bytes < 8) {
|
||||||
|
throw new Error(`Login nonce must be at least 8 bytes, got ${bytes}`);
|
||||||
|
}
|
||||||
|
return binToHex(generateRandomBytes(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSingleLine(field: string, value: string): void {
|
||||||
|
if (/[\r\n]/.test(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Login challenge ${field} must not contain a line break — a value that ` +
|
||||||
|
`spans lines could inject its own fields into the signed message`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the message text for a login challenge.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* app.example.com wants you to sign in.
|
||||||
|
*
|
||||||
|
* Address: bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h
|
||||||
|
* Nonce: 8f3a21c0d4b57e69
|
||||||
|
* Issued At: 2026-08-06T12:00:00Z
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `address` is optional because under MODE_WALLET_CHOICE the dapp does not know
|
||||||
|
* which key will answer. Including it when it IS known makes the proof
|
||||||
|
* self-describing — a third party reading the message alone can see which
|
||||||
|
* address was claimed — and `verifyLoginChallenge` then requires the recovered
|
||||||
|
* address to match it.
|
||||||
|
*/
|
||||||
|
export function createLoginChallenge(challenge: {
|
||||||
|
domain: string;
|
||||||
|
nonce: string;
|
||||||
|
address?: string;
|
||||||
|
issuedAt?: Date | string;
|
||||||
|
expiresInSeconds?: number;
|
||||||
|
statement?: string;
|
||||||
|
}): string {
|
||||||
|
const { domain, nonce, address, statement } = challenge;
|
||||||
|
|
||||||
|
if (!domain) throw new Error("Login challenge needs a domain");
|
||||||
|
assertSingleLine("domain", domain);
|
||||||
|
if (nonce.length < MIN_NONCE_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`Login challenge nonce must be at least ${MIN_NONCE_LENGTH} characters, ` +
|
||||||
|
`got ${nonce.length} — use createLoginNonce() on the server`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertSingleLine("nonce", nonce);
|
||||||
|
if (address !== undefined) assertSingleLine("address", address);
|
||||||
|
if (statement !== undefined) assertSingleLine("statement", statement);
|
||||||
|
|
||||||
|
const issued =
|
||||||
|
challenge.issuedAt instanceof Date
|
||||||
|
? challenge.issuedAt
|
||||||
|
: challenge.issuedAt !== undefined
|
||||||
|
? new Date(challenge.issuedAt)
|
||||||
|
: new Date();
|
||||||
|
if (Number.isNaN(issued.getTime())) {
|
||||||
|
throw new Error(`Login challenge issuedAt is not a valid date`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [`${domain}${FIRST_LINE_SUFFIX}`, ""];
|
||||||
|
if (address !== undefined) lines.push(`Address: ${address}`);
|
||||||
|
lines.push(`Nonce: ${nonce}`);
|
||||||
|
lines.push(`Issued At: ${toIsoSeconds(issued)}`);
|
||||||
|
if (challenge.expiresInSeconds !== undefined) {
|
||||||
|
if (challenge.expiresInSeconds <= 0) {
|
||||||
|
throw new Error("Login challenge expiresInSeconds must be positive");
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
`Expires At: ${toIsoSeconds(
|
||||||
|
new Date(issued.getTime() + challenge.expiresInSeconds * 1000),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (statement !== undefined) lines.push(`Statement: ${statement}`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoSeconds(date: Date): string {
|
||||||
|
return `${date.toISOString().slice(0, 19)}Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a login challenge, strictly.
|
||||||
|
*
|
||||||
|
* Strict on purpose: a lenient parser is where field-injection lives. Unknown
|
||||||
|
* field names, duplicate fields, fields out of order and stray lines are all
|
||||||
|
* rejected rather than skipped, so there is exactly one byte sequence that parses
|
||||||
|
* to a given challenge.
|
||||||
|
*/
|
||||||
|
export function parseLoginChallenge(
|
||||||
|
message: string,
|
||||||
|
): LoginChallenge | undefined {
|
||||||
|
const lines = message.split("\n");
|
||||||
|
if (lines.length < 4) return undefined;
|
||||||
|
if (!lines[0].endsWith(FIRST_LINE_SUFFIX)) return undefined;
|
||||||
|
const domain = lines[0].slice(0, -FIRST_LINE_SUFFIX.length);
|
||||||
|
if (!domain) return undefined;
|
||||||
|
if (lines[1] !== "") return undefined;
|
||||||
|
|
||||||
|
const fields = new Map<FieldName, string>();
|
||||||
|
let lastFieldIndex = -1;
|
||||||
|
for (const line of lines.slice(2)) {
|
||||||
|
const separator = line.indexOf(": ");
|
||||||
|
if (separator <= 0) return undefined;
|
||||||
|
const name = line.slice(0, separator);
|
||||||
|
const value = line.slice(separator + 2);
|
||||||
|
const fieldIndex = (FIELD_ORDER as readonly string[]).indexOf(name);
|
||||||
|
// Unknown, repeated or out-of-order fields all mean this is not a
|
||||||
|
// well-formed challenge; a parser that shrugged here would let an attacker
|
||||||
|
// append their own Nonce line to a legitimate statement.
|
||||||
|
if (fieldIndex === -1 || fieldIndex <= lastFieldIndex) return undefined;
|
||||||
|
if (!value) return undefined;
|
||||||
|
lastFieldIndex = fieldIndex;
|
||||||
|
fields.set(name as FieldName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonce = fields.get("Nonce");
|
||||||
|
const issuedAt = fields.get("Issued At");
|
||||||
|
if (!nonce || !issuedAt) return undefined;
|
||||||
|
if (Number.isNaN(Date.parse(issuedAt))) return undefined;
|
||||||
|
const expiresAt = fields.get("Expires At");
|
||||||
|
if (expiresAt !== undefined && Number.isNaN(Date.parse(expiresAt))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = fields.get("Address");
|
||||||
|
const statement = fields.get("Statement");
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
nonce,
|
||||||
|
issuedAt,
|
||||||
|
...(address !== undefined ? { address } : {}),
|
||||||
|
...(expiresAt !== undefined ? { expiresAt } : {}),
|
||||||
|
...(statement !== undefined ? { statement } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyLoginChallengeOptions {
|
||||||
|
/**
|
||||||
|
* The domain this verifier serves. Required, and compared exactly.
|
||||||
|
*
|
||||||
|
* Without it a signature a user produced for evil.example can be presented to
|
||||||
|
* this site and accepted — the signature is perfectly valid, it just was not
|
||||||
|
* meant for you.
|
||||||
|
*/
|
||||||
|
domain: string;
|
||||||
|
/**
|
||||||
|
* Retire the nonce, returning true if it was outstanding and is now spent.
|
||||||
|
*
|
||||||
|
* Required. This is the only thing that makes a login single-use, and it is a
|
||||||
|
* property of your storage rather than of this function, so it has to be
|
||||||
|
* supplied. It MUST be atomic — a compare-and-delete, `DELETE ... RETURNING`,
|
||||||
|
* or equivalent — because two replays arriving together will both reach this
|
||||||
|
* point, and only one may be told true.
|
||||||
|
*
|
||||||
|
* Called last, after every other check has passed, so an attacker cannot burn a
|
||||||
|
* legitimate user's nonce by submitting a bad signature with it.
|
||||||
|
*/
|
||||||
|
consumeNonce: (nonce: string) => boolean | Promise<boolean>;
|
||||||
|
/** Require this exact address, in addition to whatever the message states. */
|
||||||
|
address?: string;
|
||||||
|
/** Seconds after `Issued At` that the challenge stops being acceptable. */
|
||||||
|
maxAgeSeconds?: number;
|
||||||
|
/** Allowance for the signer's clock running ahead of ours. */
|
||||||
|
clockToleranceSeconds?: number;
|
||||||
|
/** Override the current time — for tests, and for replaying an audit log. */
|
||||||
|
now?: Date | number;
|
||||||
|
/** Network prefix used when deriving the signer's address. */
|
||||||
|
addressPrefix?: CashAddrPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginChallengeVerification =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
/** The proven address. Reachable only after `ok` is checked. */
|
||||||
|
address: string;
|
||||||
|
challenge: LoginChallenge;
|
||||||
|
}
|
||||||
|
| { ok: false; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signed login challenge and retire its nonce.
|
||||||
|
*
|
||||||
|
* Checks, in order: the message parses; the domain is ours; the challenge has not
|
||||||
|
* expired; the signature is valid for the address the message claims (or the one
|
||||||
|
* the caller expects); and finally that the nonce was outstanding.
|
||||||
|
*
|
||||||
|
* Returns a result rather than throwing, and the proven address is only reachable
|
||||||
|
* through the `ok: true` branch, so a caller cannot read an identity out of a
|
||||||
|
* failed verification.
|
||||||
|
*/
|
||||||
|
export async function verifyLoginChallenge(
|
||||||
|
message: string,
|
||||||
|
signature: string,
|
||||||
|
options: VerifyLoginChallengeOptions,
|
||||||
|
): Promise<LoginChallengeVerification> {
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
consumeNonce,
|
||||||
|
address: expectedAddress,
|
||||||
|
maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS,
|
||||||
|
clockToleranceSeconds = DEFAULT_CLOCK_TOLERANCE_SECONDS,
|
||||||
|
addressPrefix = "bitcoincash",
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const challenge = parseLoginChallenge(message);
|
||||||
|
if (!challenge) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "message is not a well-formed login challenge",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challenge.domain !== domain) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `challenge is addressed to "${challenge.domain}", not "${domain}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now =
|
||||||
|
options.now instanceof Date
|
||||||
|
? options.now.getTime()
|
||||||
|
: (options.now ?? Date.now());
|
||||||
|
const issuedAt = Date.parse(challenge.issuedAt);
|
||||||
|
const tolerance = clockToleranceSeconds * 1000;
|
||||||
|
if (issuedAt > now + tolerance) {
|
||||||
|
return { ok: false, reason: "challenge was issued in the future" };
|
||||||
|
}
|
||||||
|
if (now - issuedAt > maxAgeSeconds * 1000 + tolerance) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `challenge is older than ${maxAgeSeconds}s`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (challenge.expiresAt !== undefined) {
|
||||||
|
if (Date.parse(challenge.expiresAt) < now - tolerance) {
|
||||||
|
return { ok: false, reason: "challenge has expired" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whose signature should this be? The message's own claim wins when it makes
|
||||||
|
// one, which is what makes an address-bearing challenge self-describing.
|
||||||
|
const claimed = challenge.address ?? expectedAddress;
|
||||||
|
if (
|
||||||
|
challenge.address !== undefined &&
|
||||||
|
expectedAddress !== undefined &&
|
||||||
|
!addressesEqual(challenge.address, expectedAddress)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `challenge names ${challenge.address}, but ${expectedAddress} was expected`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimed !== undefined) {
|
||||||
|
if (!verifyMessageSignatureForAddress(message, signature, claimed)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `signature is not valid for ${claimed}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (!recoverMessageSigner(message, signature)) {
|
||||||
|
return { ok: false, reason: "signature is malformed or does not recover" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = messageSignatureAddress(message, signature, addressPrefix);
|
||||||
|
if (!address) {
|
||||||
|
return { ok: false, reason: "could not derive the signer's address" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last, so a bad signature cannot spend a nonce the real user still needs.
|
||||||
|
if (!(await consumeNonce(challenge.nonce))) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason:
|
||||||
|
"nonce was not outstanding — already used, expired or never issued",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, address, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `consumeNonce` backed by an in-memory set, for development and tests.
|
||||||
|
*
|
||||||
|
* NOT for production with more than one process: two servers behind a load
|
||||||
|
* balancer each get their own set, so a signature can be spent once per process.
|
||||||
|
* Use your database.
|
||||||
|
*/
|
||||||
|
export function createInMemoryNonceStore(): {
|
||||||
|
issue: (nonce: string) => void;
|
||||||
|
consume: (nonce: string) => boolean;
|
||||||
|
outstanding: () => number;
|
||||||
|
} {
|
||||||
|
const outstanding = new Set<string>();
|
||||||
|
return {
|
||||||
|
issue: (nonce) => {
|
||||||
|
outstanding.add(nonce);
|
||||||
|
},
|
||||||
|
consume: (nonce) => outstanding.delete(nonce),
|
||||||
|
outstanding: () => outstanding.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
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({
|
export default defineConfig({
|
||||||
test: {
|
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/**",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,58 @@ import {
|
||||||
ProtocolMessage,
|
ProtocolMessage,
|
||||||
PROTOCOL_NAME,
|
PROTOCOL_NAME,
|
||||||
childIndexOfPathName,
|
childIndexOfPathName,
|
||||||
|
checkSignMessageResponse,
|
||||||
isHdwalletv1Session,
|
isHdwalletv1Session,
|
||||||
|
isSignMessageFailure,
|
||||||
|
peerSupportsSignMessage,
|
||||||
|
MODE_DAPP_PATH,
|
||||||
|
SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||||
binToHex,
|
binToHex,
|
||||||
chunkExtensionAdvertisement,
|
chunkExtensionAdvertisement,
|
||||||
peerSupportsChunk,
|
peerSupportsChunk,
|
||||||
} from "@wizardconnect/core";
|
} from "@wizardconnect/core";
|
||||||
import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
|
import type {
|
||||||
|
PathXpub,
|
||||||
|
DappRelayResult,
|
||||||
|
MessageSignatureScheme,
|
||||||
|
MessageSigningMode,
|
||||||
|
PathName,
|
||||||
|
SignMessageRequest,
|
||||||
|
SignMessageResponse,
|
||||||
|
SignMessageSuccess,
|
||||||
|
} from "@wizardconnect/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the dapp itself confirmed the signer is the key it asked for.
|
||||||
|
*
|
||||||
|
* `checked: false` does not mean the signature is unverified — it always is. It
|
||||||
|
* means there was no dapp-chosen key to compare against, so the proven address is
|
||||||
|
* the wallet's choice rather than the dapp's selection. A dapp storing an identity
|
||||||
|
* should care about the difference.
|
||||||
|
*/
|
||||||
|
export type MessageKeyBinding =
|
||||||
|
| { checked: true; path: PathName; addressIndex: number }
|
||||||
|
| {
|
||||||
|
checked: false;
|
||||||
|
reason: "wallet_chose_key" | "path_not_derivable";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A sign_message result this library has already verified. */
|
||||||
|
export interface VerifiedMessageSignature {
|
||||||
|
/** The exact message that was signed — store this, not a reconstruction. */
|
||||||
|
message: string;
|
||||||
|
/** Base64 signature. Portable: any third party can verify it. */
|
||||||
|
signature: string;
|
||||||
|
/** Hex public key that signed, in the serialisation its header declares. */
|
||||||
|
publicKey: string;
|
||||||
|
/** CashAddr of the signing key — the identity this proves control of. */
|
||||||
|
address: string;
|
||||||
|
scheme: MessageSignatureScheme;
|
||||||
|
/** Path and index the wallet reported using, when it said. */
|
||||||
|
path?: PathName;
|
||||||
|
addressIndex?: number;
|
||||||
|
keyBinding: MessageKeyBinding;
|
||||||
|
}
|
||||||
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||||
import {
|
import {
|
||||||
type SessionStorage,
|
type SessionStorage,
|
||||||
|
|
@ -87,6 +133,26 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
||||||
/** Timestamp (ms) of the last pong or wallet_ready received. Used for liveness detection. */
|
/** Timestamp (ms) of the last pong or wallet_ready received. Used for liveness detection. */
|
||||||
private lastPongTime: number = 0;
|
private lastPongTime: number = 0;
|
||||||
private sessionPaths: PathXpub[] = [];
|
private sessionPaths: PathXpub[] = [];
|
||||||
|
/**
|
||||||
|
* hdwalletv1 extensions the wallet advertised in wallet_ready. Retained so a
|
||||||
|
* dapp can ask what the wallet supports before offering a feature. Replaced on
|
||||||
|
* every wallet_ready, so reconnecting to a wallet whose capabilities changed
|
||||||
|
* reflects the new set rather than a stale one.
|
||||||
|
*/
|
||||||
|
private sessionExtensions: Record<string, unknown> = {};
|
||||||
|
/**
|
||||||
|
* In-flight sign_message requests, keyed by sequence. Separate from
|
||||||
|
* pendingSignatureRequests because the two settle with different types; one
|
||||||
|
* shared map would force a union and lose that.
|
||||||
|
*/
|
||||||
|
private pendingSignMessageRequests = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
request: SignMessageRequest;
|
||||||
|
resolve: (r: SignMessageSuccess) => void;
|
||||||
|
reject: (e: Error) => void;
|
||||||
|
}
|
||||||
|
>();
|
||||||
private pendingSignatureRequests = new Map<
|
private pendingSignatureRequests = new Map<
|
||||||
number,
|
number,
|
||||||
{
|
{
|
||||||
|
|
@ -270,6 +336,15 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
||||||
this.pendingSignatureRequests.delete(sequence);
|
this.pendingSignatureRequests.delete(sequence);
|
||||||
handlers.reject(new Error(reason ?? "Sign request cancelled"));
|
handlers.reject(new Error(reason ?? "Sign request cancelled"));
|
||||||
}
|
}
|
||||||
|
// Sequences come from one counter, so a cancel names exactly one request and
|
||||||
|
// it may be either kind.
|
||||||
|
const messageHandlers = this.pendingSignMessageRequests.get(sequence);
|
||||||
|
if (messageHandlers) {
|
||||||
|
this.pendingSignMessageRequests.delete(sequence);
|
||||||
|
messageHandlers.reject(
|
||||||
|
new Error(reason ?? "Sign message request cancelled"),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!this.conn) return;
|
if (!this.conn) return;
|
||||||
const msg: SignCancelMessage = {
|
const msg: SignCancelMessage = {
|
||||||
action: RelayMsgAction.SignCancel,
|
action: RelayMsgAction.SignCancel,
|
||||||
|
|
@ -281,6 +356,142 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
||||||
this.emit("messagesent", msg);
|
this.emit("messagesent", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the connected wallet advertised the `sign_message` extension, for the
|
||||||
|
* mode and scheme this dapp intends to use. Lets a dapp hide a login button
|
||||||
|
* rather than offer one that fails.
|
||||||
|
*
|
||||||
|
* Defaults to asking about MODE_DAPP_PATH. Pass MODE_WALLET_CHOICE if the dapp
|
||||||
|
* wants the wallet to pick the key — those are separate capabilities and a
|
||||||
|
* wallet may implement only the first.
|
||||||
|
*/
|
||||||
|
walletSupportsSignMessage(
|
||||||
|
mode: MessageSigningMode = MODE_DAPP_PATH,
|
||||||
|
scheme: MessageSignatureScheme = SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||||
|
): boolean {
|
||||||
|
return peerSupportsSignMessage(this.sessionExtensions, mode, scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the wallet to sign a plain message, proving control of a key.
|
||||||
|
*
|
||||||
|
* Resolves only after this library has verified the result, so a caller never
|
||||||
|
* has to trust the wallet's arithmetic:
|
||||||
|
*
|
||||||
|
* - the signature recovers over the message that was actually sent;
|
||||||
|
* - the returned public key is the key that signed it;
|
||||||
|
* - the returned address is that key's address;
|
||||||
|
* - and, when the dapp named a path it can derive, the signer is exactly the
|
||||||
|
* key the dapp asked for.
|
||||||
|
*
|
||||||
|
* Anything inconsistent rejects. `keyBinding` on the result reports whether the
|
||||||
|
* last check was performed, because "the wallet chose a key" and "this is the
|
||||||
|
* key I asked for" are different claims and only one of them is an identity the
|
||||||
|
* dapp selected.
|
||||||
|
*
|
||||||
|
* Omit `path` and `addressIndex` to let the wallet choose the key — no xpub
|
||||||
|
* required, which is the privacy-preserving option for a pure identity check.
|
||||||
|
* Check walletSupportsSignMessage(MODE_WALLET_CHOICE) first.
|
||||||
|
*
|
||||||
|
* REPLAY: the signature proves control of a key over that exact text, with no
|
||||||
|
* freshness and no audience. For login, put a single-use server nonce in
|
||||||
|
* `message` and retire it after one use, or a captured signature logs someone in
|
||||||
|
* forever. This library cannot enforce that — see docs/dapp.md.
|
||||||
|
*
|
||||||
|
* Cancellation works like signTransaction: pass an AbortSignal.
|
||||||
|
*/
|
||||||
|
async signMessage(
|
||||||
|
request: Pick<SignMessageRequest, "message"> &
|
||||||
|
Partial<
|
||||||
|
Pick<
|
||||||
|
SignMessageRequest,
|
||||||
|
"path" | "addressIndex" | "scheme" | "userPrompt"
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
): Promise<VerifiedMessageSignature> {
|
||||||
|
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
|
||||||
|
if ((request.path === undefined) !== (request.addressIndex === undefined)) {
|
||||||
|
throw new Error(
|
||||||
|
"[wizardconnect/dapp] signMessage needs both path and addressIndex, or neither",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequence = this.nextSequence();
|
||||||
|
const fullRequest: SignMessageRequest = {
|
||||||
|
action: RelayMsgAction.SignMessageRequest,
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
sequence,
|
||||||
|
...request,
|
||||||
|
};
|
||||||
|
|
||||||
|
const responsePromise = this.sendSignMessageRequest(fullRequest);
|
||||||
|
|
||||||
|
if (!options?.signal) {
|
||||||
|
return this.verifySignMessageResponse(fullRequest, await responsePromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress unhandled rejection — the abort path rejects separately.
|
||||||
|
responsePromise.catch(() => {});
|
||||||
|
|
||||||
|
const response = await new Promise<SignMessageSuccess>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
const reason =
|
||||||
|
options.signal!.reason instanceof Error
|
||||||
|
? options.signal!.reason.message
|
||||||
|
: typeof options.signal!.reason === "string"
|
||||||
|
? options.signal!.reason
|
||||||
|
: "Sign message request cancelled";
|
||||||
|
this.sendSignCancel(sequence, reason).catch(() => {});
|
||||||
|
reject(new DOMException(reason, "AbortError"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.signal!.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.signal!.addEventListener("abort", onAbort, { once: true });
|
||||||
|
responsePromise
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
.finally(() => {
|
||||||
|
options.signal!.removeEventListener("abort", onAbort);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.verifySignMessageResponse(fullRequest, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a sign_message request and wait for the wallet's raw response, without
|
||||||
|
* verifying it. Prefer signMessage(), which verifies.
|
||||||
|
*/
|
||||||
|
async sendSignMessageRequest(
|
||||||
|
request: SignMessageRequest,
|
||||||
|
): Promise<SignMessageSuccess> {
|
||||||
|
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
|
||||||
|
|
||||||
|
return new Promise<SignMessageSuccess>((resolve, reject) => {
|
||||||
|
this.pendingSignMessageRequests.set(request.sequence, {
|
||||||
|
request,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.conn!.relay(request)
|
||||||
|
.then(() => {
|
||||||
|
this.emit("messagesent", request);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.pendingSignMessageRequests.delete(request.sequence);
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a disconnect message to the wallet (courtesy notification).
|
* Send a disconnect message to the wallet (courtesy notification).
|
||||||
* The caller is responsible for calling dappRelay.cleanup() afterwards.
|
* The caller is responsible for calling dappRelay.cleanup() afterwards.
|
||||||
|
|
@ -489,6 +700,9 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
||||||
case RelayMsgAction.SignTransactionResponse:
|
case RelayMsgAction.SignTransactionResponse:
|
||||||
this.handleSignTransactionResponse(msg as SignTransactionResponse);
|
this.handleSignTransactionResponse(msg as SignTransactionResponse);
|
||||||
break;
|
break;
|
||||||
|
case RelayMsgAction.SignMessageResponse:
|
||||||
|
this.handleSignMessageResponse(msg as SignMessageResponse);
|
||||||
|
break;
|
||||||
case RelayMsgAction.Disconnect:
|
case RelayMsgAction.Disconnect:
|
||||||
this.handleRemoteDisconnect(msg as DisconnectMessage);
|
this.handleRemoteDisconnect(msg as DisconnectMessage);
|
||||||
break;
|
break;
|
||||||
|
|
@ -572,6 +786,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
||||||
|
|
||||||
// Store raw paths for getSessionPaths() and xpub nodes for derivation
|
// Store raw paths for getSessionPaths() and xpub nodes for derivation
|
||||||
this.sessionPaths = [...sessionData.paths];
|
this.sessionPaths = [...sessionData.paths];
|
||||||
|
this.sessionExtensions = { ...(sessionData.extensions ?? {}) };
|
||||||
for (const pathInfo of sessionData.paths) {
|
for (const pathInfo of sessionData.paths) {
|
||||||
const decoded = decodeHdPublicKey(pathInfo.xpub);
|
const decoded = decodeHdPublicKey(pathInfo.xpub);
|
||||||
if (typeof decoded === "string") {
|
if (typeof decoded === "string") {
|
||||||
|
|
@ -614,6 +829,21 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same for pending sign_message requests. The wallet dedups on sequence, so a
|
||||||
|
// resend of one the user is still looking at will not prompt them twice.
|
||||||
|
if (this.pendingSignMessageRequests.size > 0) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
for (const [, entry] of this.pendingSignMessageRequests) {
|
||||||
|
const refreshed = { ...entry.request, time: now };
|
||||||
|
this.conn!.relay(refreshed)
|
||||||
|
.then(() => this.emit("messagesent", refreshed))
|
||||||
|
.catch((err) => {
|
||||||
|
this.pendingSignMessageRequests.delete(entry.request.sequence);
|
||||||
|
entry.reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-persist wallet identity and xpub paths to session storage
|
// Auto-persist wallet identity and xpub paths to session storage
|
||||||
if (this.sessionOptions) {
|
if (this.sessionOptions) {
|
||||||
const sessionUpdate: Partial<StoredSession> = {
|
const sessionUpdate: Partial<StoredSession> = {
|
||||||
|
|
@ -632,6 +862,99 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
||||||
this.startPingInterval();
|
this.startPingInterval();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a wallet's sign_message response before handing it to the caller.
|
||||||
|
*
|
||||||
|
* Throws rather than returning a flag for anything that indicates the response
|
||||||
|
* is not what it claims: a caller that wanted a proof and got an inconsistent
|
||||||
|
* one has no use for it, and returning it with `verified: false` invites being
|
||||||
|
* ignored.
|
||||||
|
*/
|
||||||
|
private verifySignMessageResponse(
|
||||||
|
request: SignMessageRequest,
|
||||||
|
response: SignMessageSuccess,
|
||||||
|
): VerifiedMessageSignature {
|
||||||
|
const consistency = checkSignMessageResponse(request.message, response);
|
||||||
|
if (!consistency.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`[wizardconnect/dapp] Wallet's sign_message response is inconsistent: ${consistency.reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const signer = consistency.signer;
|
||||||
|
|
||||||
|
let keyBinding: MessageKeyBinding;
|
||||||
|
if (request.path === undefined || request.addressIndex === undefined) {
|
||||||
|
// Nothing was requested, so there is nothing to bind to. The address in the
|
||||||
|
// result is the identity, and it is proven — it was simply the wallet's
|
||||||
|
// choice, not the dapp's.
|
||||||
|
keyBinding = { checked: false, reason: "wallet_chose_key" };
|
||||||
|
} else {
|
||||||
|
const childIndex = childIndexOfPathName(request.path);
|
||||||
|
if (childIndex === undefined) {
|
||||||
|
// An extension path (e.g. stealth_scan): the dapp has no derivation rule
|
||||||
|
// for it, so it cannot confirm the key. Say so instead of implying it did.
|
||||||
|
keyBinding = { checked: false, reason: "path_not_derivable" };
|
||||||
|
} else {
|
||||||
|
const expected = this.getPubkey(
|
||||||
|
childIndex,
|
||||||
|
BigInt(request.addressIndex),
|
||||||
|
);
|
||||||
|
if (!expected) {
|
||||||
|
throw new Error(
|
||||||
|
`[wizardconnect/dapp] No xpub for path "${request.path}" yet, so the ` +
|
||||||
|
`signer cannot be checked — wait for wallet_ready before calling signMessage`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Exact bytes, not a point comparison: the wallet must answer with the
|
||||||
|
// compressed key this dapp derived, because the uncompressed form of the
|
||||||
|
// same key is a different address and would be a proof about something
|
||||||
|
// the dapp did not ask about.
|
||||||
|
if (binToHex(signer.publicKey) !== binToHex(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`[wizardconnect/dapp] Wallet signed with ${binToHex(signer.publicKey)}, ` +
|
||||||
|
`but path "${request.path}"/${request.addressIndex} is ${binToHex(expected)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
keyBinding = {
|
||||||
|
checked: true,
|
||||||
|
path: request.path,
|
||||||
|
addressIndex: request.addressIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: request.message,
|
||||||
|
signature: response.signature,
|
||||||
|
publicKey: response.publicKey,
|
||||||
|
address: response.address,
|
||||||
|
scheme: response.scheme,
|
||||||
|
...(response.path !== undefined ? { path: response.path } : {}),
|
||||||
|
...(response.addressIndex !== undefined
|
||||||
|
? { addressIndex: response.addressIndex }
|
||||||
|
: {}),
|
||||||
|
keyBinding,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSignMessageResponse(response: SignMessageResponse): void {
|
||||||
|
const handlers = this.pendingSignMessageRequests.get(response.sequence);
|
||||||
|
if (!handlers) {
|
||||||
|
console.warn(
|
||||||
|
"[wizardconnect/dapp] No pending sign_message request for sequence:",
|
||||||
|
response.sequence,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingSignMessageRequests.delete(response.sequence);
|
||||||
|
|
||||||
|
if (isSignMessageFailure(response)) {
|
||||||
|
handlers.reject(new Error(response.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlers.resolve(response);
|
||||||
|
}
|
||||||
|
|
||||||
private handleSignTransactionResponse(
|
private handleSignTransactionResponse(
|
||||||
response: SignTransactionResponse,
|
response: SignTransactionResponse,
|
||||||
): void {
|
): void {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ export { DappConnectionManager } from "./dapp-connection-manager.js";
|
||||||
export type {
|
export type {
|
||||||
DappConnectionManagerEvents,
|
DappConnectionManagerEvents,
|
||||||
DappSessionOptions,
|
DappSessionOptions,
|
||||||
|
MessageKeyBinding,
|
||||||
|
VerifiedMessageSignature,
|
||||||
} from "./dapp-connection-manager.js";
|
} from "./dapp-connection-manager.js";
|
||||||
export { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
export { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
543
packages/dapp/src/sign-message.test.ts
Normal file
543
packages/dapp/src/sign-message.test.ts
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dapp-side behaviour of the `sign_message` extension.
|
||||||
|
*
|
||||||
|
* The point of these tests is that signMessage() does not hand back whatever the
|
||||||
|
* wallet said. A wallet on the other end of a relay can return a signature from a
|
||||||
|
* different key, an address that is not that key's, or a signature over other
|
||||||
|
* text; an integrator who trusts the response has an identity check that does not
|
||||||
|
* check anything. Every one of those cases must reject here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
binToHex,
|
||||||
|
deriveHdPath,
|
||||||
|
deriveHdPrivateNodeChild,
|
||||||
|
deriveHdPrivateNodeFromSeed,
|
||||||
|
deriveHdPublicNode,
|
||||||
|
encodeHdPublicKey,
|
||||||
|
hexToBin,
|
||||||
|
secp256k1,
|
||||||
|
} from "@bitauth/libauth";
|
||||||
|
import {
|
||||||
|
MODE_DAPP_PATH,
|
||||||
|
MODE_WALLET_CHOICE,
|
||||||
|
PROTOCOL_NAME,
|
||||||
|
RelayMsgAction,
|
||||||
|
SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||||
|
SIGN_MESSAGE_EXTENSION,
|
||||||
|
messageSignatureAddress,
|
||||||
|
signBitcoinMessage,
|
||||||
|
} from "@wizardconnect/core";
|
||||||
|
import type {
|
||||||
|
ProtocolMessage,
|
||||||
|
SignMessageRequest,
|
||||||
|
SignMessageSuccess,
|
||||||
|
WalletReadyMessage,
|
||||||
|
} from "@wizardconnect/core";
|
||||||
|
import { DappConnectionManager } from "./dapp-connection-manager.js";
|
||||||
|
|
||||||
|
// A wallet whose keys the dapp can actually derive: the xpub below is the public
|
||||||
|
// node for m/0, so getPubkey(0, i) matches the private child the "wallet" signs
|
||||||
|
// with. Without that the binding check would be untestable.
|
||||||
|
const SEED = new Uint8Array(32).fill(0x2a);
|
||||||
|
const MASTER = deriveHdPrivateNodeFromSeed(SEED);
|
||||||
|
const RECEIVE_NODE = deriveHdPath(MASTER, "m/0") as never;
|
||||||
|
|
||||||
|
function receiveXpub(): string {
|
||||||
|
const result = encodeHdPublicKey({
|
||||||
|
node: deriveHdPublicNode(RECEIVE_NODE),
|
||||||
|
network: "mainnet",
|
||||||
|
});
|
||||||
|
if (typeof result === "string") throw new Error(result);
|
||||||
|
return result.hdPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private key of receive/index, as the wallet would use. */
|
||||||
|
function receivePrivateKey(index: number): Uint8Array {
|
||||||
|
return deriveHdPrivateNodeChild(RECEIVE_NODE, index).privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function receivePublicKey(index: number): Uint8Array {
|
||||||
|
return secp256k1.derivePublicKeyCompressed(
|
||||||
|
receivePrivateKey(index),
|
||||||
|
) as Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockClient() {
|
||||||
|
const listeners = new Map<string, ((...args: never[]) => void)[]>();
|
||||||
|
const relayed: ProtocolMessage[] = [];
|
||||||
|
return {
|
||||||
|
relayed,
|
||||||
|
on(event: string, fn: (...args: never[]) => void) {
|
||||||
|
if (!listeners.has(event)) listeners.set(event, []);
|
||||||
|
listeners.get(event)!.push(fn);
|
||||||
|
},
|
||||||
|
emit(event: string, ...args: never[]) {
|
||||||
|
for (const fn of listeners.get(event) ?? []) fn(...args);
|
||||||
|
},
|
||||||
|
relay: vi.fn(async (msg: ProtocolMessage) => {
|
||||||
|
relayed.push(msg);
|
||||||
|
}),
|
||||||
|
setPeerCapabilities: vi.fn(),
|
||||||
|
isKeyExchangeComplete: () => true,
|
||||||
|
nextSequence: (() => {
|
||||||
|
let seq = 0;
|
||||||
|
return () => (seq += 2);
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockClient = ReturnType<typeof makeMockClient>;
|
||||||
|
|
||||||
|
function makeWalletReady(
|
||||||
|
extensions?: Record<string, unknown>,
|
||||||
|
): WalletReadyMessage {
|
||||||
|
return {
|
||||||
|
action: RelayMsgAction.WalletReady,
|
||||||
|
wallet_name: "Test Wallet",
|
||||||
|
wallet_icon: "",
|
||||||
|
public_key: "aa".repeat(32),
|
||||||
|
secret: "bb".repeat(16),
|
||||||
|
supported_protocols: [PROTOCOL_NAME],
|
||||||
|
dapp_discovered: true,
|
||||||
|
session: {
|
||||||
|
[PROTOCOL_NAME]: {
|
||||||
|
paths: [{ name: "receive", xpub: receiveXpub() }],
|
||||||
|
...(extensions ? { extensions } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGN_MESSAGE_ADVERT = {
|
||||||
|
[SIGN_MESSAGE_EXTENSION]: {
|
||||||
|
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
|
||||||
|
modes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Connect a manager to a mock client and complete the handshake. */
|
||||||
|
async function connected(extensions = SIGN_MESSAGE_ADVERT) {
|
||||||
|
const mgr = new DappConnectionManager();
|
||||||
|
const client = makeMockClient();
|
||||||
|
mgr.updateConnection(client as never, { status: "connected" });
|
||||||
|
client.emit("message", makeWalletReady(extensions) as never);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
return { mgr, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sign_message_request the dapp put on the wire. */
|
||||||
|
function sentRequest(client: MockClient): SignMessageRequest {
|
||||||
|
const request = client.relayed.find(
|
||||||
|
(msg) => msg.action === RelayMsgAction.SignMessageRequest,
|
||||||
|
);
|
||||||
|
if (!request) throw new Error("no sign_message_request was relayed");
|
||||||
|
return request as SignMessageRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the response an honest wallet would send for a relayed request. */
|
||||||
|
function honestResponse(
|
||||||
|
request: SignMessageRequest,
|
||||||
|
options: { index?: number; privateKey?: Uint8Array } = {},
|
||||||
|
): SignMessageSuccess {
|
||||||
|
const index = options.index ?? request.addressIndex ?? 0;
|
||||||
|
const privateKey = options.privateKey ?? receivePrivateKey(index);
|
||||||
|
const signature = signBitcoinMessage(request.message, privateKey);
|
||||||
|
const publicKey = secp256k1.derivePublicKeyCompressed(
|
||||||
|
privateKey,
|
||||||
|
) as Uint8Array;
|
||||||
|
return {
|
||||||
|
action: RelayMsgAction.SignMessageResponse,
|
||||||
|
sequence: request.sequence,
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
signature,
|
||||||
|
publicKey: binToHex(publicKey),
|
||||||
|
address: messageSignatureAddress(request.message, signature)!,
|
||||||
|
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("walletSupportsSignMessage", () => {
|
||||||
|
it("is false before wallet_ready", () => {
|
||||||
|
const mgr = new DappConnectionManager();
|
||||||
|
expect(mgr.walletSupportsSignMessage()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when the wallet does not advertise the extension", async () => {
|
||||||
|
const { mgr } = await connected({});
|
||||||
|
expect(mgr.walletSupportsSignMessage()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true for an advertised mode and false for an unadvertised one", async () => {
|
||||||
|
const { mgr } = await connected({
|
||||||
|
[SIGN_MESSAGE_EXTENSION]: {
|
||||||
|
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
|
||||||
|
modes: [MODE_DAPP_PATH],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mgr.walletSupportsSignMessage(MODE_DAPP_PATH)).toBe(true);
|
||||||
|
// The whole reason modes are advertised: the dapp finds out now, not after
|
||||||
|
// the user has clicked a login button.
|
||||||
|
expect(mgr.walletSupportsSignMessage(MODE_WALLET_CHOICE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a scheme the wallet cannot produce", async () => {
|
||||||
|
const { mgr } = await connected();
|
||||||
|
expect(
|
||||||
|
mgr.walletSupportsSignMessage(MODE_DAPP_PATH, "bip322" as never),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects a reconnected wallet's capabilities rather than the old ones", async () => {
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
expect(mgr.walletSupportsSignMessage()).toBe(true);
|
||||||
|
|
||||||
|
client.emit("message", makeWalletReady({}) as never);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
expect(mgr.walletSupportsSignMessage()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signMessage — request shape", () => {
|
||||||
|
it("rejects half a key selection rather than guessing the mode", async () => {
|
||||||
|
const { mgr } = await connected();
|
||||||
|
await expect(
|
||||||
|
mgr.signMessage({ message: "hello", path: "receive" }),
|
||||||
|
).rejects.toThrow(/both path and addressIndex, or neither/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the message, path and userPrompt as given", async () => {
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
void mgr.signMessage({
|
||||||
|
message: "hello",
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
userPrompt: "Sign in to example.com",
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const request = sentRequest(client);
|
||||||
|
expect(request.message).toBe("hello");
|
||||||
|
expect(request.path).toBe("receive");
|
||||||
|
expect(request.addressIndex).toBe(0);
|
||||||
|
expect(request.userPrompt).toBe("Sign in to example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when not connected", async () => {
|
||||||
|
const mgr = new DappConnectionManager();
|
||||||
|
await expect(mgr.signMessage({ message: "hello" })).rejects.toThrow(
|
||||||
|
/not connected/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signMessage — verification", () => {
|
||||||
|
async function roundTrip(
|
||||||
|
mutate: (response: SignMessageSuccess, request: SignMessageRequest) => void,
|
||||||
|
request: Parameters<DappConnectionManager["signMessage"]>[0] = {
|
||||||
|
message: "hello",
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
const promise = mgr.signMessage(request);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const sent = sentRequest(client);
|
||||||
|
const response = honestResponse(sent);
|
||||||
|
mutate(response, sent);
|
||||||
|
client.emit("message", response as never);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("resolves with a verified result for an honest wallet", async () => {
|
||||||
|
const result = await roundTrip(() => {});
|
||||||
|
|
||||||
|
expect(result.message).toBe("hello");
|
||||||
|
expect(result.publicKey).toBe(binToHex(receivePublicKey(0)));
|
||||||
|
expect(result.scheme).toBe(SCHEME_BITCOIN_SIGNED_MESSAGE);
|
||||||
|
expect(result.keyBinding).toEqual({
|
||||||
|
checked: true,
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a signature from a key other than the requested one", async () => {
|
||||||
|
// The case Ron described: the dapp must enforce the expected public key. It
|
||||||
|
// is the library's job, not the integrator's.
|
||||||
|
await expect(
|
||||||
|
roundTrip((response, request) => {
|
||||||
|
Object.assign(response, honestResponse(request, { index: 5 }));
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/but path "receive"\/0 is/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a response whose public key did not sign", async () => {
|
||||||
|
await expect(
|
||||||
|
roundTrip((response) => {
|
||||||
|
response.publicKey = binToHex(receivePublicKey(9));
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/inconsistent/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a response whose address is not the signing key's", async () => {
|
||||||
|
await expect(
|
||||||
|
roundTrip((response) => {
|
||||||
|
response.address = messageSignatureAddress(
|
||||||
|
"hello",
|
||||||
|
signBitcoinMessage("hello", receivePrivateKey(7)),
|
||||||
|
)!;
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/inconsistent/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a tampered signature", async () => {
|
||||||
|
await expect(
|
||||||
|
roundTrip((response) => {
|
||||||
|
const raw = Buffer.from(response.signature, "base64");
|
||||||
|
raw[10] ^= 0x01;
|
||||||
|
response.signature = raw.toString("base64");
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/inconsistent/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a signature over different text", async () => {
|
||||||
|
await expect(
|
||||||
|
roundTrip((response) => {
|
||||||
|
response.signature = signBitcoinMessage(
|
||||||
|
"hello, but not what was asked",
|
||||||
|
receivePrivateKey(0),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/inconsistent|but path/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an error response with the wallet's reason", async () => {
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
const promise = mgr.signMessage({
|
||||||
|
message: "hello",
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
client.emit("message", {
|
||||||
|
action: RelayMsgAction.SignMessageResponse,
|
||||||
|
sequence: sentRequest(client).sequence,
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
error: "User rejected",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow("User rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports wallet_chose_key when the dapp named no path", async () => {
|
||||||
|
// Still fully verified — there was simply no dapp-chosen key to bind to.
|
||||||
|
const result = await roundTrip(() => {}, { message: "hello" });
|
||||||
|
|
||||||
|
expect(result.keyBinding).toEqual({
|
||||||
|
checked: false,
|
||||||
|
reason: "wallet_chose_key",
|
||||||
|
});
|
||||||
|
expect(result.address).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports path_not_derivable for an extension path", async () => {
|
||||||
|
// The dapp has no derivation rule for "stealth_scan", so it says so rather
|
||||||
|
// than implying it checked.
|
||||||
|
const result = await roundTrip(() => {}, {
|
||||||
|
message: "hello",
|
||||||
|
path: "stealth_scan",
|
||||||
|
addressIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.keyBinding).toEqual({
|
||||||
|
checked: false,
|
||||||
|
reason: "path_not_derivable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to pretend when the xpub for a named path is missing", async () => {
|
||||||
|
// A derivable path with no xpub yet cannot be checked, and quietly returning
|
||||||
|
// an unchecked result is exactly the footgun to avoid.
|
||||||
|
const mgr = new DappConnectionManager();
|
||||||
|
const client = makeMockClient();
|
||||||
|
mgr.updateConnection(client as never, { status: "connected" });
|
||||||
|
client.emit("message", {
|
||||||
|
...makeWalletReady(SIGN_MESSAGE_ADVERT),
|
||||||
|
session: {
|
||||||
|
[PROTOCOL_NAME]: { paths: [], extensions: SIGN_MESSAGE_ADVERT },
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const promise = mgr.signMessage({
|
||||||
|
message: "hello",
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const sent = sentRequest(client);
|
||||||
|
client.emit("message", honestResponse(sent) as never);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow(/no xpub for path/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the wallet's echoed path through to the caller", async () => {
|
||||||
|
const result = await roundTrip((response) => {
|
||||||
|
response.path = "receive";
|
||||||
|
response.addressIndex = 0;
|
||||||
|
});
|
||||||
|
expect(result.path).toBe("receive");
|
||||||
|
expect(result.addressIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a signature a third party can verify from the address alone", async () => {
|
||||||
|
// The property the whole feature exists for.
|
||||||
|
const result = await roundTrip(() => {});
|
||||||
|
const { verifyMessageSignatureForAddress } =
|
||||||
|
await import("@wizardconnect/core");
|
||||||
|
expect(
|
||||||
|
verifyMessageSignatureForAddress(
|
||||||
|
result.message,
|
||||||
|
result.signature,
|
||||||
|
result.address,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signMessage — cancellation and reconnect", () => {
|
||||||
|
it("cancels via AbortSignal and tells the wallet", async () => {
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const promise = mgr.signMessage(
|
||||||
|
{ message: "hello", path: "receive", addressIndex: 0 },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
controller.abort("User closed the dialog");
|
||||||
|
await expect(promise).rejects.toThrow(/User closed the dialog/);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
expect(
|
||||||
|
client.relayed.some((msg) => msg.action === RelayMsgAction.SignCancel),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a pending request when sendSignCancel names its sequence", async () => {
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
const promise = mgr.signMessage({
|
||||||
|
message: "hello",
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
await mgr.sendSignCancel(sentRequest(client).sequence, "changed my mind");
|
||||||
|
await expect(promise).rejects.toThrow(/changed my mind/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-sends a pending request when wallet_ready arrives again", async () => {
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
void mgr.signMessage({
|
||||||
|
message: "hello",
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const before = client.relayed.filter(
|
||||||
|
(msg) => msg.action === RelayMsgAction.SignMessageRequest,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
client.emit("message", makeWalletReady() as never);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const after = client.relayed.filter(
|
||||||
|
(msg) => msg.action === RelayMsgAction.SignMessageRequest,
|
||||||
|
).length;
|
||||||
|
expect(after).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a response for an unknown sequence", async () => {
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
client.emit("message", {
|
||||||
|
action: RelayMsgAction.SignMessageResponse,
|
||||||
|
sequence: 9999,
|
||||||
|
time: 0,
|
||||||
|
error: "stray",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
warn.mockRestore();
|
||||||
|
expect(mgr).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a sign_message response settle a transaction request", async () => {
|
||||||
|
// Both kinds share one sequence counter; routing is by action, so a response
|
||||||
|
// of the wrong kind must not resolve the other map's promise.
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
const signPromise = mgr.sendSignRequest({
|
||||||
|
action: RelayMsgAction.SignTransactionRequest,
|
||||||
|
sequence: 4,
|
||||||
|
time: 0,
|
||||||
|
inputPaths: [],
|
||||||
|
transaction: "deadbeef",
|
||||||
|
});
|
||||||
|
let settled = false;
|
||||||
|
void signPromise.then(
|
||||||
|
() => (settled = true),
|
||||||
|
() => (settled = true),
|
||||||
|
);
|
||||||
|
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
client.emit("message", {
|
||||||
|
action: RelayMsgAction.SignMessageResponse,
|
||||||
|
sequence: 4,
|
||||||
|
time: 0,
|
||||||
|
signature: signBitcoinMessage("hello", receivePrivateKey(0)),
|
||||||
|
publicKey: binToHex(receivePublicKey(0)),
|
||||||
|
address: messageSignatureAddress(
|
||||||
|
"hello",
|
||||||
|
signBitcoinMessage("hello", receivePrivateKey(0)),
|
||||||
|
)!,
|
||||||
|
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||||
|
} as never);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
warn.mockRestore();
|
||||||
|
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hex handling", () => {
|
||||||
|
it("accepts an upper-case public key from the wallet", async () => {
|
||||||
|
const { mgr, client } = await connected();
|
||||||
|
const promise = mgr.signMessage({
|
||||||
|
message: "hello",
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const sent = sentRequest(client);
|
||||||
|
const response = honestResponse(sent);
|
||||||
|
response.publicKey = response.publicKey.toUpperCase();
|
||||||
|
client.emit("message", response as never);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(hexToBin(result.publicKey).length).toBe(33);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
// 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
|
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||||
|
|
||||||
import { Command } from "commander";
|
import { Command, Option } from "commander";
|
||||||
import { runDappMode } from "./dapp.js";
|
import { runDappMode } from "./dapp.js";
|
||||||
import { runWalletMode } from "./wallet.js";
|
import { runWalletMode } from "./wallet.js";
|
||||||
|
|
||||||
|
|
@ -37,6 +37,15 @@ program
|
||||||
"--sign",
|
"--sign",
|
||||||
"Send a dummy sign request after wallet is ready (tests approval flow)",
|
"Send a dummy sign request after wallet is ready (tests approval flow)",
|
||||||
)
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"--sign-message [mode]",
|
||||||
|
"Send a sign_message request after wallet_ready and verify the result",
|
||||||
|
)
|
||||||
|
.choices(["dapp_path", "wallet_choice"])
|
||||||
|
.preset("dapp_path")
|
||||||
|
.default("off"),
|
||||||
|
)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
await runDappMode(options);
|
await runDappMode(options);
|
||||||
});
|
});
|
||||||
|
|
@ -54,6 +63,10 @@ program
|
||||||
"-k, --private-key <hex>",
|
"-k, --private-key <hex>",
|
||||||
"Wallet private key (64 hex chars) — random if omitted",
|
"Wallet private key (64 hex chars) — random if omitted",
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
"--reject-messages",
|
||||||
|
"Refuse every sign_message request (tests the dapp's rejection path)",
|
||||||
|
)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
await runWalletMode(options);
|
await runWalletMode(options);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,19 @@ import {
|
||||||
type SignTransactionResponse,
|
type SignTransactionResponse,
|
||||||
type ProtocolMessage,
|
type ProtocolMessage,
|
||||||
type RelayClient,
|
type RelayClient,
|
||||||
|
type SignMessageRequest,
|
||||||
|
type SignMessageResponse,
|
||||||
|
checkSignMessageResponse,
|
||||||
|
createInMemoryNonceStore,
|
||||||
|
createLoginChallenge,
|
||||||
|
createLoginNonce,
|
||||||
|
isSignMessageFailure,
|
||||||
|
peerSignMessageInfo,
|
||||||
|
peerSupportsSignMessage,
|
||||||
|
verifyLoginChallenge,
|
||||||
|
verifyMessageSignatureForAddress,
|
||||||
|
MODE_DAPP_PATH,
|
||||||
|
MODE_WALLET_CHOICE,
|
||||||
} from "@wizardconnect/core";
|
} from "@wizardconnect/core";
|
||||||
|
|
||||||
// ---- State ----
|
// ---- State ----
|
||||||
|
|
@ -26,6 +39,13 @@ interface DappState {
|
||||||
// Whether we've received at least one wallet_ready
|
// Whether we've received at least one wallet_ready
|
||||||
walletReady: boolean;
|
walletReady: boolean;
|
||||||
sequence: number;
|
sequence: number;
|
||||||
|
/** Messages sent for signing, by sequence, so responses can be verified. */
|
||||||
|
pendingMessages: Map<number, string>;
|
||||||
|
/** Only one sign_message request per run, however many wallet_readys arrive. */
|
||||||
|
signMessageSent: boolean;
|
||||||
|
messageNonce: string;
|
||||||
|
/** Stands in for the server-side nonce store a real dapp would use. */
|
||||||
|
nonceStore: ReturnType<typeof createInMemoryNonceStore>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeState(): DappState {
|
function makeState(): DappState {
|
||||||
|
|
@ -35,6 +55,10 @@ function makeState(): DappState {
|
||||||
walletIcon: null,
|
walletIcon: null,
|
||||||
walletReady: false,
|
walletReady: false,
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
|
pendingMessages: new Map(),
|
||||||
|
signMessageSent: false,
|
||||||
|
messageNonce: createLoginNonce(),
|
||||||
|
nonceStore: createInMemoryNonceStore(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +125,57 @@ async function sendSignRequest(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Send a sign_message request ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the wallet to prove key control over a nonce, the way a login flow would.
|
||||||
|
*
|
||||||
|
* `walletChoice` omits the path so the wallet picks the key — the mode that needs
|
||||||
|
* no xpub. Otherwise the request names receive/0, which the dapp could verify
|
||||||
|
* against its own derived key.
|
||||||
|
*/
|
||||||
|
async function sendSignMessageRequest(
|
||||||
|
client: RelayClient,
|
||||||
|
state: DappState,
|
||||||
|
walletChoice: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const sequence = state.sequence++;
|
||||||
|
// Built with the real login-challenge helper rather than hand-rolled text, since
|
||||||
|
// this is what integrators copy. The nonce store here is per-process; a real dapp
|
||||||
|
// issues and retires nonces server-side.
|
||||||
|
const message = createLoginChallenge({
|
||||||
|
domain: "wiz-test.localhost",
|
||||||
|
nonce: state.messageNonce,
|
||||||
|
expiresInSeconds: 300,
|
||||||
|
statement: "Sign in to the WizardConnect test CLI",
|
||||||
|
});
|
||||||
|
state.nonceStore.issue(state.messageNonce);
|
||||||
|
|
||||||
|
const request: SignMessageRequest = {
|
||||||
|
action: RelayMsgAction.SignMessageRequest,
|
||||||
|
sequence,
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
message,
|
||||||
|
userPrompt: "Sign in to the WizardConnect test CLI",
|
||||||
|
...(walletChoice ? {} : { path: "receive", addressIndex: 0 }),
|
||||||
|
};
|
||||||
|
state.pendingMessages.set(sequence, message);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
chalk.blue("→ sign_message_request") +
|
||||||
|
chalk.dim(
|
||||||
|
` seq=${sequence} mode=${walletChoice ? "wallet_choice" : "dapp_path"}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(chalk.dim(` message: ${JSON.stringify(message)}`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.relay(request);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(chalk.red(" send error:"), err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Handle incoming messages ----
|
// ---- Handle incoming messages ----
|
||||||
|
|
||||||
function handleMessage(
|
function handleMessage(
|
||||||
|
|
@ -108,6 +183,7 @@ function handleMessage(
|
||||||
client: RelayClient,
|
client: RelayClient,
|
||||||
state: DappState,
|
state: DappState,
|
||||||
sendSign: boolean,
|
sendSign: boolean,
|
||||||
|
signMessageMode: "off" | "dapp_path" | "wallet_choice",
|
||||||
): void {
|
): void {
|
||||||
const now = chalk.dim(new Date().toISOString().slice(11, 23));
|
const now = chalk.dim(new Date().toISOString().slice(11, 23));
|
||||||
|
|
||||||
|
|
@ -138,6 +214,20 @@ function handleMessage(
|
||||||
sendDappReady(client, state).catch(() => {});
|
sendDappReady(client, state).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report whether this wallet can sign messages, and in which modes — the
|
||||||
|
// check a real dapp makes before showing a login button.
|
||||||
|
const signMessageInfo = peerSignMessageInfo(hdwv1?.extensions);
|
||||||
|
if (signMessageInfo) {
|
||||||
|
console.log(
|
||||||
|
chalk.dim(
|
||||||
|
` sign_message: schemes=[${signMessageInfo.schemes.join(",")}] ` +
|
||||||
|
`modes=[${signMessageInfo.modes.join(",")}]`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(chalk.dim(" sign_message: not supported by this wallet"));
|
||||||
|
}
|
||||||
|
|
||||||
// If --sign flag set, schedule a sign request after wallet_ready
|
// If --sign flag set, schedule a sign request after wallet_ready
|
||||||
if (sendSign && state.walletReady) {
|
if (sendSign && state.walletReady) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -147,6 +237,97 @@ function handleMessage(
|
||||||
sendSignRequest(client, state).catch(() => {});
|
sendSignRequest(client, state).catch(() => {});
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (signMessageMode !== "off" && !state.signMessageSent) {
|
||||||
|
state.signMessageSent = true;
|
||||||
|
const walletChoice = signMessageMode === "wallet_choice";
|
||||||
|
const mode = walletChoice ? MODE_WALLET_CHOICE : MODE_DAPP_PATH;
|
||||||
|
if (!peerSupportsSignMessage(hdwv1?.extensions, mode)) {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(
|
||||||
|
` (--sign-message: wallet does not advertise ${mode}; sending anyway ` +
|
||||||
|
`to show what a stale dapp would get)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
sendSignMessageRequest(client, state, walletChoice).catch(() => {});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case RelayMsgAction.SignMessageResponse: {
|
||||||
|
const msg = message as SignMessageResponse;
|
||||||
|
const requested = state.pendingMessages.get(msg.sequence);
|
||||||
|
state.pendingMessages.delete(msg.sequence);
|
||||||
|
|
||||||
|
if (isSignMessageFailure(msg)) {
|
||||||
|
console.log(
|
||||||
|
chalk.red("← sign_message_response") +
|
||||||
|
` seq=${msg.sequence} error="${msg.error}"`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
chalk.green("← sign_message_response") +
|
||||||
|
` seq=${msg.sequence} address=${msg.address}` +
|
||||||
|
(msg.path !== undefined
|
||||||
|
? chalk.dim(` used=${msg.path}/${msg.addressIndex}`)
|
||||||
|
: ""),
|
||||||
|
);
|
||||||
|
console.log(chalk.dim(` signature: ${msg.signature}`));
|
||||||
|
|
||||||
|
if (requested === undefined) {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(" cannot verify: no pending message for that sequence"),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify exactly as a third party would — from the message, the signature
|
||||||
|
// and the address, with no knowledge of this protocol.
|
||||||
|
const consistent = checkSignMessageResponse(requested, msg);
|
||||||
|
const verified = verifyMessageSignatureForAddress(
|
||||||
|
requested,
|
||||||
|
msg.signature,
|
||||||
|
msg.address,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
(consistent.ok && verified ? chalk.green(" ✓") : chalk.red(" ✗")) +
|
||||||
|
` verified against address: ${consistent.ok && verified}` +
|
||||||
|
(consistent.ok ? "" : chalk.red(` (${consistent.reason})`)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...and then as the server would: domain, expiry, and retiring the nonce.
|
||||||
|
void (async () => {
|
||||||
|
const login = await verifyLoginChallenge(requested, msg.signature, {
|
||||||
|
domain: "wiz-test.localhost",
|
||||||
|
consumeNonce: state.nonceStore.consume,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
(login.ok ? chalk.green(" ✓") : chalk.red(" ✗")) +
|
||||||
|
` login challenge: ${login.ok ? `signed in as ${login.address}` : login.reason}`,
|
||||||
|
);
|
||||||
|
// Prove the nonce is spent: the identical signature must not log in twice.
|
||||||
|
const replay = await verifyLoginChallenge(requested, msg.signature, {
|
||||||
|
domain: "wiz-test.localhost",
|
||||||
|
consumeNonce: state.nonceStore.consume,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
(!replay.ok ? chalk.green(" ✓") : chalk.red(" ✗")) +
|
||||||
|
` replay of the same signature rejected: ${!replay.ok}` +
|
||||||
|
(replay.ok ? "" : chalk.dim(` (${replay.reason})`)),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
chalk.dim(
|
||||||
|
` paste into Electron Cash → Tools → Verify Message to confirm ` +
|
||||||
|
`independently`,
|
||||||
|
),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +364,7 @@ export async function runDappMode(options: {
|
||||||
secret?: string;
|
secret?: string;
|
||||||
walletPublicKey?: string;
|
walletPublicKey?: string;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
|
signMessage?: "off" | "dapp_path" | "wallet_choice";
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
|
|
||||||
|
|
@ -273,7 +455,13 @@ export async function runDappMode(options: {
|
||||||
|
|
||||||
// Register message handler
|
// Register message handler
|
||||||
client.on("message", (message: ProtocolMessage) => {
|
client.on("message", (message: ProtocolMessage) => {
|
||||||
handleMessage(message, client, state, options.sign ?? false);
|
handleMessage(
|
||||||
|
message,
|
||||||
|
client,
|
||||||
|
state,
|
||||||
|
options.sign ?? false,
|
||||||
|
options.signMessage ?? "off",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send initial dapp_ready
|
// Send initial dapp_ready
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,13 @@ import {
|
||||||
type WalletAdapter,
|
type WalletAdapter,
|
||||||
DerivationPath,
|
DerivationPath,
|
||||||
} from "@wizardconnect/wallet";
|
} from "@wizardconnect/wallet";
|
||||||
import { generateKeyExchangeCredentials, hexToBin } from "@wizardconnect/core";
|
import {
|
||||||
|
generateKeyExchangeCredentials,
|
||||||
|
hexToBin,
|
||||||
|
signBitcoinMessage,
|
||||||
|
MODE_DAPP_PATH,
|
||||||
|
MODE_WALLET_CHOICE,
|
||||||
|
} from "@wizardconnect/core";
|
||||||
import {
|
import {
|
||||||
deriveHdPrivateNodeFromSeed,
|
deriveHdPrivateNodeFromSeed,
|
||||||
deriveHdPrivateNodeChild,
|
deriveHdPrivateNodeChild,
|
||||||
|
|
@ -73,6 +79,27 @@ async function buildAdapter(
|
||||||
async signTransaction(_request): Promise<any> {
|
async signTransaction(_request): Promise<any> {
|
||||||
throw new Error("signTransaction not implemented in test wallet");
|
throw new Error("signTransaction not implemented in test wallet");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Message signing IS implemented here, unlike signTransaction: the keys are
|
||||||
|
// already derived above, and this is the reference for what a wallet has to do
|
||||||
|
// — one call, no reassembling the construction.
|
||||||
|
signMessageModes: () => [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
|
||||||
|
|
||||||
|
async signMessage(request) {
|
||||||
|
const index = request.addressIndex ?? 0;
|
||||||
|
const hdChain =
|
||||||
|
request.path === "change"
|
||||||
|
? hdChange
|
||||||
|
: request.path === "defi"
|
||||||
|
? hdDefi
|
||||||
|
: hdMain;
|
||||||
|
const child = deriveHdPrivateNodeChild(hdChain, index);
|
||||||
|
return {
|
||||||
|
signature: signBitcoinMessage(request.message, child.privateKey),
|
||||||
|
path: request.path ?? "receive",
|
||||||
|
addressIndex: index,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,6 +109,7 @@ export async function runWalletMode(options: {
|
||||||
relay: string;
|
relay: string;
|
||||||
uri: string;
|
uri: string;
|
||||||
privateKey?: string;
|
privateKey?: string;
|
||||||
|
rejectMessages?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
console.log(chalk.bold("\nwiz-test wallet mode"));
|
console.log(chalk.bold("\nwiz-test wallet mode"));
|
||||||
console.log(chalk.dim(`relay: ${options.relay}`));
|
console.log(chalk.dim(`relay: ${options.relay}`));
|
||||||
|
|
@ -106,6 +134,7 @@ export async function runWalletMode(options: {
|
||||||
const adapter = await buildAdapter(relayPrivKey);
|
const adapter = await buildAdapter(relayPrivKey);
|
||||||
buildSpinner.succeed("Wallet adapter ready");
|
buildSpinner.succeed("Wallet adapter ready");
|
||||||
|
|
||||||
|
const rejectMessages = options.rejectMessages ?? false;
|
||||||
const manager = new WalletConnectionManager(adapter);
|
const manager = new WalletConnectionManager(adapter);
|
||||||
|
|
||||||
manager.on("connectionStatusChanged", (connectionId, status) => {
|
manager.on("connectionStatusChanged", (connectionId, status) => {
|
||||||
|
|
@ -136,6 +165,56 @@ export async function runWalletMode(options: {
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
manager.on("pendingSignMessageRequest", ({ connectionId, request }) => {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow("← sign_message_request") +
|
||||||
|
chalk.dim(
|
||||||
|
` conn=${connectionId} seq=${request.sequence}` +
|
||||||
|
` key=${request.path !== undefined ? `${request.path}/${request.addressIndex}` : "wallet's choice"}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// What a wallet UI must show the user: the message verbatim, and the dapp's
|
||||||
|
// prompt marked as coming from the dapp rather than presented as fact.
|
||||||
|
console.log(chalk.bold(" message to sign:"));
|
||||||
|
for (const line of request.message.split("\n")) {
|
||||||
|
console.log(` ${chalk.cyan(JSON.stringify(line))}`);
|
||||||
|
}
|
||||||
|
if (request.userPrompt !== undefined) {
|
||||||
|
console.log(
|
||||||
|
chalk.dim(` dapp says (unsigned, untrusted): `) +
|
||||||
|
JSON.stringify(request.userPrompt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rejectMessages) {
|
||||||
|
console.log(chalk.dim(" (--reject-messages: refusing)"));
|
||||||
|
manager
|
||||||
|
.sendSignMessageError(connectionId, request.sequence, "User rejected")
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.dim(" (auto-approving — this is a test wallet)"));
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await adapter.signMessage!(request);
|
||||||
|
await manager.sendSignMessageResponse(
|
||||||
|
connectionId,
|
||||||
|
request.sequence,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
chalk.green("→ sign_message_response") + chalk.dim(" sent"),
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(chalk.red(" signing failed:"), err.message);
|
||||||
|
await manager
|
||||||
|
.sendSignMessageError(connectionId, request.sequence, err.message)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
console.log(chalk.dim("\nConnecting to dapp..."));
|
console.log(chalk.dim("\nConnecting to dapp..."));
|
||||||
const connectionId = manager.connect(options.uri);
|
const connectionId = manager.connect(options.uri);
|
||||||
console.log(chalk.dim(`connection id: ${connectionId}`));
|
console.log(chalk.dim(`connection id: ${connectionId}`));
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,15 @@ export {
|
||||||
childIndexOfPath,
|
childIndexOfPath,
|
||||||
pathOfChildIndex,
|
pathOfChildIndex,
|
||||||
} from "./derivation-path.js";
|
} from "./derivation-path.js";
|
||||||
export type { WalletAdapter, SignTransactionResult } from "./wallet-adapter.js";
|
export type {
|
||||||
|
WalletAdapter,
|
||||||
|
SignTransactionResult,
|
||||||
|
SignMessageResult,
|
||||||
|
} from "./wallet-adapter.js";
|
||||||
export { WalletConnectionManager } from "./wallet-connection-manager.js";
|
export { WalletConnectionManager } from "./wallet-connection-manager.js";
|
||||||
export type {
|
export type {
|
||||||
RelayConnectionState,
|
RelayConnectionState,
|
||||||
PendingSignRequest,
|
PendingSignRequest,
|
||||||
|
PendingSignMessageRequest,
|
||||||
WalletConnectionManagerEvents,
|
WalletConnectionManagerEvents,
|
||||||
} from "./wallet-connection-manager.js";
|
} from "./wallet-connection-manager.js";
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ import {
|
||||||
PROTOCOL_NAME,
|
PROTOCOL_NAME,
|
||||||
chunkExtensionAdvertisement,
|
chunkExtensionAdvertisement,
|
||||||
peerSupportsChunk,
|
peerSupportsChunk,
|
||||||
|
signBitcoinMessage,
|
||||||
|
MODE_DAPP_PATH,
|
||||||
|
MODE_WALLET_CHOICE,
|
||||||
type RelayClient,
|
type RelayClient,
|
||||||
type RelayUpdatePayload,
|
type RelayUpdatePayload,
|
||||||
type DappReadyMessage,
|
type DappReadyMessage,
|
||||||
|
|
@ -102,6 +105,29 @@ export function makeTestAdapter(seed?: Uint8Array): WalletAdapter {
|
||||||
async signTransaction() {
|
async signTransaction() {
|
||||||
throw new Error("signTransaction not implemented in test adapter");
|
throw new Error("signTransaction not implemented in test adapter");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Message signing IS implemented, because the point of the integration test
|
||||||
|
// is a real signature crossing a real relay. The keys are right here, so
|
||||||
|
// there is nothing to fake.
|
||||||
|
signMessageModes: () => [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
|
||||||
|
|
||||||
|
async signMessage(request) {
|
||||||
|
const index = request.addressIndex ?? 0;
|
||||||
|
const hdChain =
|
||||||
|
request.path === "change"
|
||||||
|
? hdChange
|
||||||
|
: request.path === "defi"
|
||||||
|
? hdDefi
|
||||||
|
: hdMain;
|
||||||
|
const child = deriveHdPrivateNodeChild(hdChain, index);
|
||||||
|
return {
|
||||||
|
signature: signBitcoinMessage(request.message, child.privateKey),
|
||||||
|
// Echo what was used, including under wallet_choice where the dapp asked
|
||||||
|
// for nothing and this is its only way to learn the path.
|
||||||
|
path: request.path ?? "receive",
|
||||||
|
addressIndex: index,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
300
packages/wallet/src/integration/sign-message.integration.test.ts
Normal file
300
packages/wallet/src/integration/sign-message.integration.test.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `sign_message` over a live relay.
|
||||||
|
*
|
||||||
|
* The unit tests drive the managers with mock clients, which proves the logic but
|
||||||
|
* not that a signature survives the trip: NIP-17 gift wrapping, JSON encoding,
|
||||||
|
* relay storage and replay all sit between the two sides. This exercises the
|
||||||
|
* whole path — dapp → relay → wallet → user approval → relay → dapp — and then
|
||||||
|
* verifies the signature the way a third party would, from the address alone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { binToHex } from "@bitauth/libauth";
|
||||||
|
import {
|
||||||
|
MODE_DAPP_PATH,
|
||||||
|
MODE_WALLET_CHOICE,
|
||||||
|
RelayMsgAction,
|
||||||
|
SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||||
|
SIGN_MESSAGE_EXTENSION,
|
||||||
|
checkSignMessageResponse,
|
||||||
|
messageSignatureAddress,
|
||||||
|
peerSupportsSignMessage,
|
||||||
|
verifyMessageSignatureForAddress,
|
||||||
|
} from "@wizardconnect/core";
|
||||||
|
import type {
|
||||||
|
Hdwalletv1Session,
|
||||||
|
ProtocolMessage,
|
||||||
|
SignMessageRequest,
|
||||||
|
SignMessageResponse,
|
||||||
|
SignMessageSuccess,
|
||||||
|
} from "@wizardconnect/core";
|
||||||
|
import { DerivationPath } from "@wizardconnect/wallet";
|
||||||
|
|
||||||
|
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
|
||||||
|
|
||||||
|
let handles: ConnectionHandles | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
handles?.cleanup();
|
||||||
|
handles = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Wire a wallet-side auto-approver, as a host app's UI would on user approval. */
|
||||||
|
function autoApprove(h: ConnectionHandles): {
|
||||||
|
prompted: SignMessageRequest[];
|
||||||
|
failures: Error[];
|
||||||
|
} {
|
||||||
|
const prompted: SignMessageRequest[] = [];
|
||||||
|
const failures: Error[] = [];
|
||||||
|
h.wallet.manager.on(
|
||||||
|
"pendingSignMessageRequest",
|
||||||
|
({ connectionId, request }) => {
|
||||||
|
prompted.push(request);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await h.wallet.adapter.signMessage!(request);
|
||||||
|
await h.wallet.manager.sendSignMessageResponse(
|
||||||
|
connectionId,
|
||||||
|
request.sequence,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
failures.push(err as Error);
|
||||||
|
await h.wallet.manager
|
||||||
|
.sendSignMessageError(connectionId, request.sequence, String(err))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { prompted, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
function responses(h: ConnectionHandles): SignMessageResponse[] {
|
||||||
|
return h.dapp.messages.filter(
|
||||||
|
(msg: ProtocolMessage) => msg.action === RelayMsgAction.SignMessageResponse,
|
||||||
|
) as SignMessageResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestSignature(
|
||||||
|
h: ConnectionHandles,
|
||||||
|
overrides: Partial<SignMessageRequest> = {},
|
||||||
|
): Promise<SignMessageRequest> {
|
||||||
|
const client = h.dapp.client()!;
|
||||||
|
const request: SignMessageRequest = {
|
||||||
|
action: RelayMsgAction.SignMessageRequest,
|
||||||
|
sequence: client.nextSequence(),
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
message: "hello from an integration test",
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
await client.relay(request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sign_message over a live relay", () => {
|
||||||
|
it("advertises the extension in wallet_ready", async () => {
|
||||||
|
handles = await setupConnection();
|
||||||
|
|
||||||
|
const session = handles.dapp.walletReadyMessages[0].session[
|
||||||
|
"hdwalletv1"
|
||||||
|
] as Hdwalletv1Session;
|
||||||
|
|
||||||
|
expect(session.extensions?.[SIGN_MESSAGE_EXTENSION]).toBeDefined();
|
||||||
|
expect(peerSupportsSignMessage(session.extensions, MODE_DAPP_PATH)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
peerSupportsSignMessage(session.extensions, MODE_WALLET_CHOICE),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a signature the dapp can verify against its own derived key", async () => {
|
||||||
|
handles = await setupConnection();
|
||||||
|
const { prompted, failures } = autoApprove(handles);
|
||||||
|
|
||||||
|
const request = await requestSignature(handles);
|
||||||
|
|
||||||
|
await waitFor(() => responses(handles!).length > 0, {
|
||||||
|
timeoutMs: 30000,
|
||||||
|
what: "sign_message_response",
|
||||||
|
});
|
||||||
|
expect(failures).toEqual([]);
|
||||||
|
expect(prompted).toHaveLength(1);
|
||||||
|
// The message must arrive byte-identical, or the signature is over other text.
|
||||||
|
expect(prompted[0].message).toBe(request.message);
|
||||||
|
|
||||||
|
const response = responses(handles)[0] as SignMessageSuccess;
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
expect(response.scheme).toBe(SCHEME_BITCOIN_SIGNED_MESSAGE);
|
||||||
|
|
||||||
|
// Internally consistent: signature, key and address all agree.
|
||||||
|
const check = checkSignMessageResponse(request.message, response);
|
||||||
|
expect(check.ok).toBe(true);
|
||||||
|
|
||||||
|
// ...and it is the key the dapp asked for. The dapp holds the xpub, so this
|
||||||
|
// is the check a real dapp performs.
|
||||||
|
const expectedKey = handles.wallet.adapter.getPublicKey(
|
||||||
|
DerivationPath.Receive,
|
||||||
|
0n,
|
||||||
|
);
|
||||||
|
expect(response.publicKey).toBe(binToHex(expectedKey));
|
||||||
|
|
||||||
|
// Finally, the property the feature exists for: verifiable from the address
|
||||||
|
// alone, by anyone, with no knowledge of this protocol.
|
||||||
|
expect(
|
||||||
|
verifyMessageSignatureForAddress(
|
||||||
|
request.message,
|
||||||
|
response.signature,
|
||||||
|
response.address,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(messageSignatureAddress(request.message, response.signature)).toBe(
|
||||||
|
response.address,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs multi-byte text without mangling it in transit", async () => {
|
||||||
|
// JSON encoding and NIP-44 both sit in the path; a re-encode would change the
|
||||||
|
// bytes and the signature would verify against nothing.
|
||||||
|
handles = await setupConnection();
|
||||||
|
autoApprove(handles);
|
||||||
|
|
||||||
|
const message = "Straße 日本語 🍅 — nonce=7f3a2c";
|
||||||
|
const request = await requestSignature(handles, { message });
|
||||||
|
|
||||||
|
await waitFor(() => responses(handles!).length > 0, {
|
||||||
|
timeoutMs: 30000,
|
||||||
|
what: "sign_message_response",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = responses(handles)[0] as SignMessageSuccess;
|
||||||
|
expect(
|
||||||
|
verifyMessageSignatureForAddress(
|
||||||
|
message,
|
||||||
|
response.signature,
|
||||||
|
response.address,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
// The same text with a normalised or trimmed variation must NOT verify.
|
||||||
|
expect(
|
||||||
|
verifyMessageSignatureForAddress(
|
||||||
|
message.trim().replace("ß", "ss"),
|
||||||
|
response.signature,
|
||||||
|
response.address,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(request.message).toBe(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the wallet choose the key when the dapp names none", async () => {
|
||||||
|
handles = await setupConnection();
|
||||||
|
autoApprove(handles);
|
||||||
|
|
||||||
|
const request = await requestSignature(handles, {
|
||||||
|
path: undefined,
|
||||||
|
addressIndex: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => responses(handles!).length > 0, {
|
||||||
|
timeoutMs: 30000,
|
||||||
|
what: "sign_message_response",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = responses(handles)[0] as SignMessageSuccess;
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
// The dapp learns the identity from the response, having sent no xpub-derived
|
||||||
|
// path at all — the no-xpub-required case.
|
||||||
|
expect(response.address).toBeTruthy();
|
||||||
|
expect(response.path).toBe("receive");
|
||||||
|
expect(
|
||||||
|
verifyMessageSignatureForAddress(
|
||||||
|
request.message,
|
||||||
|
response.signature,
|
||||||
|
response.address,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs with a non-zero index and a non-default path", async () => {
|
||||||
|
handles = await setupConnection();
|
||||||
|
autoApprove(handles);
|
||||||
|
|
||||||
|
await requestSignature(handles, { path: "change", addressIndex: 4 });
|
||||||
|
|
||||||
|
await waitFor(() => responses(handles!).length > 0, {
|
||||||
|
timeoutMs: 30000,
|
||||||
|
what: "sign_message_response",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = responses(handles)[0] as SignMessageSuccess;
|
||||||
|
expect(response.publicKey).toBe(
|
||||||
|
binToHex(handles.wallet.adapter.getPublicKey(DerivationPath.Change, 4n)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts the user once when the dapp re-sends the same request", async () => {
|
||||||
|
// Dapps re-send pending requests on wallet_ready and relays replay stored
|
||||||
|
// events; both land as a duplicate delivery.
|
||||||
|
handles = await setupConnection();
|
||||||
|
const { prompted } = autoApprove(handles);
|
||||||
|
|
||||||
|
const request = await requestSignature(handles);
|
||||||
|
await waitFor(() => prompted.length > 0, {
|
||||||
|
timeoutMs: 30000,
|
||||||
|
what: "first prompt",
|
||||||
|
});
|
||||||
|
|
||||||
|
await handles.dapp.client()!.relay({ ...request, time: request.time + 1 });
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
expect(prompted).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers a rejection as an error response", async () => {
|
||||||
|
handles = await setupConnection();
|
||||||
|
handles.wallet.manager.on(
|
||||||
|
"pendingSignMessageRequest",
|
||||||
|
({ connectionId, request }) => {
|
||||||
|
void handles!.wallet.manager.sendSignMessageError(
|
||||||
|
connectionId,
|
||||||
|
request.sequence,
|
||||||
|
"User rejected the signature",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await requestSignature(handles);
|
||||||
|
|
||||||
|
await waitFor(() => responses(handles!).length > 0, {
|
||||||
|
timeoutMs: 30000,
|
||||||
|
what: "sign_message error response",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = responses(handles)[0];
|
||||||
|
expect(response.error).toBe("User rejected the signature");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a request for an unsupported scheme instead of going quiet", async () => {
|
||||||
|
handles = await setupConnection();
|
||||||
|
const { prompted } = autoApprove(handles);
|
||||||
|
|
||||||
|
await requestSignature(handles, { scheme: "bip322" as never });
|
||||||
|
|
||||||
|
await waitFor(() => responses(handles!).length > 0, {
|
||||||
|
timeoutMs: 30000,
|
||||||
|
what: "sign_message error response",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompted).toEqual([]);
|
||||||
|
expect(responses(handles)[0].error).toMatch(
|
||||||
|
/unsupported signature scheme/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
617
packages/wallet/src/sign-message.test.ts
Normal file
617
packages/wallet/src/sign-message.test.ts
Normal file
|
|
@ -0,0 +1,617 @@
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wallet-side behaviour of the `sign_message` extension.
|
||||||
|
*
|
||||||
|
* The construction itself is covered in core. What matters here is the wiring
|
||||||
|
* around it: that support is advertised from the adapter's real capability, that
|
||||||
|
* a request the wallet cannot serve gets an answer instead of silence, and that
|
||||||
|
* the manager derives the response's key and address from the signature rather
|
||||||
|
* than trusting an adapter to state them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
import { binToHex, hexToBin, secp256k1 } from "@bitauth/libauth";
|
||||||
|
import {
|
||||||
|
MODE_DAPP_PATH,
|
||||||
|
MODE_WALLET_CHOICE,
|
||||||
|
RelayMsgAction,
|
||||||
|
RelayStatus,
|
||||||
|
SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||||
|
SIGN_MESSAGE_EXTENSION,
|
||||||
|
signBitcoinMessage,
|
||||||
|
verifyMessageSignatureForAddress,
|
||||||
|
type MessageSigningMode,
|
||||||
|
type RelayStatusCallback,
|
||||||
|
type RelayUpdatePayload,
|
||||||
|
type SignMessageRequest,
|
||||||
|
type SignMessageResponse,
|
||||||
|
type SignMessageSuccess,
|
||||||
|
type WalletReadyMessage,
|
||||||
|
} from "@wizardconnect/core";
|
||||||
|
import type { SignMessageResult, WalletAdapter } from "./wallet-adapter.js";
|
||||||
|
import { DerivationPath } from "./derivation-path.js";
|
||||||
|
|
||||||
|
let capturedCallback: RelayStatusCallback | null = null;
|
||||||
|
|
||||||
|
function makeMockClient() {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
return {
|
||||||
|
on: emitter.on.bind(emitter),
|
||||||
|
off: emitter.off.bind(emitter),
|
||||||
|
emit: emitter.emit.bind(emitter),
|
||||||
|
relay: vi.fn(async () => {}),
|
||||||
|
isKeyExchangeComplete: () => true,
|
||||||
|
setPairedPublicKey: vi.fn(),
|
||||||
|
setPeerCapabilities: vi.fn(),
|
||||||
|
nextSequence: (() => {
|
||||||
|
let seq = 0;
|
||||||
|
return () => (seq += 2);
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockClient = ReturnType<typeof makeMockClient>;
|
||||||
|
let mockClient: MockClient;
|
||||||
|
|
||||||
|
vi.mock("@wizardconnect/core", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@wizardconnect/core")>(
|
||||||
|
"@wizardconnect/core",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
initiateWalletRelay: (cb: RelayStatusCallback) => {
|
||||||
|
capturedCallback = cb;
|
||||||
|
return {
|
||||||
|
client: mockClient,
|
||||||
|
dappPublicKey: new Uint8Array(32),
|
||||||
|
walletPublicKey: new Uint8Array(33).fill(0x02),
|
||||||
|
secret: "aa".repeat(16),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { WalletConnectionManager } =
|
||||||
|
await import("./wallet-connection-manager.js");
|
||||||
|
|
||||||
|
const PRIVATE_KEY = hexToBin("00".repeat(31) + "01");
|
||||||
|
const PUBLIC_KEY = secp256k1.derivePublicKeyCompressed(
|
||||||
|
PRIVATE_KEY,
|
||||||
|
) as Uint8Array;
|
||||||
|
|
||||||
|
const XPUB =
|
||||||
|
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
|
||||||
|
|
||||||
|
interface AdapterOptions {
|
||||||
|
signMessage?: WalletAdapter["signMessage"];
|
||||||
|
signMessageModes?: MessageSigningMode[];
|
||||||
|
getExtensions?: () => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAdapter(options: AdapterOptions = {}): WalletAdapter {
|
||||||
|
const adapter: WalletAdapter = {
|
||||||
|
walletName: "Test Wallet",
|
||||||
|
walletIcon: "",
|
||||||
|
getRelayPrivateKey: () => new Uint8Array(32).fill(0x01),
|
||||||
|
getPublicKey: () => PUBLIC_KEY,
|
||||||
|
getXpub: (_path: DerivationPath) => XPUB,
|
||||||
|
signTransaction: vi.fn(),
|
||||||
|
};
|
||||||
|
if (options.signMessage) adapter.signMessage = options.signMessage;
|
||||||
|
if (options.signMessageModes) {
|
||||||
|
adapter.signMessageModes = () => options.signMessageModes!;
|
||||||
|
}
|
||||||
|
if (options.getExtensions) adapter.getExtensions = options.getExtensions;
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An adapter that signs honestly with the fixed test key. */
|
||||||
|
function honestSignMessage(): WalletAdapter["signMessage"] {
|
||||||
|
return async (request: SignMessageRequest): Promise<SignMessageResult> => ({
|
||||||
|
signature: signBitcoinMessage(request.message, PRIVATE_KEY),
|
||||||
|
path: request.path ?? "receive",
|
||||||
|
addressIndex: request.addressIndex ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateConnection(): MockClient {
|
||||||
|
if (!capturedCallback) throw new Error("connect() not called yet");
|
||||||
|
const payload: RelayUpdatePayload = {
|
||||||
|
client: mockClient as never,
|
||||||
|
status: RelayStatus.connected(),
|
||||||
|
};
|
||||||
|
capturedCallback(payload);
|
||||||
|
return mockClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(
|
||||||
|
sequence: number,
|
||||||
|
overrides: Partial<SignMessageRequest> = {},
|
||||||
|
): SignMessageRequest {
|
||||||
|
return {
|
||||||
|
action: RelayMsgAction.SignMessageRequest,
|
||||||
|
sequence,
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
message: "hello",
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All sign_message responses the wallet put on the wire. */
|
||||||
|
function responses(client: MockClient): SignMessageResponse[] {
|
||||||
|
return client.relay.mock.calls
|
||||||
|
.map((call) => call[0] as SignMessageResponse)
|
||||||
|
.filter((msg) => msg?.action === RelayMsgAction.SignMessageResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
capturedCallback = null;
|
||||||
|
mockClient = makeMockClient();
|
||||||
|
(
|
||||||
|
WalletConnectionManager as never as {
|
||||||
|
uriSignSequences: Map<string, unknown>;
|
||||||
|
}
|
||||||
|
).uriSignSequences.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extension advertisement", () => {
|
||||||
|
/** The hdwalletv1 session extensions from the wallet_ready that was queued. */
|
||||||
|
function advertisedExtensions(
|
||||||
|
client: MockClient,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const ready = client.relay.mock.calls
|
||||||
|
.map((call) => call[0] as WalletReadyMessage)
|
||||||
|
.find((msg) => msg?.action === RelayMsgAction.WalletReady);
|
||||||
|
const session = ready?.session?.["hdwalletv1"] as
|
||||||
|
| { extensions?: Record<string, unknown> }
|
||||||
|
| undefined;
|
||||||
|
return session?.extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("advertises nothing when the adapter cannot sign messages", async () => {
|
||||||
|
const mgr = new WalletConnectionManager(makeAdapter());
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
advertisedExtensions(client)?.[SIGN_MESSAGE_EXTENSION],
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advertises from the adapter's capability, not a hand-written declaration", async () => {
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({ signMessage: honestSignMessage() }),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(advertisedExtensions(client)?.[SIGN_MESSAGE_EXTENSION]).toEqual({
|
||||||
|
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
|
||||||
|
modes: [MODE_DAPP_PATH],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advertises wallet_choice when the adapter declares it", async () => {
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({
|
||||||
|
signMessage: honestSignMessage(),
|
||||||
|
signMessageModes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
advertisedExtensions(client)?.[SIGN_MESSAGE_EXTENSION] as {
|
||||||
|
modes: string[];
|
||||||
|
}
|
||||||
|
).modes,
|
||||||
|
).toEqual([MODE_DAPP_PATH, MODE_WALLET_CHOICE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite an explicit declaration from the adapter", async () => {
|
||||||
|
const explicit = {
|
||||||
|
schemes: ["bitcoin_signed_message", "future"],
|
||||||
|
modes: [],
|
||||||
|
};
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({
|
||||||
|
signMessage: honestSignMessage(),
|
||||||
|
getExtensions: () => ({ [SIGN_MESSAGE_EXTENSION]: explicit }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(advertisedExtensions(client)?.[SIGN_MESSAGE_EXTENSION]).toEqual(
|
||||||
|
explicit,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps other adapter extensions alongside sign_message", async () => {
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({
|
||||||
|
signMessage: honestSignMessage(),
|
||||||
|
getExtensions: () => ({ rpa_bip47: { scan_path: "m/47'" } }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const extensions = advertisedExtensions(client)!;
|
||||||
|
expect(extensions[SIGN_MESSAGE_EXTENSION]).toBeDefined();
|
||||||
|
expect(extensions["rpa_bip47"]).toEqual({ scan_path: "m/47'" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("request handling", () => {
|
||||||
|
it("emits pendingSignMessageRequest for a well-formed request", async () => {
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({ signMessage: honestSignMessage() }),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
|
||||||
|
const seen: SignMessageRequest[] = [];
|
||||||
|
mgr.on("pendingSignMessageRequest", (req) => seen.push(req.request));
|
||||||
|
|
||||||
|
client.emit("message", makeRequest(42));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0].message).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prompt twice for a replayed request", async () => {
|
||||||
|
// Relays replay stored events on reconnect and the dapp re-sends pending
|
||||||
|
// requests on wallet_ready.
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({ signMessage: honestSignMessage() }),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
|
||||||
|
const seen: number[] = [];
|
||||||
|
mgr.on("pendingSignMessageRequest", (req) =>
|
||||||
|
seen.push(req.request.sequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = makeRequest(42);
|
||||||
|
client.emit("message", request);
|
||||||
|
client.emit("message", request);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(seen).toEqual([42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still blocks a replay after the response was sent", async () => {
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({ signMessage: honestSignMessage() }),
|
||||||
|
);
|
||||||
|
const connId = mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
|
||||||
|
const seen: number[] = [];
|
||||||
|
mgr.on("pendingSignMessageRequest", (req) =>
|
||||||
|
seen.push(req.request.sequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = makeRequest(42);
|
||||||
|
client.emit("message", request);
|
||||||
|
await flush();
|
||||||
|
await mgr.sendSignMessageResponse(connId, 42, {
|
||||||
|
signature: signBitcoinMessage("hello", PRIVATE_KEY),
|
||||||
|
});
|
||||||
|
|
||||||
|
client.emit("message", request);
|
||||||
|
await flush();
|
||||||
|
expect(seen).toEqual([42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers rather than hangs when the wallet cannot sign messages", async () => {
|
||||||
|
const mgr = new WalletConnectionManager(makeAdapter());
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
|
||||||
|
client.emit("message", makeRequest(42));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const sent = responses(client);
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
expect(sent[0].error).toMatch(/does not support message signing/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unsupported scheme before bothering the user", async () => {
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({ signMessage: honestSignMessage() }),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
|
||||||
|
const seen: number[] = [];
|
||||||
|
mgr.on("pendingSignMessageRequest", (req) =>
|
||||||
|
seen.push(req.request.sequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emit("message", makeRequest(42, { scheme: "bip322" as never }));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
expect(responses(client)[0].error).toMatch(/unsupported signature scheme/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a mode the adapter does not support", async () => {
|
||||||
|
// Adapter defaults to dapp_path only; this request omits the key selection.
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({ signMessage: honestSignMessage() }),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
|
||||||
|
const seen: number[] = [];
|
||||||
|
mgr.on("pendingSignMessageRequest", (req) =>
|
||||||
|
seen.push(req.request.sequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emit(
|
||||||
|
"message",
|
||||||
|
makeRequest(42, { path: undefined, addressIndex: undefined }),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
expect(responses(client)[0].error).toMatch(/wallet_choice/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a wallet_choice request when the adapter advertises it", async () => {
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({
|
||||||
|
signMessage: honestSignMessage(),
|
||||||
|
signMessageModes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
|
||||||
|
const seen: number[] = [];
|
||||||
|
mgr.on("pendingSignMessageRequest", (req) =>
|
||||||
|
seen.push(req.request.sequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emit(
|
||||||
|
"message",
|
||||||
|
makeRequest(42, { path: undefined, addressIndex: undefined }),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(seen).toEqual([42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a malformed request instead of dropping it", async () => {
|
||||||
|
// Half a key selection is ambiguous between the two modes.
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({ signMessage: honestSignMessage() }),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
|
||||||
|
const seen: number[] = [];
|
||||||
|
mgr.on("pendingSignMessageRequest", (req) =>
|
||||||
|
seen.push(req.request.sequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emit("message", makeRequest(42, { addressIndex: undefined }));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
expect(responses(client)[0].error).toMatch(/malformed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("response construction", () => {
|
||||||
|
async function connected(adapterOptions: AdapterOptions = {}) {
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({ signMessage: honestSignMessage(), ...adapterOptions }),
|
||||||
|
);
|
||||||
|
const connId = mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
client.emit("message", makeRequest(42));
|
||||||
|
await flush();
|
||||||
|
return { mgr, connId, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("derives the public key and address from the signature", async () => {
|
||||||
|
const { mgr, connId, client } = await connected();
|
||||||
|
|
||||||
|
await mgr.sendSignMessageResponse(connId, 42, {
|
||||||
|
signature: signBitcoinMessage("hello", PRIVATE_KEY),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = responses(client)[0] as SignMessageSuccess;
|
||||||
|
expect(response.publicKey).toBe(binToHex(PUBLIC_KEY));
|
||||||
|
expect(response.scheme).toBe(SCHEME_BITCOIN_SIGNED_MESSAGE);
|
||||||
|
// The address must be the one the signature actually proves.
|
||||||
|
expect(
|
||||||
|
verifyMessageSignatureForAddress(
|
||||||
|
"hello",
|
||||||
|
response.signature,
|
||||||
|
response.address,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours the address prefix so a testnet key is not shown as mainnet", async () => {
|
||||||
|
const { mgr, connId, client } = await connected();
|
||||||
|
|
||||||
|
await mgr.sendSignMessageResponse(connId, 42, {
|
||||||
|
signature: signBitcoinMessage("hello", PRIVATE_KEY),
|
||||||
|
addressPrefix: "bchtest",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((responses(client)[0] as SignMessageSuccess).address).toMatch(
|
||||||
|
/^bchtest:/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("echoes the path the wallet used", async () => {
|
||||||
|
const { mgr, connId, client } = await connected();
|
||||||
|
|
||||||
|
await mgr.sendSignMessageResponse(connId, 42, {
|
||||||
|
signature: signBitcoinMessage("hello", PRIVATE_KEY),
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = responses(client)[0] as SignMessageSuccess;
|
||||||
|
expect(response.path).toBe("receive");
|
||||||
|
expect(response.addressIndex).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the adapter signed text other than the request", async () => {
|
||||||
|
// Recovery alone cannot catch this — it succeeds and yields some other key.
|
||||||
|
// Comparing that key against the adapter's own key for the path is what makes
|
||||||
|
// it detectable, so a wallet bug surfaces here rather than as an opaque
|
||||||
|
// rejection on the far side of a relay.
|
||||||
|
const { mgr, connId } = await connected();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mgr.sendSignMessageResponse(connId, 42, {
|
||||||
|
signature: signBitcoinMessage("something else", PRIVATE_KEY),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/wrong key, or over text other than/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the adapter signed with a key that is not the requested one", async () => {
|
||||||
|
const { mgr, connId } = await connected();
|
||||||
|
const otherKey = hexToBin("00".repeat(31) + "02");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mgr.sendSignMessageResponse(connId, 42, {
|
||||||
|
signature: signBitcoinMessage("hello", otherKey),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/wrong key/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks the echoed path when the dapp named none", async () => {
|
||||||
|
// Under wallet_choice the adapter is the only one who knows which key it used,
|
||||||
|
// so its echo is what gets checked.
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({
|
||||||
|
signMessage: honestSignMessage(),
|
||||||
|
signMessageModes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const connId = mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
client.emit(
|
||||||
|
"message",
|
||||||
|
makeRequest(42, { path: undefined, addressIndex: undefined }),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mgr.sendSignMessageResponse(connId, 42, {
|
||||||
|
signature: signBitcoinMessage(
|
||||||
|
"hello",
|
||||||
|
hexToBin("00".repeat(31) + "02"),
|
||||||
|
),
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/wrong key/i);
|
||||||
|
|
||||||
|
// ...and the honest signature for that echoed path goes through.
|
||||||
|
await mgr.sendSignMessageResponse(connId, 42, {
|
||||||
|
signature: signBitcoinMessage("hello", PRIVATE_KEY),
|
||||||
|
path: "receive",
|
||||||
|
addressIndex: 0,
|
||||||
|
});
|
||||||
|
expect(responses(client)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a malformed signature", async () => {
|
||||||
|
const { mgr, connId } = await connected();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mgr.sendSignMessageResponse(connId, 42, { signature: "not-a-signature" }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for a sequence that is not awaiting a response", async () => {
|
||||||
|
const { mgr, connId } = await connected();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mgr.sendSignMessageResponse(connId, 999, {
|
||||||
|
signature: signBitcoinMessage("hello", PRIVATE_KEY),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/no pending sign_message request/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a failure response when the user rejects", async () => {
|
||||||
|
const { mgr, connId, client } = await connected();
|
||||||
|
|
||||||
|
await mgr.sendSignMessageError(connId, 42, "User rejected");
|
||||||
|
|
||||||
|
const response = responses(client)[0];
|
||||||
|
expect(response.error).toBe("User rejected");
|
||||||
|
expect((response as SignMessageSuccess).signature).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not answer a request twice", async () => {
|
||||||
|
const { mgr, connId } = await connected();
|
||||||
|
await mgr.sendSignMessageError(connId, 42, "User rejected");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mgr.sendSignMessageResponse(connId, 42, {
|
||||||
|
signature: signBitcoinMessage("hello", PRIVATE_KEY),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/no pending sign_message request/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cancellation", () => {
|
||||||
|
it("reports a dapp cancel for a message request via signCancelled", async () => {
|
||||||
|
// One sequence counter means sign_cancel names exactly one request, whichever
|
||||||
|
// kind it was.
|
||||||
|
const mgr = new WalletConnectionManager(
|
||||||
|
makeAdapter({ signMessage: honestSignMessage() }),
|
||||||
|
);
|
||||||
|
mgr.connect("wiz://test");
|
||||||
|
const client = simulateConnection();
|
||||||
|
client.emit("message", makeRequest(42));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const cancelled: number[] = [];
|
||||||
|
mgr.on("signCancelled", (_id, sequence) => cancelled.push(sequence));
|
||||||
|
|
||||||
|
client.emit("message", {
|
||||||
|
action: RelayMsgAction.SignCancel,
|
||||||
|
sequence: 42,
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(cancelled).toEqual([42]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,12 +3,47 @@
|
||||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||||
|
|
||||||
import { DerivationPath } from "./derivation-path.js";
|
import { DerivationPath } from "./derivation-path.js";
|
||||||
import { SignTransactionRequest, PathXpub } from "@wizardconnect/core";
|
import {
|
||||||
|
CashAddrPrefix,
|
||||||
|
MessageSigningMode,
|
||||||
|
PathName,
|
||||||
|
PathXpub,
|
||||||
|
SignMessageRequest,
|
||||||
|
SignTransactionRequest,
|
||||||
|
} from "@wizardconnect/core";
|
||||||
|
|
||||||
export interface SignTransactionResult {
|
export interface SignTransactionResult {
|
||||||
signedTransaction: string;
|
signedTransaction: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a wallet returns after signing a message.
|
||||||
|
*
|
||||||
|
* Deliberately just the signature. The public key and address are not asked for
|
||||||
|
* because they are recoverable FROM the signature, and
|
||||||
|
* WalletConnectionManager derives them that way — so the three can never
|
||||||
|
* disagree, and an adapter cannot accidentally claim a proof about an address it
|
||||||
|
* did not prove. Use `signBitcoinMessage()` from `@wizardconnect/core` to produce
|
||||||
|
* the signature rather than assembling the construction by hand.
|
||||||
|
*/
|
||||||
|
export interface SignMessageResult {
|
||||||
|
/** Base64 recoverable compact signature — see core's signBitcoinMessage(). */
|
||||||
|
signature: string;
|
||||||
|
/**
|
||||||
|
* Network prefix for the address derived from the signature. Defaults to
|
||||||
|
* `bitcoincash`; set it on testnet or the dapp is shown a mainnet address for a
|
||||||
|
* testnet key.
|
||||||
|
*/
|
||||||
|
addressPrefix?: CashAddrPrefix;
|
||||||
|
/**
|
||||||
|
* Which path and index the wallet actually used, when it can say. Echoed to the
|
||||||
|
* dapp so wallet-chosen signing is transparent; omit when the key sits outside
|
||||||
|
* any advertised path.
|
||||||
|
*/
|
||||||
|
path?: PathName;
|
||||||
|
addressIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract interface that wallets implement to integrate with WizardConnect.
|
* Abstract interface that wallets implement to integrate with WizardConnect.
|
||||||
*/
|
*/
|
||||||
|
|
@ -57,6 +92,50 @@ export interface WalletAdapter {
|
||||||
* Optional extension data to include in the hdwalletv1 session handshake.
|
* Optional extension data to include in the hdwalletv1 session handshake.
|
||||||
* Each key is an extension name; its presence indicates wallet support.
|
* Each key is an extension name; its presence indicates wallet support.
|
||||||
* See docs/extensions.md for conventions.
|
* See docs/extensions.md for conventions.
|
||||||
|
*
|
||||||
|
* Note: WalletConnectionManager advertises the `sign_message` extension
|
||||||
|
* automatically when `signMessage` is implemented, so an adapter does not need
|
||||||
|
* to list it here. An adapter that does list it wins — the automatic
|
||||||
|
* advertisement never overwrites an explicit one.
|
||||||
*/
|
*/
|
||||||
getExtensions?(): Record<string, unknown>;
|
getExtensions?(): Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a plain message, proving control of a key without a transaction.
|
||||||
|
*
|
||||||
|
* Optional — implementing it is what advertises the `sign_message` extension to
|
||||||
|
* dapps. Wallets that omit it keep working unchanged; a dapp that asks anyway
|
||||||
|
* gets an explicit error rather than silence.
|
||||||
|
*
|
||||||
|
* Sign `request.message` byte for byte as UTF-8, with no trimming and no
|
||||||
|
* Unicode normalisation: the signature must verify against exactly the text the
|
||||||
|
* dapp displayed. Use `signBitcoinMessage()` from `@wizardconnect/core`, which
|
||||||
|
* owns the magic string, both length prefixes and the header byte — the parts
|
||||||
|
* third-party verifiers check, and the parts this repo's conformance tests
|
||||||
|
* cover.
|
||||||
|
*
|
||||||
|
* Key selection depends on the request:
|
||||||
|
* - `path` and `addressIndex` set: sign with that key.
|
||||||
|
* - both absent: choose the key yourself and, ideally, echo it back in the
|
||||||
|
* result. The choice MUST be deterministic — a dapp treats the resulting
|
||||||
|
* address as a durable identity, so a fresh key per connection makes a
|
||||||
|
* returning user unrecognisable. Only advertise MODE_WALLET_CHOICE via
|
||||||
|
* `signMessageModes()` if you can honour that.
|
||||||
|
*
|
||||||
|
* Throw to reject: the manager turns it into an error response for the dapp.
|
||||||
|
*
|
||||||
|
* SECURITY: `request.userPrompt` is dapp-supplied and unsigned. Show the
|
||||||
|
* message itself as the thing being signed, and treat the prompt as untrusted
|
||||||
|
* decoration — otherwise a dapp can caption hostile text reassuringly. See
|
||||||
|
* docs/wallet.md.
|
||||||
|
*/
|
||||||
|
signMessage?(request: SignMessageRequest): Promise<SignMessageResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key-selection modes `signMessage` supports. Defaults to `[MODE_DAPP_PATH]`.
|
||||||
|
*
|
||||||
|
* Advertised to dapps so they can tell before offering the feature, rather than
|
||||||
|
* discovering it from a rejected request after the user clicked a button.
|
||||||
|
*/
|
||||||
|
signMessageModes?(): MessageSigningMode[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ import {
|
||||||
RelayUpdatePayload,
|
RelayUpdatePayload,
|
||||||
RelayStatusCallback,
|
RelayStatusCallback,
|
||||||
initiateWalletRelay,
|
initiateWalletRelay,
|
||||||
|
childIndexOfPathName,
|
||||||
|
isSignMessageRequest,
|
||||||
|
messageSignatureAddress,
|
||||||
|
recoverMessageSigner,
|
||||||
|
signMessageExtensionAdvertisement,
|
||||||
|
signMessageRequestMode,
|
||||||
|
MODE_DAPP_PATH,
|
||||||
|
SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||||
|
SignMessageRequest,
|
||||||
|
SignMessageFailure,
|
||||||
|
SignMessageSuccess,
|
||||||
SignTransactionRequest,
|
SignTransactionRequest,
|
||||||
SignTransactionResponse,
|
SignTransactionResponse,
|
||||||
SignCancelMessage,
|
SignCancelMessage,
|
||||||
|
|
@ -27,8 +38,8 @@ import {
|
||||||
chunkExtensionAdvertisement,
|
chunkExtensionAdvertisement,
|
||||||
peerSupportsChunk,
|
peerSupportsChunk,
|
||||||
} from "@wizardconnect/core";
|
} from "@wizardconnect/core";
|
||||||
import { WalletAdapter } from "./wallet-adapter.js";
|
import { SignMessageResult, WalletAdapter } from "./wallet-adapter.js";
|
||||||
import { DerivationPath } from "./derivation-path.js";
|
import { DerivationPath, pathOfChildIndex } from "./derivation-path.js";
|
||||||
|
|
||||||
export interface RelayConnectionState {
|
export interface RelayConnectionState {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -45,6 +56,12 @@ export interface PendingSignRequest {
|
||||||
request: SignTransactionRequest;
|
request: SignTransactionRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A dapp's message-signing request, awaiting the user's approval. */
|
||||||
|
export interface PendingSignMessageRequest {
|
||||||
|
connectionId: string;
|
||||||
|
request: SignMessageRequest;
|
||||||
|
}
|
||||||
|
|
||||||
interface ActiveConnection {
|
interface ActiveConnection {
|
||||||
id: string;
|
id: string;
|
||||||
uri: string;
|
uri: string;
|
||||||
|
|
@ -61,6 +78,12 @@ interface ActiveConnection {
|
||||||
walletReadySentThisCycle: boolean;
|
walletReadySentThisCycle: boolean;
|
||||||
/// Sign request sequences received on this connection, for cleanup on disconnect.
|
/// Sign request sequences received on this connection, for cleanup on disconnect.
|
||||||
signSequences: Set<number>;
|
signSequences: Set<number>;
|
||||||
|
/**
|
||||||
|
* Message-signing requests awaiting a response, by sequence. Retained so the
|
||||||
|
* response path can check the adapter's signature against the exact message it
|
||||||
|
* was asked to sign.
|
||||||
|
*/
|
||||||
|
pendingSignMessages: Map<number, SignMessageRequest>;
|
||||||
notificationQueue: ProtocolMessage[];
|
notificationQueue: ProtocolMessage[];
|
||||||
notificationProcessor: ReturnType<typeof setInterval> | null;
|
notificationProcessor: ReturnType<typeof setInterval> | null;
|
||||||
/// Key exchange data embedded in wallet_ready
|
/// Key exchange data embedded in wallet_ready
|
||||||
|
|
@ -71,6 +94,13 @@ interface ActiveConnection {
|
||||||
export type WalletConnectionManagerEvents = {
|
export type WalletConnectionManagerEvents = {
|
||||||
connectionStatusChanged: [connectionId: string, status: RelayStatus];
|
connectionStatusChanged: [connectionId: string, status: RelayStatus];
|
||||||
pendingSignRequest: [request: PendingSignRequest];
|
pendingSignRequest: [request: PendingSignRequest];
|
||||||
|
/**
|
||||||
|
* A dapp asked for a plain-message signature (`sign_message` extension).
|
||||||
|
* Host apps must show `request.message` verbatim and, on approval, call
|
||||||
|
* sendSignMessageResponse — or sendSignMessageError to reject. A
|
||||||
|
* `signCancelled` event for the same sequence means the dapp gave up.
|
||||||
|
*/
|
||||||
|
pendingSignMessageRequest: [request: PendingSignMessageRequest];
|
||||||
connectionsChanged: [];
|
connectionsChanged: [];
|
||||||
remoteDisconnect: [
|
remoteDisconnect: [
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
|
|
@ -132,6 +162,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
||||||
dappDiscovered: false,
|
dappDiscovered: false,
|
||||||
walletReadySentThisCycle: false,
|
walletReadySentThisCycle: false,
|
||||||
signSequences: new Set(),
|
signSequences: new Set(),
|
||||||
|
pendingSignMessages: new Map(),
|
||||||
notificationQueue: [],
|
notificationQueue: [],
|
||||||
notificationProcessor: null,
|
notificationProcessor: null,
|
||||||
walletPublicKeyHex: "",
|
walletPublicKeyHex: "",
|
||||||
|
|
@ -317,6 +348,122 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
||||||
await conn.client.relay(response);
|
await conn.client.relay(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a signed message back to the dapp.
|
||||||
|
*
|
||||||
|
* Derives the public key and address from the signature by recovery rather than
|
||||||
|
* taking the adapter's word for them, so the three values in the response
|
||||||
|
* cannot contradict each other.
|
||||||
|
*
|
||||||
|
* Then, whenever the signing key is identifiable — the dapp named a path, or the
|
||||||
|
* adapter echoed one — checks the recovered key against the adapter's own key
|
||||||
|
* for that path. Recovery alone cannot catch a signature over the wrong text:
|
||||||
|
* it succeeds regardless and simply yields a different key. Comparing against
|
||||||
|
* the key that should have signed is what turns that into a detectable error, so
|
||||||
|
* a wallet's derivation or encoding mistake surfaces here at the call site
|
||||||
|
* instead of as an opaque rejection on the far side of a relay.
|
||||||
|
*/
|
||||||
|
async sendSignMessageResponse(
|
||||||
|
connectionId: string,
|
||||||
|
sequence: number,
|
||||||
|
result: SignMessageResult,
|
||||||
|
): Promise<void> {
|
||||||
|
const conn = this.connections.get(connectionId);
|
||||||
|
if (!conn?.client) {
|
||||||
|
throw new Error(`Connection ${connectionId} not found or not connected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = conn.pendingSignMessages.get(sequence);
|
||||||
|
if (!request) {
|
||||||
|
throw new Error(
|
||||||
|
`No pending sign_message request with sequence ${sequence} on connection ${connectionId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signer = recoverMessageSigner(request.message, result.signature);
|
||||||
|
if (!signer) {
|
||||||
|
throw new Error(
|
||||||
|
"signMessage produced a malformed signature — build it with " +
|
||||||
|
"signBitcoinMessage() from @wizardconnect/core",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which key was this supposed to be? The adapter's echo wins over the
|
||||||
|
// request, because under MODE_WALLET_CHOICE the adapter is the only one who
|
||||||
|
// knows.
|
||||||
|
const path = result.path ?? request.path;
|
||||||
|
const addressIndex = result.addressIndex ?? request.addressIndex;
|
||||||
|
if (path !== undefined && addressIndex !== undefined) {
|
||||||
|
const derivationPath = pathOfChildIndex(childIndexOfPathName(path) ?? -1);
|
||||||
|
if (derivationPath !== undefined) {
|
||||||
|
const expected = this.adapter.getPublicKey(
|
||||||
|
derivationPath,
|
||||||
|
BigInt(addressIndex),
|
||||||
|
);
|
||||||
|
if (binToHex(signer.publicKey) !== binToHex(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`signMessage signed with ${binToHex(signer.publicKey)}, but ` +
|
||||||
|
`${path}/${addressIndex} is ${binToHex(expected)} — the message was ` +
|
||||||
|
`signed with the wrong key, or over text other than request.message`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const address = messageSignatureAddress(
|
||||||
|
request.message,
|
||||||
|
result.signature,
|
||||||
|
result.addressPrefix ?? "bitcoincash",
|
||||||
|
);
|
||||||
|
if (!address) {
|
||||||
|
throw new Error("Could not derive an address from the signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.pendingSignMessages.delete(sequence);
|
||||||
|
this.persistCompletedSequence(conn, sequence);
|
||||||
|
|
||||||
|
const response: SignMessageSuccess = {
|
||||||
|
action: RelayMsgAction.SignMessageResponse,
|
||||||
|
sequence,
|
||||||
|
signature: result.signature,
|
||||||
|
publicKey: binToHex(signer.publicKey),
|
||||||
|
address,
|
||||||
|
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||||
|
...(result.path !== undefined ? { path: result.path } : {}),
|
||||||
|
...(result.addressIndex !== undefined
|
||||||
|
? { addressIndex: result.addressIndex }
|
||||||
|
: {}),
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
await conn.client.relay(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tell the dapp the message was not signed (user rejected, or unsupported). */
|
||||||
|
async sendSignMessageError(
|
||||||
|
connectionId: string,
|
||||||
|
sequence: number,
|
||||||
|
errorMessage: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const conn = this.connections.get(connectionId);
|
||||||
|
if (!conn?.client) {
|
||||||
|
return; // Already disconnected, nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.pendingSignMessages.delete(sequence);
|
||||||
|
// Same reasoning as sendSignError: keep in the dedup guard until doDisconnect
|
||||||
|
// so a replayed request cannot re-prompt the user.
|
||||||
|
this.persistCompletedSequence(conn, sequence);
|
||||||
|
|
||||||
|
const response: SignMessageFailure = {
|
||||||
|
action: RelayMsgAction.SignMessageResponse,
|
||||||
|
sequence,
|
||||||
|
error: errorMessage,
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
await conn.client.relay(response);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Private helpers ---
|
// --- Private helpers ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -407,6 +554,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
||||||
case RelayMsgAction.SignCancel:
|
case RelayMsgAction.SignCancel:
|
||||||
this.handleSignCancel(conn, message as SignCancelMessage);
|
this.handleSignCancel(conn, message as SignCancelMessage);
|
||||||
break;
|
break;
|
||||||
|
case RelayMsgAction.SignMessageRequest:
|
||||||
|
this.handleSignMessageRequest(conn, message);
|
||||||
|
break;
|
||||||
case RelayMsgAction.Ping:
|
case RelayMsgAction.Ping:
|
||||||
this.handlePing(conn, message as PingMessage);
|
this.handlePing(conn, message as PingMessage);
|
||||||
break;
|
break;
|
||||||
|
|
@ -495,9 +645,20 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
||||||
];
|
];
|
||||||
|
|
||||||
const adapterExtensions = this.adapter.getExtensions?.();
|
const adapterExtensions = this.adapter.getExtensions?.();
|
||||||
|
// Advertise sign_message from the adapter's actual capability rather than
|
||||||
|
// making every wallet remember to declare it, so the handshake cannot claim
|
||||||
|
// support an adapter does not implement. An adapter that declares the key
|
||||||
|
// itself wins: spreading it last means an explicit declaration (with extra
|
||||||
|
// schemes, say) is never overwritten by this default.
|
||||||
|
const signMessageExtension = this.adapter.signMessage
|
||||||
|
? signMessageExtensionAdvertisement(
|
||||||
|
this.adapter.signMessageModes?.() ?? [MODE_DAPP_PATH],
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
const extensions = { ...signMessageExtension, ...adapterExtensions };
|
||||||
const hdwv1Session: Hdwalletv1Session = {
|
const hdwv1Session: Hdwalletv1Session = {
|
||||||
paths,
|
paths,
|
||||||
...(adapterExtensions ? { extensions: adapterExtensions } : {}),
|
...(Object.keys(extensions).length > 0 ? { extensions } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const msg: WalletReadyMessage = {
|
const msg: WalletReadyMessage = {
|
||||||
|
|
@ -521,6 +682,95 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
||||||
this.flushNotificationQueue(conn);
|
this.flushNotificationQueue(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dapp asked for a plain-message signature.
|
||||||
|
*
|
||||||
|
* Deduplicated against the same sequence set as transaction signing. That is
|
||||||
|
* correct rather than merely convenient: the dapp allocates every sequence from
|
||||||
|
* one per-connection counter (RelayClient.nextSequence), so a sequence
|
||||||
|
* identifies a request regardless of kind — which is also what lets one
|
||||||
|
* `sign_cancel` cancel either. Relays replay stored events on reconnect and the
|
||||||
|
* dapp re-sends pending requests on wallet_ready, so without the guard the user
|
||||||
|
* is prompted twice for one request.
|
||||||
|
*/
|
||||||
|
private handleSignMessageRequest(
|
||||||
|
conn: ActiveConnection,
|
||||||
|
msg: ProtocolMessage & { sequence?: number },
|
||||||
|
): void {
|
||||||
|
const sequence = msg.sequence;
|
||||||
|
if (typeof sequence !== "number") {
|
||||||
|
// No sequence means no way to address a reply, so there is nothing useful
|
||||||
|
// to say back. Drop it.
|
||||||
|
console.warn(
|
||||||
|
"[wizardconnect/wallet] sign_message_request without a sequence, ignoring",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.activeSignSequences.has(sequence)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed requests are answered, not dropped: a request with only half a
|
||||||
|
// key selection is ambiguous, and leaving the dapp waiting for a response
|
||||||
|
// that will never come is worse than telling it why.
|
||||||
|
if (!isSignMessageRequest(msg)) {
|
||||||
|
void this.sendSignMessageError(
|
||||||
|
conn.id,
|
||||||
|
sequence,
|
||||||
|
"Malformed sign_message_request",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse rather than hang if this wallet cannot sign messages. The dapp
|
||||||
|
// should have checked the advertised extension, but a stale dapp must get an
|
||||||
|
// answer either way.
|
||||||
|
if (!this.adapter.signMessage) {
|
||||||
|
void this.sendSignMessageError(
|
||||||
|
conn.id,
|
||||||
|
sequence,
|
||||||
|
"This wallet does not support message signing",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = msg.scheme ?? SCHEME_BITCOIN_SIGNED_MESSAGE;
|
||||||
|
if (scheme !== SCHEME_BITCOIN_SIGNED_MESSAGE) {
|
||||||
|
// Checked before prompting: asking the user to approve a signature we
|
||||||
|
// cannot produce in the requested scheme wastes their decision.
|
||||||
|
void this.sendSignMessageError(
|
||||||
|
conn.id,
|
||||||
|
sequence,
|
||||||
|
`Unsupported signature scheme: ${scheme}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = signMessageRequestMode(msg);
|
||||||
|
const supportedModes = this.adapter.signMessageModes?.() ?? [
|
||||||
|
MODE_DAPP_PATH,
|
||||||
|
];
|
||||||
|
if (!supportedModes.includes(mode)) {
|
||||||
|
void this.sendSignMessageError(
|
||||||
|
conn.id,
|
||||||
|
sequence,
|
||||||
|
`This wallet does not support the ${mode} key-selection mode`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeSignSequences.add(sequence);
|
||||||
|
conn.signSequences.add(sequence);
|
||||||
|
// Retained so sendSignMessageResponse can check the adapter's signature
|
||||||
|
// against the message it was supposed to sign.
|
||||||
|
conn.pendingSignMessages.set(sequence, msg);
|
||||||
|
|
||||||
|
this.emit("pendingSignMessageRequest", {
|
||||||
|
connectionId: conn.id,
|
||||||
|
request: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private handleSignRequest(
|
private handleSignRequest(
|
||||||
conn: ActiveConnection,
|
conn: ActiveConnection,
|
||||||
msg: SignTransactionRequest,
|
msg: SignTransactionRequest,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue