diff --git a/app/audit_logger.py b/app/audit_logger.py index 81911ed..58c337b 100644 --- a/app/audit_logger.py +++ b/app/audit_logger.py @@ -351,6 +351,15 @@ def log_key_rename(username, old_name, new_name, ip_address): ) +def log_key_replace(username, key_name, success, ip_address): + status = "SUCCESS" if success else "FAILED" + audit_logger.info( + f"KEY_REPLACE_{status} | user={_sanitize_log_value(username)} | " + f"key={_sanitize_log_value(key_name)} | " + f"ip={_sanitize_log_value(ip_address)}" + ) + + def log_key_delete(username, key_name, ip_address): audit_logger.info( f"KEY_DELETE | user={_sanitize_log_value(username)} | " diff --git a/app/key_encryption.py b/app/key_encryption.py index 0b55e70..c5c8ae5 100644 --- a/app/key_encryption.py +++ b/app/key_encryption.py @@ -388,3 +388,50 @@ def write_key_content( error_type=type(e).__name__, ) return False + + +@_serialized_key_operation +def replace_key_content( + user_id: str, key_path: str, key_content: str, *, + allowed_root: Path = None) -> bool: + """Replace an existing encrypted key and restore its bytes on failure.""" + path = Path(key_path) + try: + encrypted = encrypt_key_content(str(user_id), key_content) + with _key_file_lock( + path, allowed_root=allowed_root) as operation_path: + original = operation_path.read_bytes() + try: + atomic_write_bytes(operation_path, encrypted, mode=0o600) + stored = operation_path.read_bytes() + verified = decrypt_key_content( + str(user_id), stored + ).encode('utf-8') + if not hmac.compare_digest( + verified, + key_content.encode('utf-8'), + ): + raise ValueError('SSH key replacement verification failed') + except Exception as exc: + try: + _restore_plaintext(operation_path, original) + except Exception as rollback_error: + raise RuntimeError( + 'SSH key replacement rollback failed' + ) from rollback_error + log_error( + "Failed to replace encrypted key", + user_id=user_id, + error_type=type(exc).__name__, + ) + return False + return True + except RuntimeError: + raise + except Exception as exc: + log_error( + "Failed to replace encrypted key", + user_id=user_id, + error_type=type(exc).__name__, + ) + return False diff --git a/app/key_manager.py b/app/key_manager.py index 28cf8bc..5d2c406 100644 --- a/app/key_manager.py +++ b/app/key_manager.py @@ -316,6 +316,69 @@ def rename_key(user_id, key_id, new_name): return None, "Key not found" +def replace_key(user_id, key_id, key_content): + """Atomically replace one owned key while preserving its stable identity.""" + if not isinstance(key_id, str) or not key_id: + return None, "Key not found" + if not isinstance(key_content, str) or not key_content: + return None, "Invalid key content" + + try: + with storage_lock(f'keys:{user_id}'): + keys = _load_keys_with_lock_held(user_id) + key = next((item for item in keys if item['id'] == key_id), None) + if key is None: + return None, "Key not found" + + try: + replacement_type = identify_private_key(key_content) + except paramiko.PasswordRequiredException: + return None, "Passphrase-encrypted private keys are not supported" + except UnsupportedPrivateKeyError as exc: + return None, str(exc) + except paramiko.SSHException: + return None, "Invalid key format" + + keys_dir = get_user_keys_dir(user_id) + if not keys_dir: + return None, "Key not found" + key_path = _safe_key_path(keys_dir, key['filename']) + if not _path_entry_exists(key_path): + return None, "Key file not found" + stored_content = key_encryption.read_key_content( + str(user_id), + str(key_path), + migrate_legacy=False, + allowed_root=keys_dir, + ) + stored_type = identify_private_key(stored_content) + if stored_type != key['key_type']: + return None, "Stored key metadata does not match key content" + if replacement_type != stored_type: + return None, ( + "Replacement key must use the same key type " + f"({stored_type})" + ) + if not key_encryption.replace_key_content( + str(user_id), + str(key_path), + key_content, + allowed_root=keys_dir, + ): + return None, "Failed to replace key" + return {**key, 'usable': True}, None + except StorageCorruptionError: + raise + except Exception as exc: + log_error( + "Error replacing key", + user_id=user_id, + key_id=key_id, + exception_type=type(exc).__name__, + ) + return None, "Failed to replace key" + + def _remove_key_after_metadata_failure(user_id, key_id, key_path): """Best-effort rollback when the encrypted key has no metadata entry.""" try: diff --git a/app/socket_events.py b/app/socket_events.py index 62cda6f..6724953 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -10,7 +10,8 @@ from .audit_logger import (log_info, log_warning, log_error, log_debug, log_ssh_connection, log_ssh_disconnect, log_file_upload, log_file_download, - log_key_upload, log_key_rename, log_key_delete, + log_key_upload, log_key_rename, log_key_replace, + log_key_delete, log_tailscale_ssh_usage) from .tailscale_ssh import ( profile_is_authorized_for_launch, @@ -912,6 +913,56 @@ def handle_rename_key(data, current_user=None): except Exception: return _key_mutation_error('Failed to rename key') + +@socketio.on('replace_key') +@socket_login_required +def handle_replace_key(data, current_user=None): + """Replace one owned SSH key without changing its stable identity.""" + try: + data = data if isinstance(data, dict) else {} + key_id = data.get('key_id') + key_content = data.get('key_content') + if ( + not isinstance(key_id, str) + or not key_id + or not isinstance(key_content, str) + or not key_content + ): + return _key_mutation_error('Key ID and key content required') + if len(key_content) > 64 * 1024: + return _key_mutation_error( + 'Key content too large (max 64KB)' + ) + + key, error = key_manager.replace_key( + current_user.id, + key_id, + key_content, + ) + if error: + log_key_replace( + current_user.username, + key_id, + False, + request.remote_addr, + ) + return _key_mutation_error(error) + + log_key_replace( + current_user.username, + key['name'], + True, + request.remote_addr, + ) + payload = {'success': True, 'key': key} + emit('key_replaced', payload) + handle_list_keys(current_user=current_user) + return payload + except StorageCorruptionError as error: + return _emit_storage_error(error, current_user) + except Exception: + return _key_mutation_error('Failed to replace key') + @socketio.on('delete_key') @socket_login_required def handle_delete_key(data, current_user=None): diff --git a/static/css/style.css b/static/css/style.css index 8ab9cd4..1d8e32f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2733,6 +2733,10 @@ textarea.form-control { box-shadow: var(--shadow-sm); } +.key-item.replacing { + flex-wrap: wrap; +} + .key-info { display: flex; flex-direction: column; @@ -2801,6 +2805,38 @@ textarea.form-control { min-width: 0; } +.key-replace-editor { + display: flex; + flex: 1 0 100%; + flex-direction: column; + gap: 10px; + min-width: 0; + padding-top: 14px; + border-top: 1px solid var(--border-color); +} + +.key-replace-editor textarea { + width: 100%; + resize: vertical; + font-family: var(--font-mono, monospace); +} + +.key-replace-warning { + margin: 0; + color: var(--warning-color, #f3c969); + line-height: 1.5; +} + +.key-replace-status { + min-height: 1.25em; + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.key-replace-status.error { + color: var(--error-color, #e57373); +} + .profile-inline-key-status { min-height: 1.25em; color: var(--text-secondary); @@ -2829,6 +2865,10 @@ textarea.form-control { flex-direction: column; } + .key-replace-editor { + width: 100%; + } + .key-item-actions .btn, .profile-inline-key-actions .btn, .profile-inline-key > .btn { diff --git a/static/js/app.js b/static/js/app.js index 894934d..eb296d2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1021,6 +1021,16 @@ ProfileManager.upsertKeySummary(data.key); }); + socket.on('key_replaced', (data) => { + ProfileManager.upsertKeySummary(data.key); + showNotification( + window.i18n + ? i18n.t('keys.replacedSuccess') + : 'SSH key replaced successfully', + 'success', + ); + }); + socket.on('key_deleted', () => { showNotification('SSH key deleted successfully', 'success'); }); diff --git a/static/js/i18n.js b/static/js/i18n.js index f1021d2..6e2f67a 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -164,6 +164,15 @@ const translations = { 'keys.renameNamed': 'Rename {name}', 'keys.saveName': 'Save name', 'keys.renameFailed': 'Failed to rename key', + 'keys.replace': 'Replace', + 'keys.replaceNamed': 'Replace {name}', + 'keys.replacementPrivateKey': 'Replacement private key', + 'keys.replaceWarning': 'Install the matching public key on every target first. The replacement must be another {type} private key. Future connections using this key will switch immediately; active sessions stay connected.', + 'keys.replaceConfirm': 'Replace stored key', + 'keys.replacing': 'Replacing key...', + 'keys.replaceFailed': 'Failed to replace key', + 'keys.replacementRequired': 'Enter the replacement private key.', + 'keys.replacedSuccess': 'SSH key replaced successfully', 'files.fileTransfer': 'File Transfer', 'files.fileManager': 'File Manager', @@ -770,6 +779,15 @@ const translations = { 'keys.renameNamed': 'Đổi tên {name}', 'keys.saveName': 'Lưu tên', 'keys.renameFailed': 'Không thể đổi tên khóa', + 'keys.replace': 'Thay thế', + 'keys.replaceNamed': 'Thay thế {name}', + 'keys.replacementPrivateKey': 'Khóa riêng tư thay thế', + 'keys.replaceWarning': 'Trước tiên, hãy cài đặt khóa công khai tương ứng trên mọi máy đích. Khóa thay thế phải là một khóa riêng tư {type} khác. Các kết nối mới sẽ chuyển sang khóa này ngay lập tức; các phiên đang mở vẫn được giữ nguyên.', + 'keys.replaceConfirm': 'Thay thế khóa đã lưu', + 'keys.replacing': 'Đang thay thế khóa...', + 'keys.replaceFailed': 'Không thể thay thế khóa', + 'keys.replacementRequired': 'Nhập khóa riêng tư thay thế.', + 'keys.replacedSuccess': 'Đã thay thế khóa SSH', 'files.fileTransfer': 'Truyền tệp', 'files.fileManager': 'Trình quản lý tệp', @@ -1375,6 +1393,15 @@ const translations = { 'keys.renameNamed': '{name} umbenennen', 'keys.saveName': 'Namen speichern', 'keys.renameFailed': 'Schlüssel konnte nicht umbenannt werden', + 'keys.replace': 'Ersetzen', + 'keys.replaceNamed': '{name} ersetzen', + 'keys.replacementPrivateKey': 'Neuer privater Schlüssel', + 'keys.replaceWarning': 'Installiere zuerst den passenden öffentlichen Schlüssel auf allen Zielsystemen. Der Ersatz muss ebenfalls ein privater Schlüssel vom Typ {type} sein. Neue Verbindungen wechseln sofort; aktive Sitzungen bleiben verbunden.', + 'keys.replaceConfirm': 'Gespeicherten Schlüssel ersetzen', + 'keys.replacing': 'Schlüssel wird ersetzt...', + 'keys.replaceFailed': 'Schlüssel konnte nicht ersetzt werden', + 'keys.replacementRequired': 'Gib den neuen privaten Schlüssel ein.', + 'keys.replacedSuccess': 'SSH-Schlüssel erfolgreich ersetzt', 'files.fileTransfer': 'Dateiübertragung', 'files.fileManager': 'Dateimanager', @@ -1979,6 +2006,15 @@ const translations = { 'keys.renameNamed': 'Renommer {name}', 'keys.saveName': 'Enregistrer le nom', 'keys.renameFailed': 'Impossible de renommer la clé', + 'keys.replace': 'Remplacer', + 'keys.replaceNamed': 'Remplacer {name}', + 'keys.replacementPrivateKey': 'Clé privée de remplacement', + 'keys.replaceWarning': 'Installez d’abord la clé publique correspondante sur chaque cible. La clé de remplacement doit également être une clé privée de type {type}. Les nouvelles connexions basculeront immédiatement; les sessions actives resteront connectées.', + 'keys.replaceConfirm': 'Remplacer la clé enregistrée', + 'keys.replacing': 'Remplacement de la clé...', + 'keys.replaceFailed': 'Impossible de remplacer la clé', + 'keys.replacementRequired': 'Saisissez la clé privée de remplacement.', + 'keys.replacedSuccess': 'Clé SSH remplacée', 'files.fileTransfer': 'Transfert de fichiers', 'files.fileManager': 'Gestionnaire de fichiers', @@ -2583,6 +2619,15 @@ const translations = { 'keys.renameNamed': 'Cambiar el nombre de {name}', 'keys.saveName': 'Guardar nombre', 'keys.renameFailed': 'No se pudo cambiar el nombre de la clave', + 'keys.replace': 'Reemplazar', + 'keys.replaceNamed': 'Reemplazar {name}', + 'keys.replacementPrivateKey': 'Clave privada de reemplazo', + 'keys.replaceWarning': 'Instala primero la clave pública correspondiente en cada destino. La clave de reemplazo también debe ser una clave privada de tipo {type}. Las conexiones nuevas cambiarán inmediatamente; las sesiones activas seguirán conectadas.', + 'keys.replaceConfirm': 'Reemplazar clave guardada', + 'keys.replacing': 'Reemplazando clave...', + 'keys.replaceFailed': 'No se pudo reemplazar la clave', + 'keys.replacementRequired': 'Introduce la clave privada de reemplazo.', + 'keys.replacedSuccess': 'Clave SSH reemplazada correctamente', 'files.fileTransfer': 'Transferencia de archivos', 'files.fileManager': 'Gestor de archivos', @@ -3187,6 +3232,15 @@ const translations = { 'keys.renameNamed': '重命名 {name}', 'keys.saveName': '保存名称', 'keys.renameFailed': '无法重命名密钥', + 'keys.replace': '替换', + 'keys.replaceNamed': '替换 {name}', + 'keys.replacementPrivateKey': '替换私钥', + 'keys.replaceWarning': '请先在每个目标主机上安装匹配的公钥。替换项必须是另一个 {type} 私钥。后续连接会立即改用新密钥,当前活动会话不受影响。', + 'keys.replaceConfirm': '替换已保存的密钥', + 'keys.replacing': '正在替换密钥...', + 'keys.replaceFailed': '无法替换密钥', + 'keys.replacementRequired': '请输入替换私钥。', + 'keys.replacedSuccess': 'SSH 密钥替换成功', 'files.fileTransfer': '文件传输', 'files.fileManager': '文件管理器', diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index fbf7319..a113a17 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -7,6 +7,10 @@ const ProfileManager = { editingKeyId: null, editingKeyName: null, keyRenamePending: false, + replacingKeyId: null, + replacementKeyContent: '', + keyReplacePending: false, + keyReplaceError: null, inlineKeyUploadPending: false, profileSearchQuery: '', organizationPending: new Set(), @@ -83,9 +87,32 @@ const ProfileManager = { const input = button.closest('.key-item')?.querySelector('.key-rename-input'); this.submitKeyRename(keyId, input?.value || ''); } + if (button.dataset.keyAction === 'replace') this.beginKeyReplacement(keyId); + if (button.dataset.keyAction === 'cancel-replace') this.cancelKeyReplacement(); + if (button.dataset.keyAction === 'confirm-replace') { + const input = button.closest('.key-item')?.querySelector('.key-replace-input'); + this.submitKeyReplacement(keyId, input?.value || ''); + } if (button.dataset.keyAction === 'delete') this.deleteKey(keyId); }); document.getElementById('keysList')?.addEventListener('keydown', event => { + const replacementInput = event.target.closest('.key-replace-input'); + if (replacementInput) { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.cancelKeyReplacement(); + } + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + event.stopPropagation(); + this.submitKeyReplacement( + replacementInput.dataset.keyId, + replacementInput.value, + ); + } + return; + } const input = event.target.closest('.key-rename-input'); if (!input) return; if (event.key === 'Enter') { @@ -100,6 +127,12 @@ const ProfileManager = { } }); document.getElementById('keysList')?.addEventListener('input', event => { + const replacementInput = event.target.closest('.key-replace-input'); + if (replacementInput?.dataset.keyId === this.replacingKeyId) { + this.replacementKeyContent = replacementInput.value; + this.keyReplaceError = null; + return; + } const input = event.target.closest('.key-rename-input'); if (input?.dataset.keyId === this.editingKeyId) { this.editingKeyName = input.value; @@ -376,9 +409,11 @@ const ProfileManager = { } container.replaceChildren(); - this.keys.forEach(key => { + this.keys.forEach((key, index) => { const keyItem = document.createElement('div'); keyItem.className = 'key-item'; + const replacing = this.replacingKeyId === key.id; + keyItem.classList.toggle('replacing', replacing); const keyInfo = document.createElement('div'); keyInfo.className = 'key-info'; @@ -421,7 +456,9 @@ const ProfileManager = { button.dataset.keyAction = action; button.dataset.keyId = key.id; button.textContent = label; - button.disabled = this.editingKeyId === key.id && this.keyRenamePending; + button.disabled = ( + this.editingKeyId === key.id && this.keyRenamePending + ) || (replacing && this.keyReplacePending); actions.appendChild(button); return button; }; @@ -437,6 +474,17 @@ const ProfileManager = { this.t('common.cancel', 'Cancel'), 'btn-secondary', ); + } else if (replacing) { + addAction( + 'confirm-replace', + this.t('keys.replaceConfirm', 'Replace stored key'), + 'btn-danger', + ); + addAction( + 'cancel-replace', + this.t('common.cancel', 'Cancel'), + 'btn-secondary', + ); } else { const renameButton = addAction( 'rename', @@ -447,12 +495,67 @@ const ProfileManager = { 'aria-label', this.t('keys.renameNamed', 'Rename {name}').replace('{name}', key.name), ); + const replaceButton = addAction( + 'replace', + this.t('keys.replace', 'Replace'), + 'btn-secondary', + ); + replaceButton.setAttribute( + 'aria-label', + this.t('keys.replaceNamed', 'Replace {name}').replace('{name}', key.name), + ); addAction('delete', this.t('common.delete', 'Delete'), 'btn-danger'); } keyItem.appendChild(keyInfo); keyItem.appendChild(actions); + if (replacing) { + const replacementEditor = document.createElement('div'); + replacementEditor.className = 'key-replace-editor'; + const inputId = `key-replacement-${index}`; + const warningId = `key-replacement-warning-${index}`; + const statusId = `key-replacement-status-${index}`; + + const warning = document.createElement('p'); + warning.id = warningId; + warning.className = 'key-replace-warning'; + warning.textContent = this.t( + 'keys.replaceWarning', + 'Install the matching public key on every target first. The replacement must be another {type} private key. Future connections using this key will switch immediately; active sessions stay connected.', + ).replace('{type}', key.key_type); + + const label = document.createElement('label'); + label.htmlFor = inputId; + label.textContent = this.t( + 'keys.replacementPrivateKey', + 'Replacement private key', + ); + + const textarea = document.createElement('textarea'); + textarea.id = inputId; + textarea.className = 'form-control key-replace-input'; + textarea.dataset.keyId = key.id; + textarea.rows = 7; + textarea.maxLength = 64 * 1024; + textarea.value = this.replacementKeyContent; + textarea.disabled = this.keyReplacePending; + textarea.setAttribute('aria-describedby', `${warningId} ${statusId}`); + + const status = document.createElement('div'); + status.id = statusId; + status.className = 'key-replace-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + status.classList.toggle('error', Boolean(this.keyReplaceError)); + status.textContent = this.keyReplacePending + ? this.t('keys.replacing', 'Replacing key...') + : (this.keyReplaceError || ''); + + replacementEditor.append(warning, label, textarea, status); + keyItem.appendChild(replacementEditor); + } + container.appendChild(keyItem); }); @@ -461,6 +564,11 @@ const ProfileManager = { `.key-rename-input[data-key-id="${CSS.escape(this.editingKeyId)}"]` )?.focus(); } + if (this.replacingKeyId && !this.keyReplacePending) { + container.querySelector( + `.key-replace-input[data-key-id="${CSS.escape(this.replacingKeyId)}"]` + )?.focus(); + } }, selectProfile(profileId) { @@ -1030,7 +1138,11 @@ const ProfileManager = { }, beginKeyRename(keyId) { - if (this.keyRenamePending || !this.keys.some(key => key.id === keyId)) return; + if ( + this.keyRenamePending + || this.replacingKeyId + || !this.keys.some(key => key.id === keyId) + ) return; this.editingKeyId = keyId; this.editingKeyName = this.keys.find(key => key.id === keyId).name; this.renderKeysList(); @@ -1069,6 +1181,64 @@ const ProfileManager = { }); }, + beginKeyReplacement(keyId) { + if ( + this.keyReplacePending + || this.editingKeyId + || !this.keys.some(key => key.id === keyId) + ) return; + this.replacingKeyId = keyId; + this.replacementKeyContent = ''; + this.keyReplaceError = null; + this.renderKeysList(); + }, + + cancelKeyReplacement() { + if (this.keyReplacePending) return; + this.replacingKeyId = null; + this.replacementKeyContent = ''; + this.keyReplaceError = null; + this.renderKeysList(); + }, + + submitKeyReplacement(keyId, keyContent) { + if ( + this.keyReplacePending + || keyId !== this.replacingKeyId + || !window.socket + ) return; + this.replacementKeyContent = keyContent; + if (!keyContent.trim()) { + this.keyReplaceError = this.t( + 'keys.replacementRequired', + 'Enter the replacement private key.', + ); + this.renderKeysList(); + return; + } + + this.keyReplacePending = true; + this.keyReplaceError = null; + this.renderKeysList(); + window.socket.emit('replace_key', { + key_id: keyId, + key_content: keyContent, + }, acknowledgement => { + this.keyReplacePending = false; + if (!acknowledgement?.success || !acknowledgement.key) { + this.keyReplaceError = acknowledgement?.error || this.t( + 'keys.replaceFailed', 'Failed to replace key' + ); + this.renderKeysList(); + return; + } + this.replacingKeyId = null; + this.replacementKeyContent = ''; + this.keyReplaceError = null; + this.upsertKeySummary(acknowledgement.key); + }); + }, + deleteKey(keyId) { if (confirm('Are you sure you want to delete this SSH key?')) { if (window.socket) { diff --git a/templates/index.html b/templates/index.html index 5700d5b..92a1efc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -17,7 +17,7 @@ - + @@ -1149,7 +1149,7 @@

File Preview

- + @@ -1170,7 +1170,7 @@

File Preview

- + @@ -1179,7 +1179,7 @@

File Preview

- +