diff --git a/app/account_step_up_routes.py b/app/account_step_up_routes.py index 32cc70a..6659ab7 100644 --- a/app/account_step_up_routes.py +++ b/app/account_step_up_routes.py @@ -86,6 +86,7 @@ def _action_target(action, data): feature = { "passkey.enroll": "passkey", "passkey.delete": "passkey", + "mfa.enable": "passkey", "totp.enroll": "totp", "recovery.rotate": "recovery", }.get(action) @@ -93,6 +94,9 @@ def _action_target(action, data): raise StepUpError("step-up request is invalid") if action == "mfa.disable" and not current_user.mfa_enabled: raise StepUpError("step-up request is invalid") + if action == "mfa.enable": + if current_user.mfa_enabled or not current_user.webauthn_credentials.count(): + raise StepUpError("step-up request is invalid") if action == "passkey.delete": target = data.get("target") if not isinstance(target, int) or isinstance(target, bool): diff --git a/app/host_key_routes.py b/app/host_key_routes.py index 8a8918e..a01546d 100644 --- a/app/host_key_routes.py +++ b/app/host_key_routes.py @@ -1,6 +1,6 @@ """Authenticated management routes for SSH host trust.""" -from flask import Blueprint, abort, jsonify +from flask import Blueprint, abort, jsonify, request from flask_login import current_user, login_required import config @@ -63,6 +63,36 @@ def list_global_host_keys(): }) +@host_key_blueprint.post("/admin/api/host-keys") +@admin_required +@login_required +@step_up_required("host_key.global_add", "global") +def add_global_host_key(): + _require_enabled() + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Invalid request"}), 400 + entry, error = HostKeyStore.add_file_entry( + config.KNOWN_HOSTS_FILE, + data.get("entry"), + scope="global", + owner_id=None, + lock_key="host_keys:global", + ) + if error: + status = 409 if "already" in error or "different key" in error else 400 + return jsonify({"error": error}), status + log_security_event( + "GLOBAL_SSH_HOST_KEY_ADDED", + admin=current_user.username, + hosts=",".join(item["host"] for item in entry["hosts"]), + algorithm=entry["algorithm"], + fingerprint=entry["fingerprint"], + marker=entry["marker"], + ) + return jsonify({"entry": entry}), 201 + + @host_key_blueprint.delete("/admin/api/host-keys/") @admin_required @login_required diff --git a/app/host_key_store.py b/app/host_key_store.py index d7f71f5..d402684 100644 --- a/app/host_key_store.py +++ b/app/host_key_store.py @@ -86,6 +86,15 @@ def _host_pattern_is_valid(host_pattern): return len(salt) == 20 and len(digest) == 20 +def _host_identity_token(host_pattern): + """Normalize a literal pattern identity without conflating salted hashes.""" + negated = host_pattern.startswith("!") + pattern = host_pattern[1:] if negated else host_pattern + if not pattern.startswith("|1|"): + pattern = pattern.casefold() + return f"!{pattern}" if negated else pattern + + class _EffectiveKeyMapping(MutableMapping): """Paramiko-compatible key mapping returned for one runtime hostname.""" @@ -357,6 +366,86 @@ def delete_file_entry( atomic_write_bytes(path, b"".join(kept), mode=0o600) return True + @classmethod + def add_file_entry(cls, path, value, *, scope, owner_id, lock_key): + """Append one validated known_hosts entry without exposing stored keys.""" + if not isinstance(value, str): + return None, "Host key entry must be text" + try: + encoded = value.encode("utf-8") + except UnicodeEncodeError: + return None, "Invalid known_hosts entry" + if not encoded or len(encoded) > 16384 or "\n" in value or "\r" in value: + return None, "Enter exactly one known_hosts entry" + + fields = re.split(r"[ \t]+", value.strip()) + marker = None + if fields and fields[0].startswith("@"): + marker = fields.pop(0) + if marker not in (None, "@revoked") or len(fields) < 3: + return None, "Invalid or unsupported known_hosts entry" + try: + entry = HostKeyEntry.from_line(" ".join(fields[:3])) + except Exception: + return None, "Invalid known_hosts entry" + if entry is None or not entry.hostnames or not all( + _host_pattern_is_valid(pattern) for pattern in entry.hostnames + ): + return None, "Invalid known_hosts entry" + + canonical = " ".join( + ([marker] if marker else []) + fields[:3] + ).encode("utf-8") + candidate = cls._management_entry( + canonical, + scope=scope, + owner_id=owner_id, + timestamp=datetime.now(timezone.utc).isoformat(), + ) + path = Path(path) + with storage_lock(lock_key): + try: + raw = path.read_bytes() + # Refuse to append to a malformed trust store. + cls._load_strict(path) + except FileNotFoundError: + raw = b"" + + for raw_line in raw.splitlines(): + existing_line = raw_line.decode("utf-8").strip() + if not existing_line or existing_line.startswith("#"): + continue + existing_fields = re.split(r"[ \t]+", existing_line) + existing_marker = None + if existing_fields[0].startswith("@"): + existing_marker = existing_fields.pop(0) + existing_entry = HostKeyEntry.from_line( + " ".join(existing_fields[:3]) + ) + overlapping_hosts = not set(map( + _host_identity_token, existing_entry.hostnames + )).isdisjoint(map(_host_identity_token, entry.hostnames)) + same_identity = ( + overlapping_hosts + and existing_marker == marker + and existing_entry.key.get_name() == entry.key.get_name() + ) + if not same_identity: + continue + if existing_entry.key == entry.key: + return None, "Host key entry already exists" + return None, ( + "A different key already exists for this host and algorithm; " + "verify and remove it first" + ) + + prefix = raw + if prefix and not prefix.endswith(b"\n"): + prefix += b"\n" + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_bytes(path, prefix + canonical + b"\n", mode=0o600) + return candidate, None + @staticmethod def _management_entry(raw_line, *, scope, owner_id, timestamp): try: diff --git a/app/socket_events.py b/app/socket_events.py index 8366a7f..ebec47c 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -1,5 +1,5 @@ from flask_socketio import emit, join_room, disconnect -from flask import request, current_app, url_for +from flask import copy_current_request_context, request, current_app, url_for from . import (socketio, ssh_manager, profile_manager, key_manager, sftp_handler, jump_host_manager, post_connect_manager, session_insights, runtime_inventory, smb_share_manager) @@ -13,6 +13,7 @@ from .models import db, SSHSession, SocketSession from .user_settings import save_user_settings, get_user_settings from .audit_logger import (log_info, log_warning, log_error, log_debug, + log_security_event, log_ssh_connection, log_ssh_disconnect, log_file_source_operation, log_key_upload, log_key_rename, log_key_replace, @@ -78,12 +79,26 @@ }) _smb_attempts_lock = threading.RLock() _smb_attempts = {} +_ssh_banner_prompts_lock = threading.RLock() +_ssh_banner_prompts = {} +SSH_AUTH_BANNER_DECISION_TIMEOUT = 60 def _new_smb_diagnostic_id(): return f'SMB-{secrets.token_hex(6).upper()}' +def _cancel_ssh_banner_prompts_for_socket(socket_sid): + with _ssh_banner_prompts_lock: + prompts = [ + prompt + for prompt in _ssh_banner_prompts.values() + if prompt['socket_sid'] == socket_sid + ] + for prompt in prompts: + prompt['event'].set() + + def _smb_request_id(payload): if not isinstance(payload, dict): return '' @@ -377,6 +392,7 @@ def handle_connect(): def handle_disconnect(): """Handle client disconnection - cleanup socket session.""" socket_sid = request.sid + _cancel_ssh_banner_prompts_for_socket(socket_sid) owner_id = socket_capacity.release(socket_sid) user = get_user_from_socket(socket_sid) user_id = user.id if user else owner_id @@ -490,6 +506,35 @@ def restore_user_sessions(user_id): log_info("Persistent tmux session available for reconnect", host=db_session.host, tmux_session=db_session.tmux_session_name) +@socketio.on('ssh_auth_banner_decision') +@socket_login_required +def handle_ssh_auth_banner_decision(data, current_user=None): + """Resolve one pending SSH authentication-banner prompt.""" + if not isinstance(data, dict): + return {'success': False} + prompt_id = data.get('prompt_id') + accepted = data.get('accepted') + if ( + not isinstance(prompt_id, str) + or not re.fullmatch(r'[A-Za-z0-9_-]{16,64}', prompt_id) + or type(accepted) is not bool + ): + return {'success': False} + + with _ssh_banner_prompts_lock: + prompt = _ssh_banner_prompts.get(prompt_id) + if ( + prompt is None + or prompt['socket_sid'] != request.sid + or prompt['user_id'] != current_user.id + or prompt['event'].is_set() + ): + return {'success': False} + prompt['accepted'] = accepted + prompt['event'].set() + return {'success': True} + + @socketio.on('ssh_connect') @socket_login_required def handle_ssh_connect(data, current_user=None): @@ -499,6 +544,7 @@ def handle_ssh_connect(data, current_user=None): bastion_password = None bastion_key_content = None client_request_id = None + socket_sid = request.sid try: client_request_id = data.get('client_request_id') if not current_app.extensions[ @@ -520,6 +566,42 @@ def emit_error(message): client_request_id=client_request_id, )) + def request_auth_banner_decision(banner, context): + prompt_id = secrets.token_urlsafe(24) + decision_event = threading.Event() + prompt = { + 'event': decision_event, + 'accepted': False, + 'socket_sid': socket_sid, + 'user_id': current_user.id, + } + with _ssh_banner_prompts_lock: + _ssh_banner_prompts[prompt_id] = prompt + emit('ssh_auth_banner', { + 'prompt_id': prompt_id, + 'banner': banner, + 'context': context, + 'host': bastion_host if context == 'jump_host' else host, + 'port': bastion_port if context == 'jump_host' else port, + 'client_request_id': client_request_id, + }) + answered = decision_event.wait(SSH_AUTH_BANNER_DECISION_TIMEOUT) + with _ssh_banner_prompts_lock: + _ssh_banner_prompts.pop(prompt_id, None) + accepted = answered and prompt['accepted'] is True + log_security_event( + 'SSH_AUTH_BANNER_DECISION', + user=current_user.username, + host=bastion_host if context == 'jump_host' else host, + port=bastion_port if context == 'jump_host' else port, + context=context, + result='ACCEPTED' if accepted else ( + 'DECLINED' if answered else 'TIMED_OUT' + ), + ip_address=request.remote_addr, + ) + return accepted + startup_commands, startup_commands_error = ( post_connect_manager.resolve_configuration( current_user.id, data @@ -652,96 +734,188 @@ def emit_error(message): if existing: reconnect_tmux_name = raw_name - session_id, error = ssh_manager.create_ssh_connection( - host=host, - port=int(port), - username=username, - password=password, - key_content=key_content, - socketio_instance=socketio, - app=current_app._get_current_object(), - user_id=current_user.id, - proxy_jump_host=bastion_host, - proxy_jump_port=bastion_port, - proxy_jump_username=bastion_username, - proxy_jump_password=bastion_password, - proxy_jump_key_content=bastion_key_content, - use_tmux=use_tmux, - reconnect_tmux_name=reconnect_tmux_name, - auth_type=auth_type, - startup_commands='' if reconnect_tmux_name else startup_commands, - ) - - if password: - password = None - if key_content: - key_content = None - - if error: - emit_error(error) - else: - created_session = ssh_manager.get_session(session_id) - if not created_session: - log_error("SSH session disappeared after creation", session_id=session_id) - emit_error("Connection failed") - return - created_tmux_name = created_session.get('tmux_session_name') if use_tmux else None + app = current_app._get_current_object() + lifecycle = app.extensions['runtime_lifecycle'] + credential_box = { + 'password': password, + 'key_content': key_content, + 'bastion_password': bastion_password, + 'bastion_key_content': bastion_key_content, + } - display_name = data.get('display_name') if use_tmux else None - if display_name: - display_name = display_name.strip()[:128] or None + @copy_current_request_context + def connect_ssh(cancel_event, credentials=credential_box): + """Run blocking SSH setup outside the synchronous socket reader.""" + local_password = credentials.pop('password', None) + local_key_content = credentials.pop('key_content', None) + local_bastion_password = credentials.pop( + 'bastion_password', None + ) + local_bastion_key_content = credentials.pop( + 'bastion_key_content', None + ) try: - # Clean up the specific old disconnected persistent session when - # reconnecting to avoid ghost tabs on refresh. - if use_tmux and reconnect_tmux_name: - old_session = SSHSession.query.filter_by( - user_id=current_user.id, host=host, port=port, - is_persistent=True, connected=False, - tmux_session_name=reconnect_tmux_name - ).first() - if old_session: - db.session.delete(old_session) - log_info("Cleaned up old persistent session", - user=current_user.username, host=host, - tmux_session=reconnect_tmux_name) - - ssh_session = SSHSession( - session_id=session_id, - user_id=current_user.id, + if cancel_event.is_set(): + return + session_id, error = ssh_manager.create_ssh_connection( host=host, - port=port, + port=int(port), username=username, - is_persistent=use_tmux, - key_id=key_id if use_tmux else None, + password=local_password, + key_content=local_key_content, + socketio_instance=socketio, + app=app, + user_id=current_user.id, + proxy_jump_host=bastion_host, + proxy_jump_port=bastion_port, + proxy_jump_username=bastion_username, + proxy_jump_password=local_bastion_password, + proxy_jump_key_content=local_bastion_key_content, + use_tmux=use_tmux, + reconnect_tmux_name=reconnect_tmux_name, auth_type=auth_type, - tmux_session_name=created_tmux_name, - display_name=display_name if use_tmux else None + startup_commands=( + '' if reconnect_tmux_name else startup_commands + ), + auth_banner_decision=request_auth_banner_decision, ) - db.session.add(ssh_session) - db.session.commit() - except Exception as db_err: - db.session.rollback() - log_error("Failed to record SSH session in database", - error=str(db_err), session_id=session_id) - emit('ssh_connected', { - 'session_id': session_id, - 'host': host, - 'port': port, - 'username': username, - 'client_request_id': client_request_id, - 'via_jump': bastion_host, - 'use_tmux': use_tmux, - 'key_id': key_id if use_tmux else None, - 'auth_type': auth_type, - 'tmux_session_name': created_tmux_name, - 'display_name': display_name, - 'file_source': _public_file_source( - make_source_id(FileSourceKind.SFTP_SESSION, session_id), - current_user.id, - ), - }) - log_ssh_connection(current_user.username, host, port, True, request.remote_addr) + if error: + emit_error(error) + return + + socket_is_live = SocketSession.query.filter_by( + socket_sid=socket_sid, + user_id=current_user.id, + ).first() is not None + if cancel_event.is_set() or not socket_is_live: + ssh_manager.close_session( + session_id, + kill_tmux=use_tmux, + ) + return + + created_session = ssh_manager.get_session(session_id) + if not created_session: + log_error( + "SSH session disappeared after creation", + session_id=session_id, + ) + emit_error("Connection failed") + return + created_tmux_name = ( + created_session.get('tmux_session_name') + if use_tmux else None + ) + + display_name = data.get('display_name') if use_tmux else None + if display_name: + display_name = display_name.strip()[:128] or None + try: + # Clean up the specific old disconnected persistent session + # when reconnecting to avoid ghost tabs on refresh. + if use_tmux and reconnect_tmux_name: + old_session = SSHSession.query.filter_by( + user_id=current_user.id, + host=host, + port=port, + is_persistent=True, + connected=False, + tmux_session_name=reconnect_tmux_name, + ).first() + if old_session: + db.session.delete(old_session) + log_info( + "Cleaned up old persistent session", + user=current_user.username, + host=host, + tmux_session=reconnect_tmux_name, + ) + + ssh_session = SSHSession( + session_id=session_id, + user_id=current_user.id, + host=host, + port=port, + username=username, + is_persistent=use_tmux, + key_id=key_id if use_tmux else None, + auth_type=auth_type, + tmux_session_name=created_tmux_name, + display_name=display_name if use_tmux else None, + ) + db.session.add(ssh_session) + db.session.commit() + except Exception as db_err: + db.session.rollback() + log_error( + "Failed to record SSH session in database", + error=str(db_err), + session_id=session_id, + ) + + emit('ssh_connected', { + 'session_id': session_id, + 'host': host, + 'port': port, + 'username': username, + 'client_request_id': client_request_id, + 'via_jump': bastion_host, + 'use_tmux': use_tmux, + 'key_id': key_id if use_tmux else None, + 'auth_type': auth_type, + 'tmux_session_name': created_tmux_name, + 'display_name': display_name, + 'file_source': _public_file_source( + make_source_id( + FileSourceKind.SFTP_SESSION, + session_id, + ), + current_user.id, + ), + }) + log_ssh_connection( + current_user.username, + host, + port, + True, + request.remote_addr, + ) + except StorageCorruptionError as error: + emit('ssh_error', _storage_error_payload( + error, + user_id=current_user.id, + include_success=False, + client_request_id=client_request_id, + )) + except Exception as error: + log_error( + "SSH connection failed", + error=str(error), + user=current_user.username, + ) + emit('ssh_error', {'error': 'Connection failed'}) + finally: + credentials.clear() + local_password = None + local_key_content = None + local_bastion_password = None + local_bastion_key_content = None + + try: + lifecycle.start_job( + 'ssh_connect', + connect_ssh, + owner_id=current_user.id, + ) + except Exception as error: + credential_box.clear() + log_warning( + 'SSH connection job rejected', + user=current_user.username, + error_type=type(error).__name__, + ) + emit_error('Server is shutting down') except StorageCorruptionError as error: emit('ssh_error', _storage_error_payload( diff --git a/app/ssh_manager.py b/app/ssh_manager.py index 1d4e7b9..45e93be 100644 --- a/app/ssh_manager.py +++ b/app/ssh_manager.py @@ -29,6 +29,7 @@ sessions_lock = Lock() TMUX_KILL_TIMEOUT = 2.0 TMUX_PROBE_TIMEOUT = 2.0 +SSH_AUTH_BANNER_MAX_CHARS = 16 * 1024 class TailscaleSSHAuthStrategy(AuthStrategy): @@ -47,6 +48,42 @@ def _configure_host_key_trust(client, store): client.set_missing_host_key_policy(store.missing_key_policy()) +def _authentication_banner(transport): + """Return a bounded, display-safe SSH authentication banner.""" + get_banner = getattr(transport, 'get_banner', None) + if not callable(get_banner): + return '' + banner = get_banner() + if banner is None: + return '' + if isinstance(banner, bytes): + banner = banner.decode('utf-8', errors='replace') + else: + banner = str(banner) + banner = banner.replace('\r\n', '\n').replace('\r', '\n') + banner = ''.join( + character + for character in banner + if character in {'\n', '\t'} or character.isprintable() + ) + return banner[:SSH_AUTH_BANNER_MAX_CHARS].strip() + + +def _authentication_banner_accepted(transport, decision_callback, context): + banner = _authentication_banner(transport) + if not banner or decision_callback is None: + return True + try: + return decision_callback(banner, context) is True + except Exception as error: + log_warning( + "SSH authentication banner decision failed", + context=context, + error_type=type(error).__name__, + ) + return False + + def _open_exec_channel(transport, command, *, timeout, pty=None): """Open a bounded exec channel for a reviewed, fully quoted command.""" channel = transport.open_session(timeout=timeout) @@ -106,7 +143,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke proxy_jump_host=None, proxy_jump_port=None, proxy_jump_username=None, proxy_jump_password=None, proxy_jump_key_content=None, use_tmux=False, reconnect_tmux_name=None, - auth_type='password', startup_commands=''): + auth_type='password', startup_commands='', + auth_banner_decision=None): """ Create a new SSH connection and return session ID. @@ -122,6 +160,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke user_id: User ID for session tracking proxy_jump_*: Optional jump host (bastion) connection parameters auth_type: Target authentication method (password, key, or tailscale) + auth_banner_decision: Callback that must accept a server banner before + any forwarding channel, shell, tmux probe, or startup command opens """ try: host_key_store = HostKeyStore( @@ -207,6 +247,15 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke if bastion_transport: bastion_transport.set_keepalive(30) + if not _authentication_banner_accepted( + bastion_transport, auth_banner_decision, 'jump_host' + ): + return None, SSHConnectionError( + "SSH authentication banner was not accepted", + code="auth_banner_declined", + context="jump_host", + ) + sock = bastion_transport.open_channel( 'direct-tcpip', channel_destination, @@ -278,6 +327,15 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke if transport: transport.set_keepalive(30) + if not _authentication_banner_accepted( + transport, auth_banner_decision, 'target' + ): + return None, SSHConnectionError( + "SSH authentication banner was not accepted", + code="auth_banner_declined", + context="target", + ) + tmux_session_name = None if use_tmux: # Tailscale SSH does not populate locale variables. Force UTF-8 on diff --git a/app/step_up.py b/app/step_up.py index ad20d78..79edb11 100644 --- a/app/step_up.py +++ b/app/step_up.py @@ -41,6 +41,7 @@ ACCOUNT_STEP_UP_ACTIONS = frozenset({ "passkey.enroll", "passkey.delete", + "mfa.enable", "totp.enroll", "mfa.disable", "recovery.rotate", diff --git a/app/webauthn_routes.py b/app/webauthn_routes.py index b5593a3..6d02e31 100644 --- a/app/webauthn_routes.py +++ b/app/webauthn_routes.py @@ -153,6 +153,51 @@ def list_credentials(): ]}) +@webauthn_blueprint.post("/api/webauthn/mfa") +@login_required +def enable_passkey_mfa(): + """Require an enrolled Passkey after basic primary authentication.""" + _require_enabled() + data = _bounded_json() + if data is None: + return _request_body_too_large() + if data.get("confirm_enable_mfa") is not True: + return jsonify({"error": "MFA activation must be confirmed"}), 400 + grant_error = _consume_factor_grant("mfa.enable", current_user.id) + if grant_error is not None: + return grant_error + + from .recovery_service import _recovery_lock, _replace_codes_uncommitted + from .security_features import feature_is_active + + if not feature_is_active("recovery"): + return jsonify({ + "error": "Recovery codes must be active before enabling MFA" + }), 409 + + with _registration_lock, _recovery_lock: + user = db.session.get(User, current_user.id, populate_existing=True) + if user is None or user.is_locked: + return jsonify({"error": "Account is unavailable"}), 409 + if user.mfa_enabled: + return jsonify({"error": "MFA is already enabled"}), 409 + if not WebAuthnCredential.query.filter_by(user_id=user.id).first(): + return jsonify({"error": "Add a Passkey before enabling MFA"}), 409 + recovery_codes = _replace_codes_uncommitted(user.id, count=10) + user.mfa_enabled = True + try: + db.session.commit() + except Exception: + db.session.rollback() + return jsonify({"error": "MFA could not be enabled"}), 503 + + log_security_event("MFA_ENABLED", user=current_user.username, factor="passkey") + response = jsonify({"ok": True, "recovery_codes": recovery_codes}) + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + return response + + @webauthn_blueprint.post("/api/webauthn/register/options") @login_required def registration_options(): diff --git a/docs/wiki/Passkeys-and-Recovery-Codes.md b/docs/wiki/Passkeys-and-Recovery-Codes.md index a3a340c..859d2ad 100644 --- a/docs/wiki/Passkeys-and-Recovery-Codes.md +++ b/docs/wiki/Passkeys-and-Recovery-Codes.md @@ -40,6 +40,34 @@ MAX_WEBAUTHN_JSON_SIZE=65536 - Registration and authentication payloads are capped at 64 KiB. - Credential inventory and deletion require authenticated ownership. +## Direct Passkey login and Passkey MFA + +Passkeys can be used in two distinct ways: + +- **Direct Passkey login** selects **Sign in with Passkey** on the login page. + It does not ask for the account password and creates a phishing-resistant + session after the authenticator verifies the user. +- **Passkey MFA after primary login** first verifies the local password, LDAP, + or another configured primary method, then requires an enrolled Passkey, + authenticator app, or Recovery Code. + +Merely enrolling a Passkey does not make the account password path require a +second factor. After adding and testing a Passkey, choose **Require Passkey MFA +for password sign-in** in **Security**. WebSSH requires recent action-bound +Step-up, enables account MFA atomically, and returns a new ten-code Recovery set +once. Store those codes before leaving the page. + +When account MFA is enabled, an enrolled Passkey is one eligible second factor; +WebSSH does not require the same specific Passkey on every login. Direct Passkey +login remains available and already provides phishing-resistant assurance. + +The Admin authentication-feature switches are deployment and availability +controls, not organization-wide enrollment policy. This release does not let +an administrator force MFA or Passkey enrollment for every account. Such a +policy needs an enrollment grace period, recovery and break-glass rules, and a +safe treatment for service or directory-managed accounts to avoid mass +lockout. + ## Enroll a passkey 1. Sign in with the local password or LDAP. If MFA is already enabled, complete @@ -49,6 +77,8 @@ MAX_WEBAUTHN_JSON_SIZE=65536 4. Complete the authenticator prompt. 5. Give the credential a recognizable name where supported. 6. Sign out and test passkey login before depending on it. +7. If password or directory sign-in must not remain a single-factor fallback, + return to **Security** and enable Passkey MFA. Enroll more than one authenticator when losing one device would otherwise lock out the account. diff --git a/docs/wiki/Profiles-Jump-Hosts-and-Commands.md b/docs/wiki/Profiles-Jump-Hosts-and-Commands.md index 0eb64d5..a5f7335 100644 --- a/docs/wiki/Profiles-Jump-Hosts-and-Commands.md +++ b/docs/wiki/Profiles-Jump-Hosts-and-Commands.md @@ -21,6 +21,13 @@ Password-dependent profiles open the connection form at the required field. Profiles that need no password, such as a usable stored key or authorized Tailscale mode, can launch directly from an empty pane. +Use **Duplicate** in the Hosts manager to create a new draft from an existing +profile. The draft copies connection, authentication-reference, jump-host, +group, and post-connect settings, adds a localized “copy” suffix to the +bounded name, and has no source profile ID. Saving therefore creates a separate +profile and never overwrites the original. Passwords remain excluded because +they are never stored in profiles. + ## Favorites and groups The connection manager provides: diff --git a/docs/wiki/SSH-Connections-and-Host-Keys.md b/docs/wiki/SSH-Connections-and-Host-Keys.md index 59d624d..919ba18 100644 --- a/docs/wiki/SSH-Connections-and-Host-Keys.md +++ b/docs/wiki/SSH-Connections-and-Host-Keys.md @@ -48,6 +48,52 @@ Trust is scoped to the relevant user or administrator-managed global store. Users can inspect and revoke their own trust records in the Security Center; administrators can manage global trust. +## Add global host trust + +Administrators can add a verified OpenSSH `known_hosts` record under **Admin → +Settings → Global SSH host trust**. The import accepts exactly one bounded +record, validates the hostname pattern and public key, requires action-bound +administrator Step-up, and returns only fingerprint metadata to the browser. + +One way to collect a candidate record is: + +```bash +ssh-keyscan -p 22 server.example +``` + +`ssh-keyscan` collects a key but does **not** prove its identity. Verify the +fingerprint through a separate trusted channel, for example with the server +owner, console, or configuration management, before importing it. You can +inspect a collected record with: + +```bash +ssh-keygen -lf candidate_known_hosts +``` + +Paste one verified `hostname key-type base64-key` line into the Admin field. +Hashed hostnames, non-default-port tokens such as `[server.example]:2222`, +multi-host records, and `@revoked` records are supported. Duplicate records are +rejected. A different key for the same host token and algorithm must be +verified and the old record explicitly removed first. + +Removing a global record also requires Step-up and affects every user who +depends on that global trust record. Per-user trust can still take precedence +for the same effective host identity. + +## SSH authentication banners + +An SSH server can send `SSH_MSG_USERAUTH_BANNER` during authentication. The SSH +protocol does not make this a true pre-authentication message; Paramiko exposes +it after authentication completes. WebSSH therefore pauses immediately after +authentication and before it opens a target shell, jump-host forwarding +channel, tmux probe, or post-connect command. + +The browser displays the bounded, control-character-sanitized text and requires +**Continue** or **Cancel connection**. Cancellation, browser disconnect, or a +60-second timeout closes the transport. The audit log records the user, target, +target/jump-host context, and accepted/declined/timed-out result. It deliberately +does not record the banner text, which is controlled by the remote server. + ## Respond to a changed host key Do not immediately delete the record and retry. A change may indicate: @@ -119,6 +165,12 @@ Verify the remote username and selected method. For keys, check the public key is installed for that remote user and the private-key format is supported. For a jump host, distinguish bastion authentication from target authentication. +### The authentication banner closes the connection + +Choose **Continue** within 60 seconds only after reviewing the remote policy. +Cancelling, closing the browser connection, or leaving the prompt unanswered +fails closed before a shell or startup command is opened. + ### Host key is rejected Review the trust record and verify the new fingerprint. Do not disable host-key diff --git a/static/css/admin.css b/static/css/admin.css index 11ec38d..f96fba6 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -65,6 +65,18 @@ min-width: 180px; } +.admin-host-key-import { + align-items: stretch; +} + +.admin-host-key-import .form-control { + flex: 1 1 520px; + width: 100%; + min-width: min(100%, 280px); + resize: vertical; + font-family: var(--font-mono, monospace); +} + .admin-table-wrap { width: 100%; min-width: 0; diff --git a/static/css/style.css b/static/css/style.css index 5e74082..997fc55 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2296,6 +2296,49 @@ body.broadcast-active .terminal-wrapper:not(.unassigned) { width: min(100%, 440px); } +.ssh-auth-banner-modal { + z-index: 9200; +} + +.ssh-auth-banner-modal .modal-content { + width: min(100%, 680px); +} + +.ssh-auth-banner-body { + display: grid; + gap: 14px; +} + +.ssh-auth-banner-body > p { + margin: 0; +} + +.ssh-auth-banner-target { + color: var(--text-secondary); + font-size: 13px; +} + +.ssh-auth-banner-text { + min-height: 140px; + max-height: min(42vh, 360px); + margin: 0; + padding: 14px; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + font: 13px/1.55 var(--font-mono, monospace); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.ssh-auth-banner-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + .palette-list, .shortcuts-list { margin-top: 16px; diff --git a/static/js/admin.js b/static/js/admin.js index 4b1c8ef..9ea5a9f 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1315,6 +1315,37 @@ } } + async function addGlobalHostKey() { + const input = document.getElementById('globalHostKeyEntry'); + const button = document.getElementById('globalHostKeyAdd'); + const entry = input?.value.trim() || ''; + if (!entry) { + notify(t('admin.globalHostKeyRequired', 'Paste one verified known_hosts entry.'), 'error'); + input?.focus(); + return; + } + if (!window.confirm(t( + 'admin.confirmGlobalHostKeyImport', + 'Trust this verified SSH host key for every WebSSH user?' + ))) { return; } + if (button) button.disabled = true; + try { + await stepUpApi( + 'host_key.global_add', + 'global', + '/admin/api/host-keys', + {method: 'POST', body: {entry}}, + ); + input.value = ''; + await loadGlobalHostKeys(); + notify(t('admin.globalHostKeyAdded', 'Global SSH host key imported'), 'success'); + } catch (error) { + notify(error.message, 'error'); + } finally { + if (button) button.disabled = false; + } + } + document.addEventListener('DOMContentLoaded', () => { if (window.i18n && i18n.updatePageText) { i18n.updatePageText(); } initStepUpDialog(); @@ -1323,6 +1354,9 @@ initAudit(); initSettings(); initBackupRestore(); + document.getElementById('globalHostKeyAdd')?.addEventListener( + 'click', addGlobalHostKey + ); loadUsers(); loadGlobalHostKeys(); window.addEventListener('languageChanged', () => { diff --git a/static/js/app.js b/static/js/app.js index 8fcc5d5..1acf456 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1001,6 +1001,49 @@ } }, 60000); + let pendingAuthBannerPrompt = null; + + function closeAuthBannerPrompt() { + const hadPrompt = pendingAuthBannerPrompt !== null; + pendingAuthBannerPrompt = null; + window.ModalManager.close(document.getElementById('sshAuthBannerModal')); + const connectionModal = document.getElementById('connectionModal'); + if (hadPrompt && connectionModal?.classList.contains('show')) { + window.ModalManager.activeModal = connectionModal; + } + } + + function answerAuthBannerPrompt(accepted) { + if (!pendingAuthBannerPrompt) return; + const promptId = pendingAuthBannerPrompt.promptId; + closeAuthBannerPrompt(); + socket.emit('ssh_auth_banner_decision', { + prompt_id: promptId, + accepted: accepted === true, + }); + } + + socket.on('ssh_auth_banner', data => { + if ( + !data + || typeof data.prompt_id !== 'string' + || typeof data.banner !== 'string' + ) { + return; + } + pendingAuthBannerPrompt = { promptId: data.prompt_id }; + const contextKey = data.context === 'jump_host' + ? 'connection.authBannerJumpHost' + : 'connection.authBannerTarget'; + const contextLabel = window.i18n + ? i18n.t(contextKey) + : (data.context === 'jump_host' ? 'Jump host' : 'Target host'); + const target = [data.host, data.port].filter(value => value !== undefined).join(':'); + document.getElementById('sshAuthBannerTarget').textContent = `${contextLabel}: ${target}`; + document.getElementById('sshAuthBannerText').textContent = data.banner; + window.ModalManager.open(document.getElementById('sshAuthBannerModal')); + }); + socket.io.on('reconnect_attempt', (attempt) => { const reconnectBar = document.getElementById('reconnectBar'); if (reconnectBar) { @@ -1021,6 +1064,7 @@ }); socket.on('disconnect', () => { + closeAuthBannerPrompt(); showNotification( window.i18n ? i18n.t('connection.disconnectedFromServer') @@ -1038,6 +1082,7 @@ }); socket.on('ssh_connected', (data) => { + closeAuthBannerPrompt(); if (data.client_request_id) { SessionManager.clearPendingConnection(data.client_request_id); } @@ -1083,6 +1128,7 @@ }); socket.on('ssh_error', (data) => { + closeAuthBannerPrompt(); const presentation = window.SSHErrorUI?.describeSSHError?.( data, key => window.i18n?.t?.(key), @@ -2332,6 +2378,7 @@ window.addEventListener('click', (e) => { if (e.target.classList.contains('modal')) { + if (e.target.id === 'sshAuthBannerModal') return; window.ModalManager.close(e.target); if (e.target.id === 'connectionModal') { clearPendingPane(); @@ -2403,7 +2450,10 @@ TerminalSearch.close(); } else { document.querySelectorAll('.modal.show').forEach(modal => { - if (modal.id === 'sftpFileManager') return; + if ( + modal.id === 'sftpFileManager' + || modal.id === 'sshAuthBannerModal' + ) return; window.ModalManager.close(modal); }); } @@ -2416,6 +2466,13 @@ document.querySelector('.header-buttons').classList.toggle('is-open'); }); + document.getElementById('sshAuthBannerCancel')?.addEventListener( + 'click', () => answerAuthBannerPrompt(false) + ); + document.getElementById('sshAuthBannerContinue')?.addEventListener( + 'click', () => answerAuthBannerPrompt(true) + ); + document.addEventListener('click', (e) => { const menu = document.querySelector('.header-buttons'); const menuBtn = document.getElementById('mobileMenuBtn'); diff --git a/static/js/i18n.js b/static/js/i18n.js index ec5b8c2..881229d 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -46,6 +46,12 @@ const translations = { 'connection.resolveJumpHost': 'Choose a jump host or confirm a direct connection.', 'connection.newConnection': 'Quick Connect', 'connection.newSSHConnection': 'Quick Connect', + 'connection.authBannerTitle': 'SSH Authentication Banner', + 'connection.authBannerHint': 'The SSH server displayed this banner after authentication. Review it before WebSSH opens a shell or runs startup commands.', + 'connection.authBannerTarget': 'Target host', + 'connection.authBannerJumpHost': 'Jump host', + 'connection.authBannerCancel': 'Cancel connection', + 'connection.authBannerContinue': 'Continue', 'connection.noActiveSessions': 'No Active Sessions', 'connection.clickToStart': 'Click "Quick Connect" to start an SSH session', 'connection.savedProfiles': 'Hosts', @@ -116,6 +122,8 @@ const translations = { 'profiles.manage': 'Hosts', 'profiles.manageHint': 'Create, review, update, and launch saved connections.', 'profiles.create': 'Create saved connection', + 'profiles.duplicate': 'Duplicate', + 'profiles.copyNameSuffix': ' (copy)', 'profiles.none': 'No saved connections.', 'profiles.search': 'Search saved connections', 'profiles.searchPlaceholder': 'Search by name, host, user, or group', @@ -384,6 +392,13 @@ const translations = { 'admin.auditRetentionBackups': 'Audit retention backups', 'admin.auditRetentionHint': 'Number of rotated audit log backups to keep (1-90).', 'admin.globalSshHostTrust': 'Global SSH host trust', + 'admin.globalHostKeyHint': 'Import one independently verified OpenSSH known_hosts entry. Global trust applies to every WebSSH user; existing entries can be removed below.', + 'admin.globalHostKeyEntry': 'Verified known_hosts entry', + 'admin.globalHostKeyPlaceholder': 'host.example ssh-ed25519 AAAA…', + 'admin.importHostKey': 'Import verified key', + 'admin.globalHostKeyRequired': 'Paste one verified known_hosts entry.', + 'admin.confirmGlobalHostKeyImport': 'Trust this verified SSH host key for every WebSSH user?', + 'admin.globalHostKeyAdded': 'Global SSH host key imported', 'admin.accountRecovery': 'Account recovery', 'admin.linkedOidcIdentities': 'Linked OIDC identities', 'admin.administratorPassword': 'Administrator password', @@ -484,6 +499,9 @@ const translations = { 'security.invalidTotpCode': 'Enter a valid six-digit authenticator code.', 'security.mfaDisabled': 'MFA disabled', 'security.mfaEnabled': 'MFA enabled', + 'security.passkeyMfaHint': 'A Passkey can sign in directly. To prevent password-only fallback, explicitly enable MFA after adding and testing at least one Passkey.', + 'security.enablePasskeyMfa': 'Require Passkey MFA for password sign-in', + 'security.confirmEnablePasskeyMfa': 'Require a Passkey, authenticator app, or recovery code after every password or directory sign-in?', 'security.noTotpAuthenticators': 'No authenticator app is enrolled.', 'security.addPasskey': 'Add passkey', 'security.replaceLegacyPasskey': 'Replace legacy passkey', @@ -1118,6 +1136,12 @@ const translations = { 'connection.resolveJumpHost': 'Chọn máy chủ trung chuyển hoặc xác nhận kết nối trực tiếp.', 'connection.newConnection': 'Kết nối nhanh', 'connection.newSSHConnection': 'Kết nối nhanh', + 'connection.authBannerTitle': 'Biểu ngữ xác thực SSH', + 'connection.authBannerHint': 'Máy chủ SSH hiển thị biểu ngữ này sau khi xác thực. Hãy xem lại trước khi WebSSH mở shell hoặc chạy lệnh khởi động.', + 'connection.authBannerTarget': 'Máy chủ đích', + 'connection.authBannerJumpHost': 'Máy chủ trung gian', + 'connection.authBannerCancel': 'Hủy kết nối', + 'connection.authBannerContinue': 'Tiếp tục', 'connection.noActiveSessions': 'Không có phiên hoạt động', 'connection.clickToStart': 'Nhấp vào "Kết nối nhanh" để bắt đầu một phiên SSH', 'connection.savedProfiles': 'Máy chủ', @@ -1188,6 +1212,8 @@ const translations = { 'profiles.manage': 'Máy chủ', 'profiles.manageHint': 'Tạo, xem lại, cập nhật và khởi chạy các kết nối đã lưu.', 'profiles.create': 'Tạo kết nối đã lưu', + 'profiles.duplicate': 'Nhân bản', + 'profiles.copyNameSuffix': ' (bản sao)', 'profiles.none': 'Chưa có kết nối đã lưu.', 'profiles.search': 'Tìm kiếm kết nối đã lưu', 'profiles.searchPlaceholder': 'Tìm theo tên, máy chủ, người dùng hoặc nhóm', @@ -1419,6 +1445,13 @@ const translations = { 'admin.auditRetentionBackups': 'Bản sao lưu nhật ký kiểm tra', 'admin.auditRetentionHint': 'Số bản sao lưu nhật ký kiểm tra luân phiên cần giữ lại (1-90).', 'admin.globalSshHostTrust': 'Tin cậy máy chủ SSH toàn cục', + 'admin.globalHostKeyHint': 'Nhập một mục OpenSSH known_hosts đã được xác minh độc lập. Tin cậy toàn cục áp dụng cho mọi người dùng WebSSH; có thể xóa các mục hiện có bên dưới.', + 'admin.globalHostKeyEntry': 'Mục known_hosts đã xác minh', + 'admin.globalHostKeyPlaceholder': 'host.example ssh-ed25519 AAAA…', + 'admin.importHostKey': 'Nhập khóa đã xác minh', + 'admin.globalHostKeyRequired': 'Dán một mục known_hosts đã xác minh.', + 'admin.confirmGlobalHostKeyImport': 'Tin cậy khóa máy chủ SSH này cho mọi người dùng WebSSH?', + 'admin.globalHostKeyAdded': 'Đã nhập khóa máy chủ SSH toàn cục', 'admin.accountRecovery': 'Khôi phục tài khoản', 'admin.linkedOidcIdentities': 'Danh tính OIDC đã liên kết', 'admin.administratorPassword': 'Mật khẩu quản trị viên', @@ -1519,6 +1552,9 @@ const translations = { 'security.invalidTotpCode': 'Nhập mã xác thực gồm sáu chữ số hợp lệ.', 'security.mfaDisabled': 'Đã tắt MFA', 'security.mfaEnabled': 'Đã bật MFA', + 'security.passkeyMfaHint': 'Passkey có thể đăng nhập trực tiếp. Để ngăn đăng nhập chỉ bằng mật khẩu, hãy bật MFA sau khi thêm và kiểm tra ít nhất một Passkey.', + 'security.enablePasskeyMfa': 'Yêu cầu MFA bằng Passkey khi đăng nhập mật khẩu', + 'security.confirmEnablePasskeyMfa': 'Yêu cầu Passkey, ứng dụng xác thực hoặc mã khôi phục sau mỗi lần đăng nhập bằng mật khẩu hoặc thư mục?', 'security.noTotpAuthenticators': 'Chưa đăng ký ứng dụng xác thực.', 'security.addPasskey': 'Thêm passkey', 'security.replaceLegacyPasskey': 'Thay passkey cũ', @@ -2189,6 +2225,12 @@ const translations = { 'connection.resolveJumpHost': 'Wähle einen Jump Host oder bestätige eine direkte Verbindung.', 'connection.newConnection': 'Schnellverbindung', 'connection.newSSHConnection': 'Schnellverbindung', + 'connection.authBannerTitle': 'SSH-Anmeldehinweis', + 'connection.authBannerHint': 'Der SSH-Server hat diesen Hinweis nach der Anmeldung angezeigt. Prüfe ihn, bevor WebSSH eine Shell öffnet oder Startbefehle ausführt.', + 'connection.authBannerTarget': 'Zielhost', + 'connection.authBannerJumpHost': 'Jump-Host', + 'connection.authBannerCancel': 'Verbindung abbrechen', + 'connection.authBannerContinue': 'Fortfahren', 'connection.noActiveSessions': 'Keine aktiven Sitzungen', 'connection.clickToStart': 'Klicken Sie auf "Schnellverbindung", um eine SSH-Sitzung zu starten', 'connection.savedProfiles': 'Hosts', @@ -2259,6 +2301,8 @@ const translations = { 'profiles.manage': 'Hosts', 'profiles.manageHint': 'Gespeicherte Verbindungen erstellen, prüfen, aktualisieren und starten.', 'profiles.create': 'Gespeicherte Verbindung erstellen', + 'profiles.duplicate': 'Duplizieren', + 'profiles.copyNameSuffix': ' (Kopie)', 'profiles.none': 'Keine gespeicherten Verbindungen.', 'profiles.search': 'Gespeicherte Verbindungen suchen', 'profiles.searchPlaceholder': 'Nach Name, Host, Benutzer oder Gruppe suchen', @@ -2527,6 +2571,13 @@ const translations = { 'admin.auditRetentionBackups': 'Audit-Aufbewahrungssicherungen', 'admin.auditRetentionHint': 'Anzahl der aufzubewahrenden rotierten Audit-Log-Sicherungen (1-90).', 'admin.globalSshHostTrust': 'Globales SSH-Host-Vertrauen', + 'admin.globalHostKeyHint': 'Importiere genau einen unabhängig geprüften OpenSSH-known_hosts-Eintrag. Globales Vertrauen gilt für alle WebSSH-Benutzer; vorhandene Einträge können unten entfernt werden.', + 'admin.globalHostKeyEntry': 'Geprüfter known_hosts-Eintrag', + 'admin.globalHostKeyPlaceholder': 'host.example ssh-ed25519 AAAA…', + 'admin.importHostKey': 'Geprüften Schlüssel importieren', + 'admin.globalHostKeyRequired': 'Füge einen geprüften known_hosts-Eintrag ein.', + 'admin.confirmGlobalHostKeyImport': 'Diesem geprüften SSH-Host-Schlüssel für alle WebSSH-Benutzer vertrauen?', + 'admin.globalHostKeyAdded': 'Globaler SSH-Host-Schlüssel importiert', 'admin.accountRecovery': 'Kontowiederherstellung', 'admin.linkedOidcIdentities': 'Verknüpfte OIDC-Identitäten', 'admin.administratorPassword': 'Administratorpasswort', @@ -2627,6 +2678,9 @@ const translations = { 'security.invalidTotpCode': 'Gib einen gültigen sechsstelligen Authenticator-Code ein.', 'security.mfaDisabled': 'MFA deaktiviert', 'security.mfaEnabled': 'MFA aktiviert', + 'security.passkeyMfaHint': 'Ein Passkey kann direkt anmelden. Aktiviere MFA ausdrücklich, nachdem mindestens ein Passkey hinzugefügt und getestet wurde, um reine Passwort-Anmeldungen zu verhindern.', + 'security.enablePasskeyMfa': 'Passkey-MFA für Passwort-Anmeldung verlangen', + 'security.confirmEnablePasskeyMfa': 'Nach jeder Passwort- oder Verzeichnis-Anmeldung einen Passkey, eine Authenticator-App oder einen Wiederherstellungscode verlangen?', 'security.noTotpAuthenticators': 'Keine Authenticator-App eingerichtet.', 'security.addPasskey': 'Passkey hinzufügen', 'security.replaceLegacyPasskey': 'Alten Passkey ersetzen', @@ -3259,6 +3313,12 @@ const translations = { 'connection.resolveJumpHost': 'Choisissez un hôte de rebond ou confirmez une connexion directe.', 'connection.newConnection': 'Connexion rapide', 'connection.newSSHConnection': 'Connexion rapide', + 'connection.authBannerTitle': "Bannière d’authentification SSH", + 'connection.authBannerHint': "Le serveur SSH a affiché cette bannière après l’authentification. Vérifiez-la avant que WebSSH n’ouvre un shell ou n’exécute les commandes de démarrage.", + 'connection.authBannerTarget': 'Hôte cible', + 'connection.authBannerJumpHost': 'Hôte de rebond', + 'connection.authBannerCancel': 'Annuler la connexion', + 'connection.authBannerContinue': 'Continuer', 'connection.noActiveSessions': 'Aucune session active', 'connection.clickToStart': 'Cliquez sur "Connexion rapide" pour démarrer une session SSH', 'connection.savedProfiles': 'Hôtes', @@ -3329,6 +3389,8 @@ const translations = { 'profiles.manage': 'Hôtes', 'profiles.manageHint': 'Créez, vérifiez, mettez à jour et lancez des connexions enregistrées.', 'profiles.create': 'Créer une connexion enregistrée', + 'profiles.duplicate': 'Dupliquer', + 'profiles.copyNameSuffix': ' (copie)', 'profiles.none': 'Aucune connexion enregistrée.', 'profiles.search': 'Rechercher les connexions enregistrées', 'profiles.searchPlaceholder': 'Rechercher par nom, hôte, utilisateur ou groupe', @@ -3560,6 +3622,13 @@ const translations = { 'admin.auditRetentionBackups': "Sauvegardes de conservation de l’audit", 'admin.auditRetentionHint': "Nombre de sauvegardes de journaux d’audit après rotation à conserver (1-90).", 'admin.globalSshHostTrust': "Confiance globale des hôtes SSH", + 'admin.globalHostKeyHint': "Importez une seule entrée OpenSSH known_hosts vérifiée indépendamment. La confiance globale s'applique à tous les utilisateurs WebSSH ; les entrées existantes peuvent être supprimées ci-dessous.", + 'admin.globalHostKeyEntry': 'Entrée known_hosts vérifiée', + 'admin.globalHostKeyPlaceholder': 'host.example ssh-ed25519 AAAA…', + 'admin.importHostKey': 'Importer la clé vérifiée', + 'admin.globalHostKeyRequired': 'Collez une entrée known_hosts vérifiée.', + 'admin.confirmGlobalHostKeyImport': "Faire confiance à cette clé d'hôte SSH pour tous les utilisateurs WebSSH ?", + 'admin.globalHostKeyAdded': "Clé d'hôte SSH globale importée", 'admin.accountRecovery': 'Récupération du compte', 'admin.linkedOidcIdentities': 'Identités OIDC liées', 'admin.administratorPassword': "Mot de passe de l’administrateur", @@ -3660,6 +3729,9 @@ const translations = { 'security.invalidTotpCode': 'Saisissez un code d’authentification valide à six chiffres.', 'security.mfaDisabled': 'MFA désactivée', 'security.mfaEnabled': 'MFA activée', + 'security.passkeyMfaHint': 'Une clé d’accès permet une connexion directe. Pour empêcher le repli sur le mot de passe seul, activez explicitement la MFA après avoir ajouté et testé au moins une clé d’accès.', + 'security.enablePasskeyMfa': 'Exiger la MFA par clé d’accès après le mot de passe', + 'security.confirmEnablePasskeyMfa': 'Exiger une clé d’accès, une application d’authentification ou un code de récupération après chaque connexion par mot de passe ou annuaire ?', 'security.noTotpAuthenticators': "Aucune application d’authentification n’est configurée.", 'security.addPasskey': "Ajouter une clé d’accès", 'security.replaceLegacyPasskey': "Remplacer l’ancienne clé d’accès", @@ -4329,6 +4401,12 @@ const translations = { 'connection.resolveJumpHost': 'Elige un host de salto o confirma una conexión directa.', 'connection.newConnection': 'Conexión rápida', 'connection.newSSHConnection': 'Conexión rápida', + 'connection.authBannerTitle': 'Aviso de autenticación SSH', + 'connection.authBannerHint': 'El servidor SSH mostró este aviso después de la autenticación. Revísalo antes de que WebSSH abra un shell o ejecute comandos de inicio.', + 'connection.authBannerTarget': 'Host de destino', + 'connection.authBannerJumpHost': 'Host de salto', + 'connection.authBannerCancel': 'Cancelar conexión', + 'connection.authBannerContinue': 'Continuar', 'connection.noActiveSessions': 'Sin sesiones activas', 'connection.clickToStart': 'Haz clic en "Conexión rápida" para iniciar una sesión SSH', 'connection.savedProfiles': 'Hosts', @@ -4399,6 +4477,8 @@ const translations = { 'profiles.manage': 'Hosts', 'profiles.manageHint': 'Crea, revisa, actualiza e inicia conexiones guardadas.', 'profiles.create': 'Crear conexión guardada', + 'profiles.duplicate': 'Duplicar', + 'profiles.copyNameSuffix': ' (copia)', 'profiles.none': 'No hay conexiones guardadas.', 'profiles.search': 'Buscar conexiones guardadas', 'profiles.searchPlaceholder': 'Buscar por nombre, host, usuario o grupo', @@ -4630,6 +4710,13 @@ const translations = { 'admin.auditRetentionBackups': 'Copias de retención de auditoría', 'admin.auditRetentionHint': 'Número de copias rotadas del registro de auditoría que se conservarán (1-90).', 'admin.globalSshHostTrust': 'Confianza global de hosts SSH', + 'admin.globalHostKeyHint': 'Importa una sola entrada OpenSSH known_hosts verificada de forma independiente. La confianza global se aplica a todos los usuarios de WebSSH; las entradas existentes se pueden eliminar abajo.', + 'admin.globalHostKeyEntry': 'Entrada known_hosts verificada', + 'admin.globalHostKeyPlaceholder': 'host.example ssh-ed25519 AAAA…', + 'admin.importHostKey': 'Importar clave verificada', + 'admin.globalHostKeyRequired': 'Pega una entrada known_hosts verificada.', + 'admin.confirmGlobalHostKeyImport': '¿Confiar en esta clave de host SSH para todos los usuarios de WebSSH?', + 'admin.globalHostKeyAdded': 'Clave de host SSH global importada', 'admin.accountRecovery': 'Recuperación de cuenta', 'admin.linkedOidcIdentities': 'Identidades OIDC vinculadas', 'admin.administratorPassword': 'Contraseña del administrador', @@ -4730,6 +4817,9 @@ const translations = { 'security.invalidTotpCode': 'Introduce un código de autenticación válido de seis dígitos.', 'security.mfaDisabled': 'MFA desactivado', 'security.mfaEnabled': 'MFA activado', + 'security.passkeyMfaHint': 'Una passkey permite iniciar sesión directamente. Para impedir el acceso solo con contraseña, activa MFA explícitamente después de añadir y probar al menos una passkey.', + 'security.enablePasskeyMfa': 'Exigir MFA con passkey tras la contraseña', + 'security.confirmEnablePasskeyMfa': '¿Exigir una passkey, una aplicación de autenticación o un código de recuperación después de cada inicio con contraseña o directorio?', 'security.noTotpAuthenticators': 'No hay ninguna aplicación de autenticación configurada.', 'security.addPasskey': 'Añadir passkey', 'security.replaceLegacyPasskey': 'Reemplazar passkey antigua', @@ -5399,6 +5489,12 @@ const translations = { 'connection.resolveJumpHost': '请选择跳板机或确认直接连接。', 'connection.newConnection': '快速连接', 'connection.newSSHConnection': '快速连接', + 'connection.authBannerTitle': 'SSH 身份验证横幅', + 'connection.authBannerHint': 'SSH 服务器在身份验证后显示了此横幅。请先查看,再让 WebSSH 打开 shell 或运行启动命令。', + 'connection.authBannerTarget': '目标主机', + 'connection.authBannerJumpHost': '跳板主机', + 'connection.authBannerCancel': '取消连接', + 'connection.authBannerContinue': '继续', 'connection.noActiveSessions': '当前没有活动会话', 'connection.clickToStart': '点击“快速连接”开始一个 SSH 会话', 'connection.savedProfiles': '主机', @@ -5469,6 +5565,8 @@ const translations = { 'profiles.manage': '主机', 'profiles.manageHint': '创建、检查、更新并启动已保存的连接。', 'profiles.create': '创建已保存的连接', + 'profiles.duplicate': '复制', + 'profiles.copyNameSuffix': '(副本)', 'profiles.none': '尚无已保存的连接。', 'profiles.search': '搜索已保存的连接', 'profiles.searchPlaceholder': '按名称、主机、用户或分组搜索', @@ -5700,6 +5798,13 @@ const translations = { 'admin.auditRetentionBackups': '审计保留备份', 'admin.auditRetentionHint': '要保留的轮换审计日志备份数量(1-90)。', 'admin.globalSshHostTrust': '全局 SSH 主机信任', + 'admin.globalHostKeyHint': '导入一条经过独立验证的 OpenSSH known_hosts 记录。全局信任适用于所有 WebSSH 用户;可在下方删除现有记录。', + 'admin.globalHostKeyEntry': '已验证的 known_hosts 记录', + 'admin.globalHostKeyPlaceholder': 'host.example ssh-ed25519 AAAA…', + 'admin.importHostKey': '导入已验证密钥', + 'admin.globalHostKeyRequired': '请粘贴一条已验证的 known_hosts 记录。', + 'admin.confirmGlobalHostKeyImport': '要让所有 WebSSH 用户信任此已验证的 SSH 主机密钥吗?', + 'admin.globalHostKeyAdded': '已导入全局 SSH 主机密钥', 'admin.accountRecovery': '账户恢复', 'admin.linkedOidcIdentities': '已关联的 OIDC 身份', 'admin.administratorPassword': '管理员密码', @@ -5800,6 +5905,9 @@ const translations = { 'security.invalidTotpCode': '请输入有效的六位身份验证器代码。', 'security.mfaDisabled': 'MFA 已停用', 'security.mfaEnabled': 'MFA 已启用', + 'security.passkeyMfaHint': '通行密钥可直接登录。为防止仅凭密码登录,请在添加并测试至少一个通行密钥后明确启用 MFA。', + 'security.enablePasskeyMfa': '密码登录后要求通行密钥 MFA', + 'security.confirmEnablePasskeyMfa': '每次使用密码或目录登录后,都要求通行密钥、验证器应用或恢复代码吗?', 'security.noTotpAuthenticators': '尚未配置身份验证器应用。', 'security.addPasskey': '添加通行密钥', 'security.replaceLegacyPasskey': '替换旧版通行密钥', diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index 0b35f55..abce5ab 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -77,6 +77,7 @@ const ProfileManager = { if (button.dataset.profileAction === 'connect') this.connect(profileId); if (button.dataset.profileAction === 'favorite') this.toggleFavorite(profileId); if (button.dataset.profileAction === 'edit') this.openEditor(profileId); + if (button.dataset.profileAction === 'duplicate') this.duplicateProfile(profileId); if (button.dataset.profileAction === 'delete') this.deleteProfile(profileId); }); document.getElementById('profileManagementList')?.addEventListener('dragstart', event => { @@ -1028,6 +1029,7 @@ const ProfileManager = { [ ['connect', this.t('connection.connect', 'Connect'), 'btn-primary'], ['edit', this.t('common.edit', 'Edit'), 'btn-secondary'], + ['duplicate', this.t('profiles.duplicate', 'Duplicate'), 'btn-secondary'], ['delete', this.t('common.delete', 'Delete'), 'btn-danger'], ].forEach(([action, label, style]) => { const button = document.createElement('button'); @@ -1096,18 +1098,32 @@ const ProfileManager = { this.renderEditorCommandPreview(); }, - openEditor(profileId = null) { + duplicateProfileName(name) { + const suffix = this.t('profiles.copyNameSuffix', ' (copy)'); + const source = String(name || '').trim(); + const available = Math.max(0, 128 - suffix.length); + return `${source.slice(0, available).trimEnd()}${suffix}`.slice(0, 128); + }, + + duplicateProfile(profileId) { + this.openEditor(profileId, {duplicate: true}); + }, + + openEditor(profileId = null, options = {}) { const profile = profileId ? this.profiles.find(item => item.id === profileId) : null; if (profileId && !profile) return; - this.editingProfileId = profile?.id || null; + const duplicating = options?.duplicate === true; + this.editingProfileId = duplicating ? null : (profile?.id || null); this.renderEditorSelects(); document.getElementById('profileEditorForm')?.reset(); this.setInlineKeyPanelExpanded(false); - document.getElementById('profileEditorId').value = profile?.id || ''; - document.getElementById('profileEditorName').value = profile?.name || ''; + document.getElementById('profileEditorId').value = this.editingProfileId || ''; + document.getElementById('profileEditorName').value = duplicating + ? this.duplicateProfileName(profile?.name) + : (profile?.name || ''); document.getElementById('profileEditorGroup').value = profile?.group || ''; document.getElementById('profileEditorHost').value = profile?.host || ''; document.getElementById('profileEditorPort').value = profile?.port || 22; diff --git a/static/js/webauthn.js b/static/js/webauthn.js index 377e829..2254bcb 100644 --- a/static/js/webauthn.js +++ b/static/js/webauthn.js @@ -4,6 +4,7 @@ const root = (document.querySelector('meta[name="app-root"]')?.content || '').replace(/\/$/, ''); const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; const recoveryMode = document.body?.dataset.recoveryMode === 'true'; + let accountMfaEnabled = document.body?.dataset.accountMfaEnabled === 'true'; const t = (key, fallback) => { const translated = window.i18n && i18n.t ? i18n.t(key) : null; return translated && translated !== key ? translated : (fallback || key); @@ -319,8 +320,12 @@ const container = document.getElementById('passkeyList'); if (!container || !document.getElementById('passkeyAddBtn')) { return; } const data = await api('/api/webauthn/credentials'); + const credentials = data.credentials || []; + document.getElementById('passkeyEnableMfaBtn')?.classList.toggle( + 'hidden', accountMfaEnabled || credentials.length === 0 + ); container.replaceChildren(); - for (const credential of data.credentials || []) { + for (const credential of credentials) { const row = document.createElement('div'); row.className = 'admin-toolbar'; const label = document.createElement('span'); @@ -346,6 +351,41 @@ } } + async function enablePasskeyMfa() { + if (!window.confirm(t( + 'security.confirmEnablePasskeyMfa', + 'Require a Passkey, authenticator app, or recovery code after every password or directory sign-in?' + ))) { return; } + const headers = await stepUpHeaders('mfa.enable'); + if (headers === null) { return; } + const data = await api('/api/webauthn/mfa', { + method: 'POST', + headers, + body: {confirm_enable_mfa: true} + }); + const codes = data.recovery_codes || []; + if (codes.length) { + document.getElementById('passkeyMfaRecoveryCodes').textContent = [ + t( + 'security.storeRecoveryCodes', + 'Store these codes now. They will not be shown again.' + ), + '', + ...codes + ].join('\n'); + } + accountMfaEnabled = true; + document.body.dataset.accountMfaEnabled = 'true'; + document.getElementById('passkeyEnableMfaBtn')?.classList.add('hidden'); + const badge = document.getElementById('securityMfaStatus'); + if (badge) { + badge.classList.add('active'); + badge.dataset.i18n = 'security.mfaEnabled'; + badge.textContent = t('security.mfaEnabled', 'MFA enabled'); + } + notify(t('security.mfaEnabled', 'MFA enabled'), 'success'); + } + async function registerPasskey(legacyUpgrade) { try { if (!window.PublicKeyCredential) { @@ -553,6 +593,9 @@ document.getElementById('passkeyUpgradeBtn')?.addEventListener('click', () => { registerPasskey(true); }); + document.getElementById('passkeyEnableMfaBtn')?.addEventListener('click', () => { + enablePasskeyMfa().catch(error => notify(error.message, 'error')); + }); loadPasskeys().catch(error => notify(error.message, 'error')); document.getElementById('totpAddBtn')?.addEventListener('click', () => { beginTotpEnrollment().catch(error => notify(error.message, 'error')); diff --git a/templates/admin.html b/templates/admin.html index de2e07f..58154f0 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -11,8 +11,8 @@ Admin · Web SSH Terminal - - + + @@ -136,6 +136,12 @@

Authentication features

Global SSH host trust +

Import one independently verified OpenSSH known_hosts entry. Global trust applies to every WebSSH user; existing entries can be removed from the table below.

+
+ + + +
@@ -328,9 +334,9 @@

Final destru
- + - + diff --git a/templates/change_password.html b/templates/change_password.html index 7023172..e400165 100644 --- a/templates/change_password.html +++ b/templates/change_password.html @@ -6,7 +6,7 @@ Change password · WebSSH - + @@ -119,7 +119,7 @@

Change Password

- + diff --git a/templates/index.html b/templates/index.html index 68020b1..083f035 100644 --- a/templates/index.html +++ b/templates/index.html @@ -17,7 +17,7 @@ - + @@ -432,6 +432,23 @@

Top memory

+ + {% if webauthn_enabled %} +

A Passkey can sign in directly. To prevent password-only fallback, explicitly enable MFA after adding and testing at least one Passkey.

If a non-discoverable passkey from an older release no longer appears during username-less sign-in, use Replace legacy passkey. Test the replacement before deleting the old record.

+ {% if recovery_codes_enabled and not account_mfa_enabled %} + +

+            {% endif %}
             {% else %}
             

Passkeys are disabled by configuration.

{% endif %} @@ -217,8 +222,8 @@

Co
- + - + diff --git a/tests/js/profile-manager.test.js b/tests/js/profile-manager.test.js index 10de7ff..999acd9 100644 --- a/tests/js/profile-manager.test.js +++ b/tests/js/profile-manager.test.js @@ -332,3 +332,13 @@ test('group removal acknowledgement opens confirmation and retries explicitly', assert.equal(manager.profiles[0].group, 'Apps'); assert.equal(manager.pendingProfileMove, null); }); + +test('duplicate profile names are localized and remain within storage limit', () => { + const manager = loadProfileManager(); + manager.t = key => key === 'profiles.copyNameSuffix' ? ' (Kopie)' : key; + + assert.equal(manager.duplicateProfileName('Production'), 'Production (Kopie)'); + const bounded = manager.duplicateProfileName('x'.repeat(128)); + assert.equal(bounded.length, 128); + assert.equal(bounded.endsWith(' (Kopie)'), true); +}); diff --git a/tests/test_host_key_routes.py b/tests/test_host_key_routes.py index c69f738..719de80 100644 --- a/tests/test_host_key_routes.py +++ b/tests/test_host_key_routes.py @@ -117,6 +117,43 @@ def test_admin_can_manage_global_host_keys_without_raw_key_material(app, client) assert client.get("/admin/api/host-keys").get_json()["entries"] == [] +def test_admin_can_import_one_verified_global_host_key(app, client): + import config + + _create_user(app, "admin", is_admin=True) + _login(client, "admin") + key = paramiko.RSAKey.generate(1024) + raw_entry = f"global.example {key.get_name()} {key.get_base64()}" + headers, _verified = password_step_up_headers( + client, "host_key.global_add", "global" + ) + + response = client.post( + "/admin/api/host-keys", + json={"entry": raw_entry}, + headers=headers, + ) + + assert response.status_code == 201 + payload = response.get_json()["entry"] + assert payload["host"] == "global.example" + assert payload["scope"] == "global" + assert key.get_base64() not in response.get_data(as_text=True) + assert config.KNOWN_HOSTS_FILE.read_text(encoding="utf-8") == raw_entry + "\n" + + +def test_global_host_key_import_rejects_normal_user(app, client): + _create_user(app, "normal") + _login(client, "normal") + + response = client.post( + "/admin/api/host-keys", + json={"entry": "not a key"}, + ) + + assert response.status_code == 403 + + def test_multi_host_and_revoked_records_disclose_full_deletion_scope( app, client ): diff --git a/tests/test_host_key_store.py b/tests/test_host_key_store.py index bf143da..a82c147 100644 --- a/tests/test_host_key_store.py +++ b/tests/test_host_key_store.py @@ -798,3 +798,140 @@ def flaky_fsync(path): ("fsync", store.user_path.parent), ("write", store.user_path), ] + + +def test_add_file_entry_validates_and_appends_canonical_entry(tmp_path): + path = tmp_path / "known_hosts" + key = _key() + + entry, error = HostKeyStore.add_file_entry( + path, + _known_hosts_line( + "host.example,[host.example]:2222", key, comment="operator note" + ).rstrip("\n"), + scope="global", + owner_id=None, + lock_key="test:global-host-keys", + ) + + assert error is None + assert entry["hosts"] == [ + {"host": "host.example", "port": 22}, + {"host": "host.example", "port": 2222}, + ] + assert entry["fingerprint"].startswith("SHA256:") + assert path.read_text(encoding="utf-8") == ( + f"host.example,[host.example]:2222 {key.get_name()} " + f"{key.get_base64()}\n" + ) + + +def test_add_file_entry_rejects_duplicates_conflicts_and_multiline(tmp_path): + path = tmp_path / "known_hosts" + original = _key() + replacement = _key() + first, error = HostKeyStore.add_file_entry( + path, + _known_hosts_line("host.example", original).strip(), + scope="global", + owner_id=None, + lock_key="test:global-host-keys-conflict", + ) + assert first is not None and error is None + + duplicate, duplicate_error = HostKeyStore.add_file_entry( + path, + _known_hosts_line("host.example", original).strip(), + scope="global", + owner_id=None, + lock_key="test:global-host-keys-conflict", + ) + conflict, conflict_error = HostKeyStore.add_file_entry( + path, + _known_hosts_line("host.example", replacement).strip(), + scope="global", + owner_id=None, + lock_key="test:global-host-keys-conflict", + ) + multiline, multiline_error = HostKeyStore.add_file_entry( + path, + _known_hosts_line("second.example", original) + + _known_hosts_line("third.example", original), + scope="global", + owner_id=None, + lock_key="test:global-host-keys-conflict", + ) + + assert duplicate is None + assert duplicate_error == "Host key entry already exists" + assert conflict is None + assert "different key" in conflict_error + assert multiline is None + assert multiline_error == "Enter exactly one known_hosts entry" + assert path.read_text(encoding="utf-8").count("\n") == 1 + + +def test_add_file_entry_rejects_conflict_on_overlapping_host_token(tmp_path): + path = tmp_path / "known_hosts" + original = _key() + replacement = _key() + first, first_error = HostKeyStore.add_file_entry( + path, + _known_hosts_line( + "first.example,shared.example", original + ).strip(), + scope="global", + owner_id=None, + lock_key="test:global-host-keys-overlap", + ) + conflict, conflict_error = HostKeyStore.add_file_entry( + path, + _known_hosts_line("shared.example", replacement).strip(), + scope="global", + owner_id=None, + lock_key="test:global-host-keys-overlap", + ) + + assert first is not None and first_error is None + assert conflict is None + assert "different key" in conflict_error + assert path.read_text(encoding="utf-8").count("\n") == 1 + + +def test_add_file_entry_keeps_distinct_hashed_hosts_separate(tmp_path): + path = tmp_path / "known_hosts" + key = _key() + first_host = paramiko.HostKeys.hash_host("first.example") + second_host = paramiko.HostKeys.hash_host("second.example") + + first, first_error = HostKeyStore.add_file_entry( + path, + f"{first_host} {key.get_name()} {key.get_base64()}", + scope="global", + owner_id=None, + lock_key="test:global-hashed-hosts", + ) + second, second_error = HostKeyStore.add_file_entry( + path, + f"{second_host} {key.get_name()} {key.get_base64()}", + scope="global", + owner_id=None, + lock_key="test:global-hashed-hosts", + ) + + assert first is not None and first_error is None + assert second is not None and second_error is None + assert path.read_text(encoding="utf-8").count("\n") == 2 + + +def test_add_file_entry_rejects_invalid_unicode(tmp_path): + entry, error = HostKeyStore.add_file_entry( + tmp_path / "known_hosts", + "host.example ssh-rsa \ud800", + scope="global", + owner_id=None, + lock_key="test:global-invalid-unicode", + ) + + assert entry is None + assert error == "Invalid known_hosts entry" diff --git a/tests/test_key_management_ui.py b/tests/test_key_management_ui.py index 7bead37..ce707e3 100644 --- a/tests/test_key_management_ui.py +++ b/tests/test_key_management_ui.py @@ -101,10 +101,10 @@ def test_key_replacement_ui_is_accessible_warns_and_keeps_secrets_out_of_markup( def test_key_replacement_event_updates_ui_and_asset_version(): assert "socket.on('key_replaced'" in APP assert 'ProfileManager.upsertKeySummary(data.key)' in APP - assert "filename='js/profile-manager.js') }}?v=13" in TEMPLATE - assert "filename='js/i18n.js') }}?v=30" in TEMPLATE - assert "filename='js/app.js') }}?v=23" in TEMPLATE - assert "filename='css/style.css') }}?v=23" in TEMPLATE + assert "filename='js/profile-manager.js') }}?v=14" in TEMPLATE + assert "filename='js/i18n.js') }}?v=31" in TEMPLATE + assert "filename='js/app.js') }}?v=24" in TEMPLATE + assert "filename='css/style.css') }}?v=24" in TEMPLATE def test_socket_events_refresh_key_ui_without_resetting_profile_editor(): diff --git a/tests/test_profile_launcher_ui.py b/tests/test_profile_launcher_ui.py index 49b449a..714e40f 100644 --- a/tests/test_profile_launcher_ui.py +++ b/tests/test_profile_launcher_ui.py @@ -32,14 +32,14 @@ def test_saved_connection_context_precedes_authentication_method(): def test_merged_profile_frontend_assets_have_distinct_cache_versions(): template = read('templates/index.html') expected_versions = { - "filename='css/style.css'": '?v=23', + "filename='css/style.css'": '?v=24', "filename='css/sftp-file-manager.css'": '?v=15', - "filename='js/i18n.js'": '?v=30', + "filename='js/i18n.js'": '?v=31', "filename='js/command-workspace.js'": '?v=2', "filename='js/command-palette-utils.js'": '?v=1', "filename='js/profile-launcher-utils.js'": '?v=5', "filename='js/connection-launcher.js'": '?v=1', - "filename='js/profile-manager.js'": '?v=13', + "filename='js/profile-manager.js'": '?v=14', "filename='js/session-workspace.js'": '?v=9', "filename='js/session-manager.js'": '?v=12', "filename='js/terminal-manager.js'": '?v=10', @@ -50,7 +50,7 @@ def test_merged_profile_frontend_assets_have_distinct_cache_versions(): "filename='js/command-set-manager.js'": '?v=2', "filename='js/session-command-launcher.js'": '?v=5', "filename='js/connection-history.js'": '?v=2', - "filename='js/app.js'": '?v=23', + "filename='js/app.js'": '?v=24', } for asset, version in expected_versions.items(): asset_start = template.index(asset) @@ -150,7 +150,7 @@ def test_mobile_launcher_stacks_status_below_profile_details(): def test_profile_launcher_stylesheet_uses_current_cache_version(): template = read('templates/index.html') - assert "filename='css/style.css') }}?v=23" in template + assert "filename='css/style.css') }}?v=24" in template def test_active_session_command_launcher_is_loaded_after_command_data_managers(): diff --git a/tests/test_socket_event_contracts.py b/tests/test_socket_event_contracts.py index 9f01557..5a8af80 100644 --- a/tests/test_socket_event_contracts.py +++ b/tests/test_socket_event_contracts.py @@ -8,6 +8,7 @@ LOGIN_TEMPLATE = ROOT / 'templates' / 'login.html' BINARY_TRANSFER_CLIENT = ROOT / 'static' / 'js' / 'binary-transfer-client.js' AUTHENTICATED_TEMPLATE = ROOT / 'templates' / 'index.html' +AUTHENTICATED_APP = ROOT / 'static' / 'js' / 'app.js' SECURITY_TEMPLATE = ROOT / 'templates' / 'security.html' STALE_SERVER_EVENTS = {'detect_os', 'get_sessions'} @@ -129,3 +130,19 @@ def test_password_actions_navigate_through_security_center(): assert "APP_ROOT + '/security#password'" in app_source assert "getElementById('changePasswordBtn')" not in app_source + + +def test_ssh_authentication_banner_has_mandatory_safe_client_contract(): + handlers, emitted = _server_inventory() + template = AUTHENTICATED_TEMPLATE.read_text(encoding='utf-8') + app_source = AUTHENTICATED_APP.read_text(encoding='utf-8') + + assert 'ssh_auth_banner_decision' in handlers + assert 'ssh_auth_banner' in emitted + assert 'id="sshAuthBannerModal" role="alertdialog"' in template + assert 'id="sshAuthBannerCancel"' in template + assert 'id="sshAuthBannerContinue"' in template + assert "socket.on('ssh_auth_banner'" in app_source + assert "socket.emit('ssh_auth_banner_decision'" in app_source + assert "getElementById('sshAuthBannerText').textContent" in app_source + assert "e.target.id === 'sshAuthBannerModal'" in app_source diff --git a/tests/test_socket_session_lifecycle.py b/tests/test_socket_session_lifecycle.py index eddfd51..8ff7f32 100644 --- a/tests/test_socket_session_lifecycle.py +++ b/tests/test_socket_session_lifecycle.py @@ -1,6 +1,7 @@ import importlib import os import tempfile +import threading import time from datetime import datetime, timedelta, timezone from types import SimpleNamespace @@ -215,6 +216,74 @@ def test_connect_fails_closed_if_created_session_disappears(app, monkeypatch): socket_client.disconnect() +def test_ssh_authentication_banner_requires_same_socket_decision( + app, monkeypatch): + from app import socket_events + from app.ssh_errors import SSHConnectionError + + socket_client, _user_id = _authenticated_socket( + app, 'authentication_banner_user' + ) + decisions = [] + + def fake_create(**kwargs): + accepted = kwargs['auth_banner_decision']( + 'Authorized use only', 'target' + ) + decisions.append(accepted) + return None, SSHConnectionError( + 'SSH authentication banner was not accepted', + code='auth_banner_declined', + context='target', + ) + + monkeypatch.setattr(ssh_manager, 'create_ssh_connection', fake_create) + audit = [] + monkeypatch.setattr( + socket_events, + 'log_security_event', + lambda event, **details: audit.append((event, details)), + ) + + try: + socket_client.emit('ssh_connect', { + 'host': 'example.com', + 'port': 22, + 'username': 'alice', + 'password': 'secret', + 'client_request_id': 'banner-request', + }) + banner_events = _collect_until(socket_client, 'ssh_auth_banner') + banner = next( + event['args'][0] + for event in banner_events + if event['name'] == 'ssh_auth_banner' + ) + assert banner['banner'] == 'Authorized use only' + assert banner['host'] == 'example.com' + assert banner['context'] == 'target' + + result = socket_client.emit('ssh_auth_banner_decision', { + 'prompt_id': banner['prompt_id'], + 'accepted': False, + }, callback=True) + errors = _collect_until(socket_client, 'ssh_error') + + assert result == {'success': True} + assert decisions == [False] + assert any( + event['args'][0]['code'] == 'auth_banner_declined' + for event in errors + if event['name'] == 'ssh_error' + ) + assert audit[0][0] == 'SSH_AUTH_BANNER_DECISION' + assert audit[0][1]['result'] == 'DECLINED' + assert 'banner' not in audit[0][1] + finally: + if socket_client.is_connected(): + socket_client.disconnect() + + @pytest.mark.parametrize('auth_session_state', ('expired', 'deleted')) def test_socket_event_revalidates_server_authentication_session( app, monkeypatch, auth_session_state): diff --git a/tests/test_ssh_manager.py b/tests/test_ssh_manager.py index a1692da..ca693b5 100644 --- a/tests/test_ssh_manager.py +++ b/tests/test_ssh_manager.py @@ -31,6 +31,10 @@ def __init__(self): self.session_channels = [] self.open_timeout = None self.channel_timeout = None + self.banner = None + + def get_banner(self): + return self.banner def set_keepalive(self, seconds): self.keepalive = seconds @@ -266,6 +270,66 @@ def test_direct_password_connect_preserves_connect_contract(monkeypatch): assert shell_channel.timeout == 0.1 +def test_authentication_banner_must_be_accepted_before_shell(monkeypatch): + clients = install_ssh_clients(monkeypatch) + decisions = [] + + def decide(banner, context): + decisions.append((banner, context)) + return True + + # The client is created only when connect_target enters SSH setup, so use + # the factory's class default through a small wrapper. + original_factory = ssh_manager.paramiko.SSHClient + + def client_factory(): + client = original_factory() + client.transport.banner = ( + "Authorized use only\r\n\x00Review \u202epolicy" + ).encode("utf-8") + return client + + monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', client_factory) + + session_id, error = connect_target( + password='secret', auth_banner_decision=decide + ) + + assert error is None + assert session_id in ssh_manager.sessions + assert decisions == [( + "Authorized use only\nReview policy", + "target", + )] + assert clients[0].transport.session_channels + + +def test_declined_authentication_banner_prevents_shell_and_startup( + monkeypatch): + clients = install_ssh_clients(monkeypatch) + original_factory = ssh_manager.paramiko.SSHClient + + def client_factory(): + client = original_factory() + client.transport.banner = b"Consent required" + return client + + monkeypatch.setattr(ssh_manager.paramiko, 'SSHClient', client_factory) + + session_id, error = connect_target( + password='secret', + startup_commands='whoami', + auth_banner_decision=lambda _banner, _context: False, + ) + + assert session_id is None + assert str(error) == "SSH authentication banner was not accepted" + assert error.code == "auth_banner_declined" + assert error.context == "target" + assert clients[0].transport.session_channels == [] + assert clients[0].closed is True + + def test_direct_connection_pins_real_resolution_through_paramiko(monkeypatch): import socket import app.network_policy as network_policy diff --git a/tests/test_tailscale_ssh.py b/tests/test_tailscale_ssh.py index 5fa5284..17f68ec 100644 --- a/tests/test_tailscale_ssh.py +++ b/tests/test_tailscale_ssh.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +import threading import pytest from sqlalchemy import inspect, text @@ -308,10 +309,17 @@ def fake_get_session(session_id): monkeypatch.setattr(ssh_manager, 'get_session', fake_get_session) monkeypatch.setattr(config, 'TMUX_ENABLED', True) emitted = [] + connected_event = threading.Event() + + def record_emit(event, payload=None, **_kwargs): + emitted.append((event, payload)) + if event == 'ssh_connected': + connected_event.set() + monkeypatch.setattr( socket_events, 'emit', - lambda event, payload=None, **kwargs: emitted.append((event, payload)), + record_emit, ) with app.test_request_context('/socket.io', environ_base={'REMOTE_ADDR': '127.0.0.1'}): @@ -334,6 +342,7 @@ def fake_get_session(session_id): 'display_name': persistent['display_name'], }) + assert connected_event.wait(2) connected = next( payload for event, payload in emitted if event == 'ssh_connected' diff --git a/tests/test_webauthn_routes.py b/tests/test_webauthn_routes.py index cea2af9..cd95ab9 100644 --- a/tests/test_webauthn_routes.py +++ b/tests/test_webauthn_routes.py @@ -48,6 +48,75 @@ def test_webauthn_routes_are_hidden_when_disabled(app, client): assert response.status_code == 404 +def test_enrolled_passkey_can_be_required_for_password_sign_in( + app, client, monkeypatch +): + import config + from app.models import RecoveryCode, User, WebAuthnCredential, db + + user_id = _create_user(app) + with app.app_context(): + db.session.add(WebAuthnCredential( + user_id=user_id, + credential_id=b"mfa-passkey-id", + public_key=b"public-key", + sign_count=0, + transports="[]", + name="MFA Passkey", + )) + db.session.commit() + _login(client) + monkeypatch.setattr(config, "WEBAUTHN_ENABLED", True) + monkeypatch.setattr(config, "RECOVERY_CODES_ENABLED", True) + headers, _verified = account_password_step_up_headers( + client, "mfa.enable", user_id + ) + + response = client.post( + "/api/webauthn/mfa", + json={"confirm_enable_mfa": True}, + headers=headers, + ) + + assert response.status_code == 200 + assert response.headers["Cache-Control"] == "no-store" + assert response.headers["Pragma"] == "no-cache" + codes = response.get_json()["recovery_codes"] + assert len(codes) == 10 + with app.app_context(): + user = db.session.get(User, user_id) + assert user.mfa_enabled is True + stored_codes = RecoveryCode.query.filter_by(user_id=user_id).all() + assert len(stored_codes) == 10 + assert all( + code.encode("ascii") not in row.code_hash + for code in codes + for row in stored_codes + ) + + +def test_passkey_mfa_requires_confirmation_and_an_enrolled_passkey( + app, client, monkeypatch +): + import config + + user_id = _create_user(app) + _login(client) + monkeypatch.setattr(config, "WEBAUTHN_ENABLED", True) + monkeypatch.setattr(config, "RECOVERY_CODES_ENABLED", True) + + intent = client.post("/api/account/step-up/intents", json={ + "action": "mfa.enable", + "target": user_id, + }) + + assert intent.status_code == 400 + assert client.post( + "/api/webauthn/mfa", + json={"confirm_enable_mfa": False}, + ).status_code == 400 + + def test_registration_options_require_account_grant_and_exact_rp( app, client, monkeypatch ): diff --git a/tests/test_webssh2_shell.py b/tests/test_webssh2_shell.py index 3478540..aa32ebd 100644 --- a/tests/test_webssh2_shell.py +++ b/tests/test_webssh2_shell.py @@ -112,17 +112,17 @@ def test_every_user_facing_page_uses_current_shared_asset_versions(app, client): for path in ("/", "/security", "/admin", "/change-password"): response = client.get(path) assert response.status_code == 200 - assert b'css/style.css?v=23' in response.data + assert b'css/style.css?v=24' in response.data assert b'css/webssh-2.css?v=15' in response.data - assert b'js/i18n.js?v=30' in response.data + assert b'js/i18n.js?v=31' in response.data client.post("/logout") for path in ("/login", "/register"): response = client.get(path) assert response.status_code == 200 - assert b'css/style.css?v=23' in response.data + assert b'css/style.css?v=24' in response.data assert b'css/webssh-2.css?v=15' in response.data - assert b'js/i18n.js?v=30' in response.data + assert b'js/i18n.js?v=31' in response.data def test_authentication_pages_use_the_shared_professional_auth_shell():

HostStatusAlgorithmFingerprintAction