diff --git a/log.txt b/log.txt new file mode 100644 index 00000000..f87d8dd2 --- /dev/null +++ b/log.txt @@ -0,0 +1,260 @@ +C:\nvm4w\nodejs\npm.cmd run dev:backend + +> termix@1.9.0 dev:backend +> tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js + +[4:10:16 PM] [INFO] [📦] Termix Backend starting - Version: 1.9.0 [op:startup] +[4:10:16 PM] [INFO] [🚀] SSL not enabled - skipping certificate generation [op:ssl_disabled_default] +[4:10:16 PM] [INFO] [🗄️] Initializing SQLite database [op:db_init] +[4:10:16 PM] [INFO] [🗄️] Current roles in database [op:schema_migration] +[4:10:16 PM] [INFO] [🗄️] Cleanup completed [op:schema_migration] +[4:10:16 PM] [INFO] [🗄️] System roles migration completed [op:schema_migration] +[4:10:16 PM] [INFO] [🗄️] Migrated admin users to admin role [op:schema_migration] +[4:10:16 PM] [INFO] [🗄️] Migrated normal users to user role [op:schema_migration] +[4:10:16 PM] [SUCCESS] [🗄️] Schema migration completed [op:schema_migration] +[4:10:16 PM] [WARN] [🗄️] Session not found during JWT verification [op:jwt_verify_session_not_found,user:GHNBjn7uGyG8OIrh67L-G,session:tNjsSYvIkLkJcJFGn2NLm] +[4:10:16 PM] [WARN] [🗄️] Session not found during JWT verification [op:jwt_verify_session_not_found,user:GHNBjn7uGyG8OIrh67L-G,session:tNjsSYvIkLkJcJFGn2NLm] +[4:10:16 PM] [INFO] [🚀] Docker backend server started on port 30007 +[4:10:16 PM] [INFO] [🚀] Docker console WebSocket server started on port 30008 [op:startup] +[4:10:18 PM] [INFO] [📡] Found 0 autostart hosts and 2 total hosts for endpointHost resolution +[4:10:20 PM] [INFO] [🗄️] Re-encrypt step 1: Fetching shared credential [op:reencrypt_step1,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🗄️] Re-encrypt step 2: Fetching host access [op:reencrypt_step2] +[4:10:20 PM] [INFO] [🗄️] Re-encrypt step 3: Checking DEKs [op:reencrypt_step3,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [WARN] [🗄️] Re-encrypt: owner DEK not available [op:reencrypt_owner_offline] +[4:10:20 PM] [INFO] [🗄️] Re-encrypted pending credentials for user [op:reencrypt_pending_credentials,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [SUCCESS] [🔐] User logged in successfully: test123 [op:user_login_success,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:20 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:20 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:20 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:20 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:20 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:20 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:20 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:20 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:22 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:22 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:22 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:22 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:22 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:24 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:10:24 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:10:24 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:24 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:24 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:24 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:24 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:24 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] +[4:10:26 PM] [WARN] [🖥️] No credentials found for host 1 [op:ssh_credentials,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:10:26 PM] [ERROR] [🖥️] No valid authentication method provided +[4:11:29 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G] +[4:11:29 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1] +[4:11:29 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1] +[4:11:29 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:11:29 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:11:29 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:11:29 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:11:29 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1] +[4:11:29 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G] + + diff --git a/package.json b/package.json index f2f71aab..412c290c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "1.9.0", + "version": "1.10.0", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 5df19845..bb37f17a 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -590,6 +590,11 @@ const migrateSchema = () => { addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); + // System-encrypted fields for offline credential sharing + addColumnIfNotExists("ssh_credentials", "system_password", "TEXT"); + addColumnIfNotExists("ssh_credentials", "system_key", "TEXT"); + addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT"); + addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL"); @@ -842,6 +847,42 @@ const migrateSchema = () => { } } + // RBAC: Shared Credentials table + try { + sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS shared_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_access_id INTEGER NOT NULL, + original_credential_id INTEGER NOT NULL, + target_user_id TEXT NOT NULL, + encrypted_username TEXT NOT NULL, + encrypted_auth_type TEXT NOT NULL, + encrypted_password TEXT, + encrypted_key TEXT, + encrypted_key_password TEXT, + encrypted_key_type TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + needs_re_encryption INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE, + FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE, + FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + databaseLogger.info("Created shared_credentials table", { + operation: "schema_migration", + }); + } catch (createError) { + databaseLogger.warn("Failed to create shared_credentials table", { + operation: "schema_migration", + error: createError, + }); + } + } + // Clean up old system roles and seed correct ones try { // First, check what roles exist diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index db531988..08c9e034 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -185,6 +185,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", { key_password: text("key_password"), keyType: text("key_type"), detectedKeyType: text("detected_key_type"), + + // System-encrypted fields for offline credential sharing + systemPassword: text("system_password"), + systemKey: text("system_key", { length: 16384 }), + systemKeyPassword: text("system_key_password"), + usageCount: integer("usage_count").notNull().default(0), lastUsed: text("last_used"), createdAt: text("created_at") @@ -307,10 +313,10 @@ export const hostAccess = sqliteTable("host_access", { .notNull() .references(() => users.id, { onDelete: "cascade" }), - // Permission level + // Permission level (view-only) permissionLevel: text("permission_level") .notNull() - .default("use"), // "view" | "use" | "manage" + .default("view"), // Only "view" is supported // Time-based access expiresAt: text("expires_at"), // NULL = never expires @@ -323,6 +329,47 @@ export const hostAccess = sqliteTable("host_access", { accessCount: integer("access_count").notNull().default(0), }); +// RBAC: Shared Credentials (per-user encrypted credential copies) +export const sharedCredentials = sqliteTable("shared_credentials", { + id: integer("id").primaryKey({ autoIncrement: true }), + + // Link to the host access grant (CASCADE delete when share revoked) + hostAccessId: integer("host_access_id") + .notNull() + .references(() => hostAccess.id, { onDelete: "cascade" }), + + // Link to the original credential (for tracking updates/CASCADE delete) + originalCredentialId: integer("original_credential_id") + .notNull() + .references(() => sshCredentials.id, { onDelete: "cascade" }), + + // Target user (recipient of the share) - CASCADE delete when user deleted + targetUserId: text("target_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + // Encrypted credential data (encrypted with targetUserId's DEK) + encryptedUsername: text("encrypted_username").notNull(), + encryptedAuthType: text("encrypted_auth_type").notNull(), + encryptedPassword: text("encrypted_password"), + encryptedKey: text("encrypted_key", { length: 16384 }), + encryptedKeyPassword: text("encrypted_key_password"), + encryptedKeyType: text("encrypted_key_type"), + + // Metadata + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + + // Track if needs re-encryption (when original credential updated but target user offline) + needsReEncryption: integer("needs_re_encryption", { mode: "boolean" }) + .notNull() + .default(false), +}); + // RBAC Phase 2: Roles export const roles = sqliteTable("roles", { id: integer("id").primaryKey({ autoIncrement: true }), diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 60a53d9c..9d425951 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -4,7 +4,12 @@ import type { } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; -import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js"; +import { + sshCredentials, + sshCredentialUsage, + sshData, + hostAccess, +} from "../db/schema.js"; import { eq, and, desc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; @@ -473,6 +478,15 @@ router.put( userId, ); + // Update shared credentials if this credential is shared + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + await sharedCredManager.updateSharedCredentialsForOriginal( + parseInt(id), + userId, + ); + const credential = updated[0]; authLogger.success( `SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, @@ -555,8 +569,36 @@ router.delete( eq(sshData.userId, userId), ), ); + + // Revoke all shares for hosts that used this credential + for (const host of hostsUsingCredential) { + const revokedShares = await db + .delete(hostAccess) + .where(eq(hostAccess.hostId, host.id)) + .returning({ id: hostAccess.id }); + + if (revokedShares.length > 0) { + authLogger.info( + "Auto-revoked host shares due to credential deletion", + { + operation: "auto_revoke_shares", + hostId: host.id, + credentialId: parseInt(id), + revokedCount: revokedShares.length, + reason: "credential_deleted", + }, + ); + } + } } + // Delete shared credentials for this original credential + // Note: This will also be handled by CASCADE, but we do it explicitly for logging + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id)); + // sshCredentialUsage will be automatically deleted by ON DELETE CASCADE // No need for manual deletion @@ -1601,10 +1643,7 @@ router.post( } } - const deployResult = await deploySSHKeyToHost( - hostConfig, - credData, - ); + const deployResult = await deploySSHKeyToHost(hostConfig, credData); if (deployResult.success) { res.json({ diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index c963a0d7..7605958a 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -8,6 +8,7 @@ import { roles, userRoles, auditLogs, + sharedCredentials, } from "../db/schema.js"; import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm"; import type { Request, Response } from "express"; @@ -47,7 +48,7 @@ router.post( targetUserId, targetRoleId, durationHours, - permissionLevel = "use", + permissionLevel = "view", // Only "view" is supported } = req.body; // Validate target type @@ -129,11 +130,11 @@ router.post( expiresAt = expiryDate.toISOString(); } - // Validate permission level - const validLevels = ["view", "use", "manage"]; + // Validate permission level (only "view" is supported) + const validLevels = ["view"]; if (!validLevels.includes(permissionLevel)) { return res.status(400).json({ - error: "Invalid permission level", + error: "Invalid permission level. Only 'view' is supported.", validLevels, }); } @@ -162,6 +163,30 @@ router.post( }) .where(eq(hostAccess.id, existing[0].id)); + // Re-create shared credential (delete old, create new) + await db + .delete(sharedCredentials) + .where(eq(sharedCredentials.hostAccessId, existing[0].id)); + + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + if (targetType === "user") { + await sharedCredManager.createSharedCredentialForUser( + existing[0].id, + host[0].credentialId, + targetUserId!, + userId, + ); + } else { + await sharedCredManager.createSharedCredentialsForRole( + existing[0].id, + host[0].credentialId, + targetRoleId!, + userId, + ); + } + databaseLogger.info("Updated existing host access", { operation: "share_host", hostId, @@ -189,6 +214,27 @@ router.post( expiresAt, }); + // Create shared credential for the target + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + + if (targetType === "user") { + await sharedCredManager.createSharedCredentialForUser( + result.lastInsertRowid as number, + host[0].credentialId, + targetUserId!, + userId, + ); + } else { + await sharedCredManager.createSharedCredentialsForRole( + result.lastInsertRowid as number, + host[0].credentialId, + targetRoleId!, + userId, + ); + } + databaseLogger.info("Created host access", { operation: "share_host", hostId, @@ -308,8 +354,6 @@ router.get( permissionLevel: hostAccess.permissionLevel, expiresAt: hostAccess.expiresAt, createdAt: hostAccess.createdAt, - lastAccessedAt: hostAccess.lastAccessedAt, - accessCount: hostAccess.accessCount, }) .from(hostAccess) .leftJoin(users, eq(hostAccess.userId, users.id)) @@ -331,8 +375,6 @@ router.get( permissionLevel: access.permissionLevel, expiresAt: access.expiresAt, createdAt: access.createdAt, - lastAccessedAt: access.lastAccessedAt, - accessCount: access.accessCount, })); res.json({ accessList }); @@ -651,31 +693,24 @@ router.delete( }); } - // Check if role is in use - const usageCount = await db - .select({ count: sql`count(*)` }) - .from(userRoles) - .where(eq(userRoles.roleId, roleId)); + // Delete user-role assignments first + const deletedUserRoles = await db + .delete(userRoles) + .where(eq(userRoles.roleId, roleId)) + .returning({ userId: userRoles.userId }); - if (usageCount[0].count > 0) { - return res.status(409).json({ - error: `Cannot delete role: ${usageCount[0].count} user(s) are assigned to this role`, - usageCount: usageCount[0].count, - }); + // Invalidate permission cache for affected users + for (const { userId } of deletedUserRoles) { + permissionManager.invalidateUserPermissionCache(userId); } - // Check if role is used in host_access - const hostAccessCount = await db - .select({ count: sql`count(*)` }) - .from(hostAccess) - .where(eq(hostAccess.roleId, roleId)); + // Delete host_access entries for this role + const deletedHostAccess = await db + .delete(hostAccess) + .where(eq(hostAccess.roleId, roleId)) + .returning({ id: hostAccess.id }); - if (hostAccessCount[0].count > 0) { - return res.status(409).json({ - error: `Cannot delete role: ${hostAccessCount[0].count} host(s) are shared with this role`, - hostAccessCount: hostAccessCount[0].count, - }); - } + // Note: sharedCredentials will be auto-deleted by CASCADE // Delete role await db.delete(roles).where(eq(roles.id, roleId)); @@ -773,6 +808,51 @@ router.post( grantedBy: currentUserId, }); + // Create shared credentials for all hosts shared with this role + const hostsSharedWithRole = await db + .select() + .from(hostAccess) + .innerJoin(sshData, eq(hostAccess.hostId, sshData.id)) + .where(eq(hostAccess.roleId, roleId)); + + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + + for (const { host_access, ssh_data } of hostsSharedWithRole) { + if (ssh_data.credentialId) { + try { + await sharedCredManager.createSharedCredentialForUser( + host_access.id, + ssh_data.credentialId, + targetUserId, + ssh_data.userId, + ); + } catch (error) { + databaseLogger.error( + "Failed to create shared credential for new role member", + error, + { + operation: "assign_role_create_credentials", + targetUserId, + roleId, + hostId: ssh_data.id, + }, + ); + // Continue with other hosts even if one fails + } + } + } + + if (hostsSharedWithRole.length > 0) { + databaseLogger.info("Created shared credentials for new role member", { + operation: "assign_role_create_credentials", + targetUserId, + roleId, + hostCount: hostsSharedWithRole.length, + }); + } + // Invalidate permission cache permissionManager.invalidateUserPermissionCache(targetUserId); diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index f4fd5b2f..ff32ca8c 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -391,7 +391,8 @@ router.post( : undefined, }; - const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; + const resolvedHost = + (await resolveHostCredentials(baseHost, userId)) || baseHost; sshLogger.success( `SSH host created: ${name} (${ip}:${port}) by user ${userId}`, @@ -619,9 +620,25 @@ router.put( return res.status(403).json({ error: "Access denied" }); } + // Shared users cannot edit hosts (view-only) + if (!accessInfo.isOwner) { + sshLogger.warn("Shared user attempted to update host (view-only)", { + operation: "host_update", + hostId: parseInt(hostId), + userId, + }); + return res.status(403).json({ + error: "Only the host owner can modify host configuration", + }); + } + // Get the actual owner ID for the update const hostRecord = await db - .select({ userId: sshData.userId }) + .select({ + userId: sshData.userId, + credentialId: sshData.credentialId, + authType: sshData.authType, + }) .from(sshData) .where(eq(sshData.id, Number(hostId))) .limit(1); @@ -637,6 +654,56 @@ router.put( const ownerId = hostRecord[0].userId; + // Only owner can change credentialId + if ( + !accessInfo.isOwner && + sshDataObj.credentialId !== undefined && + sshDataObj.credentialId !== hostRecord[0].credentialId + ) { + return res.status(403).json({ + error: "Only the host owner can change the credential", + }); + } + + // Only owner can change authType + if ( + !accessInfo.isOwner && + sshDataObj.authType !== undefined && + sshDataObj.authType !== hostRecord[0].authType + ) { + return res.status(403).json({ + error: "Only the host owner can change the authentication type", + }); + } + + // Check if credentialId is changing from non-null to null + // This happens when switching from "credential" auth to "password"/"key"/"none" + if (sshDataObj.credentialId !== undefined) { + if ( + hostRecord[0].credentialId !== null && + sshDataObj.credentialId === null + ) { + // Auth type changed away from credential - revoke all shares + const revokedShares = await db + .delete(hostAccess) + .where(eq(hostAccess.hostId, Number(hostId))) + .returning({ id: hostAccess.id, userId: hostAccess.userId }); + + if (revokedShares.length > 0) { + sshLogger.info( + "Auto-revoked host shares due to auth type change from credential", + { + operation: "auto_revoke_shares", + hostId: Number(hostId), + revokedCount: revokedShares.length, + reason: "auth_type_changed_from_credential", + }, + ); + // Note: sharedCredentials will be auto-deleted by CASCADE + } + } + } + await SimpleDBOps.update( sshData, "ssh_data", @@ -691,7 +758,8 @@ router.put( : undefined, }; - const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; + const resolvedHost = + (await resolveHostCredentials(baseHost, userId)) || baseHost; sshLogger.success( `SSH host updated: ${name} (${ip}:${port}) by user ${userId}`, @@ -854,12 +922,51 @@ router.get( ), ); - // Decrypt and format the data - const data = await SimpleDBOps.select( - Promise.resolve(rawData), - "ssh_data", + // Separate own hosts from shared hosts for proper decryption + const ownHosts = rawData.filter((row) => row.userId === userId); + const sharedHosts = rawData.filter((row) => row.userId !== userId); + + // Decrypt own hosts with user's DEK + let decryptedOwnHosts: any[] = []; + try { + decryptedOwnHosts = await SimpleDBOps.select( + Promise.resolve(ownHosts), + "ssh_data", + userId, + ); + sshLogger.debug("Own hosts decrypted successfully", { + operation: "host_fetch_own_decrypted", + userId, + count: decryptedOwnHosts.length, + }); + } catch (decryptError) { + sshLogger.error("Failed to decrypt own hosts", decryptError, { + operation: "host_fetch_own_decrypt_failed", + userId, + }); + // Return empty array if decryption fails + decryptedOwnHosts = []; + } + + // For shared hosts, DON'T try to decrypt them with user's DEK + // Just pass them through as plain objects without encrypted credential fields + // The credentials will be resolved via SharedCredentialManager later when resolveHostCredentials is called + sshLogger.info("Processing shared hosts", { + operation: "host_fetch_shared_process", userId, - ); + count: sharedHosts.length, + }); + + const sanitizedSharedHosts = sharedHosts; + + sshLogger.info("Combining hosts", { + operation: "host_fetch_combine", + userId, + ownCount: decryptedOwnHosts.length, + sharedCount: sanitizedSharedHosts.length, + }); + + const data = [...decryptedOwnHosts, ...sanitizedSharedHosts]; const result = await Promise.all( data.map(async (row: Record) => { @@ -900,10 +1007,18 @@ router.get( sharedExpiresAt: row.expiresAt || undefined, }; - return (await resolveHostCredentials(baseHost)) || baseHost; + const resolved = + (await resolveHostCredentials(baseHost, userId)) || baseHost; + return resolved; }), ); + sshLogger.info("Credential resolution complete, sending response", { + operation: "host_fetch_complete", + userId, + hostCount: result.length, + }); + res.json(result); } catch (err) { sshLogger.error("Failed to fetch SSH hosts from database", err, { @@ -978,7 +1093,7 @@ router.get( : [], }; - res.json((await resolveHostCredentials(result)) || result); + res.json((await resolveHostCredentials(result, userId)) || result); } catch (err) { sshLogger.error("Failed to fetch SSH host by ID from database", err, { operation: "host_fetch_by_id", @@ -1022,7 +1137,7 @@ router.get( const host = hosts[0]; - const resolvedHost = (await resolveHostCredentials(host)) || host; + const resolvedHost = (await resolveHostCredentials(host, userId)) || host; const exportData = { name: resolvedHost.name, @@ -1644,12 +1759,68 @@ router.delete( async function resolveHostCredentials( host: Record, + requestingUserId?: string, ): Promise> { try { + sshLogger.info("Resolving credentials for host", { + operation: "resolve_credentials_start", + hostId: host.id as number, + hasCredentialId: !!host.credentialId, + requestingUserId, + ownerId: (host.ownerId || host.userId) as string, + }); + if (host.credentialId && (host.userId || host.ownerId)) { const credentialId = host.credentialId as number; const ownerId = (host.ownerId || host.userId) as string; + // Check if this is a shared host access + if (requestingUserId && requestingUserId !== ownerId) { + // User is accessing a shared host - use shared credential + try { + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + const sharedCred = await sharedCredManager.getSharedCredentialForUser( + host.id as number, + requestingUserId, + ); + + if (sharedCred) { + const resolvedHost: Record = { + ...host, + authType: sharedCred.authType, + password: sharedCred.password, + key: sharedCred.key, + keyPassword: sharedCred.keyPassword, + keyType: sharedCred.keyType, + }; + + // Only override username if overrideCredentialUsername is not enabled + if (!host.overrideCredentialUsername) { + resolvedHost.username = sharedCred.username; + } + + return resolvedHost; + } + } catch (sharedCredError) { + sshLogger.warn( + "Failed to get shared credential, falling back to owner credential", + { + operation: "resolve_shared_credential_fallback", + hostId: host.id as number, + requestingUserId, + error: + sharedCredError instanceof Error + ? sharedCredError.message + : "Unknown error", + }, + ); + // Fall through to try owner's credential + } + } + + // Original owner access - use original credential const credentials = await SimpleDBOps.select( db .select() diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c4588c70..f00224d0 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1215,6 +1215,21 @@ router.post("/login", async (req, res) => { return res.status(401).json({ error: "Incorrect password" }); } + // Re-encrypt any pending shared credentials for this user + try { + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id); + } catch (error) { + authLogger.warn("Failed to re-encrypt pending shared credentials", { + operation: "reencrypt_pending_credentials", + userId: userRecord.id, + error, + }); + // Continue with login even if re-encryption fails + } + if (userRecord.totp_enabled) { const tempToken = await authManager.generateJWTToken(userRecord.id, { pendingTOTP: true, diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts index dff81f9c..a2181677 100644 --- a/src/backend/ssh/docker.ts +++ b/src/backend/ssh/docker.ts @@ -373,12 +373,9 @@ app.post("/docker/ssh/connect", async (req, res) => { } try { - // Get host configuration + // Get host configuration - check both owned and shared hosts const hosts = await SimpleDBOps.select( - getDb() - .select() - .from(sshData) - .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))), + getDb().select().from(sshData).where(eq(sshData.id, hostId)), "ssh_data", userId, ); @@ -388,6 +385,27 @@ app.post("/docker/ssh/connect", async (req, res) => { } const host = hosts[0] as unknown as SSHHost; + + // Verify user has access to this host (either owner or shared access) + if (host.userId !== userId) { + const { PermissionManager } = + await import("../utils/permission-manager.js"); + const permissionManager = PermissionManager.getInstance(); + const accessInfo = await permissionManager.canAccessHost( + userId, + hostId, + "execute", + ); + + if (!accessInfo.hasAccess) { + dockerLogger.warn("User does not have access to host", { + operation: "docker_connect", + hostId, + userId, + }); + return res.status(403).json({ error: "Access denied" }); + } + } if (typeof host.jumpHosts === "string" && host.jumpHosts) { try { host.jumpHosts = JSON.parse(host.jumpHosts); @@ -427,29 +445,61 @@ app.post("/docker/ssh/connect", async (req, res) => { }; if (host.credentialId) { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); + const ownerId = host.userId; - if (credentials.length > 0) { - const credential = credentials[0]; - resolvedCredentials = { - password: credential.password, - sshKey: - credential.private_key || credential.privateKey || credential.key, - keyPassword: credential.key_password || credential.keyPassword, - authType: credential.auth_type || credential.authType, - }; + // Check if this is a shared host access + if (userId !== ownerId) { + // User is accessing a shared host - use shared credential + try { + const { SharedCredentialManager } = + await import("../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + const sharedCred = await sharedCredManager.getSharedCredentialForUser( + host.id, + userId, + ); + + if (sharedCred) { + resolvedCredentials = { + password: sharedCred.password, + sshKey: sharedCred.key, + keyPassword: sharedCred.keyPassword, + authType: sharedCred.authType, + }; + } + } catch (error) { + dockerLogger.error("Failed to resolve shared credential", error, { + operation: "docker_connect", + hostId, + userId, + }); + } + } else { + // Owner accessing their own host + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedCredentials = { + password: credential.password, + sshKey: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + authType: credential.auth_type || credential.authType, + }; + } } } diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index eb68577f..0c1b0db4 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -499,12 +499,7 @@ async function connectSSHTunnel( getDb() .select() .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, tunnelConfig.sourceCredentialId), - eq(sshCredentials.userId, tunnelConfig.sourceUserId), - ), - ), + .where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)), "ssh_credentials", tunnelConfig.sourceUserId, ); @@ -582,12 +577,7 @@ async function connectSSHTunnel( getDb() .select() .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, tunnelConfig.endpointCredentialId), - eq(sshCredentials.userId, tunnelConfig.endpointUserId), - ), - ), + .where(eq(sshCredentials.id, tunnelConfig.endpointCredentialId)), "ssh_credentials", tunnelConfig.endpointUserId, ); @@ -1021,7 +1011,8 @@ async function connectSSHTunnel( if ( tunnelConfig.useSocks5 && (tunnelConfig.socks5Host || - (tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0)) + (tunnelConfig.socks5ProxyChain && + tunnelConfig.socks5ProxyChain.length > 0)) ) { try { const socks5Socket = await createSocks5Connection( @@ -1088,12 +1079,7 @@ async function killRemoteTunnelByMarker( getDb() .select() .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, tunnelConfig.sourceCredentialId), - eq(sshCredentials.userId, tunnelConfig.sourceUserId), - ), - ), + .where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)), "ssh_credentials", tunnelConfig.sourceUserId, ); @@ -1298,7 +1284,8 @@ async function killRemoteTunnelByMarker( if ( tunnelConfig.useSocks5 && (tunnelConfig.socks5Host || - (tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0)) + (tunnelConfig.socks5ProxyChain && + tunnelConfig.socks5ProxyChain.length > 0)) ) { (async () => { try { diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index fd706176..1c2d9b52 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -154,9 +154,8 @@ class AuthManager { return; } - const { getSqlite, saveMemoryDatabaseToFile } = await import( - "../database/db/index.js" - ); + const { getSqlite, saveMemoryDatabaseToFile } = + await import("../database/db/index.js"); const sqlite = getSqlite(); @@ -169,6 +168,33 @@ class AuthManager { if (migrationResult.migrated) { await saveMemoryDatabaseToFile(); } + + // Migrate credentials to system encryption for offline sharing + try { + const { CredentialSystemEncryptionMigration } = + await import("./credential-system-encryption-migration.js"); + const credMigration = new CredentialSystemEncryptionMigration(); + const credResult = await credMigration.migrateUserCredentials(userId); + + if (credResult.migrated > 0) { + databaseLogger.info( + "Credentials migrated to system encryption on login", + { + operation: "login_credential_migration", + userId, + migrated: credResult.migrated, + }, + ); + await saveMemoryDatabaseToFile(); + } + } catch (error) { + // Log but don't fail login + databaseLogger.warn("Credential migration failed during login", { + operation: "login_credential_migration_failed", + userId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } } catch (error) { databaseLogger.error("Lazy encryption migration failed", error, { operation: "lazy_encryption_migration_error", @@ -231,9 +257,8 @@ class AuthManager { }); try { - const { saveMemoryDatabaseToFile } = await import( - "../database/db/index.js" - ); + const { saveMemoryDatabaseToFile } = + await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( @@ -334,9 +359,8 @@ class AuthManager { await db.delete(sessions).where(eq(sessions.id, sessionId)); try { - const { saveMemoryDatabaseToFile } = await import( - "../database/db/index.js" - ); + const { saveMemoryDatabaseToFile } = + await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( @@ -387,9 +411,8 @@ class AuthManager { } try { - const { saveMemoryDatabaseToFile } = await import( - "../database/db/index.js" - ); + const { saveMemoryDatabaseToFile } = + await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( @@ -430,9 +453,8 @@ class AuthManager { .where(sql`${sessions.expiresAt} < datetime('now')`); try { - const { saveMemoryDatabaseToFile } = await import( - "../database/db/index.js" - ); + const { saveMemoryDatabaseToFile } = + await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( @@ -568,9 +590,8 @@ class AuthManager { .where(eq(sessions.id, payload.sessionId)) .then(async () => { try { - const { saveMemoryDatabaseToFile } = await import( - "../database/db/index.js" - ); + const { saveMemoryDatabaseToFile } = + await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); const remainingSessions = await db @@ -714,9 +735,8 @@ class AuthManager { await db.delete(sessions).where(eq(sessions.id, sessionId)); try { - const { saveMemoryDatabaseToFile } = await import( - "../database/db/index.js" - ); + const { saveMemoryDatabaseToFile } = + await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( diff --git a/src/backend/utils/credential-system-encryption-migration.ts b/src/backend/utils/credential-system-encryption-migration.ts new file mode 100644 index 00000000..e8f430ae --- /dev/null +++ b/src/backend/utils/credential-system-encryption-migration.ts @@ -0,0 +1,164 @@ +import { db } from "../database/db/index.js"; +import { sshCredentials } from "../database/db/schema.js"; +import { eq, and, or, isNull } from "drizzle-orm"; +import { DataCrypto } from "./data-crypto.js"; +import { SystemCrypto } from "./system-crypto.js"; +import { FieldCrypto } from "./field-crypto.js"; +import { databaseLogger } from "./logger.js"; + +/** + * Migrates credentials to include system-encrypted fields for offline sharing + */ +export class CredentialSystemEncryptionMigration { + /** + * Migrates a user's credentials to include system-encrypted fields + * Requires user to be logged in (DEK available) + */ + async migrateUserCredentials(userId: string): Promise<{ + migrated: number; + failed: number; + skipped: number; + }> { + try { + // Get user's DEK (requires logged in) + const userDEK = DataCrypto.getUserDataKey(userId); + if (!userDEK) { + throw new Error("User must be logged in to migrate credentials"); + } + + // Get system key + const systemCrypto = SystemCrypto.getInstance(); + const CSKEK = await systemCrypto.getCredentialSharingKey(); + + // Find credentials without system encryption + const credentials = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.userId, userId), + or( + isNull(sshCredentials.systemPassword), + isNull(sshCredentials.systemKey), + isNull(sshCredentials.systemKeyPassword), + ), + ), + ); + + let migrated = 0; + let failed = 0; + const skipped = 0; + + for (const cred of credentials) { + try { + // Decrypt with user DEK + const plainPassword = cred.password + ? FieldCrypto.decryptField( + cred.password, + userDEK, + cred.id.toString(), + "password", + ) + : null; + + const plainKey = cred.key + ? FieldCrypto.decryptField( + cred.key, + userDEK, + cred.id.toString(), + "key", + ) + : null; + + const plainKeyPassword = cred.key_password + ? FieldCrypto.decryptField( + cred.key_password, + userDEK, + cred.id.toString(), + "key_password", + ) + : null; + + // Re-encrypt with CSKEK + const systemPassword = plainPassword + ? FieldCrypto.encryptField( + plainPassword, + CSKEK, + cred.id.toString(), + "password", + ) + : null; + + const systemKey = plainKey + ? FieldCrypto.encryptField( + plainKey, + CSKEK, + cred.id.toString(), + "key", + ) + : null; + + const systemKeyPassword = plainKeyPassword + ? FieldCrypto.encryptField( + plainKeyPassword, + CSKEK, + cred.id.toString(), + "key_password", + ) + : null; + + // Update database + await db + .update(sshCredentials) + .set({ + systemPassword, + systemKey, + systemKeyPassword, + updatedAt: new Date().toISOString(), + }) + .where(eq(sshCredentials.id, cred.id)); + + migrated++; + + databaseLogger.info("Credential migrated for offline sharing", { + operation: "credential_system_encryption_migrated", + credentialId: cred.id, + userId, + }); + } catch (error) { + databaseLogger.error("Failed to migrate credential", error, { + credentialId: cred.id, + userId, + }); + failed++; + } + } + + if (migrated > 0) { + databaseLogger.success( + "Credential system encryption migration completed", + { + operation: "credential_migration_complete", + userId, + migrated, + failed, + skipped, + }, + ); + } + + return { migrated, failed, skipped }; + } catch (error) { + databaseLogger.error( + "Credential system encryption migration failed", + error, + { + operation: "credential_migration_failed", + userId, + error: error instanceof Error ? error.message : "Unknown error", + }, + ); + throw error; + } + } +} diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index 462d2956..19f0326f 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -475,6 +475,56 @@ class DataCrypto { return false; } } + + /** + * Encrypt sensitive credential fields with system key for offline sharing + * Returns an object with systemPassword, systemKey, systemKeyPassword fields + */ + static async encryptRecordWithSystemKey>( + tableName: string, + record: T, + systemKey: Buffer, + ): Promise> { + const systemEncrypted: Record = {}; + const recordId = record.id || "temp-" + Date.now(); + + // Only encrypt for sshCredentials table + if (tableName !== "ssh_credentials") { + return systemEncrypted as Partial; + } + + // Encrypt password field + if (record.password && typeof record.password === "string") { + systemEncrypted.systemPassword = FieldCrypto.encryptField( + record.password as string, + systemKey, + recordId as string, + "password", + ); + } + + // Encrypt key field + if (record.key && typeof record.key === "string") { + systemEncrypted.systemKey = FieldCrypto.encryptField( + record.key as string, + systemKey, + recordId as string, + "key", + ); + } + + // Encrypt key_password field + if (record.key_password && typeof record.key_password === "string") { + systemEncrypted.systemKeyPassword = FieldCrypto.encryptField( + record.key_password as string, + systemKey, + recordId as string, + "key_password", + ); + } + + return systemEncrypted as Partial; + } } export { DataCrypto }; diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts index b529b5d8..38dc8549 100644 --- a/src/backend/utils/permission-manager.ts +++ b/src/backend/utils/permission-manager.ts @@ -19,7 +19,7 @@ interface HostAccessInfo { hasAccess: boolean; isOwner: boolean; isShared: boolean; - permissionLevel?: string; + permissionLevel?: "view"; // Only "view" is supported for shared access expiresAt?: string | null; } @@ -246,32 +246,28 @@ class PermissionManager { if (sharedAccess.length > 0) { const access = sharedAccess[0]; - // Check permission level for write/delete actions + // All shared access is view-only - deny write/delete if (action === "write" || action === "delete") { - const level = access.permissionLevel; - if (level === "view" || level === "readonly") { - return { - hasAccess: false, - isOwner: false, - isShared: true, - permissionLevel: level, - expiresAt: access.expiresAt, - }; - } + return { + hasAccess: false, + isOwner: false, + isShared: true, + permissionLevel: access.permissionLevel as "view", + expiresAt: access.expiresAt, + }; } // Update last accessed time try { - db.update(hostAccess) + await db + .update(hostAccess) .set({ lastAccessedAt: now, - accessCount: sql`${hostAccess.accessCount} + 1`, }) - .where(eq(hostAccess.id, access.id)) - .run(); + .where(eq(hostAccess.id, access.id)); } catch (error) { - databaseLogger.warn("Failed to update host access stats", { - operation: "update_host_access_stats", + databaseLogger.warn("Failed to update host access timestamp", { + operation: "update_host_access_timestamp", error, }); } @@ -280,7 +276,7 @@ class PermissionManager { hasAccess: true, isOwner: false, isShared: true, - permissionLevel: access.permissionLevel, + permissionLevel: access.permissionLevel as "view", expiresAt: access.expiresAt, }; } diff --git a/src/backend/utils/shared-credential-manager.ts b/src/backend/utils/shared-credential-manager.ts new file mode 100644 index 00000000..57687c34 --- /dev/null +++ b/src/backend/utils/shared-credential-manager.ts @@ -0,0 +1,817 @@ +import { db } from "../database/db/index.js"; +import { + sharedCredentials, + sshCredentials, + hostAccess, + users, + userRoles, + sshData, +} from "../database/db/schema.js"; +import { eq, and } from "drizzle-orm"; +import { DataCrypto } from "./data-crypto.js"; +import { FieldCrypto } from "./field-crypto.js"; +import { databaseLogger } from "./logger.js"; + +interface CredentialData { + username: string; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; +} + +/** + * Manages shared credentials for RBAC host sharing. + * Creates per-user encrypted credential copies to enable credential sharing + * without requiring the credential owner to be online. + */ +class SharedCredentialManager { + private static instance: SharedCredentialManager; + + private constructor() {} + + static getInstance(): SharedCredentialManager { + if (!this.instance) { + this.instance = new SharedCredentialManager(); + } + return this.instance; + } + + /** + * Create shared credential for a specific user + * Called when sharing a host with a user + */ + async createSharedCredentialForUser( + hostAccessId: number, + originalCredentialId: number, + targetUserId: string, + ownerId: string, + ): Promise { + try { + // Try owner's DEK first (existing path) + const ownerDEK = DataCrypto.getUserDataKey(ownerId); + + if (ownerDEK) { + // Owner online - use existing flow + const targetDEK = DataCrypto.getUserDataKey(targetUserId); + if (!targetDEK) { + // Target user is offline, mark for lazy re-encryption + await this.createPendingSharedCredential( + hostAccessId, + originalCredentialId, + targetUserId, + ); + return; + } + + // Fetch and decrypt original credential using owner's DEK + const credentialData = await this.getDecryptedCredential( + originalCredentialId, + ownerId, + ownerDEK, + ); + + // Encrypt credential data with target user's DEK + const encryptedForTarget = this.encryptCredentialForUser( + credentialData, + targetUserId, + targetDEK, + hostAccessId, + ); + + // Store shared credential + await db.insert(sharedCredentials).values({ + hostAccessId, + originalCredentialId, + targetUserId, + ...encryptedForTarget, + needsReEncryption: false, + }); + + databaseLogger.info("Created shared credential for user", { + operation: "create_shared_credential", + hostAccessId, + targetUserId, + }); + } else { + // NEW: Owner offline - use system key fallback + databaseLogger.info( + "Owner offline, attempting to share using system key", + { + operation: "create_shared_credential_system_key", + hostAccessId, + targetUserId, + ownerId, + }, + ); + + // Get target user's DEK + const targetDEK = DataCrypto.getUserDataKey(targetUserId); + if (!targetDEK) { + // Both offline - create pending + await this.createPendingSharedCredential( + hostAccessId, + originalCredentialId, + targetUserId, + ); + return; + } + + // Decrypt using system key + const credentialData = + await this.getDecryptedCredentialViaSystemKey(originalCredentialId); + + // Encrypt for target user + const encryptedForTarget = this.encryptCredentialForUser( + credentialData, + targetUserId, + targetDEK, + hostAccessId, + ); + + // Store shared credential + await db.insert(sharedCredentials).values({ + hostAccessId, + originalCredentialId, + targetUserId, + ...encryptedForTarget, + needsReEncryption: false, + }); + + databaseLogger.info("Created shared credential using system key", { + operation: "create_shared_credential_system_key", + hostAccessId, + targetUserId, + }); + } + } catch (error) { + databaseLogger.error("Failed to create shared credential", error, { + operation: "create_shared_credential", + hostAccessId, + targetUserId, + }); + throw error; + } + } + + /** + * Create shared credentials for all users in a role + * Called when sharing a host with a role + */ + async createSharedCredentialsForRole( + hostAccessId: number, + originalCredentialId: number, + roleId: number, + ownerId: string, + ): Promise { + try { + // Get all users in the role + const roleUsers = await db + .select({ userId: userRoles.userId }) + .from(userRoles) + .where(eq(userRoles.roleId, roleId)); + + // Create shared credential for each user + for (const { userId } of roleUsers) { + try { + await this.createSharedCredentialForUser( + hostAccessId, + originalCredentialId, + userId, + ownerId, + ); + } catch (error) { + databaseLogger.error( + "Failed to create shared credential for role member", + error, + { + operation: "create_shared_credentials_role", + hostAccessId, + roleId, + userId, + }, + ); + // Continue with other users even if one fails + } + } + + databaseLogger.info("Created shared credentials for role", { + operation: "create_shared_credentials_role", + hostAccessId, + roleId, + userCount: roleUsers.length, + }); + } catch (error) { + databaseLogger.error( + "Failed to create shared credentials for role", + error, + { + operation: "create_shared_credentials_role", + hostAccessId, + roleId, + }, + ); + throw error; + } + } + + /** + * Get credential data for a shared user + * Called when a shared user connects to a host + */ + async getSharedCredentialForUser( + hostId: number, + userId: string, + ): Promise { + try { + const userDEK = DataCrypto.getUserDataKey(userId); + if (!userDEK) { + throw new Error(`User ${userId} data not unlocked`); + } + + // Find shared credential via hostAccess + const sharedCred = await db + .select() + .from(sharedCredentials) + .innerJoin( + hostAccess, + eq(sharedCredentials.hostAccessId, hostAccess.id), + ) + .where( + and( + eq(hostAccess.hostId, hostId), + eq(sharedCredentials.targetUserId, userId), + ), + ) + .limit(1); + + if (sharedCred.length === 0) { + return null; + } + + const cred = sharedCred[0].shared_credentials; + + // Check if needs re-encryption + if (cred.needsReEncryption) { + databaseLogger.warn( + "Shared credential needs re-encryption but cannot be accessed yet", + { + operation: "get_shared_credential_pending", + hostId, + userId, + }, + ); + // Credential is pending re-encryption - owner must be offline + // Return null instead of trying to re-encrypt (which would cause infinite loop) + return null; + } + + // Decrypt credential data with user's DEK + return this.decryptSharedCredential(cred, userDEK); + } catch (error) { + databaseLogger.error("Failed to get shared credential", error, { + operation: "get_shared_credential", + hostId, + userId, + }); + throw error; + } + } + + /** + * Update all shared credentials when original credential is updated + * Called when credential owner updates credential + */ + async updateSharedCredentialsForOriginal( + credentialId: number, + ownerId: string, + ): Promise { + try { + // Get all shared credentials for this original credential + const sharedCreds = await db + .select() + .from(sharedCredentials) + .where(eq(sharedCredentials.originalCredentialId, credentialId)); + + // Try owner's DEK first + const ownerDEK = DataCrypto.getUserDataKey(ownerId); + let credentialData: CredentialData; + + if (ownerDEK) { + // Owner online - use owner's DEK + credentialData = await this.getDecryptedCredential( + credentialId, + ownerId, + ownerDEK, + ); + } else { + // Owner offline - use system key fallback + databaseLogger.info( + "Updating shared credentials using system key (owner offline)", + { + operation: "update_shared_credentials_system_key", + credentialId, + ownerId, + }, + ); + + try { + credentialData = + await this.getDecryptedCredentialViaSystemKey(credentialId); + } catch (error) { + databaseLogger.warn( + "Cannot update shared credentials: owner offline and credential not migrated", + { + operation: "update_shared_credentials_failed", + credentialId, + ownerId, + error: error instanceof Error ? error.message : "Unknown error", + }, + ); + // Mark all shared credentials for re-encryption + await db + .update(sharedCredentials) + .set({ needsReEncryption: true }) + .where(eq(sharedCredentials.originalCredentialId, credentialId)); + return; + } + } + + // Update each shared credential + for (const sharedCred of sharedCreds) { + const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId); + + if (!targetDEK) { + // Target user offline, mark for lazy re-encryption + await db + .update(sharedCredentials) + .set({ needsReEncryption: true }) + .where(eq(sharedCredentials.id, sharedCred.id)); + continue; + } + + // Re-encrypt with target user's DEK + const encryptedForTarget = this.encryptCredentialForUser( + credentialData, + sharedCred.targetUserId, + targetDEK, + sharedCred.hostAccessId, + ); + + await db + .update(sharedCredentials) + .set({ + ...encryptedForTarget, + needsReEncryption: false, + updatedAt: new Date().toISOString(), + }) + .where(eq(sharedCredentials.id, sharedCred.id)); + } + + databaseLogger.info("Updated shared credentials for original", { + operation: "update_shared_credentials", + credentialId, + count: sharedCreds.length, + }); + } catch (error) { + databaseLogger.error("Failed to update shared credentials", error, { + operation: "update_shared_credentials", + credentialId, + }); + } + } + + /** + * Delete shared credentials when original credential is deleted + * Called from credential deletion route + */ + async deleteSharedCredentialsForOriginal( + credentialId: number, + ): Promise { + try { + const result = await db + .delete(sharedCredentials) + .where(eq(sharedCredentials.originalCredentialId, credentialId)) + .returning({ id: sharedCredentials.id }); + + databaseLogger.info("Deleted shared credentials for original", { + operation: "delete_shared_credentials", + credentialId, + count: result.length, + }); + } catch (error) { + databaseLogger.error("Failed to delete shared credentials", error, { + operation: "delete_shared_credentials", + credentialId, + }); + } + } + + /** + * Re-encrypt pending shared credentials for a user when they log in + * Called during user login + */ + async reEncryptPendingCredentialsForUser(userId: string): Promise { + try { + const userDEK = DataCrypto.getUserDataKey(userId); + if (!userDEK) { + return; // User not unlocked yet + } + + const pendingCreds = await db + .select() + .from(sharedCredentials) + .where( + and( + eq(sharedCredentials.targetUserId, userId), + eq(sharedCredentials.needsReEncryption, true), + ), + ); + + for (const cred of pendingCreds) { + await this.reEncryptSharedCredential(cred.id, userId); + } + + if (pendingCreds.length > 0) { + databaseLogger.info("Re-encrypted pending credentials for user", { + operation: "reencrypt_pending_credentials", + userId, + count: pendingCreds.length, + }); + } + } catch (error) { + databaseLogger.error("Failed to re-encrypt pending credentials", error, { + operation: "reencrypt_pending_credentials", + userId, + }); + } + } + + // ========== PRIVATE HELPER METHODS ========== + + private async getDecryptedCredential( + credentialId: number, + ownerId: string, + ownerDEK: Buffer, + ): Promise { + const creds = await db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, ownerId), + ), + ) + .limit(1); + + if (creds.length === 0) { + throw new Error(`Credential ${credentialId} not found`); + } + + const cred = creds[0]; + + // Decrypt sensitive fields + // Note: username and authType are NOT encrypted + return { + username: cred.username, + authType: cred.authType, + password: cred.password + ? this.decryptField(cred.password, ownerDEK, credentialId, "password") + : undefined, + key: cred.key + ? this.decryptField(cred.key, ownerDEK, credentialId, "key") + : undefined, + keyPassword: cred.key_password + ? this.decryptField( + cred.key_password, + ownerDEK, + credentialId, + "key_password", + ) + : undefined, + keyType: cred.keyType, + }; + } + + /** + * Decrypt credential using system key (for offline sharing when owner is offline) + */ + private async getDecryptedCredentialViaSystemKey( + credentialId: number, + ): Promise { + const creds = await db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, credentialId)) + .limit(1); + + if (creds.length === 0) { + throw new Error(`Credential ${credentialId} not found`); + } + + const cred = creds[0]; + + // Check if system fields exist + if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) { + throw new Error( + "Credential not yet migrated for offline sharing. " + + "Please ask credential owner to log in to enable sharing.", + ); + } + + // Get system key + const { SystemCrypto } = await import("./system-crypto.js"); + const systemCrypto = SystemCrypto.getInstance(); + const CSKEK = await systemCrypto.getCredentialSharingKey(); + + // Decrypt using system-encrypted fields + return { + username: cred.username, + authType: cred.authType, + password: cred.systemPassword + ? this.decryptField( + cred.systemPassword, + CSKEK, + credentialId, + "password", + ) + : undefined, + key: cred.systemKey + ? this.decryptField(cred.systemKey, CSKEK, credentialId, "key") + : undefined, + keyPassword: cred.systemKeyPassword + ? this.decryptField( + cred.systemKeyPassword, + CSKEK, + credentialId, + "key_password", + ) + : undefined, + keyType: cred.keyType, + }; + } + + private encryptCredentialForUser( + credentialData: CredentialData, + targetUserId: string, + targetDEK: Buffer, + hostAccessId: number, + ): { + encryptedUsername: string; + encryptedAuthType: string; + encryptedPassword: string | null; + encryptedKey: string | null; + encryptedKeyPassword: string | null; + encryptedKeyType: string | null; + } { + const recordId = `shared-${hostAccessId}-${targetUserId}`; + + return { + encryptedUsername: FieldCrypto.encryptField( + credentialData.username, + targetDEK, + recordId, + "username", + ), + encryptedAuthType: credentialData.authType, // authType is not sensitive + encryptedPassword: credentialData.password + ? FieldCrypto.encryptField( + credentialData.password, + targetDEK, + recordId, + "password", + ) + : null, + encryptedKey: credentialData.key + ? FieldCrypto.encryptField( + credentialData.key, + targetDEK, + recordId, + "key", + ) + : null, + encryptedKeyPassword: credentialData.keyPassword + ? FieldCrypto.encryptField( + credentialData.keyPassword, + targetDEK, + recordId, + "key_password", + ) + : null, + encryptedKeyType: credentialData.keyType || null, + }; + } + + private decryptSharedCredential( + sharedCred: typeof sharedCredentials.$inferSelect, + userDEK: Buffer, + ): CredentialData { + const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`; + + return { + username: FieldCrypto.decryptField( + sharedCred.encryptedUsername, + userDEK, + recordId, + "username", + ), + authType: sharedCred.encryptedAuthType, + password: sharedCred.encryptedPassword + ? FieldCrypto.decryptField( + sharedCred.encryptedPassword, + userDEK, + recordId, + "password", + ) + : undefined, + key: sharedCred.encryptedKey + ? FieldCrypto.decryptField( + sharedCred.encryptedKey, + userDEK, + recordId, + "key", + ) + : undefined, + keyPassword: sharedCred.encryptedKeyPassword + ? FieldCrypto.decryptField( + sharedCred.encryptedKeyPassword, + userDEK, + recordId, + "key_password", + ) + : undefined, + keyType: sharedCred.encryptedKeyType || undefined, + }; + } + + private decryptField( + encryptedValue: string, + dek: Buffer, + recordId: number | string, + fieldName: string, + ): string { + try { + return FieldCrypto.decryptField( + encryptedValue, + dek, + recordId.toString(), + fieldName, + ); + } catch (error) { + // If decryption fails, value might not be encrypted (legacy data) + databaseLogger.warn("Field decryption failed, returning as-is", { + operation: "decrypt_field", + fieldName, + recordId, + }); + return encryptedValue; + } + } + + private async createPendingSharedCredential( + hostAccessId: number, + originalCredentialId: number, + targetUserId: string, + ): Promise { + // Create placeholder with needsReEncryption flag + await db.insert(sharedCredentials).values({ + hostAccessId, + originalCredentialId, + targetUserId, + encryptedUsername: "", // Will be filled during re-encryption + encryptedAuthType: "", + needsReEncryption: true, + }); + + databaseLogger.info("Created pending shared credential", { + operation: "create_pending_shared_credential", + hostAccessId, + targetUserId, + }); + } + + private async reEncryptSharedCredential( + sharedCredId: number, + userId: string, + ): Promise { + try { + // Get the shared credential + const sharedCred = await db + .select() + .from(sharedCredentials) + .where(eq(sharedCredentials.id, sharedCredId)) + .limit(1); + + if (sharedCred.length === 0) { + databaseLogger.warn("Re-encrypt: shared credential not found", { + operation: "reencrypt_not_found", + sharedCredId, + }); + return; + } + + const cred = sharedCred[0]; + + // Get the host access to find the owner + const access = await db + .select() + .from(hostAccess) + .innerJoin(sshData, eq(hostAccess.hostId, sshData.id)) + .where(eq(hostAccess.id, cred.hostAccessId)) + .limit(1); + + if (access.length === 0) { + databaseLogger.warn("Re-encrypt: host access not found", { + operation: "reencrypt_access_not_found", + sharedCredId, + }); + return; + } + + const ownerId = access[0].ssh_data.userId; + + // Get user's DEK (must be available) + const userDEK = DataCrypto.getUserDataKey(userId); + if (!userDEK) { + databaseLogger.warn("Re-encrypt: user DEK not available", { + operation: "reencrypt_user_offline", + sharedCredId, + userId, + }); + // User offline, keep pending + return; + } + + // Try owner's DEK first + const ownerDEK = DataCrypto.getUserDataKey(ownerId); + let credentialData: CredentialData; + + if (ownerDEK) { + // Owner online - use owner's DEK + credentialData = await this.getDecryptedCredential( + cred.originalCredentialId, + ownerId, + ownerDEK, + ); + } else { + // Owner offline - use system key fallback + databaseLogger.info("Re-encrypt: using system key (owner offline)", { + operation: "reencrypt_system_key", + sharedCredId, + ownerId, + }); + + try { + credentialData = await this.getDecryptedCredentialViaSystemKey( + cred.originalCredentialId, + ); + } catch (error) { + databaseLogger.warn( + "Re-encrypt: system key decryption failed, credential may not be migrated yet", + { + operation: "reencrypt_system_key_failed", + sharedCredId, + error: error instanceof Error ? error.message : "Unknown error", + }, + ); + // Keep pending if system fields don't exist yet + return; + } + } + + // Re-encrypt for user + const encryptedForTarget = this.encryptCredentialForUser( + credentialData, + userId, + userDEK, + cred.hostAccessId, + ); + + // Update shared credential + await db + .update(sharedCredentials) + .set({ + ...encryptedForTarget, + needsReEncryption: false, + updatedAt: new Date().toISOString(), + }) + .where(eq(sharedCredentials.id, sharedCredId)); + + databaseLogger.info("Re-encrypted shared credential successfully", { + operation: "reencrypt_shared_credential", + sharedCredId, + userId, + }); + } catch (error) { + databaseLogger.error("Failed to re-encrypt shared credential", error, { + operation: "reencrypt_shared_credential", + sharedCredId, + userId, + }); + } + } +} + +export { SharedCredentialManager }; diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index 57d2803e..e8dfafb7 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -2,7 +2,12 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; import { DataCrypto } from "./data-crypto.js"; import type { SQLiteTable } from "drizzle-orm/sqlite-core"; -type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity" | "socks5_proxy_presets"; +type TableName = + | "users" + | "ssh_data" + | "ssh_credentials" + | "recent_activity" + | "socks5_proxy_presets"; class SimpleDBOps { static async insert>( @@ -23,6 +28,21 @@ class SimpleDBOps { userDataKey, ); + // Also encrypt with system key for ssh_credentials (offline sharing) + if (tableName === "ssh_credentials") { + const { SystemCrypto } = await import("./system-crypto.js"); + const systemCrypto = SystemCrypto.getInstance(); + const systemKey = await systemCrypto.getCredentialSharingKey(); + + const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey( + tableName, + dataWithTempId, + systemKey, + ); + + Object.assign(encryptedData, systemEncrypted); + } + if (!data.id) { delete encryptedData.id; } @@ -105,6 +125,21 @@ class SimpleDBOps { userDataKey, ); + // Also encrypt with system key for ssh_credentials (offline sharing) + if (tableName === "ssh_credentials") { + const { SystemCrypto } = await import("./system-crypto.js"); + const systemCrypto = SystemCrypto.getInstance(); + const systemKey = await systemCrypto.getCredentialSharingKey(); + + const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey( + tableName, + data, + systemKey, + ); + + Object.assign(encryptedData, systemEncrypted); + } + const result = await getDb() .update(table) .set(encryptedData) diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index fdff0263..34f60cee 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -8,6 +8,7 @@ class SystemCrypto { private jwtSecret: string | null = null; private databaseKey: Buffer | null = null; private internalAuthToken: string | null = null; + private credentialSharingKey: Buffer | null = null; private constructor() {} @@ -158,6 +159,48 @@ class SystemCrypto { return this.internalAuthToken!; } + async initializeCredentialSharingKey(): Promise { + try { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + const envKey = process.env.CREDENTIAL_SHARING_KEY; + if (envKey && envKey.length >= 64) { + this.credentialSharingKey = Buffer.from(envKey, "hex"); + return; + } + + try { + const envContent = await fs.readFile(envPath, "utf8"); + const csKeyMatch = envContent.match(/^CREDENTIAL_SHARING_KEY=(.+)$/m); + if (csKeyMatch && csKeyMatch[1] && csKeyMatch[1].length >= 64) { + this.credentialSharingKey = Buffer.from(csKeyMatch[1], "hex"); + process.env.CREDENTIAL_SHARING_KEY = csKeyMatch[1]; + return; + } + } catch (fileError) {} + + await this.generateAndGuideCredentialSharingKey(); + } catch (error) { + databaseLogger.error( + "Failed to initialize credential sharing key", + error, + { + operation: "cred_sharing_key_init_failed", + dataDir: process.env.DATA_DIR || "./db/data", + }, + ); + throw new Error("Credential sharing key initialization failed"); + } + } + + async getCredentialSharingKey(): Promise { + if (!this.credentialSharingKey) { + await this.initializeCredentialSharingKey(); + } + return this.credentialSharingKey!; + } + private async generateAndGuideUser(): Promise { const newSecret = crypto.randomBytes(32).toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex"); @@ -210,6 +253,26 @@ class SystemCrypto { ); } + private async generateAndGuideCredentialSharingKey(): Promise { + const newKey = crypto.randomBytes(32); + const newKeyHex = newKey.toString("hex"); + const instanceId = crypto.randomBytes(8).toString("hex"); + + this.credentialSharingKey = newKey; + + await this.updateEnvFile("CREDENTIAL_SHARING_KEY", newKeyHex); + + databaseLogger.success( + "Credential sharing key auto-generated and saved to .env", + { + operation: "cred_sharing_key_auto_generated", + instanceId, + envVarName: "CREDENTIAL_SHARING_KEY", + note: "Used for offline credential sharing - no restart required", + }, + ); + } + async validateJWTSecret(): Promise { try { const secret = await this.getJWTSecret(); diff --git a/src/locales/en.json b/src/locales/en.json index d83a9b2a..e7290f95 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -875,6 +875,7 @@ "selectCredentialPlaceholder": "Choose a credential...", "credentialRequired": "Credential is required when using credential authentication", "credentialDescription": "Selecting a credential will overwrite the current username and use the credential's authentication details.", + "cannotChangeAuthAsSharedUser": "Cannot change authentication as shared user", "sshPrivateKey": "SSH Private Key", "keyPassword": "Key Password", "keyType": "Key Type", @@ -2308,11 +2309,7 @@ "sharing": "Sharing", "selectUserAndRole": "Please select both a user and a role", "view": "View Only", - "viewDesc": "Can view and connect to the host in read-only mode", - "use": "Use", - "useDesc": "Can use the host normally but cannot modify host configuration", - "manage": "Manage", - "manageDesc": "Full control including modifying host configuration and sharing settings" + "viewDesc": "Due to the Termix encryption system, other permission levels will come at a later date" }, "commandPalette": { "searchPlaceholder": "Search for hosts or quick actions...", diff --git a/src/types/index.ts b/src/types/index.ts index 82605fec..5be5b549 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -60,9 +60,9 @@ export interface SSHHost { createdAt: string; updatedAt: string; - // Shared access metadata + // Shared access metadata (view-only) isShared?: boolean; - permissionLevel?: "view" | "manage"; + permissionLevel?: "view"; sharedExpiresAt?: string; } @@ -394,6 +394,7 @@ export interface TabContextTab { hostConfig?: SSHHost; terminalRef?: any; initialTab?: string; + _updateTimestamp?: number; } export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; @@ -485,6 +486,7 @@ export interface HostManagerProps { isTopbarOpen?: boolean; initialTab?: string; hostConfig?: SSHHost; + _updateTimestamp?: number; rightSidebarOpen?: boolean; rightSidebarWidth?: number; } diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 478d4592..d70e1e72 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -277,6 +277,7 @@ function AppContent() { isTopbarOpen={isTopbarOpen} initialTab={currentTabData?.initialTab} hostConfig={currentTabData?.hostConfig} + _updateTimestamp={currentTabData?._updateTimestamp} rightSidebarOpen={rightSidebarOpen} rightSidebarWidth={rightSidebarWidth} /> diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index 1dd53977..46b926b7 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -19,6 +19,8 @@ import { FolderOpen, Pencil, EllipsisVertical, + ArrowDownUp, + Container, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { BiMoney, BiSupport } from "react-icons/bi"; @@ -27,6 +29,7 @@ import { GrUpdate } from "react-icons/gr"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts"; import type { RecentActivityItem } from "@/ui/main-axios.ts"; +import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { DropdownMenu, DropdownMenuTrigger, @@ -52,8 +55,10 @@ interface SSHHost { enableTerminal: boolean; enableTunnel: boolean; enableFileManager: boolean; + enableDocker: boolean; defaultPath: string; tunnelConnections: unknown[]; + statsConfig?: string; createdAt: string; updatedAt: string; } @@ -88,7 +93,10 @@ export function CommandPalette({ const handleAddHost = () => { const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); if (sshManagerTab) { - updateTab(sshManagerTab.id, { initialTab: "add_host" }); + updateTab(sshManagerTab.id, { + initialTab: "add_host", + hostConfig: undefined, + }); setCurrentTab(sshManagerTab.id); } else { const id = addTab({ @@ -104,7 +112,10 @@ export function CommandPalette({ const handleAddCredential = () => { const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); if (sshManagerTab) { - updateTab(sshManagerTab.id, { initialTab: "add_credential" }); + updateTab(sshManagerTab.id, { + initialTab: "add_credential", + hostConfig: undefined, + }); setCurrentTab(sshManagerTab.id); } else { const id = addTab({ @@ -216,6 +227,22 @@ export function CommandPalette({ setIsOpen(false); }; + const handleHostTunnelClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ type: "tunnel", title, hostConfig: host }); + setIsOpen(false); + }; + + const handleHostDockerClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ type: "docker", title, hostConfig: host }); + setIsOpen(false); + }; + const handleHostEditClick = (host: SSHHost) => { const title = host.name?.trim() ? host.name @@ -301,6 +328,33 @@ export function CommandPalette({ const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`; + + // Parse statsConfig to determine if metrics should be shown + let shouldShowMetrics = true; + try { + const statsConfig = host.statsConfig + ? JSON.parse(host.statsConfig) + : DEFAULT_STATS_CONFIG; + shouldShowMetrics = statsConfig.metricsEnabled !== false; + } catch { + shouldShowMetrics = true; + } + + // Check if host has at least one tunnel connection + let hasTunnelConnections = false; + try { + const tunnelConnections = Array.isArray( + host.tunnelConnections, + ) + ? host.tunnelConnections + : JSON.parse(host.tunnelConnections as string); + hasTunnelConnections = + Array.isArray(tunnelConnections) && + tunnelConnections.length > 0; + } catch { + hasTunnelConnections = false; + } + return ( - { - e.stopPropagation(); - handleHostServerDetailsClick(host); - }} - className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" - > - - - {t("commandPalette.openServerDetails")} - - - { - e.stopPropagation(); - handleHostFileManagerClick(host); - }} - className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" - > - - - {t("commandPalette.openFileManager")} - - + {shouldShowMetrics && ( + { + e.stopPropagation(); + handleHostServerDetailsClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" + > + + + {t("hosts.openServerStats")} + + + )} + {host.enableFileManager && ( + { + e.stopPropagation(); + handleHostFileManagerClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" + > + + + {t("hosts.openFileManager")} + + + )} + {host.enableTunnel && hasTunnelConnections && ( + { + e.stopPropagation(); + handleHostTunnelClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" + > + + + {t("hosts.openTunnels")} + + + )} + {host.enableDocker && ( + { + e.stopPropagation(); + handleHostDockerClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" + > + + + {t("hosts.openDocker")} + + + )} { e.stopPropagation(); @@ -367,9 +453,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" > - - {t("commandPalette.edit")} - + {t("common.edit")} diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 215d9cc9..110c06e1 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -317,7 +317,10 @@ export function Dashboard({ const handleAddHost = () => { const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); if (sshManagerTab) { - updateTab(sshManagerTab.id, { initialTab: "add_host" }); + updateTab(sshManagerTab.id, { + initialTab: "add_host", + hostConfig: undefined, + }); setCurrentTab(sshManagerTab.id); } else { const id = addTab({ @@ -332,7 +335,10 @@ export function Dashboard({ const handleAddCredential = () => { const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); if (sshManagerTab) { - updateTab(sshManagerTab.id, { initialTab: "add_credential" }); + updateTab(sshManagerTab.id, { + initialTab: "add_credential", + hostConfig: undefined, + }); setCurrentTab(sshManagerTab.id); } else { const id = addTab({ diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx index ea40cd29..e06be97f 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManager.tsx @@ -18,6 +18,7 @@ export function HostManager({ isTopbarOpen, initialTab = "host_viewer", hostConfig, + _updateTimestamp, rightSidebarOpen = false, rightSidebarWidth = 400, }: HostManagerProps): React.ReactElement { @@ -36,20 +37,39 @@ export function HostManager({ const ignoreNextHostConfigChangeRef = useRef(false); const lastProcessedHostIdRef = useRef(undefined); + // Sync state when tab is updated externally (via updateTab or addTab) useEffect(() => { - if (initialTab) { - setActiveTab(initialTab); - } - }, [initialTab]); + // Always sync on timestamp changes + if (_updateTimestamp !== undefined) { + // Update activeTab if initialTab has changed + if (initialTab && initialTab !== activeTab) { + setActiveTab(initialTab); + } - // Update editingHost when hostConfig changes - useEffect(() => { - if (hostConfig) { - setEditingHost(hostConfig); - setActiveTab("add_host"); - lastProcessedHostIdRef.current = hostConfig.id; + // Update editingHost if hostConfig has changed + if (hostConfig && hostConfig.id !== editingHost?.id) { + setEditingHost(hostConfig); + lastProcessedHostIdRef.current = hostConfig.id; + } else if (!hostConfig && editingHost) { + // Clear editingHost if hostConfig is now undefined + setEditingHost(null); + } + + // Clear editingCredential if switching away from add_credential + if (initialTab !== "add_credential" && editingCredential) { + setEditingCredential(null); + } + } else { + // Initial mount - set state from props + if (initialTab) { + setActiveTab(initialTab); + } + if (hostConfig) { + setEditingHost(hostConfig); + lastProcessedHostIdRef.current = hostConfig.id; + } } - }, [hostConfig?.id]); + }, [_updateTimestamp, initialTab, hostConfig?.id]); const handleEditHost = (host: SSHHost) => { setEditingHost(host); diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx index 04f82b71..1582550e 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx @@ -1465,6 +1465,7 @@ export function HostManagerEditor({ { + if (editingHost?.isShared) return; const newAuthType = value as | "password" | "key" @@ -1478,25 +1479,29 @@ export function HostManagerEditor({ {t("hosts.password")} {t("hosts.key")} {t("hosts.credential")} {t("hosts.none")} @@ -1709,26 +1714,34 @@ export function HostManagerEditor({ name="credentialId" render={({ field }) => ( - { - if ( - credential && - !form.getValues( - "overrideCredentialUsername", - ) - ) { - form.setValue( - "username", - credential.username, - ); - } - }} - /> - - {t("hosts.credentialDescription")} - + {editingHost?.isShared ? ( +
+ {t("hosts.cannotChangeAuthAsSharedUser")} +
+ ) : ( + { + if ( + credential && + !form.getValues( + "overrideCredentialUsername", + ) + ) { + form.setValue( + "username", + credential.username, + ); + } + }} + /> + )} + {!editingHost?.isShared && ( + + {t("hosts.credentialDescription")} + + )}
)} /> @@ -3769,7 +3782,7 @@ export function HostManagerEditor({