mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
79e5fe3583
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.
226 lines
5.9 KiB
TypeScript
226 lines
5.9 KiB
TypeScript
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(),
|
|
]);
|
|
}
|
|
}
|