diff --git a/README.md b/README.md index 2f8eb36..9ef8f49 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,15 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a - Browse folders, upload (drag & drop), download, create folders, rename, move, delete **Settings** -- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:Account/get`), falling back to the browser's; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse +- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse +- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live) +- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings › Appearance - Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings **Platform** - Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB - **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings › General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in +- **About** reports the Stalwart generation ihasmail detected (0.16+ or older) and the edition where the server gives one. Stalwart does not publish a version number to clients, so no version is shown rather than a made-up one - Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers ## Architecture @@ -132,15 +135,15 @@ Verified against the mock server and, for the core mail flows, against a live St - **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server. - **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass. +- **Self-service credentials** — the **0.15.x REST path is confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path has only been exercised against the mock**, which enforces the same rules a real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it) — it still wants a pass against a real 0.16 server. Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens. - Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). - Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented. -- The account locale is read with Stalwart's `x:Account/get`, which needs the `sysAccountGet` permission; where a regular user is not granted it, ihasmail silently falls back to the browser locale and the setting can be chosen by hand. +- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. ## Roadmap / not yet - Snooze and scheduled send (needs server-side support) - Read-receipt (MDN) sending, S/MIME / OpenPGP -- Self-service password / app-password / 2FA management (Stalwart exposes this through its own account portal) - Translations (strings are English-only for now) ## License diff --git a/package-lock.json b/package-lock.json index bb00554..65f333b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2459,6 +2459,12 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -3826,6 +3832,7 @@ "@tanstack/react-virtual": "^3.13.2", "dompurify": "^3.2.4", "lucide-react": "^0.477.0", + "qrcode-generator": "^2.0.4", "react": "^19.0.0", "react-dom": "^19.0.0", "wouter": "^3.6.0", diff --git a/server/src/account.test.ts b/server/src/account.test.ts new file mode 100644 index 0000000..587fb5b --- /dev/null +++ b/server/src/account.test.ts @@ -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 = "demo@example.com"; +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), ...(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: "demo@example.com", 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; +}); diff --git a/server/src/account.ts b/server/src/account.ts new file mode 100644 index 0000000..80f769b --- /dev/null +++ b/server/src/account.ts @@ -0,0 +1,386 @@ +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(); +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 { + 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 { + // 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) { + try { + 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 + } catch { + // Not an answer we can read - most likely a server too old to know the + // capability we named, which rejects the whole request rather than the + // one call. Fall through and try the endpoint such servers do have. + } + } + 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]; + +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(ctx: Ctx, init: RequestInit): Promise { + 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 | 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 | 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) : 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 { + 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[] { + 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[]) : []; +} + +function firstListItem(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record | null { + return listOf(res, callId)[0] ?? null; +} + +export async function changePassword( + sessionId: string, + ctx: Ctx, + opts: { current: string; next: string; otpCode?: string }, +): Promise { + const backend = await detectBackend(sessionId, ctx); + if (backend === "registry") { + const update: Record = { 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(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(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 { + 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(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 { + 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(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 { + 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(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 { + 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 }; diff --git a/server/src/accountinfo.test.ts b/server/src/accountinfo.test.ts new file mode 100644 index 0000000..e018e3d --- /dev/null +++ b/server/src/accountinfo.test.ts @@ -0,0 +1,67 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getAccountInfo, interpretAccountInfo } from "./upstream.js"; + +/** + * The account locale used to be read only from `x:Account/get`, which needs + * the `sysAccountGet` permission — one the built-in `user` role is not given. + * Ordinary users therefore silently fell back to the browser locale. Stalwart + * 0.16 exposes the same field on `x:AccountSettings`, which users *can* read, + * so both are asked for and whichever answers wins. + */ + +type Responses = [string, Record, string][]; + +const settingsOk = (locale: string): Responses[number] => ["x:AccountSettings/get", { list: [{ id: "singleton", locale }] }, "s"]; +const accountOk = (locale: string): Responses[number] => ["x:Account/get", { list: [{ id: "a1", locale }] }, "a"]; +const failed = (id: string, type: string): Responses[number] => ["error", { type }, id]; + +test("prefers the locale a regular user is allowed to read", () => { + const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]); + assert.equal(info.locale, "de-DE"); + assert.equal(info.generation, "0.16+"); +}); + +test("falls back to x:Account when the settings object is forbidden", () => { + const info = interpretAccountInfo([failed("s", "forbidden"), accountOk("sr_RS@latin")]); + assert.equal(info.locale, "sr-Latn-RS"); +}); + +test("an older server is recognised by its unknownMethod, and still yields a locale", () => { + const info = interpretAccountInfo([failed("s", "unknownMethod"), accountOk("en_GB")]); + assert.equal(info.generation, "pre-0.16"); + assert.equal(info.locale, "en-GB"); +}); + +test("a server answering the new method is 0.16+ even with no locale set", () => { + const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]); + assert.equal(info.generation, "0.16+"); + assert.equal(info.locale, null); +}); + +test("neither answering leaves everything unknown rather than guessing", () => { + const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]); + assert.deepEqual(info, { locale: null, generation: null, edition: null }); + assert.deepEqual(interpretAccountInfo([]), { locale: null, generation: null, edition: null }); +}); + +test("locales that carry no language are dropped, not passed through", () => { + assert.equal(interpretAccountInfo([settingsOk("C")]).locale, null); + assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null); +}); + +test("a server that never heard of the Stalwart capability is reported as pre-0.16", async () => { + // 0.16 always advertises urn:stalwart:jmap and nothing older knows it at all, + // so its absence is the answer - and asking anyway would fail the whole + // request on those servers. This is what the live 0.15.5 box hits. + const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} }; + const info = await getAccountInfo("session-pre-016", "Basic x", session as never); + assert.equal(info.generation, "pre-0.16"); + assert.equal(info.locale, null); + assert.equal(info.edition, null); +}); + +test("no capabilities at all leaves the generation unknown", async () => { + const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never); + assert.equal(info.generation, null); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 9fded67..c43b301 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,15 +6,28 @@ import { config } from "./config.js"; import { SessionStore, type LiveSession } from "./sessions.js"; import { RateLimiter } from "./ratelimit.js"; import { + type AccountInfo, UpstreamError, absoluteUpstream, expandTemplate, fetchUpstreamSession, forgetUpstreamSession, - getAccountLocale, + getAccountInfo, 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 +35,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", @@ -171,8 +191,8 @@ export function createApp(): Hono { ip, }); setSessionCookie(c, cookie, session.remember); - const locale = await getAccountLocale(session.id, session.authorization, upstream); - return c.json(localizeSession(upstream, sessionExtras(session, locale))); + const info = await getAccountInfo(session.id, session.authorization, upstream); + return c.json(localizeSession(upstream, sessionExtras(session, info))); } catch (err) { return upstreamFailure(c, err); } @@ -182,8 +202,8 @@ export function createApp(): Hono { const session = c.get("session"); try { const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1"); - const locale = await getAccountLocale(session.id, session.authorization, upstream); - return c.json(localizeSession(upstream, sessionExtras(session, locale))); + const info = await getAccountInfo(session.id, session.authorization, upstream); + return c.json(localizeSession(upstream, sessionExtras(session, info))); } catch (err) { if (err instanceof UpstreamError && err.status === 401) { sessions.destroy(session.id); @@ -215,6 +235,183 @@ export function createApp(): Hono { 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) => { + 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): 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,7 +550,22 @@ export function createApp(): Hono { return app; } -function sessionExtras(session: LiveSession, userLocale: string | null = null) { +async function readJson(c: Context): Promise { + 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, info: AccountInfo = { locale: null, generation: null, edition: null }) { return { ihasmail: { appName: config.appName, @@ -363,7 +575,9 @@ function sessionExtras(session: LiveSession, userLocale: string | null = null) { loginName: session.username, remember: session.remember, /** Locale configured for the account in Stalwart's directory, if readable. */ - userLocale, + userLocale: info.locale, + /** What the upstream server would tell us about itself. */ + server: { generation: info.generation, edition: info.edition }, }, }; } diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 0740de1..3b11cb2 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -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 ?? "demo@example.com"; /** 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; const state = { n: 1 }; @@ -256,6 +265,13 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) { } const handlers: Record = { + // 0.16 exposes the account locale here, under a permission ordinary users + // actually have (unlike x:Account below, which needs sysAccountGet). + "x:AccountSettings/get": (a) => { + const ids = (a.ids as string[] | null) ?? ["singleton"]; + const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null })); + return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") }; + }, // Stalwart's directory extension - the client reads the account locale from here. "x:Account/get": (a) => { const ids = (a.ids as string[] | null) ?? [ACCOUNT]; @@ -302,6 +318,64 @@ const handlers: Record = { }, "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 +423,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 { return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); }); @@ -377,13 +468,19 @@ 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") { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify(session())); } + // 0.16's account info endpoint; the only place a server reports its edition. + if (url.pathname === "/api/account" && req.method === "GET") { + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE })); + } if (url.pathname === "/jmap/" && req.method === "POST") { const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][] }; const responses: [string, Obj, string][] = []; diff --git a/server/src/sessions.ts b/server/src/sessions.ts index f5c36ae..dbeb7f1 100644 --- a/server/src/sessions.ts +++ b/server/src/sessions.ts @@ -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(); } diff --git a/server/src/totp.test.ts b/server/src/totp.test.ts new file mode 100644 index 0000000..6c18154 --- /dev/null +++ b/server/src/totp.test.ts @@ -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: "ann@example.org", 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); +}); diff --git a/server/src/totp.ts b/server/src/totp.ts new file mode 100644 index 0000000..ee099bb --- /dev/null +++ b/server/src/totp.ts @@ -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 = { 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); +} diff --git a/server/src/upstream.ts b/server/src/upstream.ts index 9a12ed1..ea5036c 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -59,7 +59,7 @@ export async function getUpstreamSession(sessionId: string, authorization: strin export function forgetUpstreamSession(sessionId: string): void { sessionCache.delete(sessionId); - localeCache.delete(sessionId); + infoCache.delete(sessionId); } /* ------------------------------------------------------------------ */ @@ -68,8 +68,25 @@ export function forgetUpstreamSession(sessionId: string): void { const STALWART_CAP = "urn:stalwart:jmap"; const JMAP_CORE = "urn:ietf:params:jmap:core"; -const localeCache = new Map(); -const LOCALE_CACHE_MS = 30 * 60_000; + +export interface AccountInfo { + /** BCP-47 tag configured for the account, or null if unreadable. */ + locale: string | null; + /** + * Which generation of Stalwart's API answered: "0.16+" has the registry + * (`x:AccountSettings`), older builds only have `x:Account`. Null when the + * server is not Stalwart or told us nothing. + */ + generation: "0.16+" | "pre-0.16" | null; + /** "oss" | "community" | "enterprise", where the server reports it. */ + edition: string | null; +} + +const infoCache = new Map(); +const INFO_CACHE_MS = 30 * 60_000; +const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null }; +/** A server that has never heard of the registry: nothing to read, but dated. */ +const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null }; /** * glibc modifiers that name a script rather than a dialect or a currency: @@ -112,47 +129,100 @@ export function normalizeLocale(raw: unknown): string | null { } /** - * Best-effort lookup of the locale configured for this account in Stalwart's - * directory (`x:Account/get`, Stalwart's JMAP extension). Servers that do not - * expose it — or that deny a regular user the `sysAccountGet` permission — - * simply yield null and the client falls back to the browser locale. + * Best-effort lookup of what the server can tell us about this account. + * + * The locale used to come from `x:Account/get`, which needs the `sysAccountGet` + * permission — a tenant/admin one that ordinary users are not granted, so the + * setting silently fell back to the browser locale for exactly the people most + * likely to want it. Stalwart 0.16 exposes the same field on `x:AccountSettings`, + * whose `sysAccountSettingsGet` permission *is* part of the built-in user role. + * Ask for both in one request and take whichever the server allows, which also + * tells us which generation we are talking to. */ -async function fetchAccountLocale(authorization: string, session: UpstreamSession): Promise { - if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return null; +async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise { + // Every 0.16 build advertises urn:stalwart:jmap, and no earlier one knows it + // at all, so its absence already answers the question — and asking anyway + // would fail the whole request, since those servers reject a `using` naming + // a capability they cannot parse. + if (!session.capabilities) return EMPTY_INFO; + if (!(STALWART_CAP in session.capabilities)) return PRE_REGISTRY_INFO; const accountId = session.primaryAccounts?.[STALWART_CAP] ?? session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(session.accounts ?? {})[0]; - if (!accountId) return null; + if (!accountId) return EMPTY_INFO; const res = await fetch(absoluteUpstream(session.apiUrl), { method: "POST", headers: { authorization, "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], - methodCalls: [["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "l"]], + methodCalls: [ + ["x:AccountSettings/get", { accountId, ids: ["singleton"], properties: ["locale"] }, "s"], + ["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "a"], + ], }), signal: AbortSignal.timeout(config.upstreamTimeout), }); - if (!res.ok) return null; + if (!res.ok) return EMPTY_INFO; const body = (await res.json()) as { methodResponses?: [string, Record, string][] }; - const call = body.methodResponses?.[0]; - if (!call || call[0] !== "x:Account/get") return null; + return interpretAccountInfo(body.methodResponses ?? []); +} + +/** + * Read the pair of replies: prefer the locale from `x:AccountSettings`, fall + * back to `x:Account` for servers (or permissions) where only that one works, + * and note which generation answered. + */ +export function interpretAccountInfo(responses: [string, Record, string][]): AccountInfo { + const settings = responses.find((r) => r[2] === "s"); + const account = responses.find((r) => r[2] === "a"); + // Only 0.16+ knows the method at all; older builds cannot even parse the name. + const generation: AccountInfo["generation"] = + settings && settings[0] !== "error" + ? "0.16+" + : (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod" + ? "pre-0.16" + : null; + return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null }; +} + +function localeOf(call: [string, Record, string] | undefined): string | null { + if (!call || call[0] === "error") return null; const list = call[1]?.list; if (!Array.isArray(list) || !list.length) return null; return normalizeLocale((list[0] as { locale?: unknown } | undefined)?.locale); } -export async function getAccountLocale(sessionId: string, authorization: string, session: UpstreamSession): Promise { - const cached = localeCache.get(sessionId); - if (cached && Date.now() - cached.fetchedAt < LOCALE_CACHE_MS) return cached.locale; - let locale: string | null = null; +/** + * Which edition the server is running. Stalwart deliberately does not publish + * its version number to clients, but 0.16 does report its edition here. + */ +async function fetchEdition(authorization: string): Promise { try { - locale = await fetchAccountLocale(authorization, session); + const res = await fetch(`${config.stalwartUrl}/api/account`, { + headers: { authorization, accept: "application/json" }, + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (!res.ok) return null; + const body = (await res.json()) as { edition?: unknown }; + return typeof body.edition === "string" ? body.edition : null; } catch { - /* the server locale is a nicety - never fail the session over it */ + return null; } - localeCache.set(sessionId, { locale, fetchedAt: Date.now() }); - return locale; +} + +export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise { + const cached = infoCache.get(sessionId); + if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info; + let info = EMPTY_INFO; + try { + info = await fetchAccountInfo(authorization, session); + if (info.generation === "0.16+") info = { ...info, edition: await fetchEdition(authorization) }; + } catch { + /* all of this is a nicety - never fail the session over it */ + } + infoCache.set(sessionId, { info, fetchedAt: Date.now() }); + return info; } /** diff --git a/web/package.json b/web/package.json index 759e7ed..06ba186 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "@tanstack/react-virtual": "^3.13.2", "dompurify": "^3.2.4", "lucide-react": "^0.477.0", + "qrcode-generator": "^2.0.4", "react": "^19.0.0", "react-dom": "^19.0.0", "wouter": "^3.6.0", diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index 79b5bd1..542e162 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -32,6 +32,12 @@ export interface JmapSession { remember: boolean; /** Locale configured for the account in Stalwart, if the server exposes it. */ userLocale?: string | null; + /** What the upstream server was willing to say about itself. */ + server?: { + /** Which API generation answered: Stalwart publishes no version number. */ + generation?: "0.16+" | "pre-0.16" | null; + edition?: string | null; + }; }; } diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 1c492be..0bf3b7b 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { create } from "zustand"; import { loadJson, saveJson } from "@/lib/storage"; import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime"; @@ -186,6 +187,23 @@ if (typeof window !== "undefined") { window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme()); } +/** + * The theme actually on screen, which is not the same as the setting: "system" + * resolves to whatever the OS is doing right now, and follows it as it changes. + */ +export function useEffectiveTheme(): "light" | "dark" { + const theme = useSettings((s) => s.settings.theme); + const [systemDark, setSystemDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false); + useEffect(() => { + const mq = window.matchMedia?.("(prefers-color-scheme: dark)"); + if (!mq) return; + const onChange = () => setSystemDark(mq.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + return theme === "dark" || (theme === "system" && systemDark) ? "dark" : "light"; +} + export const settings = () => useSettings.getState().settings; /** diff --git a/web/src/ui/__tests__/dialog-focus.test.tsx b/web/src/ui/__tests__/dialog-focus.test.tsx new file mode 100644 index 0000000..7f8e924 --- /dev/null +++ b/web/src/ui/__tests__/dialog-focus.test.tsx @@ -0,0 +1,82 @@ +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Dialog } from "../dialog"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/** + * Dialogs are almost always given an inline arrow for onClose, so its identity + * changes on every render of the parent. While that was in the effect's + * dependencies, any dialog holding state tore the effect down and set it up + * again on each keystroke — and its autofocus dragged the caret back to the + * first field. Typing a digit into the second field jumped you to the first. + */ + +/** A dialog with two fields, whose parent re-renders as either is typed in. */ +function TwoFieldDialog() { + const [first, setFirst] = useState(""); + const [second, setSecond] = useState(""); + return ( + undefined} title="Two fields"> + setFirst(e.target.value)} /> + setSecond(e.target.value)} /> + + ); +} + +describe("Dialog focus handling", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + const type = (el: HTMLInputElement, value: string) => { + act(() => { + el.focus(); + // What React's onChange sees when a character is typed. + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); + }); + }; + + it("autofocuses the first field when it opens", async () => { + act(() => root.render()); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + expect(document.activeElement?.id).toBe("first"); + }); + + it("leaves the caret alone while a later field is typed in", async () => { + act(() => root.render()); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const second = document.getElementById("second") as HTMLInputElement; + type(second, "1"); + // The old effect re-ran here and pulled focus back to the first field. + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + expect(document.activeElement?.id).toBe("second"); + + type(second, "12"); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + expect(document.activeElement?.id).toBe("second"); + expect(second.value).toBe("12"); + }); +}); diff --git a/web/src/ui/dialog.tsx b/web/src/ui/dialog.tsx index cc0e46b..b746d77 100644 --- a/web/src/ui/dialog.tsx +++ b/web/src/ui/dialog.tsx @@ -16,13 +16,23 @@ interface DialogProps { export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) { const ref = useRef(null); + /* + * Callers almost always pass an inline arrow for onClose, so its identity + * changes on every render of the parent. Depending on it here would tear the + * effect down and set it up again on every keystroke in a dialog that holds + * state, and the autofocus below would drag the caret back to the first + * field mid-typing. Keep the latest handler in a ref instead, so the effect + * depends only on `open`. + */ + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; useEffect(() => { if (!open) return; const prev = document.activeElement as HTMLElement | null; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); - onClose(); + onCloseRef.current(); } if (e.key === "Tab" && ref.current) { const focusables = ref.current.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'); @@ -48,7 +58,7 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl document.removeEventListener("keydown", onKey, true); prev?.focus?.(); }; - }, [open, onClose]); + }, [open]); if (!open) return null; return createPortal(
{ + const qr = qrcode(0, "M"); + qr.addData(value); + qr.make(); + const count = qr.getModuleCount(); + let path = ""; + for (let row = 0; row < count; row++) { + for (let col = 0; col < count; col++) { + if (qr.isDark(row, col)) path += `M${col} ${row}h1v1h-1z`; + } + } + return { path, count }; + }, [value]); + + return ( + + {title ?? "QR code"} + + + ); +} diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index fe08c65..5338fe0 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -1,8 +1,8 @@ import { useEffect, useState, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, PenSquare, Settings, Users, LogOut, Plus, RefreshCw } from "lucide-react"; +import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react"; import { useSession } from "@/store/session"; -import { useSettings } from "@/store/settings"; +import { useEffectiveTheme, useSettings } from "@/store/settings"; import { useMail } from "@/store/mail"; import { draftFromMailto, useCompose } from "@/store/compose"; import { Avatar, useIsMobile } from "@/ui/misc"; @@ -70,6 +70,7 @@ export function AppShell({ children }: { children: ReactNode }) { + @@ -196,3 +197,27 @@ function QuotaBar() {
); } + +/** + * Flip between light and dark from the top bar. + * + * The stored setting has a third value, "system", so the button acts on what + * is actually on screen rather than on the setting: whichever theme you can + * see, one click gives you the other one. Choosing "match system" again lives + * in Settings › Appearance, where the three-way choice belongs. + */ +function ThemeToggle() { + const effective = useEffectiveTheme(); + const update = useSettings((s) => s.update); + const next = effective === "dark" ? "light" : "dark"; + return ( + + ); +} diff --git a/web/src/views/settings/AboutSettings.tsx b/web/src/views/settings/AboutSettings.tsx index 458406e..a3a1bb8 100644 --- a/web/src/views/settings/AboutSettings.tsx +++ b/web/src/views/settings/AboutSettings.tsx @@ -19,11 +19,13 @@ export function AboutSettings() { +
Signed in as{session?.username}
Stalwart{describeServer(session?.ihasmail?.server)}
Accounts{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}
Max upload{Math.round(client.maxSizeUpload / 1048576)} MB
Image privacy proxy{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}
+

Stalwart does not publish its version number to mail clients, so ihasmail reports the API generation it detected instead.

Server capabilities

{caps.map((c) => {c.replace("urn:ietf:params:jmap:", "")})} @@ -31,3 +33,15 @@ export function AboutSettings() {
); } + +/** + * Stalwart deliberately withholds its version from clients (it reports a fixed + * "1.0.0" wherever it publishes one at all), so the most honest thing we can + * show is which generation of its API answered us, plus the edition where the + * server reports it. + */ +function describeServer(server: { generation?: "0.16+" | "pre-0.16" | null; edition?: string | null } | undefined): string { + if (!server?.generation) return "not detected"; + const generation = server.generation === "0.16+" ? "0.16 or newer" : "older than 0.16"; + return server.edition ? `${generation} (${server.edition})` : generation; +} diff --git a/web/src/views/settings/SecuritySettings.tsx b/web/src/views/settings/SecuritySettings.tsx index da0842e..ffa6fc7 100644 --- a/web/src/views/settings/SecuritySettings.tsx +++ b/web/src/views/settings/SecuritySettings.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from "react"; -import { apiFetch } from "@/jmap/client"; +import { useCallback, useEffect, useState } from "react"; +import { Copy, KeyRound, ShieldCheck, Smartphone } from "lucide-react"; +import { apiFetch, ApiError } from "@/jmap/client"; import { useSession } from "@/store/session"; import { formatFullDate } from "@/lib/format"; import { toast } from "@/ui/toast"; -import { confirmDialog } from "@/ui/dialog"; +import { confirmDialog, Dialog } from "@/ui/dialog"; +import { QrCode } from "@/ui/qrcode"; interface SessionRow { id: string; @@ -16,19 +18,72 @@ interface SessionRow { ip: string; } +interface AppPasswordRow { + id: string; + description: string; + createdAt: string | null; + expiresAt: string | null; +} + +interface SecurityState { + backend: "registry" | "legacy"; + otpEnabled: boolean; + appPasswords: AppPasswordRow[]; + appPasswordsKeyedByName: boolean; +} + export function SecuritySettings() { const [rows, setRows] = useState(null); const [current, setCurrent] = useState(""); + const [state, setState] = useState(null); + /** Set when the server has no self-service API at all (pre-0.15 or a proxy). */ + const [unsupported, setUnsupported] = useState(null); const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); + const load = () => apiFetch<{ current: string; sessions: SessionRow[] }>("/api/auth/sessions").then((r) => { setRows(r.sessions); setCurrent(r.current); }).catch(() => setRows([])); + + const loadSecurity = useCallback(async () => { + try { + setState(await apiFetch("/api/account/security")); + setUnsupported(null); + } catch (err) { + setState(null); + setUnsupported(err instanceof ApiError && err.status === 501 ? err.message : (err as Error).message); + } + }, []); + useEffect(() => { void load(); - }, []); + void loadSecurity(); + }, [loadSecurity]); + return (

Security & sessions

You're signed in as {session?.username}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.

+ +

Password

+ {unsupported ? ( +

{unsupported}

+ ) : ( + { void load(); }} /> + )} + +

Two-factor authentication

+ {unsupported ? ( +

Two-factor authentication is managed by your mail administrator.

+ ) : ( + { await loadSecurity(); await load(); }} /> + )} + +

App passwords

+ {unsupported ? ( +

App passwords are managed by your mail administrator.

+ ) : ( + + )} +

Active webmail sessions

{rows === null ?

Loading…

: ( @@ -50,8 +105,290 @@ export function SecuritySettings() { -

Password & two-factor

-

Password changes, app passwords and 2FA are managed by your mail administrator or via Stalwart's self-service portal.

+ + ); +} + +/* ------------------------------------------------------------------ */ + +function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChanged: () => void }) { + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [confirm, setConfirm] = useState(""); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (next !== confirm) { + toast.error("The new passwords don't match"); + return; + } + setBusy(true); + try { + const res = await apiFetch<{ revokedSessions: number }>("/api/account/password", { + method: "POST", + body: JSON.stringify({ current, next, otpCode: code || undefined }), + }); + setCurrent(""); setNext(""); setConfirm(""); setCode(""); + toast.success(res.revokedSessions ? `Password changed. ${res.revokedSessions} other session(s) signed out.` : "Password changed"); + onChanged(); + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusy(false); + } + }; + + return ( + +

Changing your password signs out your other webmail sessions. Any app passwords keep working.

+
+ + setCurrent(e.target.value)} required /> +
+ {otpEnabled && ( +
+ + setCode(e.target.value)} placeholder="123456" required /> +
+ )} +
+
+ + setNext(e.target.value)} required /> +
+
+ + setConfirm(e.target.value)} required /> +
+
+ + + ); +} + +/* ------------------------------------------------------------------ */ + +function TwoFactor({ state, reload }: { state: SecurityState | null; reload: () => Promise }) { + const [setup, setSetup] = useState<{ secret: string; url: string } | null>(null); + const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [disabling, setDisabling] = useState(false); + + if (!state) return

Loading…

; + + const begin = async () => { + try { + setSetup(await apiFetch<{ secret: string; url: string }>("/api/account/2fa/begin", { method: "POST", body: "{}" })); + setCode(""); setPassword(""); + } catch (err) { + toast.error((err as Error).message); + } + }; + + const enable = async () => { + if (!setup) return; + setBusy(true); + try { + const res = await apiFetch<{ sessionKept: boolean }>("/api/account/2fa/enable", { + method: "POST", + body: JSON.stringify({ url: setup.url, code, current: password }), + }); + setSetup(null); + await reload(); + if (res.sessionKept) { + toast.success("Two-factor authentication is on. This browser stays signed in."); + } else { + toast.success("Two-factor authentication is on. You'll need to sign in again with a code."); + } + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusy(false); + } + }; + + const disable = async () => { + setBusy(true); + try { + await apiFetch("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) }); + setDisabling(false); + await reload(); + toast.success("Two-factor authentication is off"); + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
+

+ {state.otpEnabled + ? "Signing in requires a code from your authenticator app as well as your password." + : "Add a one-time code from an authenticator app to your sign-in, so a stolen password isn't enough on its own."} +

+
+ + {state.otpEnabled ? "Enabled" : "Not enabled"} + {state.otpEnabled + ? + : } +
+ + setSetup(null)} title="Set up two-factor authentication" size="md" + footer={<> + + + }> + {setup && ( +
+
    +
  1. Scan this with your authenticator app.
  2. +
  3. Enter the six-digit code it shows, and your password.
  4. +
+
+ +
+
+ + +
+
+ + setCode(e.target.value)} placeholder="123456" /> +
+
+ + setPassword(e.target.value)} /> +
+
+
+

Codes are checked before anything is saved, so a mistyped key can't lock you out.

+
+ )} +
+ + setDisabling(false)} title="Turn off two-factor authentication" size="sm" + footer={<> + + + }> +

Your password alone will be enough to sign in again.

+
+ + setPassword(e.target.value)} /> +
+
+ + setCode(e.target.value)} placeholder="123456" /> +
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ + +function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise }) { + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null); + + if (!state) return

Loading…

; + + const create = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + try { + const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", { + method: "POST", + body: JSON.stringify({ description: name }), + }); + setIssued({ description: name, secret: res.secret }); + setName(""); + await reload(); + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusy(false); + } + }; + + const revoke = async (row: AppPasswordRow) => { + const ok = await confirmDialog({ + title: `Revoke "${row.description}"?`, + message: "Anything signed in with this password stops working immediately.", + confirmLabel: "Revoke", + danger: true, + }); + if (!ok) return; + try { + await apiFetch("/api/account/app-passwords/revoke", { method: "POST", body: JSON.stringify({ id: row.id }) }); + await reload(); + toast.success("App password revoked"); + } catch (err) { + toast.error((err as Error).message); + } + }; + + return ( +
+

+ A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one. +

+ {state.appPasswords.length > 0 && ( +
+ {!state.appPasswordsKeyedByName && } + + {state.appPasswords.map((row) => ( + + + {!state.appPasswordsKeyedByName && } + + + ))} + +
NameCreated
{row.description}{row.createdAt ? formatFullDate(row.createdAt) : "—"}
+ )} +
+
+ + setName(e.target.value)} placeholder="Thunderbird on my laptop" required /> +
+ +
+ {state.appPasswordsKeyedByName &&

This mail server identifies app passwords by name, so give each one a different name.

} + + setIssued(null)} title="Your new app password" size="sm" + footer={}> + {issued && ( +
+

Copy it into {issued.description} now — it isn't shown again.

+ +

Use your usual address as the username.

+
+ )} +
+
+ ); +} + +function CopyableSecret({ value }: { value: string }) { + return ( +
+ {value} +
); }