mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 21:33:41 +00:00
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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user