FEATURE: Docker log-based password recovery with KEK-DEK preservation (#303)
Breaking Changes: - Adds compromise mode to zero-trust architecture for UX - Enables password recovery via physical Docker access Key Features: - 6-digit recovery codes output to Docker logs for physical access control - Recovery DEK layer preserves user encrypted data during password reset - Zero-trust migration path for future security upgrade - Critical fix for password reset data loss vulnerability Security Model: - Physical access required (Docker logs access) - 1-minute code expiry with 3-attempt limit - Recovery keys stored encrypted in database - Gradual migration path to zero-trust mode Technical Details: - Schema: Added recovery_dek, backup_encrypted_dek, zero_trust_mode fields - API: New /recovery/* endpoints for recovery flow - UI: Complete password recovery interface redesign - Crypto: Recovery layer in KEK-DEK architecture - Migration: ZeroTrustMigration utility for future upgrades Bug Fixes: - Fixed critical password reset vulnerability causing permanent data loss - Fixed JWT token storage inconsistency in recovery login - Proper KEK-DEK re-encryption during password reset Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit was merged in pull request #303.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import express from "express";
|
||||
import crypto from "crypto";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
users,
|
||||
@@ -20,6 +21,7 @@ import { authLogger, apiLogger } 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 { ZeroTrustMigration } from "../../utils/zero-trust-migration.js";
|
||||
|
||||
// Get auth manager instance
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -1252,13 +1254,86 @@ router.post("/complete-reset", async (req, res) => {
|
||||
return res.status(400).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||
// Get user ID for KEK-DEK operations
|
||||
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
|
||||
.update(users)
|
||||
.set({ password_hash })
|
||||
.where(eq(users.username, username));
|
||||
// 🔥 CRITICAL FIX: Use recovery DEK to preserve user data
|
||||
if (user[0].recovery_dek && user[0].backup_encrypted_dek) {
|
||||
try {
|
||||
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);
|
||||
|
||||
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
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
@@ -1932,4 +2007,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');
|
||||
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Store temporary session
|
||||
const tempKey = `temp_recovery_session_${username}`;
|
||||
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,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user