Ask whose computer this is, and believe the answer

Sign-out never cleared local storage. It stopped push, flushed settings and
removed the subscription -- that last one reasoned explicitly that a browser
left holding someone's mail becomes somebody else's next -- and then left the
settings cache and the recently-addressed list on disk. That list is other
people's addresses, and nothing ever removed it.

Clearing it on sign-out is now unconditional, because lending a laptop is the
same exposure as a public machine, only quieter. The keep-list is short and
deliberate: lastUser, which only a trusted device writes; the trust flag; and
the random push device id. Everything else goes, so a key added later is
forgotten by default rather than by nobody having thought about it.

"Keep me signed in on this device" defaulted to true, which assumed the answer
most costly to get wrong -- someone on a library machine got a thirty-day
cookie unless they noticed a ticked box. It now asks whose computer this is,
defaults to not yours, and says what each answer does. Untrusted means a
session cookie, nothing written locally, no push subscription, and a five
minute idle sign-out.

The idle timer is there because the alternative does not work: custom
beforeunload text was removed from browsers years ago, and no event fires at
all for walking away from a signed-in screen, which is the case that matters.
A timer needs nobody's cooperation.

Reads are gated as well as writes, since a machine trusted once still has the
residue; an untrusted sign-in purges it outright. The wire keeps calling this
`remember` -- it is persisted in SESSION_FILE, and renaming it would invalidate
every session file on upgrade for a change of vocabulary.

