diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index fab88f92..1d2205ab 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -422,6 +422,11 @@ const migrateSchema = () => { addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0"); 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", "folder", "TEXT"); addColumnIfNotExists("ssh_data", "tags", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 8e0f8e79..16cdac05 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -23,6 +23,13 @@ export const users = sqliteTable("users", { .notNull() .default(false), 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", { diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 5ed51780..8820ee68 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -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; diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index c0d181c7..66b961ce 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 } from "../database/db/schema.js"; +import { settings, users } from "../database/db/schema.js"; import { eq } from "drizzle-orm"; import { databaseLogger } from "./logger.js"; @@ -71,13 +71,20 @@ class UserCrypto { const encryptedDEK = this.encryptDEK(DEK, KEK); 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 KEK.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", userId, + recoveryEnabled: true, }); } @@ -403,6 +410,34 @@ class UserCrypto { return null; } } + + /** + * Store recovery data (breaks zero-trust for UX compromise) + */ + private async storeRecoveryData(userId: string, recoveryDEK: Buffer, backupEncryptedDEK: EncryptedDEK): Promise { + 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 }; \ No newline at end of file diff --git a/src/backend/utils/zero-trust-migration.ts b/src/backend/utils/zero-trust-migration.ts new file mode 100644 index 00000000..b03e36db --- /dev/null +++ b/src/backend/utils/zero-trust-migration.ts @@ -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 { + 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 { + 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' + ); + + // 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 }; + } + } +} \ No newline at end of file diff --git a/src/ui/Desktop/Homepage/HomepageAuth.tsx b/src/ui/Desktop/Homepage/HomepageAuth.tsx index 3f8189b6..813529df 100644 --- a/src/ui/Desktop/Homepage/HomepageAuth.tsx +++ b/src/ui/Desktop/Homepage/HomepageAuth.tsx @@ -14,6 +14,9 @@ import { getRegistrationAllowed, getOIDCConfig, getSetupRequired, + requestRecoveryCode, + verifyRecoveryCode, + loginWithRecovery, initiatePasswordReset, verifyPasswordResetCode, completePasswordReset, @@ -80,6 +83,16 @@ export function HomepageAuth({ const [registrationAllowed, setRegistrationAllowed] = useState(true); 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< "initiate" | "verify" | "newPassword" >("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"); + setRecoveryCode(""); + setRecoveryTempToken(""); + setError(null); + } + + // ===== Legacy password reset functions (deprecated) ===== + async function handleInitiatePasswordReset() { setError(null); setResetLoading(true); @@ -811,7 +911,131 @@ export function HomepageAuth({ )} {tab === "reset" && ( <> - {resetStep === "initiate" && ( + {/* New Recovery Flow (UX compromise) */} + {recoveryStep === "request" && ( + <> +
+

🔥 Password Recovery with Docker Access

+

+ Recovery requires server access to view Docker logs +

+
+
+
+ + setLocalUsername(e.target.value)} + disabled={recoveryLoading} + /> +
+ +
+ + )} + + {recoveryStep === "verify" && ( + <> +
+

+ Check Docker logs for recovery code for{" "} + {localUsername} +

+
+ docker logs termix | grep RECOVERY +
+
+
+
+ + + setRecoveryCode(e.target.value.replace(/\D/g, "")) + } + disabled={recoveryLoading} + placeholder="000000" + /> +
+ + +
+ + )} + + {recoveryStep === "login" && ( + <> +
+

✅ Recovery verification successful!

+

+ Click below to complete login for{" "} + {localUsername} +

+
+
+ + +
+ + )} + + {/* Legacy Reset Flow (kept for compatibility) */} + {false && resetStep === "initiate" && ( <>

{t("auth.resetCodeDesc")}

diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 9c5b6f86..326fdd11 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1592,6 +1592,49 @@ export async function getUserCount(): Promise { } } +// ===== New Recovery API functions (UX compromise) ===== + +export async function requestRecoveryCode(username: string): Promise { + 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 { + 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 { + 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 { try { const response = await authApi.post("/users/initiate-reset", { username });