diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 62bf5769..bd1a5b4c 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -746,7 +746,6 @@ jobs: mkdir -p homebrew-submission/Casks/t cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb - cp homebrew/README.md homebrew-submission/ sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 7b3b6138..8310ed6c 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -95,6 +95,32 @@ async function initializeDatabaseAsync(): Promise { databaseKeyLength: process.env.DATABASE_KEY?.length || 0, }); + try { + databaseLogger.info( + "Generating diagnostic information for database encryption failure", + { + operation: "db_encryption_diagnostic", + }, + ); + const diagnosticInfo = + DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath); + databaseLogger.error( + "Database encryption diagnostic completed - check logs above for details", + null, + { + operation: "db_encryption_diagnostic_completed", + filesConsistent: diagnosticInfo.validation.filesConsistent, + sizeMismatch: diagnosticInfo.validation.sizeMismatch, + }, + ); + } catch (diagError) { + databaseLogger.warn("Failed to generate diagnostic information", { + operation: "db_diagnostic_failed", + error: + diagError instanceof Error ? diagError.message : "Unknown error", + }); + } + throw new Error( `Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`, ); diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index 002464b0..7bbb406b 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -12,6 +12,7 @@ interface EncryptedFileMetadata { algorithm: string; keySource?: string; salt?: string; + dataSize?: number; } class DatabaseFileEncryption { @@ -25,6 +26,10 @@ class DatabaseFileEncryption { buffer: Buffer, targetPath: string, ): Promise { + const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; + const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; + const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; + try { const key = await this.systemCrypto.getDatabaseKey(); @@ -38,6 +43,12 @@ class DatabaseFileEncryption { const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); const tag = cipher.getAuthTag(); + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -45,14 +56,35 @@ class DatabaseFileEncryption { fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", + dataSize: encrypted.length, }; - const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; - fs.writeFileSync(targetPath, encrypted); - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + fs.writeFileSync(tmpPath, encrypted); + fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + + fs.renameSync(tmpPath, targetPath); + fs.renameSync(tmpMetadataPath, metadataPath); return targetPath; } catch (error) { + try { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + if (fs.existsSync(tmpMetadataPath)) { + fs.unlinkSync(tmpMetadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup temporary files", { + operation: "temp_file_cleanup_failed", + tmpPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } + databaseLogger.error("Failed to encrypt database buffer", error, { operation: "database_buffer_encryption_failed", targetPath, @@ -74,6 +106,8 @@ class DatabaseFileEncryption { const encryptedPath = targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + const tmpPath = `${encryptedPath}.tmp-${Date.now()}-${process.pid}`; + const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; try { const sourceData = fs.readFileSync(sourcePath); @@ -93,6 +127,12 @@ class DatabaseFileEncryption { ]); const tag = cipher.getAuthTag(); + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -100,10 +140,14 @@ class DatabaseFileEncryption { fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", + dataSize: encrypted.length, }; - fs.writeFileSync(encryptedPath, encrypted); - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + fs.writeFileSync(tmpPath, encrypted); + fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + + fs.renameSync(tmpPath, encryptedPath); + fs.renameSync(tmpMetadataPath, metadataPath); databaseLogger.info("Database file encrypted successfully", { operation: "database_file_encryption", @@ -111,11 +155,30 @@ class DatabaseFileEncryption { encryptedPath, fileSize: sourceData.length, encryptedSize: encrypted.length, + keyFingerprint, fingerprintPrefix: metadata.fingerprint, }); return encryptedPath; } catch (error) { + try { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + if (fs.existsSync(tmpMetadataPath)) { + fs.unlinkSync(tmpMetadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup temporary files", { + operation: "temp_file_cleanup_failed", + tmpPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } + databaseLogger.error("Failed to encrypt database file", error, { operation: "database_file_encryption_failed", sourcePath, @@ -140,11 +203,34 @@ class DatabaseFileEncryption { } try { + const dataFileStats = fs.statSync(encryptedPath); + const metaFileStats = fs.statSync(metadataPath); + const metadataContent = fs.readFileSync(metadataPath, "utf8"); const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); const encryptedData = fs.readFileSync(encryptedPath); + if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + databaseLogger.error( + "Encrypted file size mismatch - possible corrupted write or mismatched metadata", + null, + { + operation: "database_file_size_mismatch", + encryptedPath, + actualSize: encryptedData.length, + expectedSize: metadata.dataSize, + difference: encryptedData.length - metadata.dataSize, + dataFileMtime: dataFileStats.mtime.toISOString(), + metaFileMtime: metaFileStats.mtime.toISOString(), + }, + ); + throw new Error( + `Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` + + `This indicates corrupted files or interrupted write operation.`, + ); + } + let key: Buffer; if (metadata.version === "v2") { key = await this.systemCrypto.getDatabaseKey(); @@ -167,6 +253,12 @@ class DatabaseFileEncryption { throw new Error(`Unsupported encryption version: ${metadata.version}`); } + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const decipher = crypto.createDecipheriv( metadata.algorithm, key, @@ -181,13 +273,64 @@ class DatabaseFileEncryption { return decryptedBuffer; } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + const isAuthError = + errorMessage.includes("Unsupported state") || + errorMessage.includes("authenticate data") || + errorMessage.includes("auth"); + + if (isAuthError) { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + let envFileExists = false; + let envFileReadable = false; + try { + envFileExists = fs.existsSync(envPath); + if (envFileExists) { + fs.accessSync(envPath, fs.constants.R_OK); + envFileReadable = true; + } + } catch {} + + databaseLogger.error( + "Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write", + error, + { + operation: "database_buffer_decryption_auth_failed", + encryptedPath, + metadataPath, + dataDir, + envPath, + envFileExists, + envFileReadable, + hasEnvKey: !!process.env.DATABASE_KEY, + envKeyLength: process.env.DATABASE_KEY?.length || 0, + suggestion: + "Check if DATABASE_KEY in .env matches the key used for encryption", + }, + ); + throw new Error( + `Database decryption authentication failed. This usually means:\n` + + `1. DATABASE_KEY has changed or is missing from ${dataDir}/.env\n` + + `2. Encrypted file was corrupted during write (system crash/restart)\n` + + `3. Metadata file does not match encrypted data\n` + + `\nDebug info:\n` + + `- DATA_DIR: ${dataDir}\n` + + `- .env file exists: ${envFileExists}\n` + + `- .env file readable: ${envFileReadable}\n` + + `- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` + + `Original error: ${errorMessage}`, + ); + } + databaseLogger.error("Failed to decrypt database to buffer", error, { operation: "database_buffer_decryption_failed", encryptedPath, + errorMessage, }); - throw new Error( - `Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + throw new Error(`Database buffer decryption failed: ${errorMessage}`); } } @@ -215,6 +358,23 @@ class DatabaseFileEncryption { const encryptedData = fs.readFileSync(encryptedPath); + if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + databaseLogger.error( + "Encrypted file size mismatch - possible corrupted write or mismatched metadata", + null, + { + operation: "database_file_size_mismatch", + encryptedPath, + actualSize: encryptedData.length, + expectedSize: metadata.dataSize, + }, + ); + throw new Error( + `Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` + + `This indicates corrupted files or interrupted write operation.`, + ); + } + let key: Buffer; if (metadata.version === "v2") { key = await this.systemCrypto.getDatabaseKey(); @@ -322,6 +482,125 @@ class DatabaseFileEncryption { } } + static getDiagnosticInfo(encryptedPath: string): { + dataFile: { + exists: boolean; + size?: number; + mtime?: string; + readable?: boolean; + }; + metadataFile: { + exists: boolean; + size?: number; + mtime?: string; + readable?: boolean; + content?: EncryptedFileMetadata; + }; + environment: { + dataDir: string; + envPath: string; + envFileExists: boolean; + envFileReadable: boolean; + hasEnvKey: boolean; + envKeyLength: number; + }; + validation: { + filesConsistent: boolean; + sizeMismatch?: boolean; + expectedSize?: number; + actualSize?: number; + }; + } { + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + const result: ReturnType = { + dataFile: { exists: false }, + metadataFile: { exists: false }, + environment: { + dataDir, + envPath, + envFileExists: false, + envFileReadable: false, + hasEnvKey: !!process.env.DATABASE_KEY, + envKeyLength: process.env.DATABASE_KEY?.length || 0, + }, + validation: { + filesConsistent: false, + }, + }; + + try { + result.dataFile.exists = fs.existsSync(encryptedPath); + if (result.dataFile.exists) { + try { + fs.accessSync(encryptedPath, fs.constants.R_OK); + result.dataFile.readable = true; + const stats = fs.statSync(encryptedPath); + result.dataFile.size = stats.size; + result.dataFile.mtime = stats.mtime.toISOString(); + } catch { + result.dataFile.readable = false; + } + } + + result.metadataFile.exists = fs.existsSync(metadataPath); + if (result.metadataFile.exists) { + try { + fs.accessSync(metadataPath, fs.constants.R_OK); + result.metadataFile.readable = true; + const stats = fs.statSync(metadataPath); + result.metadataFile.size = stats.size; + result.metadataFile.mtime = stats.mtime.toISOString(); + + const content = fs.readFileSync(metadataPath, "utf8"); + result.metadataFile.content = JSON.parse(content); + } catch { + result.metadataFile.readable = false; + } + } + + result.environment.envFileExists = fs.existsSync(envPath); + if (result.environment.envFileExists) { + try { + fs.accessSync(envPath, fs.constants.R_OK); + result.environment.envFileReadable = true; + } catch {} + } + + if ( + result.dataFile.exists && + result.metadataFile.exists && + result.metadataFile.content + ) { + result.validation.filesConsistent = true; + + if (result.metadataFile.content.dataSize !== undefined) { + result.validation.expectedSize = result.metadataFile.content.dataSize; + result.validation.actualSize = result.dataFile.size; + result.validation.sizeMismatch = + result.metadataFile.content.dataSize !== result.dataFile.size; + if (result.validation.sizeMismatch) { + result.validation.filesConsistent = false; + } + } + } + } catch (error) { + databaseLogger.error("Failed to generate diagnostic info", error, { + operation: "diagnostic_info_failed", + encryptedPath, + }); + } + + databaseLogger.info("Database encryption diagnostic info", { + operation: "diagnostic_info_generated", + ...result, + }); + + return result; + } + static async createEncryptedBackup( databasePath: string, backupDir: string, diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index bd6aa727..156d0b33 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -51,17 +51,8 @@ class SystemCrypto { }, ); } - } catch (fileError) { - databaseLogger.warn("Failed to read .env file for JWT secret", { - operation: "jwt_init_file_read_failed", - error: - fileError instanceof Error ? fileError.message : "Unknown error", - }); - } + } catch (fileError) {} - databaseLogger.warn("Generating new JWT secret", { - operation: "jwt_generating_new_secret", - }); await this.generateAndGuideUser(); } catch (error) { databaseLogger.error("Failed to initialize JWT secret", error, { @@ -80,29 +71,44 @@ class SystemCrypto { async initializeDatabaseKey(): Promise { try { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + const envKey = process.env.DATABASE_KEY; if (envKey && envKey.length >= 64) { this.databaseKey = Buffer.from(envKey, "hex"); + const keyFingerprint = crypto + .createHash("sha256") + .update(this.databaseKey) + .digest("hex") + .substring(0, 16); + return; } - const dataDir = process.env.DATA_DIR || "./db/data"; - const envPath = path.join(dataDir, ".env"); - try { const envContent = await fs.readFile(envPath, "utf8"); const dbKeyMatch = envContent.match(/^DATABASE_KEY=(.+)$/m); if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) { this.databaseKey = Buffer.from(dbKeyMatch[1], "hex"); process.env.DATABASE_KEY = dbKeyMatch[1]; + + const keyFingerprint = crypto + .createHash("sha256") + .update(this.databaseKey) + .digest("hex") + .substring(0, 16); + return; + } else { } - } catch {} + } catch (fileError) {} await this.generateAndGuideDatabaseKey(); } catch (error) { databaseLogger.error("Failed to initialize database key", error, { operation: "db_key_init_failed", + dataDir: process.env.DATA_DIR || "./db/data", }); throw new Error("Database key initialization failed"); } diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index fb894adc..997d712b 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -24,11 +24,20 @@ import { verifyTOTPLogin, getServerConfig, isElectron, - logoutUser, } from "../../main-axios.ts"; import { ElectronServerConfig as ServerConfigComponent } from "@/ui/desktop/authentication/ElectronServerConfig.tsx"; import { ElectronLoginForm } from "@/ui/desktop/authentication/ElectronLoginForm.tsx"; +function getCookie(name: string): string | undefined { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(";").shift(); +} + +interface ExtendedWindow extends Window { + IS_ELECTRON_WEBVIEW?: boolean; +} + interface AuthProps extends React.ComponentProps<"div"> { setLoggedIn: (loggedIn: boolean) => void; setIsAdmin: (isAdmin: boolean) => void; @@ -37,7 +46,6 @@ interface AuthProps extends React.ComponentProps<"div"> { loggedIn: boolean; authLoading: boolean; setDbError: (error: string | null) => void; - dbError?: string | null; onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; @@ -54,21 +62,20 @@ export function Auth({ loggedIn, authLoading, setDbError, - dbError, onAuthSuccess, ...props }: AuthProps) { const { t } = useTranslation(); const isInElectronWebView = () => { - if ((window as any).IS_ELECTRON_WEBVIEW) { + if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) { return true; } try { if (window.self !== window.top) { return true; } - } catch (e) { + } catch (_e) { return false; } return false; @@ -126,7 +133,7 @@ export function Auth({ userId: meRes.userId || null, }); toast.success(t("messages.loginSuccess")); - } catch (err) { + } catch (_err) { toast.error(t("errors.failedUserInfo")); } }, [ @@ -206,7 +213,7 @@ export function Auth({ .finally(() => { setDbHealthChecking(false); }); - }, [setDbError, firstUserToastShown, showServerConfig]); + }, [setDbError, firstUserToastShown, showServerConfig, t]); useEffect(() => { if (!registrationAllowed && !internalLoggedIn) { @@ -282,9 +289,9 @@ export function Auth({ ); setWebviewAuthSuccess(true); setTimeout(() => window.location.reload(), 100); - setLoading(false); - return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } const [meRes] = await Promise.all([getUserInfo()]); @@ -461,7 +468,9 @@ export function Auth({ setTimeout(() => window.location.reload(), 100); setTotpLoading(false); return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } setInternalLoggedIn(true); @@ -569,7 +578,9 @@ export function Auth({ setTimeout(() => window.location.reload(), 100); setOidcLoading(false); return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } } @@ -607,7 +618,16 @@ export function Auth({ setOidcLoading(false); }); } - }, []); + }, [ + onAuthSuccess, + setDbError, + setIsAdmin, + setLoggedIn, + setUserId, + setUsername, + t, + isInElectronWebView, + ]); const Spinner = (