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.
132 lines
3.9 KiB
TypeScript
132 lines
3.9 KiB
TypeScript
/**
|
|
* 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;
|
|
return { ...fallback, ...(JSON.parse(raw) as T) };
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
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;
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export function saveJson(key: string, value: unknown): void {
|
|
if (!trusted) return;
|
|
try {
|
|
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
|
} catch {
|
|
/* quota exceeded or private mode */
|
|
}
|
|
}
|
|
|
|
export function removeKey(key: string): void {
|
|
try {
|
|
localStorage.removeItem(PREFIX + key);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
/** Namespaced per account so multiple logins on one browser don't collide. */
|
|
export function accountKey(accountId: string | null | undefined, key: string): string {
|
|
return `${accountId ?? "anon"}:${key}`;
|
|
}
|