Every build writes the exact source it was built from, uncommitted work and new files included, as source.tar.gz next to the app, named after that tree. Docker builds, which have no git, pack the build context and name it by a hash of its files. The sign-in page and Settings > About link to it instead of a repository that can drift. What users, operators and packagers see no longer names the upstream server: - interface text, in all nine catalogues, with a token-session line for Security; - server messages; - the settings, now MAIL_SERVER_URL, MAIL_SERVERS_FILE, ADMIN_URL and MAIL_SERVER_FOLLOW_ADVERTISED_URLS, and mail-servers.example.json; - the Tenants notice, which is gone; - the README, CONTRIBUTING and SECURITY. ihasmail's own FEATURES, KNOWN-ISSUES and ROADMAP stay with public ihasmail, and INBUXA.md is folded into the README.
222 lines
9.4 KiB
TypeScript
222 lines
9.4 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, ctx.session.baseUrl), {
|
|
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(`The mail server 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 enrollment: 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 beginOtpEnrollment(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 assertEnrollmentCode(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> {
|
|
assertEnrollmentCode(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 };
|