Compare commits
3 commits
master
...
feat/messa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9a0e16893 | ||
|
|
b40da7230a | ||
|
|
763754b30e |
37 changed files with 6200 additions and 338 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -21,12 +21,8 @@ This codebase communicates over a live relay with timing-sensitive handshakes an
|
|||
**Integration tests** (`npm run test:integration` in a package):
|
||||
- Hit the real relays at `wss://relay.riften.net:443` and `wss://relay.cauldron.quest:443`
|
||||
- Test the full protocol handshake end-to-end
|
||||
- Located in `src/integration/*.test.ts` (wallet is the only package with them today)
|
||||
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`, serially
|
||||
(`singleFork`) to avoid relay contention, and with `retry: 2` — these hit third-party
|
||||
relays, so a dropped connection is an environment failure rather than a regression
|
||||
- A failure that reproduces locally is real; one that does not is usually the relay.
|
||||
Check whether the same test passed on an earlier pipeline before assuming a regression
|
||||
- Located in `src/__tests__/*.integration.test.ts`
|
||||
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
|
||||
- Must pass before any release
|
||||
|
||||
### Running tests
|
||||
|
|
@ -91,13 +87,3 @@ npm run build # builds all packages in dependency order
|
|||
```
|
||||
|
||||
Packages must be built before integration tests run (tests import from `dist/`).
|
||||
|
||||
## Releases
|
||||
|
||||
The `publish` CI job runs on `master` only, via `contrib/auto-publish.js`.
|
||||
|
||||
**The `version` in each `package.json` is a floor, not the shipped version** — all declare
|
||||
`0.2.0` while npm carries higher patches. Use `npm view @wizardconnect/<pkg> version`.
|
||||
|
||||
The lockfile is not published, so clearing a dependency advisory means bumping the declared
|
||||
range in `package.json`, not just `package-lock.json`.
|
||||
|
|
|
|||
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. */
|
||||
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>
|
||||
|
||||
/** 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). */
|
||||
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
|
||||
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
|
||||
|
||||
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) |
|
||||
| `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 |
|
||||
| `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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
138
docs/protocol.md
138
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_response — wallet → dapp, returns signed tx or error
|
||||
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
|
||||
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
|
||||
|
|
@ -335,12 +340,20 @@ interface SignCancelMessage {
|
|||
}
|
||||
```
|
||||
|
||||
Sent by the **dapp only** to cancel an in-flight `sign_transaction_request`. The wallet should
|
||||
dismiss the corresponding sign dialog immediately upon receipt.
|
||||
Sent by the **dapp only** to cancel an in-flight `sign_transaction_request` **or
|
||||
`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:
|
||||
- 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).
|
||||
- A login prompt the user never answered. `sign_message` has no timeout by design — cancellation is
|
||||
explicit, matching `sign_transaction_request`.
|
||||
|
||||
**Dapp side** (`DappConnectionManager`):
|
||||
- `sendSignCancel(sequence, reason?)` — immediately rejects the pending Promise for that sequence,
|
||||
|
|
@ -352,18 +365,123 @@ 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
|
||||
|
||||
Either side may send a `disconnect` message before tearing down the relay connection. This is a
|
||||
courtesy notification — the remote side treats the connection as closed immediately upon receipt
|
||||
(no acknowledgement).
|
||||
|
||||
No acknowledgement does not mean fire-and-forget on the sender's side. `relay()` resolves only
|
||||
after the publish has been settled against every configured relay, so the sender must keep the
|
||||
relay connection open until then — closing it first kills the publish in flight and the peer
|
||||
never learns of the disconnect. Sending a courtesy `disconnect` and immediately tearing the
|
||||
transport down is the same as not sending one.
|
||||
|
||||
```typescript
|
||||
enum DisconnectReason {
|
||||
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
|
||||
|
|
@ -379,9 +497,7 @@ interface DisconnectMessage {
|
|||
```
|
||||
|
||||
**Wallet side** (`WalletConnectionManager`):
|
||||
- `disconnect(id)` sends `UserDisconnect`, then tears the connection down once the publish
|
||||
settles — bounded, so an unreachable relay cannot hold the socket open. The connection leaves
|
||||
the registry synchronously. See [wallet.md § Sending disconnect](wallet.md#sending-disconnect).
|
||||
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
|
||||
- Incoming `disconnect` emits a `remoteDisconnect` event
|
||||
(`connectionId`, `reason`, `message`) and removes the connection.
|
||||
|
||||
|
|
|
|||
|
|
@ -50,12 +50,10 @@ connect(): Promise<void>
|
|||
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
|
||||
|
||||
disconnect(): Promise<void>
|
||||
// Stop subscription, close the relay pool, mark queue not-ready, update
|
||||
// lastProcessedTimestamp. Kills any in-flight publish — see below.
|
||||
// Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
|
||||
|
||||
relay(message: ProtocolMessage): Promise<void>
|
||||
// Send a message. Enqueues if relays not ready. Throws if paired key not set.
|
||||
// Resolves only once the publish has settled against every configured relay.
|
||||
|
||||
setPairedPublicKey(key: Uint8Array): void
|
||||
// Called after key exchange. Enables outbound messages and incoming peer filtering.
|
||||
|
|
@ -80,10 +78,6 @@ regardless (avoiding silent message loss on slow connections).
|
|||
On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are
|
||||
held rather than dropped.
|
||||
|
||||
Because `disconnect()` closes the pool, it kills any publish still in flight — so anything
|
||||
sending a final message before tearing down must await the `relay()` first. See
|
||||
[wallet.md § Sending disconnect](wallet.md#sending-disconnect).
|
||||
|
||||
### Replay protection
|
||||
|
||||
`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated
|
||||
|
|
|
|||
131
docs/wallet.md
131
docs/wallet.md
|
|
@ -34,6 +34,13 @@ interface WalletAdapter {
|
|||
|
||||
/** Optional: extension data for the session handshake. */
|
||||
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
|
||||
|
||||
```typescript
|
||||
|
|
@ -69,11 +92,9 @@ class WalletConnectionManager extends EventEmitter {
|
|||
connect(uri: string): string
|
||||
|
||||
// Tear down a specific connection, sending a UserDisconnect courtesy message.
|
||||
// Returns as soon as the connection has left the registry; the relay socket
|
||||
// closes once the courtesy message is published. See § Sending disconnect.
|
||||
disconnect(connectionId: string): void
|
||||
|
||||
// Tear down all connections. Each is disconnected independently.
|
||||
// Tear down all connections.
|
||||
disconnectAll(): void
|
||||
|
||||
// Snapshot of all connections for UI rendering.
|
||||
|
|
@ -85,9 +106,17 @@ class WalletConnectionManager extends EventEmitter {
|
|||
// Send an error back to the dapp (user rejected, signing failed, etc.)
|
||||
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
|
||||
on("connectionStatusChanged", (id: string, status: RelayStatus) => void)
|
||||
on("pendingSignRequest", (req: PendingSignRequest) => void)
|
||||
on("pendingSignMessageRequest", (req: PendingSignMessageRequest) => void)
|
||||
on("connectionsChanged", () => void)
|
||||
on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void)
|
||||
on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages
|
||||
|
|
@ -156,28 +185,6 @@ wallet_discovered=true → set dappDiscovered=true, no further action
|
|||
|
||||
`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them.
|
||||
|
||||
### Sending disconnect
|
||||
|
||||
`disconnect(id)` and `disconnectAll()` split teardown into two phases, because the two halves
|
||||
have opposite timing requirements.
|
||||
|
||||
**Synchronous** — the connection is removed from the registry, its pending sign sequences are
|
||||
released, and `connectionsChanged` is emitted. This cannot wait: `getConnections()` is what the
|
||||
UI renders, and `connect()` returns the existing connection for a URI that already has one, so a
|
||||
connection left in the map during teardown would be handed back to a caller as if it were live.
|
||||
|
||||
**Deferred** — the relay socket closes only after the courtesy `disconnect` message has been
|
||||
published. `RelayClient.relay()` resolves after the publish settles against every configured
|
||||
relay, which is a real round trip; closing the socket before that kills the publish in flight and
|
||||
the dapp keeps believing the wallet is connected until its own liveness timeout fires.
|
||||
|
||||
The deferral is bounded by `DISCONNECT_PUBLISH_TIMEOUT_MS` (5 s). A publish that never settles is
|
||||
precisely the unreachable-relay case, and a socket that is never closed is a worse failure than a
|
||||
courtesy message that is never delivered.
|
||||
|
||||
Callers do not need to await anything. The observable contract is that state is correct
|
||||
immediately and delivery is best-effort within the timeout.
|
||||
|
||||
### Receiving disconnect
|
||||
|
||||
When a `disconnect` message arrives from the dapp:
|
||||
|
|
@ -209,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
|
||||
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
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
134
package-lock.json
generated
134
package-lock.json
generated
|
|
@ -1400,15 +1400,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
|
|
@ -1417,13 +1417,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
|
|
@ -1444,9 +1444,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
||||
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1457,13 +1457,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.7",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
|
|
@ -1472,13 +1472,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
|
|
@ -1487,9 +1487,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1500,13 +1500,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
|
|
@ -1608,16 +1608,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
|
|
@ -2470,9 +2470,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2706,9 +2706,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2726,7 +2726,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.17",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -3147,14 +3147,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
|
|
@ -3246,20 +3246,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.7",
|
||||
"@vitest/mocker": "3.2.7",
|
||||
"@vitest/pretty-format": "^3.2.7",
|
||||
"@vitest/runner": "3.2.7",
|
||||
"@vitest/snapshot": "3.2.7",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
|
|
@ -3289,8 +3289,8 @@
|
|||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
|
|
@ -3372,9 +3372,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
|
|
@ -3408,7 +3408,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@wizardconnect/core",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@bch-wc2/interfaces": "^0.0.8",
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
|
|
@ -3416,28 +3416,28 @@
|
|||
"isomorphic-ws": "^5.0.0",
|
||||
"lossless-json": "^4.3.0",
|
||||
"nostr-tools": "^2.23.0",
|
||||
"ws": "^8.21.3"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"packages/dapp": {
|
||||
"name": "@wizardconnect/dapp",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"eventemitter3": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"packages/react": {
|
||||
"name": "@wizardconnect/react",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"@wizardconnect/dapp": "*",
|
||||
|
|
@ -3450,7 +3450,7 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
|
|
@ -3511,7 +3511,7 @@
|
|||
},
|
||||
"packages/test-cli": {
|
||||
"name": "@wizardconnect/test-cli",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"@wizardconnect/core": "*",
|
||||
|
|
@ -3531,7 +3531,7 @@
|
|||
},
|
||||
"packages/wallet": {
|
||||
"name": "@wizardconnect/wallet",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"@wizardconnect/core": "*",
|
||||
|
|
@ -3539,7 +3539,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
|
||||
"test:compat": "vitest --config vitest.compat.config.ts --run",
|
||||
"lint:prettier": "prettier --ignore-path ../../.gitignore . --list-different",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint": "npm run lint:eslint && npm run lint:prettier",
|
||||
|
|
@ -40,10 +41,10 @@
|
|||
"eventemitter3": "^5.0.1",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lossless-json": "^4.3.0",
|
||||
"ws": "^8.21.3"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export type {
|
|||
KeyExchangeURIResult,
|
||||
} from "./key-exchange.js";
|
||||
export * from "./protocols/hdwalletv1.js";
|
||||
export * from "./protocols/message-signing.js";
|
||||
export * from "./protocols/login-challenge.js";
|
||||
export * from "./protocols/base.js";
|
||||
export {
|
||||
CHUNK_EXTENSION_NAME,
|
||||
|
|
|
|||
|
|
@ -21,8 +21,15 @@ export enum RelayMsgAction {
|
|||
/// Request: The dapp wants wallet to sign a transaction.
|
||||
SignTransactionRequest = "sign_transaction_request",
|
||||
SignTransactionResponse = "sign_transaction_response",
|
||||
/// Dapp-only: cancels an in-flight sign_transaction_request.
|
||||
/// Dapp-only: cancels an in-flight sign_transaction_request or
|
||||
/// sign_message_request. Sequences are unique across both, so one action
|
||||
/// cancels either.
|
||||
SignCancel = "sign_cancel",
|
||||
/// Request: The dapp wants the wallet to sign a plain message, proving control
|
||||
/// of a key without a transaction. Gated on the `sign_message` extension —
|
||||
/// see message-signing.ts and docs/extensions.md.
|
||||
SignMessageRequest = "sign_message_request",
|
||||
SignMessageResponse = "sign_message_response",
|
||||
/// Courtesy notification: one side is closing the connection.
|
||||
Disconnect = "disconnect",
|
||||
/// Transport-level: carries one slice of a message that exceeds NIP-44's
|
||||
|
|
|
|||
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({
|
||||
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/**",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,58 @@ import {
|
|||
ProtocolMessage,
|
||||
PROTOCOL_NAME,
|
||||
childIndexOfPathName,
|
||||
checkSignMessageResponse,
|
||||
isHdwalletv1Session,
|
||||
isSignMessageFailure,
|
||||
peerSupportsSignMessage,
|
||||
MODE_DAPP_PATH,
|
||||
SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||
binToHex,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
} 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 {
|
||||
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. */
|
||||
private lastPongTime: number = 0;
|
||||
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<
|
||||
number,
|
||||
{
|
||||
|
|
@ -270,6 +336,15 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
this.pendingSignatureRequests.delete(sequence);
|
||||
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;
|
||||
const msg: SignCancelMessage = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
|
|
@ -281,6 +356,142 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
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).
|
||||
* The caller is responsible for calling dappRelay.cleanup() afterwards.
|
||||
|
|
@ -489,6 +700,9 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
case RelayMsgAction.SignTransactionResponse:
|
||||
this.handleSignTransactionResponse(msg as SignTransactionResponse);
|
||||
break;
|
||||
case RelayMsgAction.SignMessageResponse:
|
||||
this.handleSignMessageResponse(msg as SignMessageResponse);
|
||||
break;
|
||||
case RelayMsgAction.Disconnect:
|
||||
this.handleRemoteDisconnect(msg as DisconnectMessage);
|
||||
break;
|
||||
|
|
@ -572,6 +786,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
|
||||
// Store raw paths for getSessionPaths() and xpub nodes for derivation
|
||||
this.sessionPaths = [...sessionData.paths];
|
||||
this.sessionExtensions = { ...(sessionData.extensions ?? {}) };
|
||||
for (const pathInfo of sessionData.paths) {
|
||||
const decoded = decodeHdPublicKey(pathInfo.xpub);
|
||||
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
|
||||
if (this.sessionOptions) {
|
||||
const sessionUpdate: Partial<StoredSession> = {
|
||||
|
|
@ -632,6 +862,99 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
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(
|
||||
response: SignTransactionResponse,
|
||||
): void {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ export { DappConnectionManager } from "./dapp-connection-manager.js";
|
|||
export type {
|
||||
DappConnectionManagerEvents,
|
||||
DappSessionOptions,
|
||||
MessageKeyBinding,
|
||||
VerifiedMessageSignature,
|
||||
} from "./dapp-connection-manager.js";
|
||||
export { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -39,6 +39,6 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// 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 { Command } from "commander";
|
||||
import { Command, Option } from "commander";
|
||||
import { runDappMode } from "./dapp.js";
|
||||
import { runWalletMode } from "./wallet.js";
|
||||
|
||||
|
|
@ -37,6 +37,15 @@ program
|
|||
"--sign",
|
||||
"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) => {
|
||||
await runDappMode(options);
|
||||
});
|
||||
|
|
@ -54,6 +63,10 @@ program
|
|||
"-k, --private-key <hex>",
|
||||
"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) => {
|
||||
await runWalletMode(options);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,19 @@ import {
|
|||
type SignTransactionResponse,
|
||||
type ProtocolMessage,
|
||||
type RelayClient,
|
||||
type SignMessageRequest,
|
||||
type SignMessageResponse,
|
||||
checkSignMessageResponse,
|
||||
createInMemoryNonceStore,
|
||||
createLoginChallenge,
|
||||
createLoginNonce,
|
||||
isSignMessageFailure,
|
||||
peerSignMessageInfo,
|
||||
peerSupportsSignMessage,
|
||||
verifyLoginChallenge,
|
||||
verifyMessageSignatureForAddress,
|
||||
MODE_DAPP_PATH,
|
||||
MODE_WALLET_CHOICE,
|
||||
} from "@wizardconnect/core";
|
||||
|
||||
// ---- State ----
|
||||
|
|
@ -26,6 +39,13 @@ interface DappState {
|
|||
// Whether we've received at least one wallet_ready
|
||||
walletReady: boolean;
|
||||
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 {
|
||||
|
|
@ -35,6 +55,10 @@ function makeState(): DappState {
|
|||
walletIcon: null,
|
||||
walletReady: false,
|
||||
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 ----
|
||||
|
||||
function handleMessage(
|
||||
|
|
@ -108,6 +183,7 @@ function handleMessage(
|
|||
client: RelayClient,
|
||||
state: DappState,
|
||||
sendSign: boolean,
|
||||
signMessageMode: "off" | "dapp_path" | "wallet_choice",
|
||||
): void {
|
||||
const now = chalk.dim(new Date().toISOString().slice(11, 23));
|
||||
|
||||
|
|
@ -138,6 +214,20 @@ function handleMessage(
|
|||
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 (sendSign && state.walletReady) {
|
||||
console.log(
|
||||
|
|
@ -147,6 +237,97 @@ function handleMessage(
|
|||
sendSignRequest(client, state).catch(() => {});
|
||||
}, 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;
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +364,7 @@ export async function runDappMode(options: {
|
|||
secret?: string;
|
||||
walletPublicKey?: string;
|
||||
sign?: boolean;
|
||||
signMessage?: "off" | "dapp_path" | "wallet_choice";
|
||||
}): Promise<void> {
|
||||
const state = makeState();
|
||||
|
||||
|
|
@ -273,7 +455,13 @@ export async function runDappMode(options: {
|
|||
|
||||
// Register message handler
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ import {
|
|||
type WalletAdapter,
|
||||
DerivationPath,
|
||||
} from "@wizardconnect/wallet";
|
||||
import { generateKeyExchangeCredentials, hexToBin } from "@wizardconnect/core";
|
||||
import {
|
||||
generateKeyExchangeCredentials,
|
||||
hexToBin,
|
||||
signBitcoinMessage,
|
||||
MODE_DAPP_PATH,
|
||||
MODE_WALLET_CHOICE,
|
||||
} from "@wizardconnect/core";
|
||||
import {
|
||||
deriveHdPrivateNodeFromSeed,
|
||||
deriveHdPrivateNodeChild,
|
||||
|
|
@ -73,6 +79,27 @@ async function buildAdapter(
|
|||
async signTransaction(_request): Promise<any> {
|
||||
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;
|
||||
uri: string;
|
||||
privateKey?: string;
|
||||
rejectMessages?: boolean;
|
||||
}): Promise<void> {
|
||||
console.log(chalk.bold("\nwiz-test wallet mode"));
|
||||
console.log(chalk.dim(`relay: ${options.relay}`));
|
||||
|
|
@ -106,6 +134,7 @@ export async function runWalletMode(options: {
|
|||
const adapter = await buildAdapter(relayPrivKey);
|
||||
buildSpinner.succeed("Wallet adapter ready");
|
||||
|
||||
const rejectMessages = options.rejectMessages ?? false;
|
||||
const manager = new WalletConnectionManager(adapter);
|
||||
|
||||
manager.on("connectionStatusChanged", (connectionId, status) => {
|
||||
|
|
@ -136,6 +165,56 @@ export async function runWalletMode(options: {
|
|||
.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..."));
|
||||
const connectionId = manager.connect(options.uri);
|
||||
console.log(chalk.dim(`connection id: ${connectionId}`));
|
||||
|
|
|
|||
|
|
@ -31,6 +31,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,15 @@ export {
|
|||
childIndexOfPath,
|
||||
pathOfChildIndex,
|
||||
} 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 type {
|
||||
RelayConnectionState,
|
||||
PendingSignRequest,
|
||||
PendingSignMessageRequest,
|
||||
WalletConnectionManagerEvents,
|
||||
} from "./wallet-connection-manager.js";
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
// 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-initiated disconnect must actually reach the dapp.
|
||||
*
|
||||
* disconnect.test.ts covers the other direction — the dapp sends `disconnect`
|
||||
* and the wallet reacts. Nothing covered wallet → dapp, which is how this got
|
||||
* shipped broken: doDisconnect fired the courtesy message without awaiting it and
|
||||
* then tore the relay connection down, so the publish died mid-flight and the
|
||||
* dapp went on believing the wallet was connected until its own liveness
|
||||
* timeout. Downstream wallets were carrying a patch for it.
|
||||
*
|
||||
* These tests are worth their runtime because the failure is invisible locally —
|
||||
* the wallet's own state is correct either way, and only the peer notices.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { RelayMsgAction, DisconnectReason } from "@wizardconnect/core";
|
||||
import type { DisconnectMessage, ProtocolMessage } from "@wizardconnect/core";
|
||||
|
||||
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
|
||||
|
||||
let handles: ConnectionHandles | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
handles?.cleanup();
|
||||
handles = null;
|
||||
});
|
||||
|
||||
/** Disconnect messages the dapp actually received off the relay. */
|
||||
function disconnectsSeenByDapp(h: ConnectionHandles): DisconnectMessage[] {
|
||||
return h.dapp.messages.filter(
|
||||
(msg: ProtocolMessage) => msg.action === RelayMsgAction.Disconnect,
|
||||
) as DisconnectMessage[];
|
||||
}
|
||||
|
||||
describe("wallet-initiated disconnect", () => {
|
||||
it("delivers the courtesy disconnect to the dapp", async () => {
|
||||
handles = await setupConnection();
|
||||
expect(disconnectsSeenByDapp(handles)).toHaveLength(0);
|
||||
|
||||
handles.wallet.manager.disconnect(handles.wallet.connectionId);
|
||||
|
||||
// The whole point: this arrives over a real relay, which it cannot do if the
|
||||
// connection is torn down while the publish is still in flight.
|
||||
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
|
||||
timeoutMs: 20000,
|
||||
what: "disconnect message received by the dapp",
|
||||
});
|
||||
|
||||
expect(disconnectsSeenByDapp(handles)[0].reason).toBe(
|
||||
DisconnectReason.UserDisconnect,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the connection immediately, without waiting for delivery", async () => {
|
||||
// Teardown is deferred, but the registry must not be: a caller that
|
||||
// disconnects and then inspects state should never see the dying connection.
|
||||
handles = await setupConnection();
|
||||
const { manager, connectionId } = handles.wallet;
|
||||
expect(Object.keys(manager.getConnections())).toContain(connectionId);
|
||||
|
||||
manager.disconnect(connectionId);
|
||||
|
||||
expect(Object.keys(manager.getConnections())).not.toContain(connectionId);
|
||||
});
|
||||
|
||||
it("delivers a disconnect for every connection in disconnectAll", async () => {
|
||||
handles = await setupConnection();
|
||||
|
||||
handles.wallet.manager.disconnectAll();
|
||||
|
||||
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
|
||||
timeoutMs: 20000,
|
||||
what: "disconnect message from disconnectAll",
|
||||
});
|
||||
expect(Object.keys(handles.wallet.manager.getConnections())).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the dapp reconnect on the same URI afterwards", async () => {
|
||||
// Deferring teardown must not leave the URI unusable — connect() returns an
|
||||
// existing connection for a URI, so a stale entry would be handed back.
|
||||
handles = await setupConnection();
|
||||
const { manager, connectionId } = handles.wallet;
|
||||
|
||||
manager.disconnect(connectionId);
|
||||
const reconnectedId = manager.connect(handles.dapp.uri);
|
||||
|
||||
expect(reconnectedId).not.toBe(connectionId);
|
||||
expect(Object.keys(manager.getConnections())).toContain(reconnectedId);
|
||||
|
||||
manager.disconnect(reconnectedId);
|
||||
});
|
||||
});
|
||||
|
|
@ -24,6 +24,9 @@ import {
|
|||
PROTOCOL_NAME,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
signBitcoinMessage,
|
||||
MODE_DAPP_PATH,
|
||||
MODE_WALLET_CHOICE,
|
||||
type RelayClient,
|
||||
type RelayUpdatePayload,
|
||||
type DappReadyMessage,
|
||||
|
|
@ -102,6 +105,29 @@ export function makeTestAdapter(seed?: Uint8Array): WalletAdapter {
|
|||
async signTransaction() {
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -205,38 +231,10 @@ export async function setupConnection(
|
|||
}
|
||||
});
|
||||
|
||||
// Prompt one more wallet_ready, now that the handler above is registered.
|
||||
// The wallet_ready that completed key exchange was consumed by
|
||||
// initiateDappRelay before this handler existed, so it is not in
|
||||
// walletReadyMessages and the caller's "wallet_ready with paths" wait
|
||||
// needs a fresh one. dapp_discovered=false is what makes the wallet
|
||||
// re-send.
|
||||
// Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
|
||||
await sendDappReady(false);
|
||||
});
|
||||
|
||||
// Re-announce until key exchange completes.
|
||||
//
|
||||
// The wallet sends exactly one wallet_ready per connection cycle, and it
|
||||
// fires as soon as manager.connect() resolves — which can be before this
|
||||
// dapp's relay subscription is live. If that single message is missed there
|
||||
// is nothing to retry against: keyexchangecomplete never fires, so the
|
||||
// handler above never registers and no dapp_ready is ever sent. The suite
|
||||
// then sat until the 15s "key exchange" timeout. Under singleFork the
|
||||
// previous file's teardown is still closing sockets while this runs, which
|
||||
// is exactly when the race is won by the wrong side.
|
||||
//
|
||||
// dapp_ready(wallet_discovered=false) resets walletReadySentThisCycle on the
|
||||
// wallet, so each retry earns another wallet_ready. This is the recovery path
|
||||
// the protocol's mutual-discovery design already specifies — the harness
|
||||
// simply was not using it.
|
||||
const reannounce = setInterval(() => {
|
||||
if (keyExchanged) {
|
||||
clearInterval(reannounce);
|
||||
return;
|
||||
}
|
||||
if (dappClient) sendDappReady(false).catch(() => {});
|
||||
}, 2000);
|
||||
|
||||
// ---- Wallet side ----
|
||||
|
||||
const manager = new WalletConnectionManager(adapter);
|
||||
|
|
@ -244,16 +242,7 @@ export async function setupConnection(
|
|||
|
||||
// ---- Wait for key exchange ----
|
||||
|
||||
try {
|
||||
await waitFor(() => keyExchanged, {
|
||||
timeoutMs: 15000,
|
||||
what: "key exchange",
|
||||
});
|
||||
} finally {
|
||||
// Must not outlive the wait: on timeout a leaked interval keeps publishing
|
||||
// dapp_ready into later tests and holds the fork open.
|
||||
clearInterval(reannounce);
|
||||
}
|
||||
await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" });
|
||||
|
||||
// ---- Wait for wallet_ready with paths ----
|
||||
|
||||
|
|
|
|||
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
|
||||
|
||||
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 {
|
||||
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.
|
||||
*/
|
||||
|
|
@ -57,6 +92,50 @@ export interface WalletAdapter {
|
|||
* Optional extension data to include in the hdwalletv1 session handshake.
|
||||
* Each key is an extension name; its presence indicates wallet support.
|
||||
* 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>;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
RelayStatusCallback,
|
||||
initiateWalletRelay,
|
||||
childIndexOfPathName,
|
||||
isSignMessageRequest,
|
||||
messageSignatureAddress,
|
||||
recoverMessageSigner,
|
||||
signMessageExtensionAdvertisement,
|
||||
signMessageRequestMode,
|
||||
MODE_DAPP_PATH,
|
||||
SCHEME_BITCOIN_SIGNED_MESSAGE,
|
||||
SignMessageRequest,
|
||||
SignMessageFailure,
|
||||
SignMessageSuccess,
|
||||
SignTransactionRequest,
|
||||
SignTransactionResponse,
|
||||
SignCancelMessage,
|
||||
|
|
@ -27,8 +38,8 @@ import {
|
|||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
} from "@wizardconnect/core";
|
||||
import { WalletAdapter } from "./wallet-adapter.js";
|
||||
import { DerivationPath } from "./derivation-path.js";
|
||||
import { SignMessageResult, WalletAdapter } from "./wallet-adapter.js";
|
||||
import { DerivationPath, pathOfChildIndex } from "./derivation-path.js";
|
||||
|
||||
export interface RelayConnectionState {
|
||||
id: string;
|
||||
|
|
@ -45,14 +56,11 @@ export interface PendingSignRequest {
|
|||
request: SignTransactionRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long doDisconnect waits for the courtesy `disconnect` message to be
|
||||
* published before tearing the relay connection down anyway.
|
||||
*
|
||||
* Generous enough for a slow relay, short enough that an unreachable one cannot
|
||||
* hold the socket open indefinitely.
|
||||
*/
|
||||
const DISCONNECT_PUBLISH_TIMEOUT_MS = 5000;
|
||||
/** A dapp's message-signing request, awaiting the user's approval. */
|
||||
export interface PendingSignMessageRequest {
|
||||
connectionId: string;
|
||||
request: SignMessageRequest;
|
||||
}
|
||||
|
||||
interface ActiveConnection {
|
||||
id: string;
|
||||
|
|
@ -70,6 +78,12 @@ interface ActiveConnection {
|
|||
walletReadySentThisCycle: boolean;
|
||||
/// Sign request sequences received on this connection, for cleanup on disconnect.
|
||||
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[];
|
||||
notificationProcessor: ReturnType<typeof setInterval> | null;
|
||||
/// Key exchange data embedded in wallet_ready
|
||||
|
|
@ -80,6 +94,13 @@ interface ActiveConnection {
|
|||
export type WalletConnectionManagerEvents = {
|
||||
connectionStatusChanged: [connectionId: string, status: RelayStatus];
|
||||
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: [];
|
||||
remoteDisconnect: [
|
||||
connectionId: string,
|
||||
|
|
@ -141,6 +162,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
dappDiscovered: false,
|
||||
walletReadySentThisCycle: false,
|
||||
signSequences: new Set(),
|
||||
pendingSignMessages: new Map(),
|
||||
notificationQueue: [],
|
||||
notificationProcessor: null,
|
||||
walletPublicKeyHex: "",
|
||||
|
|
@ -226,58 +248,22 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
const conn = this.connections.get(connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
// Drop the connection from the registry synchronously, before any awaiting.
|
||||
// getConnections() must reflect the disconnect immediately, and connect()
|
||||
// returns an existing connection for a URI — so leaving this one in the map
|
||||
// while its teardown is pending would hand a caller a dying connection.
|
||||
if (sendMessage && conn.client) {
|
||||
const disconnectMsg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
conn.client.relay(disconnectMsg).catch(() => {});
|
||||
}
|
||||
|
||||
for (const seq of conn.signSequences) {
|
||||
this.activeSignSequences.delete(seq);
|
||||
}
|
||||
clearInterval(conn.notificationProcessor ?? undefined);
|
||||
conn.notificationProcessor = null;
|
||||
conn.cleanup();
|
||||
this.connections.delete(connectionId);
|
||||
this.emit("connectionsChanged");
|
||||
|
||||
if (!sendMessage || !conn.client) {
|
||||
conn.cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const disconnectMsg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
// Tear down only once the courtesy message has actually gone out.
|
||||
//
|
||||
// relay() resolves after `Promise.allSettled(pool.publish(...))` — a real
|
||||
// round trip to every configured relay. Calling conn.cleanup() straight
|
||||
// after firing it closed the pool underneath the in-flight publish, so the
|
||||
// disconnect usually never reached the relay and the dapp went on believing
|
||||
// the wallet was connected until its own liveness timeout fired. Downstream
|
||||
// wallets were patching this out of the published package.
|
||||
//
|
||||
// Bounded, because "the publish never settles" is exactly the case where a
|
||||
// relay is unreachable, and a socket that is never closed is worse than a
|
||||
// courtesy message that is never delivered.
|
||||
let torndown = false;
|
||||
const teardown = () => {
|
||||
if (torndown) return;
|
||||
torndown = true;
|
||||
conn.cleanup();
|
||||
};
|
||||
|
||||
const timer = setTimeout(teardown, DISCONNECT_PUBLISH_TIMEOUT_MS);
|
||||
conn.client
|
||||
.relay(disconnectMsg)
|
||||
.catch(() => {
|
||||
// Nothing to do: we are disconnecting either way.
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timer);
|
||||
teardown();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -362,6 +348,122 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
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 ---
|
||||
|
||||
/**
|
||||
|
|
@ -452,6 +554,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
case RelayMsgAction.SignCancel:
|
||||
this.handleSignCancel(conn, message as SignCancelMessage);
|
||||
break;
|
||||
case RelayMsgAction.SignMessageRequest:
|
||||
this.handleSignMessageRequest(conn, message);
|
||||
break;
|
||||
case RelayMsgAction.Ping:
|
||||
this.handlePing(conn, message as PingMessage);
|
||||
break;
|
||||
|
|
@ -540,9 +645,20 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
];
|
||||
|
||||
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 = {
|
||||
paths,
|
||||
...(adapterExtensions ? { extensions: adapterExtensions } : {}),
|
||||
...(Object.keys(extensions).length > 0 ? { extensions } : {}),
|
||||
};
|
||||
|
||||
const msg: WalletReadyMessage = {
|
||||
|
|
@ -566,6 +682,95 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
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(
|
||||
conn: ActiveConnection,
|
||||
msg: SignTransactionRequest,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ export default defineConfig({
|
|||
include: ["src/integration/**/*.test.ts"],
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 60000,
|
||||
// These tests talk to live relays. A dropped connection or a slow publish
|
||||
// is an environment failure, not a regression, and without a retry a single
|
||||
// one reds the whole pipeline.
|
||||
retry: 2,
|
||||
// Run integration tests serially to avoid relay contention
|
||||
pool: "forks",
|
||||
poolOptions: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue