Drop Stalwart 0.15 support
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.
This commit is contained in:
+44
-222
@@ -1,17 +1,15 @@
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, hasStalwartRegistry, UpstreamError, type UpstreamSession } from "./upstream.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.
|
||||
* Self-service credential management, over Stalwart's JMAP registry:
|
||||
* `x:AccountPassword` (a singleton holding the password and the otpauth URL)
|
||||
* and `x:AppPassword`.
|
||||
*
|
||||
* 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.
|
||||
* 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";
|
||||
@@ -21,10 +19,7 @@ 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;
|
||||
@@ -32,14 +27,8 @@ export interface AppPasswordRow {
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -61,49 +50,7 @@ interface Ctx {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Backend detection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const backendCache = new Map<string, { backend: Backend; at: number }>();
|
||||
const BACKEND_CACHE_MS = 30 * 60_000;
|
||||
|
||||
export function forgetBackend(sessionId: string): void {
|
||||
backendCache.delete(sessionId);
|
||||
}
|
||||
|
||||
export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backend> {
|
||||
const cached = backendCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.at < BACKEND_CACHE_MS) return cached.backend;
|
||||
const backend = await probeBackend(ctx);
|
||||
backendCache.set(sessionId, { backend, at: Date.now() });
|
||||
return backend;
|
||||
}
|
||||
|
||||
async function probeBackend(ctx: Ctx): Promise<Backend> {
|
||||
// A server with the registry answers x:AccountPassword/get; one without it
|
||||
// fails to parse the method name at all and returns unknownMethod.
|
||||
if (hasStalwartRegistry(ctx.session)) {
|
||||
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 {
|
||||
// The capability already told us this server has the registry, so a
|
||||
// request we could not read is a fault to surface, not evidence of an
|
||||
// older server. Falling back here would post the user's password to a
|
||||
// REST endpoint 0.16 removed and report the feature as unsupported.
|
||||
return "registry";
|
||||
}
|
||||
// It named the capability and then disowned the method: nothing else to try.
|
||||
return "registry";
|
||||
}
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transports */
|
||||
/* Transport */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function accountId(ctx: Ctx): string {
|
||||
@@ -129,29 +76,6 @@ async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodRespon
|
||||
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
||||
}
|
||||
|
||||
async function legacy<T>(ctx: Ctx, init: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${config.stalwartUrl}/api/account/auth`, {
|
||||
...init,
|
||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||
if (res.status === 404) {
|
||||
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; details?: string; reason?: string };
|
||||
detail = body.details ?? body.reason ?? body.error ?? "";
|
||||
} catch {
|
||||
/* fall through to the generic message */
|
||||
}
|
||||
throw new AccountError(detail || `The mail server rejected the change (${res.status}).`, 502, "upstream");
|
||||
}
|
||||
return ((await res.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the single result out of a /set, turning JMAP's several failure shapes
|
||||
* into one error carrying whatever the server was willing to explain.
|
||||
@@ -192,17 +116,7 @@ function describeSetError(err: { type?: string; description?: string; properties
|
||||
/* Operations */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function getState(sessionId: string, ctx: Ctx): Promise<SecurityState> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "legacy") {
|
||||
const data = await legacy<{ otpEnabled?: boolean; appPasswords?: string[] }>(ctx, { method: "GET" });
|
||||
return {
|
||||
backend,
|
||||
otpEnabled: Boolean(data.otpEnabled),
|
||||
appPasswords: (data.appPasswords ?? []).map((name) => ({ id: name, description: name, createdAt: null, expiresAt: null })),
|
||||
appPasswordsKeyedByName: true,
|
||||
};
|
||||
}
|
||||
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"],
|
||||
@@ -211,7 +125,6 @@ export async function getState(sessionId: string, ctx: Ctx): Promise<SecuritySta
|
||||
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) => ({
|
||||
@@ -220,7 +133,6 @@ export async function getState(sessionId: string, ctx: Ctx): Promise<SecuritySta
|
||||
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
|
||||
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
|
||||
})),
|
||||
appPasswordsKeyedByName: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,56 +147,25 @@ function firstListItem(res: { methodResponses?: [string, unknown, string][] }, c
|
||||
return listOf(res, callId)[0] ?? null;
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; next: string; otpCode?: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
|
||||
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
|
||||
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
// The legacy endpoint changes the password without asking for the old one,
|
||||
// so anyone holding a live session could set it. Prove it ourselves first.
|
||||
await assertCurrentPassword(ctx, opts.current, opts.otpCode);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "setPassword", password: opts.next }]) });
|
||||
export async function 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(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { description: string },
|
||||
): Promise<{ id: string; secret: string }> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
export async function createAppPassword(ctx: Ctx, opts: { description: string }): Promise<{ id: string; secret: string }> {
|
||||
const description = opts.description.trim() || "App password";
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
|
||||
const created = setResult(res, "created");
|
||||
const secret = created && typeof created.secret === "string" ? created.secret : "";
|
||||
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
|
||||
return { id: String(created?.id ?? description), secret };
|
||||
}
|
||||
// Legacy servers take a secret of our choosing and key it by name.
|
||||
const secret = readableSecret();
|
||||
await legacy<unknown>(ctx, {
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ type: "addAppPassword", name: description, password: secret }]),
|
||||
});
|
||||
return { id: description, secret };
|
||||
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(sessionId: string, ctx: Ctx, id: string): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
||||
setResult(res, "destroyed");
|
||||
return;
|
||||
}
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "removeAppPassword", name: id }]) });
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,89 +192,30 @@ export function assertEnrolmentCode(url: string, code: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableOtp(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { url: string; code: string; current: string },
|
||||
): Promise<void> {
|
||||
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
|
||||
assertEnrolmentCode(opts.url, opts.code);
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "enableOtpAuth", url: opts.url }]) });
|
||||
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(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; code: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{
|
||||
accountId: accountId(ctx),
|
||||
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
|
||||
},
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current, opts.code);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "disableOtpAuth", url: null }]) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a password by authenticating with it, for the legacy endpoint that
|
||||
* would otherwise take our word for it.
|
||||
*/
|
||||
async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string): Promise<void> {
|
||||
const secret = otpCode ? `${current}$${otpCode}` : current;
|
||||
const authorization = `Basic ${Buffer.from(`${ctx.username}:${secret}`, "utf8").toString("base64")}`;
|
||||
const res = await fetch(`${config.stalwartUrl}/.well-known/jmap`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new AccountError("That password is incorrect.", 403, "bad_password");
|
||||
}
|
||||
if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502);
|
||||
}
|
||||
|
||||
/**
|
||||
* A legacy app password a person can read off a screen and type.
|
||||
*
|
||||
* Drawn by rejection sampling. Plain `% alphabet.length` would favour the
|
||||
* first 25 characters, because 256 is not a multiple of 33: each of those
|
||||
* would come up on 8 byte values and the remaining 8 on only 7.
|
||||
*/
|
||||
export function readableSecret(): string {
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes
|
||||
const limit = 256 - (256 % alphabet.length);
|
||||
const chars: string[] = [];
|
||||
while (chars.length < 20) {
|
||||
for (const b of randomBytes(32)) {
|
||||
if (b >= limit) continue; // the tail that would skew the alphabet
|
||||
chars.push(alphabet[b % alphabet.length]!);
|
||||
if (chars.length === 20) break;
|
||||
}
|
||||
}
|
||||
return (chars.join("").match(/.{5}/g) ?? []).join("-");
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user