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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ RATELIMIT_DEFAULT=200 per hour
RATELIMIT_REAUTH=5 per minute
# Per-user SSH and quick-connect attempt rate.
SSH_CONNECT_RATELIMIT=10 per minute
# Per-user upload/replacement rate for encrypted SSH keys.
SSH_KEY_WRITE_RATELIMIT=30 per minute
# Per-account encrypted SSH-key limits. Existing over-limit stores remain
# readable and can still be renamed, deleted, or replaced with smaller keys.
SSH_KEY_MAX_RECORDS=100
SSH_KEY_STORE_MAX_BYTES=8388608
# Per-user command and command-set mutation rate.
# COMMAND_MUTATION_RATELIMIT=60 per minute
# Storage backend for rate-limit counters.
Expand All @@ -221,6 +227,17 @@ SSH_CONNECT_RATELIMIT=10 per minute
# periodically retries Redis without delaying every request.
# RATELIMIT_STORAGE_URL=memory://

# Bound live SSH output waiting for browser acknowledgements. A browser that
# keeps an acknowledgement budget blocked for the timeout is disconnected;
# its SSH/tmux session remains available for reconnect.
SSH_OUTPUT_MAX_UNACKED_BYTES_PER_SOCKET=524288
SSH_OUTPUT_MAX_UNACKED_EVENTS_PER_SOCKET=128
SSH_OUTPUT_MAX_UNACKED_EVENTS_PER_USER=1024
SSH_OUTPUT_MAX_UNACKED_EVENTS_GLOBAL=8192
SSH_OUTPUT_MAX_UNACKED_BYTES_PER_USER=4194304
SSH_OUTPUT_MAX_UNACKED_BYTES_GLOBAL=33554432
SSH_OUTPUT_ACK_TIMEOUT_SECONDS=10

# ─── File transfer limits (bytes) ────────────────────────────────────────────
# Max single-file download (default 100 MB).
MAX_DOWNLOAD_SIZE=104857600
Expand Down
3 changes: 3 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ Instead, report vulnerabilities via:
| Host Key Logging | New keys logged with fingerprint for audit |
| Connection Isolation | Session ownership verified on every operation |
| Credential Handling | Cleared from memory after use |
| Stored-key work | Connection attempt rate limiting precedes stored-key PBKDF2/decryption; encrypted key stores have atomic count and byte growth limits |
| Remote clipboard | Persistent-tmux OSC 52 requests require a fresh, visible browser action; replayed and plain-SSH output cannot write the clipboard |
| Live output | Per-browser ACK accounting with per-socket, per-user, and global bounds applies SSH backpressure; a stalled browser is disconnected without closing its SSH/tmux session |

## Security Best Practices for Deployment

