diff --git a/src/background/state.ts b/src/background/state.ts index 0a2831c..b2ab1fb 100644 --- a/src/background/state.ts +++ b/src/background/state.ts @@ -1,5 +1,6 @@ import type { Policy, AttributeCon, Badge, CryptoPopupInitData, CryptoPopupResult } from "../lib/types"; import { EMAIL_ATTRIBUTE_TYPE } from "../lib/utils"; +import { encryptString, decryptString, type EncryptedBlob } from "./token-crypto"; export type { Policy, AttributeCon }; @@ -147,6 +148,11 @@ export function cleanupDecryptedMessage(msgId: number): void { // still query the upload session via `resumeUpload(uuid, recoveryToken)`. // Records are cleared on successful send (or popup error) and pruned on // startup against `MAX_IN_FLIGHT_AGE_MS`. +// +// The `recoveryToken` is a session credential and must never touch disk in +// cleartext. The in-memory `InFlightUpload` holds the +// plaintext token (needed to call `resumeUpload`), but the *persisted* form +// (`PersistedInFlightUpload`) stores it AES-GCM-encrypted via `token-crypto`. export interface InFlightUpload { uuid: string; @@ -156,6 +162,21 @@ export interface InFlightUpload { startedAt: number; } +/** On-disk shape written to `storage.local`: the credential is encrypted, + * while `uuid`/`startedAt` stay in the clear so stale records can be pruned + * without a decrypt (and `uuid` alone is useless without the token). */ +interface PersistedInFlightUpload { + uuid: string; + /** Encrypted `recoveryToken`. Optional so records written by pre-fix + * versions (plaintext `recoveryToken`) can still be read once on upgrade. */ + token?: EncryptedBlob; + /** @deprecated Legacy plaintext token from builds before the credential was + * encrypted at rest. Read once on upgrade, then re-persisted encrypted; + * never written. */ + recoveryToken?: string; + startedAt: number; +} + export const inFlightUploads = new Map(); const IN_FLIGHT_KEY = "inFlightUploads"; @@ -170,9 +191,13 @@ export async function persistInFlightUploads(): Promise { await browser.storage.local.remove(IN_FLIGHT_KEY); return; } - const out: Record = {}; + const out: Record = {}; for (const [tabId, record] of inFlightUploads) { - out[String(tabId)] = record; + out[String(tabId)] = { + uuid: record.uuid, + token: await encryptString(record.recoveryToken), + startedAt: record.startedAt, + }; } await browser.storage.local.set({ [IN_FLIGHT_KEY]: out }); } @@ -184,14 +209,19 @@ export async function persistInFlightUploads(): Promise { export async function loadInFlightUploads(): Promise> { try { const data = await browser.storage.local.get(IN_FLIGHT_KEY); - const saved = data[IN_FLIGHT_KEY] as Record | undefined; + const saved = data[IN_FLIGHT_KEY] as Record | undefined; if (!saved) return []; const now = Date.now(); const fresh: Array<{ tabId: number; record: InFlightUpload }> = []; - for (const [tabIdStr, record] of Object.entries(saved)) { - if (now - record.startedAt <= MAX_IN_FLIGHT_AGE_MS) { - fresh.push({ tabId: Number(tabIdStr), record }); - } + for (const [tabIdStr, persisted] of Object.entries(saved)) { + // Prune stale records before spending any crypto work on them. + if (now - persisted.startedAt > MAX_IN_FLIGHT_AGE_MS) continue; + const recoveryToken = await recoverToken(persisted); + if (recoveryToken == null) continue; // undecryptable / malformed — drop it + fresh.push({ + tabId: Number(tabIdStr), + record: { uuid: persisted.uuid, recoveryToken, startedAt: persisted.startedAt }, + }); } return fresh; } catch (e) { @@ -200,6 +230,25 @@ export async function loadInFlightUploads(): Promise { + if (persisted.token) { + try { + return await decryptString(persisted.token); + } catch (e) { + console.warn("[PostGuard] Failed to decrypt in-flight upload token; dropping record:", e); + return null; + } + } + // Legacy plaintext record from before the credential was encrypted at rest: + // read it once so an active session survives the upgrade; the next persist + // re-writes it encrypted. + return typeof persisted.recoveryToken === "string" ? persisted.recoveryToken : null; +} + export function recordInFlightUpload(tabId: number, uuid: string, recoveryToken: string): void { inFlightUploads.set(tabId, { uuid, recoveryToken, startedAt: Date.now() }); } diff --git a/src/background/token-crypto.ts b/src/background/token-crypto.ts new file mode 100644 index 0000000..0244774 --- /dev/null +++ b/src/background/token-crypto.ts @@ -0,0 +1,137 @@ +// AES-GCM encryption-at-rest for short-lived upload-session credentials. +// +// Short-lived upload credentials should not be persisted to disk in the clear, +// so the credential is wrapped with an AES-GCM key before it is stored. +// +// The token is wrapped with an AES-GCM key that is: +// * generated once per installation, +// * marked NON-EXTRACTABLE, so JavaScript can never export the raw key +// bytes, and +// * kept in IndexedDB — a different storage backend than the credential +// store itself. +// +// This is a proportionate mitigation for a local-access, short-lived (≤24h) +// credential; it is not a claim of protection against an attacker who can +// already read and fully reverse-engineer the profile, since Thunderbird +// exposes no OS keystore to extensions. + +/** Ciphertext envelope stored in place of the plaintext token. */ +export interface EncryptedBlob { + /** AES-GCM IV (12 random bytes), base64-encoded. */ + iv: string; + /** Ciphertext + auth tag, base64-encoded. */ + data: string; +} + +const DB_NAME = "postguard-keystore"; +const STORE_NAME = "keys"; +const KEY_ID = "in-flight-token-key"; + +// Memoised so we don't reopen IndexedDB on every persist/load. Reset to +// undefined if key acquisition fails so a later call can retry. +let cachedKey: Promise | undefined; +// Ephemeral fallback used only when no IndexedDB backend exists (e.g. under +// vitest in Node). Encryption still round-trips within a single session. +let memoryKey: CryptoKey | undefined; + +function generateKey(): Promise { + return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [ + "encrypt", + "decrypt", + ]); +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function idbGet(db: IDBDatabase, key: string): Promise { + return new Promise((resolve, reject) => { + const req = db.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function idbPut(db: IDBDatabase, key: string, value: unknown): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function loadOrCreateKey(): Promise { + // No persistent keystore (non-browser env / tests): fall back to an + // in-memory key so encryption still works, without touching disk. + if (typeof indexedDB === "undefined") { + if (!memoryKey) memoryKey = await generateKey(); + return memoryKey; + } + const db = await openDb(); + try { + const existing = (await idbGet(db, KEY_ID)) as CryptoKey | undefined; + // A stored CryptoKey survives structured-clone round-trips; guard against + // anything else that might have been left in the store. + if (existing instanceof CryptoKey) return existing; + const key = await generateKey(); + await idbPut(db, KEY_ID, key); + return key; + } finally { + db.close(); + } +} + +function getKey(): Promise { + if (!cachedKey) { + cachedKey = loadOrCreateKey().catch((e) => { + cachedKey = undefined; // allow a later retry + throw e; + }); + } + return cachedKey; +} + +function toBase64(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s); +} + +function fromBase64(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +/** Encrypt a token into a storable envelope. */ +export async function encryptString(plaintext: string): Promise { + const key = await getKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode(plaintext), + ); + return { iv: toBase64(iv), data: toBase64(new Uint8Array(ct)) }; +} + +/** Decrypt an envelope produced by {@link encryptString}. Throws if the blob + * was written under a different key (e.g. the keystore was cleared) or is + * malformed — callers treat that as a dropped record. */ +export async function decryptString(blob: EncryptedBlob): Promise { + const key = await getKey(); + const pt = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: fromBase64(blob.iv) }, + key, + fromBase64(blob.data), + ); + return new TextDecoder().decode(pt); +} diff --git a/tests/in-flight-uploads.test.ts b/tests/in-flight-uploads.test.ts index 6d851b1..ce60308 100644 --- a/tests/in-flight-uploads.test.ts +++ b/tests/in-flight-uploads.test.ts @@ -66,10 +66,36 @@ describe("in-flight upload persistence", () => { mod.recordInFlightUpload(1, "uuid-1", "tok-1"); mod.recordInFlightUpload(2, "uuid-2", "tok-2"); await mod.persistInFlightUploads(); - const saved = store.inFlightUploads as Record; + const saved = store.inFlightUploads as Record< + string, + { uuid: string; token: { iv: string; data: string }; startedAt: number } + >; expect(Object.keys(saved)).toEqual(expect.arrayContaining(["1", "2"])); expect(saved["1"].uuid).toBe("uuid-1"); - expect(saved["2"].recoveryToken).toBe("tok-2"); + expect(typeof saved["1"].startedAt).toBe("number"); + }); + + it("persistInFlightUploads never writes the recovery token in cleartext", async () => { + const mod = await loadModule(); + mod.recordInFlightUpload(1, "uuid-1", "super-secret-token"); + await mod.persistInFlightUploads(); + // The whole persisted payload must not contain the plaintext token, + // and there must be no `recoveryToken` field on disk at all. + const serialized = JSON.stringify(store.inFlightUploads); + expect(serialized).not.toContain("super-secret-token"); + const saved = store.inFlightUploads as Record>; + expect(saved["1"].recoveryToken).toBeUndefined(); + expect(saved["1"].token).toMatchObject({ iv: expect.any(String), data: expect.any(String) }); + }); + + it("persist → load round-trips the encrypted token back to plaintext", async () => { + const mod = await loadModule(); + mod.recordInFlightUpload(7, "uuid-7", "tok-7"); + await mod.persistInFlightUploads(); + const records = await mod.loadInFlightUploads(); + expect(records).toEqual([ + { tabId: 7, record: expect.objectContaining({ uuid: "uuid-7", recoveryToken: "tok-7" }) }, + ]); }); it("persistInFlightUploads removes the storage key when the map is empty", async () => { @@ -82,7 +108,10 @@ describe("in-flight upload persistence", () => { expect(store.inFlightUploads).toBeUndefined(); }); - it("loadInFlightUploads returns persisted records that are within the freshness window", async () => { + it("loadInFlightUploads reads legacy plaintext records written by pre-fix builds", async () => { + // Records persisted before the credential was encrypted at rest have a + // cleartext `recoveryToken` and no encrypted `token`. They must still load once so + // an active session survives the upgrade (then get re-persisted encrypted). const mod = await loadModule(); const now = Date.now(); store.inFlightUploads = { @@ -104,6 +133,21 @@ describe("in-flight upload persistence", () => { expect(records).toEqual([]); }); + it("loadInFlightUploads drops a record whose encrypted token cannot be decrypted", async () => { + // e.g. the keystore was cleared, or the blob was tampered with. Dropping + // the (short-lived) record must not crash the startup probe. + const mod = await loadModule(); + store.inFlightUploads = { + "3": { + uuid: "uuid-bad", + token: { iv: "AAAAAAAAAAAAAAAA", data: "AAAAAAAAAAAAAAAAAAAAAA==" }, + startedAt: Date.now() - 1000, + }, + }; + const records = await mod.loadInFlightUploads(); + expect(records).toEqual([]); + }); + it("loadInFlightUploads returns [] when nothing is persisted", async () => { const mod = await loadModule(); const records = await mod.loadInFlightUploads();