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:
LukeGus
2026-07-16 00:19:12 -05:00
parent 16506de9da
commit ee7768dd86
14 changed files with 234 additions and 1114 deletions
@@ -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)
-12
View File
@@ -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;
+2 -3
View File
@@ -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");
+3 -6
View File
@@ -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");
}
+3 -4
View File
@@ -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({
+3 -3
View File
@@ -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");
}
+1 -1
View File
@@ -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();
});
});
+76 -253
View File
@@ -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(
+6 -10
View File
@@ -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 {
-773
View File
@@ -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 };