Files
ihasmail/server/src/sessions.ts
T
jcoffey-dev 0f1fbcff93 Manage your own password, app passwords and 2FA
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.
2026-08-24 08:18:47 -07:00

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,
};
}
}