From b9a0e1689366c37a4c7a2b907b0dec42d95cd91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5vard=20Kittelsen?= Date: Thu, 6 Aug 2026 14:33:11 +0200 Subject: [PATCH] feat(core): login challenge helpers, so replay protection is not optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/dapp.md | 87 +++- docs/extensions.md | 10 +- docs/protocol.md | 9 +- packages/core/src/index.ts | 1 + .../src/protocols/login-challenge.test.ts | 453 ++++++++++++++++++ .../core/src/protocols/login-challenge.ts | 408 ++++++++++++++++ packages/test-cli/src/dapp.ts | 48 +- 7 files changed, 995 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/protocols/login-challenge.test.ts create mode 100644 packages/core/src/protocols/login-challenge.ts diff --git a/docs/dapp.md b/docs/dapp.md index a03248d..e4ec48f 100644 --- a/docs/dapp.md +++ b/docs/dapp.md @@ -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 diff --git a/docs/extensions.md b/docs/extensions.md index 1c0db86..e07a26b 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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, diff --git a/docs/protocol.md b/docs/protocol.md index 608eb6e..92d3614 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7044efd..26caeac 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/protocols/login-challenge.test.ts b/packages/core/src/protocols/login-challenge.test.ts new file mode 100644 index 0000000..a01020d --- /dev/null +++ b/packages/core/src/protocols/login-challenge.test.ts @@ -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[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 = {}) => + 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); + }); +}); diff --git a/packages/core/src/protocols/login-challenge.ts b/packages/core/src/protocols/login-challenge.ts new file mode 100644 index 0000000..523b8cf --- /dev/null +++ b/packages/core/src/protocols/login-challenge.ts @@ -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(); + 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; + /** 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 { + 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(); + return { + issue: (nonce) => { + outstanding.add(nonce); + }, + consume: (nonce) => outstanding.delete(nonce), + outstanding: () => outstanding.size, + }; +} diff --git a/packages/test-cli/src/dapp.ts b/packages/test-cli/src/dapp.ts index 37c79b4..b3e5324 100644 --- a/packages/test-cli/src/dapp.ts +++ b/packages/test-cli/src/dapp.ts @@ -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; /** 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; } 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 { 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 ` +