feat(core): login challenge helpers, so replay protection is not optional

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>
This commit is contained in:
Håvard Kittelsen 2026-08-06 14:33:11 +02:00
parent b40da7230a
commit b9a0e16893
7 changed files with 995 additions and 21 deletions

View file

@ -403,16 +403,89 @@ dapp's selection:
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 — your responsibility
### 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. Nothing in this library
can change that today.
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.
**Use a single-use, server-issued nonce inside `message`, and retire it after one use.** A login built
without one is permanently replayable by anyone who ever sees a signature — including the relay
operator, or anyone reading it out of an OP_RETURN. Including the origin and an issued-at timestamp
is also good practice, but the nonce is the part that actually stops replay.
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

View file

@ -298,18 +298,20 @@ repository's conformance tests against a real Electron Cash install; a reimpleme
### Dapp side
```typescript
import { MODE_WALLET_CHOICE } from "@wizardconnect/core";
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({
// The nonce MUST be single-use and server-issued — see dapp.md § Replay.
message: `${location.host} wants you to sign in\nnonce=${singleUseNonce}`,
message: createLoginChallenge({ domain: location.host, nonce, expiresInSeconds: 300 }),
userPrompt: "Sign in",
});
// result is already verified; result.address is the proven identity.
// 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,

View file

@ -462,9 +462,12 @@ Anything inconsistent rejects. See [dapp.md](dapp.md#signmessage).
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 in this protocol can enforce that, and a dapp that
skips it has built a login that any captured signature reopens indefinitely. See
[dapp.md § Replay](dapp.md#replay--your-responsibility).
`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

View file

@ -31,6 +31,7 @@ export type {
} 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,

View 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);
});
});

View 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,
};
}

View file

@ -18,9 +18,13 @@ import {
type SignMessageRequest,
type SignMessageResponse,
checkSignMessageResponse,
createInMemoryNonceStore,
createLoginChallenge,
createLoginNonce,
isSignMessageFailure,
peerSignMessageInfo,
peerSupportsSignMessage,
verifyLoginChallenge,
verifyMessageSignatureForAddress,
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
@ -39,7 +43,9 @@ interface DappState {
pendingMessages: Map<number, string>;
/** Only one sign_message request per run, however many wallet_readys arrive. */
signMessageSent: boolean;
messageTag: string;
messageNonce: string;
/** Stands in for the server-side nonce store a real dapp would use. */
nonceStore: ReturnType<typeof createInMemoryNonceStore>;
}
function makeState(): DappState {
@ -51,7 +57,8 @@ function makeState(): DappState {
sequence: 1,
pendingMessages: new Map(),
signMessageSent: false,
messageTag: Math.random().toString(36).slice(2, 10),
messageNonce: createLoginNonce(),
nonceStore: createInMemoryNonceStore(),
};
}
@ -133,11 +140,16 @@ async function sendSignMessageRequest(
walletChoice: boolean,
): Promise<void> {
const sequence = state.sequence++;
// Deliberately a plain test message rather than a login: a login needs a
// single-use nonce, a domain and an expiry to be safe, and this CLI has no
// server to issue them. Signing something that merely LOOKS like a login would
// be a bad pattern to copy.
const message = `wizardconnect test message ${state.messageTag}`;
// 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,
@ -288,6 +300,28 @@ function handleMessage(
(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 ` +