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>
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
// 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,
|
|
};
|
|
}
|