Expand Down
67 changes: 48 additions & 19 deletions app/key_encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,24 +354,11 @@ def read_key_content(
raise
return plaintext

@_serialized_key_operation
def write_key_content(
user_id: str, key_path: str, key_content: str, *,
def _write_prepared_key_content(
user_id: str, key_path: str, encrypted: bytes, *,
allowed_root: Path = None) -> bool:
"""
Encrypt and write SSH key content to file.

Args:
user_id: User identifier
key_path: Path to write the key
key_content: SSH private key content (PEM format)

Returns:
True if successful
"""
"""Write ciphertext prepared by ``encrypt_key_content``."""
try:
encrypted = encrypt_key_content(user_id, key_content)

path = Path(key_path)
path.parent.mkdir(parents=True, exist_ok=True)
with _key_file_lock(
Expand All @@ -391,13 +378,32 @@ def write_key_content(


@_serialized_key_operation
def replace_key_content(
def write_prepared_key_content(
user_id: str, key_path: str, encrypted: bytes, *,
allowed_root: Path = None) -> bool:
"""Write server-prepared ciphertext without deriving the key twice."""
return _write_prepared_key_content(
user_id, key_path, encrypted, allowed_root=allowed_root
)


@_serialized_key_operation
def write_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."""
"""Encrypt and write SSH key content to file."""
encrypted = encrypt_key_content(user_id, key_content)
return _write_prepared_key_content(
user_id, key_path, encrypted, allowed_root=allowed_root
)


def _replace_prepared_key_content(
user_id: str, key_path: str, key_content: str, encrypted: bytes, *,
allowed_root: Path = None) -> bool:
"""Replace an encrypted key using already prepared ciphertext."""
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()
Expand Down Expand Up @@ -435,3 +441,26 @@ def replace_key_content(
error_type=type(exc).__name__,
)
return False


@_serialized_key_operation
def replace_prepared_key_content(
user_id: str, key_path: str, key_content: str, encrypted: bytes, *,
allowed_root: Path = None) -> bool:
"""Atomically replace a key with server-prepared ciphertext."""
return _replace_prepared_key_content(
user_id, key_path, key_content, encrypted,
allowed_root=allowed_root,
)


@_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."""
encrypted = encrypt_key_content(str(user_id), key_content)
return _replace_prepared_key_content(
user_id, key_path, key_content, encrypted,
allowed_root=allowed_root,
)
70 changes: 58 additions & 12 deletions app/key_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import paramiko
import stat
import config
from datetime import datetime, timezone
from pathlib import Path
from cryptography.fernet import InvalidToken
Expand Down Expand Up @@ -106,6 +107,23 @@ def _valid_key_document(value):

_DELETE_STAGING_PREFIX = '.delete-'
_DELETE_TOKEN_LENGTH = 32
SSH_KEY_CONTENT_MAX_BYTES = 64 * 1024
SSH_KEY_STORAGE_LIMIT_ERROR = (
"SSH key storage limit reached; delete a key or replace one with a "
"smaller key"
)


def _stored_key_bytes(keys_dir, keys):
"""Return exact encrypted bytes referenced by one locked key document."""
total = 0
for key in keys:
key_path = _safe_key_path(keys_dir, key['filename'])
try:
total += key_path.stat().st_size
except FileNotFoundError:
continue
return total


def _pending_delete_filename(path):
Expand Down Expand Up @@ -322,23 +340,28 @@ def replace_key(user_id, key_id, key_content):
return None, "Key not found"
if not isinstance(key_content, str) or not key_content:
return None, "Invalid key content"
if len(key_content.encode('utf-8')) > SSH_KEY_CONTENT_MAX_BYTES:
return None, "Key content too large (max 64KB)"

try:
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"
encrypted = key_encryption.encrypt_key_content(
str(user_id), key_content
)

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"
Expand All @@ -359,10 +382,19 @@ def replace_key(user_id, key_id, key_content):
"Replacement key must use the same key type "
f"({stored_type})"
)
if not key_encryption.replace_key_content(
current_bytes = _stored_key_bytes(keys_dir, keys)
stored_bytes = key_path.stat().st_size
prospective_bytes = current_bytes - stored_bytes + len(encrypted)
if (
prospective_bytes > config.SSH_KEY_STORE_MAX_BYTES
and prospective_bytes > current_bytes
):
return None, SSH_KEY_STORAGE_LIMIT_ERROR
if not key_encryption.replace_prepared_key_content(
str(user_id),
str(key_path),
key_content,
encrypted,
allowed_root=keys_dir,
):
return None, "Failed to replace key"
Expand Down Expand Up @@ -397,6 +429,12 @@ def save_key(user_id, name, key_content):
try:
if not isinstance(name, str) or not name:
return None, "Invalid key name"
if len(name) > 128:
return None, "Key name too long (max 128 characters)"
if not isinstance(key_content, str) or not key_content:
return None, "Invalid key content"
if len(key_content.encode('utf-8')) > SSH_KEY_CONTENT_MAX_BYTES:
return None, "Key content too large (max 64KB)"
keys_dir = get_user_keys_dir(user_id)
if not keys_dir:
return None, "User not found"
Expand All @@ -411,6 +449,9 @@ def save_key(user_id, name, key_content):
return None, str(exc)
except paramiko.SSHException:
return None, "Invalid key format"
encrypted = key_encryption.encrypt_key_content(
str(user_id), key_content
)

key_meta = {
'id': key_id,
Expand All @@ -424,10 +465,15 @@ def save_key(user_id, name, key_content):
}
with storage_lock(f'keys:{user_id}'):
keys = _load_keys_with_lock_held(user_id)
if not key_encryption.write_key_content(
if len(keys) >= config.SSH_KEY_MAX_RECORDS:
return None, SSH_KEY_STORAGE_LIMIT_ERROR
current_bytes = _stored_key_bytes(keys_dir, keys)
if current_bytes + len(encrypted) > config.SSH_KEY_STORE_MAX_BYTES:
return None, SSH_KEY_STORAGE_LIMIT_ERROR
if not key_encryption.write_prepared_key_content(
str(user_id),
str(key_path),
key_content,
encrypted,
allowed_root=keys_dir,
):
return None, "Failed to encrypt and save key"
Expand Down
Loading
Loading