mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 21:33:41 +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() }) },
|
DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../utils/user-crypto.js", () => ({
|
|
||||||
UserCrypto: { getInstance: () => ({ getUserKey: vi.fn() }) },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../repositories/factory.js", () => ({
|
vi.mock("../repositories/factory.js", () => ({
|
||||||
createCurrentTermixIdentityCaRepository: vi.fn(() => ({
|
createCurrentTermixIdentityCaRepository: vi.fn(() => ({
|
||||||
findPublicByIdentityId: vi.fn(),
|
findPublicByIdentityId: vi.fn(),
|
||||||
|
|||||||
@@ -119,40 +119,6 @@ export function registerUserOidcAccountRoutes(
|
|||||||
scopes: oidcUser.scopes || "openid email profile",
|
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);
|
await authManager.revokeAllUserSessions(oidcUserId);
|
||||||
authManager.logoutUser(oidcUserId);
|
authManager.logoutUser(oidcUserId);
|
||||||
|
|
||||||
|
|||||||
@@ -251,8 +251,6 @@ export function registerUserWebAuthnRoutes(
|
|||||||
(req.body?.response as RegistrationResponseJSON | undefined)?.response
|
(req.body?.response as RegistrationResponseJSON | undefined)?.response
|
||||||
?.transports ?? [];
|
?.transports ?? [];
|
||||||
|
|
||||||
await authManager.setupWebAuthnUserEncryption(userId);
|
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
typeof req.body?.name === "string" && req.body.name.trim()
|
typeof req.body?.name === "string" && req.body.name.trim()
|
||||||
? req.body.name.trim().slice(0, 80)
|
? 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" });
|
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);
|
const deviceInfo = parseUserAgent(req);
|
||||||
|
|
||||||
let dataUnlocked = false;
|
let dataUnlocked = false;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||||
import { SerialPort } from "serialport";
|
import { SerialPort } from "serialport";
|
||||||
import { AuthManager } from "../utils/auth-manager.js";
|
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";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
interface SerialConnectData {
|
interface SerialConnectData {
|
||||||
@@ -18,7 +18,6 @@ interface WebSocketMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const userCrypto = UserCrypto.getInstance();
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ port: 30011 });
|
const wss = new WebSocketServer({ port: 30011 });
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataKey = userCrypto.getUserDataKey(userId);
|
const dataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!dataKey) {
|
if (!dataKey) {
|
||||||
ws.send(JSON.stringify({ type: "error", data: "Data locked" }));
|
ws.send(JSON.stringify({ type: "error", data: "Data locked" }));
|
||||||
ws.close(1008, "Data access required");
|
ws.close(1008, "Data access required");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { WebSocket } from "ws";
|
|||||||
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
|
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
import { createCurrentOpksshTokenRepository } from "../database/repositories/factory.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 { FieldCrypto } from "../utils/field-crypto.js";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -647,12 +647,10 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
|||||||
|
|
||||||
async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
|
async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userCrypto = UserCrypto.getInstance();
|
|
||||||
|
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||||
|
|
||||||
const userDataKey = userCrypto.getUserDataKey(session.userId);
|
const userDataKey = DataCrypto.getUserDataKey(session.userId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
throw new Error("User data key not found");
|
throw new Error("User data key not found");
|
||||||
}
|
}
|
||||||
@@ -729,8 +727,7 @@ export async function getOPKSSHToken(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCrypto = UserCrypto.getInstance();
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
const userDataKey = userCrypto.getUserDataKey(userId);
|
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
throw new Error("User data key not found");
|
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 { sshLogger, authLogger } from "../utils/logger.js";
|
||||||
import { logAudit } from "../utils/audit-logger.js";
|
import { logAudit } from "../utils/audit-logger.js";
|
||||||
import { AuthManager } from "../utils/auth-manager.js";
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
import { UserCrypto } from "../utils/user-crypto.js";
|
import { DataCrypto } from "../utils/data-crypto.js";
|
||||||
import {
|
import {
|
||||||
createSocks5Connection,
|
createSocks5Connection,
|
||||||
type SOCKS5Config,
|
type SOCKS5Config,
|
||||||
@@ -98,7 +98,6 @@ interface WebSocketMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const userCrypto = UserCrypto.getInstance();
|
|
||||||
|
|
||||||
const userConnections = new Map<string, Set<WebSocket>>();
|
const userConnections = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
@@ -158,7 +157,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataKey = userCrypto.getUserDataKey(userId);
|
const dataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!dataKey) {
|
if (!dataKey) {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -268,7 +267,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.on("message", async (msg: RawData) => {
|
ws.on("message", async (msg: RawData) => {
|
||||||
const currentDataKey = userCrypto.getUserDataKey(userId);
|
const currentDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!currentDataKey) {
|
if (!currentDataKey) {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
// is re-exported here so callers have a single import surface.
|
// is re-exported here so callers have a single import surface.
|
||||||
|
|
||||||
import { createCurrentVaultTokenRepository } from "../database/repositories/factory.js";
|
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 { FieldCrypto } from "../utils/field-crypto.js";
|
||||||
import { parseCertValidBefore } from "./vault-signer-core.js";
|
import { parseCertValidBefore } from "./vault-signer-core.js";
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export async function storeVaultCert(
|
|||||||
privateKey: string,
|
privateKey: string,
|
||||||
signedCert: string,
|
signedCert: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const userDataKey = UserCrypto.getInstance().getUserDataKey(userId);
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
throw new Error("User data key not found");
|
throw new Error("User data key not found");
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ export async function getVaultCert(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userDataKey = UserCrypto.getInstance().getUserDataKey(userId);
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!userDataKey) {
|
if (!userDataKey) {
|
||||||
throw new Error("User data key not found");
|
throw new Error("User data key not found");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ import {
|
|||||||
|
|
||||||
const { runBootDekMigration } =
|
const { runBootDekMigration } =
|
||||||
await import("./utils/crypto-migration/dek-migration.js");
|
await import("./utils/crypto-migration/dek-migration.js");
|
||||||
await runBootDekMigration();
|
await runBootDekMigration({ cleanupLegacy: true });
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const mocks = vi.hoisted(() => ({
|
|||||||
run: (...values: unknown[]) => unknown;
|
run: (...values: unknown[]) => unknown;
|
||||||
};
|
};
|
||||||
} | null,
|
} | null,
|
||||||
logoutUser: vi.fn(),
|
|
||||||
saveDatabase: vi.fn().mockResolvedValue(undefined),
|
saveDatabase: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -25,11 +24,12 @@ vi.mock("../database/db/index.js", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("./user-crypto.js", () => ({
|
vi.mock("./user-keys.js", () => ({
|
||||||
UserCrypto: {
|
UserKeyManager: {
|
||||||
getInstance: () => ({
|
getInstance: () => ({
|
||||||
setSessionExpiredCallback: vi.fn(),
|
invalidate: vi.fn(),
|
||||||
logoutUser: mocks.logoutUser,
|
tryGetUserDEK: vi.fn(() => null),
|
||||||
|
hasUserDEK: vi.fn(() => true),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -120,7 +120,6 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.sqlite!.exec("DELETE FROM sessions");
|
mocks.sqlite!.exec("DELETE FROM sessions");
|
||||||
mocks.logoutUser.mockReset();
|
|
||||||
mocks.saveDatabase.mockReset().mockResolvedValue(undefined);
|
mocks.saveDatabase.mockReset().mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +155,6 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
|||||||
).resolves.toBe(1);
|
).resolves.toBe(1);
|
||||||
|
|
||||||
expect(sessionIds()).toEqual(["other-provider", "other-session"]);
|
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 () => {
|
it("revokes all provider sessions for a subject when sid is absent", async () => {
|
||||||
@@ -190,8 +188,6 @@ describe("AuthManager.revokeSessionsByOidc", () => {
|
|||||||
).resolves.toBe(2);
|
).resolves.toBe(2);
|
||||||
|
|
||||||
expect(sessionIds()).toEqual(["other-subject"]);
|
expect(sessionIds()).toEqual(["other-subject"]);
|
||||||
expect(mocks.logoutUser).toHaveBeenCalledOnce();
|
|
||||||
expect(mocks.logoutUser).toHaveBeenCalledWith("user-1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not persist when no session matches", async () => {
|
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 jwt from "jsonwebtoken";
|
||||||
import crypto from "crypto";
|
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 { SystemCrypto } from "./system-crypto.js";
|
||||||
import { DataCrypto } from "./data-crypto.js";
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
|
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
|
||||||
@@ -18,6 +22,7 @@ import {
|
|||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
createCurrentApiKeyRepository,
|
createCurrentApiKeyRepository,
|
||||||
createCurrentTrustedDeviceRepository,
|
createCurrentTrustedDeviceRepository,
|
||||||
|
getCurrentSettingValue,
|
||||||
} from "../database/repositories/factory.js";
|
} from "../database/repositories/factory.js";
|
||||||
|
|
||||||
interface AuthenticationResult {
|
interface AuthenticationResult {
|
||||||
@@ -64,15 +69,11 @@ interface RequestWithHeaders extends Request {
|
|||||||
class AuthManager {
|
class AuthManager {
|
||||||
private static instance: AuthManager;
|
private static instance: AuthManager;
|
||||||
private systemCrypto: SystemCrypto;
|
private systemCrypto: SystemCrypto;
|
||||||
private userCrypto: UserCrypto;
|
private userKeys: UserKeyManager;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.systemCrypto = SystemCrypto.getInstance();
|
this.systemCrypto = SystemCrypto.getInstance();
|
||||||
this.userCrypto = UserCrypto.getInstance();
|
this.userKeys = UserKeyManager.getInstance();
|
||||||
|
|
||||||
this.userCrypto.setSessionExpiredCallback((userId: string) => {
|
|
||||||
this.invalidateUserTokens(userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
@@ -101,88 +102,65 @@ class AuthManager {
|
|||||||
await this.systemCrypto.initializeJWTSecret();
|
await this.systemCrypto.initializeJWTSecret();
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerUser(userId: string, password: string): Promise<void> {
|
async registerUser(userId: string, _password?: string): Promise<void> {
|
||||||
await this.userCrypto.setupUserEncryption(userId, password);
|
if (!this.userKeys.hasUserDEK(userId)) {
|
||||||
|
await this.userKeys.createUserDEK(userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerOIDCUser(
|
async registerOIDCUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
sessionDurationMs: number,
|
_sessionDurationMs?: number,
|
||||||
): Promise<void> {
|
): 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(
|
async authenticateOIDCUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
deviceType?: DeviceType,
|
_deviceType?: DeviceType,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const sessionDurationMs =
|
if (!this.userKeys.hasUserDEK(userId)) {
|
||||||
deviceType === "desktop" || deviceType === "mobile"
|
await this.userKeys.createUserDEK(userId);
|
||||||
? 30 * 24 * 60 * 60 * 1000
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const authenticated = await this.userCrypto.authenticateOIDCUser(
|
|
||||||
userId,
|
|
||||||
sessionDurationMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (authenticated) {
|
|
||||||
await this.performLazyEncryptionMigration(userId);
|
|
||||||
}
|
}
|
||||||
|
return this.ensureUserDEK(userId);
|
||||||
return authenticated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticateUser(
|
async authenticateUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
password: string,
|
password: string,
|
||||||
deviceType?: DeviceType,
|
_deviceType?: DeviceType,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const sessionDurationMs =
|
// Password-wrapped legacy DEKs can only be recovered while the password
|
||||||
deviceType === "desktop" || deviceType === "mobile"
|
// is in hand, so login is where those users migrate to the v3 wrap.
|
||||||
? 30 * 24 * 60 * 60 * 1000
|
const migrated = await migratePasswordUserAtLogin(userId, password);
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const authenticated = await this.userCrypto.authenticateUser(
|
if (!migrated && !this.userKeys.hasUserDEK(userId)) {
|
||||||
userId,
|
const raw = getCurrentSettingValue(`user_encrypted_dek_${userId}`);
|
||||||
password,
|
if (raw) {
|
||||||
sessionDurationMs,
|
// A legacy wrap exists but the password could not unwrap it.
|
||||||
);
|
return false;
|
||||||
|
}
|
||||||
if (authenticated) {
|
await this.userKeys.createUserDEK(userId);
|
||||||
await this.performLazyEncryptionMigration(userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return authenticated;
|
return this.ensureUserDEK(userId);
|
||||||
}
|
|
||||||
|
|
||||||
async setupWebAuthnUserEncryption(userId: string): Promise<void> {
|
|
||||||
await this.userCrypto.setupWebAuthnUserEncryption(userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticateWebAuthnUser(
|
async authenticateWebAuthnUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
deviceType?: DeviceType,
|
_deviceType?: DeviceType,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const sessionDurationMs =
|
return this.ensureUserDEK(userId);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
||||||
@@ -232,27 +210,6 @@ class AuthManager {
|
|||||||
return Buffer.from(`${userId}:${sessionId || ""}`, "utf8");
|
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(
|
private async unwrapUserDataKey(
|
||||||
userId: string,
|
userId: string,
|
||||||
sessionId: string | undefined,
|
sessionId: string | undefined,
|
||||||
@@ -279,41 +236,11 @@ class AuthManager {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addWrappedDataKey(payload: JWTPayload): Promise<void> {
|
// Upgrade shim, remove in the release after 2.5: tokens issued before the
|
||||||
if (payload.pendingTOTP) {
|
// v3 key wrap carried the user's DEK wrapped with the system ENCRYPTION_KEY.
|
||||||
return;
|
// 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)) {
|
||||||
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()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,11 +250,10 @@ class AuthManager {
|
|||||||
payload.sessionId,
|
payload.sessionId,
|
||||||
payload.dataKeyWrap,
|
payload.dataKeyWrap,
|
||||||
);
|
);
|
||||||
this.userCrypto.restoreUserDataKey(payload.userId, dataKey, expiresAt);
|
await adoptRecoveredDEK(payload.userId, dataKey);
|
||||||
dataKey.fill(0);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.warn("Failed to restore data key from session token", {
|
databaseLogger.warn("Failed to migrate data key from session token", {
|
||||||
operation: "session_data_key_restore_failed",
|
operation: "session_data_key_migrate_failed",
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
@@ -374,7 +300,6 @@ class AuthManager {
|
|||||||
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
|
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
|
||||||
const sessionId = nanoid();
|
const sessionId = nanoid();
|
||||||
payload.sessionId = sessionId;
|
payload.sessionId = sessionId;
|
||||||
await this.addWrappedDataKey(payload);
|
|
||||||
|
|
||||||
const token = jwt.sign(payload, jwtSecret, {
|
const token = jwt.sign(payload, jwtSecret, {
|
||||||
expiresIn,
|
expiresIn,
|
||||||
@@ -410,7 +335,6 @@ class AuthManager {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.addWrappedDataKey(payload);
|
|
||||||
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
|
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,10 +380,7 @@ class AuthManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.restoreDataKeyFromPayload(
|
await this.migrateDataKeyFromPayload(payload);
|
||||||
payload,
|
|
||||||
sessionRecord.expiresAt,
|
|
||||||
);
|
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
"Failed to check session in database during JWT verification",
|
"Failed to check session in database during JWT verification",
|
||||||
@@ -472,7 +393,7 @@ class AuthManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.restoreDataKeyFromPayload(payload);
|
await this.migrateDataKeyFromPayload(payload);
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -503,7 +424,6 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload: JWTPayload = { userId, sessionId };
|
const payload: JWTPayload = { userId, sessionId };
|
||||||
await this.addWrappedDataKey(payload);
|
|
||||||
|
|
||||||
const token = jwt.sign(payload, await this.systemCrypto.getJWTSecret(), {
|
const token = jwt.sign(payload, await this.systemCrypto.getJWTSecret(), {
|
||||||
expiresIn: Math.ceil(maxAge / 1000),
|
expiresIn: Math.ceil(maxAge / 1000),
|
||||||
@@ -605,16 +525,6 @@ class AuthManager {
|
|||||||
sessionCount: matchedIds.length,
|
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 } =
|
const { saveMemoryDatabaseToFile } =
|
||||||
await import("../database/db/index.js");
|
await import("../database/db/index.js");
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
@@ -642,15 +552,6 @@ class AuthManager {
|
|||||||
|
|
||||||
await sessionRepository.deleteExpired(now);
|
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;
|
return expiredCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to cleanup expired sessions", 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.userId = matchedKey.userId;
|
||||||
req.apiKeyId = matchedKey.id;
|
req.apiKeyId = matchedKey.id;
|
||||||
next();
|
next();
|
||||||
@@ -876,37 +745,12 @@ class AuthManager {
|
|||||||
difference: currentTime - sessionExpiryTime,
|
difference: currentTime - sessionExpiryTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionRepository
|
sessionRepository.revoke(payload.sessionId).catch((error) => {
|
||||||
.revoke(payload.sessionId)
|
databaseLogger.error("Failed to delete expired session", error, {
|
||||||
.then(async () => {
|
operation: "expired_session_delete_failed",
|
||||||
try {
|
sessionId: payload.sessionId,
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return res
|
return res
|
||||||
.clearCookie("jwt", this.getClearCookieOptions(req))
|
.clearCookie("jwt", this.getClearCookieOptions(req))
|
||||||
@@ -951,8 +795,7 @@ class AuthManager {
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataKey = this.userCrypto.getUserDataKey(userId);
|
authReq.dataKey = this.userKeys.tryGetUserDEK(userId) ?? undefined;
|
||||||
authReq.dataKey = dataKey || undefined;
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1035,65 +878,45 @@ class AuthManager {
|
|||||||
async logoutUser(userId: string, sessionId?: string): Promise<void> {
|
async logoutUser(userId: string, sessionId?: string): Promise<void> {
|
||||||
const sessionRepository = createCurrentSessionRepository();
|
const sessionRepository = createCurrentSessionRepository();
|
||||||
|
|
||||||
if (sessionId) {
|
try {
|
||||||
try {
|
if (sessionId) {
|
||||||
await sessionRepository.revoke(sessionId);
|
await sessionRepository.revoke(sessionId);
|
||||||
|
} else {
|
||||||
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 {
|
|
||||||
await sessionRepository.revokeAllForUser(userId);
|
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 {
|
getUserDataKey(userId: string): Buffer | null {
|
||||||
return this.userCrypto.getUserDataKey(userId);
|
return this.userKeys.tryGetUserDEK(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
isUserUnlocked(userId: string): boolean {
|
isUserUnlocked(userId: string): boolean {
|
||||||
return this.userCrypto.isUserUnlocked(userId);
|
return this.userKeys.tryGetUserDEK(userId) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeUserPassword(
|
async changeUserPassword(
|
||||||
userId: string,
|
userId: string,
|
||||||
oldPassword: string,
|
oldPassword: string,
|
||||||
newPassword: string,
|
_newPassword?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return await this.userCrypto.changeUserPassword(
|
// The DEK is no longer derived from the password; the old password is
|
||||||
userId,
|
// only needed when the user still has a legacy password-wrapped key.
|
||||||
oldPassword,
|
await migratePasswordUserAtLogin(userId, oldPassword);
|
||||||
newPassword,
|
return this.userKeys.tryGetUserDEK(userId) !== null;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetUserPasswordWithPreservedDEK(
|
async resetUserPasswordWithPreservedDEK(
|
||||||
userId: string,
|
userId: string,
|
||||||
newPassword: string,
|
_newPassword?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return await this.userCrypto.resetUserPasswordWithPreservedDEK(
|
return this.userKeys.tryGetUserDEK(userId) !== null;
|
||||||
userId,
|
|
||||||
newPassword,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async isTrustedDevice(
|
async isTrustedDevice(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FieldCrypto } from "./field-crypto.js";
|
import { FieldCrypto } from "./field-crypto.js";
|
||||||
import { LazyFieldEncryption } from "./lazy-field-encryption.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 { DatabaseSaveTrigger } from "./database-save-trigger.js";
|
||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
} from "./user-encryption-migration-store.js";
|
} from "./user-encryption-migration-store.js";
|
||||||
|
|
||||||
class DataCrypto {
|
class DataCrypto {
|
||||||
private static userCrypto: UserCrypto;
|
private static userKeys: UserKeyManager;
|
||||||
|
|
||||||
static initialize() {
|
static initialize() {
|
||||||
this.userCrypto = UserCrypto.getInstance();
|
this.userKeys = UserKeyManager.getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
static encryptRecord<T extends Record<string, unknown>>(
|
static encryptRecord<T extends Record<string, unknown>>(
|
||||||
@@ -235,7 +235,7 @@ class DataCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getUserDataKey(userId: string): Buffer | null {
|
static getUserDataKey(userId: string): Buffer | null {
|
||||||
return this.userCrypto.getUserDataKey(userId);
|
return this.userKeys.tryGetUserDEK(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async reencryptUserDataAfterPasswordReset(
|
static async reencryptUserDataAfterPasswordReset(
|
||||||
@@ -413,11 +413,7 @@ class DataCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static validateUserAccess(userId: string): Buffer {
|
static validateUserAccess(userId: string): Buffer {
|
||||||
const userDataKey = this.getUserDataKey(userId);
|
return this.userKeys.getUserDEK(userId);
|
||||||
if (!userDataKey) {
|
|
||||||
throw new Error(`User ${userId} data not unlocked`);
|
|
||||||
}
|
|
||||||
return userDataKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static encryptRecordForUser<T extends Record<string, unknown>>(
|
static encryptRecordForUser<T extends Record<string, unknown>>(
|
||||||
@@ -448,7 +444,7 @@ class DataCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static canUserAccessData(userId: string): boolean {
|
static canUserAccessData(userId: string): boolean {
|
||||||
return this.userCrypto.isUserUnlocked(userId);
|
return this.userKeys.tryGetUserDEK(userId) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static testUserEncryption(userId: string): boolean {
|
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