FEATURE: Docker log-based password recovery with KEK-DEK preservation #303

Merged
ZacharyZcR merged 1 commits from main into dev-1.7.0 2025-09-26 21:30:10 +00:00
7 changed files with 1036 additions and 9 deletions

View File

@@ -423,6 +423,11 @@ const migrateSchema = () => {
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "totp_backup_codes", "TEXT"); addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
// Password recovery fields (UX compromise - breaks zero-trust for usability)
addColumnIfNotExists("users", "recovery_dek", "TEXT");
addColumnIfNotExists("users", "backup_encrypted_dek", "TEXT");
addColumnIfNotExists("users", "zero_trust_mode", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("ssh_data", "name", "TEXT"); addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists("ssh_data", "folder", "TEXT"); addColumnIfNotExists("ssh_data", "folder", "TEXT");
addColumnIfNotExists("ssh_data", "tags", "TEXT"); addColumnIfNotExists("ssh_data", "tags", "TEXT");

View File

@@ -23,6 +23,13 @@ export const users = sqliteTable("users", {
.notNull() .notNull()
.default(false), .default(false),
totp_backup_codes: text("totp_backup_codes"), totp_backup_codes: text("totp_backup_codes"),
// Password recovery fields (breaks zero-trust for UX)
recovery_dek: text("recovery_dek"), // Recovery DEK stored in plaintext
backup_encrypted_dek: text("backup_encrypted_dek"), // DEK encrypted with recovery DEK
zero_trust_mode: integer("zero_trust_mode", { mode: "boolean" })
.notNull()
.default(false), // false=compromise mode, true=zero-trust mode
}); });
export const settings = sqliteTable("settings", { export const settings = sqliteTable("settings", {

View File

@@ -1,4 +1,5 @@
import express from "express"; import express from "express";
import crypto from "crypto";
import { db } from "../db/index.js"; import { db } from "../db/index.js";
import { import {
users, users,
@@ -20,6 +21,7 @@ import { authLogger, apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js"; import { AuthManager } from "../../utils/auth-manager.js";
import { UserCrypto } from "../../utils/user-crypto.js"; import { UserCrypto } from "../../utils/user-crypto.js";
import { DataCrypto } from "../../utils/data-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js";
import { ZeroTrustMigration } from "../../utils/zero-trust-migration.js";
// Get auth manager instance // Get auth manager instance
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
@@ -1247,13 +1249,86 @@ router.post("/complete-reset", async (req, res) => {
return res.status(400).json({ error: "Invalid temporary token" }); return res.status(400).json({ error: "Invalid temporary token" });
} }
const saltRounds = parseInt(process.env.SALT || "10", 10); // Get user ID for KEK-DEK operations
const password_hash = await bcrypt.hash(newPassword, saltRounds); const user = await db.select().from(users).where(eq(users.username, username));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const userId = user[0].id;
await db // 🔥 CRITICAL FIX: Use recovery DEK to preserve user data
.update(users) if (user[0].recovery_dek && user[0].backup_encrypted_dek) {
.set({ password_hash }) try {
.where(eq(users.username, username)); const userCrypto = UserCrypto.getInstance();
// Decrypt DEK using recovery key
const recoveryDEK = Buffer.from(user[0].recovery_dek, 'hex');
const backupEncryptedDEK = JSON.parse(user[0].backup_encrypted_dek);
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, recoveryDEK);
// Generate new KEK with new password
const newKekSalt = await (userCrypto as any).generateKEKSalt();
const newKEK = (userCrypto as any).deriveKEK(newPassword, newKekSalt);
const newEncryptedDEK = (userCrypto as any).encryptDEK(originalDEK, newKEK);
// Update password hash
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
// Update everything atomically
await (userCrypto as any).storeKEKSalt(userId, newKekSalt);
await (userCrypto as any).storeEncryptedDEK(userId, newEncryptedDEK);
await db
.update(users)
.set({ password_hash })
.where(eq(users.username, username));
// Update recovery data for new password
const newRecoveryDEK = crypto.randomBytes(32);
const newBackupEncryptedDEK = (userCrypto as any).encryptDEK(originalDEK, newRecoveryDEK);
await db
.update(users)
.set({
recovery_dek: newRecoveryDEK.toString('hex'),
backup_encrypted_dek: JSON.stringify(newBackupEncryptedDEK),
})
.where(eq(users.id, userId));
// Clean sensitive data
originalDEK.fill(0);
newKEK.fill(0);
newRecoveryDEK.fill(0);
gemini-code-assist[bot] commented 2025-09-26 07:44:50 +00:00 (Migrated from github.com)
Review

high

The code frequently uses (userCrypto as any) to access methods like decryptDEK, generateKEKSalt, deriveKEK, encryptDEK, storeKEKSalt, and storeEncryptedDEK. This bypasses TypeScript's type safety, breaks the encapsulation of the UserCrypto class, and makes the code brittle and hard to refactor. These methods are likely private for a reason. A proper public API should be exposed on the UserCrypto class to handle the password reset and re-encryption flow in a controlled and type-safe manner.

![high](https://www.gstatic.com/codereviewagent/high-priority.svg) The code frequently uses `(userCrypto as any)` to access methods like `decryptDEK`, `generateKEKSalt`, `deriveKEK`, `encryptDEK`, `storeKEKSalt`, and `storeEncryptedDEK`. This bypasses TypeScript's type safety, breaks the encapsulation of the `UserCrypto` class, and makes the code brittle and hard to refactor. These methods are likely private for a reason. A proper public API should be exposed on the `UserCrypto` class to handle the password reset and re-encryption flow in a controlled and type-safe manner.
authLogger.success("Password reset with KEK-DEK re-encryption successful", {
operation: "password_reset_with_kek_dek",
username,
userId,
});
} catch (error) {
authLogger.error("Failed to re-encrypt KEK-DEK during password reset", error, {
operation: "password_reset_kek_dek_failed",
username,
});
return res.status(500).json({
error: "Failed to preserve encrypted data during password reset"
});
}
} else {
// Fallback: No recovery data available - user will lose encrypted data
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
await db
.update(users)
.set({ password_hash })
.where(eq(users.username, username));
authLogger.warn("Password reset without KEK-DEK preservation - encrypted data may be lost", {
operation: "password_reset_no_recovery",
username,
userId,
});
}
db.$client db.$client
.prepare("DELETE FROM settings WHERE key = ?") .prepare("DELETE FROM settings WHERE key = ?")
@@ -1927,4 +2002,378 @@ router.get("/security-status", authenticateJWT, async (req, res) => {
} }
}); });
// ===== Password Recovery API endpoints (UX compromise) =====
// Route: Request recovery code (outputs to Docker logs)
// POST /users/recovery/request
router.post("/recovery/request", async (req, res) => {
const { username } = req.body;
if (!isNonEmptyString(username)) {
return res.status(400).json({ error: "Username is required" });
}
try {
// Check if user exists
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
// Check if user has recovery data
if (!user[0].recovery_dek) {
return res.status(400).json({
error: "Recovery not available for this user"
});
}
// Generate 6-digit recovery code
const recoveryCode = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = Date.now() + 60 * 1000; // 1 minute expiry
// Store recovery code in settings
const key = `recovery_code_${username}`;
const value = JSON.stringify({
code: recoveryCode,
expiresAt,
attempts: 0
});
const existing = await db.select().from(settings).where(eq(settings.key, key));
if (existing.length > 0) {
await db.update(settings).set({ value }).where(eq(settings.key, key));
} else {
await db.insert(settings).values({ key, value });
}
// 🔥 Output to Docker logs for physical access verification
console.log(`[RECOVERY] Code for user '${username}': ${recoveryCode} (expires in 60s)`);
authLogger.info("Recovery code generated", {
operation: "recovery_code_generated",
username,
expiresAt,
});
res.json({
message: "Recovery code sent to system logs",
instruction: "Use 'docker logs termix | grep RECOVERY' to view the code"
});
} catch (error) {
authLogger.error("Failed to generate recovery code", error, {
operation: "recovery_request_failed",
username,
});
res.status(500).json({ error: "Failed to generate recovery code" });
}
});
// Route: Verify recovery code and auto-decrypt user data
// POST /users/recovery/verify
router.post("/recovery/verify", async (req, res) => {
const { username, code } = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(code)) {
return res.status(400).json({
error: "Username and recovery code are required"
});
}
try {
// Get and verify recovery code
const key = `recovery_code_${username}`;
const result = await db.select().from(settings).where(eq(settings.key, key));
if (result.length === 0) {
return res.status(400).json({ error: "No recovery code found" });
}
const codeData = JSON.parse(result[0].value);
const now = Date.now();
// Check expiry
if (now > codeData.expiresAt) {
await db.delete(settings).where(eq(settings.key, key));
return res.status(400).json({ error: "Recovery code has expired" });
}
// Check attempts (prevent brute force)
if (codeData.attempts >= 3) {
await db.delete(settings).where(eq(settings.key, key));
return res.status(400).json({ error: "Too many attempts" });
}
// Verify code
if (codeData.code !== code) {
// Increment attempts
codeData.attempts++;
await db.update(settings)
.set({ value: JSON.stringify(codeData) })
.where(eq(settings.key, key));
return res.status(400).json({ error: "Invalid recovery code" });
}
// Get user data
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user[0].recovery_dek || !user[0].backup_encrypted_dek) {
return res.status(400).json({ error: "Recovery data not available" });
}
// 🔥 Auto-decrypt using recovery DEK
const userCrypto = UserCrypto.getInstance();
const recoveryDEK = Buffer.from(user[0].recovery_dek, 'hex');
const backupEncryptedDEK = JSON.parse(user[0].backup_encrypted_dek);
try {
// Decrypt original DEK using recovery key
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, recoveryDEK);
// Create user session with decrypted DEK
const tempToken = crypto.randomBytes(32).toString('hex');
gemini-code-assist[bot] commented 2025-09-26 07:44:50 +00:00 (Migrated from github.com)
Review

medium

Math.random() is not a cryptographically secure random number generator. For security-sensitive operations like generating a recovery code, it is best practice to use a secure source of randomness. Node.js's crypto module provides a safer alternative.

    const recoveryCode = crypto.randomInt(100000, 1000000).toString();
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) `Math.random()` is not a cryptographically secure random number generator. For security-sensitive operations like generating a recovery code, it is best practice to use a secure source of randomness. Node.js's `crypto` module provides a safer alternative. ```suggestion const recoveryCode = crypto.randomInt(100000, 1000000).toString(); ```
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
// Store temporary session
const tempKey = `temp_recovery_session_${username}`;
gemini-code-assist[bot] commented 2025-09-26 07:44:50 +00:00 (Migrated from github.com)
Review

high

Constructing the database key for the recovery code using a raw username (recovery_code_${username}) is unsafe. If a username contains special characters, it could lead to unexpected behavior or potential injection vulnerabilities, depending on the underlying database driver. It is much safer to use a stable, sanitized identifier, such as the user's ID, to construct this key. This same issue applies to the temporary session key on line 2146.

![high](https://www.gstatic.com/codereviewagent/high-priority.svg) Constructing the database key for the recovery code using a raw username (`recovery_code_${username}`) is unsafe. If a username contains special characters, it could lead to unexpected behavior or potential injection vulnerabilities, depending on the underlying database driver. It is much safer to use a stable, sanitized identifier, such as the user's ID, to construct this key. This same issue applies to the temporary session key on line 2146.
const tempValue = JSON.stringify({
userId: user[0].id,
tempToken,
expiresAt,
dekHex: originalDEK.toString('hex')
});
const existingTemp = await db.select().from(settings).where(eq(settings.key, tempKey));
if (existingTemp.length > 0) {
await db.update(settings).set({ value: tempValue }).where(eq(settings.key, tempKey));
} else {
await db.insert(settings).values({ key: tempKey, value: tempValue });
}
// Clean up recovery code
await db.delete(settings).where(eq(settings.key, key));
authLogger.success("Recovery verification successful", {
operation: "recovery_verify_success",
username,
userId: user[0].id,
});
res.json({
success: true,
tempToken,
expiresAt,
message: "Recovery successful. You can now login normally.",
});
} catch (decryptError) {
authLogger.error("Failed to decrypt recovery data", decryptError, {
operation: "recovery_decrypt_failed",
username,
});
res.status(500).json({ error: "Failed to decrypt user data" });
}
} catch (error) {
authLogger.error("Recovery verification failed", error, {
operation: "recovery_verify_failed",
username,
});
res.status(500).json({ error: "Recovery verification failed" });
}
});
// Route: Login with recovery session (temporary login after recovery)
// POST /users/recovery/login
router.post("/recovery/login", async (req, res) => {
const { username, tempToken } = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(tempToken)) {
return res.status(400).json({
error: "Username and temporary token are required"
});
}
try {
// Get temporary session
const tempKey = `temp_recovery_session_${username}`;
const result = await db.select().from(settings).where(eq(settings.key, tempKey));
if (result.length === 0) {
return res.status(400).json({ error: "Invalid recovery session" });
}
const sessionData = JSON.parse(result[0].value);
const now = Date.now();
// Check expiry
if (now > sessionData.expiresAt) {
await db.delete(settings).where(eq(settings.key, tempKey));
return res.status(400).json({ error: "Recovery session has expired" });
}
// Verify token
if (sessionData.tempToken !== tempToken) {
return res.status(400).json({ error: "Invalid temporary token" });
}
// Get user
const user = await db.select().from(users).where(eq(users.id, sessionData.userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
// Create DEK session for user
const userCrypto = UserCrypto.getInstance();
const originalDEK = Buffer.from(sessionData.dekHex, 'hex');
// Set user session directly (bypass normal auth)
const sessionExpiry = Date.now() + 2 * 60 * 60 * 1000; // 2 hours
(userCrypto as any).userSessions.set(sessionData.userId, {
dataKey: originalDEK,
lastActivity: Date.now(),
expiresAt: sessionExpiry,
});
// Generate JWT token
const token = await authManager.generateJWTToken(sessionData.userId, {
expiresIn: "2h",
});
// Clean up temporary session
await db.delete(settings).where(eq(settings.key, tempKey));
authLogger.success("Recovery login successful", {
operation: "recovery_login_success",
username,
userId: sessionData.userId,
});
gemini-code-assist[bot] commented 2025-09-26 07:44:49 +00:00 (Migrated from github.com)
Review

critical

Storing the original Data Encryption Key (dekHex) in plaintext in the settings table, even as part of a temporary session, is a critical security vulnerability. An attacker with database access during this 5-minute window can capture the DEK and decrypt all of that user's data. The DEK should never be stored in plaintext anywhere on the server. Instead, consider creating a temporary, server-side encrypted session that doesn't expose the raw DEK, or pass the DEK to the client to hold in memory for the next step of the flow.

![critical](https://www.gstatic.com/codereviewagent/critical.svg) Storing the original Data Encryption Key (`dekHex`) in plaintext in the `settings` table, even as part of a temporary session, is a critical security vulnerability. An attacker with database access during this 5-minute window can capture the DEK and decrypt all of that user's data. The DEK should never be stored in plaintext anywhere on the server. Instead, consider creating a temporary, server-side encrypted session that doesn't expose the raw DEK, or pass the DEK to the client to hold in memory for the next step of the flow.
res.json({
token,
is_admin: !!user[0].is_admin,
username: user[0].username,
message: "Login successful via recovery"
});
} catch (error) {
authLogger.error("Recovery login failed", error, {
operation: "recovery_login_failed",
username,
});
res.status(500).json({ error: "Recovery login failed" });
}
});
// ===== Zero Trust Migration API endpoints =====
// Route: Get user security mode status
// GET /users/security-mode
router.get("/security-mode", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const migration = ZeroTrustMigration.getInstance();
const status = await migration.getUserSecurityMode(userId);
res.json(status);
} catch (error) {
authLogger.error("Failed to get security mode", error, {
operation: "get_security_mode_failed",
userId,
});
res.status(500).json({ error: "Failed to get security mode" });
}
});
// Route: Migrate user to zero-trust mode
// POST /users/migrate-to-zero-trust
router.post("/migrate-to-zero-trust", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const migration = ZeroTrustMigration.getInstance();
// Check if user can migrate
const canMigrate = await migration.canMigrateToZeroTrust(userId);
if (!canMigrate) {
return res.status(400).json({
error: "User cannot migrate to zero-trust mode"
});
}
// Perform migration
const recoverySeed = await migration.migrateUserToZeroTrust(userId);
if (!recoverySeed) {
return res.status(500).json({
error: "Migration failed"
});
}
authLogger.success("User migrated to zero-trust mode", {
operation: "zero_trust_migration_success",
userId,
});
res.json({
success: true,
recoverySeed,
message: "Migration successful. Save this recovery seed securely!",
warning: "This is the only time you will see this seed. Store it safely!"
});
} catch (error) {
authLogger.error("Zero-trust migration failed", error, {
operation: "zero_trust_migration_error",
userId,
});
res.status(500).json({ error: "Migration failed" });
}
});
// Route: Zero-trust password recovery (requires user seed)
// POST /users/recovery/zero-trust
router.post("/recovery/zero-trust", async (req, res) => {
const { username, code, userSeed } = req.body;
if (!username || !code || !userSeed) {
return res.status(400).json({
error: "Username, recovery code, and user seed are required"
});
}
try {
const migration = ZeroTrustMigration.getInstance();
const result = await migration.recoverInZeroTrustMode(username, code, userSeed);
if (result.success) {
res.json({
success: true,
tempToken: result.tempToken,
expiresAt: result.expiresAt,
message: "Zero-trust recovery successful"
});
} else {
res.status(400).json({
error: "Recovery failed. Check your code and recovery seed."
});
}
} catch (error) {
authLogger.error("Zero-trust recovery failed", error, {
operation: "zero_trust_recovery_error",
username,
});
res.status(500).json({ error: "Recovery failed" });
}
});
export default router; export default router;

