mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 21:33:41 +00:00
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:
@@ -117,6 +117,13 @@ import {
|
|||||||
operation: "backend_init_db",
|
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();
|
const authManager = AuthManager.getInstance();
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
DataCrypto.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user