diff --git a/README.md b/README.md index 6e8305f..0842022 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,35 @@ Useful launcher options: STANDTERM_HOST=127.0.0.1 STANDTERM_PORT=5000 ./run.sh ``` +## Browser-managed SSH Sessions And Keys + +Quick Connect can load saved SSH profiles and the six most recent successful +SSH targets. Use **Settings > SSH Sessions** to create, update, reorder, or +delete profiles and to clear history. Profiles and history stay in the current +browser and never store passwords. + +A saved profile can explicitly generate an Ed25519 key with **Use browser key +authentication**. The private `CryptoKey` is non-extractable and stays in that +browser's IndexedDB. Copy the displayed OpenSSH public key to the remote +account's `~/.ssh/authorized_keys`, then select the exact saved profile in Quick +Connect. **Use key** remains optional, even when the profile has a key. Browser +key authentication is allowed only from loopback or an authorized HTTPS browser. + +During authentication, Python sends the SSH challenge to the initiating browser +and receives only its Ed25519 signature; the private key is never sent to the +StandTerm Python process. A changed host, port, or username disables the profile +key binding. Deleting or unlinking a keyed profile permanently deletes that +browser key. These keys are protected from export, but they are not hardware +keys: script running in the same browser origin could still request signatures. + +**Settings > General > Import & Export** transfers browser preferences, SSH +profiles and order, SSH history, and persistent UI layout in a versioned JSON +envelope containing a Base64 ZIP archive. Import merges profiles by stable ID, +appends new IDs, and deduplicates history. A local keyed profile keeps its local +host, port, and username so import cannot silently rebind its key. SSH keys, key +IDs, passwords, browser authorization identity, access tokens, and runtime +diagnostics are never included or changed by import. + ## Browser Authorization And HTTPS When StandTerm listens on a non-loopback address, HTTPS is enabled by default so diff --git a/app.py b/app.py index d17703b..bafc2ec 100644 --- a/app.py +++ b/app.py @@ -156,6 +156,9 @@ def parse_optional_seconds_env(name, default=None): SESSION_RENEW_INTERVAL_SECONDS = 5 * 60 SESSION_CLEANUP_INTERVAL_SECONDS = 60 LOCALHOST_KEY_SETUP_TTL_SECONDS = 120 +SSH_BROWSER_SIGN_TIMEOUT_SECONDS = 15 +SSH_BROWSER_SIGN_REQUEST_EVENT = 'ssh_browser_sign_request' +SSH_BROWSER_SIGN_RESPONSE_EVENT = 'ssh_browser_sign_response' MIN_TERMINAL_COLS = 2 MAX_TERMINAL_COLS = 500 MIN_TERMINAL_ROWS = 2 @@ -1865,6 +1868,183 @@ def update_headless_terminal_mirror(session_token, terminal_id, payload, cols, r pending_backend_actions = BackendActionStore(time_func=time.time) pending_localhost_key_setups = pending_backend_actions + + +class BrowserSSHSignRequestStore: + def __init__(self, timeout_seconds=SSH_BROWSER_SIGN_TIMEOUT_SECONDS): + self._requests = {} + self._lock = threading.RLock() + self._timeout_seconds = timeout_seconds + + def create(self, session_token, terminal_id, sid, browser_id, browser_key, challenge, algorithm): + if not isinstance(challenge, bytes) or not challenge or len(challenge) > 4096: + return None, 'ssh_browser_key_invalid_challenge' + now = time.time() + with self._lock: + self._trim(now) + if any(entry.get('sid') == sid for entry in self._requests.values()): + return None, 'ssh_browser_key_sign_busy' + request_id = 'sshs_' + secrets.token_urlsafe(18) + challenge_hash = hashlib.sha256(challenge).hexdigest() + payload = { + 'request_id': request_id, + 'terminal_id': terminal_id, + 'profile_id': browser_key['profile_id'], + 'key_id': browser_key['key_id'], + 'public_key_fingerprint': browser_key['fingerprint'], + 'algorithm': algorithm, + 'challenge': base64.b64encode(challenge).decode('ascii'), + 'challenge_sha256': challenge_hash, + 'expires_at': now + self._timeout_seconds, + } + self._requests[request_id] = { + 'request': payload, + 'session_token': session_token, + 'terminal_id': terminal_id, + 'sid': sid, + 'browser_id': browser_id, + 'expires_at': payload['expires_at'], + 'event': threading.Event(), + 'signature': None, + 'error_code': None, + } + return dict(payload), None + + def resolve(self, session_token, sid, data): + if not isinstance(data, dict): + return 'ssh_browser_key_sign_invalid' + request_id = data.get('request_id') + if not isinstance(request_id, str): + return 'ssh_browser_key_sign_invalid' + with self._lock: + self._trim(time.time()) + entry = self._requests.get(request_id) + if not entry: + return 'ssh_browser_key_sign_stale' + expected = entry['request'] + if entry.get('session_token') != session_token or entry.get('sid') != sid: + return 'ssh_browser_key_sign_stale' + for field_name in ('terminal_id', 'profile_id', 'key_id', 'challenge_sha256'): + actual_value = data.get(field_name) + expected_value = expected.get(field_name) + if ( + not isinstance(actual_value, str) + or not isinstance(expected_value, str) + or not secrets.compare_digest(actual_value, expected_value) + ): + return 'ssh_browser_key_sign_stale' + if entry['event'].is_set(): + return 'ssh_browser_key_sign_stale' + if data.get('status') == 'failed': + entry['error_code'] = 'ssh_browser_key_sign_failed' + entry['event'].set() + return entry['error_code'] + if data.get('status') != 'ok': + return 'ssh_browser_key_sign_invalid' + signature_text = data.get('signature') + if not isinstance(signature_text, str): + return 'ssh_browser_key_sign_invalid' + try: + signature = base64.b64decode(signature_text.encode('ascii'), validate=True) + except Exception: + return 'ssh_browser_key_sign_invalid' + if len(signature) != 64: + return 'ssh_browser_key_sign_invalid' + entry['signature'] = signature + entry['event'].set() + return None + + def wait(self, request_payload): + request_id = request_payload.get('request_id') if isinstance(request_payload, dict) else None + with self._lock: + entry = self._requests.get(request_id) + if not entry: + return None, 'ssh_browser_key_sign_stale' + wait_seconds = max(0, entry['expires_at'] - time.time()) + if not entry['event'].wait(wait_seconds): + with self._lock: + self._requests.pop(request_id, None) + return None, 'ssh_browser_key_sign_timeout' + with self._lock: + self._requests.pop(request_id, None) + if entry.get('error_code'): + return None, entry['error_code'] + if not isinstance(entry.get('signature'), bytes): + return None, 'ssh_browser_key_sign_stale' + return entry['signature'], None + + def discard(self, session_token, terminal_id=None, sid=None): + with self._lock: + for request_id, entry in list(self._requests.items()): + if ( + entry.get('session_token') == session_token + and (terminal_id is None or entry.get('terminal_id') == terminal_id) + and (sid is None or entry.get('sid') == sid) + ): + entry['error_code'] = 'ssh_browser_key_sign_stale' + entry['event'].set() + self._requests.pop(request_id, None) + + def clear(self): + with self._lock: + for entry in self._requests.values(): + entry['error_code'] = 'ssh_browser_key_sign_stale' + entry['event'].set() + self._requests.clear() + + def _trim(self, now): + for request_id, entry in list(self._requests.items()): + if entry.get('expires_at', 0) <= now: + entry['error_code'] = 'ssh_browser_key_sign_timeout' + entry['event'].set() + self._requests.pop(request_id, None) + + +browser_ssh_sign_request_store = BrowserSSHSignRequestStore() + + +def request_browser_ssh_signature(bridge, signer_sid, browser_key, challenge, algorithm): + session_token = bridge.owner_session + identity = socket_browser_identities.get(signer_sid) or {} + browser_id = identity.get('browser_id') + client_ip = socket_client_ips.get(signer_sid, 'unknown') + signer_allowed = is_local_client_ip(client_ip) or ( + HTTPS_ENABLED and socket_browser_authorized.get(signer_sid, False) + ) + if ( + socket_session_tokens.get(signer_sid) != session_token + or not isinstance(browser_id, str) + or not signer_allowed + ): + raise RuntimeError('Browser SSH signer is unavailable.') + request_payload, error_code = browser_ssh_sign_request_store.create( + session_token, + bridge.terminal_id, + signer_sid, + browser_id, + browser_key, + challenge, + algorithm, + ) + if error_code: + raise RuntimeError('Browser SSH signer is busy or unavailable.') + socketio.emit(SSH_BROWSER_SIGN_REQUEST_EVENT, request_payload, room=signer_sid) + signature, error_code = browser_ssh_sign_request_store.wait(request_payload) + current_identity = socket_browser_identities.get(signer_sid) or {} + current_client_ip = socket_client_ips.get(signer_sid, 'unknown') + signer_still_allowed = is_local_client_ip(current_client_ip) or ( + HTTPS_ENABLED and socket_browser_authorized.get(signer_sid, False) + ) + if ( + error_code + or socket_session_tokens.get(signer_sid) != session_token + or current_identity.get('browser_id') != browser_id + or not signer_still_allowed + ): + raise RuntimeError('Browser SSH signing did not complete.') + return signature + + TERMINAL_BACKEND_REGISTRY = TerminalBackendRegistry([ SSHBackendPlugin( bridge_cls=SSHBridge, @@ -1879,12 +2059,16 @@ def update_headless_terminal_mirror(session_token, terminal_id, payload, cols, r client_ip, browser_authorized=browser_authorized, ), + is_browser_key_allowed=lambda client_ip, browser_authorized=False: ( + is_local_client_ip(client_ip) or (HTTPS_ENABLED and browser_authorized) + ), allowed_action_types=ALLOWED_CONNECTION_ACTION_TYPES, backend_action_store=pending_backend_actions, bridge_kwargs={ 'get_paramiko': get_paramiko, 'ssh_term': SSH_TERM, 'local_public_key_types': LOCAL_PUBLIC_KEY_TYPES, + 'request_browser_signature': request_browser_ssh_signature, }, low_risk_settings_capability=CAPABILITY_SETTINGS_UPDATE_LOW_RISK, high_risk_settings_capability=CAPABILITY_SETTINGS_UPDATE_HIGH_RISK, @@ -1950,6 +2134,7 @@ def update_headless_terminal_mirror(session_token, terminal_id, payload, cols, r ) bridges = {} +pending_terminal_starts = {} active_sessions = {} socket_session_tokens = {} socket_client_ips = {} @@ -1961,6 +2146,7 @@ def update_headless_terminal_mirror(session_token, terminal_id, payload, cols, r agent_viewer_ids = {} agent_lock = threading.RLock() external_agent_lock = threading.RLock() +terminal_start_lock = threading.RLock() session_cleanup_task_started = False class AgentAuditStore: @@ -4730,6 +4916,30 @@ def record_agent_terminal_cleanup(session_token, terminal_id, reason): def get_bridge(session_token, terminal_id): return bridges.get(session_token, {}).get(terminal_id) +def begin_terminal_start(session_token, terminal_id): + start_token = secrets.token_urlsafe(18) + with terminal_start_lock: + pending_terminal_starts[(session_token, terminal_id)] = start_token + return start_token + +def is_current_terminal_start(session_token, terminal_id, start_token): + with terminal_start_lock: + return pending_terminal_starts.get((session_token, terminal_id)) == start_token + +def finish_terminal_start(session_token, terminal_id, start_token): + with terminal_start_lock: + key = (session_token, terminal_id) + if pending_terminal_starts.get(key) != start_token: + return False + pending_terminal_starts.pop(key, None) + return True + +def cancel_terminal_starts(session_token, terminal_id=None): + with terminal_start_lock: + for key in list(pending_terminal_starts): + if key[0] == session_token and (terminal_id is None or key[1] == terminal_id): + pending_terminal_starts.pop(key, None) + def set_bridge(session_token, terminal_id, bridge): bridges.setdefault(session_token, {})[terminal_id] = bridge @@ -4754,9 +4964,11 @@ def unregister_terminal_bridge(session_token, terminal_id, bridge): agent_headless_terminal_mirror_store.discard(session_token, terminal_id) agent_viewport_snapshot_store.discard(session_token, terminal_id=terminal_id) agent_viewport_render_request_store.discard(session_token, terminal_id=terminal_id) + browser_ssh_sign_request_store.discard(session_token, terminal_id=terminal_id) close_bridge(bridge) def close_terminal_bridge(session_token, terminal_id): + cancel_terminal_starts(session_token, terminal_id) stop_operator_observation(session_token, terminal_id, AGENT_REASON_TERMINAL_CLOSED) record_agent_terminal_cleanup(session_token, terminal_id, AGENT_REASON_TERMINAL_CLOSED) invalidate_agent_states(session_token, terminal_id=terminal_id, reason=AGENT_REASON_TERMINAL_CLOSED) @@ -4765,9 +4977,11 @@ def close_terminal_bridge(session_token, terminal_id): agent_headless_terminal_mirror_store.discard(session_token, terminal_id) agent_viewport_snapshot_store.discard(session_token, terminal_id=terminal_id) agent_viewport_render_request_store.discard(session_token, terminal_id=terminal_id) + browser_ssh_sign_request_store.discard(session_token, terminal_id=terminal_id) close_bridge(pop_bridge(session_token, terminal_id)) def close_all_terminal_bridges(session_token): + cancel_terminal_starts(session_token) for terminal_id in list(bridges.get(session_token, {})): stop_operator_observation(session_token, terminal_id, AGENT_REASON_TERMINAL_CLOSED) record_agent_terminal_cleanup(session_token, terminal_id, AGENT_REASON_TERMINAL_CLOSED) @@ -4777,6 +4991,7 @@ def close_all_terminal_bridges(session_token): agent_headless_terminal_mirror_store.discard(session_token) agent_viewport_snapshot_store.discard(session_token) agent_viewport_render_request_store.discard(session_token) + browser_ssh_sign_request_store.discard(session_token) terminals = bridges.pop(session_token, {}) for bridge in list(terminals.values()): close_bridge(bridge) @@ -7649,50 +7864,32 @@ def on_agent_viewport_render_result(data): ) -@socketio.on('start_ssh') -def on_start_ssh(data): - cleanup_expired_sessions() - session_token = socket_session_tokens.get(request.sid) - if not session_token: - return - client_ip = socket_client_ips.get(request.sid, 'unknown') - payload, validation_error = validate_start_ssh_payload( - data, - client_ip, - browser_authorized=socket_browser_authorized.get(request.sid, False), - ) - if validation_error: - if isinstance(validation_error, dict): - message = validation_error.get('message', 'Invalid connection payload.') - error_code = validation_error.get('error_code', 'invalid_start_ssh_payload') - else: - message = validation_error - error_code = 'invalid_start_ssh_payload' - terminal_id = validate_terminal_id_payload(data, default=TERMINAL_ID_MAIN) or TERMINAL_ID_MAIN - emit_connection_error(request.sid, message, error_code=error_code, terminal_id=terminal_id) - return - - pending_backend_actions.discard(request.sid) +def start_terminal_backend(sid, session_token, payload, start_token): terminal_id = payload['terminal_id'] + if not is_current_terminal_start(session_token, terminal_id, start_token): + return + pending_backend_actions.discard(sid) replacing_existing = get_bridge(session_token, terminal_id) is not None if not replacing_existing and len(bridges.get(session_token, {})) >= MAX_TERMINALS_PER_CLIENT: emit_connection_error( - request.sid, + sid, 'Terminal limit reached.', error_code='terminal_limit_reached', terminal_id=terminal_id, ) + finish_terminal_start(session_token, terminal_id, start_token) return connection_type = payload['connection_type'] plugin = TERMINAL_BACKEND_REGISTRY.get(connection_type) if not plugin: emit_connection_error( - request.sid, + sid, 'Connection type must be ssh, local_shell, or uart.', error_code='invalid_start_ssh_payload', terminal_id=terminal_id, ) + finish_terminal_start(session_token, terminal_id, start_token) return cols = 80 rows = 24 @@ -7703,20 +7900,29 @@ def on_start_ssh(data): raise TypeError('Backend did not return a terminal bridge.') if bridge.connection_type != connection_type: raise ValueError('Backend returned a bridge with a mismatched connection type.') - bridge.attach(request.sid) + if payload.get('browser_key') and isinstance(bridge, SSHBridge): + bridge.set_browser_signer_sid(sid) + bridge.attach(sid) success, result = plugin.connect_bridge(bridge, payload, cols, rows) except Exception as exc: log_message(f"[!] Backend start error for {connection_type}: {exc}") close_bridge(bridge) - emit_connection_error( - request.sid, - 'Connection failed.', - error_code='backend_start_failed', - terminal_id=terminal_id, - ) + if finish_terminal_start(session_token, terminal_id, start_token): + emit_connection_error( + sid, + 'Connection failed.', + error_code='backend_start_failed', + terminal_id=terminal_id, + ) return if success: + if ( + socket_session_tokens.get(sid) != session_token + or not finish_terminal_start(session_token, terminal_id, start_token) + ): + close_bridge(bridge) + return close_terminal_bridge(session_token, terminal_id) bridge.update_terminal_size(cols, rows) set_bridge(session_token, terminal_id, bridge) @@ -7726,10 +7932,12 @@ def on_start_ssh(data): socketio.start_background_task(target=bridge.read_loop) return - failure = plugin.build_connection_failure(request.sid, bridge, payload, result) + failure = plugin.build_connection_failure(sid, bridge, payload, result) close_bridge(bridge) + if not finish_terminal_start(session_token, terminal_id, start_token): + return emit_connection_error( - request.sid, + sid, failure['message'], error_code=failure.get('error_code'), action_type=failure.get('action_type'), @@ -7739,6 +7947,55 @@ def on_start_ssh(data): terminal_id=terminal_id, ) + +@socketio.on(SSH_BROWSER_SIGN_RESPONSE_EVENT) +def on_ssh_browser_sign_response(data): + session_token = socket_session_tokens.get(request.sid) + if not session_token: + return + error_code = browser_ssh_sign_request_store.resolve(session_token, request.sid, data) + if error_code: + socketio.emit( + 'ssh_browser_sign_status', + { + 'status': 'failed', + 'request_id': data.get('request_id') if isinstance(data, dict) else None, + 'error_code': error_code, + }, + room=request.sid, + ) + + +@socketio.on('start_ssh') +def on_start_ssh(data): + cleanup_expired_sessions() + session_token = socket_session_tokens.get(request.sid) + if not session_token: + return + client_ip = socket_client_ips.get(request.sid, 'unknown') + payload, validation_error = validate_start_ssh_payload( + data, + client_ip, + browser_authorized=socket_browser_authorized.get(request.sid, False), + ) + if validation_error: + if isinstance(validation_error, dict): + message = validation_error.get('message', 'Invalid connection payload.') + error_code = validation_error.get('error_code', 'invalid_start_ssh_payload') + else: + message = validation_error + error_code = 'invalid_start_ssh_payload' + terminal_id = validate_terminal_id_payload(data, default=TERMINAL_ID_MAIN) or TERMINAL_ID_MAIN + emit_connection_error(request.sid, message, error_code=error_code, terminal_id=terminal_id) + return + + browser_ssh_sign_request_store.discard(session_token, terminal_id=payload['terminal_id']) + start_token = begin_terminal_start(session_token, payload['terminal_id']) + if payload.get('browser_key'): + socketio.start_background_task(start_terminal_backend, request.sid, session_token, payload, start_token) + return + start_terminal_backend(request.sid, session_token, payload, start_token) + @socketio.on('setup_localhost_key_access') def on_setup_localhost_key_access(data): session_token = socket_session_tokens.get(request.sid) @@ -7879,6 +8136,7 @@ def on_disconnect(reason=None): socket_settings_admin_grant_ids.pop(request.sid, None) agent_viewer_ids.pop(request.sid, None) if session_token: + browser_ssh_sign_request_store.discard(session_token, sid=request.sid) with agent_lock: for state in [ state for state in agent_states.values() diff --git a/run.bat b/run.bat index 1db038a..e9cb6ab 100644 --- a/run.bat +++ b/run.bat @@ -168,23 +168,20 @@ if errorlevel 1 ( ) ) -call :dependencies_available -if errorlevel 1 ( - echo [*] Python dependencies are missing or unavailable; dependency check will run. - set "FORCE_RECHECK=true" -) - -if "%FORCE_RECHECK%"=="false" if exist "%INSTALLED_FLAG%" ( - call :stamp_matches - if errorlevel 1 ( - echo [*] Dependency stamp is stale; dependency check will run. - set "FORCE_RECHECK=true" - ) -) - +REM Consult the install stamp before running the dependency checker. The stamp +REM records the runtime kind, the Python version, and the requirements.txt hash, +REM so a match means this runtime already satisfies requirements.txt. The checker +REM imports paramiko and eventlet, which the server itself imports lazily or not +REM at all, so running it on every warm start costs seconds for no new information. if "%FORCE_RECHECK%"=="true" goto :install_deps if not exist "%INSTALLED_FLAG%" goto :install_deps +call :stamp_matches +if errorlevel 1 ( + echo [*] Dependency stamp is stale; dependency check will run. + goto :install_deps +) + echo [*] Skipping dependency check (valid flag exists^). echo [*] Hint: Use 'run.bat --force' to re-check. exit /b 0 diff --git a/run.sh b/run.sh index f5b6936..fa8d082 100755 --- a/run.sh +++ b/run.sh @@ -316,18 +316,21 @@ if ! python_is_usable python; then venv_activation_is_current || exit 1 fi -echo "[*] Checking Python dependencies..." -if ! verify_dependencies; then - echo "[*] Python dependencies are missing or unavailable; dependency check will run." - FORCE_RECHECK=true -fi -if [ "$FORCE_RECHECK" = false ] && [ -f "$INSTALLED_FLAG" ] && ! install_stamp_matches; then - echo "[*] Dependency stamp is stale; dependency check will run." - FORCE_RECHECK=true -fi - # 3. Check and install dependencies -if [ "$FORCE_RECHECK" = true ] || ! install_stamp_matches; then +# Consult the install stamp before importing anything. The stamp records the +# platform, the Python version, and the requirements.txt hash, so a match means +# this venv already satisfies requirements.txt. The import verification below +# loads paramiko and eventlet, which the server itself imports lazily or not at +# all, and on a /mnt or network filesystem that costs several seconds on every +# warm start. +if [ "$FORCE_RECHECK" = false ] && install_stamp_matches; then + echo "[*] Skipping dependency check (flag exists)." + echo "[*] Hint: Use './run.sh --force' or delete '$INSTALLED_FLAG' to re-check." +else + if [ "$FORCE_RECHECK" = false ] && [ -f "$INSTALLED_FLAG" ]; then + echo "[*] Dependency stamp is stale; dependency check will run." + fi + echo "[*] Checking Python dependencies..." rm -f "$INSTALLED_FLAG" ensure_venv_pip || exit 1 if [ -f "$REQ_FILE" ]; then @@ -347,9 +350,6 @@ if [ "$FORCE_RECHECK" = true ] || ! install_stamp_matches; then echo " Fix the package error above, then rerun ./run.sh --force." exit 1 fi -else - echo "[*] Skipping dependency check (flag exists)." - echo "[*] Hint: Use './run.sh --force' or delete '$INSTALLED_FLAG' to re-check." fi # 4. Start the server diff --git a/run_at_wsl+screen.bat b/run_at_wsl+screen.bat index b6d1428..ea9fd14 100644 --- a/run_at_wsl+screen.bat +++ b/run_at_wsl+screen.bat @@ -19,20 +19,16 @@ if errorlevel 1 ( exit /b 1 ) -wsl.exe --cd "%PROJECT_DIR%" bash -lc "command -v screen >/dev/null 2>&1" -if errorlevel 1 ( - echo [!] ERROR: screen is required inside WSL but was not found. - echo Install with: sudo apt install screen - pause - exit /b 1 -) - +REM The screen check runs inside the launch command below instead of in its own +REM wsl.exe round trip. Each wsl.exe invocation costs a WSL entry, and several +REM seconds when the distro is not already running, so asking one question is +REM not worth a separate trip. echo [*] Starting or attaching WSL screen session: %SCREEN_NAME% echo [*] Reattach from Windows with: echo wsl.exe screen -r standterm echo [*] Force reattach with: echo wsl.exe screen -d -r standterm -wsl.exe --cd "%PROJECT_DIR%" bash -lc "export STANDTERM_RESTART_PATH='%STANDTERM_RESTART_PATH%'; chmod +x ./run.sh; screen -wipe standterm >/dev/null 2>&1 || true; if screen -ls | grep -Eq '[[:space:]][0-9]+\.standterm[[:space:]].*\(Detached\)'; then exec screen -r standterm; fi; if screen -ls | grep -Eq '[[:space:]][0-9]+\.standterm[[:space:]].*\(Attached\)'; then exec screen -d -r standterm; fi; exec screen -S standterm bash -lc 'exec ./run.sh %*'" +wsl.exe --cd "%PROJECT_DIR%" bash -lc "command -v screen >/dev/null 2>&1 || { echo '[!] ERROR: screen is required inside WSL but was not found.'; echo ' Install with: sudo apt install screen'; exit 1; }; export STANDTERM_RESTART_PATH='%STANDTERM_RESTART_PATH%'; chmod +x ./run.sh; screen -wipe standterm >/dev/null 2>&1 || true; if screen -ls | grep -Eq '[[:space:]][0-9]+\.standterm[[:space:]].*\(Detached\)'; then exec screen -r standterm; fi; if screen -ls | grep -Eq '[[:space:]][0-9]+\.standterm[[:space:]].*\(Attached\)'; then exec screen -d -r standterm; fi; exec screen -S standterm bash -lc 'exec ./run.sh %*'" if errorlevel 1 ( pause exit /b 1 diff --git a/templates/index.html b/templates/index.html index 63d998b..8ca4043 100644 --- a/templates/index.html +++ b/templates/index.html @@ -177,6 +177,14 @@ .ssh-profile-actions button.primary { background: #0a84ff; } .ssh-profile-actions button.danger { background: #7d302b; } .ssh-profile-actions button:disabled { color: #777; cursor: default; opacity: 0.7; } + .ssh-key-controls { display: grid; gap: 8px; padding: 9px; border: 1px solid #3a3a3c; border-radius: 6px; background: #171717; } + .ssh-key-controls > label { display: flex; grid-template-columns: none; align-items: center; gap: 8px; color: #ddd; } + .ssh-key-controls input[type="checkbox"] { width: auto; } + .ssh-key-public { min-height: 62px; width: 100%; box-sizing: border-box; resize: vertical; padding: 7px 8px; background: #111; border: 1px solid #444; border-radius: 4px; color: #aaa; font: 11px/1.4 monospace; } + .ssh-key-public-actions { display: flex; gap: 8px; align-items: center; } + .ssh-key-public-actions button { padding: 6px 9px; border: none; border-radius: 4px; background: #3a3a3c; color: #fff; cursor: pointer; } + .settings-transfer-actions { display: flex; gap: 8px; flex-wrap: wrap; } + .settings-transfer-actions button { padding: 7px 10px; border: none; border-radius: 4px; background: #3a3a3c; color: #fff; cursor: pointer; } .diagnostics-actions { display: flex; justify-content: flex-end; gap: 8px; margin-bottom: 10px; } .diagnostics-actions button { padding: 6px 12px; background: #3a3a3c; border: none; border-radius: 4px; color: #fff; cursor: pointer; } #connection-diagnostics-log { @@ -201,6 +209,7 @@ color: #ffd7a8; font-size: 13px; line-height: 1.45; } #server-availability-message strong { display: block; margin-bottom: 4px; color: #ff9f0a; } + #server-retry-now { margin-top: 9px; padding: 6px 10px; border: none; border-radius: 4px; background: #b56a00; color: #fff; cursor: pointer; } .mode-selector { display: flex; border: 1px solid #555; border-radius: 6px; overflow: hidden; margin-bottom: 15px; background: #161616; } .mode-selector label { flex: 1; padding: 9px 8px; text-align: center; cursor: pointer; font-size: 13px; color: #aaa; border-right: 1px solid #444; user-select: none; } .mode-selector label:last-child { border-right: none; } @@ -645,6 +654,15 @@

Settings

+
+ Import & Export +
Exports include browser preferences, SSH profiles, and SSH history. SSH keys are never included.
+
+ + + +
+
@@ -695,6 +713,14 @@

Settings

+
+ +
No browser key is linked.
+ + +
Create always saves a new profile. Save updates only the loaded profile.
@@ -794,7 +820,8 @@

StandTerm

Server not available - StandTerm is not running or cannot be reached. This page will keep checking and reconnect automatically. If the server restarted, you may be asked for the current access token. +
StandTerm is not running or cannot be reached. This page will keep checking and reconnect automatically. If the server restarted, you may be asked for the current access token.
+
@@ -837,6 +864,7 @@

StandTerm

+
@@ -1282,9 +1310,11 @@

Access token required

const SSH_SESSIONS_DB = 'standterm-ssh-sessions-v1'; const SSH_SESSIONS_STORE = 'state'; + const SSH_KEYS_STORE = 'keys'; const SSH_SESSIONS_KEY = 'current'; const SSH_HISTORY_LIMIT = 6; const SSH_PROFILE_NAME_MAX_LENGTH = 64; + const SSH_KEY_ALGORITHM = 'Ed25519'; function createSshSessionId(prefix) { if (window.crypto && typeof crypto.randomUUID === 'function') { @@ -1360,11 +1390,14 @@

Access token required

function openSshSessionsDb() { return new Promise((resolve, reject) => { - const request = indexedDB.open(SSH_SESSIONS_DB, 1); + const request = indexedDB.open(SSH_SESSIONS_DB, 2); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(SSH_SESSIONS_STORE)) { request.result.createObjectStore(SSH_SESSIONS_STORE); } + if (!request.result.objectStoreNames.contains(SSH_KEYS_STORE)) { + request.result.createObjectStore(SSH_KEYS_STORE, { keyPath: 'keyId' }); + } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); @@ -1404,6 +1437,137 @@

Access token required

}); } + async function saveSshSessionStateWithKeyChanges(value, keyChanges = []) { + const normalized = normalizeSshSessionState(value); + const db = await openSshSessionsDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction([SSH_SESSIONS_STORE, SSH_KEYS_STORE], 'readwrite'); + tx.objectStore(SSH_SESSIONS_STORE).put(normalized, SSH_SESSIONS_KEY); + const keyStore = tx.objectStore(SSH_KEYS_STORE); + keyChanges.forEach(change => { + if (change && change.type === 'put' && change.record) keyStore.put(change.record); + if (change && change.type === 'delete' && change.keyId) keyStore.delete(change.keyId); + }); + tx.oncomplete = () => { + db.close(); + resolve(normalized); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + tx.onabort = () => { + db.close(); + reject(tx.error || new Error('SSH key storage was aborted.')); + }; + }); + } + + async function loadSshKeyRecord(keyId) { + if (!keyId) return null; + const db = await openSshSessionsDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(SSH_KEYS_STORE, 'readonly'); + const request = tx.objectStore(SSH_KEYS_STORE).get(keyId); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); + } + + function base64ToUint8Array(value) { + const binary = atob(String(value || '')); + return Uint8Array.from(binary, char => char.charCodeAt(0)); + } + + function buildSshEd25519PublicBlob(rawPublicKey) { + const keyType = new TextEncoder().encode('ssh-ed25519'); + const raw = rawPublicKey instanceof Uint8Array ? rawPublicKey : new Uint8Array(rawPublicKey); + const blob = new Uint8Array(4 + keyType.length + 4 + raw.length); + const view = new DataView(blob.buffer); + view.setUint32(0, keyType.length); + blob.set(keyType, 4); + view.setUint32(4 + keyType.length, raw.length); + blob.set(raw, 4 + keyType.length + 4); + return blob; + } + + async function getSshPublicKeyFingerprint(publicKeyBlob) { + const digest = await crypto.subtle.digest('SHA-256', publicKeyBlob); + return `SHA256:${arrayBufferToBase64(digest).replace(/=+$/, '')}`; + } + + async function createBrowserSshKeyRecord(ownerProfileId) { + if (!window.isSecureContext || !window.crypto || !crypto.subtle) { + throw new Error('Browser SSH keys require a secure browser context.'); + } + let keyPair; + try { + keyPair = await crypto.subtle.generateKey({ name: SSH_KEY_ALGORITHM }, false, ['sign', 'verify']); + } catch (err) { + throw new Error('This browser does not support non-extractable Ed25519 SSH keys.'); + } + if (!keyPair.privateKey || keyPair.privateKey.extractable) { + throw new Error('The browser did not create a non-extractable SSH private key.'); + } + const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey)); + if (publicRaw.byteLength !== 32) throw new Error('The browser returned an invalid Ed25519 public key.'); + const probe = crypto.getRandomValues(new Uint8Array(32)); + const signature = await crypto.subtle.sign({ name: SSH_KEY_ALGORITHM }, keyPair.privateKey, probe); + if (!(await crypto.subtle.verify({ name: SSH_KEY_ALGORITHM }, keyPair.publicKey, signature, probe))) { + throw new Error('The browser could not verify the generated SSH key.'); + } + const publicKeyRawB64 = arrayBufferToBase64(publicRaw); + const publicKeyBlob = buildSshEd25519PublicBlob(publicRaw); + return { + version: 1, + keyId: createSshSessionId('sshkey'), + ownerProfileId, + targetKey: null, + algorithm: SSH_KEY_ALGORITHM, + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicKeyRawB64, + publicKeyOpenSsh: `ssh-ed25519 ${arrayBufferToBase64(publicKeyBlob)}`, + fingerprint: await getSshPublicKeyFingerprint(publicKeyBlob), + publicKeyFingerprintHex: await arrayBufferToHex(publicRaw), + createdAt: new Date().toISOString() + }; + } + + async function validateBrowserSshKeyRecord(record, profile) { + if ( + !record || !profile + || record.keyId !== profile.keyId + || record.ownerProfileId !== profile.id + || record.targetKey !== getSshTargetKey(profile) + ) { + throw new Error('The browser SSH key is missing or is linked to another profile.'); + } + if (record.algorithm !== SSH_KEY_ALGORITHM || !record.privateKey || !record.publicKey) { + throw new Error('The browser SSH key record is invalid.'); + } + if (record.privateKey.type !== 'private' || record.privateKey.extractable) { + throw new Error('The browser SSH private key is not non-extractable.'); + } + const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', record.publicKey)); + const publicKeyRawB64 = arrayBufferToBase64(publicRaw); + const publicKeyBlob = buildSshEd25519PublicBlob(publicRaw); + const publicKeyOpenSsh = `ssh-ed25519 ${arrayBufferToBase64(publicKeyBlob)}`; + const fingerprint = await getSshPublicKeyFingerprint(publicKeyBlob); + const publicKeyFingerprintHex = await arrayBufferToHex(publicRaw); + if ( + publicRaw.byteLength !== 32 + || publicKeyRawB64 !== record.publicKeyRawB64 + || publicKeyOpenSsh !== record.publicKeyOpenSsh + || fingerprint !== record.fingerprint + || publicKeyFingerprintHex !== record.publicKeyFingerprintHex + ) { + throw new Error('The browser SSH key record failed integrity validation.'); + } + return record; + } + async function buildBrowserIdentityFromKeys(privateKey, publicKey) { const publicKeyBuffer = await crypto.subtle.exportKey('spki', publicKey); return { @@ -1528,6 +1692,8 @@

Access token required

const sshPasswordInput = document.getElementById('password'); const sshSaveHistoryInput = document.getElementById('ssh-save-history'); const sshSaveSessionInput = document.getElementById('ssh-save-session'); + const sshUseBrowserKeyLabel = document.getElementById('ssh-use-browser-key-label'); + const sshUseBrowserKeyInput = document.getElementById('ssh-use-browser-key'); const sshProfileIndicator = document.getElementById('ssh-profile-indicator'); const sshSessionPickerToggle = document.getElementById('ssh-session-picker-toggle'); const sshSessionPickerPanel = document.getElementById('ssh-session-picker-panel'); @@ -1538,6 +1704,11 @@

Access token required

const sshProfileHostInput = document.getElementById('ssh-profile-host'); const sshProfilePortInput = document.getElementById('ssh-profile-port'); const sshProfileUsernameInput = document.getElementById('ssh-profile-username'); + const sshProfileKeyEnabledInput = document.getElementById('ssh-profile-key-enabled'); + const sshProfileKeyStatus = document.getElementById('ssh-profile-key-status'); + const sshProfileKeyPublic = document.getElementById('ssh-profile-key-public'); + const sshProfileKeyActions = document.getElementById('ssh-profile-key-actions'); + const sshProfileKeyCopyBtn = document.getElementById('ssh-profile-key-copy'); const sshProfileCreateBtn = document.getElementById('ssh-profile-create'); const sshProfileSaveBtn = document.getElementById('ssh-profile-save'); const sshProfileDeleteBtn = document.getElementById('ssh-profile-delete'); @@ -1635,6 +1806,11 @@

Access token required

const connectionDiagnosticsCopyBtn = document.getElementById('connection-diagnostics-copy'); const connectionDiagnosticsClearBtn = document.getElementById('connection-diagnostics-clear'); const serverAvailabilityMessage = document.getElementById('server-availability-message'); + const serverRetryNowBtn = document.getElementById('server-retry-now'); + const settingsTransferStatus = document.getElementById('settings-transfer-status'); + const settingsExportBtn = document.getElementById('settings-export'); + const settingsImportBtn = document.getElementById('settings-import'); + const settingsImportFile = document.getElementById('settings-import-file'); const debugEnabled = (new URLSearchParams(window.location.search)).get('debug') === '1'; const CONNECTION_DIAGNOSTICS_STORAGE_KEY = 'standterm-connection-diagnostics-v1'; const CONNECTION_DIAGNOSTICS_LIMIT = 100; @@ -1666,6 +1842,10 @@

Access token required

let sshSessionWriteQueue = Promise.resolve(); let selectedSshPickerEntry = null; let editingSshProfileId = null; + let editingSshKeyRecord = null; + let quickConnectSshKeyRecord = null; + let quickConnectKeyLoadVersion = 0; + const handledSshSignRequestIds = new Set(); const UART_MANUAL_PORT_VALUE = '__manual__'; const UART_PORT_REFRESH_MIN_INTERVAL_MS = 3000; let lastUartPortRefreshAt = 0; @@ -3413,6 +3593,11 @@

Access token required

return sshSessionState.profiles.find(profile => getSshTargetKey(profile) === targetKey) || null; } + function isBrowserSshKeyAllowedByPolicy() { + const option = terminalPolicy.connection_options.find(item => item.connection_type === 'ssh'); + return !!(option && option.allowed && option.browser_key_allowed); + } + function closeSshSessionPicker() { sshSessionPickerPanel.classList.remove('open'); sshSessionPickerToggle.setAttribute('aria-expanded', 'false'); @@ -3422,6 +3607,7 @@

Access token required

if (!selectedSshPickerEntry || selectedSshPickerEntry.type !== 'profile') { sshProfileIndicator.style.display = 'none'; sshProfileIndicator.innerText = ''; + refreshQuickConnectSshKey(false); return; } const profile = findSshProfile(selectedSshPickerEntry.id); @@ -3429,6 +3615,7 @@

Access token required

selectedSshPickerEntry = null; sshProfileIndicator.style.display = 'none'; sshProfileIndicator.innerText = ''; + refreshQuickConnectSshKey(false); return; } const modified = getSshTargetKey(profile) !== getSshTargetKey(getCurrentSshTarget()); @@ -3436,6 +3623,44 @@

Access token required

? `Based on: ${profile.name} (modified)` : `Profile: ${profile.name}`; sshProfileIndicator.style.display = 'block'; + refreshQuickConnectSshKey(false); + } + + function applyQuickConnectKeySelection() { + const useKey = !!(quickConnectSshKeyRecord && sshUseBrowserKeyInput.checked); + sshPasswordInput.disabled = useKey; + if (useKey) sshPasswordInput.value = ''; + } + + async function refreshQuickConnectSshKey(preferKey) { + const version = ++quickConnectKeyLoadVersion; + const profile = selectedSshPickerEntry && selectedSshPickerEntry.type === 'profile' + ? findSshProfile(selectedSshPickerEntry.id) + : null; + const exactMatch = profile && getSshTargetKey(profile) === getSshTargetKey(getCurrentSshTarget()); + if (!isBrowserSshKeyAllowedByPolicy() || !exactMatch || !profile.keyId) { + quickConnectSshKeyRecord = null; + sshUseBrowserKeyInput.checked = false; + sshUseBrowserKeyLabel.hidden = true; + applyQuickConnectKeySelection(); + return; + } + try { + const record = await validateBrowserSshKeyRecord(await loadSshKeyRecord(profile.keyId), profile); + if (version !== quickConnectKeyLoadVersion) return; + const changedKey = !quickConnectSshKeyRecord || quickConnectSshKeyRecord.keyId !== record.keyId; + quickConnectSshKeyRecord = record; + sshUseBrowserKeyLabel.hidden = false; + if (preferKey || changedKey) sshUseBrowserKeyInput.checked = true; + applyQuickConnectKeySelection(); + } catch (err) { + if (version !== quickConnectKeyLoadVersion) return; + quickConnectSshKeyRecord = null; + sshUseBrowserKeyInput.checked = false; + sshUseBrowserKeyLabel.hidden = true; + applyQuickConnectKeySelection(); + setSshSessionMessage(err.message || 'The browser SSH key is unavailable.', true); + } } function applySshPickerEntry(entryType, entryId) { @@ -3451,6 +3676,7 @@

Access token required

}); clearConnectionFieldEdited('ssh', 'password'); updateSshProfileIndicator(); + refreshQuickConnectSshKey(entryType === 'profile'); setSshSessionMessage(''); closeSshSessionPicker(); } @@ -3493,6 +3719,7 @@

Access token required

function clearSshProfileEditor(useQuickConnectValues = false) { editingSshProfileId = null; + editingSshKeyRecord = null; const quickTarget = useQuickConnectValues ? getCurrentSshTarget() : { host: '', port: '22', username: '' }; sshProfileNameInput.value = useQuickConnectValues && quickTarget.host ? normalizeSshProfileName(`${quickTarget.username}@${quickTarget.host}`) @@ -3500,21 +3727,76 @@

Access token required

sshProfileHostInput.value = quickTarget.host; sshProfilePortInput.value = quickTarget.port; sshProfileUsernameInput.value = quickTarget.username; + sshProfileKeyEnabledInput.checked = false; + renderSshProfileKeyEditor(); renderSshProfileManager(); } - function loadSshProfileEditor(profileId) { + async function loadSshProfileEditor(profileId) { const profile = findSshProfile(profileId); if (!profile) return; editingSshProfileId = profile.id; + editingSshKeyRecord = null; sshProfileNameInput.value = profile.name; sshProfileHostInput.value = profile.host; sshProfilePortInput.value = profile.port; sshProfileUsernameInput.value = profile.username; setSshProfileStatus(`Loaded ${profile.name}. Save will update this entry; Create will make a copy.`); + if (profile.keyId) { + try { + editingSshKeyRecord = await validateBrowserSshKeyRecord(await loadSshKeyRecord(profile.keyId), profile); + sshProfileKeyEnabledInput.checked = true; + } catch (err) { + sshProfileKeyEnabledInput.checked = false; + setSshProfileStatus(err.message || 'The linked browser SSH key is unavailable.', true); + } + } else { + sshProfileKeyEnabledInput.checked = false; + } + renderSshProfileKeyEditor(); renderSshProfileManager(); } + function renderSshProfileKeyEditor() { + const enabled = sshProfileKeyEnabledInput.checked; + const record = editingSshKeyRecord; + sshProfileKeyPublic.hidden = !record; + sshProfileKeyActions.hidden = !record; + sshProfileKeyPublic.value = record ? record.publicKeyOpenSsh : ''; + if (record && enabled) { + sshProfileKeyStatus.innerText = `${record.fingerprint} ยท private key stays non-extractable in this browser`; + } else if (record) { + sshProfileKeyStatus.innerText = 'This key will be removed only when the loaded profile is saved.'; + } else if (enabled) { + sshProfileKeyStatus.innerText = 'Generating a non-extractable Ed25519 key...'; + } else { + sshProfileKeyStatus.innerText = 'No browser key is linked.'; + } + } + + async function toggleSshProfileKey() { + if (!sshProfileKeyEnabledInput.checked) { + renderSshProfileKeyEditor(); + return; + } + if (!editingSshKeyRecord) { + if (!isBrowserSshKeyAllowedByPolicy()) { + sshProfileKeyEnabledInput.checked = false; + renderSshProfileKeyEditor(); + throw new Error('Browser SSH keys are not allowed for this browser connection.'); + } + renderSshProfileKeyEditor(); + try { + editingSshKeyRecord = await createBrowserSshKeyRecord(editingSshProfileId || ''); + } catch (err) { + sshProfileKeyEnabledInput.checked = false; + renderSshProfileKeyEditor(); + throw err; + } + } + renderSshProfileKeyEditor(); + } + function renderSshProfileManager() { sshProfileList.replaceChildren(); if (!sshSessionState.profiles.length) { @@ -3535,7 +3817,10 @@

Access token required

target.className = 'ssh-profile-list-target'; target.innerText = `${profile.username}@${profile.host}:${profile.port}`; button.append(name, target); - button.onclick = () => loadSshProfileEditor(profile.id); + button.onclick = () => { + loadSshProfileEditor(profile.id) + .catch(err => setSshProfileStatus(err.message || 'Profile could not be loaded.', true)); + }; sshProfileList.appendChild(button); }); } @@ -3568,6 +3853,21 @@

Access token required

return operation; } + function updateSshSessionStateWithKeyChanges(mutator, keyChanges) { + const operation = sshSessionWriteQueue + .catch(() => {}) + .then(() => sshSessionReady) + .then(async () => { + const nextState = normalizeSshSessionState(sshSessionState); + const result = mutator(nextState); + sshSessionState = await saveSshSessionStateWithKeyChanges(nextState, keyChanges); + renderSshSessionState(); + return result; + }); + sshSessionWriteQueue = operation; + return operation; + } + function initializeSshSessions() { sshSessionReady = loadSshSessionState() .then(state => { @@ -3658,17 +3958,33 @@

Access token required

const editorValue = validateSshProfileEditor(); if (!editorValue) return; const profileId = createSshSessionId('profile'); - await updateSshSessionState(nextState => { + const skippedLoadedKey = !!(editingSshKeyRecord && editingSshProfileId); + const mayAttachDraftKey = editingSshKeyRecord + && sshProfileKeyEnabledInput.checked + && !editingSshProfileId; + const keyRecord = mayAttachDraftKey + ? { ...editingSshKeyRecord, ownerProfileId: profileId, targetKey: getSshTargetKey(editorValue) } + : null; + await updateSshSessionStateWithKeyChanges(nextState => { nextState.profiles.push({ id: profileId, sortOrder: nextState.profiles.length, ...editorValue, - keyId: null + keyId: keyRecord ? keyRecord.keyId : null }); - }); + }, keyRecord ? [{ type: 'put', record: keyRecord }] : []); editingSshProfileId = profileId; + editingSshKeyRecord = keyRecord; + sshProfileKeyEnabledInput.checked = !!keyRecord; + renderSshProfileKeyEditor(); renderSshSessionState(); - setSshProfileStatus(`Created ${editorValue.name}.`); + setSshProfileStatus( + mayAttachDraftKey + ? `Created ${editorValue.name} with a browser SSH key.` + : skippedLoadedKey + ? `Created ${editorValue.name}. Browser keys from loaded profiles are not copied.` + : `Created ${editorValue.name}.` + ); } async function saveSshProfileEditor() { @@ -3679,20 +3995,53 @@

Access token required

} const editorValue = validateSshProfileEditor(); if (!editorValue) return; - await updateSshSessionState(nextState => { + const wantsKey = sshProfileKeyEnabledInput.checked; + if (wantsKey && !editingSshKeyRecord) { + setSshProfileStatus('Generate the browser SSH key before saving.', true); + return; + } + if (!wantsKey && existingProfile.keyId && !window.confirm( + `Remove the browser SSH key from ${existingProfile.name}? The private key cannot be recovered.` + )) return; + const keyRecord = wantsKey + ? { + ...editingSshKeyRecord, + ownerProfileId: existingProfile.id, + targetKey: getSshTargetKey(editorValue) + } + : null; + const keyChanges = []; + if (keyRecord) keyChanges.push({ type: 'put', record: keyRecord }); + if (existingProfile.keyId && (!keyRecord || existingProfile.keyId !== keyRecord.keyId)) { + const oldKeyRecord = await loadSshKeyRecord(existingProfile.keyId); + if (oldKeyRecord && oldKeyRecord.ownerProfileId === existingProfile.id) { + keyChanges.push({ type: 'delete', keyId: existingProfile.keyId }); + } + } + await updateSshSessionStateWithKeyChanges(nextState => { const existing = nextState.profiles.find(profile => profile.id === existingProfile.id); - if (existing) Object.assign(existing, editorValue); - }); + if (existing) Object.assign(existing, editorValue, { keyId: keyRecord ? keyRecord.keyId : null }); + }, keyChanges); + editingSshKeyRecord = keyRecord; + renderSshProfileKeyEditor(); renderSshSessionState(); setSshProfileStatus(`Saved ${editorValue.name}.`); } async function deleteEditingSshProfile() { const profile = findSshProfile(editingSshProfileId); - if (!profile || !window.confirm(`Delete SSH profile ${profile.name}?`)) return; - await updateSshSessionState(nextState => { + if (!profile) return; + const prompt = profile.keyId + ? `Delete SSH profile ${profile.name} and its non-recoverable browser private key?` + : `Delete SSH profile ${profile.name}?`; + if (!window.confirm(prompt)) return; + const keyRecord = profile.keyId ? await loadSshKeyRecord(profile.keyId) : null; + const keyChanges = keyRecord && keyRecord.ownerProfileId === profile.id + ? [{ type: 'delete', keyId: profile.keyId }] + : []; + await updateSshSessionStateWithKeyChanges(nextState => { nextState.profiles = nextState.profiles.filter(item => item.id !== profile.id); - }); + }, keyChanges); if (selectedSshPickerEntry && selectedSshPickerEntry.type === 'profile' && selectedSshPickerEntry.id === profile.id) { selectedSshPickerEntry = null; } @@ -3896,6 +4245,7 @@

Access token required

? userSelectedConnectionType : (forcedConnectionType || defaultConnectionType); setConnectionType(nextConnection); + refreshQuickConnectSshKey(false); updateDebugHud( 'policy.apply', `default=${defaultConnectionType} next=${nextConnection} local_allowed=${!!nextLocalShellOption.allowed} user_selected=${userSelectedConnectionType || ''}` @@ -4124,6 +4474,270 @@

Access token required

URL.revokeObjectURL(url); } + function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; + } + + function createStoredZip(filename, contentBytes) { + const nameBytes = new TextEncoder().encode(filename); + const localSize = 30 + nameBytes.length + contentBytes.length; + const centralSize = 46 + nameBytes.length; + const bytes = new Uint8Array(localSize + centralSize + 22); + const view = new DataView(bytes.buffer); + const checksum = crc32(contentBytes); + let offset = 0; + view.setUint32(offset, 0x04034b50, true); offset += 4; + view.setUint16(offset, 20, true); offset += 2; + view.setUint16(offset, 0x0800, true); offset += 2; + view.setUint16(offset, 0, true); offset += 2; + view.setUint32(offset, 0, true); offset += 4; + view.setUint32(offset, checksum, true); offset += 4; + view.setUint32(offset, contentBytes.length, true); offset += 4; + view.setUint32(offset, contentBytes.length, true); offset += 4; + view.setUint16(offset, nameBytes.length, true); offset += 2; + view.setUint16(offset, 0, true); offset += 2; + bytes.set(nameBytes, offset); offset += nameBytes.length; + bytes.set(contentBytes, offset); offset += contentBytes.length; + const centralOffset = offset; + view.setUint32(offset, 0x02014b50, true); offset += 4; + view.setUint16(offset, 20, true); offset += 2; + view.setUint16(offset, 20, true); offset += 2; + view.setUint16(offset, 0x0800, true); offset += 2; + view.setUint16(offset, 0, true); offset += 2; + view.setUint32(offset, 0, true); offset += 4; + view.setUint32(offset, checksum, true); offset += 4; + view.setUint32(offset, contentBytes.length, true); offset += 4; + view.setUint32(offset, contentBytes.length, true); offset += 4; + view.setUint16(offset, nameBytes.length, true); offset += 2; + view.setUint16(offset, 0, true); offset += 2; + view.setUint16(offset, 0, true); offset += 2; + view.setUint16(offset, 0, true); offset += 2; + view.setUint16(offset, 0, true); offset += 2; + view.setUint32(offset, 0, true); offset += 4; + view.setUint32(offset, 0, true); offset += 4; + bytes.set(nameBytes, offset); offset += nameBytes.length; + view.setUint32(offset, 0x06054b50, true); offset += 4; + view.setUint16(offset, 0, true); offset += 2; + view.setUint16(offset, 0, true); offset += 2; + view.setUint16(offset, 1, true); offset += 2; + view.setUint16(offset, 1, true); offset += 2; + view.setUint32(offset, centralSize, true); offset += 4; + view.setUint32(offset, centralOffset, true); offset += 4; + view.setUint16(offset, 0, true); + return bytes; + } + + function readStoredSettingsZip(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength < 22 || bytes.byteLength > 524288) { + throw new Error('Settings archive is too large.'); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let eocdOffset = -1; + for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset -= 1) { + if (view.getUint32(offset, true) === 0x06054b50) { eocdOffset = offset; break; } + } + if (eocdOffset < 0 || view.getUint16(eocdOffset + 10, true) !== 1) { + throw new Error('Settings archive must contain exactly one file.'); + } + const centralOffset = view.getUint32(eocdOffset + 16, true); + if (centralOffset + 46 > bytes.length || view.getUint32(centralOffset, true) !== 0x02014b50) { + throw new Error('Settings archive directory is invalid.'); + } + const method = view.getUint16(centralOffset + 10, true); + const checksum = view.getUint32(centralOffset + 16, true); + const compressedSize = view.getUint32(centralOffset + 20, true); + const contentSize = view.getUint32(centralOffset + 24, true); + const nameLength = view.getUint16(centralOffset + 28, true); + if (centralOffset + 46 + nameLength > eocdOffset) throw new Error('Settings archive directory is truncated.'); + const name = new TextDecoder().decode(bytes.slice(centralOffset + 46, centralOffset + 46 + nameLength)); + if (method !== 0 || compressedSize !== contentSize || contentSize > 262144 || name !== 'standterm-settings.json') { + throw new Error('Settings archive uses an unsupported format.'); + } + if ( + view.getUint32(0, true) !== 0x04034b50 + || view.getUint16(8, true) !== 0 + || view.getUint32(14, true) !== checksum + || view.getUint32(18, true) !== contentSize + || view.getUint32(22, true) !== contentSize + ) throw new Error('Settings archive entry is invalid.'); + const localNameLength = view.getUint16(26, true); + const localExtraLength = view.getUint16(28, true); + const contentOffset = 30 + localNameLength + localExtraLength; + if (contentOffset + contentSize > bytes.length) throw new Error('Settings archive is truncated.'); + const localName = new TextDecoder().decode(bytes.slice(30, 30 + localNameLength)); + if (localName !== name) throw new Error('Settings archive entry name is invalid.'); + const content = bytes.slice(contentOffset, contentOffset + contentSize); + if (crc32(content) !== checksum) throw new Error('Settings archive checksum failed.'); + return content; + } + + function getExportablePreferences() { + return Object.fromEntries(Object.keys(PREF_DEFAULTS).map(key => [key, prefs[key]])); + } + + function buildBrowserSettingsPayload() { + return { + format: 'standterm-browser-settings', + version: 1, + exportedAt: new Date().toISOString(), + preferences: getExportablePreferences(), + ui: { + agentPanelPosition: loadAgentPanelPosition() + }, + ssh: { + profiles: sshSessionState.profiles.map(({ keyId, ...profile }) => ({ ...profile })), + history: sshSessionState.history.map(entry => ({ ...entry })) + } + }; + } + + async function createBrowserSettingsEnvelope() { + await sshSessionReady; + await sshSessionWriteQueue.catch(() => {}); + const innerBytes = new TextEncoder().encode(JSON.stringify(buildBrowserSettingsPayload(), null, 2)); + const zipBytes = createStoredZip('standterm-settings.json', innerBytes); + return { + format: 'standterm-settings-envelope', + version: 1, + contentType: 'application/zip', + encoding: 'base64', + content: arrayBufferToBase64(zipBytes) + }; + } + + async function exportBrowserSettings() { + const envelope = await createBrowserSettingsEnvelope(); + const date = new Date().toISOString().slice(0, 10).replaceAll('-', ''); + downloadTextFile(`standterm-settings-${date}.json`, JSON.stringify(envelope, null, 2)); + settingsTransferStatus.textContent = 'Settings exported. SSH keys were not included.'; + return envelope; + } + + function normalizeImportedPreferences(value) { + const source = value && typeof value === 'object' ? value : {}; + const result = {}; + Object.entries(PREF_DEFAULTS).forEach(([key, defaultValue]) => { + if (typeof source[key] === typeof defaultValue) result[key] = source[key]; + }); + if (result.urlClickAction && !['overlay', 'popup', 'newtab'].includes(result.urlClickAction)) delete result.urlClickAction; + if (result.colorScheme && !SCHEMES[result.colorScheme]) delete result.colorScheme; + if (result.fontFace) result.fontFace = result.fontFace.slice(0, 240); + if ('fontSize' in result) result.fontSize = normalizeFontSize(result.fontSize); + if ('fontWeight' in result) result.fontWeight = normalizeFontWeight(result.fontWeight); + if ('cursorStyle' in result) result.cursorStyle = normalizeCursorStyle(result.cursorStyle); + return result; + } + + function normalizeImportedSshState(value) { + const source = value && typeof value === 'object' ? value : {}; + const validId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value); + const profiles = (Array.isArray(source.profiles) ? source.profiles : []) + .filter(profile => profile && validId(profile.id)) + .map((profile, index) => ({ + id: profile.id, + sortOrder: index, + name: normalizeSshProfileName(profile.name), + ...normalizeSshTarget(profile), + keyId: null + })) + .filter(profile => profile.name && profile.host && profile.host.length <= 255 && profile.username && profile.username.length <= 128); + const history = (Array.isArray(source.history) ? source.history : []) + .filter(entry => entry && validId(entry.id)) + .map(entry => ({ + id: entry.id, + ...normalizeSshTarget(entry), + lastUsedAt: typeof entry.lastUsedAt === 'string' ? entry.lastUsedAt : new Date(0).toISOString() + })) + .filter(entry => entry.host && entry.host.length <= 255 && entry.username && entry.username.length <= 128); + return normalizeSshSessionState({ profiles, history }); + } + + function normalizeImportedUiSettings(value) { + const source = value && typeof value === 'object' ? value : {}; + if (!Object.prototype.hasOwnProperty.call(source, 'agentPanelPosition')) return {}; + const position = source.agentPanelPosition; + if (position === null) return { agentPanelPosition: null }; + if ( + !position || !Number.isFinite(position.left) || !Number.isFinite(position.top) + || Math.abs(position.left) > 100000 || Math.abs(position.top) > 100000 + ) return {}; + return { agentPanelPosition: { left: position.left, top: position.top } }; + } + + function parseBrowserSettingsEnvelope(text) { + if (typeof text !== 'string' || text.length > 1048576) throw new Error('Settings file is too large.'); + const envelope = JSON.parse(text); + if ( + !envelope || envelope.format !== 'standterm-settings-envelope' || envelope.version !== 1 + || envelope.contentType !== 'application/zip' || envelope.encoding !== 'base64' + || typeof envelope.content !== 'string' + ) throw new Error('Settings envelope is not supported.'); + const inner = JSON.parse(new TextDecoder().decode(readStoredSettingsZip(base64ToUint8Array(envelope.content)))); + if (!inner || inner.format !== 'standterm-browser-settings' || inner.version !== 1 || inner.keys) { + throw new Error('Settings payload is not supported.'); + } + return { + preferences: normalizeImportedPreferences(inner.preferences), + ui: normalizeImportedUiSettings(inner.ui), + ssh: normalizeImportedSshState(inner.ssh) + }; + } + + async function importBrowserSettingsText(text) { + const imported = parseBrowserSettingsEnvelope(text); + const preferenceCount = Object.keys(imported.preferences).length; + if (!window.confirm( + `Import ${preferenceCount} preferences, ${imported.ssh.profiles.length} SSH profiles, and ${imported.ssh.history.length} history entries? Existing browser SSH keys will not change.` + )) return false; + await sshSessionReady; + await updateSshSessionState(nextState => { + const importedProfiles = new Map(imported.ssh.profiles.map(profile => [profile.id, profile])); + nextState.profiles = nextState.profiles.map(profile => { + const replacement = importedProfiles.get(profile.id); + if (!replacement) return profile; + importedProfiles.delete(profile.id); + const protectedTarget = profile.keyId ? normalizeSshTarget(profile) : null; + return { + ...profile, + ...replacement, + ...(protectedTarget || {}), + sortOrder: profile.sortOrder, + keyId: profile.keyId + }; + }); + importedProfiles.forEach(profile => { + nextState.profiles.push({ ...profile, sortOrder: nextState.profiles.length, keyId: null }); + }); + const history = [...imported.ssh.history, ...nextState.history]; + const seen = new Set(); + nextState.history = history.filter(entry => { + const key = getSshTargetKey(entry); + if (seen.has(key)) return false; + seen.add(key); + return true; + }).slice(0, SSH_HISTORY_LIMIT); + }); + prefs = { ...prefs, ...imported.preferences }; + savePrefs(prefs); + if (Object.prototype.hasOwnProperty.call(imported.ui, 'agentPanelPosition')) { + if (imported.ui.agentPanelPosition) { + saveAgentPanelPosition(imported.ui.agentPanelPosition); + } else { + localStorage.removeItem('agentPanelPosition.v1'); + } + } + settingsTransferStatus.textContent = 'Settings imported. Reloading to apply browser preferences...'; + setTimeout(() => location.reload(), 50); + return true; + } + function startBrowserPairingResponseTimer() { if (browserPairingResponseTimer) clearTimeout(browserPairingResponseTimer); browserPairingResponseTimer = setTimeout(() => { @@ -4678,6 +5292,9 @@

Access token required

socket.connect(); return true; }, + retryServerConnectionNowForTest() { + return retryServerConnectionNow(); + }, getActiveAgentState() { return serializeAgentForTest(getActiveTerminalState()); }, @@ -4865,13 +5482,93 @@

Access token required

renderSshSessionState(); return cloneForTest(sshSessionState); }, + async createBrowserSshKeyForProfileForTest(profileId) { + await sshSessionReady; + const profile = findSshProfile(profileId); + if (!profile) throw new Error('SSH profile was not found.'); + const generatedRecord = await createBrowserSshKeyRecord(profile.id); + const record = { ...generatedRecord, targetKey: getSshTargetKey(profile) }; + await updateSshSessionStateWithKeyChanges(nextState => { + const nextProfile = nextState.profiles.find(item => item.id === profile.id); + if (nextProfile) nextProfile.keyId = record.keyId; + }, [{ type: 'put', record }]); + return { + keyId: record.keyId, + ownerProfileId: record.ownerProfileId, + algorithm: record.algorithm, + privateKeyExtractable: record.privateKey.extractable, + publicKeyRawB64: record.publicKeyRawB64, + publicKeyOpenSsh: record.publicKeyOpenSsh, + fingerprint: record.fingerprint, + publicKeyFingerprintHex: record.publicKeyFingerprintHex + }; + }, + async getBrowserSshKeyMetadataForTest(profileId) { + await sshSessionReady; + const profile = findSshProfile(profileId); + if (!profile || !profile.keyId) return null; + const record = await validateBrowserSshKeyRecord(await loadSshKeyRecord(profile.keyId), profile); + return { + keyId: record.keyId, + ownerProfileId: record.ownerProfileId, + algorithm: record.algorithm, + privateKeyExtractable: record.privateKey.extractable, + publicKeyRawB64: record.publicKeyRawB64, + publicKeyOpenSsh: record.publicKeyOpenSsh, + fingerprint: record.fingerprint, + publicKeyFingerprintHex: record.publicKeyFingerprintHex + }; + }, + async browserSshKeyRecordExistsForTest(keyId) { + return !!(await loadSshKeyRecord(keyId)); + }, + async signBrowserSshChallengeForTest(profileId, challengeB64) { + const profile = findSshProfile(profileId); + if (!profile || !profile.keyId) throw new Error('SSH profile key was not found.'); + const record = await validateBrowserSshKeyRecord(await loadSshKeyRecord(profile.keyId), profile); + const challenge = base64ToUint8Array(challengeB64); + return arrayBufferToBase64(await crypto.subtle.sign( + { name: SSH_KEY_ALGORITHM }, record.privateKey, challenge + )); + }, + async createBrowserSettingsEnvelopeForTest() { + return createBrowserSettingsEnvelope(); + }, + decodeBrowserSettingsEnvelopeForTest(envelope) { + const parsed = typeof envelope === 'string' ? JSON.parse(envelope) : envelope; + const bytes = base64ToUint8Array(parsed.content); + return JSON.parse(new TextDecoder().decode(readStoredSettingsZip(bytes))); + }, + parseBrowserSettingsEnvelopeForTest(envelope) { + return parseBrowserSettingsEnvelope(typeof envelope === 'string' ? envelope : JSON.stringify(envelope)); + }, + async importBrowserSettingsEnvelopeForTest(envelope) { + return importBrowserSettingsText(typeof envelope === 'string' ? envelope : JSON.stringify(envelope)); + }, + getConnectionFormDataForTest() { + return cloneForTest(getConnectionFormData()); + }, + setConnectionTypeForTest(connectionType) { + setConnectionType(connectionType); + forcedConnectionType = null; + selectedConnectionType = normalizeConnectionType(connectionType); + return getSelectedConnectionType(); + }, + async handleBrowserSshSignRequestForTest(payload) { + await handleBrowserSshSignRequest(cloneForTest(payload)); + }, stageSshConnectionForTest(value) { pendingSshConnectionDrafts.set(activeTerminalId, { ...normalizeSshTarget(value), saveHistory: !(value && value.saveHistory === false), saveSession: !!(value && value.saveSession), profileName: normalizeSshProfileName(value && value.profileName), - useKey: false + useKey: !!(value && value.useKey), + profileId: value && value.profileId ? value.profileId : null, + keyId: value && value.keyId ? value.keyId : null, + publicKeyFingerprintHex: value && value.publicKeyFingerprintHex + ? value.publicKeyFingerprintHex + : null }); }, getMatchingSshProfileNameForTest() { @@ -4944,6 +5641,7 @@

Access token required

reconnection: true, reconnectionAttempts: Infinity }); + if (serverRetryNowBtn) serverRetryNowBtn.onclick = retryServerConnectionNow; setServerConnectionState('connecting'); installBrowserTestHook(); socket.io.on('reconnect_attempt', attempt => { @@ -5038,6 +5736,86 @@

Access token required

}) .catch(err => updateBrowserAuthUi(err.message)); }); + async function handleBrowserSshSignRequest(data) { + const responseBase = { + request_id: data && data.request_id, + terminal_id: data && data.terminal_id, + profile_id: data && data.profile_id, + key_id: data && data.key_id, + challenge_sha256: data && data.challenge_sha256 + }; + const fail = message => { + if (socket && socket.connected) { + socket.emit('ssh_browser_sign_response', { + ...responseBase, + status: 'failed', + message: String(message || 'Browser SSH signing failed.').slice(0, 160) + }); + } + }; + try { + if (!data || typeof data !== 'object') throw new Error('Invalid SSH signing request.'); + const stringFields = [ + 'request_id', 'terminal_id', 'profile_id', 'key_id', + 'public_key_fingerprint', 'algorithm', 'challenge', 'challenge_sha256' + ]; + if (stringFields.some(field => typeof data[field] !== 'string' || !data[field])) { + throw new Error('Incomplete SSH signing request.'); + } + if ( + data.request_id.length > 128 + || data.profile_id.length > 128 + || data.key_id.length > 128 + || data.challenge.length > 8192 + || !/^[0-9a-f]{64}$/.test(data.challenge_sha256) + || !/^[0-9a-f]{64}$/.test(data.public_key_fingerprint) + ) throw new Error('Invalid SSH signing request fields.'); + if (data.algorithm !== 'ssh-ed25519') throw new Error('Unsupported SSH signing algorithm.'); + if ( + !Number.isFinite(data.expires_at) + || data.expires_at <= Date.now() / 1000 + || data.expires_at > Date.now() / 1000 + 20 + ) { + throw new Error('SSH signing request expired.'); + } + if (handledSshSignRequestIds.has(data.request_id)) throw new Error('SSH signing request was already handled.'); + const draft = pendingSshConnectionDrafts.get(data.terminal_id); + if ( + !draft + || !draft.useKey + || draft.profileId !== data.profile_id + || draft.keyId !== data.key_id + || draft.publicKeyFingerprintHex !== data.public_key_fingerprint + ) { + throw new Error('SSH signing request does not match the active connection.'); + } + const profile = findSshProfile(data.profile_id); + const record = await validateBrowserSshKeyRecord(await loadSshKeyRecord(data.key_id), profile); + const challenge = base64ToUint8Array(data.challenge); + if (!challenge.byteLength || challenge.byteLength > 4096) throw new Error('Invalid SSH signing challenge.'); + if ((await arrayBufferToHex(challenge)) !== data.challenge_sha256) { + throw new Error('SSH signing challenge hash mismatch.'); + } + handledSshSignRequestIds.add(data.request_id); + if (handledSshSignRequestIds.size > 256) { + handledSshSignRequestIds.delete(handledSshSignRequestIds.values().next().value); + } + const signature = await crypto.subtle.sign( + { name: SSH_KEY_ALGORITHM }, + record.privateKey, + challenge + ); + if (new Uint8Array(signature).byteLength !== 64) throw new Error('Invalid Ed25519 signature length.'); + socket.emit('ssh_browser_sign_response', { + ...responseBase, + status: 'ok', + signature: arrayBufferToBase64(signature) + }); + } catch (err) { + fail(err && err.message ? err.message : 'Browser SSH signing failed.'); + } + } + socket.on('ssh_browser_sign_request', handleBrowserSshSignRequest); socket.on('browser_pairing_file', data => { clearBrowserPairingResponseTimer(); setBrowserAuthBusy(false); @@ -5100,6 +5878,8 @@

Access token required

}); clearBrowserPairingResponseTimer(); setBrowserAuthBusy(false); + pendingSshConnectionDrafts.clear(); + handledSshSignRequestIds.clear(); clearSessionRenewTimer(); setServerConnectionState('unavailable'); terminals.forEach(state => { @@ -5153,6 +5933,38 @@

Access token required

connectBtn.disabled = state !== 'available'; } + function retryServerConnectionNow() { + if (!socket || socket.connected || serverConnectionState !== 'unavailable') return false; + recordConnectionDiagnostic('socket.retry_now', { + online: navigator.onLine, + visibility: document.visibilityState + }); + if (serverRetryNowBtn) { + serverRetryNowBtn.disabled = true; + serverRetryNowBtn.innerText = 'Retrying...'; + setTimeout(() => { + serverRetryNowBtn.disabled = false; + serverRetryNowBtn.innerText = 'Retry Now'; + }, 1000); + } + const manager = socket.io; + if (manager && manager._readyState === 'closed' && manager._reconnecting) { + manager.cleanup(); + manager._reconnecting = false; + if (manager.backoff) manager.backoff.reset(); + } + if (manager && manager._readyState === 'closed') { + manager.open(err => { + if (!err) return; + manager._reconnecting = false; + manager.reconnect(); + }); + } else { + socket.connect(); + } + return true; + } + function isSessionRequiredConnectError(err) { if (!err) return false; return ( @@ -5342,6 +6154,17 @@

Access token required

formData.password = sshPasswordInput.value; const matchingProfile = findSshProfileForTarget(formData); if (matchingProfile) formData.profile_name = matchingProfile.name; + if ( + isBrowserSshKeyAllowedByPolicy() + && quickConnectSshKeyRecord + && sshUseBrowserKeyInput.checked + ) { + formData.password = ''; + formData.use_browser_key = true; + formData.profile_id = quickConnectSshKeyRecord.ownerProfileId; + formData.key_id = quickConnectSshKeyRecord.keyId; + formData.browser_public_key = quickConnectSshKeyRecord.publicKeyRawB64; + } } else if (connectionType === 'local_shell' && localShellKindSelect.value) { formData.local_shell_kind = localShellKindSelect.value; } else if (connectionType === 'uart') { @@ -5359,6 +6182,10 @@

Access token required

}); sshSaveHistoryInput.checked = prefs.saveSshHistory; sshSaveSessionInput.checked = false; + sshUseBrowserKeyInput.checked = false; + sshUseBrowserKeyLabel.hidden = true; + quickConnectSshKeyRecord = null; + applyQuickConnectKeySelection(); selectedSshPickerEntry = null; updateSshProfileIndicator(); setSshSessionMessage(''); @@ -5385,7 +6212,12 @@

Access token required

saveHistory: !!sshSaveHistoryInput.checked, saveSession: !!sshSaveSessionInput.checked, profileName: normalizeSshProfileName(formData.profile_name), - useKey: false + useKey: formData.use_browser_key === true, + profileId: typeof formData.profile_id === 'string' ? formData.profile_id : null, + keyId: typeof formData.key_id === 'string' ? formData.key_id : null, + publicKeyFingerprintHex: quickConnectSshKeyRecord + ? quickConnectSshKeyRecord.publicKeyFingerprintHex + : null }); } else { pendingSshConnectionDrafts.delete(formData.terminal_id); @@ -5411,6 +6243,7 @@

Access token required

[sshHostInput, sshPortInput, sshUsernameInput].forEach(input => { input.addEventListener('input', updateSshProfileIndicator); }); + sshUseBrowserKeyInput.onchange = applyQuickConnectKeySelection; document.addEventListener('click', event => { if (!event.target.closest('.ssh-session-picker')) closeSshSessionPicker(); }); @@ -5421,6 +6254,15 @@

Access token required

sshProfileCreateBtn.onclick = () => { createSshProfileEditor().catch(err => setSshProfileStatus(err.message || 'Profile could not be created.', true)); }; + sshProfileKeyEnabledInput.onchange = () => { + toggleSshProfileKey() + .catch(err => setSshProfileStatus(err.message || 'Browser SSH key could not be generated.', true)); + }; + sshProfileKeyCopyBtn.onclick = () => { + if (!editingSshKeyRecord) return; + copyToClipboard(editingSshKeyRecord.publicKeyOpenSsh); + setSshProfileStatus('SSH public key copied.'); + }; sshProfileSaveBtn.onclick = () => { saveSshProfileEditor().catch(err => setSshProfileStatus(err.message || 'Profile could not be saved.', true)); }; @@ -5436,6 +6278,29 @@

Access token required

sshHistoryClearBtn.onclick = () => { clearSshHistory().catch(err => setSshProfileStatus(err.message || 'SSH history could not be cleared.', true)); }; + if (settingsExportBtn) { + settingsExportBtn.onclick = () => { + settingsExportBtn.disabled = true; + exportBrowserSettings() + .catch(err => { settingsTransferStatus.textContent = err.message || 'Settings could not be exported.'; }) + .finally(() => { settingsExportBtn.disabled = false; }); + }; + } + if (settingsImportBtn && settingsImportFile) { + settingsImportBtn.onclick = () => settingsImportFile.click(); + settingsImportFile.onchange = () => { + const file = settingsImportFile.files && settingsImportFile.files[0]; + settingsImportFile.value = ''; + if (!file) return; + if (file.size > 1048576) { + settingsTransferStatus.textContent = 'Settings file is too large.'; + return; + } + file.text() + .then(importBrowserSettingsText) + .catch(err => { settingsTransferStatus.textContent = err.message || 'Settings could not be imported.'; }); + }; + } browserAuthUrlForm.onsubmit = event => { event.preventDefault(); setBrowserAuthUrlError(''); diff --git a/terminal_backends/ssh.py b/terminal_backends/ssh.py index b28218e..15035f6 100644 --- a/terminal_backends/ssh.py +++ b/terminal_backends/ssh.py @@ -1,7 +1,9 @@ import base64 import codecs import getpass +import hashlib import os +import re from pathlib import Path from .base import BackendAction, BackendSettingSchema, BackendStartFieldSchema, TerminalBackendPlugin, TerminalBridge @@ -9,6 +11,62 @@ SSH_PROFILE_NAME_MAX_LENGTH = 64 +SSH_BROWSER_KEY_ID_MAX_LENGTH = 128 +SSH_BROWSER_KEY_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$') + + +class BrowserSSHKeyError(Exception): + pass + + +class BrowserEd25519Key: + name = 'ssh-ed25519' + public_blob = None + + def __init__(self, paramiko_module, public_key, sign_callback): + if not isinstance(public_key, bytes) or len(public_key) != 32: + raise BrowserSSHKeyError('Browser Ed25519 public key must be 32 bytes.') + self._paramiko = paramiko_module + self._public_key = public_key + self._sign_callback = sign_callback + self._verifier = paramiko_module.Ed25519Key(data=self.asbytes()) + + def asbytes(self): + message = self._paramiko.Message() + message.add_string(self.name) + message.add_string(self._public_key) + return message.asbytes() + + def get_name(self): + return self.name + + def get_bits(self): + return 256 + + def get_fingerprint(self): + return hashlib.md5(self.asbytes()).digest() + + def can_sign(self): + return True + + def sign_ssh_data(self, data, algorithm=None): + if algorithm != self.name: + raise BrowserSSHKeyError('Browser SSH key only supports ssh-ed25519 signatures.') + try: + signature = self._sign_callback(data, algorithm) + except BrowserSSHKeyError: + raise + except Exception as exc: + raise BrowserSSHKeyError(str(exc)) from exc + if not isinstance(signature, bytes) or len(signature) != 64: + raise BrowserSSHKeyError('Browser Ed25519 signature must be 64 bytes.') + signature_message = self._paramiko.Message() + signature_message.add_string(self.name) + signature_message.add_string(signature) + verifier_message = self._paramiko.Message(signature_message.asbytes()) + if not self._verifier.verify_ssh_sig(data, verifier_message): + raise BrowserSSHKeyError('Browser SSH signature verification failed.') + return signature_message class SSHBridge(TerminalBridge): @@ -24,16 +82,31 @@ def __init__( get_paramiko, ssh_term, local_public_key_types, + request_browser_signature=None, ): super().__init__(owner_session, terminal_id) self._get_paramiko = get_paramiko self._ssh_term = ssh_term self._local_public_key_types = local_public_key_types + self._request_browser_signature = request_browser_signature + self._browser_signer_sid = None self.ssh = None + self.auth_method = None self._reset_ssh_client() self.channel = None self._output_decoder = codecs.getincrementaldecoder('utf-8')(errors='ignore') + def metadata(self, cols=None, rows=None): + metadata = super().metadata(cols=cols, rows=rows) + if self.auth_method: + metadata['auth_method'] = self.auth_method + return metadata + + def set_browser_signer_sid(self, sid): + if self._browser_signer_sid is not None and self._browser_signer_sid != sid: + raise BrowserSSHKeyError('Browser SSH signer is already assigned.') + self._browser_signer_sid = sid + def _reset_ssh_client(self, trust_unknown_host=False): paramiko_module = self._get_paramiko() if self.ssh: @@ -366,14 +439,45 @@ def _connect_with_local_keys(self, host, port, user, password): return False, '; '.join(auth_errors) - def connect(self, host, port, user, password=None, cols=80, rows=24): + def _connect_with_browser_key(self, host, port, user, browser_key, is_localhost): + if not self._browser_signer_sid or not self._request_browser_signature: + raise BrowserSSHKeyError('Browser SSH signer is unavailable.') + paramiko_module = self._get_paramiko() + public_key = base64.b64decode(browser_key['public_key'].encode('ascii'), validate=True) + signer_key = BrowserEd25519Key( + paramiko_module, + public_key, + lambda data, algorithm: self._request_browser_signature( + self, + self._browser_signer_sid, + browser_key, + data, + algorithm, + ), + ) + self._reset_ssh_client(trust_unknown_host=is_localhost) + self.ssh.connect( + host, + port=int(port), + username=user, + password=None, + pkey=signer_key, + timeout=15, + allow_agent=False, + look_for_keys=False, + ) + self.auth_method = 'browser-key' + + def connect(self, host, port, user, password=None, browser_key=None, cols=80, rows=24): paramiko_module = self._get_paramiko() try: pwd = password if password else "" log_message(f"[*] Attempting SSH connection for {user!r} at {host!r}:{port}...") is_localhost = self._is_local_target(host) - if is_localhost and not pwd: + if browser_key: + self._connect_with_browser_key(host, port, user, browser_key, is_localhost) + elif is_localhost and not pwd: success, key_error = self._connect_with_local_keys(host, port, user, None) if not success: setup_availability = self._get_local_key_setup_availability(user) @@ -392,6 +496,7 @@ def connect(self, host, port, user, password=None, cols=80, rows=24): raise paramiko_module.AuthenticationException( f"Local public key auth failed: {key_error or 'no usable local key found'}" ) + self.auth_method = 'host-key' else: self._reset_ssh_client(trust_unknown_host=is_localhost) self.ssh.connect( @@ -403,11 +508,18 @@ def connect(self, host, port, user, password=None, cols=80, rows=24): allow_agent=False, look_for_keys=False, ) + self.auth_method = 'password' self.channel = self.ssh.invoke_shell(term=self._ssh_term, width=cols, height=rows) self.channel.setblocking(0) log_message(f"[+] SSH connection established for {self.sid}") return True, None + except BrowserSSHKeyError as exc: + log_message(f"[!] Browser SSH key error: {exc}") + return False, { + 'message': str(exc), + 'error_code': 'ssh_browser_key_failed', + } except Exception as e: error_msg = str(e) log_message(f"[!] SSH Connection Error: {error_msg}") @@ -494,6 +606,7 @@ def __init__( max_password_bytes, has_control_chars, is_allowed_for_client, + is_browser_key_allowed, allowed_action_types, backend_action_store, bridge_kwargs, @@ -512,6 +625,7 @@ def __init__( self._max_password_bytes = max_password_bytes self._has_control_chars = has_control_chars self._is_allowed_for_client = is_allowed_for_client + self._is_browser_key_allowed = is_browser_key_allowed self._allowed_action_types = allowed_action_types self._backend_action_store = backend_action_store self._bridge_kwargs = bridge_kwargs @@ -531,6 +645,12 @@ def build_policy_option(self, context=None, browser_authorized=False): 'allowed': allowed, 'authorization_available': not allowed, 'browser_authorized': bool(browser_authorized), + 'browser_key_allowed': bool( + allowed and self._is_browser_key_allowed( + client_ip, + browser_authorized=browser_authorized, + ) + ), } def get_settings_schema(self): @@ -776,12 +896,51 @@ def validate_start_payload(self, data, terminal_id, client_ip, browser_authorize if self._has_control_chars(profile_name): return None, 'SSH profile name contains invalid control characters.' + use_browser_key = data.get('use_browser_key', False) + if not isinstance(use_browser_key, bool): + return None, 'Use browser key must be a boolean.' + browser_key = None + if use_browser_key: + if not self._is_browser_key_allowed(client_ip, browser_authorized=browser_authorized): + return None, { + 'message': 'Browser SSH keys require a local browser or an authorized HTTPS connection.', + 'error_code': 'ssh_browser_key_insecure_transport', + } + if password: + return None, 'Password must be empty when browser key authentication is selected.' + profile_id = data.get('profile_id') + key_id = data.get('key_id') + for field_name, field_value in (('SSH profile id', profile_id), ('SSH key id', key_id)): + if ( + not isinstance(field_value, str) + or not field_value + or len(field_value) > SSH_BROWSER_KEY_ID_MAX_LENGTH + or not SSH_BROWSER_KEY_ID_PATTERN.fullmatch(field_value) + ): + return None, f'{field_name} is invalid.' + public_key = data.get('browser_public_key') + if not isinstance(public_key, str): + return None, 'Browser SSH public key must be a Base64 string.' + try: + public_key_bytes = base64.b64decode(public_key.encode('ascii'), validate=True) + except (UnicodeEncodeError, ValueError): + return None, 'Browser SSH public key is invalid.' + if len(public_key_bytes) != 32: + return None, 'Browser SSH public key must be 32 bytes.' + browser_key = { + 'profile_id': profile_id, + 'key_id': key_id, + 'public_key': public_key, + 'fingerprint': hashlib.sha256(public_key_bytes).hexdigest(), + } + return { 'host': host, 'port': port, 'username': user, 'password': password, 'profile_name': profile_name or None, + 'browser_key': browser_key, }, None def create_bridge(self, session_token, terminal_id, payload): @@ -797,6 +956,7 @@ def connect_bridge(self, bridge, payload, cols, rows): payload['port'], payload['username'], payload['password'], + browser_key=payload.get('browser_key'), cols=cols, rows=rows, ) diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index 4dfff3b..75966fd 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -1,4 +1,5 @@ import base64 +import hashlib import sys import tempfile import threading @@ -17,6 +18,7 @@ import scripts.access_window as access_window sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) import agent_cli +from terminal_backends.ssh import BrowserEd25519Key, BrowserSSHKeyError def make_test_png_base64(width, height): @@ -91,6 +93,7 @@ def create_terminal_input_proposal(self, context, run): def reset_state(): standterm.bridges.clear() + standterm.pending_terminal_starts.clear() standterm.pending_localhost_key_setups.clear() standterm.active_sessions.clear() standterm.socket_session_tokens.clear() @@ -111,6 +114,7 @@ def reset_state(): standterm.agent_headless_terminal_mirror_store.clear() standterm.agent_viewport_snapshot_store.clear() standterm.agent_viewport_render_request_store.clear() + standterm.browser_ssh_sign_request_store.clear() standterm.external_agent_attach_store.clear() standterm.operator_observations.clear() standterm.serial_port_cache['expires_at'] = 0 @@ -3785,6 +3789,207 @@ def test_remote_ssh_requires_browser_authorization_or_explicit_remote_access(): standterm.os.environ['STANDTERM_ALLOW_REMOTE_SSH'] = original_standterm_env +def test_browser_ssh_key_payload_requires_local_or_authorized_https_transport(): + original_https_enabled = standterm.HTTPS_ENABLED + data = { + 'connection_type': standterm.CONNECTION_TYPE_SSH, + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'host': 'example.test', + 'port': 22, + 'username': 'operator', + 'password': '', + 'profile_name': 'Build Server', + 'profile_id': 'profile-123', + 'use_browser_key': True, + 'key_id': 'key-123', + 'browser_public_key': base64.b64encode(b'k' * 32).decode('ascii'), + } + try: + standterm.HTTPS_ENABLED = False + payload, error = standterm.validate_start_ssh_payload( + data, + '127.0.0.1', + browser_authorized=False, + ) + assert error is None + assert payload['browser_key']['profile_id'] == 'profile-123' + assert payload['browser_key']['key_id'] == 'key-123' + assert payload['password'] == '' + + payload, error = standterm.validate_start_ssh_payload( + data, + '203.0.113.10', + browser_authorized=True, + ) + assert payload is None + assert error['error_code'] == 'ssh_browser_key_insecure_transport' + + standterm.HTTPS_ENABLED = True + payload, error = standterm.validate_start_ssh_payload( + data, + '203.0.113.10', + browser_authorized=True, + ) + assert error is None + assert payload['browser_key']['fingerprint'] == hashlib.sha256(b'k' * 32).hexdigest() + + payload, error = standterm.validate_start_ssh_payload( + dict(data, password='must-not-fallback'), + '127.0.0.1', + browser_authorized=False, + ) + assert payload is None + assert error == 'Password must be empty when browser key authentication is selected.' + + for invalid_public_key in ( + base64.b64encode(b'k' * 31).decode('ascii'), + base64.b64encode(b'k' * 33).decode('ascii'), + 'not-base64', + ): + payload, error = standterm.validate_start_ssh_payload( + dict(data, browser_public_key=invalid_public_key), + '127.0.0.1', + browser_authorized=False, + ) + assert payload is None + assert error in { + 'Browser SSH public key is invalid.', + 'Browser SSH public key must be 32 bytes.', + } + finally: + standterm.HTTPS_ENABLED = original_https_enabled + + +def test_browser_ed25519_key_wraps_and_verifies_remote_signature(): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + challenges = [] + signer = BrowserEd25519Key( + standterm.get_paramiko(), + public_key, + lambda data, algorithm: challenges.append((data, algorithm)) or private_key.sign(data), + ) + challenge = b'structured SSH authentication challenge' + signature_message = signer.sign_ssh_data(challenge, 'ssh-ed25519') + verifier = standterm.get_paramiko().Ed25519Key(data=signer.asbytes()) + + assert challenges == [(challenge, 'ssh-ed25519')] + assert verifier.verify_ssh_sig( + challenge, + standterm.get_paramiko().Message(signature_message.asbytes()), + ) is True + assert signer.get_name() == 'ssh-ed25519' + assert signer.get_bits() == 256 + + wrong_private_key = ed25519.Ed25519PrivateKey.generate() + wrong_signer = BrowserEd25519Key( + standterm.get_paramiko(), + public_key, + lambda data, _algorithm: wrong_private_key.sign(data), + ) + try: + wrong_signer.sign_ssh_data(challenge, 'ssh-ed25519') + raise AssertionError('wrong browser key signature was accepted') + except BrowserSSHKeyError as exc: + assert 'verification failed' in str(exc) + + +def test_browser_ssh_sign_request_store_is_sid_bound_and_fail_closed(): + store = standterm.BrowserSSHSignRequestStore(timeout_seconds=0.05) + browser_key = { + 'profile_id': 'profile-1', + 'key_id': 'key-1', + 'fingerprint': 'f' * 64, + } + request_payload, error = store.create( + 'session-1', + 'terminal-1', + 'sid-a', + 'browser-a', + browser_key, + b'challenge', + 'ssh-ed25519', + ) + assert error is None + second_payload, second_error = store.create( + 'session-1', + 'terminal-2', + 'sid-a', + 'browser-a', + browser_key, + b'challenge-2', + 'ssh-ed25519', + ) + assert second_payload is None + assert second_error == 'ssh_browser_key_sign_busy' + + response = { + 'request_id': request_payload['request_id'], + 'terminal_id': request_payload['terminal_id'], + 'profile_id': request_payload['profile_id'], + 'key_id': request_payload['key_id'], + 'challenge_sha256': request_payload['challenge_sha256'], + 'status': 'ok', + 'signature': base64.b64encode(b's' * 64).decode('ascii'), + } + assert store.resolve('session-1', 'sid-b', response) == 'ssh_browser_key_sign_stale' + assert store.resolve('session-1', 'sid-a', dict(response, key_id='key-2')) == 'ssh_browser_key_sign_stale' + assert store.resolve('session-1', 'sid-a', response) is None + assert store.resolve('session-1', 'sid-a', response) == 'ssh_browser_key_sign_stale' + signature, wait_error = store.wait(request_payload) + assert wait_error is None + assert signature == b's' * 64 + + timeout_payload, error = store.create( + 'session-1', + 'terminal-1', + 'sid-a', + 'browser-a', + browser_key, + b'timeout', + 'ssh-ed25519', + ) + assert error is None + signature, wait_error = store.wait(timeout_payload) + assert signature is None + assert wait_error == 'ssh_browser_key_sign_timeout' + + cancelled_payload, error = store.create( + 'session-1', + 'terminal-1', + 'sid-a', + 'browser-a', + browser_key, + b'cancelled', + 'ssh-ed25519', + ) + assert error is None + store.discard('session-1', sid='sid-a') + signature, wait_error = store.wait(cancelled_payload) + assert signature is None + assert wait_error == 'ssh_browser_key_sign_stale' + + +def test_terminal_start_tokens_reject_stale_background_connections(): + first = standterm.begin_terminal_start('session-1', 'main') + assert standterm.is_current_terminal_start('session-1', 'main', first) is True + + replacement = standterm.begin_terminal_start('session-1', 'main') + assert standterm.is_current_terminal_start('session-1', 'main', first) is False + assert standterm.finish_terminal_start('session-1', 'main', first) is False + assert standterm.finish_terminal_start('session-1', 'main', replacement) is True + + cancelled = standterm.begin_terminal_start('session-1', 'secondary') + standterm.cancel_terminal_starts('session-1', terminal_id='secondary') + assert standterm.is_current_terminal_start('session-1', 'secondary', cancelled) is False + + def test_remote_unauthorized_socket_cannot_attach_existing_ssh_terminal(): flask_client = standterm.app.test_client() response = flask_client.get('/?token=' + standterm.ACCESS_TOKEN) @@ -4654,6 +4859,7 @@ def __getattr__(self, name): max_password_bytes=standterm.MAX_PASSWORD_BYTES, has_control_chars=standterm.has_control_chars, is_allowed_for_client=lambda _client_ip, browser_authorized=False: True, + is_browser_key_allowed=lambda _client_ip, browser_authorized=False: True, allowed_action_types={'offer_localhost_key_setup'}, backend_action_store=action_store, bridge_kwargs={}, @@ -5772,6 +5978,10 @@ def main(): test_terminal_policy_creates_authorized_dir_for_fresh_checkout, test_wsl_client_ips_require_explicit_trust_for_local_resources, test_remote_ssh_requires_browser_authorization_or_explicit_remote_access, + test_browser_ssh_key_payload_requires_local_or_authorized_https_transport, + test_browser_ed25519_key_wraps_and_verifies_remote_signature, + test_browser_ssh_sign_request_store_is_sid_bound_and_fail_closed, + test_terminal_start_tokens_reject_stale_background_connections, test_remote_unauthorized_socket_cannot_attach_existing_ssh_terminal, test_browser_authorization_success_refreshes_visible_terminal_list, test_settings_capabilities_are_separate_from_local_resource_access, diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index e670f75..0f22354 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -1,3 +1,5 @@ +import base64 +import hashlib import os import queue import re @@ -292,6 +294,19 @@ def test_server_unavailable_waits_for_reconnect(browser, access_url): check(unavailable['messageDisplay'] == 'block', 'server unavailable guidance was not visible') check(unavailable['connectionFormDisplay'] == 'none', 'connection picker remained visible while the server was unavailable') check(unavailable['connectDisabled'] is True, 'terminal connect button remained enabled while the server was unavailable') + check(page.locator('#server-retry-now').is_visible(), 'Retry Now was not visible with the disconnect warning') + page.click('#server-retry-now') + check( + page.locator('#server-retry-now').inner_text() == 'Retrying...', + 'Retry Now did not trigger an immediate reconnect attempt', + ) + check( + any( + event['event'] == 'socket.retry_now' + for event in page.evaluate('() => window.terminalTest.getConnectionDiagnostics()') + ), + 'Retry Now did not record an explicit reconnect attempt', + ) context.set_offline(False) page.wait_for_function( @@ -2040,6 +2055,7 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url): policy.default_connection = 'ssh'; const ssh = policy.connection_options.find(option => option.connection_type === 'ssh'); ssh.allowed = true; + ssh.browser_key_allowed = true; window.terminalTest.applyTerminalPolicy(policy); const sshMode = document.querySelector('input[name="connection_type"][value="ssh"]'); sshMode.checked = true; @@ -2264,6 +2280,251 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url): close_context(context) +def test_browser_ssh_key_lifecycle_and_settings_transfer(browser, access_url): + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + context = browser.new_context(viewport={'width': 1280, 'height': 800}) + page = context.new_page() + try: + page.goto(debug_url(access_url), wait_until='domcontentloaded') + page.wait_for_function('() => !!window.terminalTest', timeout=10000) + page.wait_for_function( + "() => window.terminalTest.getSocketState().connected === true", + timeout=10000, + ) + page.evaluate( + """async () => { + await window.terminalTest.setSshSessionState({ + profiles: [ + { id: 'profile-primary', sortOrder: 0, name: 'Primary', host: 'primary.example', port: '22', username: 'alice', keyId: null }, + { id: 'profile-imported', sortOrder: 1, name: 'Imported', host: 'imported.example', port: '2200', username: 'bob', keyId: null } + ], + history: [ + { id: 'history-imported', host: 'recent.example', port: '22', username: 'recent', lastUsedAt: '2026-08-26T01:00:00.000Z' } + ] + }); + const policy = window.terminalTest.getTerminalPolicy(); + policy.force_connection = null; + policy.default_connection = 'ssh'; + const ssh = policy.connection_options.find(option => option.connection_type === 'ssh'); + ssh.allowed = true; + ssh.browser_key_allowed = true; + window.terminalTest.applyTerminalPolicy(policy); + const sshMode = document.querySelector('input[name="connection_type"][value="ssh"]'); + sshMode.checked = true; + sshMode.dispatchEvent(new Event('change', { bubbles: true })); + }""" + ) + + page.click('#quick-settings') + page.click('.settings-nav-item[data-tab="ssh-sessions"]') + page.click('#ssh-profile-list button[data-profile-id="profile-primary"]') + page.wait_for_function( + "() => document.getElementById('ssh-profile-name').value === 'Primary'", + timeout=5000, + ) + page.check('#ssh-profile-key-enabled') + page.wait_for_function( + "() => document.getElementById('ssh-profile-key-status').innerText.includes('SHA256:')", + timeout=10000, + ) + check( + page.locator('#ssh-profile-key-public').input_value().startswith('ssh-ed25519 '), + 'generated browser SSH key did not expose an OpenSSH public key', + ) + page.click('#ssh-profile-save') + page.wait_for_function( + "() => document.getElementById('ssh-profile-status').innerText === 'Saved Primary.'", + timeout=5000, + ) + metadata = page.evaluate( + "() => window.terminalTest.getBrowserSshKeyMetadataForTest('profile-primary')" + ) + check(metadata['algorithm'] == 'Ed25519', 'browser SSH key did not use Ed25519') + check(metadata['privateKeyExtractable'] is False, 'browser SSH private key was extractable') + check(len(base64.b64decode(metadata['publicKeyRawB64'])) == 32, 'Ed25519 public key was not 32 bytes') + key_type, public_blob_b64 = metadata['publicKeyOpenSsh'].split() + public_blob = base64.b64decode(public_blob_b64) + key_type_length = int.from_bytes(public_blob[:4], 'big') + raw_length_offset = 4 + key_type_length + raw_length = int.from_bytes(public_blob[raw_length_offset:raw_length_offset + 4], 'big') + check(key_type == 'ssh-ed25519', 'OpenSSH public key used the wrong key type') + check(public_blob[4:raw_length_offset] == b'ssh-ed25519', 'OpenSSH public key blob omitted its key type') + check(raw_length == 32 and len(public_blob) == raw_length_offset + 4 + raw_length, 'OpenSSH public key blob is invalid') + expected_fingerprint = 'SHA256:' + base64.b64encode(hashlib.sha256(public_blob).digest()).decode('ascii').rstrip('=') + check(metadata['fingerprint'] == expected_fingerprint, 'browser SSH key fingerprint is not OpenSSH-compatible') + + challenge = b'StandTerm browser-owned SSH signer smoke challenge' + signature_b64 = page.evaluate( + """args => window.terminalTest.signBrowserSshChallengeForTest( + args.profileId, args.challenge + )""", + {'profileId': 'profile-primary', 'challenge': base64.b64encode(challenge).decode('ascii')}, + ) + signature = base64.b64decode(signature_b64) + check(len(signature) == 64, 'browser returned an invalid Ed25519 signature length') + Ed25519PublicKey.from_public_bytes(base64.b64decode(metadata['publicKeyRawB64'])).verify( + signature, + challenge, + ) + + page.click('#settings-close') + check( + page.evaluate("() => window.terminalTest.setConnectionTypeForTest('ssh')") == 'ssh', + 'test policy did not select SSH Quick Connect', + ) + page.evaluate( + """() => { + document.getElementById('ssh-session-picker-toggle').click(); + document.querySelector('.ssh-session-picker-entry[data-entry-id="profile-primary"]').click(); + }""" + ) + page.wait_for_function( + "() => !document.getElementById('ssh-use-browser-key-label').hidden", + timeout=5000, + ) + check(page.locator('#ssh-use-browser-key').is_checked(), 'exact keyed profile did not default Use key on') + check(page.locator('#password').is_disabled(), 'Use key did not disable the password field') + form_data = page.evaluate('() => window.terminalTest.getConnectionFormDataForTest()') + check( + form_data.get('use_browser_key') is True, + f'Quick Connect omitted the browser key control field: {form_data!r}', + ) + check(form_data['password'] == '', 'Quick Connect sent a password with browser key authentication') + check(form_data['profile_id'] == 'profile-primary', 'Quick Connect sent the wrong key owner profile') + check(form_data['key_id'] == metadata['keyId'], 'Quick Connect sent the wrong browser key ID') + + page.evaluate( + """metadata => { + window.terminalTest.stageSshConnectionForTest({ + host: 'primary.example', port: '22', username: 'alice', + useKey: true, profileId: 'profile-primary', keyId: metadata.keyId, + publicKeyFingerprintHex: metadata.publicKeyFingerprintHex + }); + window.terminalTest.clearEmitted(); + }""", + metadata, + ) + request_payload = { + 'request_id': 'request-valid-signature', + 'terminal_id': 'main', + 'profile_id': 'profile-primary', + 'key_id': metadata['keyId'], + 'public_key_fingerprint': metadata['publicKeyFingerprintHex'], + 'algorithm': 'ssh-ed25519', + 'challenge': base64.b64encode(challenge).decode('ascii'), + 'challenge_sha256': hashlib.sha256(challenge).hexdigest(), + 'expires_at': time.time() + 10, + } + page.evaluate( + 'payload => window.terminalTest.handleBrowserSshSignRequestForTest(payload)', + request_payload, + ) + response = page.evaluate( + """() => window.terminalTest.getEmitted() + .filter(entry => entry.event === 'ssh_browser_sign_response').at(-1).args[0]""" + ) + check(response['status'] == 'ok', 'structured browser SSH signing request failed') + Ed25519PublicKey.from_public_bytes(base64.b64decode(metadata['publicKeyRawB64'])).verify( + base64.b64decode(response['signature']), + challenge, + ) + + page.fill('#port', '2222') + page.locator('#port').dispatch_event('input') + page.wait_for_function( + "() => document.getElementById('ssh-use-browser-key-label').hidden", + timeout=5000, + ) + check(page.locator('#password').is_enabled(), 'modified profile target kept key-only authentication active') + + envelope = page.evaluate('() => window.terminalTest.createBrowserSettingsEnvelopeForTest()') + exported = page.evaluate( + 'envelope => window.terminalTest.decodeBrowserSettingsEnvelopeForTest(envelope)', + envelope, + ) + check(exported['format'] == 'standterm-browser-settings', 'settings ZIP payload format is incorrect') + check('keys' not in exported, 'settings export included an SSH key collection') + check( + all('keyId' not in profile for profile in exported['ssh']['profiles']), + 'settings export included SSH profile key IDs', + ) + exported_text = repr(exported) + check('ssh-ed25519 ' not in exported_text, 'settings export included an SSH public key') + check(metadata['keyId'] not in exported_text, 'settings export included an SSH key ID') + + page.evaluate( + """async keyId => { + await window.terminalTest.setSshSessionState({ + profiles: [ + { id: 'profile-primary', sortOrder: 0, name: 'Changed Locally', host: 'primary.example', port: '22', username: 'alice', keyId }, + { id: 'profile-local', sortOrder: 1, name: 'Local Only', host: 'local.example', port: '22', username: 'local', keyId: null } + ], + history: [ + { id: 'history-local', host: 'local-recent.example', port: '22', username: 'local', lastUsedAt: '2026-08-26T02:00:00.000Z' } + ] + }); + const saveHistory = document.getElementById('ssh-save-history'); + saveHistory.checked = false; + saveHistory.dispatchEvent(new Event('change', { bubbles: true })); + }""", + metadata['keyId'], + ) + page.once('dialog', lambda dialog: dialog.accept()) + with page.expect_navigation(wait_until='domcontentloaded', timeout=10000): + page.evaluate( + 'envelope => window.terminalTest.importBrowserSettingsEnvelopeForTest(envelope)', + envelope, + ) + page.wait_for_function('() => !!window.terminalTest', timeout=10000) + merged = page.evaluate('() => window.terminalTest.getSshSessionState()') + check( + [profile['id'] for profile in merged['profiles']] + == ['profile-primary', 'profile-local', 'profile-imported'], + 'settings import did not update by stable ID and append new profiles', + ) + primary = next(profile for profile in merged['profiles'] if profile['id'] == 'profile-primary') + check(primary['name'] == 'Primary', 'settings import did not update the matching stable profile ID') + check(primary['keyId'] == metadata['keyId'], 'settings import changed the existing browser key link') + check(len(merged['history']) == 2, 'settings import did not merge SSH history') + check(page.locator('#ssh-save-history').is_checked(), 'settings import did not restore browser preferences') + check( + page.evaluate("keyId => window.terminalTest.browserSshKeyRecordExistsForTest(keyId)", metadata['keyId']), + 'settings import removed the existing browser private key', + ) + + page.click('#quick-settings') + page.click('.settings-nav-item[data-tab="ssh-sessions"]') + page.click('#ssh-profile-list button[data-profile-id="profile-primary"]') + page.wait_for_function("() => document.getElementById('ssh-profile-key-enabled').checked", timeout=5000) + page.fill('#ssh-profile-name', 'Primary Copy') + page.click('#ssh-profile-create') + page.wait_for_function( + """async () => (await window.terminalTest.getSshSessionState()).profiles + .some(profile => profile.name === 'Primary Copy')""", + timeout=5000, + ) + copied_state = page.evaluate('() => window.terminalTest.getSshSessionState()') + copied = next(profile for profile in copied_state['profiles'] if profile['name'] == 'Primary Copy') + check(copied['keyId'] is None, 'Create copied a browser key from the loaded profile') + + page.click('#ssh-profile-list button[data-profile-id="profile-primary"]') + page.wait_for_function("() => document.getElementById('ssh-profile-key-enabled').checked", timeout=5000) + page.once('dialog', lambda dialog: dialog.accept()) + page.click('#ssh-profile-delete') + page.wait_for_function( + """async () => !(await window.terminalTest.getSshSessionState()).profiles + .some(profile => profile.id === 'profile-primary')""", + timeout=5000, + ) + check( + page.evaluate("keyId => window.terminalTest.browserSshKeyRecordExistsForTest(keyId)", metadata['keyId']) is False, + 'deleting a keyed profile left its private key orphaned', + ) + finally: + close_context(context) + + def main(): sync_playwright, PlaywrightError, _ = load_playwright() tests = [ @@ -2297,6 +2558,7 @@ def main(): test_terminal_payload_text_is_not_control, test_ssh_history_and_auto_profile_follow_structured_success, test_ssh_profile_picker_and_settings_save_semantics, + test_browser_ssh_key_lifecycle_and_settings_transfer, ] proc = None browser = None