View File

@@ -1,6 +1,6 @@
import crypto from "crypto"; import crypto from "crypto";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import { settings } from "../database/db/schema.js"; import { settings, users } from "../database/db/schema.js";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { databaseLogger } from "./logger.js"; import { databaseLogger } from "./logger.js";
@@ -71,13 +71,20 @@ class UserCrypto {
const encryptedDEK = this.encryptDEK(DEK, KEK); const encryptedDEK = this.encryptDEK(DEK, KEK);
await this.storeEncryptedDEK(userId, encryptedDEK); await this.storeEncryptedDEK(userId, encryptedDEK);
// 🔴 Add recovery layer (breaks zero-trust for UX)
const recoveryDEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
const backupEncryptedDEK = this.encryptDEK(DEK, recoveryDEK);
await this.storeRecoveryData(userId, recoveryDEK, backupEncryptedDEK);
// Immediately clean temporary keys // Immediately clean temporary keys
KEK.fill(0); KEK.fill(0);
DEK.fill(0); DEK.fill(0);
recoveryDEK.fill(0);
databaseLogger.success("User encryption setup completed", { databaseLogger.success("User encryption setup completed with recovery layer", {
operation: "user_crypto_setup", operation: "user_crypto_setup",
userId, userId,
recoveryEnabled: true,
}); });
} }
@@ -403,6 +410,34 @@ class UserCrypto {
return null; return null;
} }
} }
/**
* Store recovery data (breaks zero-trust for UX compromise)
*/
private async storeRecoveryData(userId: string, recoveryDEK: Buffer, backupEncryptedDEK: EncryptedDEK): Promise<void> {
try {
await getDb()
.update(users)
.set({
recovery_dek: recoveryDEK.toString('hex'),
backup_encrypted_dek: JSON.stringify(backupEncryptedDEK),
zero_trust_mode: false, // Compromise mode for UX
})
.where(eq(users.id, userId));
databaseLogger.info("Recovery data stored for user", {
operation: "store_recovery_data",
userId,
mode: "compromise",
});
} catch (error) {
databaseLogger.error("Failed to store recovery data", error, {
operation: "store_recovery_data_failed",
userId,
});
throw error;
}
}
} }
export { UserCrypto, type KEKSalt, type EncryptedDEK }; export { UserCrypto, type KEKSalt, type EncryptedDEK };

