From 895a8a8926d670fc17170b54cb290ca0f45152b2 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 26 Aug 2026 23:21:26 +0800 Subject: [PATCH 1/3] Add browser-owned SSH key authentication ## Why SSH profiles currently retain targets but authentication still depends on passwords or host-side keys. Moving a browser private key through Python would expand credential exposure, while reconnect backoff also leaves no explicit immediate retry control. ## What changed - Keep non-extractable Ed25519 keys in browser IndexedDB and return only bounded, SID-scoped SSH challenge signatures. - Gate key use to exact saved profiles on loopback or authorized HTTPS, preserving password and host-key fallbacks when Use key is off. - Export and merge browser-owned settings through a versioned Base64 ZIP envelope while excluding keys, credentials, identity, and runtime state. - Add Retry Now to the existing Socket.IO reconnect manager without resending terminal credentials. ## Testing Covered backend payload policy, signature verification, stale signer and background-start rejection, browser key lifecycle, OpenSSH public-key formatting, settings merge and exclusion rules, immediate reconnect, and the existing backend and browser regression suites. --- README.md | 29 ++ app.py | 328 +++++++++++-- templates/index.html | 897 ++++++++++++++++++++++++++++++++++- terminal_backends/ssh.py | 164 ++++++- tests/agent_backend_smoke.py | 210 ++++++++ tests/agent_browser_smoke.py | 262 ++++++++++ 6 files changed, 1837 insertions(+), 53 deletions(-) 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/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 From 70446963a5cc4228d60a5c7605d1617e1d6ebf87 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 26 Aug 2026 23:24:02 +0800 Subject: [PATCH 2/3] Check dependency stamp before importing dependencies ## Why Warm launcher starts import every optional runtime dependency before consulting an install stamp that already records the platform, Python version, and requirements hash. On mounted or network filesystems those imports add several seconds even when the environment is unchanged. ## What changed - Skip dependency imports when the existing install stamp matches the current runtime and requirements. - Preserve install and verification when --force is used or the stamp is missing or stale. - Keep manual recovery through --force or removing the .installed stamp when an environment is modified outside the launcher. ## Testing Covered shell syntax, the current dependency checker, stamp and force branches by inspection, and Windows batch control-flow parity. --- run.bat | 25 +++++++++++-------------- run.sh | 28 ++++++++++++++-------------- 2 files changed, 25 insertions(+), 28 deletions(-) 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 From 11117002e8223bc0385806cfa693625f93054e36 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 26 Aug 2026 23:24:23 +0800 Subject: [PATCH 3/3] Check for screen inside the launch command --- run_at_wsl+screen.bat | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) 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