ihasmail spoke to two generations of Stalwart that are less alike than their version numbers suggest: 0.16 replaced the REST management API with JMAP registry objects, changed the shape of FileNode, split its rights up, and moved configuration into the store. Carrying both meant 34 branch points across nine files, a 92-line compatibility shim whose only job was telling them apart, a parallel REST implementation of every credential operation, and a mock that had to model both. The branches were not the real cost. The cost was that a wrong answer about which generation had answered always had somewhere to fall back to, so it failed quietly rather than loudly: one capability looked for in the wrong place downgraded every real 0.16 server onto the 0.15 path, which posted the current password to an endpoint 0.16 had removed, reported the wrong generation on About, and ran Files on the older code. It reached production and was recorded as verified when it was not. The mock mirrored the same wrong placement, which is why the tests agreed. Removed: the filenode compatibility shim, the dual "registry" | "legacy" backend in account.ts, the pre-0.16 generation in AccountInfo and everything that read it, the mock's LEGACY mode and dev:mock:legacy, and the three test files that existed only to pin 0.15 behaviour. Sign-in now refuses an older server by name, once, rather than letting Files, the account locale and credentials each fail in their own way with nothing connecting them. It says the credentials were fine -- someone hitting this has typed a correct password, and telling them otherwise sends them round in circles -- and names the tag to build from. Four tests cover it, including that no session cookie is minted and that bad credentials on such a server are still a plain 401. Two fallbacks went that were not strictly about 0.15, and both for the same reason the removal is happening. Files no longer answers a refused filter or sort by fetching every node in the account, which would hide a real fault behind a performance cliff nobody would notice. And the app folder lookups now filter on parentId/isTopLevel alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query rather than being ignored. The last release that runs on 0.15 is tagged stalwart-0.15-support. Verified against the mock end to end: sign-in, the Files tree on the 0.16 path with the app folder hidden, and self-service credentials over the registry. 226 web + 75 server tests pass; typecheck and build clean.
222 lines
9.3 KiB
TypeScript
222 lines
9.3 KiB
TypeScript
import { config } from "./config.js";
|
|
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
|
|
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
|
|
|
/**
|
|
* Self-service credential management, over Stalwart's JMAP registry:
|
|
* `x:AccountPassword` (a singleton holding the password and the otpauth URL)
|
|
* and `x:AppPassword`.
|
|
*
|
|
* The registry crate arrived in 0.16, which is the oldest Stalwart ihasmail
|
|
* supports. Sign-in refuses anything older, so by the time any of this runs
|
|
* the registry is known to be there.
|
|
*/
|
|
|
|
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 interface AppPasswordRow {
|
|
id: string;
|
|
description: string;
|
|
createdAt: string | null;
|
|
expiresAt: string | null;
|
|
}
|
|
|
|
export interface SecurityState {
|
|
otpEnabled: boolean;
|
|
appPasswords: AppPasswordRow[];
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Transport */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function accountId(ctx: Ctx): string {
|
|
return (
|
|
ctx.session.primaryAccounts?.[STALWART_CAP] ??
|
|
ctx.session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
|
Object.keys(ctx.session.accounts ?? {})[0] ??
|
|
""
|
|
);
|
|
}
|
|
|
|
type Invocation = [string, Record<string, unknown>, string];
|
|
|
|
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
|
|
const res = await fetch(absoluteUpstream(ctx.session.apiUrl), {
|
|
method: "POST",
|
|
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
|
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
|
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
|
});
|
|
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
|
if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502);
|
|
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
|
}
|
|
|
|
/**
|
|
* Pull the single result out of a /set, turning JMAP's several failure shapes
|
|
* into one error carrying whatever the server was willing to explain.
|
|
*/
|
|
function setResult(res: { methodResponses?: [string, unknown, string][] }, kind: "created" | "updated" | "destroyed"): Record<string, unknown> | null {
|
|
const [name, args] = res.methodResponses?.[0] ?? [];
|
|
if (!name) throw new AccountError("The mail server sent no response.", 502, "upstream");
|
|
if (name === "error") {
|
|
const err = args as { type?: string; description?: string };
|
|
if (err.type === "unknownMethod") {
|
|
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
|
|
}
|
|
throw new AccountError(err.description ?? `The mail server refused the request (${err.type ?? "error"}).`, 502, err.type ?? "upstream");
|
|
}
|
|
const body = args as Record<string, Record<string, unknown> | undefined>;
|
|
const notKind = kind === "created" ? "notCreated" : kind === "updated" ? "notUpdated" : "notDestroyed";
|
|
const failures = body[notKind];
|
|
const failure = failures && Object.values(failures)[0];
|
|
if (failure) {
|
|
const err = failure as { type?: string; description?: string; properties?: string[] };
|
|
throw new AccountError(describeSetError(err), err.type === "forbidden" ? 403 : 400, err.type ?? "invalid");
|
|
}
|
|
const ok = body[kind];
|
|
return ok ? ((Object.values(ok)[0] ?? {}) as Record<string, unknown>) : null;
|
|
}
|
|
|
|
function describeSetError(err: { type?: string; description?: string; properties?: string[] }): string {
|
|
if (err.description) return err.description;
|
|
if (err.type === "forbidden") return "The mail server refused the change.";
|
|
if (err.type === "overQuota") return "You have reached the number of app passwords this account allows.";
|
|
if (err.type === "invalidProperties") {
|
|
return err.properties?.length ? `The mail server rejected ${err.properties.join(", ")}.` : "The mail server rejected the value.";
|
|
}
|
|
return `The mail server refused the change (${err.type ?? "error"}).`;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Operations */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export async function getState(ctx: Ctx): Promise<SecurityState> {
|
|
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 {
|
|
// 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,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function listOf(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown>[] {
|
|
const call = res.methodResponses?.find((r) => r[2] === callId);
|
|
if (!call || call[0] === "error") return [];
|
|
const list = (call[1] as { list?: unknown }).list;
|
|
return Array.isArray(list) ? (list as Record<string, unknown>[]) : [];
|
|
}
|
|
|
|
function firstListItem(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown> | null {
|
|
return listOf(res, callId)[0] ?? null;
|
|
}
|
|
|
|
export async function changePassword(ctx: Ctx, opts: { current: string; next: string; otpCode?: string }): Promise<void> {
|
|
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
|
|
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
|
|
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
|
|
setResult(res, "updated");
|
|
}
|
|
|
|
export async function createAppPassword(ctx: Ctx, opts: { description: string }): Promise<{ id: string; secret: string }> {
|
|
const description = opts.description.trim() || "App password";
|
|
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 };
|
|
}
|
|
|
|
export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
|
|
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
|
setResult(res, "destroyed");
|
|
}
|
|
|
|
/**
|
|
* 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(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
|
|
assertEnrolmentCode(opts.url, opts.code);
|
|
const res = await jmap(ctx, [
|
|
[
|
|
"x:AccountPassword/set",
|
|
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
|
|
"s",
|
|
],
|
|
]);
|
|
setResult(res, "updated");
|
|
}
|
|
|
|
export async function disableOtp(ctx: Ctx, opts: { current: string; code: string }): Promise<void> {
|
|
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");
|
|
}
|
|
|
|
export { MASKED };
|