diff --git a/README.md b/README.md index 0842022..114aaff 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,26 @@ 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. +### Why This SSH Architecture Matters + +| Design choice | Practical advantage | +| --- | --- | +| Non-extractable browser-owned private key | Private key bytes do not cross the browser boundary or enter Python memory, configuration files, settings exports, or terminal payloads. | +| Explicit key creation and per-connection **Use key** control | A key exists only after the user opts in for a saved profile, and password or host-side authentication remains available when key use is off. | +| Typed, short-lived signing requests | Each request is bound to the initiating browser connection, terminal, profile, key, public-key fingerprint, and challenge hash. Expired, replayed, stale, or mismatched responses fail closed. | +| Exact host, port, and username binding | Editing a Quick Connect target cannot silently reuse a profile key for another SSH account or endpoint. | +| Standard OpenSSH Ed25519 public key | The remote host only needs the copied key in `authorized_keys`; it does not need StandTerm, a browser component, or an agent. | +| Separate settings and key stores | Profiles, history, and browser preferences remain portable while private keys and key identifiers stay local to the browser that created them. | + +The signing path keeps authentication authority narrow. Paramiko passes an SSH +challenge to StandTerm's browser-key adapter. StandTerm emits a structured +request only to the browser connection that started that terminal. The browser +validates the active connection and exact profile binding before signing, then +returns a 64-byte Ed25519 signature. Python verifies that signature against the +profile's public key before returning it to Paramiko. A browser disconnect, +timeout, changed connection draft, or stale terminal start cancels the path +without falling back to a password automatically. + **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, @@ -607,6 +627,20 @@ asset README files when publishing releases that include the vendored files. unless remote browser access is intentional. - Do not expose `/agent/external/command` or an `agt_...` token on a network interface. +- A browser authorization URL, the `?token=...&authorize=...` link produced by + the launcher, is a bearer credential that can grant full terminal control. + Minting is restricted to local launcher controls, and the HTTP minting + endpoint additionally requires the launcher token. Redeeming deliberately + does not check the client address, because the feature exists to authorize a + browser reaching StandTerm over a non-loopback address. Within its single-use, + 120-second lifetime, any browser that holds the link and can reach the server + can authorize itself. Treat the link like a password and do not forward it. +- Browser authorization does not expire. An accepted browser is recorded in + `authorized/browsers.json` and stays valid until it is revoked, and the + authorization follows the browser's key rather than its network address. + Revoke browsers that no longer need access. +- The access token lives for the lifetime of the server process and is not + rotated on its own. Restart the launcher to issue a new one. - `standterm_external_agent_handoff.json`, `standterm_external_agent_handoffs/`, `authorized/`, local certs, and venvs are ignored runtime state. - Terminal display payload is data. App control decisions should use typed diff --git a/templates/index.html b/templates/index.html index 8ca4043..01c021e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5953,15 +5953,7 @@

Access token required

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(); - } + socket.connect(); return true; } diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 0f22354..087ac56 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -329,6 +329,35 @@ def test_server_unavailable_waits_for_reconnect(browser, access_url): close_context(context) +def test_retry_now_resubscribes_after_socket_disconnect(browser, access_url): + 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("() => window.terminalTest.disconnectSocketForTest()") + page.wait_for_function( + "() => window.terminalTest.getSocketState().serverConnectionState === 'unavailable'", + timeout=5000, + ) + page.click('#server-retry-now') + page.wait_for_function( + "() => window.terminalTest.getSocketState().connected === true", + timeout=5000, + ) + check( + page.evaluate("() => window.terminalTest.getSocketState().serverConnectionState") == 'available', + 'Retry Now did not restore the Socket.IO namespace subscription', + ) + finally: + close_context(context) + + def test_invalid_session_reconnect_prompts_for_current_token(browser, access_url): parsed = urllib.parse.urlparse(access_url) token = urllib.parse.parse_qs(parsed.query)['token'][0] @@ -2531,6 +2560,7 @@ def main(): test_access_required_page_accepts_token_login, test_browser_authorization_gate_hides_connection_controls, test_server_unavailable_waits_for_reconnect, + test_retry_now_resubscribes_after_socket_disconnect, test_invalid_session_reconnect_prompts_for_current_token, test_agent_panel_can_be_dragged, test_terminal_pip_hides_selected_tab_and_keeps_background_tab,