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.
This commit is contained in:
LukeGus
2026-07-16 00:10:39 -05:00
parent 79e5fe3583
commit 16506de9da
3 changed files with 681 additions and 0 deletions
+7
View File
@@ -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();
@@ -0,0 +1,330 @@
import crypto from "crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const settingsStore = new Map<string, string>();
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);
});
});
@@ -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<T>(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<Buffer> {
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<Buffer> {
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<Buffer | null> {
const encrypted = parseJson<LegacyEncryptedDEK>(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<void> {
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<Buffer | null> {
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<BootDekMigrationSummary> {
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<boolean> {
const userKeys = UserKeyManager.getInstance();
if (userKeys.hasUserDEK(userId)) {
if (hasAnyLegacyWrap(userId)) {
await deleteLegacyWraps(userId);
}
return true;
}
const keys = legacySettingsKeys(userId);
const kekSalt = parseJson<LegacyKekSalt>(
getCurrentSettingValue(keys.kekSalt),
);
const encrypted = parseJson<LegacyEncryptedDEK>(
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<void> {
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,
});
}