Verified in a browser against the mock, not only in tests: untrusted sign-in
leaves localStorage empty through a full session including folder expansion;
trusted writes settings, recent and lastUser as before; sign-out clears recent
and settings while keeping lastUser; an untrusted sign-in afterwards clears
even that.
This commit is contained in:
2026-08-28 14:15:15 -07:00
parent 045dda109b
commit 0b01956535
10 changed files with 361 additions and 9 deletions
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { startIdleLogout, stopIdleLogout, IDLE_TIMEOUT_MS } from "@/lib/idleLogout";
describe("idle sign-out on an untrusted device", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
stopIdleLogout();
vi.useRealTimers();
});
it("signs out after five minutes of nothing happening", () => {
const expire = vi.fn();
startIdleLogout(expire);
expect(IDLE_TIMEOUT_MS).toBe(5 * 60 * 1000);
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1);
expect(expire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(expire).toHaveBeenCalledTimes(1);
});
it("starts the clock again on any sign of a person", () => {
const expire = vi.fn();
startIdleLogout(expire);
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
window.dispatchEvent(new Event("keydown"));
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
expect(expire).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(expire).toHaveBeenCalledTimes(1);
});
it("fires once, not repeatedly, and stops listening afterwards", () => {
const expire = vi.fn();
startIdleLogout(expire);
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 3);
expect(expire).toHaveBeenCalledTimes(1);
// A late event must not resurrect a timer for a session that has ended.
window.dispatchEvent(new Event("keydown"));
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
expect(expire).toHaveBeenCalledTimes(1);
});
it("stops cleanly, so a trusted sign-in is never signed out", () => {
const expire = vi.fn();
startIdleLogout(expire);
stopIdleLogout();
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
expect(expire).not.toHaveBeenCalled();
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
accountKey,
clearAllData,
clearSignedInData,
isDeviceTrusted,
loadJson,
loadRaw,
saveJson,
setDeviceTrusted,
} from "@/lib/storage";
/**
* The gate is a privacy boundary rather than a convenience, so it is tested
* from both sides: that a trusted device still works exactly as it did, and
* that an untrusted one leaves nothing to find.
*/
describe("device-trusted storage", () => {
let store: Map<string, string>;
beforeEach(() => {
store = new Map();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
get length() {
return store.size;
},
key: (i: number) => [...store.keys()][i] ?? null,
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
setDeviceTrusted(false);
});
afterEach(() => {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage");
});
it("writes nothing at all when the device is not trusted", () => {
saveJson("settings", { theme: "dark" });
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
expect([...store.keys()].filter((k) => k !== "ihasmail:deviceTrusted")).toEqual([]);
});
it("does not read residue left by an earlier trusted session", () => {
setDeviceTrusted(true);
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
setDeviceTrusted(false);
// The bytes are still on disk until a purge; the gate must not serve them.
expect(loadRaw(accountKey("acct1", "recent"), [])).toEqual([]);
});
it("round-trips normally on a trusted device", () => {
setDeviceTrusted(true);
saveJson("settings", { theme: "dark" });
expect(loadJson("settings", { theme: "light", accent: "blue" })).toEqual({ theme: "dark", accent: "blue" });
expect(isDeviceTrusted()).toBe(true);
});
it("remembers trust across a reload, so a trusted device still paints from cache", () => {
setDeviceTrusted(true);
expect(store.get("ihasmail:deviceTrusted")).toBe("1");
setDeviceTrusted(false);
expect(store.has("ihasmail:deviceTrusted")).toBe(false);
});
it("clears the account's data on sign-out but keeps the deliberate exceptions", () => {
setDeviceTrusted(true);
saveJson("settings", { theme: "dark" });
saveJson("mbx-expanded", { a: true });
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
store.set("ihasmail:lastUser", "[email protected]");
store.set("ihasmail:pushDeviceId", "ihasmail-abc");
clearSignedInData();
expect(store.has("ihasmail:settings")).toBe(false);
expect(store.has("ihasmail:mbx-expanded")).toBe(false);
expect(store.has("ihasmail:acct1:recent")).toBe(false);
// Kept on purpose: prefills sign-in, and only a trusted device wrote it.
expect(store.get("ihasmail:lastUser")).toBe("[email protected]");
expect(store.get("ihasmail:pushDeviceId")).toBe("ihasmail-abc");
});
it("clears everything, lastUser included, for an untrusted sign-in", () => {
setDeviceTrusted(true);
saveJson("settings", { theme: "dark" });
store.set("ihasmail:lastUser", "[email protected]");
clearAllData();
expect([...store.keys()]).toEqual([]);
});
it("leaves keys belonging to anything else alone", () => {
setDeviceTrusted(true);
store.set("someone-elses-key", "keep me");
clearAllData();
expect(store.get("someone-elses-key")).toBe("keep me");
});
});
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
import { loadJson, saveJson } from "@/lib/storage";
import { loadJson, saveJson, setDeviceTrusted } from "@/lib/storage";
/**
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
@@ -60,9 +60,14 @@ describe("the default theme", () => {
removeItem: (k: string) => void store.delete(k),
},
});
// Reads and writes are gated on device trust now, and the gate defaults to
// closed. These tests are about `loadJson`'s merge, so open it and put it
// back -- an untrusted device is covered by storage.test.ts instead.
setDeviceTrusted(true);
try {
fn();
} finally {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage");
}
};
+59
View File
@@ -0,0 +1,59 @@
/**
* Sign out an untrusted device after a few minutes of inactivity.
*
* This exists because the alternative does not work. Asking someone to
* remember to sign out relies on the person, which is the part you cannot rely
* on when the machine is not theirs — and a browser cannot help: custom
* `beforeunload` text was removed years ago, and no event fires at all for the
* case that actually matters, which is walking away from a signed-in screen.
*
* A timer needs nobody's cooperation, so that is what this is.
*
* Trusted devices are left alone entirely: the whole point of saying a machine
* is yours is not being signed out of it.
*/
const IDLE_MS = 5 * 60 * 1000;
/** Coarse enough not to fire constantly, broad enough to catch a person reading. */
const ACTIVITY = ["mousedown", "keydown", "touchstart", "scroll", "focus"] as const;
let timer: ReturnType<typeof setTimeout> | null = null;
let onExpire: (() => void) | null = null;
function arm(): void {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const fn = onExpire;
stopIdleLogout();
fn?.();
}, IDLE_MS);
}
/**
* Reading a long message is not idleness, but it produces no events either.
* Visibility is the honest signal available: a hidden tab is one nobody is
* looking at, so the clock keeps running; showing it again is activity.
*/
function onVisibility(): void {
if (document.visibilityState === "visible") arm();
}
export function startIdleLogout(expire: () => void): void {
stopIdleLogout();
onExpire = expire;
for (const ev of ACTIVITY) window.addEventListener(ev, arm, { passive: true, capture: true });
document.addEventListener("visibilitychange", onVisibility);
arm();
}
export function stopIdleLogout(): void {
if (timer) clearTimeout(timer);
timer = null;
onExpire = null;
for (const ev of ACTIVITY) window.removeEventListener(ev, arm, { capture: true });
document.removeEventListener("visibilitychange", onVisibility);
}
/** Exported for tests, which should not wait five real minutes. */
export const IDLE_TIMEOUT_MS = IDLE_MS;
+89
View File
@@ -1,6 +1,93 @@
/**
* Local storage, gated on whether this device is trusted.
*
* Everything here is a *cache* or a screen preference — the real copy lives in
* the account's JMAP Files (see `settingsSync`). That makes it safe to write
* nothing at all, which is what an untrusted device does: on a shared or public
* machine the cost of a stale first frame is nothing beside leaving someone's
* address book on it.
*
* Reads are gated as well as writes. A machine that was trusted once still has
* the residue, and honouring it would let a previous session's data surface in
* a later untrusted one.
*/
const PREFIX = "ihasmail:";
/**
* Kept when a session ends. Everything else is cleared, so a key added later
* is forgotten by default rather than by nobody having thought about it.
*
* - `lastUser` is a deliberate convenience: it prefills the sign-in field, and
* it is only ever written by a trusted device in the first place.
* - `deviceTrusted` is how the next boot knows to read at all.
* - `pushDeviceId` is a random id for this browser, so re-subscribing replaces
* rather than accumulates. The subscription itself is removed on sign-out.
*/
const KEEP_ON_SIGN_OUT = ["lastUser", "deviceTrusted", "pushDeviceId"];
const TRUST_KEY = `${PREFIX}deviceTrusted`;
/**
* Read at module load rather than waiting for the session, so a trusted device
* still paints its first frame from cache. An untrusted one has nothing to
* read, so there is nothing to wait for.
*/
let trusted = (() => {
try {
return localStorage.getItem(TRUST_KEY) === "1";
} catch {
return false;
}
})();
export function isDeviceTrusted(): boolean {
return trusted;
}
/** Set from the session's `remember` flag, which is the answer given at sign-in. */
export function setDeviceTrusted(value: boolean): void {
trusted = value;
try {
if (value) localStorage.setItem(TRUST_KEY, "1");
else localStorage.removeItem(TRUST_KEY);
} catch {
/* private mode: the in-memory flag still holds for this tab */
}
}
/** Every `ihasmail:` key currently present, without the prefix. */
function ownKeys(): string[] {
const out: string[] = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(PREFIX)) out.push(k.slice(PREFIX.length));
}
} catch {
/* ignore */
}
return out;
}
/**
* Drop what this browser was holding for a signed-in account. Called on every
* sign-out, trusted or not: handing a laptop to someone else is the same
* exposure as a public machine, only quieter.
*/
export function clearSignedInData(): void {
for (const key of ownKeys()) {
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
removeKey(key);
}
}
/** Everything, `lastUser` included — for signing in to a device we do not trust. */
export function clearAllData(): void {
for (const key of ownKeys()) removeKey(key);
}
export function loadJson<T>(key: string, fallback: T): T {
if (!trusted) return fallback;
try {
const raw = localStorage.getItem(PREFIX + key);
if (raw == null) return fallback;
@@ -11,6 +98,7 @@ export function loadJson<T>(key: string, fallback: T): T {
}
export function loadRaw<T>(key: string, fallback: T): T {
if (!trusted) return fallback;
try {
const raw = localStorage.getItem(PREFIX + key);
if (raw == null) return fallback;
@@ -21,6 +109,7 @@ export function loadRaw<T>(key: string, fallback: T): T {
}
export function saveJson(key: string, value: unknown): void {
if (!trusted) return;
try {
localStorage.setItem(PREFIX + key, JSON.stringify(value));
} catch {
+5
View File
@@ -19,6 +19,7 @@
*/
import { CAP, client } from "@/jmap/client";
import type { GetResponse, Id, SetResponse } from "@/jmap/types";
import { isDeviceTrusted } from "@/lib/storage";
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
@@ -93,6 +94,10 @@ export function encodeKey(buffer: ArrayBuffer | null): string {
/** A stable id for this browser, so a re-subscribe replaces rather than piles up. */
export function deviceClientId(): string {
const KEY = "ihasmail:pushDeviceId";
// An untrusted device gets a per-session id instead of a stored one. It is
// the same trade private mode already makes below: re-subscribing will not
// reuse it, which costs nothing when push is refused there anyway.
if (!isDeviceTrusted()) return `ihasmail-${crypto.randomUUID()}`;
try {
const existing = localStorage.getItem(KEY);
if (existing) return existing;
+7
View File
@@ -6,6 +6,7 @@
* permission prompt, none of which exists under a test runner.
*/
import { CAP } from "@/jmap/client";
import { isDeviceTrusted } from "@/lib/storage";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import {
@@ -67,6 +68,12 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
if (Notification.permission === "denied") {
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." };
}
// A subscription outlives the tab and belongs to the account, not the
// session -- so on a machine the user has told us is not theirs, it would go
// on delivering their mail to it long after they had gone.
if (!isDeviceTrusted()) {
return { ok: false, reason: "Background notifications need a device you have marked as your own. Sign in again with \u201CThis is my own device\u201D ticked." };
}
const key = applicationServerKey();
if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
+3 -2
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
@@ -441,7 +442,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const next = [...addrs.filter((a) => a.email), ...cur.filter((r) => !addrs.some((a) => a.email.toLowerCase() === r.email.toLowerCase()))].slice(0, 200);
set({ recent: next });
try {
localStorage.setItem(`ihasmail:${get().accountId}:recent`, JSON.stringify(next));
saveJson(accountKey(get().accountId, "recent"), next);
} catch {
/* ignore */
}
@@ -466,7 +467,7 @@ useSession.subscribe((s) => {
const accountId = s.accountFor(CAP.contacts);
let recent: EmailAddress[] = [];
try {
recent = JSON.parse(localStorage.getItem(`ihasmail:${accountId}:recent`) ?? "[]") as EmailAddress[];
recent = loadRaw<EmailAddress[]>(accountKey(accountId, "recent"), []);
} catch {
/* ignore */
}
+22
View File
@@ -7,6 +7,8 @@ import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
import { reloadIfServerRebuilt } from "@/lib/staleBuild";
import { unsubscribeThisDevice } from "@/lib/webpush";
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -81,6 +83,11 @@ export const useSession = create<SessionState>((set, get) => ({
} catch {
/* ignore */
}
stopIdleLogout();
// Unconditional. The push subscription above is removed for exactly this
// reason -- that a browser left holding someone's mail is somebody else's
// problem next -- and the address book cached here is the same argument.
clearSignedInData();
client.session = null;
set({ status: "anonymous", session: null, accountId: null });
},
@@ -112,6 +119,19 @@ export const useSession = create<SessionState>((set, get) => ({
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.session = s;
setServerLocale(s.ihasmail?.userLocale);
// `remember` is the answer to "is this device yours", given at sign-in and
// carried on the session -- so a reload arrives at the same answer without
// the client storing it, which on an untrusted device it could not do anyway.
const trusted = Boolean(s.ihasmail?.remember);
setDeviceTrusted(trusted);
if (trusted) {
stopIdleLogout();
} else {
// Residue from an earlier trusted session on this machine is exactly what
// an untrusted sign-in is asking us not to keep.
clearAllData();
startIdleLogout(() => void useSession.getState().logout());
}
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
set({ status: "authenticated", session: s, accountId, error: null });
}
@@ -119,6 +139,8 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.onUnauthenticated(() => {
push.stop();
stopSettingsSync();
stopIdleLogout();
clearSignedInData();
client.session = null;
// Ask before showing the sign-in form rather than after. A deploy is the
// usual reason to be signed out here, and reloading a form someone has
+11 -6
View File
@@ -22,7 +22,7 @@ export function LoginPage() {
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(true);
const [trustDevice, setTrustDevice] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -34,8 +34,8 @@ export function LoginPage() {
try {
// No two-factor code: the field is not on this form until the flow works
// end to end, and the server treats an absent code as none given.
await login(username.trim(), password, "", remember);
localStorage.setItem("ihasmail:lastUser", username.trim());
await login(username.trim(), password, "", trustDevice);
if (trustDevice) localStorage.setItem("ihasmail:lastUser", username.trim());
} catch (err) {
if (err instanceof ApiError) {
if (err.code === "invalid_credentials") {
@@ -74,10 +74,15 @@ export function LoginPage() {
</button>
</div>
</div>
<label className="check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
<span>Keep me signed in on this device</span>
<label className="check" style={{ marginBottom: 4 }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
<span>This is my own device</span>
</label>
<p className="hint" style={{ marginBottom: 12 }}>
{trustDevice
? "Stay signed in, and keep settings and recent addresses on this computer."
: "Signed out after 5 minutes of inactivity, and nothing is kept on this computer. Leave this unticked on a shared or public one."}
</p>
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
{busy ? "Signing in…" : "Sign in"}