Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/audit_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)} | "
Expand Down
47 changes: 47 additions & 0 deletions app/key_encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
63 changes: 63 additions & 0 deletions app/key_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
53 changes: 52 additions & 1 deletion app/socket_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
40 changes: 40 additions & 0 deletions static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
54 changes: 54 additions & 0 deletions static/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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': '文件管理器',
Expand Down
Loading
Loading