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