diff --git a/src/backend/utils/user-keys.test.ts b/src/backend/utils/user-keys.test.ts new file mode 100644 index 00000000..e2eafa4b --- /dev/null +++ b/src/backend/utils/user-keys.test.ts @@ -0,0 +1,161 @@ +import crypto from "crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const settingsStore = new Map(); + +vi.mock("../database/repositories/factory.js", () => ({ + getCurrentSettingValue: (key: string) => settingsStore.get(key) ?? null, + createCurrentSettingsRepository: () => ({ + upsert: async (key: string, value: string) => { + settingsStore.set(key, value); + }, + delete: async (key: string) => { + settingsStore.delete(key); + }, + }), +})); + +import { + UserKeyManager, + UserKeyUnavailableError, + userDekSettingsKey, +} from "./user-keys.js"; + +const masterKey = crypto.randomBytes(32); +const manager = UserKeyManager.getInstance(); + +function storedWrap(userId: string): Record { + return JSON.parse(settingsStore.get(userDekSettingsKey(userId))!); +} + +function putWrap(userId: string, wrap: Record): void { + settingsStore.set(userDekSettingsKey(userId), JSON.stringify(wrap)); +} + +beforeEach(async () => { + settingsStore.clear(); + await manager.initialize(masterKey); +}); + +describe("UserKeyManager", () => { + it("creates and round-trips a DEK", async () => { + const dek = await manager.createUserDEK("user-1"); + + expect(dek).toHaveLength(32); + expect(manager.hasUserDEK("user-1")).toBe(true); + + manager.clearCache(); + expect(manager.getUserDEK("user-1").equals(dek)).toBe(true); + }); + + it("refuses to create a second DEK for the same user", async () => { + await manager.createUserDEK("user-1"); + await expect(manager.createUserDEK("user-1")).rejects.toThrow( + /already has a data encryption key/, + ); + }); + + it("throws missing for a user with no wrap and no legacy rows", () => { + expect(() => manager.getUserDEK("ghost")).toThrow(UserKeyUnavailableError); + try { + manager.getUserDEK("ghost"); + } catch (error) { + expect((error as UserKeyUnavailableError).reason).toBe("missing"); + } + expect(manager.tryGetUserDEK("ghost")).toBeNull(); + }); + + it("throws pending_migration when only legacy wrap rows exist", () => { + settingsStore.set("user_encrypted_dek_legacy-user", "{}"); + try { + manager.getUserDEK("legacy-user"); + expect.unreachable(); + } catch (error) { + expect((error as UserKeyUnavailableError).reason).toBe( + "pending_migration", + ); + } + + settingsStore.clear(); + settingsStore.set("user_kek_salt_legacy-user", "{}"); + try { + manager.getUserDEK("legacy-user"); + expect.unreachable(); + } catch (error) { + expect((error as UserKeyUnavailableError).reason).toBe( + "pending_migration", + ); + } + }); + + it("fails to unwrap with a different master key", async () => { + const dek = await manager.createUserDEK("user-1"); + + await manager.initialize(crypto.randomBytes(32)); + expect(() => manager.getUserDEK("user-1")).toThrow(); + + await manager.initialize(masterKey); + expect(manager.getUserDEK("user-1").equals(dek)).toBe(true); + }); + + it("rejects tampered iv, ciphertext and tag", async () => { + await manager.createUserDEK("user-1"); + + for (const field of ["iv", "ct", "tag"] as const) { + const wrap = storedWrap("user-1"); + const bytes = Buffer.from(wrap[field] as string, "base64"); + bytes[0] ^= 0xff; + putWrap("user-1", { ...wrap, [field]: bytes.toString("base64") }); + manager.clearCache(); + expect(() => manager.getUserDEK("user-1")).toThrow(); + } + }); + + it("rejects a wrap moved to another user (AAD/info binding)", async () => { + await manager.createUserDEK("user-a"); + + putWrap("user-b", storedWrap("user-a")); + manager.clearCache(); + expect(() => manager.getUserDEK("user-b")).toThrow(); + }); + + it("rejects unknown wrap versions and algorithms", async () => { + await manager.createUserDEK("user-1"); + + const wrap = storedWrap("user-1"); + putWrap("user-1", { ...wrap, v: 99 }); + manager.clearCache(); + expect(() => manager.getUserDEK("user-1")).toThrow(/Unsupported key wrap/); + + putWrap("user-1", { ...wrap, alg: "aes-128-gcm" }); + manager.clearCache(); + expect(() => manager.getUserDEK("user-1")).toThrow(/Unsupported key wrap/); + }); + + it("persistDEK overwrites and rotateUserDEK replaces the key", async () => { + const original = await manager.createUserDEK("user-1"); + const rotated = await manager.rotateUserDEK("user-1"); + + expect(rotated.equals(original)).toBe(false); + manager.clearCache(); + expect(manager.getUserDEK("user-1").equals(rotated)).toBe(true); + }); + + it("deleteUserDEK removes the wrap and cached key", async () => { + await manager.createUserDEK("user-1"); + await manager.deleteUserDEK("user-1"); + + expect(manager.hasUserDEK("user-1")).toBe(false); + expect(manager.tryGetUserDEK("user-1")).toBeNull(); + }); + + it("serves cached DEKs without re-reading settings", async () => { + const dek = await manager.createUserDEK("user-1"); + + settingsStore.delete(userDekSettingsKey("user-1")); + expect(manager.getUserDEK("user-1").equals(dek)).toBe(true); + + manager.clearCache(); + expect(() => manager.getUserDEK("user-1")).toThrow(UserKeyUnavailableError); + }); +}); diff --git a/src/backend/utils/user-keys.ts b/src/backend/utils/user-keys.ts new file mode 100644 index 00000000..e33fd4a9 --- /dev/null +++ b/src/backend/utils/user-keys.ts @@ -0,0 +1,225 @@ +import crypto from "crypto"; +import { SystemCrypto } from "./system-crypto.js"; +import { + createCurrentSettingsRepository, + getCurrentSettingValue, +} from "../database/repositories/factory.js"; +import { authLogger } from "./logger.js"; + +const DEK_LENGTH = 32; +const WRAP_VERSION = 3; +const WRAP_ALGORITHM = "aes-256-gcm"; +const WRAP_INFO_PREFIX = "termix:dek-wrap:v3:"; +const CACHE_TTL_MS = 15 * 60 * 1000; + +export type UserKeyUnavailableReason = "pending_migration" | "missing"; + +export class UserKeyUnavailableError extends Error { + constructor( + readonly userId: string, + readonly reason: UserKeyUnavailableReason, + ) { + super( + reason === "pending_migration" + ? `User ${userId} data stays locked until their next login migrates their encryption key` + : `User ${userId} has no data encryption key`, + ); + this.name = "UserKeyUnavailableError"; + } +} + +interface WrappedDek { + v: number; + alg: string; + iv: string; + ct: string; + tag: string; + createdAt: string; +} + +interface CacheEntry { + dek: Buffer; + expiresAt: number; +} + +export function userDekSettingsKey(userId: string): string { + return `user_dek_v3_${userId}`; +} + +const LEGACY_WRAP_KEY_PREFIXES = [ + "user_encrypted_dek_", + "user_kek_salt_", +] as const; + +export class UserKeyManager { + private static instance: UserKeyManager | null = null; + + private masterKey: Buffer | null = null; + private cache = new Map(); + + static getInstance(): UserKeyManager { + if (!UserKeyManager.instance) { + UserKeyManager.instance = new UserKeyManager(); + } + return UserKeyManager.instance; + } + + async initialize(masterKey?: Buffer): Promise { + this.masterKey = + masterKey ?? (await SystemCrypto.getInstance().getEncryptionKey()); + this.cache.clear(); + } + + getUserDEK(userId: string): Buffer { + const cached = this.cache.get(userId); + if (cached && cached.expiresAt > Date.now()) { + return cached.dek; + } + + const raw = getCurrentSettingValue(userDekSettingsKey(userId)); + if (!raw) { + throw new UserKeyUnavailableError( + userId, + this.hasLegacyWrap(userId) ? "pending_migration" : "missing", + ); + } + + const dek = this.unwrap(userId, raw); + this.cacheDek(userId, dek); + return dek; + } + + tryGetUserDEK(userId: string): Buffer | null { + try { + return this.getUserDEK(userId); + } catch (error) { + if (error instanceof UserKeyUnavailableError) { + return null; + } + throw error; + } + } + + hasUserDEK(userId: string): boolean { + if (this.cache.has(userId)) return true; + return getCurrentSettingValue(userDekSettingsKey(userId)) !== null; + } + + async createUserDEK(userId: string): Promise { + if (this.hasUserDEK(userId)) { + throw new Error(`User ${userId} already has a data encryption key`); + } + + const dek = crypto.randomBytes(DEK_LENGTH); + await this.persistDEK(userId, dek); + return dek; + } + + async persistDEK(userId: string, dek: Buffer): Promise { + if (dek.length !== DEK_LENGTH) { + throw new Error(`DEK must be ${DEK_LENGTH} bytes`); + } + + const wrapped = this.wrap(userId, dek); + await createCurrentSettingsRepository().upsert( + userDekSettingsKey(userId), + JSON.stringify(wrapped), + ); + this.cacheDek(userId, dek); + } + + async rotateUserDEK(userId: string): Promise { + const dek = crypto.randomBytes(DEK_LENGTH); + await this.persistDEK(userId, dek); + authLogger.warn(`Replaced data encryption key for user ${userId}`, { + operation: "user_dek_rotated", + userId, + }); + return dek; + } + + async deleteUserDEK(userId: string): Promise { + await createCurrentSettingsRepository().delete(userDekSettingsKey(userId)); + this.invalidate(userId); + } + + invalidate(userId: string): void { + this.cache.delete(userId); + } + + clearCache(): void { + this.cache.clear(); + } + + private hasLegacyWrap(userId: string): boolean { + return LEGACY_WRAP_KEY_PREFIXES.some( + (prefix) => getCurrentSettingValue(`${prefix}${userId}`) !== null, + ); + } + + private cacheDek(userId: string, dek: Buffer): void { + this.cache.set(userId, { dek, expiresAt: Date.now() + CACHE_TTL_MS }); + } + + private deriveWrapKey(userId: string): Buffer { + if (!this.masterKey) { + throw new Error("UserKeyManager is not initialized"); + } + + return Buffer.from( + crypto.hkdfSync( + "sha256", + this.masterKey, + Buffer.alloc(0), + `${WRAP_INFO_PREFIX}${userId}`, + DEK_LENGTH, + ), + ); + } + + private wrap(userId: string, dek: Buffer): WrappedDek { + const wrapKey = this.deriveWrapKey(userId); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(WRAP_ALGORITHM, wrapKey, iv); + cipher.setAAD(Buffer.from(userId, "utf8")); + const ct = Buffer.concat([cipher.update(dek), cipher.final()]); + + return { + v: WRAP_VERSION, + alg: WRAP_ALGORITHM, + iv: iv.toString("base64"), + ct: ct.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + createdAt: new Date().toISOString(), + }; + } + + private unwrap(userId: string, raw: string): Buffer { + let wrapped: WrappedDek; + try { + wrapped = JSON.parse(raw) as WrappedDek; + } catch { + throw new Error(`Stored key wrap for user ${userId} is not valid JSON`); + } + + if (wrapped.v !== WRAP_VERSION || wrapped.alg !== WRAP_ALGORITHM) { + throw new Error( + `Unsupported key wrap (v=${wrapped.v}, alg=${wrapped.alg}) for user ${userId}`, + ); + } + + const wrapKey = this.deriveWrapKey(userId); + const decipher = crypto.createDecipheriv( + WRAP_ALGORITHM, + wrapKey, + Buffer.from(wrapped.iv, "base64"), + ); + decipher.setAAD(Buffer.from(userId, "utf8")); + decipher.setAuthTag(Buffer.from(wrapped.tag, "base64")); + + return Buffer.concat([ + decipher.update(Buffer.from(wrapped.ct, "base64")), + decipher.final(), + ]); + } +}