mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +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",
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user