Skip to content
10 changes: 10 additions & 0 deletions app/audit_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,16 @@ def log_key_upload(username, key_name, success, ip_address):
f"key={_sanitize_log_value(key_name)} | ip={_sanitize_log_value(ip_address)}"
)


def log_key_rename(username, old_name, new_name, ip_address):
audit_logger.info(
f"KEY_RENAME | user={_sanitize_log_value(username)} | "
f"old={_sanitize_log_value(old_name)} | "
f"new={_sanitize_log_value(new_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
25 changes: 25 additions & 0 deletions app/key_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,31 @@ def save_keys(user_id, keys):
return False


def rename_key(user_id, key_id, new_name):
"""Rename one owned key without changing its identity or encrypted data."""
if not isinstance(new_name, str) or not new_name.strip():
return None, "Invalid key name"
name = new_name.strip()
if len(name) > 128:
return None, "Key name too long (max 128 characters)"
if not isinstance(key_id, str) or not key_id:
return None, "Key not found"

with storage_lock(f'keys:{user_id}'):
keys = _load_keys_with_lock_held(user_id)
for index, key in enumerate(keys):
if key['id'] != key_id:
continue
before = dict(key)
updated = {**key, 'name': name}
replacement = [*keys]
replacement[index] = updated
if not save_keys(user_id, replacement):
return None, "Failed to rename key"
return {'before': before, 'key': updated}, None
return None, "Key not found"


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
67 changes: 53 additions & 14 deletions app/socket_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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_delete,
log_key_upload, log_key_rename, log_key_delete,
log_tailscale_ssh_usage)
from .tailscale_ssh import (
profile_is_authorized_for_launch,
Expand Down Expand Up @@ -87,6 +87,12 @@ def _emit_storage_error(error, current_user):
return payload


def _key_mutation_error(message):
payload = {'success': False, 'error': message}
emit('error', {'error': message})
return payload


def _is_valid_host(host_str):
"""Validate host is a valid hostname or IP address."""
try:
Expand Down Expand Up @@ -807,35 +813,68 @@ def handle_list_keys(current_user=None):
def handle_upload_key(data, current_user=None):
"""Store a new SSH private key for this user."""
try:
data = data if isinstance(data, dict) else {}
name = data.get('name')
key_content = data.get('key_content')

if not all([name, key_content]):
emit('error', {'error': 'Name and key content required'})
return
if (not isinstance(name, str) or not name
or not isinstance(key_content, str) or not key_content):
return _key_mutation_error('Name and key content required')

# SSH private keys are a few KB at most; reject oversized input outright
# so a client cannot force large writes to disk.
if len(name) > 128:
emit('error', {'error': 'Key name too long (max 128 characters)'})
return
return _key_mutation_error(
'Key name too long (max 128 characters)'
)
if len(key_content) > 64 * 1024:
emit('error', {'error': 'Key content too large (max 64KB)'})
return
return _key_mutation_error(
'Key content too large (max 64KB)'
)

key_meta, error = key_manager.save_key(current_user.id, name, key_content)
if error:
log_key_upload(current_user.username, name, False, request.remote_addr)
emit('error', {'error': error})
else:
log_key_upload(current_user.username, name, True, request.remote_addr)
emit('key_uploaded', {'key': key_meta})
handle_list_keys(current_user=current_user)
return _key_mutation_error(error)
log_key_upload(current_user.username, name, True, request.remote_addr)
emit('key_uploaded', {'key': key_meta})
handle_list_keys(current_user=current_user)
return {'success': True, 'key': key_meta}

except StorageCorruptionError as error:
return _emit_storage_error(error, current_user)
except Exception:
return _key_mutation_error('Failed to upload key')


@socketio.on('rename_key')
@socket_login_required
def handle_rename_key(data, current_user=None):
"""Rename one owned SSH key without exposing its encrypted contents."""
try:
data = data if isinstance(data, dict) else {}
result, error = key_manager.rename_key(
current_user.id,
data.get('key_id'),
data.get('name'),
)
if error:
return _key_mutation_error(error)

log_key_rename(
current_user.username,
result['before']['name'],
result['key']['name'],
request.remote_addr,
)
payload = {'success': True, 'key': result['key']}
emit('key_renamed', payload)
handle_list_keys(current_user=current_user)
return payload
except StorageCorruptionError as error:
return _emit_storage_error(error, current_user)
except Exception:
emit('error', {'error': 'Failed to upload key'})
return _key_mutation_error('Failed to rename key')

@socketio.on('delete_key')
@socket_login_required
Expand Down
77 changes: 77 additions & 0 deletions static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2413,6 +2413,7 @@ textarea.form-control {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}

.key-info strong {
Expand All @@ -2436,6 +2437,82 @@ textarea.form-control {
font-weight: 600;
}

.profile-inline-key {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
min-width: 0;
margin-top: 10px;
}

.profile-inline-key-panel {
width: 100%;
padding: 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
}

.profile-inline-key-panel textarea {
width: 100%;
resize: vertical;
}

.profile-inline-key-actions,
.key-item-actions,
.key-rename-editor {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}

.profile-inline-key-actions {
justify-content: flex-end;
}

.key-rename-editor .form-control {
min-width: 0;
}

.profile-inline-key-status {
min-height: 1.25em;
color: var(--text-secondary);
overflow-wrap: anywhere;
}

.profile-inline-key-status.error {
color: var(--error-color, #e57373);
}

@media (max-width: 375px) {
.key-item {
align-items: stretch;
flex-direction: column;
gap: 12px;
transform: none;
}

.key-item:hover {
transform: none;
}

.key-item-actions,
.profile-inline-key-actions {
align-items: stretch;
flex-direction: column;
}

.key-item-actions .btn,
.profile-inline-key-actions .btn,
.profile-inline-key > .btn {
width: 100%;
min-height: 44px;
}
}

.no-items {
text-align: center;
color: var(--text-secondary);
Expand Down
Loading
Loading