Files
webmail/lib/offline/crypto.ts
T
Linus Rath db5ac82f53 feat: keep recent mail on this device and send mail written offline #523
Settings > Backup & reset gets "Keep mail on this device", off until the
user turns it on in this browser. While it is on:

- the JMAP client hands the server's answers to an encrypted cache in
  IndexedDB (AES-GCM, non-extractable key) and answers from it when the
  network is gone: the folder list, the last 30 days of the Inbox and of
  the folders the user opened, bodies and attachments, at most 250 MB,
  oldest out first; a background sync keeps it current
- read, flag, tags and moves made offline are queued and sent in order
  when the server answers again; deleting for good and emptying a folder
  are refused offline
- the composer, quick reply and the S/MIME and PGP plugins' sends that
  complete the composer's go to an outbox once every step up to the
  transport ran (checks, label, signature, plugin send checks); only the
  finished send waits, with files attached offline
- the service worker keeps this build's app shell, so the app also
  starts offline from the kept session and the last policy
- a full sign-out, the idle sign-out and signing one account out delete
  it, a sign-out the user starts asks first when mail is unsent, and the
  login page names what an automatic sign-out deleted; a policy fetch
  that forbids it (devices.noOffline) wipes that account

Not in Lite.
2026-11-01 11:46:11 +01:00

60 lines
2.1 KiB
TypeScript

/**
* Encryption at rest for offline mode (#523): AES-GCM with a 256-bit key
* the browser generates as non-extractable and keeps in the offline
* database. Script on this origin can't read the key's bytes, only use it.
*
* What it protects against: someone who copies the browser profile's files
* (a backup, a stolen disk image) without being able to run this site in
* that profile. What it doesn't: someone using the unlocked browser profile,
* or a script running on this origin, can decrypt everything. The docs say
* so and recommend disk encryption.
*/
import { idbGet, idbWrite } from './db';
export interface Sealed {
iv: Uint8Array;
data: ArrayBuffer;
}
let keyPromise: Promise<CryptoKey> | null = null;
async function loadOrCreateKey(): Promise<CryptoKey> {
const stored = await idbGet<{ id: string; key: CryptoKey }>('keys', 'aes');
if (stored?.key) return stored.key;
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
await idbWrite([{ store: 'keys', put: { id: 'aes', key } }]);
return key;
}
function deviceKey(): Promise<CryptoKey> {
if (!keyPromise) {
keyPromise = loadOrCreateKey();
keyPromise.catch(() => { keyPromise = null; });
}
return keyPromise;
}
/** Forget the key in memory (the database with it was deleted). */
export function forgetDeviceKey(): void {
keyPromise = null;
}
export async function sealBytes(bytes: ArrayBuffer | Uint8Array): Promise<Sealed> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await deviceKey(), bytes as BufferSource);
return { iv, data };
}
export async function openBytes(sealed: Sealed): Promise<ArrayBuffer> {
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.iv as BufferSource }, await deviceKey(), sealed.data);
}
export async function sealJson(value: unknown): Promise<Sealed> {
return sealBytes(new TextEncoder().encode(JSON.stringify(value)));
}
export async function openJson<T>(sealed: Sealed): Promise<T> {
return JSON.parse(new TextDecoder().decode(await openBytes(sealed))) as T;
}