Fix: Password Change/Reset Credential Preservation #383
@@ -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,48 @@ router.post("/complete-reset", async (req, res) => {
|
||||
.where(eq(users.username, username));
|
||||
|
||||
try {
|
||||
await authManager.registerUser(userId, newPassword);
|
||||
authManager.logoutUser(userId);
|
||||
const hasActiveSession = authManager.isUserUnlocked(userId);
|
||||
|
||||
if (hasActiveSession) {
|
||||
const success = await authManager.resetUserPasswordWithPreservedDEK(
|
||||
userId,
|
||||
newPassword,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
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);
|
||||
authManager.logoutUser(userId);
|
||||
} else {
|
||||
authLogger.success(
|
||||
`Password reset completed for user: ${username}. Data preserved using existing session.`,
|
||||
{
|
||||
operation: "password_reset_data_preserved",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
|
|
||||
}
|
||||
} else {
|
||||
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 +1368,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 +2044,9 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
.set({ password_hash: newPasswordHash })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
authLogger.success("User password changed successfully", {
|
||||
operation: "password_change_success",
|
||||
userId,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -263,19 +263,82 @@ class UserCrypto {
|
||||
|
||||
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);
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
oldKEK.fill(0);
|
||||
newKEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
this.logoutUser(userId);
|
||||
const dekCopy = Buffer.from(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,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user
In the failure scenario for preserving the DEK, the user's session is not terminated after re-registering the user with a new DEK. This leaves the user in an inconsistent state, where their active session contains a stale DEK that no longer corresponds to the encrypted data on disk. This could lead to unexpected errors.
To ensure a clean state, the user should be logged out, similar to the flow for a non-logged-in password reset.
File: users.ts
Fix: Added authManager.logoutUser(userId) after registerUser() in the failure scenario
Why: When DEK preservation fails and a new DEK is created, the old session with stale DEK must be terminated to avoid inconsistent state