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 cea7545481
commit 0f1fbcff93
12 changed files with 1490 additions and 10 deletions
+163
View File
@@ -0,0 +1,163 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
/**
* End-to-end self-service credential flows against the mock, which enforces
* the same rules a real 0.16 server does: the current password is checked,
* password policy is applied, and once 2FA is on every request wants a fresh
* TOTP code — except one authenticating with an app password.
*/
const PORT = 18797;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-account-flows";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
const app = createApp();
let cookie = "";
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
const res = await app.request(path, {
...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string>), ...(cookie ? { cookie } : {}) },
});
const setCookie = res.headers.get("set-cookie");
if (setCookie) cookie = setCookie.split(";")[0]!;
const text = await res.text();
return { status: res.status, body: text ? JSON.parse(text) : null };
}
const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) });
before(async () => {
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password" });
assert.equal(res.status, 200, "login should succeed against the mock");
});
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
test("the 0.16 registry backend is detected and reported empty", async () => {
const res = await call("/api/account/security");
assert.equal(res.status, 200);
assert.equal(res.body.backend, "registry");
assert.equal(res.body.otpEnabled, false);
assert.deepEqual(res.body.appPasswords, []);
assert.equal(res.body.appPasswordsKeyedByName, false);
});
test("app passwords are created, listed once with their secret, and revoked", async () => {
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
assert.equal(created.status, 200);
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
assert.ok(created.body.id);
const list = await call("/api/account/security");
assert.equal(list.body.appPasswords.length, 1);
assert.equal(list.body.appPasswords[0].description, "Thunderbird");
assert.equal(list.body.appPasswords[0].secret, undefined, "the secret is never listed again");
const revoked = await post("/api/account/app-passwords/revoke", { id: created.body.id });
assert.equal(revoked.status, 200);
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
});
test("an app password needs a name", async () => {
const res = await post("/api/account/app-passwords", { description: " " });
assert.equal(res.status, 400);
assert.equal(res.body.error, "missing_fields");
});
test("the wrong current password is refused with the server's reason", async () => {
const res = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
assert.equal(res.status, 403);
assert.match(res.body.message, /Current secret is incorrect/);
});
test("the server's password policy is surfaced verbatim", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "short" });
assert.equal(res.status, 400);
assert.match(res.body.message, /at least 8 characters/);
});
test("a password unchanged from the old one is rejected before we ask upstream", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "demo-password" });
assert.equal(res.status, 400);
assert.equal(res.body.error, "unchanged");
});
test("changing the password keeps this session working", async () => {
const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" });
assert.equal(res.status, 200);
// The stored credential was re-sealed, so the next proxied call still passes
// upstream authentication with the new password.
assert.equal((await call("/api/auth/session")).status, 200);
assert.equal((await call("/api/account/security")).status, 200);
});
test("enabling 2FA rejects a code the new secret did not produce", async () => {
const begin = await post("/api/account/2fa/begin", {});
assert.equal(begin.status, 200);
assert.match(begin.body.url, /^otpauth:\/\/totp\//);
const res = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
assert.equal(res.status, 400);
assert.equal(res.body.code, undefined);
assert.match(res.body.message, /doesn't match/);
assert.equal((await call("/api/account/security")).body.otpEnabled, false, "nothing was stored");
});
test("enabling 2FA switches the session onto an app password so it survives", async () => {
const begin = await post("/api/account/2fa/begin", {});
const params = parseOtpauthUrl(begin.body.url);
assert.ok(params);
const res = await post("/api/account/2fa/enable", {
url: begin.body.url,
code: totpCode(params),
current: "a-brand-new-password",
});
assert.equal(res.status, 200);
assert.equal(res.body.sessionKept, true);
const state = await call("/api/account/security");
assert.equal(state.status, 200, "the session still authenticates upstream");
assert.equal(state.body.otpEnabled, true);
assert.equal(state.body.appPasswords.length, 1, "one app password was minted for this browser");
assert.match(state.body.appPasswords[0].description, /\(/, "it is named after the browser");
});
test("with 2FA on, a password change needs the current code too", async () => {
const withoutCode = await post("/api/account/password", { current: "a-brand-new-password", next: "yet-another-password" });
assert.equal(withoutCode.status, 403);
assert.match(withoutCode.body.message, /OTP code is required/);
});
test("2FA is switched off with the password and a current code", async () => {
const state = await call("/api/account/security");
assert.equal(state.body.otpEnabled, true);
// The enrolment secret is known only to the client, so disabling uses a code
// from the authenticator - here, the one the mock stored.
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
const params = parseOtpauthUrl(stored!);
assert.ok(params);
const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) });
assert.equal(res.status, 200);
assert.equal((await call("/api/account/security")).body.otpEnabled, false);
});
test("credential endpoints reject unauthenticated callers", async () => {
const saved = cookie;
cookie = "";
assert.equal((await call("/api/account/security")).status, 401);
assert.equal((await post("/api/account/password", { current: "a", next: "b" })).status, 401);
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
cookie = saved;
});
+380
View File
@@ -0,0 +1,380 @@
import { config } from "./config.js";
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
import { randomBytes } from "node:crypto";
/**
* Self-service credential management, across two incompatible Stalwart APIs.
*
* 0.16+ JMAP registry objects: x:AccountPassword (a singleton holding the
* password and the otpauth URL) and x:AppPassword.
* 0.15.x a REST endpoint, POST /api/account/auth, taking a list of actions.
*
* The registry crate does not exist before 0.16 and the REST endpoint is gone
* after it, so which one answers is the only reliable way to tell them apart.
*/
const STALWART_CAP = "urn:stalwart:jmap";
const JMAP_CORE = "urn:ietf:params:jmap:core";
/** Stalwart's id for a singleton object; the number it encodes spells this. */
const SINGLETON = "singleton";
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
const MASKED = "[********]";
export type Backend = "registry" | "legacy";
export interface AppPasswordRow {
/** Registry object id, or the name itself on legacy servers. */
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
}
export interface SecurityState {
backend: Backend;
otpEnabled: boolean;
appPasswords: AppPasswordRow[];
/**
* Legacy servers key app passwords by name and hand back nothing else, so
* the UI must keep names unique and cannot show when one was created.
*/
appPasswordsKeyedByName: boolean;
}
/** An error with a message meant for the person using the app. */
export class AccountError extends Error {
constructor(
message: string,
public readonly status = 400,
public readonly code = "account_error",
) {
super(message);
this.name = "AccountError";
}
}
interface Ctx {
authorization: string;
session: UpstreamSession;
username: string;
}
/* ------------------------------------------------------------------ */
/* Backend detection */
/* ------------------------------------------------------------------ */
const backendCache = new Map<string, { backend: Backend; at: number }>();
const BACKEND_CACHE_MS = 30 * 60_000;
export function forgetBackend(sessionId: string): void {
backendCache.delete(sessionId);
}
export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backend> {
const cached = backendCache.get(sessionId);
if (cached && Date.now() - cached.at < BACKEND_CACHE_MS) return cached.backend;
const backend = await probeBackend(ctx);
backendCache.set(sessionId, { backend, at: Date.now() });
return backend;
}
async function probeBackend(ctx: Ctx): Promise<Backend> {
// A server with the registry answers x:AccountPassword/get; one without it
// fails to parse the method name at all and returns unknownMethod.
if (ctx.session.capabilities && STALWART_CAP in ctx.session.capabilities) {
const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]);
const [name, args] = res.methodResponses?.[0] ?? [];
if (name && name !== "error") return "registry";
const type = (args as { type?: string } | undefined)?.type;
if (type && type !== "unknownMethod") return "registry"; // present, but refused us
}
return "legacy";
}
/* ------------------------------------------------------------------ */
/* Transports */
/* ------------------------------------------------------------------ */
function accountId(ctx: Ctx): string {
return (
ctx.session.primaryAccounts?.[STALWART_CAP] ??
ctx.session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
Object.keys(ctx.session.accounts ?? {})[0] ??
""
);
}
type Invocation = [string, Record<string, unknown>, string];
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
const res = await fetch(absoluteUpstream(ctx.session.apiUrl), {
method: "POST",
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502);
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
}
async function legacy<T>(ctx: Ctx, init: RequestInit): Promise<T> {
const res = await fetch(`${config.stalwartUrl}/api/account/auth`, {
...init,
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
if (res.status === 404) {
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
}
if (!res.ok) {
let detail = "";
try {
const body = (await res.json()) as { error?: string; details?: string; reason?: string };
detail = body.details ?? body.reason ?? body.error ?? "";
} catch {
/* fall through to the generic message */
}
throw new AccountError(detail || `The mail server rejected the change (${res.status}).`, 502, "upstream");
}
return ((await res.json()) as { data: T }).data;
}
/**
* Pull the single result out of a /set, turning JMAP's several failure shapes
* into one error carrying whatever the server was willing to explain.
*/
function setResult(res: { methodResponses?: [string, unknown, string][] }, kind: "created" | "updated" | "destroyed"): Record<string, unknown> | null {
const [name, args] = res.methodResponses?.[0] ?? [];
if (!name) throw new AccountError("The mail server sent no response.", 502, "upstream");
if (name === "error") {
const err = args as { type?: string; description?: string };
if (err.type === "unknownMethod") {
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
}
throw new AccountError(err.description ?? `The mail server refused the request (${err.type ?? "error"}).`, 502, err.type ?? "upstream");
}
const body = args as Record<string, Record<string, unknown> | undefined>;
const notKind = kind === "created" ? "notCreated" : kind === "updated" ? "notUpdated" : "notDestroyed";
const failures = body[notKind];
const failure = failures && Object.values(failures)[0];
if (failure) {
const err = failure as { type?: string; description?: string; properties?: string[] };
throw new AccountError(describeSetError(err), err.type === "forbidden" ? 403 : 400, err.type ?? "invalid");
}
const ok = body[kind];
return ok ? ((Object.values(ok)[0] ?? {}) as Record<string, unknown>) : null;
}
function describeSetError(err: { type?: string; description?: string; properties?: string[] }): string {
if (err.description) return err.description;
if (err.type === "forbidden") return "The mail server refused the change.";
if (err.type === "overQuota") return "You have reached the number of app passwords this account allows.";
if (err.type === "invalidProperties") {
return err.properties?.length ? `The mail server rejected ${err.properties.join(", ")}.` : "The mail server rejected the value.";
}
return `The mail server refused the change (${err.type ?? "error"}).`;
}
/* ------------------------------------------------------------------ */
/* Operations */
/* ------------------------------------------------------------------ */
export async function getState(sessionId: string, ctx: Ctx): Promise<SecurityState> {
const backend = await detectBackend(sessionId, ctx);
if (backend === "legacy") {
const data = await legacy<{ otpEnabled?: boolean; appPasswords?: string[] }>(ctx, { method: "GET" });
return {
backend,
otpEnabled: Boolean(data.otpEnabled),
appPasswords: (data.appPasswords ?? []).map((name) => ({ id: name, description: name, createdAt: null, expiresAt: null })),
appPasswordsKeyedByName: true,
};
}
const id = accountId(ctx);
const res = await jmap(ctx, [
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
["x:AppPassword/get", { accountId: id, ids: null }, "a"],
]);
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
const apps = listOf(res, "a");
return {
backend,
// The URL itself is masked; its presence is what tells us 2FA is on.
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
appPasswords: apps.map((a) => ({
id: String(a.id ?? ""),
description: String(a.description ?? "App password"),
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
})),
appPasswordsKeyedByName: false,
};
}
function listOf(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown>[] {
const call = res.methodResponses?.find((r) => r[2] === callId);
if (!call || call[0] === "error") return [];
const list = (call[1] as { list?: unknown }).list;
return Array.isArray(list) ? (list as Record<string, unknown>[]) : [];
}
function firstListItem(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown> | null {
return listOf(res, callId)[0] ?? null;
}
export async function changePassword(
sessionId: string,
ctx: Ctx,
opts: { current: string; next: string; otpCode?: string },
): Promise<void> {
const backend = await detectBackend(sessionId, ctx);
if (backend === "registry") {
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
setResult(res, "updated");
return;
}
// The legacy endpoint changes the password without asking for the old one,
// so anyone holding a live session could set it. Prove it ourselves first.
await assertCurrentPassword(ctx, opts.current, opts.otpCode);
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "setPassword", password: opts.next }]) });
}
export async function createAppPassword(
sessionId: string,
ctx: Ctx,
opts: { description: string },
): Promise<{ id: string; secret: string }> {
const backend = await detectBackend(sessionId, ctx);
const description = opts.description.trim() || "App password";
if (backend === "registry") {
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
const created = setResult(res, "created");
const secret = created && typeof created.secret === "string" ? created.secret : "";
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
return { id: String(created?.id ?? description), secret };
}
// Legacy servers take a secret of our choosing and key it by name.
const secret = readableSecret();
await legacy<unknown>(ctx, {
method: "POST",
body: JSON.stringify([{ type: "addAppPassword", name: description, password: secret }]),
});
return { id: description, secret };
}
export async function revokeAppPassword(sessionId: string, ctx: Ctx, id: string): Promise<void> {
const backend = await detectBackend(sessionId, ctx);
if (backend === "registry") {
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
setResult(res, "destroyed");
return;
}
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "removeAppPassword", name: id }]) });
}
/**
* Start enrolment: mint a secret and hand back the URL to show as a QR code.
* Nothing is stored until the user proves they can produce a code from it.
*/
export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
const secret = generateSecret();
return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) };
}
/**
* Prove the user can produce a code from the secret they just scanned.
*
* Stalwart validates the credentials already on the account and never looks at
* the new secret, so without this an authenticator that was mistyped or out of
* step would lock the user out of their mailbox at the next sign-in.
*/
export function assertEnrolmentCode(url: string, code: string): void {
const params = parseOtpauthUrl(url);
if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url");
if (!verifyTotp(params, code)) {
throw new AccountError("That code doesn't match. Check your authenticator app and try the next code.", 400, "bad_code");
}
}
export async function enableOtp(
sessionId: string,
ctx: Ctx,
opts: { url: string; code: string; current: string },
): Promise<void> {
assertEnrolmentCode(opts.url, opts.code);
const backend = await detectBackend(sessionId, ctx);
if (backend === "registry") {
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
"s",
],
]);
setResult(res, "updated");
return;
}
await assertCurrentPassword(ctx, opts.current);
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "enableOtpAuth", url: opts.url }]) });
}
export async function disableOtp(
sessionId: string,
ctx: Ctx,
opts: { current: string; code: string },
): Promise<void> {
const backend = await detectBackend(sessionId, ctx);
if (backend === "registry") {
const res = await jmap(ctx, [
[
"x:AccountPassword/set",
{
accountId: accountId(ctx),
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
},
"s",
],
]);
setResult(res, "updated");
return;
}
await assertCurrentPassword(ctx, opts.current, opts.code);
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "disableOtpAuth", url: null }]) });
}
/**
* Confirm a password by authenticating with it, for the legacy endpoint that
* would otherwise take our word for it.
*/
async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string): Promise<void> {
const secret = otpCode ? `${current}$${otpCode}` : current;
const authorization = `Basic ${Buffer.from(`${ctx.username}:${secret}`, "utf8").toString("base64")}`;
const res = await fetch(`${config.stalwartUrl}/.well-known/jmap`, {
headers: { authorization, accept: "application/json" },
redirect: "follow",
signal: AbortSignal.timeout(config.upstreamTimeout),
});
if (res.status === 401 || res.status === 403) {
throw new AccountError("That password is incorrect.", 403, "bad_password");
}
if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502);
}
/** A legacy app password a person can read off a screen and type. */
function readableSecret(): string {
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes
const bytes = randomBytes(20);
let out = "";
for (let i = 0; i < 20; i++) {
if (i > 0 && i % 5 === 0) out += "-";
out += alphabet[bytes[i]! % alphabet.length];
}
return out;
}
export { MASKED };
+211
View File
@@ -15,6 +15,18 @@ import {
getUpstreamSession,
localizeSession,
} from "./upstream.js";
import {
AccountError,
assertEnrolmentCode,
beginOtpEnrolment,
changePassword,
createAppPassword,
disableOtp,
enableOtp,
forgetBackend,
getState,
revokeAppPassword,
} from "./account.js";
import { imageProxyHandler } from "./imageproxy.js";
import { staticHandler } from "./static.js";
@@ -22,6 +34,13 @@ type Env = { Variables: { session: LiveSession } };
export const sessions = new SessionStore(config.sessionFile);
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
/**
* Credential changes verify the current password upstream, and Stalwart's
* fail2ban counts those failures against the *caller's* IP — which for a proxy
* is shared by every user. Keep our own lid on it so one person guessing
* cannot get the whole deployment banned.
*/
const accountLimiter = new RateLimiter(10, 15 * 60_000);
const HOP_BY_HOP = new Set([
"connection",
@@ -215,6 +234,183 @@ export function createApp(): Hono<Env> {
return c.json({ revoked: n });
});
// ---------- Self-service credentials ----------
/**
* Password, app passwords and 2FA. These live on the server rather than in
* the browser because the pre-0.16 API is REST rather than JMAP (the browser
* only ever sees /api/jmap), and because changing a credential means
* re-sealing the session cookie that holds it.
*/
const accountCtx = async (c: Context<Env>) => {
const session = c.get("session");
const upstream = await getUpstreamSession(session.id, session.authorization);
return { authorization: session.authorization, session: upstream, username: session.username };
};
const accountFailure = (c: Context, err: unknown) => {
if (err instanceof AccountError) {
return c.json({ error: err.code, message: err.message }, err.status as 400);
}
return upstreamFailure(c, err);
};
/** Guard the endpoints that check a password against brute-forcing. */
const guarded = (c: Context<Env>): Response | null => {
const key = `account|${c.get("session").username.toLowerCase()}`;
if (accountLimiter.check(key)) return null;
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
};
api.get("/account/security", requireSession, async (c) => {
const session = c.get("session");
try {
return c.json(await getState(session.id, await accountCtx(c)));
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/password", requireSession, async (c) => {
const limited = guarded(c);
if (limited) return limited;
const session = c.get("session");
const body = await readJson<{ current?: string; next?: string; otpCode?: string }>(c);
if (!body) return c.json({ error: "bad_request" }, 400);
const current = body.current ?? "";
const next = body.next ?? "";
if (!current || !next) return c.json({ error: "missing_fields", message: "Both passwords are required." }, 400);
if (next.length > 1024) return c.json({ error: "bad_request" }, 400);
if (next === current) {
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
}
try {
await changePassword(session.id, await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
} catch (err) {
return accountFailure(c, err);
}
// The old password is now dead: re-seal this session with the new one and
// drop the others, whose sealed copies would fail on their next call.
const otpCode = body.otpCode?.trim();
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
forgetUpstreamSession(session.id);
const revoked = sessions.destroyAllForUser(session.username, session.id);
return c.json({ ok: true, revokedSessions: revoked });
});
api.get("/account/app-passwords", requireSession, async (c) => {
const session = c.get("session");
try {
const state = await getState(session.id, await accountCtx(c));
return c.json({ appPasswords: state.appPasswords, keyedByName: state.appPasswordsKeyedByName });
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/app-passwords", requireSession, async (c) => {
const session = c.get("session");
const body = await readJson<{ description?: string }>(c);
if (!body) return c.json({ error: "bad_request" }, 400);
const description = (body.description ?? "").trim().slice(0, 120);
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
try {
return c.json(await createAppPassword(session.id, await accountCtx(c), { description }));
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/app-passwords/revoke", requireSession, async (c) => {
const session = c.get("session");
const body = await readJson<{ id?: string }>(c);
if (!body?.id) return c.json({ error: "bad_request" }, 400);
try {
await revokeAppPassword(session.id, await accountCtx(c), body.id);
return c.json({ ok: true });
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/2fa/begin", requireSession, async (c) => {
try {
// Nothing is stored yet; the client hands the URL back to confirm.
return c.json(beginOtpEnrolment(await accountCtx(c)));
} catch (err) {
return accountFailure(c, err);
}
});
api.post("/account/2fa/enable", requireSession, async (c) => {
const limited = guarded(c);
if (limited) return limited;
const session = c.get("session");
const body = await readJson<{ url?: string; code?: string; current?: string }>(c);
if (!body?.url || !body.code || !body.current) return c.json({ error: "bad_request" }, 400);
const ctx = await accountCtx(c);
const code = body.code.trim();
/*
* Every proxied call re-authenticates with the stored password, and once
* 2FA is on the server wants a fresh TOTP code alongside it — which we
* cannot produce between requests. An app password authenticates without
* one, so the session moves onto a dedicated app password rather than
* being signed out the moment 2FA is switched on.
*
* Order matters: mint it while the current credential still works, since
* the moment 2FA is enabled this session can no longer authenticate at all.
*/
try {
assertEnrolmentCode(body.url, code);
} catch (err) {
return accountFailure(c, err);
}
let app: { id: string; secret: string } | null = null;
try {
app = await createAppPassword(session.id, ctx, { description: appPasswordName(c) });
} catch (err) {
// Out of app-password quota, say. 2FA is still worth having; the user
// just has to sign in again afterwards.
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
}
try {
await enableOtp(session.id, ctx, { url: body.url, code, current: body.current });
} catch (err) {
if (app) {
// Don't leave a credential behind for a change that never happened.
await revokeAppPassword(session.id, ctx, app.id).catch(() => {});
}
return accountFailure(c, err);
}
let sessionKept = false;
if (app) {
sessionKept = sessions.reseal(getCookie(c, config.cookieName), app.secret);
if (sessionKept) forgetUpstreamSession(session.id);
}
// Other sessions still hold the bare password and will be refused.
const revoked = sessions.destroyAllForUser(session.username, session.id);
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
});
api.post("/account/2fa/disable", requireSession, async (c) => {
const limited = guarded(c);
if (limited) return limited;
const session = c.get("session");
const body = await readJson<{ current?: string; code?: string }>(c);
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
try {
await disableOtp(session.id, await accountCtx(c), { current: body.current, code: body.code.trim() });
} catch (err) {
return accountFailure(c, err);
}
// This session may be running on the app password minted when 2FA went on;
// the plain password works again now, so put it back.
sessions.reseal(getCookie(c, config.cookieName), body.current);
forgetUpstreamSession(session.id);
forgetBackend(session.id);
return c.json({ ok: true });
});
// ---------- JMAP API proxy ----------
api.post("/jmap", requireSession, async (c) => {
const session = c.get("session");
@@ -353,6 +549,21 @@ export function createApp(): Hono<Env> {
return app;
}
async function readJson<T>(c: Context): Promise<T | null> {
try {
return (await c.req.json()) as T;
} catch {
return null;
}
}
/** Name the app password after the browser it will live in. */
function appPasswordName(c: Context): string {
const ua = c.req.header("user-agent") ?? "";
const browser = /Firefox\//.test(ua) ? "Firefox" : /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "browser";
return `${config.appName} (${browser})`;
}
function sessionExtras(session: LiveSession, userLocale: string | null = null) {
return {
ihasmail: {
+88 -3
View File
@@ -5,6 +5,7 @@
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
const PORT = Number(process.env.MOCK_PORT ?? 8788);
const ACCOUNT = "a1";
@@ -12,6 +13,14 @@ const USER = process.env.MOCK_USER ?? "[email protected]";
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
const PASS = process.env.MOCK_PASS ?? "demo";
/**
* Credential state, mutable so the self-service flows can be exercised against
* the mock the way they run against a real 0.16 server: the password changes,
* 2FA starts demanding a code on every request, and app passwords keep working
* without one.
*/
export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] };
const MASKED = "[********]";
type Obj = Record<string, unknown>;
const state = { n: 1 };
@@ -302,6 +311,64 @@ const handlers: Record<string, Handler> = {
},
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
"Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; },
// Stalwart 0.16 registry objects backing self-service credentials.
"x:AccountPassword/get": () => ({
accountId: ACCOUNT,
state: String(state.n),
list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }],
notFound: [],
}),
"x:AccountPassword/set": (a) => {
const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined;
if (!patch) return setResp({ updated: {} });
const current = patch.currentSecret as string | undefined;
const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined;
if (!current) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } });
}
if (current !== account.password) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
}
if (account.otpUrl && !code) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } });
}
if (account.otpUrl && !checkOtp(code!)) {
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
}
const secret = patch.secret as string | undefined;
if (secret !== undefined && secret !== MASKED) {
if (secret.length < 8) {
return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } });
}
account.password = secret;
}
if ("otpAuth/otpUrl" in patch) {
const url = patch["otpAuth/otpUrl"] as string | null;
if (url !== MASKED) account.otpUrl = url;
}
state.n++;
return setResp({ updated: { singleton: null } });
},
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
"x:AppPassword/set": (a) => {
const created: Obj = {};
const destroyed: string[] = [];
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const id = `ap${randomUUID().slice(0, 6)}`;
// Real app passwords carry their credential id, so the server can spot
// one by its shape alone. Mirror that.
const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`;
const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret };
account.appPasswords.push(row);
created[cid] = { id, secret, createdAt: row.createdAt };
}
for (const id of (a.destroy as string[]) ?? []) {
const i = account.appPasswords.findIndex((x) => x.id === id);
if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); }
}
state.n++;
return setResp({ created, destroyed });
},
"Identity/get": genericGet(identities),
"Identity/set": genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o })),
"EmailSubmission/set": (a) => {
@@ -349,11 +416,28 @@ function unauthorized(res: ServerResponse) {
res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' });
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" }));
}
function checkOtp(code: string | undefined): boolean {
if (!account.otpUrl) return true;
const params = parseOtpauthUrl(account.otpUrl);
return Boolean(code && params && verifyTotp(params, code));
}
function checkAuth(req: IncomingMessage): boolean {
const h = req.headers.authorization ?? "";
if (!h.startsWith("Basic ")) return false;
const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":");
return u === USER && p === PASS;
const raw = Buffer.from(h.slice(6), "base64").toString();
const sep = raw.indexOf(":");
if (sep < 0) return false;
const u = raw.slice(0, sep);
const p = raw.slice(sep + 1);
if (u !== USER) return false;
// App passwords are recognised by shape and skip the second factor, which is
// exactly what lets a webmail session survive 2FA being switched on.
if (account.appPasswords.some((a) => a.secret === p)) return true;
if (!account.otpUrl) return p === account.password;
const at = p.lastIndexOf("$");
if (at < 0) return false;
return p.slice(0, at) === account.password && checkOtp(p.slice(at + 1));
}
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); });
@@ -377,7 +461,8 @@ function broadcast(types: string[]) {
for (const c of sseClients) c.write(payload);
}
createServer(async (req, res) => {
/** Exported so tests can drive the mock in-process and shut it down. */
export const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
if (!checkAuth(req)) return unauthorized(res);
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
+24
View File
@@ -171,6 +171,30 @@ export class SessionStore {
return this.toLive(stored, creds.u, creds.p);
}
/**
* Re-seal this session's stored credentials.
*
* The upstream password is what every proxied call authenticates with, so a
* password change (or swapping in an app password when 2FA is switched on)
* would otherwise leave the session holding a credential the server no
* longer accepts. Needs the cookie: the sealing key is derived from the
* secret half of it, which the server never keeps.
*/
reseal(cookie: string | undefined, password: string): boolean {
if (!cookie) return false;
const idx = cookie.indexOf(COOKIE_SEP);
if (idx <= 0) return false;
const id = cookie.slice(0, idx);
const secret = cookie.slice(idx + 1);
const stored = this.sessions.get(id);
if (!stored) return false;
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
this.scheduleSave();
return true;
}
destroy(id: string): void {
if (this.sessions.delete(id)) this.scheduleSave();
}
+85
View File
@@ -0,0 +1,85 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { base32Decode, base32Encode, generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
/** RFC 6238 Appendix B seeds. */
const SHA1_SECRET = base32Encode(Buffer.from("12345678901234567890", "ascii"));
const SHA256_SECRET = base32Encode(Buffer.from("12345678901234567890123456789012", "ascii"));
test("base32 matches the RFC 4648 alphabet and round-trips", () => {
assert.equal(SHA1_SECRET, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
assert.equal(base32Encode(Buffer.from("f", "ascii")), "MY");
assert.equal(base32Encode(Buffer.from("foobar", "ascii")), "MZXW6YTBOI");
assert.deepEqual(base32Decode("MZXW6YTBOI"), Buffer.from("foobar", "ascii"));
// Users paste secrets with spaces, lowercase and padding.
assert.deepEqual(base32Decode("mzxw 6ytb-oi==="), Buffer.from("foobar", "ascii"));
assert.equal(base32Decode("not base32!"), null);
});
test("verifyTotp accepts the RFC 6238 SHA-1 test vectors", () => {
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
for (const [time, code] of [
[59, "94287082"],
[1111111109, "07081804"],
[1111111111, "14050471"],
[1234567890, "89005924"],
[2000000000, "69279037"],
[20000000000, "65353130"],
] as const) {
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
}
});
test("verifyTotp accepts the RFC 6238 SHA-256 test vectors", () => {
const params = { secret: SHA256_SECRET, algorithm: "SHA256" as const, digits: 8, period: 30 };
for (const [time, code] of [
[59, "46119246"],
[1111111109, "68084774"],
[1234567890, "91819424"],
] as const) {
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
}
});
test("verifyTotp rejects wrong, malformed and mis-sized codes", () => {
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
const at = { window: 0, now: 59_000 };
assert.equal(verifyTotp(params, "94287083", at), false);
assert.equal(verifyTotp(params, "9428708", at), false, "too short");
assert.equal(verifyTotp(params, "942870822", at), false, "too long");
assert.equal(verifyTotp(params, "abcdefgh", at), false);
assert.equal(verifyTotp(params, "", at), false);
assert.equal(verifyTotp({ ...params, secret: "!!!" }, "94287082", at), false, "bad secret");
});
test("the skew window covers a step either side and no further", () => {
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
// 94287082 is the code for the step containing t=59.
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 89_000 }), true, "one step late");
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 29_000 }), true, "one step early");
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 119_000 }), false, "two steps late");
});
test("otpauth URLs round-trip through the parser", () => {
const secret = generateSecret();
const url = otpauthUrl({ secret, account: "[email protected]", issuer: "ihasmail" });
assert.match(url, /^otpauth:\/\/totp\/ihasmail:ann%40example\.org\?/);
const parsed = parseOtpauthUrl(url);
assert.deepEqual(parsed, { secret, algorithm: "SHA1", digits: 6, period: 30 });
});
test("generated secrets are 160-bit and distinct", () => {
const a = generateSecret();
const b = generateSecret();
assert.equal(base32Decode(a)?.length, 20);
assert.notEqual(a, b);
});
test("parseOtpauthUrl rejects anything that is not a usable TOTP URL", () => {
assert.equal(parseOtpauthUrl("https://example.org"), null);
assert.equal(parseOtpauthUrl("otpauth://hotp/a?secret=GEZDGNBV"), null, "counter-based");
assert.equal(parseOtpauthUrl("otpauth://totp/a"), null, "no secret");
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=!!!"), null, "unusable secret");
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&algorithm=MD5"), null);
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&digits=99"), null);
});
+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);
}