Settings › Security grows three working sections instead of a note telling people to use Stalwart's own portal. Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has the x:AccountPassword singleton and x:AppPassword registry objects over JMAP, while 0.15.x has the /api/account/auth REST endpoint. Which one answers the probe is the only reliable way to tell them apart, and the result is cached per session. The built-in `user` role already grants sysAccountPassword* and sysAppPassword*, so no administrator setup is needed. Two problems are worth calling out, because both would bite a user hard: Stalwart validates the credentials already on the account when 2FA is turned on and never checks the new secret, so an authenticator that was mistyped or out of step would lock someone out of their mailbox at the next sign-in. We verify a code against the new secret ourselves first (RFC 6238, tested against the spec's vectors) and only then ask the server to store anything. Every proxied call re-authenticates with the credential sealed into the session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with it — which we cannot produce between requests. Turning 2FA on would therefore sign the user out of the browser they just turned it on in. App passwords authenticate without a second factor, so the session is moved onto one minted for this browser, and the session cookie is re-sealed with it. The order matters: it is minted while the old credential still works, and revoked again if enabling then fails. Password changes re-seal this session too and drop the others, whose sealed copies of the old password would fail on their next call. The mock now enforces what a real server does — current password, password policy, a TOTP code on every request once 2FA is on, app passwords exempt — so the whole flow is exercised in tests rather than only by hand.
238 lines
7.5 KiB
TypeScript
238 lines
7.5 KiB
TypeScript
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import { randomBytes } from "node:crypto";
|
|
import { config } from "./config.js";
|
|
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
|
|
|
|
export interface StoredSession {
|
|
id: string;
|
|
/** sha256 of the cookie secret; used to validate presented cookies. */
|
|
secretHash: string;
|
|
/** base64 random salt for key derivation */
|
|
salt: string;
|
|
/** sealed JSON {username, password} */
|
|
sealedCredentials: string;
|
|
username: string;
|
|
createdAt: number;
|
|
lastSeenAt: number;
|
|
expiresAt: number;
|
|
remember: boolean;
|
|
userAgent: string;
|
|
ip: string;
|
|
}
|
|
|
|
export interface LiveSession {
|
|
id: string;
|
|
username: string;
|
|
/** Basic Authorization header value for upstream calls. */
|
|
authorization: string;
|
|
remember: boolean;
|
|
createdAt: number;
|
|
lastSeenAt: number;
|
|
expiresAt: number;
|
|
userAgent: string;
|
|
ip: string;
|
|
}
|
|
|
|
const COOKIE_SEP = ".";
|
|
|
|
export class SessionStore {
|
|
private sessions = new Map<string, StoredSession>();
|
|
private dirty = false;
|
|
private saveTimer: NodeJS.Timeout | null = null;
|
|
private sweepTimer: NodeJS.Timeout | null = null;
|
|
|
|
constructor(private readonly file: string) {}
|
|
|
|
async init(): Promise<void> {
|
|
if (this.file) {
|
|
try {
|
|
const raw = await readFile(this.file, "utf8");
|
|
const arr = JSON.parse(raw) as StoredSession[];
|
|
const now = Date.now();
|
|
for (const s of arr) if (s.expiresAt > now) this.sessions.set(s.id, s);
|
|
console.log(`[ihasmail] restored ${this.sessions.size} session(s)`);
|
|
} catch (err: unknown) {
|
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
console.warn("[ihasmail] could not read session file:", (err as Error).message);
|
|
}
|
|
}
|
|
}
|
|
this.sweepTimer = setInterval(() => this.sweep(), 60_000);
|
|
this.sweepTimer.unref();
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
await this.flush();
|
|
}
|
|
|
|
private sweep(): void {
|
|
const now = Date.now();
|
|
let removed = 0;
|
|
for (const [id, s] of this.sessions) {
|
|
if (s.expiresAt <= now) {
|
|
this.sessions.delete(id);
|
|
removed++;
|
|
}
|
|
}
|
|
if (removed) this.scheduleSave();
|
|
}
|
|
|
|
private scheduleSave(): void {
|
|
this.dirty = true;
|
|
if (!this.file || this.saveTimer) return;
|
|
this.saveTimer = setTimeout(() => {
|
|
this.saveTimer = null;
|
|
void this.flush();
|
|
}, 1000);
|
|
this.saveTimer.unref();
|
|
}
|
|
|
|
private async flush(): Promise<void> {
|
|
if (!this.file || !this.dirty) return;
|
|
this.dirty = false;
|
|
try {
|
|
await mkdir(dirname(this.file), { recursive: true });
|
|
const tmp = `${this.file}.tmp`;
|
|
await writeFile(tmp, JSON.stringify([...this.sessions.values()]), { mode: 0o600 });
|
|
await rename(tmp, this.file);
|
|
} catch (err) {
|
|
console.warn("[ihasmail] could not persist sessions:", (err as Error).message);
|
|
}
|
|
}
|
|
|
|
/** Create a session; returns the cookie value to hand to the client. */
|
|
create(params: {
|
|
username: string;
|
|
password: string;
|
|
remember: boolean;
|
|
userAgent: string;
|
|
ip: string;
|
|
}): { cookie: string; session: LiveSession } {
|
|
const id = randomToken(18);
|
|
const secret = randomToken(32);
|
|
const salt = randomBytes(16);
|
|
const key = deriveKey(secret, config.appSecret, salt);
|
|
const now = Date.now();
|
|
const ttl = (params.remember ? config.sessionRememberTtl : config.sessionTtl) * 1000;
|
|
const stored: StoredSession = {
|
|
id,
|
|
secretHash: sha256(secret),
|
|
salt: salt.toString("base64"),
|
|
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
|
username: params.username,
|
|
createdAt: now,
|
|
lastSeenAt: now,
|
|
expiresAt: now + ttl,
|
|
remember: params.remember,
|
|
userAgent: params.userAgent.slice(0, 200),
|
|
ip: params.ip,
|
|
};
|
|
this.sessions.set(id, stored);
|
|
this.scheduleSave();
|
|
const cookie = `${id}${COOKIE_SEP}${secret}`;
|
|
return { cookie, session: this.toLive(stored, params.username, params.password) };
|
|
}
|
|
|
|
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
|
|
resolve(cookie: string | undefined): LiveSession | null {
|
|
if (!cookie) return null;
|
|
const idx = cookie.indexOf(COOKIE_SEP);
|
|
if (idx <= 0) return null;
|
|
const id = cookie.slice(0, idx);
|
|
const secret = cookie.slice(idx + 1);
|
|
const stored = this.sessions.get(id);
|
|
if (!stored) return null;
|
|
const now = Date.now();
|
|
if (stored.expiresAt <= now) {
|
|
this.sessions.delete(id);
|
|
this.scheduleSave();
|
|
return null;
|
|
}
|
|
if (!safeEqual(stored.secretHash, sha256(secret))) return null;
|
|
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
|
const json = open(stored.sealedCredentials, key);
|
|
if (!json) return null;
|
|
let creds: { u: string; p: string };
|
|
try {
|
|
creds = JSON.parse(json) as { u: string; p: string };
|
|
} catch {
|
|
return null;
|
|
}
|
|
// Sliding expiry: bump every few minutes, not on every request.
|
|
if (now - stored.lastSeenAt > 60_000) {
|
|
stored.lastSeenAt = now;
|
|
const ttl = (stored.remember ? config.sessionRememberTtl : config.sessionTtl) * 1000;
|
|
stored.expiresAt = now + ttl;
|
|
this.scheduleSave();
|
|
}
|
|
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();
|
|
}
|
|
|
|
destroyAllForUser(username: string, exceptId?: string): number {
|
|
let n = 0;
|
|
for (const [id, s] of this.sessions) {
|
|
if (s.username === username && id !== exceptId) {
|
|
this.sessions.delete(id);
|
|
n++;
|
|
}
|
|
}
|
|
if (n) this.scheduleSave();
|
|
return n;
|
|
}
|
|
|
|
listForUser(username: string): Array<Omit<StoredSession, "secretHash" | "salt" | "sealedCredentials">> {
|
|
const out = [];
|
|
for (const s of this.sessions.values()) {
|
|
if (s.username !== username) continue;
|
|
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
|
|
out.push(rest);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private toLive(s: StoredSession, username: string, password: string): LiveSession {
|
|
return {
|
|
id: s.id,
|
|
username,
|
|
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
|
remember: s.remember,
|
|
createdAt: s.createdAt,
|
|
lastSeenAt: s.lastSeenAt,
|
|
expiresAt: s.expiresAt,
|
|
userAgent: s.userAgent,
|
|
ip: s.ip,
|
|
};
|
|
}
|
|
}
|