View File

@@ -0,0 +1,264 @@
import crypto from "crypto";
import { getDb } from "../database/db/index.js";
import { users, settings } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { databaseLogger } from "./logger.js";
import { UserCrypto, type EncryptedDEK } from "./user-crypto.js";
/**
* ZeroTrustMigration - Handle migration between compromise and zero-trust modes
*
* Linus principles:
* - Simple migration path with clear rollback capability
* - User controls the security tradeoff
* - Future-proof architecture for security upgrades
*/
export class ZeroTrustMigration {
private static instance: ZeroTrustMigration;
private constructor() {}
static getInstance(): ZeroTrustMigration {
if (!this.instance) {
this.instance = new ZeroTrustMigration();
}
return this.instance;
}
/**
* Check if user can migrate to zero-trust mode
*/
async canMigrateToZeroTrust(userId: string): Promise<boolean> {
try {
const db = getDb();
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return false;
}
// Must have recovery data and be in compromise mode
return !!(user[0].recovery_dek && !user[0].zero_trust_mode);
} catch (error) {
databaseLogger.error("Failed to check zero-trust migration eligibility", error, {
operation: "zero_trust_check_failed",
userId,
});
return false;
}
}
/**
* Migrate user to zero-trust mode
* Returns recovery seed for user to save
*/
async migrateUserToZeroTrust(userId: string): Promise<string | null> {
try {
const db = getDb();
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
throw new Error("User not found");
}
const userData = user[0];
if (!userData.recovery_dek || !userData.backup_encrypted_dek) {
throw new Error("No recovery data available");
}
if (userData.zero_trust_mode) {
throw new Error("User already in zero-trust mode");
}
// Generate user recovery seed (256-bit)
const userRecoverySeed = crypto.randomBytes(32).toString('hex');
// Get current DEK from plaintext recovery data
const userCrypto = UserCrypto.getInstance();
const recoveryDEK = Buffer.from(userData.recovery_dek, 'hex');
const backupEncryptedDEK = JSON.parse(userData.backup_encrypted_dek);
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, recoveryDEK);
// Create user recovery key from seed
const userRecoveryKey = crypto.pbkdf2Sync(
userRecoverySeed,
'zero_trust_recovery',
100000,
32,
'sha256'
);
gemini-code-assist[bot] commented 2025-09-26 07:44:50 +00:00 (Migrated from github.com)
Review

