mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
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.
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<void> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<string, Set<WebSocket>>();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<string, unknown>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
await this.userCrypto.setupUserEncryption(userId, password);
|
||||
async registerUser(userId: string, _password?: string): Promise<void> {
|
||||
if (!this.userKeys.hasUserDEK(userId)) {
|
||||
await this.userKeys.createUserDEK(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async registerOIDCUser(
|
||||
userId: string,
|
||||
sessionDurationMs: number,
|
||||
_sessionDurationMs?: number,
|
||||
): Promise<void> {
|
||||
await this.userCrypto.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||
if (!this.userKeys.hasUserDEK(userId)) {
|
||||
await this.userKeys.createUserDEK(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUserDEK(userId: string): Promise<boolean> {
|
||||
if (this.userKeys.tryGetUserDEK(userId)) {
|
||||
await this.performLazyEncryptionMigration(userId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async authenticateOIDCUser(
|
||||
userId: string,
|
||||
deviceType?: DeviceType,
|
||||
_deviceType?: DeviceType,
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await this.userCrypto.setupWebAuthnUserEncryption(userId);
|
||||
return this.ensureUserDEK(userId);
|
||||
}
|
||||
|
||||
async authenticateWebAuthnUser(
|
||||
userId: string,
|
||||
deviceType?: DeviceType,
|
||||
_deviceType?: DeviceType,
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
await this.userCrypto.convertToOIDCEncryption(userId);
|
||||
return this.ensureUserDEK(userId);
|
||||
}
|
||||
|
||||
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
||||
@@ -232,27 +210,6 @@ class AuthManager {
|
||||
return Buffer.from(`${userId}:${sessionId || ""}`, "utf8");
|
||||
}
|
||||
|
||||
private async wrapUserDataKey(
|
||||
userId: string,
|
||||
sessionId: string | undefined,
|
||||
dataKey: Buffer,
|
||||
): Promise<WrappedDataKey> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
return await this.userCrypto.resetUserPasswordWithPreservedDEK(
|
||||
userId,
|
||||
newPassword,
|
||||
);
|
||||
return this.userKeys.tryGetUserDEK(userId) !== null;
|
||||
}
|
||||
|
||||
async isTrustedDevice(
|
||||
|
||||
@@ -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<T extends Record<string, unknown>>(
|
||||
@@ -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<T extends Record<string, unknown>>(
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, UserSession> = 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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<KEKSalt> {
|
||||
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<Buffer> {
|
||||
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<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,
|
||||
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<Buffer> {
|
||||
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<void> {
|
||||
await createCurrentSettingsRepository().set(key, value);
|
||||
}
|
||||
|
||||
private async getSetting(key: string): Promise<string | null> {
|
||||
return createCurrentSettingsRepository().get(key);
|
||||
}
|
||||
|
||||
private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise<void> {
|
||||
const key = `user_kek_salt_${userId}`;
|
||||
const value = JSON.stringify(kekSalt);
|
||||
|
||||
await this.setSetting(key, value);
|
||||
}
|
||||
|
||||
private async getKEKSalt(userId: string): Promise<KEKSalt | null> {
|
||||
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<void> {
|
||||
const key = `user_encrypted_dek_${userId}`;
|
||||
const value = JSON.stringify(encryptedDEK);
|
||||
|
||||
await this.setSetting(key, value);
|
||||
}
|
||||
|
||||
private async getEncryptedDEK(userId: string): Promise<EncryptedDEK | null> {
|
||||
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<EncryptedDEK | null> {
|
||||
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<void> {
|
||||
const key = `user_encrypted_dek_webauthn_${userId}`;
|
||||
const value = JSON.stringify(encryptedDEK);
|
||||
await this.setSetting(key, value);
|
||||
}
|
||||
|
||||
private async getWebAuthnEncryptedDEK(
|
||||
userId: string,
|
||||
): Promise<EncryptedDEK | null> {
|
||||
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 };
|
||||
Reference in New Issue
Block a user