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.
This commit is contained in:
thorved
2025-10-08 12:26:02 +05:30
parent 9d78fca870
commit d87c7a80a8
3 changed files with 155 additions and 15 deletions

View File

@@ -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,

View File

@@ -295,6 +295,16 @@ class AuthManager {
newPassword,
);
}
async resetUserPasswordWithPreservedDEK(
userId: string,
newPassword: string,
): Promise<boolean> {
return await this.userCrypto.resetUserPasswordWithPreservedDEK(
userId,
newPassword,
);
}
}
export { AuthManager, type AuthenticationResult, type JWTPayload };

View File

@@ -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<boolean> {
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<boolean> {
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;
}
}