mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
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.
60 lines
2.1 KiB
TypeScript
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;
|
|
}
|