Skip to content
This repository was archived by the owner on Jul 30, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 56 additions & 7 deletions src/background/state.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -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;
Expand All @@ -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<number, InFlightUpload>();

const IN_FLIGHT_KEY = "inFlightUploads";
Expand All @@ -170,9 +191,13 @@ export async function persistInFlightUploads(): Promise<void> {
await browser.storage.local.remove(IN_FLIGHT_KEY);
return;
}
const out: Record<string, InFlightUpload> = {};
const out: Record<string, PersistedInFlightUpload> = {};
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 });
}
Expand All @@ -184,14 +209,19 @@ export async function persistInFlightUploads(): Promise<void> {
export async function loadInFlightUploads(): Promise<Array<{ tabId: number; record: InFlightUpload }>> {
try {
const data = await browser.storage.local.get(IN_FLIGHT_KEY);
const saved = data[IN_FLIGHT_KEY] as Record<string, InFlightUpload> | undefined;
const saved = data[IN_FLIGHT_KEY] as Record<string, PersistedInFlightUpload> | 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) {
Expand All @@ -200,6 +230,25 @@ export async function loadInFlightUploads(): Promise<Array<{ tabId: number; reco
}
}

/** Recover the plaintext token from a persisted record: decrypt the encrypted
* envelope, or accept a legacy plaintext token written by a pre-fix build.
* Returns null if the token can't be recovered so the caller drops the
* (short-lived, ≤24h) record rather than crashing the startup probe. */
async function recoverToken(persisted: PersistedInFlightUpload): Promise<string | null> {
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() });
}
Expand Down
137 changes: 137 additions & 0 deletions src/background/token-crypto.ts
Original file line number Diff line number Diff line change
@@ -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<CryptoKey> | 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<CryptoKey> {
return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [
"encrypt",
"decrypt",
]);
}

function openDb(): Promise<IDBDatabase> {
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<unknown> {
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<void> {
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<CryptoKey> {
// 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<CryptoKey> {
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<ArrayBuffer> {
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<EncryptedBlob> {
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<string> {
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);
}
50 changes: 47 additions & 3 deletions tests/in-flight-uploads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { uuid: string; recoveryToken: string; startedAt: number }>;
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<string, Record<string, unknown>>;
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 () => {
Expand All @@ -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 = {
Expand All @@ -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();
Expand Down