Merge pull request #123 from LINUXexpert-org/untrusted-device-mode

Ask whose computer this is, and believe the answer
This commit is contained in:
LINUXexpert.org
2026-08-28 14:20:40 -07:00
committed by GitHub
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 { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings"; 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 * "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), 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 { try {
fn(); fn();
} finally { } finally {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage"); 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:"; 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 { export function loadJson<T>(key: string, fallback: T): T {
if (!trusted) return fallback;
try { try {
const raw = localStorage.getItem(PREFIX + key); const raw = localStorage.getItem(PREFIX + key);
if (raw == null) return fallback; 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 { export function loadRaw<T>(key: string, fallback: T): T {
if (!trusted) return fallback;
try { try {
const raw = localStorage.getItem(PREFIX + key); const raw = localStorage.getItem(PREFIX + key);
if (raw == null) return fallback; 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 { export function saveJson(key: string, value: unknown): void {
if (!trusted) return;
try { try {
localStorage.setItem(PREFIX + key, JSON.stringify(value)); localStorage.setItem(PREFIX + key, JSON.stringify(value));
} catch { } catch {
+5
View File
@@ -19,6 +19,7 @@
*/ */
import { CAP, client } from "@/jmap/client"; import { CAP, client } from "@/jmap/client";
import type { GetResponse, Id, SetResponse } from "@/jmap/types"; 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 VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush"; 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. */ /** A stable id for this browser, so a re-subscribe replaces rather than piles up. */
export function deviceClientId(): string { export function deviceClientId(): string {
const KEY = "ihasmail:pushDeviceId"; 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 { try {
const existing = localStorage.getItem(KEY); const existing = localStorage.getItem(KEY);
if (existing) return existing; if (existing) return existing;
+7
View File
@@ -6,6 +6,7 @@
* permission prompt, none of which exists under a test runner. * permission prompt, none of which exists under a test runner.
*/ */
import { CAP } from "@/jmap/client"; import { CAP } from "@/jmap/client";
import { isDeviceTrusted } from "@/lib/storage";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { import {
@@ -67,6 +68,12 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
if (Notification.permission === "denied") { if (Notification.permission === "denied") {
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." }; 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(); const key = applicationServerKey();
if (!key) return { ok: false, reason: "This mail server does not publish a push key." }; 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 { create } from "zustand";
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
import { CAP, client, setErrorMessage } from "@/jmap/client"; import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types"; import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts"; 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); 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 }); set({ recent: next });
try { try {
localStorage.setItem(`ihasmail:${get().accountId}:recent`, JSON.stringify(next)); saveJson(accountKey(get().accountId, "recent"), next);
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -466,7 +467,7 @@ useSession.subscribe((s) => {
const accountId = s.accountFor(CAP.contacts); const accountId = s.accountFor(CAP.contacts);
let recent: EmailAddress[] = []; let recent: EmailAddress[] = [];
try { try {
recent = JSON.parse(localStorage.getItem(`ihasmail:${accountId}:recent`) ?? "[]") as EmailAddress[]; recent = loadRaw<EmailAddress[]>(accountKey(accountId, "recent"), []);
} catch { } catch {
/* ignore */ /* ignore */
} }
+22
View File
@@ -7,6 +7,8 @@ import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync"; import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
import { reloadIfServerRebuilt } from "@/lib/staleBuild"; import { reloadIfServerRebuilt } from "@/lib/staleBuild";
import { unsubscribeThisDevice } from "@/lib/webpush"; import { unsubscribeThisDevice } from "@/lib/webpush";
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
export type AuthStatus = "loading" | "anonymous" | "authenticated"; export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -81,6 +83,11 @@ export const useSession = create<SessionState>((set, get) => ({
} catch { } catch {
/* ignore */ /* 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; client.session = null;
set({ status: "anonymous", session: null, accountId: 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) { function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.session = s; client.session = s;
setServerLocale(s.ihasmail?.userLocale); 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; const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
set({ status: "authenticated", session: s, accountId, error: null }); set({ status: "authenticated", session: s, accountId, error: null });
} }
@@ -119,6 +139,8 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.onUnauthenticated(() => { client.onUnauthenticated(() => {
push.stop(); push.stop();
stopSettingsSync(); stopSettingsSync();
stopIdleLogout();
clearSignedInData();
client.session = null; client.session = null;
// Ask before showing the sign-in form rather than after. A deploy is the // 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 // 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 [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false); const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(true); const [trustDevice, setTrustDevice] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -34,8 +34,8 @@ export function LoginPage() {
try { try {
// No two-factor code: the field is not on this form until the flow works // 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. // end to end, and the server treats an absent code as none given.
await login(username.trim(), password, "", remember); await login(username.trim(), password, "", trustDevice);
localStorage.setItem("ihasmail:lastUser", username.trim()); if (trustDevice) localStorage.setItem("ihasmail:lastUser", username.trim());
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
if (err.code === "invalid_credentials") { if (err.code === "invalid_credentials") {
@@ -74,10 +74,15 @@ export function LoginPage() {
</button> </button>
</div> </div>
</div> </div>
<label className="check" style={{ marginBottom: 12 }}> <label className="check" style={{ marginBottom: 4 }}>
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} /> <input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
<span>Keep me signed in on this device</span> <span>This is my own device</span>
</label> </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}> <button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />} {busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
{busy ? "Signing in…" : "Sign in"} {busy ? "Signing in…" : "Sign in"}