The sign_message extension leaves replay to the dapp, because a signature proves
key control over an exact string and carries no freshness or audience. The
previous commit said so in three places in the docs. That is exactly the failure
this repository should not ship: a login built on a bare signMessage call works in
every manual test and is a password that never expires, so documenting the
requirement mostly relocates the blame.
So the two failures that matter are structural here rather than advisory:
verifyLoginChallenge cannot be called without `domain` and `consumeNonce`. There
is no overload that omits them. Verifying a login without single-use enforcement
and audience binding is not something this API can express — if you want plain
signature verification, verifyMessageSignatureForAddress is right there and is
honestly named.
createLoginChallenge refuses a nonce under MIN_NONCE_LENGTH and refuses a line
break in any field, so neither a guessable nonce nor an injected `Nonce:` line
can reach a signed message.
Check order is deliberate: parse, domain, expiry, signature, THEN consume the
nonce. Consuming earlier would let anyone who sniffs a nonce burn it with a
garbage signature before the real user finishes signing; there is a test asserting
the nonce survives a bad signature and the genuine login still completes.
consumeNonce is a caller-supplied callback rather than a store this module owns,
because single-use enforcement is a property of the caller's database — two
replays arriving together both reach that point and only one may be told true. The
docstring says it must be atomic. createInMemoryNonceStore exists for development
and says plainly that it is per-process, so two servers behind a load balancer
would each honour the same signature once.
parseLoginChallenge is strict: unknown fields, duplicate fields, out-of-order
fields and stray lines are rejected rather than skipped, so exactly one byte
sequence parses to a given challenge. A lenient parser is where field injection
lives.
Address is optional in the message because under wallet_choice the dapp does not
yet know which key will answer. When present the proof is self-describing — a
third party reading the message alone sees which address was claimed — and
verification then requires the recovered address to match it.
NOT SIWE. The layout is deliberately similar to Sign-In With Ethereum so it reads
familiarly, but it does not claim EIP-4361 or CAIP-122 compatibility: 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.
Also adds addressesEqual() to message-signing, which compares decoded public key
hashes so prefixed CashAddr, bare CashAddr, the token-aware form and legacy base58
all compare equal for the same key.
27 tests, mostly about what must be refused: the replay, the wrong site, the stale
and future-dated challenge, four field-injection attempts, the nonce-burning
attack, the wrong key, and a cross-encoding address match.
test-cli now builds its challenge with these helpers rather than hand-rolled text,
since that is what integrators copy, and verifies the response twice — once as a
third party would and once as the server would, printing proof that replaying the
identical signature is rejected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dapps have been asking for message signing to prove key control — identity
verification, SIWX-style login, and publishing a signed statement on chain. This
adds the construction and the verification; the protocol wiring follows
separately.
The known workaround is a dummy transaction: one input with a null outpoint, its
prevout script set to the P2PKH of the key being proven, one OP_RETURN output
carrying a server nonce, signed but never broadcast. It covers login, but not the
rest. A signature made that way is bound to a transaction, so verifying it means
rebuilding that exact transaction and knowing how it was serialised — it cannot
be published in an OP_RETURN and checked later by a third party holding only the
message, the signature and an address. It also asks a wallet to sign a real
transaction preimage, which is one bug away from signing a genuine spend.
So this produces the portable form: the standard "Bitcoin Signed Message"
construction. BCH wallets kept Bitcoin's magic string verbatim, so a signature
made here verifies in Electron Cash, Electrum and `bitcoin-cli verifymessage`.
The magic prefix also guarantees the digest can never coincide with a transaction
sighash, which makes signing a message categorically safer to approve than
signing a dummy transaction.
Address-level API, because that is what verification actually looks like
elsewhere: Electron Cash exposes only `verify_message(address, sig, message)`,
and a third party pulling a proof off an explorer has an address, not a public
key. verifyMessageSignatureForAddress accepts CashAddr with or without a prefix
and legacy base58, and rejects P2SH — no message signature can prove control of a
script hash.
recoverMessageSigner returns { publicKey, compressed } rather than a bare key.
The header byte declares which serialisation was used, and a key's compressed and
uncompressed forms hash to DIFFERENT addresses. Dropping that bit is how a
signature proving control of one address gets accepted as proof of another;
message-signing.test.ts pins the case in both directions.
signBitcoinMessage takes the private key as an argument and never retains it. It
exists so a wallet calls one function instead of reassembling the magic string,
both compactSize prefixes and the header byte — the parts third-party verifiers
check, and the parts covered by the tests here.
Testing: the byte layout is not asserted against our own reimplementation of the
spec, because that catches a coding mistake but not a misreading of it.
message-signing.vectors.json holds 32 vectors generated by a real Electron Cash
4.4.5 install (contrib/generate-message-signing-vectors.py) — two keys, both
compression forms, eight messages including empty, multi-byte UTF-8, multi-line
and the 252/253-byte compactSize boundary. Every preimage hash must match byte
for byte, and every signature must verify. Signature bytes are NOT portable
across implementations — Electron Cash and libauth derive the ECDSA nonce
differently — so the reproducible quantity is the hash.
message-signing.compat.test.ts drives `electron-cash verifymessage` directly,
closing the loop that vectors cannot: that our OUTPUT is accepted. Not part of
`npm test` (each assertion spawns a full Electron Cash process); run
`npm run test:compat -w @wizardconnect/core`. Skips when Electron Cash is
absent, so CI is unaffected.
77 tests, 426 in core.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splits ProtocolMessages exceeding NIP-44's 65,535-byte plaintext ceiling
across multiple gift-wrapped events. Symmetric (both directions),
fire-and-forget, backward-compatible via a new transport-level
\`extensions\` field on \`dapp_ready\` and \`wallet_ready\`. Resolves the
\"Failed to swap: invalid plaintext size\" error on aggregated swap
requests and enables signed-tx responses up to the 1 MB BCH consensus
limit (~2 MB hex).
This backward compatible change allows wallets/dapps to add additional
features outside the basic transaction signature support to the
hdwalletv1 protocol.
The package nostr-dev-kit was using a dependency (tseep) which
downstream would flag as not CSP-safe.
Additionally nostr-tools footprint is much smaller.
Canonical encoding for BigInt (<bigint: Xn>) and Uint8Array (hex or
<Uint8Array: 0x...>) used in the relay protocol. Provides both
serialization (sourceOutputToRelay, transactionToHex) and deserialization
(parseExtendedJson, toUint8Array, toBigInt) so dapps and wallets
don't have to implement this independently.
This solves an issue where wallet has to scan address ranges for each
derivation path and match it to locking script to figure out what
private key to use for signature.
This is a waste of effort and unnecessary complex for wallet
implementations since the dapp side already knows what inputs its using.