high

The crypto.pbkdf2Sync function is using a hardcoded string 'zero_trust_recovery' as the salt. When the salt is the same for all users, it is not a salt but a "pepper". This is less secure than using a unique, randomly generated salt for each user, as it makes multi-target attacks (like rainbow tables) more feasible. A unique salt should be generated for each user and stored in the database alongside the backup_encrypted_dek.

![high](https://www.gstatic.com/codereviewagent/high-priority.svg) The `crypto.pbkdf2Sync` function is using a hardcoded string `'zero_trust_recovery'` as the salt. When the salt is the same for all users, it is not a salt but a "pepper". This is less secure than using a unique, randomly generated salt for each user, as it makes multi-target attacks (like rainbow tables) more feasible. A unique salt should be generated for each user and stored in the database alongside the `backup_encrypted_dek`.
// Re-encrypt DEK with user recovery key
const newBackupEncryptedDEK = (userCrypto as any).encryptDEK(originalDEK, userRecoveryKey);
// 🔥 Remove plaintext recovery data and enable zero-trust mode
await db
.update(users)
.set({
recovery_dek: null, // Delete plaintext recovery key
backup_encrypted_dek: JSON.stringify(newBackupEncryptedDEK),
zero_trust_mode: true, // Enable zero-trust mode
})
.where(eq(users.id, userId));
databaseLogger.success("User migrated to zero-trust mode", {
operation: "zero_trust_migration_success",
userId,
mode: "zero_trust",
});
return userRecoverySeed;
} catch (error) {
databaseLogger.error("Failed to migrate user to zero-trust", error, {
operation: "zero_trust_migration_failed",
userId,
});
throw error;
}
}
/**
* Zero-trust recovery (requires user recovery seed)
*/
async recoverInZeroTrustMode(
username: string,
code: string,
userSeed: string
): Promise<{ success: boolean; tempToken?: string; expiresAt?: number }> {
try {
const db = getDb();
// Verify recovery code (same mechanism as compromise mode)
const key = `recovery_code_${username}`;
const result = await db.select().from(settings).where(eq(settings.key, key));
if (result.length === 0) {
return { success: false };
}
const codeData = JSON.parse(result[0].value);
const now = Date.now();
// Check expiry and attempts
if (now > codeData.expiresAt || codeData.attempts >= 3) {
await db.delete(settings).where(eq(settings.key, key));
return { success: false };
}
// Verify code
if (codeData.code !== code) {
codeData.attempts++;
await db.update(settings)
.set({ value: JSON.stringify(codeData) })
.where(eq(settings.key, key));
return { success: false };
}
// Get user
const user = await db.select().from(users).where(eq(users.username, username));
if (!user || user.length === 0) {
return { success: false };
}
const userData = user[0];
if (!userData.zero_trust_mode || !userData.backup_encrypted_dek) {
return { success: false };
}
// Derive user recovery key from seed
const userRecoveryKey = crypto.pbkdf2Sync(
userSeed,
'zero_trust_recovery',
100000,
32,
'sha256'
);
try {
// Decrypt DEK using user recovery key
const userCrypto = UserCrypto.getInstance();
const backupEncryptedDEK = JSON.parse(userData.backup_encrypted_dek);
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, userRecoveryKey);
// Create temporary session
const tempToken = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
const tempKey = `temp_recovery_session_${username}`;
const tempValue = JSON.stringify({
userId: userData.id,
tempToken,
expiresAt,
dekHex: originalDEK.toString('hex'),
mode: 'zero_trust'
});
const existingTemp = await db.select().from(settings).where(eq(settings.key, tempKey));
if (existingTemp.length > 0) {
await db.update(settings).set({ value: tempValue }).where(eq(settings.key, tempKey));
} else {
await db.insert(settings).values({ key: tempKey, value: tempValue });
}
// Clean up recovery code
await db.delete(settings).where(eq(settings.key, key));
databaseLogger.success("Zero-trust recovery successful", {
operation: "zero_trust_recovery_success",
username,
userId: userData.id,
});
return { success: true, tempToken, expiresAt };
} catch (decryptError) {
// Invalid seed - decryption failed
databaseLogger.warn("Zero-trust recovery failed - invalid seed", {
operation: "zero_trust_recovery_invalid_seed",
username,
});
return { success: false };
}
} catch (error) {
databaseLogger.error("Zero-trust recovery failed", error, {
operation: "zero_trust_recovery_error",
username,
});
return { success: false };
}
}
/**
* Get user security mode status
*/
async getUserSecurityMode(userId: string): Promise<{
mode: 'compromise' | 'zero_trust';
canMigrate: boolean;
hasRecoveryData: boolean;
}> {
try {
const db = getDb();
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return { mode: 'compromise', canMigrate: false, hasRecoveryData: false };
}
const userData = user[0];
const mode = userData.zero_trust_mode ? 'zero_trust' : 'compromise';
const hasRecoveryData = !!(userData.recovery_dek || userData.backup_encrypted_dek);
const canMigrate = mode === 'compromise' && hasRecoveryData;
return { mode, canMigrate, hasRecoveryData };
} catch (error) {
databaseLogger.error("Failed to get user security mode", error, {
operation: "get_security_mode_failed",
userId,
});
return { mode: 'compromise', canMigrate: false, hasRecoveryData: false };
}
}
}

