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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user