Manage your own password, app passwords and 2FA

Settings › Security grows three working sections instead of a note telling
people to use Stalwart's own portal.

Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has
the x:AccountPassword singleton and x:AppPassword registry objects over JMAP,
while 0.15.x has the /api/account/auth REST endpoint. Which one answers the
probe is the only reliable way to tell them apart, and the result is cached
per session. The built-in `user` role already grants sysAccountPassword* and
sysAppPassword*, so no administrator setup is needed.

Two problems are worth calling out, because both would bite a user hard:

Stalwart validates the credentials already on the account when 2FA is turned
on and never checks the new secret, so an authenticator that was mistyped or
out of step would lock someone out of their mailbox at the next sign-in. We
verify a code against the new secret ourselves first (RFC 6238, tested against
the spec's vectors) and only then ask the server to store anything.

Every proxied call re-authenticates with the credential sealed into the
session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with
it — which we cannot produce between requests. Turning 2FA on would therefore
sign the user out of the browser they just turned it on in. App passwords
authenticate without a second factor, so the session is moved onto one minted
for this browser, and the session cookie is re-sealed with it. The order
matters: it is minted while the old credential still works, and revoked again
if enabling then fails.

Password changes re-seal this session too and drop the others, whose sealed
copies of the old password would fail on their next call.

The mock now enforces what a real server does — current password, password
policy, a TOTP code on every request once 2FA is on, app passwords exempt —
so the whole flow is exercised in tests rather than only by hand.
This commit is contained in:
2026-08-24 08:18:47 -07:00
parent c4731fc8e0
commit 3621e81d0c
12 changed files with 1490 additions and 10 deletions
+145
View File
@@ -0,0 +1,145 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* TOTP (RFC 6238) — just enough to enrol a second factor safely.
*
* Stalwart stores the otpauth:// URL and checks codes at login, but it does
* *not* check the new secret when 2FA is switched on: it verifies the
* credentials that are already on the account. A user whose authenticator was
* mistyped or whose clock has drifted would be locked out of their mailbox at
* the next sign-in. So ihasmail proves the enrolment itself, before asking the
* server to store anything.
*/
export interface TotpParams {
secret: string;
algorithm: "SHA1" | "SHA256" | "SHA512";
digits: number;
period: number;
}
const DEFAULTS: Omit<TotpParams, "secret"> = { algorithm: "SHA1", digits: 6, period: 30 };
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function base32Encode(buf: Buffer): string {
let bits = 0;
let value = 0;
let out = "";
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += BASE32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
return out;
}
/** Decode base32, tolerating lowercase, padding and the spaces users paste. */
export function base32Decode(input: string): Buffer | null {
const clean = input.replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase();
if (!clean || /[^A-Z2-7]/.test(clean)) return null;
let bits = 0;
let value = 0;
const out: number[] = [];
for (const ch of clean) {
value = (value << 5) | BASE32.indexOf(ch);
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(out);
}
/** A fresh 160-bit secret — the size RFC 4226 recommends for HMAC-SHA1. */
export function generateSecret(): string {
return base32Encode(randomBytes(20));
}
/**
* Build the otpauth:// URL that authenticator apps scan and Stalwart stores.
* The label is "issuer:account" with the issuer repeated as a parameter, which
* is what totp-rs (Stalwart's parser) and every common app expect.
*/
export function otpauthUrl(opts: { secret: string; account: string; issuer: string }): string {
const label = `${encodeURIComponent(opts.issuer)}:${encodeURIComponent(opts.account)}`;
const params = new URLSearchParams({
secret: opts.secret,
issuer: opts.issuer,
algorithm: DEFAULTS.algorithm,
digits: String(DEFAULTS.digits),
period: String(DEFAULTS.period),
});
return `otpauth://totp/${label}?${params.toString()}`;
}
export function parseOtpauthUrl(url: string): TotpParams | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.protocol !== "otpauth:" || parsed.host.toLowerCase() !== "totp") return null;
const secret = parsed.searchParams.get("secret");
if (!secret || !base32Decode(secret)) return null;
const algorithm = (parsed.searchParams.get("algorithm") ?? DEFAULTS.algorithm).toUpperCase();
if (algorithm !== "SHA1" && algorithm !== "SHA256" && algorithm !== "SHA512") return null;
const digits = Number(parsed.searchParams.get("digits") ?? DEFAULTS.digits);
const period = Number(parsed.searchParams.get("period") ?? DEFAULTS.period);
if (!Number.isInteger(digits) || digits < 6 || digits > 10) return null;
if (!Number.isInteger(period) || period < 5 || period > 300) return null;
return { secret, algorithm, digits, period };
}
/** The HOTP code for one counter value. */
function hotp(key: Buffer, counter: number, algorithm: string, digits: number): string {
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const digest = createHmac(algorithm.toLowerCase(), key).update(buf).digest();
const offset = digest[digest.length - 1]! & 0x0f;
const binary = digest.readUInt32BE(offset) & 0x7fffffff;
return (binary % 10 ** digits).toString().padStart(digits, "0");
}
/** The code an authenticator app would show at `now`. */
export function totpCode(params: TotpParams, now = Date.now()): string {
const key = base32Decode(params.secret);
if (!key || !key.length) throw new Error("unusable TOTP secret");
return hotp(key, Math.floor(now / 1000 / params.period), params.algorithm, params.digits);
}
/**
* Check a user-supplied code, allowing `window` steps of clock skew either way
* (one step = 30s by default, so the default tolerates ±30s).
*/
export function verifyTotp(params: TotpParams, code: string, opts: { window?: number; now?: number } = {}): boolean {
const digits = params.digits;
const cleaned = code.replace(/\s/g, "");
if (cleaned.length !== digits || !/^\d+$/.test(cleaned)) return false;
const key = base32Decode(params.secret);
if (!key || !key.length) return false;
const window = opts.window ?? 1;
const counter = Math.floor((opts.now ?? Date.now()) / 1000 / params.period);
let ok = false;
// Check every candidate rather than returning early, so the time taken does
// not reveal which step matched.
for (let i = -window; i <= window; i++) {
const step = counter + i;
if (step < 0) continue; // only reachable for times within a step of the epoch
const expected = hotp(key, step, params.algorithm, digits);
if (safeEqual(expected, cleaned)) ok = true;
}
return ok;
}
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}