View File

@@ -14,6 +14,9 @@ import {
getRegistrationAllowed, getRegistrationAllowed,
getOIDCConfig, getOIDCConfig,
getSetupRequired, getSetupRequired,
requestRecoveryCode,
verifyRecoveryCode,
loginWithRecovery,
initiatePasswordReset, initiatePasswordReset,
verifyPasswordResetCode, verifyPasswordResetCode,
completePasswordReset, completePasswordReset,
@@ -80,6 +83,16 @@ export function HomepageAuth({
const [registrationAllowed, setRegistrationAllowed] = useState(true); const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false); const [oidcConfigured, setOidcConfigured] = useState(false);
// Recovery states (new UX compromise flow)
const [recoveryStep, setRecoveryStep] = useState<
"request" | "verify" | "login"
>("request");
const [recoveryCode, setRecoveryCode] = useState("");
const [recoveryTempToken, setRecoveryTempToken] = useState("");
const [recoveryLoading, setRecoveryLoading] = useState(false);
const [recoverySuccess, setRecoverySuccess] = useState(false);
// Legacy reset states (kept for compatibility)
const [resetStep, setResetStep] = useState< const [resetStep, setResetStep] = useState<
"initiate" | "verify" | "newPassword" "initiate" | "verify" | "newPassword"
>("initiate"); >("initiate");
@@ -248,6 +261,93 @@ export function HomepageAuth({
} }
} }
// ===== New Recovery Functions (UX compromise) =====
async function handleRequestRecoveryCode() {
setError(null);
setRecoveryLoading(true);
try {
const result = await requestRecoveryCode(localUsername);
setRecoveryStep("verify");
toast.success("Recovery code sent to Docker logs");
} catch (err: any) {
toast.error(
err?.response?.data?.error ||
err?.message ||
"Failed to request recovery code",
);
} finally {
setRecoveryLoading(false);
}
}
async function handleVerifyRecoveryCode() {
setError(null);
setRecoveryLoading(true);
try {
const response = await verifyRecoveryCode(localUsername, recoveryCode);
setRecoveryTempToken(response.tempToken);
setRecoveryStep("login");
toast.success("Recovery verification successful");
} catch (err: any) {
toast.error(err?.response?.data?.error || "Failed to verify recovery code");
} finally {
setRecoveryLoading(false);
}
}
async function handleRecoveryLogin() {
setError(null);
setRecoveryLoading(true);
try {
const response = await loginWithRecovery(localUsername, recoveryTempToken);
// Auto-login successful - use same cookie mechanism as normal login
setCookie("jwt", response.token);
// DEBUG: Verify JWT was set correctly (same as normal login)
const verifyJWT = getCookie("jwt");
console.log("Recovery JWT Set Debug:", {
originalToken: response.token.substring(0, 20) + "...",
retrievedToken: verifyJWT ? verifyJWT.substring(0, 20) + "..." : null,
match: response.token === verifyJWT,
tokenLength: response.token.length,
retrievedLength: verifyJWT?.length || 0
});
setLoggedIn(true);
setIsAdmin(response.is_admin);
setUsername(response.username);
onAuthSuccess({
isAdmin: response.is_admin,
username: response.username,
userId: response.userId || null,
});
// Reset recovery state
setRecoveryStep("request");
setRecoveryCode("");
setRecoveryTempToken("");
setRecoverySuccess(true);
toast.success("Login successful via recovery");
} catch (err: any) {
toast.error(err?.response?.data?.error || "Recovery login failed");
} finally {
setRecoveryLoading(false);
}
}
function resetRecoveryState() {
setRecoveryStep("request");
gemini-code-assist[bot] commented 2025-09-26 07:44:50 +00:00 (Migrated from github.com)
Review

medium

This console.log statement appears to be for debugging purposes. It should be removed from the production codebase to avoid leaking information about token handling into the browser console.

![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) This `console.log` statement appears to be for debugging purposes. It should be removed from the production codebase to avoid leaking information about token handling into the browser console.
setRecoveryCode("");
setRecoveryTempToken("");
setError(null);
}
// ===== Legacy password reset functions (deprecated) =====
async function handleInitiatePasswordReset() { async function handleInitiatePasswordReset() {
setError(null); setError(null);
setResetLoading(true); setResetLoading(true);
@@ -811,7 +911,131 @@ export function HomepageAuth({
)} )}
{tab === "reset" && ( {tab === "reset" && (
<> <>
{resetStep === "initiate" && ( {/* New Recovery Flow (UX compromise) */}
{recoveryStep === "request" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>🔥 Password Recovery with Docker Access</p>
<p className="text-sm mt-2">
Recovery requires server access to view Docker logs
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="recovery-username">
{t("common.username")}
</Label>
<Input
id="recovery-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={recoveryLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading || !localUsername.trim()}
onClick={handleRequestRecoveryCode}
>
{recoveryLoading ? Spinner : "Request Recovery Code"}
</Button>
</div>
</>
)}
{recoveryStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
Check Docker logs for recovery code for{" "}
<strong>{localUsername}</strong>
</p>
<div className="mt-2 p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono">
docker logs termix | grep RECOVERY
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="recovery-code">
Recovery Code (6 digits)
</Label>
<Input
id="recovery-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={recoveryCode}
onChange={(e) =>
setRecoveryCode(e.target.value.replace(/\D/g, ""))
}
disabled={recoveryLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading || recoveryCode.length !== 6}
onClick={handleVerifyRecoveryCode}
>
{recoveryLoading ? Spinner : "Verify & Unlock"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading}
onClick={() => {
setRecoveryStep("request");
setRecoveryCode("");
}}
>
Back
</Button>
</div>
</>
)}
{recoveryStep === "login" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p> Recovery verification successful!</p>
<p className="text-sm mt-2">
Click below to complete login for{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-4">
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading}
onClick={handleRecoveryLogin}
>
{recoveryLoading ? Spinner : "Complete Login"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading}
onClick={() => {
resetRecoveryState();
}}
>
Start Over
</Button>
</div>
</>
)}
{/* Legacy Reset Flow (kept for compatibility) */}
{false && resetStep === "initiate" && (
<> <>
<div className="text-center text-muted-foreground mb-4"> <div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p> <p>{t("auth.resetCodeDesc")}</p>

View File

@@ -1552,6 +1552,49 @@ export async function getUserCount(): Promise<UserCount> {
} }
} }
// ===== New Recovery API functions (UX compromise) =====
export async function requestRecoveryCode(username: string): Promise<any> {
try {
const response = await authApi.post("/users/recovery/request", { username });
return response.data;
} catch (error) {
handleApiError(error, "request recovery code");
}
}
export async function verifyRecoveryCode(
username: string,
code: string,
): Promise<any> {
try {
const response = await authApi.post("/users/recovery/verify", {
username,
code,
});
return response.data;
} catch (error) {
handleApiError(error, "verify recovery code");
}
}
export async function loginWithRecovery(
username: string,
tempToken: string,
): Promise<any> {
try {
const response = await authApi.post("/users/recovery/login", {
username,
tempToken,
});
return response.data;
} catch (error) {
handleApiError(error, "recovery login");
}
}
// ===== Legacy password reset functions (deprecated) =====
export async function initiatePasswordReset(username: string): Promise<any> { export async function initiatePasswordReset(username: string): Promise<any> {
try { try {
const response = await authApi.post("/users/initiate-reset", { username }); const response = await authApi.post("/users/initiate-reset", { username });