feat(crypto): add UserKeyManager with system-wrapped per-user DEKs

New utils/user-keys.ts: one random 32-byte DEK per user, wrapped
AES-256-GCM under an HKDF key derived from the system ENCRYPTION_KEY
(per-user info string + AAD binding, versioned v3 wrap format stored in
settings). Synchronous unwrap-on-demand with a 15-minute cache so the
existing DataCrypto facade keeps its sync call sites. Not wired up yet.
This commit is contained in:
LukeGus
2026-07-16 00:07:21 -05:00
parent d710989070
commit 79e5fe3583
2 changed files with 386 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
import crypto from "crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const settingsStore = new Map<string, 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);
},
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<string, unknown> {
return JSON.parse(settingsStore.get(userDekSettingsKey(userId))!);
}
function putWrap(userId: string, wrap: Record<string, unknown>): 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);
});
});
+225
View File
@@ -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<string, CacheEntry>();
static getInstance(): UserKeyManager {
if (!UserKeyManager.instance) {
UserKeyManager.instance = new UserKeyManager();
}
return UserKeyManager.instance;
}
async initialize(masterKey?: Buffer): Promise<void> {
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<Buffer> {
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<void> {
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<Buffer> {
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<void> {
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(),
]);
}
}