From d87c7a80a88f204a89378b6bc7c2a4df5528e792 Mon Sep 17 00:00:00 2001 From: thorved <54140516+thorved@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:26:02 +0530 Subject: [PATCH] Improve password reset to preserve encrypted data during active sessions Enhances password reset logic to preserve encrypted user data when the user is logged in and has an active session. Introduces a fallback mechanism to create a new data encryption key (DEK) if preservation fails, ensuring user data integrity. Adds a dedicated method for preserving the DEK during password reset and updates related session management. Includes improved logging for better tracking of password reset operations and potential data loss scenarios. Fixes inefficiencies in password change and reset workflows by persisting encryption key changes promptly and cleaning up sensitive data from memory. --- src/backend/database/routes/users.ts | 62 ++++++++++++++---- src/backend/utils/auth-manager.ts | 10 +++ src/backend/utils/user-crypto.ts | 98 +++++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 15 deletions(-) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 3c2303cc..9b4b30f4 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -18,7 +18,6 @@ import QRCode from "qrcode"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { UserCrypto } from "../../utils/user-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; @@ -1318,8 +1317,52 @@ router.post("/complete-reset", async (req, res) => { .where(eq(users.username, username)); try { - await authManager.registerUser(userId, newPassword); - authManager.logoutUser(userId); + // Check if user has an active session with DEK (logged in while resetting) + const hasActiveSession = authManager.isUserUnlocked(userId); + + if (hasActiveSession) { + // User is logged in - preserve their data by keeping the same DEK + // This happens when user resets password from settings page + const success = await authManager.resetUserPasswordWithPreservedDEK( + userId, + newPassword, + ); + + if (!success) { + // If preservation fails, fall back to new DEK (will lose data) + authLogger.warn( + `Failed to preserve DEK during password reset for ${username}. Creating new DEK - data will be lost.`, + { + operation: "password_reset_preserve_failed", + userId, + username, + }, + ); + await authManager.registerUser(userId, newPassword); + } else { + authLogger.success( + `Password reset completed for user: ${username}. Data preserved using existing session.`, + { + operation: "password_reset_data_preserved", + userId, + username, + }, + ); + } + } else { + // User is NOT logged in - create new DEK (data will be inaccessible) + await authManager.registerUser(userId, newPassword); + authManager.logoutUser(userId); + + authLogger.warn( + `Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`, + { + operation: "password_reset_data_inaccessible", + userId, + username, + }, + ); + } await db .update(users) @@ -1329,15 +1372,6 @@ router.post("/complete-reset", async (req, res) => { totp_backup_codes: null, }) .where(eq(users.id, userId)); - - authLogger.warn( - `Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`, - { - operation: "password_reset_data_inaccessible", - userId, - username, - }, - ); } catch (encryptionError) { authLogger.error( "Failed to re-encrypt user data after password reset", @@ -2014,6 +2048,10 @@ router.post("/change-password", authenticateJWT, async (req, res) => { .set({ password_hash: newPasswordHash }) .where(eq(users.id, userId)); + // Trigger database save to persist password hash + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + authLogger.success("User password changed successfully", { operation: "password_change_success", userId, diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 541d889f..a13db6cd 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -295,6 +295,16 @@ class AuthManager { newPassword, ); } + + async resetUserPasswordWithPreservedDEK( + userId: string, + newPassword: string, + ): Promise { + return await this.userCrypto.resetUserPasswordWithPreservedDEK( + userId, + newPassword, + ); + } } export { AuthManager, type AuthenticationResult, type JWTPayload }; diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index 73a2e39f..ea55b8fe 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; import { getDb } from "../database/db/index.js"; -import { settings, users } from "../database/db/schema.js"; +import { settings } from "../database/db/schema.js"; import { eq } from "drizzle-orm"; import { databaseLogger } from "./logger.js"; @@ -249,6 +249,7 @@ class UserCrypto { newPassword: string, ): Promise { try { + // Validate current password const isValid = await this.validatePassword(userId, oldPassword); if (!isValid) return false; @@ -259,23 +260,114 @@ class UserCrypto { const encryptedDEK = await this.getEncryptedDEK(userId); if (!encryptedDEK) return false; + // Decrypt DEK with old password's KEK const DEK = this.decryptDEK(encryptedDEK, oldKEK); + // The DEK stays the same - only the KEK changes with the new password + // This preserves all encrypted user data + + // Generate new KEK from new password const newKekSalt = await this.generateKEKSalt(); const newKEK = this.deriveKEK(newPassword, newKekSalt); + + // Re-encrypt the same DEK with new KEK const newEncryptedDEK = this.encryptDEK(DEK, newKEK); + // Store new KEK salt and new encrypted DEK await this.storeKEKSalt(userId, newKekSalt); await this.storeEncryptedDEK(userId, newEncryptedDEK); + // Trigger database save to persist the encryption key changes + const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); + await saveMemoryDatabaseToFile(); + + // Clean up sensitive data from memory oldKEK.fill(0); newKEK.fill(0); - DEK.fill(0); - this.logoutUser(userId); + // Create a copy of DEK for the session before zeroing it out + const dekCopy = Buffer.allocUnsafe(DEK.length); + DEK.copy(dekCopy); + + // Keep user session active with the same DEK + const now = Date.now(); + const oldSession = this.userSessions.get(userId); + if (oldSession) { + oldSession.dataKey.fill(0); + } + + this.userSessions.set(userId, { + dataKey: dekCopy, + lastActivity: now, + expiresAt: now + UserCrypto.SESSION_DURATION, + }); + + // Zero out the original DEK from memory + DEK.fill(0); return true; } catch (error) { + databaseLogger.error("Password change failed", error, { + operation: "password_change_error", + userId, + error: error instanceof Error ? error.message : "Unknown error", + }); + return false; + } + } + + async resetUserPasswordWithPreservedDEK( + userId: string, + newPassword: string, + ): Promise { + try { + // This method preserves the existing DEK by re-encrypting it with the new password + // Used for password reset when user is logged in + + // Check if user has an active session with DEK + const existingDEK = this.getUserDataKey(userId); + if (!existingDEK) { + return false; + } + + const kekSalt = await this.getKEKSalt(userId); + if (!kekSalt) { + return false; + } + + // Generate new KEK from new password + const newKekSalt = await this.generateKEKSalt(); + const newKEK = this.deriveKEK(newPassword, newKekSalt); + + // Re-encrypt the existing DEK with new KEK + const newEncryptedDEK = this.encryptDEK(existingDEK, newKEK); + + // Store new KEK salt and new encrypted DEK + await this.storeKEKSalt(userId, newKekSalt); + await this.storeEncryptedDEK(userId, newEncryptedDEK); + + // Trigger database save to persist the encryption key changes + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); + await saveMemoryDatabaseToFile(); + + // Clean up sensitive data + newKEK.fill(0); + + // Update session activity timestamp + 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; } }