From 16506de9daeaac2f160bc9f8eadc9d7dac06e05e Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 16 Jul 2026 00:10:39 -0500 Subject: [PATCH] feat(crypto): boot-time DEK migration to system-wrapped v3 format utils/crypto-migration/dek-migration.ts carries the legacy unwrap paths (PBKDF2 password KEK, OIDC/WebAuthn system keys, hardcoded-default fallback) and migrates every server-unwrappable DEK to the v3 wrap at startup. Password-wrapped DEKs migrate at next login or from a live session via adoptRecoveredDEK. Legacy rows are kept for now; cleanup flips on once the new path is authoritative. --- src/backend/starter.ts | 7 + .../crypto-migration/dek-migration.test.ts | 330 +++++++++++++++++ .../utils/crypto-migration/dek-migration.ts | 344 ++++++++++++++++++ 3 files changed, 681 insertions(+) create mode 100644 src/backend/utils/crypto-migration/dek-migration.test.ts create mode 100644 src/backend/utils/crypto-migration/dek-migration.ts diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 15e502ba..67c68f40 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -117,6 +117,13 @@ import { operation: "backend_init_db", }); + const { UserKeyManager } = await import("./utils/user-keys.js"); + await UserKeyManager.getInstance().initialize(); + + const { runBootDekMigration } = + await import("./utils/crypto-migration/dek-migration.js"); + await runBootDekMigration(); + const authManager = AuthManager.getInstance(); await authManager.initialize(); DataCrypto.initialize(); diff --git a/src/backend/utils/crypto-migration/dek-migration.test.ts b/src/backend/utils/crypto-migration/dek-migration.test.ts new file mode 100644 index 00000000..a783ed48 --- /dev/null +++ b/src/backend/utils/crypto-migration/dek-migration.test.ts @@ -0,0 +1,330 @@ +import crypto from "crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const settingsStore = new Map(); +let userRows: Array<{ id: string }> = []; + +vi.mock("../../database/repositories/factory.js", () => ({ + getCurrentSettingValue: (key: string) => settingsStore.get(key) ?? null, + createCurrentSettingsRepository: () => ({ + upsert: async (key: string, value: string) => { + settingsStore.set(key, value); + }, + set: async (key: string, value: string) => { + settingsStore.set(key, value); + }, + delete: async (key: string) => { + settingsStore.delete(key); + }, + }), + createCurrentUserRepository: () => ({ + listAll: async () => userRows, + }), +})); + +const masterKey = crypto.randomBytes(32); + +vi.mock("../system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ + getEncryptionKey: async () => masterKey, + }), + }, +})); + +import { UserKeyManager } from "../user-keys.js"; +import { + adoptRecoveredDEK, + legacySettingsKeys, + migratePasswordUserAtLogin, + runBootDekMigration, +} from "./dek-migration.js"; + +const manager = UserKeyManager.getInstance(); + +// Fixture helpers replicating the deleted legacy wrap formats exactly. +function legacyEncryptDEK(dek: Buffer, kek: Buffer): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv); + const encrypted = Buffer.concat([cipher.update(dek), cipher.final()]); + return JSON.stringify({ + data: encrypted.toString("hex"), + iv: iv.toString("hex"), + tag: cipher.getAuthTag().toString("hex"), + algorithm: "aes-256-gcm", + createdAt: new Date().toISOString(), + }); +} + +function oidcSystemKey(userId: string): Buffer { + return Buffer.from( + crypto.hkdfSync( + "sha256", + masterKey, + Buffer.from(userId, "utf8"), + Buffer.from("termix:oidc-user-kek", "utf8"), + 32, + ), + ); +} + +function webauthnSystemKey(userId: string): Buffer { + return Buffer.from( + crypto.hkdfSync( + "sha256", + masterKey, + Buffer.from(userId, "utf8"), + Buffer.from("termix:webauthn-user-kek", "utf8"), + 32, + ), + ); +} + +function legacyDefaultOidcKey(userId: string): Buffer { + return crypto.pbkdf2Sync( + "termix-oidc-system-secret-default", + Buffer.from(userId, "utf8"), + 100000, + 32, + "sha256", + ); +} + +function passwordKek(password: string, saltHex: string): Buffer { + return crypto.pbkdf2Sync( + password, + Buffer.from(saltHex, "hex"), + 100000, + 32, + "sha256", + ); +} + +function seedPasswordUser(userId: string, password: string): Buffer { + const dek = crypto.randomBytes(32); + const saltHex = crypto.randomBytes(32).toString("hex"); + const keys = legacySettingsKeys(userId); + settingsStore.set( + keys.kekSalt, + JSON.stringify({ + salt: saltHex, + iterations: 100000, + algorithm: "pbkdf2-sha256", + createdAt: new Date().toISOString(), + }), + ); + settingsStore.set( + keys.passwordWrap, + legacyEncryptDEK(dek, passwordKek(password, saltHex)), + ); + return dek; +} + +beforeEach(async () => { + settingsStore.clear(); + userRows = []; + await manager.initialize(masterKey); + manager.clearCache(); +}); + +describe("runBootDekMigration", () => { + it("migrates a pure-OIDC user whose primary slot is system-wrapped", async () => { + const dek = crypto.randomBytes(32); + const keys = legacySettingsKeys("oidc-user"); + settingsStore.set( + keys.passwordWrap, + legacyEncryptDEK(dek, oidcSystemKey("oidc-user")), + ); + userRows = [{ id: "oidc-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.migrated).toBe(1); + expect(manager.getUserDEK("oidc-user").equals(dek)).toBe(true); + }); + + it("migrates via the dedicated oidc wrap slot for dual-auth users", async () => { + const dek = seedPasswordUser("dual-user", "hunter2"); + const keys = legacySettingsKeys("dual-user"); + settingsStore.set( + keys.oidcWrap, + legacyEncryptDEK(dek, oidcSystemKey("dual-user")), + ); + userRows = [{ id: "dual-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.migrated).toBe(1); + expect(manager.getUserDEK("dual-user").equals(dek)).toBe(true); + }); + + it("migrates webauthn-wrapped users", async () => { + const dek = seedPasswordUser("wa-user", "hunter2"); + const keys = legacySettingsKeys("wa-user"); + settingsStore.set( + keys.webauthnWrap, + legacyEncryptDEK(dek, webauthnSystemKey("wa-user")), + ); + userRows = [{ id: "wa-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.migrated).toBe(1); + expect(manager.getUserDEK("wa-user").equals(dek)).toBe(true); + }); + + it("migrates wraps made with the legacy hardcoded default secret", async () => { + const dek = crypto.randomBytes(32); + const keys = legacySettingsKeys("legacy-user"); + settingsStore.set( + keys.oidcWrap, + legacyEncryptDEK(dek, legacyDefaultOidcKey("legacy-user")), + ); + userRows = [{ id: "legacy-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.migrated).toBe(1); + expect(manager.getUserDEK("legacy-user").equals(dek)).toBe(true); + }); + + it("leaves password-only users pending without touching their rows", async () => { + seedPasswordUser("pw-user", "hunter2"); + userRows = [{ id: "pw-user" }]; + const keys = legacySettingsKeys("pw-user"); + const before = { + salt: settingsStore.get(keys.kekSalt), + wrap: settingsStore.get(keys.passwordWrap), + }; + + const summary = await runBootDekMigration({ cleanupLegacy: true }); + + expect(summary.pendingPasswordLogin).toBe(1); + expect(manager.hasUserDEK("pw-user")).toBe(false); + expect(settingsStore.get(keys.kekSalt)).toBe(before.salt); + expect(settingsStore.get(keys.passwordWrap)).toBe(before.wrap); + }); + + it("creates a fresh DEK for users with no key material at all", async () => { + userRows = [{ id: "new-user" }]; + + const summary = await runBootDekMigration(); + + expect(summary.created).toBe(1); + expect(manager.hasUserDEK("new-user")).toBe(true); + }); + + it("is idempotent across repeated runs", async () => { + const dek = crypto.randomBytes(32); + const keys = legacySettingsKeys("oidc-user"); + settingsStore.set( + keys.passwordWrap, + legacyEncryptDEK(dek, oidcSystemKey("oidc-user")), + ); + userRows = [{ id: "oidc-user" }]; + + await runBootDekMigration(); + const second = await runBootDekMigration(); + + expect(second.alreadyMigrated).toBe(1); + expect(second.migrated).toBe(0); + manager.clearCache(); + expect(manager.getUserDEK("oidc-user").equals(dek)).toBe(true); + }); + + it("cleans leftover legacy rows when v3 already exists (crash resume)", async () => { + const dek = crypto.randomBytes(32); + await manager.persistDEK("resume-user", dek); + const keys = legacySettingsKeys("resume-user"); + settingsStore.set( + keys.oidcWrap, + legacyEncryptDEK(dek, oidcSystemKey("resume-user")), + ); + userRows = [{ id: "resume-user" }]; + + const summary = await runBootDekMigration({ cleanupLegacy: true }); + + expect(summary.alreadyMigrated).toBe(1); + expect(settingsStore.has(keys.oidcWrap)).toBe(false); + expect(manager.getUserDEK("resume-user").equals(dek)).toBe(true); + }); + + it("removes legacy wraps after migration when cleanup is enabled", async () => { + const dek = seedPasswordUser("dual-user", "hunter2"); + const keys = legacySettingsKeys("dual-user"); + settingsStore.set( + keys.oidcWrap, + legacyEncryptDEK(dek, oidcSystemKey("dual-user")), + ); + userRows = [{ id: "dual-user" }]; + + await runBootDekMigration({ cleanupLegacy: true }); + + expect(settingsStore.has(keys.oidcWrap)).toBe(false); + expect(settingsStore.has(keys.passwordWrap)).toBe(false); + expect(settingsStore.has(keys.kekSalt)).toBe(false); + expect(manager.getUserDEK("dual-user").equals(dek)).toBe(true); + }); +}); + +describe("migratePasswordUserAtLogin", () => { + it("migrates with the correct password and cleans legacy rows", async () => { + const dek = seedPasswordUser("pw-user", "hunter2"); + const keys = legacySettingsKeys("pw-user"); + + const migrated = await migratePasswordUserAtLogin("pw-user", "hunter2"); + + expect(migrated).toBe(true); + expect(manager.getUserDEK("pw-user").equals(dek)).toBe(true); + expect(settingsStore.has(keys.passwordWrap)).toBe(false); + expect(settingsStore.has(keys.kekSalt)).toBe(false); + }); + + it("fails with a wrong password and leaves rows intact", async () => { + seedPasswordUser("pw-user", "hunter2"); + const keys = legacySettingsKeys("pw-user"); + + const migrated = await migratePasswordUserAtLogin("pw-user", "wrong"); + + expect(migrated).toBe(false); + expect(manager.hasUserDEK("pw-user")).toBe(false); + expect(settingsStore.has(keys.passwordWrap)).toBe(true); + expect(settingsStore.has(keys.kekSalt)).toBe(true); + }); + + it("returns true and cleans up when the user is already migrated", async () => { + const dek = seedPasswordUser("pw-user", "hunter2"); + await manager.persistDEK("pw-user", dek); + + const migrated = await migratePasswordUserAtLogin("pw-user", "ignored"); + + expect(migrated).toBe(true); + expect(settingsStore.has(legacySettingsKeys("pw-user").passwordWrap)).toBe( + false, + ); + }); +}); + +describe("adoptRecoveredDEK", () => { + it("persists a session-recovered DEK and cleans legacy rows", async () => { + seedPasswordUser("pw-user", "hunter2"); + const dek = crypto.randomBytes(32); + + await adoptRecoveredDEK("pw-user", dek); + + expect(manager.getUserDEK("pw-user").equals(dek)).toBe(true); + expect(settingsStore.has(legacySettingsKeys("pw-user").passwordWrap)).toBe( + false, + ); + }); + + it("does not overwrite an existing v3 wrap", async () => { + const existing = crypto.randomBytes(32); + await manager.persistDEK("pw-user", existing); + + await adoptRecoveredDEK("pw-user", crypto.randomBytes(32)); + + manager.clearCache(); + expect(manager.getUserDEK("pw-user").equals(existing)).toBe(true); + }); +}); diff --git a/src/backend/utils/crypto-migration/dek-migration.ts b/src/backend/utils/crypto-migration/dek-migration.ts new file mode 100644 index 00000000..27ebb903 --- /dev/null +++ b/src/backend/utils/crypto-migration/dek-migration.ts @@ -0,0 +1,344 @@ +import crypto from "crypto"; +import { databaseLogger } from "../logger.js"; +import { SystemCrypto } from "../system-crypto.js"; +import { UserKeyManager } from "../user-keys.js"; +import { + createCurrentSettingsRepository, + createCurrentUserRepository, + getCurrentSettingValue, +} from "../../database/repositories/factory.js"; + +// Legacy (pre-v3) wrap formats, kept only to migrate existing installs. +// Password users: DEK wrapped by PBKDF2(password) using user_kek_salt_*. +// OIDC/WebAuthn users: DEK wrapped by a key derived from the system +// ENCRYPTION_KEY (or *_SYSTEM_SECRET env overrides), so the server can +// unwrap those eagerly at boot. Password wraps need the password (login) +// or the DEK recovered from a live session's JWT. + +interface LegacyEncryptedDEK { + data: string; + iv: string; + tag: string; + algorithm: string; + createdAt: string; +} + +interface LegacyKekSalt { + salt: string; + iterations: number; + algorithm: string; + createdAt: string; +} + +const KEY_LENGTH = 32; + +export function legacySettingsKeys(userId: string): { + passwordWrap: string; + kekSalt: string; + oidcWrap: string; + webauthnWrap: string; +} { + return { + passwordWrap: `user_encrypted_dek_${userId}`, + kekSalt: `user_kek_salt_${userId}`, + oidcWrap: `user_encrypted_dek_oidc_${userId}`, + webauthnWrap: `user_encrypted_dek_webauthn_${userId}`, + }; +} + +function parseJson(raw: string | null): T | null { + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function decryptLegacyDEK(encrypted: LegacyEncryptedDEK, kek: Buffer): Buffer { + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + kek, + Buffer.from(encrypted.iv, "hex"), + ); + decipher.setAuthTag(Buffer.from(encrypted.tag, "hex")); + return Buffer.concat([ + decipher.update(Buffer.from(encrypted.data, "hex")), + decipher.final(), + ]); +} + +function deriveLegacyKEK(password: string, kekSalt: LegacyKekSalt): Buffer { + return crypto.pbkdf2Sync( + password, + Buffer.from(kekSalt.salt, "hex"), + kekSalt.iterations, + KEY_LENGTH, + "sha256", + ); +} + +async function deriveOIDCSystemKey(userId: string): Promise { + if (process.env.OIDC_SYSTEM_SECRET) { + return crypto.pbkdf2Sync( + process.env.OIDC_SYSTEM_SECRET, + Buffer.from(userId, "utf8"), + 100000, + KEY_LENGTH, + "sha256", + ); + } + + const systemSecret = await SystemCrypto.getInstance().getEncryptionKey(); + return Buffer.from( + crypto.hkdfSync( + "sha256", + systemSecret, + Buffer.from(userId, "utf8"), + Buffer.from("termix:oidc-user-kek", "utf8"), + KEY_LENGTH, + ), + ); +} + +async function deriveWebAuthnSystemKey(userId: string): Promise { + const configuredSecret = + process.env.WEBAUTHN_SYSTEM_SECRET || process.env.OIDC_SYSTEM_SECRET; + if (configuredSecret) { + return crypto.pbkdf2Sync( + configuredSecret, + Buffer.from(`webauthn:${userId}`, "utf8"), + 100000, + KEY_LENGTH, + "sha256", + ); + } + + const systemSecret = await SystemCrypto.getInstance().getEncryptionKey(); + return Buffer.from( + crypto.hkdfSync( + "sha256", + systemSecret, + Buffer.from(userId, "utf8"), + Buffer.from("termix:webauthn-user-kek", "utf8"), + KEY_LENGTH, + ), + ); +} + +function deriveLegacyDefaultKey( + userId: string, + type: "oidc" | "webauthn", +): Buffer { + const secret = + type === "oidc" + ? "termix-oidc-system-secret-default" + : "termix-webauthn-system-secret-default"; + const salt = Buffer.from( + type === "oidc" ? userId : `webauthn:${userId}`, + "utf8", + ); + return crypto.pbkdf2Sync(secret, salt, 100000, KEY_LENGTH, "sha256"); +} + +async function tryUnwrapSystemWrapped( + userId: string, + raw: string | null, + type: "oidc" | "webauthn", +): Promise { + const encrypted = parseJson(raw); + if (!encrypted?.data || !encrypted.iv || !encrypted.tag) return null; + + const candidates = [ + type === "oidc" + ? await deriveOIDCSystemKey(userId) + : await deriveWebAuthnSystemKey(userId), + deriveLegacyDefaultKey(userId, type), + ]; + + for (const key of candidates) { + try { + const dek = decryptLegacyDEK(encrypted, key); + if (dek.length === KEY_LENGTH) return dek; + } catch { + // wrong key for this wrap; try the next candidate + } + } + + return null; +} + +function hasAnyLegacyWrap(userId: string): boolean { + const keys = legacySettingsKeys(userId); + return ( + getCurrentSettingValue(keys.passwordWrap) !== null || + getCurrentSettingValue(keys.kekSalt) !== null || + getCurrentSettingValue(keys.oidcWrap) !== null || + getCurrentSettingValue(keys.webauthnWrap) !== null + ); +} + +export async function deleteLegacyWraps(userId: string): Promise { + const keys = legacySettingsKeys(userId); + const settings = createCurrentSettingsRepository(); + await settings.delete(keys.passwordWrap); + await settings.delete(keys.kekSalt); + await settings.delete(keys.oidcWrap); + await settings.delete(keys.webauthnWrap); +} + +// Server-side unwrap for one user, in preference order. The primary +// user_encrypted_dek_* slot is only tried when no KEK salt exists: with a +// salt present it is password-wrapped and unrecoverable here. +async function unwrapServerSide(userId: string): Promise { + const keys = legacySettingsKeys(userId); + + const fromOidc = await tryUnwrapSystemWrapped( + userId, + getCurrentSettingValue(keys.oidcWrap), + "oidc", + ); + if (fromOidc) return fromOidc; + + const fromWebauthn = await tryUnwrapSystemWrapped( + userId, + getCurrentSettingValue(keys.webauthnWrap), + "webauthn", + ); + if (fromWebauthn) return fromWebauthn; + + if (getCurrentSettingValue(keys.kekSalt) === null) { + const fromPrimary = await tryUnwrapSystemWrapped( + userId, + getCurrentSettingValue(keys.passwordWrap), + "oidc", + ); + if (fromPrimary) return fromPrimary; + } + + return null; +} + +export interface BootDekMigrationSummary { + totalUsers: number; + alreadyMigrated: number; + migrated: number; + created: number; + pendingPasswordLogin: number; +} + +export interface BootDekMigrationOptions { + cleanupLegacy?: boolean; +} + +export async function runBootDekMigration( + options: BootDekMigrationOptions = {}, +): Promise { + const cleanupLegacy = options.cleanupLegacy ?? false; + const userKeys = UserKeyManager.getInstance(); + const users = await createCurrentUserRepository().listAll(); + + const summary: BootDekMigrationSummary = { + totalUsers: users.length, + alreadyMigrated: 0, + migrated: 0, + created: 0, + pendingPasswordLogin: 0, + }; + + for (const user of users) { + if (userKeys.hasUserDEK(user.id)) { + summary.alreadyMigrated++; + if (cleanupLegacy && hasAnyLegacyWrap(user.id)) { + await deleteLegacyWraps(user.id); + } + continue; + } + + const dek = await unwrapServerSide(user.id); + if (dek) { + await userKeys.persistDEK(user.id, dek); + summary.migrated++; + if (cleanupLegacy) { + await deleteLegacyWraps(user.id); + } + continue; + } + + if (hasAnyLegacyWrap(user.id)) { + summary.pendingPasswordLogin++; + continue; + } + + await userKeys.createUserDEK(user.id); + summary.created++; + } + + databaseLogger.info("User key migration pass finished", { + operation: "dek_migration_boot", + ...summary, + }); + + return summary; +} + +// Password login is the only remaining way to recover a password-wrapped DEK. +export async function migratePasswordUserAtLogin( + userId: string, + password: string, +): Promise { + const userKeys = UserKeyManager.getInstance(); + + if (userKeys.hasUserDEK(userId)) { + if (hasAnyLegacyWrap(userId)) { + await deleteLegacyWraps(userId); + } + return true; + } + + const keys = legacySettingsKeys(userId); + const kekSalt = parseJson( + getCurrentSettingValue(keys.kekSalt), + ); + const encrypted = parseJson( + getCurrentSettingValue(keys.passwordWrap), + ); + if (!kekSalt?.salt || !encrypted?.data) return false; + + let kek: Buffer | null = null; + try { + kek = deriveLegacyKEK(password, kekSalt); + const dek = decryptLegacyDEK(encrypted, kek); + if (dek.length !== KEY_LENGTH) return false; + + await userKeys.persistDEK(userId, dek); + await deleteLegacyWraps(userId); + + databaseLogger.info("Migrated password-wrapped user key at login", { + operation: "dek_migration_login", + userId, + }); + return true; + } catch { + return false; + } finally { + kek?.fill(0); + } +} + +// Adopt a DEK recovered from a live session (the legacy JWT dataKeyWrap). +export async function adoptRecoveredDEK( + userId: string, + dek: Buffer, +): Promise { + const userKeys = UserKeyManager.getInstance(); + if (userKeys.hasUserDEK(userId)) return; + + await userKeys.persistDEK(userId, dek); + await deleteLegacyWraps(userId); + + databaseLogger.info("Migrated user key from active session", { + operation: "dek_migration_session", + userId, + }); +}