From ee7768dd86e4085b2b60d7f61de25c8a54288991 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 16 Jul 2026 00:19:12 -0500 Subject: [PATCH] refactor(crypto): make system-wrapped DEKs the authoritative key path DataCrypto and AuthManager now read keys through UserKeyManager: DEKs are always unwrappable server-side, so the in-memory unlock session, DEK-in-JWT wrapping, session-expiry data locks and ALLOW_APIKEY_DATA_UNLOCK are gone. utils/user-crypto.ts is deleted; boot migration now cleans legacy wraps. A one-release shim adopts DEKs from legacy dataKeyWrap tokens so active password users migrate without re-login. Password login migrates legacy password-wrapped DEKs via migratePasswordUserAtLogin. --- src/backend/database/routes/termix-id.test.ts | 4 - .../routes/user-oidc-account-routes.ts | 34 - .../database/routes/user-webauthn-routes.ts | 2 - src/backend/database/routes/users.ts | 12 - src/backend/serial/serial.ts | 5 +- src/backend/ssh/opkssh-auth.ts | 9 +- src/backend/ssh/terminal.ts | 7 +- src/backend/ssh/vault-signer-auth.ts | 6 +- src/backend/starter.ts | 2 +- .../utils/auth-manager-oidc-logout.test.ts | 14 +- src/backend/utils/auth-manager-token.test.ts | 135 +++ src/backend/utils/auth-manager.ts | 329 ++------ src/backend/utils/data-crypto.ts | 16 +- src/backend/utils/user-crypto.ts | 773 ------------------ 14 files changed, 234 insertions(+), 1114 deletions(-) create mode 100644 src/backend/utils/auth-manager-token.test.ts delete mode 100644 src/backend/utils/user-crypto.ts diff --git a/src/backend/database/routes/termix-id.test.ts b/src/backend/database/routes/termix-id.test.ts index eba98459..39f55ab5 100644 --- a/src/backend/database/routes/termix-id.test.ts +++ b/src/backend/database/routes/termix-id.test.ts @@ -49,10 +49,6 @@ vi.mock("../../utils/data-crypto.js", () => ({ DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) }, })); -vi.mock("../../utils/user-crypto.js", () => ({ - UserCrypto: { getInstance: () => ({ getUserKey: vi.fn() }) }, -})); - vi.mock("../repositories/factory.js", () => ({ createCurrentTermixIdentityCaRepository: vi.fn(() => ({ findPublicByIdentityId: vi.fn(), diff --git a/src/backend/database/routes/user-oidc-account-routes.ts b/src/backend/database/routes/user-oidc-account-routes.ts index 24ca6b93..29fd6bd4 100644 --- a/src/backend/database/routes/user-oidc-account-routes.ts +++ b/src/backend/database/routes/user-oidc-account-routes.ts @@ -119,40 +119,6 @@ export function registerUserOidcAccountRoutes( scopes: oidcUser.scopes || "openid email profile", }); - try { - await authManager.convertToOIDCEncryption(targetUser.id); - } catch (encryptionError) { - authLogger.error( - "Failed to convert encryption to OIDC during linking", - encryptionError, - { - operation: "link_convert_encryption_failed", - userId: targetUser.id, - }, - ); - await userRepository.update(targetUser.id, { - isOidc: false, - oidcIdentifier: null, - clientId: "", - clientSecret: "", - issuerUrl: "", - authorizationUrl: "", - tokenUrl: "", - identifierPath: "", - namePath: "", - scopes: "openid email profile", - }); - - return res.status(500).json({ - error: - "Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.", - details: - encryptionError instanceof Error - ? encryptionError.message - : "Unknown error", - }); - } - await authManager.revokeAllUserSessions(oidcUserId); authManager.logoutUser(oidcUserId); diff --git a/src/backend/database/routes/user-webauthn-routes.ts b/src/backend/database/routes/user-webauthn-routes.ts index f2d4c06e..8306903f 100644 --- a/src/backend/database/routes/user-webauthn-routes.ts +++ b/src/backend/database/routes/user-webauthn-routes.ts @@ -251,8 +251,6 @@ export function registerUserWebAuthnRoutes( (req.body?.response as RegistrationResponseJSON | undefined)?.response ?.transports ?? []; - await authManager.setupWebAuthnUserEncryption(userId); - const name = typeof req.body?.name === "string" && req.body.name.trim() ? req.body.name.trim().slice(0, 80) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 59f202a4..7b553751 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1508,18 +1508,6 @@ router.post("/login", async (req, res) => { return res.status(401).json({ error: "Invalid username or password" }); } - try { - const kekSalt = await createCurrentSettingsRepository().get( - `user_kek_salt_${userRecord.id}`, - ); - - if (!kekSalt) { - await authManager.registerUser(userRecord.id, password); - } - } catch { - // expected - KEK salt registration may fail for existing users - } - const deviceInfo = parseUserAgent(req); let dataUnlocked = false; diff --git a/src/backend/serial/serial.ts b/src/backend/serial/serial.ts index f491c068..7868c2eb 100644 --- a/src/backend/serial/serial.ts +++ b/src/backend/serial/serial.ts @@ -1,7 +1,7 @@ import { WebSocketServer, WebSocket, type RawData } from "ws"; import { SerialPort } from "serialport"; import { AuthManager } from "../utils/auth-manager.js"; -import { UserCrypto } from "../utils/user-crypto.js"; +import { DataCrypto } from "../utils/data-crypto.js"; import { sshLogger } from "../utils/logger.js"; interface SerialConnectData { @@ -18,7 +18,6 @@ interface WebSocketMessage { } const authManager = AuthManager.getInstance(); -const userCrypto = UserCrypto.getInstance(); const wss = new WebSocketServer({ port: 30011 }); @@ -64,7 +63,7 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - const dataKey = userCrypto.getUserDataKey(userId); + const dataKey = DataCrypto.getUserDataKey(userId); if (!dataKey) { ws.send(JSON.stringify({ type: "error", data: "Data locked" })); ws.close(1008, "Data access required"); diff --git a/src/backend/ssh/opkssh-auth.ts b/src/backend/ssh/opkssh-auth.ts index 2dbc14ec..1988bc96 100644 --- a/src/backend/ssh/opkssh-auth.ts +++ b/src/backend/ssh/opkssh-auth.ts @@ -4,7 +4,7 @@ import { WebSocket } from "ws"; import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js"; import { sshLogger } from "../utils/logger.js"; import { createCurrentOpksshTokenRepository } from "../database/repositories/factory.js"; -import { UserCrypto } from "../utils/user-crypto.js"; +import { DataCrypto } from "../utils/data-crypto.js"; import { FieldCrypto } from "../utils/field-crypto.js"; import { promises as fs } from "fs"; import path from "path"; @@ -647,12 +647,10 @@ function handleOPKSSHOutput(requestId: string, output: string): void { async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise { try { - const userCrypto = UserCrypto.getInstance(); - const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + 24); - const userDataKey = userCrypto.getUserDataKey(session.userId); + const userDataKey = DataCrypto.getUserDataKey(session.userId); if (!userDataKey) { throw new Error("User data key not found"); } @@ -729,8 +727,7 @@ export async function getOPKSSHToken( return null; } - const userCrypto = UserCrypto.getInstance(); - const userDataKey = userCrypto.getUserDataKey(userId); + const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { throw new Error("User data key not found"); } diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 8c996f7c..57b6c669 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -11,7 +11,7 @@ import { createCurrentHostResolutionRepository } from "../database/repositories/ import { sshLogger, authLogger } from "../utils/logger.js"; import { logAudit } from "../utils/audit-logger.js"; import { AuthManager } from "../utils/auth-manager.js"; -import { UserCrypto } from "../utils/user-crypto.js"; +import { DataCrypto } from "../utils/data-crypto.js"; import { createSocks5Connection, type SOCKS5Config, @@ -98,7 +98,6 @@ interface WebSocketMessage { } const authManager = AuthManager.getInstance(); -const userCrypto = UserCrypto.getInstance(); const userConnections = new Map>(); @@ -158,7 +157,7 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - const dataKey = userCrypto.getUserDataKey(userId); + const dataKey = DataCrypto.getUserDataKey(userId); if (!dataKey) { ws.send( JSON.stringify({ @@ -268,7 +267,7 @@ wss.on("connection", async (ws: WebSocket, req) => { } ws.on("message", async (msg: RawData) => { - const currentDataKey = userCrypto.getUserDataKey(userId); + const currentDataKey = DataCrypto.getUserDataKey(userId); if (!currentDataKey) { ws.send( JSON.stringify({ diff --git a/src/backend/ssh/vault-signer-auth.ts b/src/backend/ssh/vault-signer-auth.ts index 3db47614..4e0eba2f 100644 --- a/src/backend/ssh/vault-signer-auth.ts +++ b/src/backend/ssh/vault-signer-auth.ts @@ -17,7 +17,7 @@ // is re-exported here so callers have a single import surface. import { createCurrentVaultTokenRepository } from "../database/repositories/factory.js"; -import { UserCrypto } from "../utils/user-crypto.js"; +import { DataCrypto } from "../utils/data-crypto.js"; import { FieldCrypto } from "../utils/field-crypto.js"; import { parseCertValidBefore } from "./vault-signer-core.js"; @@ -50,7 +50,7 @@ export async function storeVaultCert( privateKey: string, signedCert: string, ): Promise { - const userDataKey = UserCrypto.getInstance().getUserDataKey(userId); + const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { throw new Error("User data key not found"); } @@ -104,7 +104,7 @@ export async function getVaultCert( return null; } - const userDataKey = UserCrypto.getInstance().getUserDataKey(userId); + const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { throw new Error("User data key not found"); } diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 67c68f40..f7bf088d 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -122,7 +122,7 @@ import { const { runBootDekMigration } = await import("./utils/crypto-migration/dek-migration.js"); - await runBootDekMigration(); + await runBootDekMigration({ cleanupLegacy: true }); const authManager = AuthManager.getInstance(); await authManager.initialize(); diff --git a/src/backend/utils/auth-manager-oidc-logout.test.ts b/src/backend/utils/auth-manager-oidc-logout.test.ts index 9bab0eaf..e9c8f733 100644 --- a/src/backend/utils/auth-manager-oidc-logout.test.ts +++ b/src/backend/utils/auth-manager-oidc-logout.test.ts @@ -8,7 +8,6 @@ const mocks = vi.hoisted(() => ({ run: (...values: unknown[]) => unknown; }; } | null, - logoutUser: vi.fn(), saveDatabase: vi.fn().mockResolvedValue(undefined), })); @@ -25,11 +24,12 @@ vi.mock("../database/db/index.js", async () => { }; }); -vi.mock("./user-crypto.js", () => ({ - UserCrypto: { +vi.mock("./user-keys.js", () => ({ + UserKeyManager: { getInstance: () => ({ - setSessionExpiredCallback: vi.fn(), - logoutUser: mocks.logoutUser, + invalidate: vi.fn(), + tryGetUserDEK: vi.fn(() => null), + hasUserDEK: vi.fn(() => true), }), }, })); @@ -120,7 +120,6 @@ describe("AuthManager.revokeSessionsByOidc", () => { beforeEach(() => { mocks.sqlite!.exec("DELETE FROM sessions"); - mocks.logoutUser.mockReset(); mocks.saveDatabase.mockReset().mockResolvedValue(undefined); }); @@ -156,7 +155,6 @@ describe("AuthManager.revokeSessionsByOidc", () => { ).resolves.toBe(1); expect(sessionIds()).toEqual(["other-provider", "other-session"]); - expect(mocks.logoutUser).not.toHaveBeenCalled(); }); it("revokes all provider sessions for a subject when sid is absent", async () => { @@ -190,8 +188,6 @@ describe("AuthManager.revokeSessionsByOidc", () => { ).resolves.toBe(2); expect(sessionIds()).toEqual(["other-subject"]); - expect(mocks.logoutUser).toHaveBeenCalledOnce(); - expect(mocks.logoutUser).toHaveBeenCalledWith("user-1"); }); it("does not persist when no session matches", async () => { diff --git a/src/backend/utils/auth-manager-token.test.ts b/src/backend/utils/auth-manager-token.test.ts new file mode 100644 index 00000000..b5552778 --- /dev/null +++ b/src/backend/utils/auth-manager-token.test.ts @@ -0,0 +1,135 @@ +import crypto from "crypto"; +import jwt from "jsonwebtoken"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const jwtSecret = "a".repeat(64); +const encryptionKey = crypto.randomBytes(32); + +const mocks = vi.hoisted(() => ({ + hasUserDEK: vi.fn(() => false), + adoptRecoveredDEK: vi.fn(async () => {}), +})); + +vi.mock("../database/db/index.js", () => ({ + db: {}, + getDb: () => ({}), + saveMemoryDatabaseToFile: vi.fn(), +})); + +vi.mock("../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ get: async () => null }), + createCurrentSessionRepository: () => ({}), + createCurrentUserRepository: () => ({}), + createCurrentApiKeyRepository: () => ({}), + createCurrentTrustedDeviceRepository: () => ({}), + getCurrentSettingValue: () => null, +})); + +vi.mock("./user-keys.js", () => ({ + UserKeyManager: { + getInstance: () => ({ + hasUserDEK: mocks.hasUserDEK, + tryGetUserDEK: vi.fn(() => null), + invalidate: vi.fn(), + }), + }, +})); + +vi.mock("./crypto-migration/dek-migration.js", () => ({ + adoptRecoveredDEK: mocks.adoptRecoveredDEK, + migratePasswordUserAtLogin: vi.fn(async () => true), +})); + +vi.mock("./system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ + getJWTSecret: async () => jwtSecret, + getEncryptionKey: async () => encryptionKey, + }), + }, +})); + +const { AuthManager } = await import("./auth-manager.js"); +const authManager = AuthManager.getInstance(); + +function makeLegacyDataKeyWrap( + userId: string, + dek: Buffer, +): Record { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv); + cipher.setAAD(Buffer.from(`${userId}:`, "utf8")); + const data = Buffer.concat([cipher.update(dek), cipher.final()]); + return { + version: "v1", + iv: iv.toString("base64url"), + tag: cipher.getAuthTag().toString("base64url"), + data: data.toString("base64url"), + }; +} + +beforeEach(() => { + mocks.hasUserDEK.mockReset().mockReturnValue(false); + mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined); +}); + +describe("AuthManager token handling", () => { + it("issues tokens without a dataKeyWrap", async () => { + const token = await authManager.generateJWTToken("user-1"); + const payload = jwt.decode(token) as Record; + + expect(payload.userId).toBe("user-1"); + expect(payload.dataKeyWrap).toBeUndefined(); + }); + + it("adopts the DEK from a legacy dataKeyWrap token on verify", async () => { + const dek = crypto.randomBytes(32); + const token = jwt.sign( + { userId: "user-1", dataKeyWrap: makeLegacyDataKeyWrap("user-1", dek) }, + jwtSecret, + { expiresIn: "1h" }, + ); + + const payload = await authManager.verifyJWTToken(token); + + expect(payload?.userId).toBe("user-1"); + expect(mocks.adoptRecoveredDEK).toHaveBeenCalledOnce(); + const [userId, adopted] = mocks.adoptRecoveredDEK.mock.calls[0] as [ + string, + Buffer, + ]; + expect(userId).toBe("user-1"); + expect(adopted.equals(dek)).toBe(true); + }); + + it("skips adoption when the user already has a v3 key", async () => { + mocks.hasUserDEK.mockReturnValue(true); + const token = jwt.sign( + { + userId: "user-1", + dataKeyWrap: makeLegacyDataKeyWrap("user-1", crypto.randomBytes(32)), + }, + jwtSecret, + { expiresIn: "1h" }, + ); + + await authManager.verifyJWTToken(token); + + expect(mocks.adoptRecoveredDEK).not.toHaveBeenCalled(); + }); + + it("tolerates a tampered dataKeyWrap without failing verification", async () => { + const wrap = makeLegacyDataKeyWrap("user-1", crypto.randomBytes(32)); + wrap.tag = Buffer.from( + Buffer.from(wrap.tag, "base64url").map((b) => b ^ 0xff), + ).toString("base64url"); + const token = jwt.sign({ userId: "user-1", dataKeyWrap: wrap }, jwtSecret, { + expiresIn: "1h", + }); + + const payload = await authManager.verifyJWTToken(token); + + expect(payload?.userId).toBe("user-1"); + expect(mocks.adoptRecoveredDEK).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 98cf3d45..52a1a8f6 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -1,6 +1,10 @@ import jwt from "jsonwebtoken"; import crypto from "crypto"; -import { UserCrypto } from "./user-crypto.js"; +import { UserKeyManager } from "./user-keys.js"; +import { + adoptRecoveredDEK, + migratePasswordUserAtLogin, +} from "./crypto-migration/dek-migration.js"; import { SystemCrypto } from "./system-crypto.js"; import { DataCrypto } from "./data-crypto.js"; import { DatabaseSaveTrigger } from "./database-save-trigger.js"; @@ -18,6 +22,7 @@ import { createCurrentUserRepository, createCurrentApiKeyRepository, createCurrentTrustedDeviceRepository, + getCurrentSettingValue, } from "../database/repositories/factory.js"; interface AuthenticationResult { @@ -64,15 +69,11 @@ interface RequestWithHeaders extends Request { class AuthManager { private static instance: AuthManager; private systemCrypto: SystemCrypto; - private userCrypto: UserCrypto; + private userKeys: UserKeyManager; private constructor() { this.systemCrypto = SystemCrypto.getInstance(); - this.userCrypto = UserCrypto.getInstance(); - - this.userCrypto.setSessionExpiredCallback((userId: string) => { - this.invalidateUserTokens(userId); - }); + this.userKeys = UserKeyManager.getInstance(); setInterval( () => { @@ -101,88 +102,65 @@ class AuthManager { await this.systemCrypto.initializeJWTSecret(); } - async registerUser(userId: string, password: string): Promise { - await this.userCrypto.setupUserEncryption(userId, password); + async registerUser(userId: string, _password?: string): Promise { + if (!this.userKeys.hasUserDEK(userId)) { + await this.userKeys.createUserDEK(userId); + } } async registerOIDCUser( userId: string, - sessionDurationMs: number, + _sessionDurationMs?: number, ): Promise { - await this.userCrypto.setupOIDCUserEncryption(userId, sessionDurationMs); + if (!this.userKeys.hasUserDEK(userId)) { + await this.userKeys.createUserDEK(userId); + } + } + + private async ensureUserDEK(userId: string): Promise { + if (this.userKeys.tryGetUserDEK(userId)) { + await this.performLazyEncryptionMigration(userId); + return true; + } + return false; } async authenticateOIDCUser( userId: string, - deviceType?: DeviceType, + _deviceType?: DeviceType, ): Promise { - const sessionDurationMs = - deviceType === "desktop" || deviceType === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - - const authenticated = await this.userCrypto.authenticateOIDCUser( - userId, - sessionDurationMs, - ); - - if (authenticated) { - await this.performLazyEncryptionMigration(userId); + if (!this.userKeys.hasUserDEK(userId)) { + await this.userKeys.createUserDEK(userId); } - - return authenticated; + return this.ensureUserDEK(userId); } async authenticateUser( userId: string, password: string, - deviceType?: DeviceType, + _deviceType?: DeviceType, ): Promise { - const sessionDurationMs = - deviceType === "desktop" || deviceType === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; + // Password-wrapped legacy DEKs can only be recovered while the password + // is in hand, so login is where those users migrate to the v3 wrap. + const migrated = await migratePasswordUserAtLogin(userId, password); - const authenticated = await this.userCrypto.authenticateUser( - userId, - password, - sessionDurationMs, - ); - - if (authenticated) { - await this.performLazyEncryptionMigration(userId); + if (!migrated && !this.userKeys.hasUserDEK(userId)) { + const raw = getCurrentSettingValue(`user_encrypted_dek_${userId}`); + if (raw) { + // A legacy wrap exists but the password could not unwrap it. + return false; + } + await this.userKeys.createUserDEK(userId); } - return authenticated; - } - - async setupWebAuthnUserEncryption(userId: string): Promise { - await this.userCrypto.setupWebAuthnUserEncryption(userId); + return this.ensureUserDEK(userId); } async authenticateWebAuthnUser( userId: string, - deviceType?: DeviceType, + _deviceType?: DeviceType, ): Promise { - const sessionDurationMs = - deviceType === "desktop" || deviceType === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - - const authenticated = await this.userCrypto.authenticateWebAuthnUser( - userId, - sessionDurationMs, - ); - - if (authenticated) { - await this.performLazyEncryptionMigration(userId); - } - - return authenticated; - } - - async convertToOIDCEncryption(userId: string): Promise { - await this.userCrypto.convertToOIDCEncryption(userId); + return this.ensureUserDEK(userId); } private async performLazyEncryptionMigration(userId: string): Promise { @@ -232,27 +210,6 @@ class AuthManager { return Buffer.from(`${userId}:${sessionId || ""}`, "utf8"); } - private async wrapUserDataKey( - userId: string, - sessionId: string | undefined, - dataKey: Buffer, - ): Promise { - const encryptionKey = await this.systemCrypto.getEncryptionKey(); - const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv); - cipher.setAAD(this.getDataKeyAAD(userId, sessionId)); - - const encrypted = Buffer.concat([cipher.update(dataKey), cipher.final()]); - const tag = cipher.getAuthTag(); - - return { - version: "v1", - iv: iv.toString("base64url"), - tag: tag.toString("base64url"), - data: encrypted.toString("base64url"), - }; - } - private async unwrapUserDataKey( userId: string, sessionId: string | undefined, @@ -279,41 +236,11 @@ class AuthManager { ]); } - private async addWrappedDataKey(payload: JWTPayload): Promise { - if (payload.pendingTOTP) { - return; - } - - const dataKey = this.userCrypto.getUserDataKey(payload.userId); - if (!dataKey) { - return; - } - - payload.dataKeyWrap = await this.wrapUserDataKey( - payload.userId, - payload.sessionId, - dataKey, - ); - } - - private async restoreDataKeyFromPayload( - payload: JWTPayload, - sessionExpiresAt?: string, - ): Promise { - if ( - !payload.dataKeyWrap || - this.userCrypto.getUserDataKey(payload.userId) - ) { - return; - } - - const expiresAt = sessionExpiresAt - ? new Date(sessionExpiresAt).getTime() - : payload.exp - ? payload.exp * 1000 - : Date.now(); - - if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + // Upgrade shim, remove in the release after 2.5: tokens issued before the + // v3 key wrap carried the user's DEK wrapped with the system ENCRYPTION_KEY. + // Adopting it here silently migrates password users with live sessions. + private async migrateDataKeyFromPayload(payload: JWTPayload): Promise { + if (!payload.dataKeyWrap || this.userKeys.hasUserDEK(payload.userId)) { return; } @@ -323,11 +250,10 @@ class AuthManager { payload.sessionId, payload.dataKeyWrap, ); - this.userCrypto.restoreUserDataKey(payload.userId, dataKey, expiresAt); - dataKey.fill(0); + await adoptRecoveredDEK(payload.userId, dataKey); } catch (error) { - databaseLogger.warn("Failed to restore data key from session token", { - operation: "session_data_key_restore_failed", + databaseLogger.warn("Failed to migrate data key from session token", { + operation: "session_data_key_migrate_failed", userId: payload.userId, sessionId: payload.sessionId, error: error instanceof Error ? error.message : "Unknown error", @@ -374,7 +300,6 @@ class AuthManager { if (!options.pendingTOTP && options.deviceType && options.deviceInfo) { const sessionId = nanoid(); payload.sessionId = sessionId; - await this.addWrappedDataKey(payload); const token = jwt.sign(payload, jwtSecret, { expiresIn, @@ -410,7 +335,6 @@ class AuthManager { return token; } - await this.addWrappedDataKey(payload); return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions); } @@ -456,10 +380,7 @@ class AuthManager { return null; } - await this.restoreDataKeyFromPayload( - payload, - sessionRecord.expiresAt, - ); + await this.migrateDataKeyFromPayload(payload); } catch (dbError) { databaseLogger.error( "Failed to check session in database during JWT verification", @@ -472,7 +393,7 @@ class AuthManager { return null; } } else { - await this.restoreDataKeyFromPayload(payload); + await this.migrateDataKeyFromPayload(payload); } return payload; } catch (error) { @@ -503,7 +424,6 @@ class AuthManager { } const payload: JWTPayload = { userId, sessionId }; - await this.addWrappedDataKey(payload); const token = jwt.sign(payload, await this.systemCrypto.getJWTSecret(), { expiresIn: Math.ceil(maxAge / 1000), @@ -605,16 +525,6 @@ class AuthManager { sessionCount: matchedIds.length, }); - for (const userId of affectedUsers) { - const remaining = await db - .select() - .from(sessions) - .where(eq(sessions.userId, userId)); - if (remaining.length === 0) { - this.userCrypto.logoutUser(userId); - } - } - const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); @@ -642,15 +552,6 @@ class AuthManager { await sessionRepository.deleteExpired(now); - const affectedUsers = new Set(expiredSessions.map((s) => s.userId)); - for (const userId of affectedUsers) { - const remainingSessions = await sessionRepository.listByUserId(userId); - - if (remainingSessions.length === 0) { - this.userCrypto.logoutUser(userId); - } - } - return expiredCount; } catch (error) { databaseLogger.error("Failed to cleanup expired sessions", error, { @@ -763,38 +664,6 @@ class AuthManager { }); }); - // Auto-unlock the per-user Data Encryption Key for OIDC-backed users so - // API-key (headless / automation) requests can access encrypted - // credentials/hosts. Without this, every encrypted-table op throws - // "User data not unlocked" because the API-key path never established a - // DEK session. OIDC DEKs are wrapped with a server-derived system key - // (deriveOIDCSystemKey), so no user password is required. Password-based - // users are deliberately NOT unlocked here (their KEK is password-derived - // and cannot be reproduced server-side). - // - // Opt-in and OFF by default: enabling this widens the blast radius of a - // leaked API key (it can then read that user's stored SSH secrets in - // clear text), so operators must turn it on explicitly. - if ( - process.env.ALLOW_APIKEY_DATA_UNLOCK === "true" && - !this.userCrypto.isUserUnlocked(matchedKey.userId) - ) { - try { - const keyUser = await createCurrentUserRepository().findById( - matchedKey.userId, - ); - if (keyUser?.isOidc) { - await this.authenticateOIDCUser(matchedKey.userId); - } - } catch (err) { - databaseLogger.warn("API-key OIDC auto-unlock failed", { - operation: "api_key_oidc_unlock_failed", - userId: matchedKey.userId, - error: err instanceof Error ? err.message : "Unknown", - }); - } - } - req.userId = matchedKey.userId; req.apiKeyId = matchedKey.id; next(); @@ -876,37 +745,12 @@ class AuthManager { difference: currentTime - sessionExpiryTime, }); - sessionRepository - .revoke(payload.sessionId) - .then(async () => { - try { - const remainingSessions = - await sessionRepository.listByUserId(payload.userId); - - if (remainingSessions.length === 0) { - this.userCrypto.logoutUser(payload.userId); - } - } catch (cleanupError) { - databaseLogger.error( - "Failed to cleanup after expired session", - cleanupError, - { - operation: "expired_session_cleanup_failed", - sessionId: payload.sessionId, - }, - ); - } - }) - .catch((error) => { - databaseLogger.error( - "Failed to delete expired session", - error, - { - operation: "expired_session_delete_failed", - sessionId: payload.sessionId, - }, - ); + sessionRepository.revoke(payload.sessionId).catch((error) => { + databaseLogger.error("Failed to delete expired session", error, { + operation: "expired_session_delete_failed", + sessionId: payload.sessionId, }); + }); return res .clearCookie("jwt", this.getClearCookieOptions(req)) @@ -951,8 +795,7 @@ class AuthManager { return res.status(401).json({ error: "Authentication required" }); } - const dataKey = this.userCrypto.getUserDataKey(userId); - authReq.dataKey = dataKey || undefined; + authReq.dataKey = this.userKeys.tryGetUserDEK(userId) ?? undefined; next(); }; } @@ -1035,65 +878,45 @@ class AuthManager { async logoutUser(userId: string, sessionId?: string): Promise { const sessionRepository = createCurrentSessionRepository(); - if (sessionId) { - try { + try { + if (sessionId) { await sessionRepository.revoke(sessionId); - - const remainingSessions = await sessionRepository.listByUserId(userId); - - if (remainingSessions.length === 0) { - this.userCrypto.logoutUser(userId); - } else { - // expected - other sessions still active, keep user crypto state - } - } catch (error) { - databaseLogger.error("Failed to delete session on logout", error, { - operation: "session_delete_logout_failed", - userId, - sessionId, - }); - } - } else { - try { + } else { await sessionRepository.revokeAllForUser(userId); - } catch (error) { - databaseLogger.error("Failed to revoke all sessions on logout", error, { - operation: "session_revoke_all_failed", - userId, - }); } - this.userCrypto.logoutUser(userId); + } catch (error) { + databaseLogger.error("Failed to revoke sessions on logout", error, { + operation: "session_delete_logout_failed", + userId, + sessionId, + }); } } getUserDataKey(userId: string): Buffer | null { - return this.userCrypto.getUserDataKey(userId); + return this.userKeys.tryGetUserDEK(userId); } isUserUnlocked(userId: string): boolean { - return this.userCrypto.isUserUnlocked(userId); + return this.userKeys.tryGetUserDEK(userId) !== null; } async changeUserPassword( userId: string, oldPassword: string, - newPassword: string, + _newPassword?: string, ): Promise { - return await this.userCrypto.changeUserPassword( - userId, - oldPassword, - newPassword, - ); + // The DEK is no longer derived from the password; the old password is + // only needed when the user still has a legacy password-wrapped key. + await migratePasswordUserAtLogin(userId, oldPassword); + return this.userKeys.tryGetUserDEK(userId) !== null; } async resetUserPasswordWithPreservedDEK( userId: string, - newPassword: string, + _newPassword?: string, ): Promise { - return await this.userCrypto.resetUserPasswordWithPreservedDEK( - userId, - newPassword, - ); + return this.userKeys.tryGetUserDEK(userId) !== null; } async isTrustedDevice( diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index bfdaaccb..0e6c4593 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -1,6 +1,6 @@ import { FieldCrypto } from "./field-crypto.js"; import { LazyFieldEncryption } from "./lazy-field-encryption.js"; -import { UserCrypto } from "./user-crypto.js"; +import { UserKeyManager } from "./user-keys.js"; import { DatabaseSaveTrigger } from "./database-save-trigger.js"; import { databaseLogger } from "./logger.js"; import { @@ -12,10 +12,10 @@ import { } from "./user-encryption-migration-store.js"; class DataCrypto { - private static userCrypto: UserCrypto; + private static userKeys: UserKeyManager; static initialize() { - this.userCrypto = UserCrypto.getInstance(); + this.userKeys = UserKeyManager.getInstance(); } static encryptRecord>( @@ -235,7 +235,7 @@ class DataCrypto { } static getUserDataKey(userId: string): Buffer | null { - return this.userCrypto.getUserDataKey(userId); + return this.userKeys.tryGetUserDEK(userId); } static async reencryptUserDataAfterPasswordReset( @@ -413,11 +413,7 @@ class DataCrypto { } static validateUserAccess(userId: string): Buffer { - const userDataKey = this.getUserDataKey(userId); - if (!userDataKey) { - throw new Error(`User ${userId} data not unlocked`); - } - return userDataKey; + return this.userKeys.getUserDEK(userId); } static encryptRecordForUser>( @@ -448,7 +444,7 @@ class DataCrypto { } static canUserAccessData(userId: string): boolean { - return this.userCrypto.isUserUnlocked(userId); + return this.userKeys.tryGetUserDEK(userId) !== null; } static testUserEncryption(userId: string): boolean { diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts deleted file mode 100644 index b068346d..00000000 --- a/src/backend/utils/user-crypto.ts +++ /dev/null @@ -1,773 +0,0 @@ -import crypto from "crypto"; -import { databaseLogger } from "./logger.js"; -import { createCurrentSettingsRepository } from "../database/repositories/factory.js"; -import { SystemCrypto } from "./system-crypto.js"; - -interface KEKSalt { - salt: string; - iterations: number; - algorithm: string; - createdAt: string; -} - -interface EncryptedDEK { - data: string; - iv: string; - tag: string; - algorithm: string; - createdAt: string; -} - -interface UserSession { - dataKey: Buffer; - expiresAt: number; - lastActivity?: number; -} - -class UserCrypto { - private static instance: UserCrypto; - private userSessions: Map = new Map(); - private sessionExpiredCallback?: (userId: string) => void; - - private static readonly PBKDF2_ITERATIONS = 100000; - private static readonly KEK_LENGTH = 32; - private static readonly DEK_LENGTH = 32; - - private constructor() { - setInterval( - () => { - this.cleanupExpiredSessions(); - }, - 5 * 60 * 1000, - ); - } - - static getInstance(): UserCrypto { - if (!this.instance) { - this.instance = new UserCrypto(); - } - return this.instance; - } - - setSessionExpiredCallback(callback: (userId: string) => void): void { - this.sessionExpiredCallback = callback; - } - - async setupUserEncryption(userId: string, password: string): Promise { - const kekSalt = await this.generateKEKSalt(); - await this.storeKEKSalt(userId, kekSalt); - - const KEK = this.deriveKEK(password, kekSalt); - const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH); - const encryptedDEK = this.encryptDEK(DEK, KEK); - await this.storeEncryptedDEK(userId, encryptedDEK); - - KEK.fill(0); - DEK.fill(0); - } - - async setupOIDCUserEncryption( - userId: string, - sessionDurationMs: number, - ): Promise { - const existingEncryptedDEK = await this.getEncryptedDEK(userId); - - let DEK: Buffer; - - if (existingEncryptedDEK) { - DEK = await this.decryptSystemWrappedDEK( - existingEncryptedDEK, - userId, - "oidc", - `user_encrypted_dek_${userId}`, - ); - } else { - DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH); - const systemKey = await this.deriveOIDCSystemKey(userId); - - try { - const encryptedDEK = this.encryptDEK(DEK, systemKey); - await this.storeEncryptedDEK(userId, encryptedDEK); - - const storedEncryptedDEK = await this.getEncryptedDEK(userId); - if ( - storedEncryptedDEK && - storedEncryptedDEK.data !== encryptedDEK.data - ) { - DEK.fill(0); - DEK = this.decryptDEK(storedEncryptedDEK, systemKey); - } else if (!storedEncryptedDEK) { - throw new Error("Failed to store and retrieve user encryption key."); - } - } finally { - systemKey.fill(0); - } - } - - const now = Date.now(); - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: now + sessionDurationMs, - }); - - DEK.fill(0); - } - - async authenticateUser( - userId: string, - password: string, - sessionDurationMs: number, - ): Promise { - try { - const kekSalt = await this.getKEKSalt(userId); - if (!kekSalt) return false; - - const KEK = this.deriveKEK(password, kekSalt); - const encryptedDEK = await this.getEncryptedDEK(userId); - if (!encryptedDEK) { - KEK.fill(0); - return false; - } - - const DEK = this.decryptDEK(encryptedDEK, KEK); - KEK.fill(0); - - if (!DEK || DEK.length === 0) { - databaseLogger.error("DEK is empty or invalid after decryption", { - operation: "user_crypto_auth_debug", - userId, - dekLength: DEK ? DEK.length : 0, - }); - return false; - } - - const now = Date.now(); - - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: now + sessionDurationMs, - }); - - DEK.fill(0); - - return true; - } catch (error) { - databaseLogger.warn("User authentication failed", { - operation: "user_crypto_auth_failed", - userId, - error: error instanceof Error ? error.message : "Unknown", - }); - return false; - } - } - - async authenticateOIDCUser( - userId: string, - sessionDurationMs: number, - ): Promise { - try { - const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId); - - if (oidcEncryptedDEK) { - const DEK = await this.decryptSystemWrappedDEK( - oidcEncryptedDEK, - userId, - "oidc", - `user_encrypted_dek_oidc_${userId}`, - ); - - if (!DEK || DEK.length === 0) { - databaseLogger.error( - "Failed to decrypt OIDC DEK for dual-auth user", - { - operation: "oidc_auth_dual_decrypt_failed", - userId, - }, - ); - return false; - } - - const now = Date.now(); - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: now + sessionDurationMs, - }); - - DEK.fill(0); - return true; - } - - const kekSalt = await this.getKEKSalt(userId); - const encryptedDEK = await this.getEncryptedDEK(userId); - - if (!kekSalt || !encryptedDEK) { - await this.setupOIDCUserEncryption(userId, sessionDurationMs); - return true; - } - - const DEK = await this.decryptSystemWrappedDEK( - encryptedDEK, - userId, - "oidc", - `user_encrypted_dek_${userId}`, - ); - - if (!DEK || DEK.length === 0) { - await this.setupOIDCUserEncryption(userId, sessionDurationMs); - return true; - } - - const now = Date.now(); - - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: now + sessionDurationMs, - }); - - DEK.fill(0); - - return true; - } catch (error) { - databaseLogger.error("OIDC authentication failed", error, { - operation: "oidc_auth_error", - userId, - error: error instanceof Error ? error.message : "Unknown", - }); - await this.setupOIDCUserEncryption(userId, sessionDurationMs); - return true; - } - } - - async setupWebAuthnUserEncryption(userId: string): Promise { - const existingDEK = this.getUserDataKey(userId); - - if (!existingDEK) { - throw new Error( - "Cannot enable WebAuthn encryption - user session not active. Please log in first.", - ); - } - - const systemKey = await this.deriveWebAuthnSystemKey(userId); - const encryptedDEK = this.encryptDEK(existingDEK, systemKey); - systemKey.fill(0); - - await this.storeWebAuthnEncryptedDEK(userId, encryptedDEK); - } - - async authenticateWebAuthnUser( - userId: string, - sessionDurationMs: number, - ): Promise { - try { - const encryptedDEK = await this.getWebAuthnEncryptedDEK(userId); - if (!encryptedDEK) { - return false; - } - - const DEK = await this.decryptSystemWrappedDEK( - encryptedDEK, - userId, - "webauthn", - `user_encrypted_dek_webauthn_${userId}`, - ); - - if (!DEK || DEK.length === 0) { - return false; - } - - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(DEK), - expiresAt: Date.now() + sessionDurationMs, - }); - - DEK.fill(0); - return true; - } catch (error) { - databaseLogger.warn("WebAuthn authentication failed", { - operation: "webauthn_auth_failed", - userId, - error: error instanceof Error ? error.message : "Unknown", - }); - return false; - } - } - - getUserDataKey(userId: string): Buffer | null { - const session = this.userSessions.get(userId); - if (!session) { - return null; - } - - const now = Date.now(); - - if (now > session.expiresAt) { - this.userSessions.delete(userId); - session.dataKey.fill(0); - if (this.sessionExpiredCallback) { - this.sessionExpiredCallback(userId); - } - return null; - } - - return session.dataKey; - } - - restoreUserDataKey(userId: string, dataKey: Buffer, expiresAt: number): void { - const oldSession = this.userSessions.get(userId); - if (oldSession) { - oldSession.dataKey.fill(0); - } - - this.userSessions.set(userId, { - dataKey: Buffer.from(dataKey), - expiresAt, - lastActivity: Date.now(), - }); - } - - logoutUser(userId: string): void { - const session = this.userSessions.get(userId); - if (session) { - session.dataKey.fill(0); - this.userSessions.delete(userId); - } - } - - isUserUnlocked(userId: string): boolean { - return this.getUserDataKey(userId) !== null; - } - - async changeUserPassword( - userId: string, - oldPassword: string, - newPassword: string, - ): Promise { - try { - const isValid = await this.validatePassword(userId, oldPassword); - if (!isValid) return false; - - const kekSalt = await this.getKEKSalt(userId); - if (!kekSalt) return false; - - const oldKEK = this.deriveKEK(oldPassword, kekSalt); - const encryptedDEK = await this.getEncryptedDEK(userId); - if (!encryptedDEK) return false; - - const DEK = this.decryptDEK(encryptedDEK, oldKEK); - - const newKekSalt = await this.generateKEKSalt(); - const newKEK = this.deriveKEK(newPassword, newKekSalt); - - const newEncryptedDEK = this.encryptDEK(DEK, newKEK); - - await this.storeKEKSalt(userId, newKekSalt); - await this.storeEncryptedDEK(userId, newEncryptedDEK); - - oldKEK.fill(0); - newKEK.fill(0); - DEK.fill(0); - - return true; - } catch (error) { - databaseLogger.error("Password change failed", error, { - operation: "password_change_error", - userId, - error: error instanceof Error ? error.message : "Unknown error", - }); - return false; - } - } - - async resetUserPasswordWithPreservedDEK( - userId: string, - newPassword: string, - ): Promise { - try { - const existingDEK = this.getUserDataKey(userId); - if (!existingDEK) { - return false; - } - - const newKekSalt = await this.generateKEKSalt(); - const newKEK = this.deriveKEK(newPassword, newKekSalt); - - const newEncryptedDEK = this.encryptDEK(existingDEK, newKEK); - - await this.storeKEKSalt(userId, newKekSalt); - await this.storeEncryptedDEK(userId, newEncryptedDEK); - - newKEK.fill(0); - - const session = this.userSessions.get(userId); - if (session) { - session.lastActivity = Date.now(); - } - - return true; - } catch (error) { - databaseLogger.error("Password reset with preserved DEK failed", error, { - operation: "password_reset_preserve_error", - userId, - error: error instanceof Error ? error.message : "Unknown error", - }); - return false; - } - } - - async convertToOIDCEncryption(userId: string): Promise { - try { - const existingEncryptedDEK = await this.getEncryptedDEK(userId); - const existingKEKSalt = await this.getKEKSalt(userId); - - if (!existingEncryptedDEK && !existingKEKSalt) { - databaseLogger.warn("No existing encryption to convert for user", { - operation: "convert_to_oidc_encryption_skip", - userId, - }); - return; - } - - const existingDEK = this.getUserDataKey(userId); - - if (!existingDEK) { - throw new Error( - "Cannot convert to OIDC encryption - user session not active. Please log in with password first.", - ); - } - - const systemKey = await this.deriveOIDCSystemKey(userId); - const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey); - systemKey.fill(0); - - const key = `user_encrypted_dek_oidc_${userId}`; - const value = JSON.stringify(oidcEncryptedDEK); - - await this.setSetting(key, value); - - databaseLogger.info( - "Converted user encryption to dual-auth (password + OIDC)", - { - operation: "convert_to_oidc_encryption_preserved", - userId, - }, - ); - } catch (error) { - databaseLogger.error("Failed to convert to OIDC encryption", error, { - operation: "convert_to_oidc_encryption_error", - userId, - error: error instanceof Error ? error.message : "Unknown error", - }); - throw error; - } - } - - private async validatePassword( - userId: string, - password: string, - ): Promise { - try { - const kekSalt = await this.getKEKSalt(userId); - if (!kekSalt) return false; - - const KEK = this.deriveKEK(password, kekSalt); - const encryptedDEK = await this.getEncryptedDEK(userId); - if (!encryptedDEK) return false; - - const DEK = this.decryptDEK(encryptedDEK, KEK); - - KEK.fill(0); - DEK.fill(0); - - return true; - } catch { - return false; - } - } - - private cleanupExpiredSessions(): void { - const now = Date.now(); - const expiredUsers: string[] = []; - - for (const [userId, session] of this.userSessions.entries()) { - if (now > session.expiresAt) { - session.dataKey.fill(0); - expiredUsers.push(userId); - } - } - - expiredUsers.forEach((userId) => { - this.userSessions.delete(userId); - }); - } - - private async generateKEKSalt(): Promise { - return { - salt: crypto.randomBytes(32).toString("hex"), - iterations: UserCrypto.PBKDF2_ITERATIONS, - algorithm: "pbkdf2-sha256", - createdAt: new Date().toISOString(), - }; - } - - private deriveKEK(password: string, kekSalt: KEKSalt): Buffer { - return crypto.pbkdf2Sync( - password, - Buffer.from(kekSalt.salt, "hex"), - kekSalt.iterations, - UserCrypto.KEK_LENGTH, - "sha256", - ); - } - - private async deriveOIDCSystemKey(userId: string): Promise { - if (process.env.OIDC_SYSTEM_SECRET) { - return crypto.pbkdf2Sync( - process.env.OIDC_SYSTEM_SECRET, - Buffer.from(userId, "utf8"), - 100000, - UserCrypto.KEK_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"), - UserCrypto.KEK_LENGTH, - ), - ); - } - - private async deriveWebAuthnSystemKey(userId: string): Promise { - const configuredSecret = - process.env.WEBAUTHN_SYSTEM_SECRET || process.env.OIDC_SYSTEM_SECRET; - if (configuredSecret) { - return crypto.pbkdf2Sync( - configuredSecret, - Buffer.from(`webauthn:${userId}`, "utf8"), - 100000, - UserCrypto.KEK_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"), - UserCrypto.KEK_LENGTH, - ), - ); - } - - private deriveLegacySystemKey( - 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, - UserCrypto.KEK_LENGTH, - "sha256", - ); - } - - private async decryptSystemWrappedDEK( - encryptedDEK: EncryptedDEK, - userId: string, - type: "oidc" | "webauthn", - settingKey: string, - ): Promise { - const systemKey = - type === "oidc" - ? await this.deriveOIDCSystemKey(userId) - : await this.deriveWebAuthnSystemKey(userId); - try { - return this.decryptDEK(encryptedDEK, systemKey); - } catch { - const legacyKey = this.deriveLegacySystemKey(userId, type); - try { - const dek = this.decryptDEK(encryptedDEK, legacyKey); - const value = JSON.stringify(this.encryptDEK(dek, systemKey)); - await createCurrentSettingsRepository().set(settingKey, value); - databaseLogger.info("Migrated legacy system-wrapped user key", { - operation: "user_key_wrap_migrated", - userId, - type, - }); - return dek; - } finally { - legacyKey.fill(0); - } - } finally { - systemKey.fill(0); - } - } - - private encryptDEK(dek: Buffer, kek: Buffer): EncryptedDEK { - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv); - - let encrypted = cipher.update(dek); - encrypted = Buffer.concat([encrypted, cipher.final()]); - const tag = cipher.getAuthTag(); - - return { - data: encrypted.toString("hex"), - iv: iv.toString("hex"), - tag: tag.toString("hex"), - algorithm: "aes-256-gcm", - createdAt: new Date().toISOString(), - }; - } - - private decryptDEK(encryptedDEK: EncryptedDEK, kek: Buffer): Buffer { - const decipher = crypto.createDecipheriv( - "aes-256-gcm", - kek, - Buffer.from(encryptedDEK.iv, "hex"), - ); - - decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex")); - let decrypted = decipher.update(Buffer.from(encryptedDEK.data, "hex")); - decrypted = Buffer.concat([decrypted, decipher.final()]); - - return decrypted; - } - - private async setSetting(key: string, value: string): Promise { - await createCurrentSettingsRepository().set(key, value); - } - - private async getSetting(key: string): Promise { - return createCurrentSettingsRepository().get(key); - } - - private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise { - const key = `user_kek_salt_${userId}`; - const value = JSON.stringify(kekSalt); - - await this.setSetting(key, value); - } - - private async getKEKSalt(userId: string): Promise { - try { - const key = `user_kek_salt_${userId}`; - const value = await this.getSetting(key); - - if (value === null) { - return null; - } - - return JSON.parse(value); - } catch { - return null; - } - } - - private async storeEncryptedDEK( - userId: string, - encryptedDEK: EncryptedDEK, - ): Promise { - const key = `user_encrypted_dek_${userId}`; - const value = JSON.stringify(encryptedDEK); - - await this.setSetting(key, value); - } - - private async getEncryptedDEK(userId: string): Promise { - try { - const key = `user_encrypted_dek_${userId}`; - const value = await this.getSetting(key); - - if (value === null) { - return null; - } - - return JSON.parse(value); - } catch { - return null; - } - } - - private async getOIDCEncryptedDEK( - userId: string, - ): Promise { - try { - const key = `user_encrypted_dek_oidc_${userId}`; - const value = await this.getSetting(key); - - if (value === null) { - return null; - } - - return JSON.parse(value); - } catch { - return null; - } - } - - private async storeWebAuthnEncryptedDEK( - userId: string, - encryptedDEK: EncryptedDEK, - ): Promise { - const key = `user_encrypted_dek_webauthn_${userId}`; - const value = JSON.stringify(encryptedDEK); - await this.setSetting(key, value); - } - - private async getWebAuthnEncryptedDEK( - userId: string, - ): Promise { - try { - const key = `user_encrypted_dek_webauthn_${userId}`; - const value = await this.getSetting(key); - - if (value === null) { - return null; - } - - return JSON.parse(value); - } catch { - return null; - } - } -} - -export { UserCrypto, type KEKSalt, type EncryptedDEK };