diff --git a/.changeset/crisp-documentation-screenshots.md b/.changeset/crisp-documentation-screenshots.md new file mode 100644 index 000000000..7778a1425 --- /dev/null +++ b/.changeset/crisp-documentation-screenshots.md @@ -0,0 +1,5 @@ +--- +"@internal/docs": patch +--- + +Recapture documentation screenshots at twice the pixel density, with consistent margins and frames for clearer text and enlarged views. diff --git a/.changeset/crisp-website-screenshots.md b/.changeset/crisp-website-screenshots.md new file mode 100644 index 000000000..4942a2860 --- /dev/null +++ b/.changeset/crisp-website-screenshots.md @@ -0,0 +1,5 @@ +--- +"@internal/website": patch +--- + +Replace feature screenshots with sharp, lossless captures and consistent product-colored frames. diff --git a/.changeset/document-desktop-ssh-keys.md b/.changeset/document-desktop-ssh-keys.md new file mode 100644 index 000000000..c6f358ea4 --- /dev/null +++ b/.changeset/document-desktop-ssh-keys.md @@ -0,0 +1,5 @@ +--- +"@internal/docs": patch +--- + +Explain how to connect with a key from your machine, including browser risks, destination confirmation, temporary local signing, and compatibility requirements. Document retry behavior, passphrase retention, failed-connection cleanup, and resolved addresses in recent suggestions. Remove the outdated authentication screenshot. diff --git a/.changeset/ssh-leaves-the-agent-list.md b/.changeset/ssh-leaves-the-agent-list.md new file mode 100644 index 000000000..bc3608eeb --- /dev/null +++ b/.changeset/ssh-leaves-the-agent-list.md @@ -0,0 +1,10 @@ +--- +"@subshell-ai/plugin-api": minor +"@subshell-ai/plugin-ssh": patch +"@internal/server": patch +"@internal/node": patch +--- + +The New subshell Agent list no longer offers SSH, on web and on mobile. A pick there carries no destination, so it died at ssh's usage prompt; the SSH terminal tab's connect form is the real door, and it keeps working as before. + +Plugins now say this about themselves: a `pickable: false` in the manifest's `subshell` block keeps a plugin out of the launch pickers while leaving it launchable by id. The picker holds no id list; the SSH plugin carries the declaration. diff --git a/.changeset/use-a-key-from-my-machine.md b/.changeset/use-a-key-from-my-machine.md new file mode 100644 index 000000000..c1a64e95c --- /dev/null +++ b/.changeset/use-a-key-from-my-machine.md @@ -0,0 +1,10 @@ +--- +"@internal/server": minor +"@internal/node": patch +"@internal/desktop-server": minor +"@internal/desktop-client": minor +--- + +Connect an SSH subshell using a key selected on your own machine in a browser or desktop app. The key stays in the local signer, authorizes one host-bound authentication, and is released after use or a short timeout. Add native key selection and encrypted-key unlock, an authenticated temporary signing bridge, browser signing with explicit website-trust and memory-cleanup warnings, and guidance on verification and cleanup. + +The desktop picker supports Ed25519, ECDSA and RSA private and host keys. Native RSA uses AWS-LC instead of the RustCrypto RSA backend; browser RSA uses Web Crypto. Failed browser attempts retain the selected file and passphrase for retry until the signing flow succeeds or the form is closed. diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index 683e44094..133af7218 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -131,6 +131,12 @@ execution during `ssh -G`; trust the connecting account’s SSH configuration. F a conflicting pin. Host-pin recovery APIs remain even though their settings UI is gone. Passwords enter through the terminal, are not saved as credentials, and are still visible to trusted transport endpoints. Saved destinations are not trust pins. +**Local key signing** (`/api/ssh/local-key*`, cookie-session only) feeds signatures +through a session-scoped, sequence-checked, one-use bridge: one key, ≤8 requests, +90 s. The browser worker's disposal is a lifetime boundary, NOT a defense against +the page it runs in (that path's trust-the-site checkbox is load-bearing); the +native path keeps private material outside the remote page and approves the +destination natively. ## Network plugins @@ -157,11 +163,11 @@ Design: `docs/superpowers/specs/2026-09-15-updates-design.md` (§15 "Amendments Per-app mechanics (command lists and shapes, assistant screens, the reset path, resolution ladders, entitlement plists, tray/window details) live in each app's `AGENTS.md` and `docs/`; they load when you touch that app; read them before working there. The cross-cutting security rules: -- **The boundary is window KIND, not count** (§8b): commands touching a CLI, config, service manager or filesystem are granted to BUNDLED pages only; each app has one remote window and one bundled page. Each app's ACL manifest is **load-bearing by existence** (no manifest ⇒ Tauri leaves app commands ungated for local windows); the `csp` in `tauri.conf.json` governs bundled pages only; the remote window carries whatever the plane sends. -- **Subshell Server's remote window** opens on `APP_BASE_URL` when https, else the managed server's loopback page; it may NAVIGATE anywhere http(s) (OAuth-proxy sign-in needs it), but the app commands answer only while the COMMITTED main-frame page is on loopback (either spelling, http, any port) or the configured `APP_BASE_URL` (`trust.rs`, keyed on the calling webview). The capability's wildcard `remote.urls` is NOT the boundary. Arming is by committed main-frame load only, never a navigation request or iframe; `on_navigation` refuses OS-actionable schemes and arms nothing. The grant is scoped to the WINDOW, not an admin session. Six of the seven commands cannot touch the machine; `desktop_set_supervision` is the ONE deliberate CLI-touching grant: it applies the restart route's pane-safety refusal (failing closed on an unreadable definition), refuses to interleave with itself (refuse, never queue), and its audit row (`server.supervision.request`) is best-effort, posted by the CALLER: legible, not a control. +- **The boundary is window KIND, not count** (§8b): commands touching a CLI, config, service manager or filesystem are granted to BUNDLED pages only, with one narrow exception: the SSH signer commands let a REMOTE page ask for native confirmation and a native file picker, but it cannot name a file or receive key contents; each app has one remote window, one bundled management page, and a temporary bundled SSH-key unlock window. Each app's ACL manifest is **load-bearing by existence** (no manifest ⇒ Tauri leaves app commands ungated for local windows); the `csp` in `tauri.conf.json` governs bundled pages only; the remote window carries whatever the plane sends. +- **Subshell Server's remote window** opens on `APP_BASE_URL` when https, else the managed server's loopback page; it may NAVIGATE anywhere http(s) (OAuth-proxy sign-in needs it), but the app commands answer only while the COMMITTED main-frame page is on loopback (either spelling, http, any port) or the configured `APP_BASE_URL` (`trust.rs`, keyed on the calling webview). The capability's wildcard `remote.urls` is NOT the boundary. Arming is by committed main-frame load only, never a navigation request or iframe; `on_navigation` refuses OS-actionable schemes and arms nothing. The grant is scoped to the WINDOW, not an admin session. `main` holds eleven commands now: the four `desktop_ssh_key_*` signer commands require HTTPS except on loopback, bind each selection to the COMMITTED page, and need native approval before a key loads; they keep nothing past one signature. Six of the original seven cannot touch the machine; `desktop_set_supervision` is the ONE deliberate CLI-touching grant: it applies the restart route's pane-safety refusal (failing closed on an unreadable definition), refuses to interleave with itself (refuse, never queue), and its audit row (`server.supervision.request`) is best-effort, posted by the CALLER: legible, not a control. - **The Server assistant (`wizard`, bundled)** owns first run, recovery, update and reset. `desktop_reset` takes ONLY a typed hostname (compared to Rust's memoized `hostname(1)`), never a path; deletion paths come all-or-nothing from the server's own `status --json` `paths` block (partial = refuse); a recursive delete that would contain the installed `~/.local/bin/subshell-server` is refused before any mutation; a reset does NOT reach enrolled remote nodes or a same-machine `subshell` agent. - **Server config routes** (cookie-admin, bearer refused) rewrite config.env for port, bind, base URL, trusted origins; NEVER `DATABASE_PATH`, never the auth secret; a key sourced from process env is refused 409, never written (a write the next boot masks is a fake success). Restart is refused unless the manager reports THIS pid and again when the definition would take tmux panes down absent `{force:true}`. **Stop, start, install, uninstall and reset have NO route**: each leaves the server unreachable; they stay with the CLI and the assistant. The config validator is SHARED with the CLI through one `applyConfig`; never mirror it. -- **Subshell Client's remote window** is granted exactly ONE command, `desktop_open_in_browser`: it takes a PATH (`/`-prefixed, validator shared in `crates/desktop-core`; no authority, no backslash, no whitespace/control) and joins it onto `PlanePin`, the origin the window was OPENED with (moved by a deliberate plane switch, never by a redirect). Refusals are invisible by design; the window's `SubshellClient/…` marker is what `isServerDesktop()`/`isDesktop()` gate on in the SPA. Everything privileged lives on the **bundled node page** (`capabilities/node.json`): reset/enroll/unenroll/service/install-agent, plane verbs, updater verbs. A pasted setup key reaches `subshell enroll --key` as one argv element; the returned node key is never surfaced; enrolling twice is destructive, so the app spawns nothing until confirmed. +- **Subshell Client's remote window** is granted FIVE narrow commands: `desktop_open_in_browser` plus the four `desktop_ssh_key_*` signer commands. The signer commands bind to `PlanePin`, require HTTPS except on loopback, and require native approval before a key loads; the page never names a file or receives key material or a passphrase (§8b "Use a key from my machine"). `desktop_open_in_browser` takes a PATH (`/`-prefixed, validator shared in `crates/desktop-core`; no authority, no backslash, no whitespace/control) and joins it onto `PlanePin`, the origin the window was OPENED with (moved by a deliberate plane switch, never by a redirect). Refusals are invisible by design; the window's `SubshellClient/…` marker is what `isServerDesktop()`/`isDesktop()` gate on in the SPA. Everything privileged lives on the **bundled node page** (`capabilities/node.json`): reset/enroll/unenroll/service/install-agent, plane verbs, updater verbs. A pasted setup key reaches `subshell enroll --key` as one argv element; the returned node key is never surfaced; enrolling twice is destructive, so the app spawns nothing until confirmed. - **Entitlements:** each app keeps a SEPARATE plist (one Tauri slot per bundle; sharing one silently widens whichever pipeline was not edited), carrying only `allow-jit` + `allow-unsigned-executable-memory`, pinned by test; `disable-library-validation`, `allow-dyld-environment-variables` and `disable-executable-page-protection` are ABSENT. - **They execute what they find:** each resolution ladder runs ` version`, and the installed copy in `~/.local/bin` is executed on every launch from a user-writable directory (same OS user; accepted. But validate a hand-chosen binary before persisting it and bound every spawn); the login-shell PATH probe runs the user's own profile, arbitrary code by construction. - **Desktop-as-supervisor:** the server believes `SUBSHELL_SUPERVISOR*` only when the named pid is its actual parent; a forged claim buys `restart.available: true` AND pane-safety-refusal suppression, accepted because the forger is whoever launched the process. The app signals the MAIN pid, never the process group, which earns its `paneSafety: "keeps"`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efef8103d..0cde760b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -746,6 +746,16 @@ jobs: # whichever Mac image the fleet hands the shard. targets: aarch64-apple-darwin,x86_64-apple-darwin + - name: Ensure native crypto build dependencies + if: startsWith(matrix.app, 'desktop-') + run: | + if [ "$(uname -s)" = Linux ]; then + apt-get update + apt-get install -y --no-install-recommends cmake clang libclang-dev + elif ! command -v cmake >/dev/null; then + brew install cmake + fi + - name: Cache cargo if: startsWith(matrix.app, 'desktop-') uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -1369,6 +1379,16 @@ jobs: # whichever Mac image the fleet hands the shard. targets: aarch64-apple-darwin,x86_64-apple-darwin + - name: Ensure native crypto build dependencies + if: startsWith(matrix.app, 'desktop-') + run: | + if [ "$(uname -s)" = Linux ]; then + apt-get update + apt-get install -y --no-install-recommends cmake clang libclang-dev + elif ! command -v cmake >/dev/null; then + brew install cmake + fi + - name: Cache cargo if: startsWith(matrix.app, 'desktop-') uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69b422634..d65fc6573 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -243,6 +243,11 @@ jobs: - name: Build workspace packages run: bun run build + # Browser signer tests generate real OpenSSH key fixtures. + - name: Ensure OpenSSH key generation + run: | + command -v ssh-keygen >/dev/null || { apt-get update && apt-get install -y --no-install-recommends openssh-client; } + - name: Run web tests run: bunx turbo run test --continue --filter=@internal/server-web @@ -775,6 +780,11 @@ jobs: # The image installs rustup with `--profile minimal`, which does NOT # carry rustfmt or clippy — and this job is nothing but those two plus # tests. Idempotent, so it is a no-op once the image bakes them in. + - name: Ensure native crypto build dependencies + run: | + apt-get update + apt-get install -y --no-install-recommends cmake clang libclang-dev + - name: Ensure rustfmt and clippy run: | set -euo pipefail @@ -850,6 +860,11 @@ jobs: - name: Trust the bind-mounted workspace run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Ensure native crypto build dependencies + run: | + apt-get update + apt-get install -y --no-install-recommends cmake clang libclang-dev + - name: Ensure rustfmt and clippy run: | set -euo pipefail @@ -1040,7 +1055,9 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report - path: e2e/playwright-report/ + path: | + e2e/playwright-report/ + e2e/test-results/ retention-days: 7 - name: Un-root the workspace diff --git a/AGENTS.md b/AGENTS.md index c9ee1ef89..56be73d4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,9 +178,11 @@ Client, as a second window: whoever makes their laptop a node is usually also watching subshells on it, and shipping that as two installs would ask a user to understand a split that serves only us. **That window existing is the whole "node functionality" toggle**; there is no mode flag. The window loading the -control plane's page is granted exactly ONE Tauri command ("open this page in -the system browser", which takes a path and can name no host), and everything -privileged lives on the bundled node page; see `apps/client/desktop/AGENTS.md`. +control plane's page is granted narrow Tauri commands for opening a path in the system browser and +one-use native SSH signing. The signer requires native destination approval, +keeps keys and passphrase entry outside the remote page, checks the committed +pinned origin, and ends access after one signature or 90 seconds. General node +management stays on the bundled node page; see `apps/client/desktop/AGENTS.md`. `apps/server/web` is the SERVER's SPA and nothing else's, which is what the nesting says out loud. `apps/client/mobile` is a client because it is a person's @@ -379,6 +381,14 @@ this is the human-facing statement of it.) ### Development +**When the user asks to spin up development for testing, always provide the +hot-reload frontend URL**, normally `http://localhost:5174`, together with +the test login. Start Vite against an isolated backend using +`DEV_API_ORIGIN` (see `apps/server/web/AGENTS.md`) and verify login through +that URL. Don't hand over the backend's built-SPA URL as the testing entry +point: UI edits should appear without manual rebuilds or page refreshes. +Preserve an existing test account and database when possible. + ```bash bun run start # Start dev mode with watch (turbo watch dev) turbo watch dev # Same as above diff --git a/apps/client/desktop/AGENTS.md b/apps/client/desktop/AGENTS.md index c9c8fa09e..fecd9e1d8 100644 --- a/apps/client/desktop/AGENTS.md +++ b/apps/client/desktop/AGENTS.md @@ -21,12 +21,13 @@ once. shadcn colour names), and `bun run lint:design` fails on a literal size, weight or colour outside the token file. Pick a role, never a number. -## Two windows, and why that is the whole design +## Window authority | window | page | granted | |---|---|---| -| `main` | the control plane's own UI, at the plane's origin | **one command**: `desktop_open_in_browser` | +| `main` | the control plane's own UI, at the plane's origin | `desktop_open_in_browser` and four constrained `desktop_ssh_key_*` commands | | `node` | `ui/dist/index.html`, the bundled assistant | every `node_*` command | +| `ssh-key-unlock` | a separate bundled passphrase form | only `desktop_ssh_key_unlock` | `main` loads the plane's own page rather than a bundled copy because `apps/server/web` is hard same-origin (relative `apiFetch` with @@ -34,59 +35,41 @@ weight or colour outside the token file. Pick a role, never a number. built from `window.location.host`), so a `tauri://` page could not carry the `SameSite=Lax` session cookie to any of them. -**`capabilities/main.json` grants that window ONE command, and the narrowness -is in the ARGUMENT rather than in the scope.** `apps/server/desktop` can pin -its remote window to loopback because it manages the server serving it; a -control plane can live on any host, so there is no equivalent pin here. The -capability's `remote.urls` is therefore a WILDCARD (`http://*:*`, -`https://*:*`; `http://*` alone does not match a non-default port in the -`urlpattern` crate Tauri 2.11.5 uses, which would silently exclude the default -`:3080` plane), and the command is what has to be safe. - -Until 2026-09-14 that file did not exist, and the ABSENCE was the boundary. -That was right while a plane's page had nothing useful to ask for. "Open in -browser" is useful: a webview has no address bar and no second tab, and the -page a person wants in their real browser (with their profiles, their password -manager, their extensions) is the one they are looking at. - -- **`desktop_open_in_browser` takes a PATH.** It must start with `/`, must not - start with `//` (protocol-relative names a HOST), and may contain no `://`, - no backslash (`\` is `/` to the WHATWG URL parser, so `/\evil.test` is the - same attack in another spelling), no whitespace and no control characters. - The rule and its tests are `crates/desktop-core`'s `browser` module, shared - with the other app so the two cannot disagree about what a path is. The - ORIGIN comes from `PlanePin`: the origin this window was OPENED with, which - is no longer what `on_navigation` enforces (it follows any http(s) URL since - 2026-09-18, so a plane behind an OAuth proxy can complete its sign-in). That - is precisely why the command reads the pin rather than the page: a page - anywhere a redirect leads can still only open a path of the plane this window - opened with. So - the page names the route and Rust names the host. The worst an XSS in a - plane's SPA gains is opening a page of that same plane, which the person can - do by typing the address. -- **Nothing else.** No `node_*` verb, no plugin permission, no `core:default`; - `ui/src/__tests__/ipc-acl.test.ts` pins the scope, pins the permission list - at one entry, asserts every other manifest permission is the bundled page's, - and pins the Rust signature at `(app, path)`. -- **A `SubshellClient/…` user-agent marker, new and paired with that grant.** - The marker is how `apps/server/web` decides it is in a desktop shell and - starts talking to Tauri; it was deliberately absent while nothing was - granted, because every call it invited would have been refused one at a time. - The SPA branches on the PRODUCT TOKEN now: `isServerDesktop()` gates Subshell - Server's chrome (overlay title bar, update, reset, supervision, native - notifications) and is false here, while `isDesktop()` gates only the two - "Open in browser" surfaces, the sidebar row and a subshell's actions menu. - So there is still no `shell_ready` handshake and no `bridge.rs` CustomEvent - bus here: this window's whole Tauri surface is one command. - `withGlobalTauri` is `true` for the same reason (see below). -- **What IS kept:** an `on_navigation` SCHEME refusal (the origin pin went on - 2026-09-18, see above; the window follows http(s) so a proxied sign-in - works, and non-http(s) is still refused so it cannot be steered into - anything the OS would act on), `disable_drag_drop_handler` - (Tauri's native file-drop handler otherwise swallows the HTML5 drags behind - drag-a-subshell-into-a-workspace and the terminal's uploads), and a 360x240 - minimum size (a third of the SPA's 1024px tiling breakpoint), so the window - can be parked in a corner and renders the SPA's narrow chrome when it is. +**The remote page holds browser navigation and constrained local SSH signing.** +`capabilities/main.json` uses a wildcard http(s) scope because a chosen plane +can live anywhere and OAuth redirects must work. That scope is not authority: +`desktop_open_in_browser` joins a validated path to `PlanePin`, and +`ssh_key.rs` allows local key operations only when the last committed +main-frame origin equals that pin. HTTPS is required except on loopback. +Provisional navigation URLs must never decide this check; `on_page_load`'s +committed callback records the origin and cancels any previous signer. + +The four `desktop_ssh_key_*` commands expose availability, native key picking, +public SSH-agent requests, and release. Picking first shows a native approval +with the full SSH destination and host fingerprint and asks the person to +verify it independently; the native picker starts in `~/.ssh`. No page can +supply a file path. An encrypted key opens `ssh-key-unlock`, whose separate +local capability grants only its one-use passphrase receiver. Private keys and +passphrases never reach the control plane page or the server. + +The shared `desktop-core::ssh_signer` verifies OpenSSH's session binding and +signs exactly one host-bound authentication for the approved host key and user. +It refuses arbitrary signing, other users or host keys, forwarding, rebinding, +and RSA SHA-1. Native RSA uses AWS-LC through the OpenSSL bindings, with +ssh-key’s RustCrypto RSA backend kept disabled; browser RSA uses Web Crypto. +AWS-LC cleanses the key allocations it releases; transient parser copies, +physical RAM erasure and operating-system copies cannot be guaranteed. Keys +expire after 90 seconds from reading, +and native timers, navigation, window close, and explicit release clean up +without depending on the remote page. See the +[signer boundary](../../server/desktop/docs/ipc-boundary.md#local-ssh-keys). + +No `node_*` command or generic file, dialog, event, or opener permission is +available to the plane window. `ipc-acl.test.ts` pins the exact command sets, +including the unlock window's separate authority. The SPA detects this app +through its `SubshellClient` user-agent token and `withGlobalTauri`; the global +itself grants no command. `disable_drag_drop_handler` preserves HTML drags, +and navigation still refuses schemes other than http(s). **The node window is a FIXED 1024x720 assistant frame** (spec 2026-09-12 § 6.4), non-resizable and centred, and the window-state plugin is DENYLISTED for it. @@ -137,8 +120,10 @@ Three things about it are load-bearing: generates it, so `ui/src/__tests__/ipc-acl.test.ts` reads `permissions/desktop.toml` and `capabilities/node.json` and asserts the granted command set is exactly the invoked one. That three-way mismatch is a - runtime permission rejection, not a compile error. The same file asserts what - the `main` window holds: one permission, one scope, one Rust signature. + runtime permission rejection, not a compile error. The same file pins the + `main` window's five commands (open-in-browser plus the four 2026-10-14 SSH + signer commands) against those sources and its wildcard scope, and pins the + one signature a remote page could aim anywhere: the browser command's. - **`withGlobalTauri` is `true`, and it is the OTHER window that needs it.** This page imports `invoke` from `@tauri-apps/api/core` and has no use for a global; it was `false` from the day that import landed. What changed on @@ -147,8 +132,10 @@ Three things about it are load-bearing: and imports nothing by design: it must not pull `@tauri-apps/api` into a bundle served to browsers, and the repo forbids the dynamic import that would avoid that. The global GRANTS nothing; `capabilities/main.json` does. Turning - it back off would not close a hole, it would make that one command silently - unreachable: the bridge never throws, which is exactly the failure + it back off would not close a hole, it would make the plane window's five + commands silently unreachable (the bridge in `src/lib/desktop.ts` invokes the + browser command and all four SSH signer commands through the global): the + bridge never throws, which is exactly the failure `apps/server/desktop` measured on 2026-09-10 when its config was copied from this one. `ui/src/__tests__/tauri-config.test.ts` pins the PAIR: while `windows.rs` ships a marker, the global must exist. diff --git a/apps/client/desktop/docs/tray.md b/apps/client/desktop/docs/tray.md index 426ef1162..245dfb6b9 100644 --- a/apps/client/desktop/docs/tray.md +++ b/apps/client/desktop/docs/tray.md @@ -8,8 +8,8 @@ summary and routes here. The text below is verbatim. (Split from AGENTS.md's "Two windows"; it is tray mechanics.) **It must always have a route home, and the tray is not one.** The plane window -is remote content whose one grant opens a browser, so it cannot offer a way -back into this app, and a tray +is remote content whose grants are a browser path and one-use SSH signing, +neither a way back into this app, and a tray icon is silently invisible wherever no StatusNotifier host is registered. So: macOS gets **Window → Open Client App** in the menu bar (always drawn, which also covers the notched-display hazard in `tray.rs`); everywhere else, diff --git a/apps/client/desktop/docs/zoom.md b/apps/client/desktop/docs/zoom.md index 41ff6f054..65dfeb048 100644 --- a/apps/client/desktop/docs/zoom.md +++ b/apps/client/desktop/docs/zoom.md @@ -20,7 +20,7 @@ no command to a page it has never granted one. **Tauri's own `zoom_hotkeys_enabled` was rejected, and the reason is the trust boundary.** On macOS and Linux it injects a page script that invokes `plugin:webview|set_webview_zoom`, so it works only on a window granted that -command, and this app's `main` window is granted exactly one, which opens a browser rather than resizing anything, since a control plane's origin cannot be enumerated ahead of time and the grant there has to stay argument-narrow. It also keeps its level in a page-local variable, which a +command, and this app's `main` window is granted five narrow commands (a browser opener and the four SSH signer commands), none of which resizes anything, since a control plane's origin cannot be enumerated ahead of time and the grants there have to stay argument-narrow. It also keeps its level in a page-local variable, which a reload resets. `set_zoom` called from Rust touches no ACL at all. **Both menus' items share one id set, and it is routed in exactly one place**: diff --git a/apps/client/desktop/src-tauri/Cargo.lock b/apps/client/desktop/src-tauri/Cargo.lock index 46e4d3ff1..9e02b665e 100644 --- a/apps/client/desktop/src-tauri/Cargo.lock +++ b/apps/client/desktop/src-tauri/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -222,6 +257,25 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -240,6 +294,43 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -279,6 +370,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -301,6 +401,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "brotli" version = "9.0.0" @@ -426,6 +536,15 @@ dependencies = [ "toml 1.1.5+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.5" @@ -433,7 +552,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", - "shlex", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] @@ -442,6 +563,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfb" version = "0.7.3" @@ -480,6 +610,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "chrono" version = "0.4.45" @@ -492,6 +633,36 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.8" @@ -511,6 +682,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cookie" version = "0.18.2" @@ -556,7 +733,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -604,6 +781,18 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -643,6 +832,41 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.23.0" @@ -719,6 +943,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -767,7 +1001,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -897,6 +1133,66 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -1006,6 +1302,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1055,6 +1367,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1062,7 +1383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1076,6 +1397,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1091,6 +1418,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -1282,6 +1615,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1318,6 +1652,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -1414,6 +1758,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1502,6 +1857,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.39.0" @@ -1796,6 +2160,16 @@ dependencies = [ "cfb 0.14.0", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1821,6 +2195,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1977,6 +2360,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.105" @@ -2020,6 +2413,15 @@ dependencies = [ "serde", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2040,7 +2442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -2069,6 +2471,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.23" @@ -2137,6 +2555,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.5" @@ -2231,12 +2655,57 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2244,6 +2713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2496,6 +2966,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.3" @@ -2507,12 +2983,50 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "aws-lc-sys", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2543,6 +3057,44 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + [[package]] name = "pango" version = "0.18.3" @@ -2597,6 +3149,24 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2673,6 +3243,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2732,6 +3323,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2762,12 +3376,40 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2860,6 +3502,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2974,6 +3646,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rfd" version = "0.16.0" @@ -3012,6 +3694,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3194,6 +3897,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3416,6 +4133,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -3432,6 +4155,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3530,6 +4263,72 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3590,14 +4389,21 @@ dependencies = [ name = "subshell-desktop-core" version = "0.1.0" dependencies = [ + "base64 0.22.1", "block2", + "libc", "objc2", "objc2-foundation", "objc2-photos", "objc2-user-notifications", + "openssl", + "rand", "serde", "serde_json", + "signature", + "ssh-key", "wait-timeout", + "zeroize", ] [[package]] @@ -4517,6 +5323,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4566,6 +5382,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -5533,6 +6355,26 @@ dependencies = [ "serde", ] +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/apps/client/desktop/src-tauri/capabilities/main.json b/apps/client/desktop/src-tauri/capabilities/main.json index caf404c2a..2a4baad15 100644 --- a/apps/client/desktop/src-tauri/capabilities/main.json +++ b/apps/client/desktop/src-tauri/capabilities/main.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main", - "description": "The window showing a CONTROL PLANE's own page. A plane can live on any host, so unlike apps/server/desktop's loopback window this one's origin cannot be enumerated here, which is why the scope is a wildcard and why the narrowness has to live in the COMMAND instead. It gets exactly one: desktop_open_in_browser, which takes a PATH (no scheme, no protocol-relative '//host', no backslash, no whitespace or control characters) and joins it onto the origin windows.rs's PlanePin already holds, the same value that window's navigation guard enforces. So a compromised plane page can open a page of ITSELF in the person's browser, which they could do by typing the address. No node_* verb, no plugin permission, and no core:default: everything that drives the subshell CLI is granted to the bundled `node` window and to nothing else. The wildcard scope is deliberate and is NOT a widening of who may invoke: any page loaded in this window is the plane the person chose, because on_navigation pins the window to that origin. Adding a second permission here is a change to the trust model; see docs/security.md, and the pin in ui/src/__tests__/ipc-acl.test.ts.", + "description": "The control plane page. Browser navigation takes only a path joined to the chosen PlanePin. Local SSH key commands require the committed main-frame origin to match that pin, HTTPS except loopback, and native destination approval before a native file picker. The remote page receives only an opaque signer id, public identity, and public SSH-agent replies. No node commands, file paths, private keys, passphrases, generic filesystem/dialog grants, or unlock-form authority. The wildcard permits OAuth redirects; redirect pages cannot use the signer.", "local": false, "remote": { "urls": [ @@ -13,6 +13,10 @@ "main" ], "permissions": [ - "allow-desktop-open-in-browser" + "allow-desktop-open-in-browser", + "allow-desktop-ssh-key-available", + "allow-desktop-ssh-key-pick", + "allow-desktop-ssh-key-request", + "allow-desktop-ssh-key-close" ] } diff --git a/apps/client/desktop/src-tauri/capabilities/ssh-key-unlock.json b/apps/client/desktop/src-tauri/capabilities/ssh-key-unlock.json new file mode 100644 index 000000000..65dfacd2c --- /dev/null +++ b/apps/client/desktop/src-tauri/capabilities/ssh-key-unlock.json @@ -0,0 +1,10 @@ +{ + "identifier": "ssh-key-unlock", + "description": "Bundled local SSH key passphrase form. No remote URLs, filesystem access, events, or other commands.", + "windows": [ + "ssh-key-unlock" + ], + "permissions": [ + "allow-desktop-ssh-key-unlock" + ] +} diff --git a/apps/client/desktop/src-tauri/permissions/desktop.toml b/apps/client/desktop/src-tauri/permissions/desktop.toml index 067a3056d..c7ae64665 100644 --- a/apps/client/desktop/src-tauri/permissions/desktop.toml +++ b/apps/client/desktop/src-tauri/permissions/desktop.toml @@ -12,16 +12,21 @@ # the bundled `node` page and nothing else; nothing that drives the `subshell` # CLI is reachable from a page this repo did not ship. # -# `main` holds exactly ONE permission (2026-09-14), -# `allow-desktop-open-in-browser`, and it is named `desktop_` rather than -# `node_` to say out loud that it is not a node verb. A control plane can live -# on any host, so `capabilities/main.json`'s scope is a WILDCARD; -# `apps/server/desktop` can pin its remote window to loopback because it manages -# the server serving it, and there is no equivalent pin here. The narrowness -# therefore lives in the command's ARGUMENT instead: a path, refused if it could -# name a host, joined onto the origin that window is already pinned to. A second -# permission here is a change to the trust model, not a convenience; see -# `docs/security.md` and the pin in `ui/src/__tests__/ipc-acl.test.ts`. +# `main` holds FIVE permissions: `allow-desktop-open-in-browser` (2026-09-14) +# and the four one-use `allow-desktop-ssh-key-*` commands (2026-10-14), all +# named `desktop_` rather than `node_` to say out loud that none is a node +# verb. A control plane can live on any host, so `capabilities/main.json`'s +# scope is a WILDCARD; `apps/server/desktop` can pin its remote window to +# loopback because it manages the server serving it, and there is no equivalent +# pin here. The narrowness therefore lives in each command's ARGUMENT and +# checks: the browser command takes a path, refused if it could name a host, +# joined onto the origin that window is already pinned to. The key commands are +# the argued exception to "filesystem verbs stay bundled": they ask the person +# for one native approval, can name no file themselves (the OS picker answers), +# and carry no key material or passphrase back to the page - see the SSH +# signer's four-command grant in `docs/security.md` §8b. A sixth permission +# here is a change to the trust model, not a convenience; +# `ui/src/__tests__/ipc-acl.test.ts` pins the exact set. "$schema" = "schemas/schema.json" @@ -82,7 +87,7 @@ commands.allow = ["node_settings"] [[permission]] identifier = "allow-node-open-plane" -description = "Open the window showing a control plane's own UI, at the address the bundled page names, validated http/https. Opens are one-off (operator ruling 2026-09-22, the plane list): it stores nothing. That window holds exactly ONE command of its own, allow-desktop-open-in-browser, which takes a path and can name no host; nothing that drives the CLI is reachable from it." +description = "Open the window showing a control plane's own UI, at the address the bundled page names, validated http/https. Opens are one-off (operator ruling 2026-09-22, the plane list): it stores nothing. That window holds five narrow commands of its own: allow-desktop-open-in-browser, which takes a path and can name no host, plus the four one-use local SSH signing commands of 2026-10-14; nothing that drives the CLI is reachable from it." commands.allow = ["node_open_plane"] [[permission]] @@ -127,5 +132,30 @@ commands.allow = ["node_install_app_update"] [[permission]] identifier = "allow-desktop-open-in-browser" -description = "Open one page of the control plane this window is already on, in the system browser. `desktop_`, not `node_`, because it is the one permission in this file that is NOT a node verb: it is granted to `main` (the plane's own page) and to nothing else, which is the opposite of every rule above it. It takes a PATH and nothing else (no scheme, no protocol-relative '//host', no backslash, no whitespace or control characters; see crates/desktop-core's browser::browser_url) and joins it onto the origin windows.rs's PlanePin holds, so the page names the route and this side names the host. The tray and menu-bar routes to the same act need no permission at all: they run in Rust." +description = "Open one page of the control plane this window is already on, in the system browser. `desktop_`, not `node_`, because it is not a node verb: like the four one-use local SSH signing commands of 2026-10-14, it is granted to `main` (the plane's own page) and to nothing else, which is the opposite of every rule above it. It takes a PATH and nothing else (no scheme, no protocol-relative '//host', no backslash, no whitespace or control characters; see crates/desktop-core's browser::browser_url) and joins it onto the origin windows.rs's PlanePin holds, so the page names the route and this side names the host. The tray and menu-bar routes to the same act need no permission at all: they run in Rust." commands.allow = ["desktop_open_in_browser"] + +[[permission]] +identifier = "allow-desktop-ssh-key-available" +description = "Local SSH signing: available. Native approval, origin binding, host-bound authentication, one signature, and a 90-second lifetime apply." +commands.allow = ["desktop_ssh_key_available"] + +[[permission]] +identifier = "allow-desktop-ssh-key-pick" +description = "Local SSH signing: pick. Native approval, origin binding, host-bound authentication, one signature, and a 90-second lifetime apply." +commands.allow = ["desktop_ssh_key_pick"] + +[[permission]] +identifier = "allow-desktop-ssh-key-request" +description = "Local SSH signing: request. Native approval, origin binding, host-bound authentication, one signature, and a 90-second lifetime apply." +commands.allow = ["desktop_ssh_key_request"] + +[[permission]] +identifier = "allow-desktop-ssh-key-close" +description = "Local SSH signing: close. Native approval, origin binding, host-bound authentication, one signature, and a 90-second lifetime apply." +commands.allow = ["desktop_ssh_key_close"] + +[[permission]] +identifier = "allow-desktop-ssh-key-unlock" +description = "Local SSH signing: unlock. Native approval, origin binding, host-bound authentication, one signature, and a 90-second lifetime apply." +commands.allow = ["desktop_ssh_key_unlock"] diff --git a/apps/client/desktop/src-tauri/src/control.rs b/apps/client/desktop/src-tauri/src/control.rs index c93dd68fb..d81c92d23 100644 --- a/apps/client/desktop/src-tauri/src/control.rs +++ b/apps/client/desktop/src-tauri/src/control.rs @@ -1673,11 +1673,12 @@ pub fn node_plane_remove( /// which is what keeps `desktop_open_in_browser` aimed at the plane the row /// named rather than at whatever the page last was. /// -/// The window this opens is granted exactly ONE command — see +/// The window this opens is granted five narrow commands — see /// `windows::open_plane` — so this is nearly the whole of the plane's reach -/// into the app: a webview, an origin pin, and `desktop_open_in_browser`, -/// which takes a path and joins it onto that same pin. Nothing that drives the -/// CLI is reachable from it. +/// into the app: a webview, an origin pin, `desktop_open_in_browser`, which +/// takes a path and joins it onto that same pin, and the four one-use +/// `desktop_ssh_key_*` commands, which never see a key or a passphrase. +/// Nothing that drives the CLI is reachable from it. #[tauri::command(async)] pub fn node_open_plane(app: AppHandle, url: String) -> Result { let resolved = validate_server_url(&url)?; @@ -1800,8 +1801,10 @@ pub const MENU_BROWSER_ID: &str = "menu:browser"; /// Open one page of the control plane this window is on, in the system browser. /// -/// **The only command granted to the plane's window**, and the only one that -/// ever will be without the argument in `docs/security.md` being rewritten. It +/// **Granted to the plane's window**, as one of five narrow commands — and no +/// sixth joins them without the argument in `docs/security.md` being written +/// first (the four `desktop_ssh_key_*` commands of 2026-10-14 arrived that +/// way). It /// is safe for a window whose origin this app cannot enumerate because it names /// no origin: the page supplies a PATH, /// `subshell_desktop_core::browser::browser_url` refuses anything that could be @@ -2045,7 +2048,8 @@ pub fn node_pending_screen(app: AppHandle) -> Option { /// Read-only and node-window-only. Read-only because it fetches one JSON list /// and asks the updater plugin to verify one manifest; node-window-only for /// the same reason every other `node_*` command is — the plane's window is a -/// control plane's own page, and it holds one command that opens a browser. +/// control plane's own page, and what it holds is a browser path and four +/// one-use signing commands, never an updater. #[tauri::command(async)] pub async fn node_check_app_update(app: AppHandle) -> Result { crate::app_update::check_app_update(&app).await diff --git a/apps/client/desktop/src-tauri/src/lib.rs b/apps/client/desktop/src-tauri/src/lib.rs index d068fede0..ddbca8213 100644 --- a/apps/client/desktop/src-tauri/src/lib.rs +++ b/apps/client/desktop/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ mod app_update; mod control; mod node_bin; mod reset; +mod ssh_key; // macOS only: a GTK menu bar is per-window chrome rather than a system bar, so // Linux has none — and a module compiled there would be entirely dead code. // Gated at the MODULE, never at the call site, so `cargo clippy` on Linux sees @@ -48,9 +49,13 @@ const SETTINGS_PATHS: SettingsPaths = SettingsPaths { /// Windows whose geometry the window-state plugin must not save or restore. /// -/// One name, and it is the assistant's. Kept as a constant so the plugin's -/// configuration and the test that pins it read the same string. -const DENYLIST: &[&str] = &[windows::NODE_LABEL]; +/// The assistant, and the SSH-key unlock window opened by `ssh_key.rs` +/// (2026-10-14). Both are fixed, centred, non-resizable frames, and this is +/// the geometry trap the app's own notes record: the plugin restores AFTER +/// the builder's `center()`, so a tracked one would open wherever the last +/// pick left it. Kept as a constant so the plugin's configuration and the +/// test that pins it read the same string. +const DENYLIST: &[&str] = &[windows::NODE_LABEL, ssh_key::UNLOCK]; /// Environment variables this app refuses to pass on to the agent. /// @@ -159,6 +164,7 @@ pub fn run() { // before handing the plugin exactly one URL. .plugin(tauri_plugin_updater::Builder::new().build()) .manage(SettingsState::new(SETTINGS_PATHS)) + .manage(ssh_key::State::default()) .manage(windows::PlanePin::new()) .manage(windows::PendingScreen::new()) .manage(reset::Stash::default()) @@ -180,12 +186,30 @@ pub fn run() { control::node_open_plane, control::node_open_plane_url, control::desktop_open_in_browser, + ssh_key::desktop_ssh_key_available, + ssh_key::desktop_ssh_key_pick, + ssh_key::desktop_ssh_key_request, + ssh_key::desktop_ssh_key_close, + ssh_key::desktop_ssh_key_unlock, control::node_check_app_update, control::node_install_app_update, reset::node_arm_reset, reset::node_reset, ]) .on_window_event(|window, event| { + if matches!( + event, + tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed + ) { + ssh_key::closed( + window.app_handle(), + window.label(), + matches!(event, tauri::WindowEvent::Destroyed), + ); + } + if window.label() == "ssh-key-unlock" { + return; + } if let tauri::WindowEvent::CloseRequested { api, .. } = event { // Close-to-tray is opt-in, and the check RE-PROBES the desktop // here rather than trusting the stored preference: where no @@ -207,6 +231,7 @@ pub fn run() { }) .setup(|app| { let handle = app.handle().clone(); + ssh_key::start_expiry(handle.clone()); #[cfg(target_os = "macos")] app.set_menu(menu::build(&handle)?)?; // Registered on EVERY platform, and it is the ONE place text @@ -304,6 +329,18 @@ mod tests { assert_ne!(SETTINGS_PATHS.linux_dir, "subshell-desktop-server"); } + /// Both are fixed, centred, non-resizable frames, so there is nothing a + /// person could have chosen — while `main`'s size IS such a choice, which + /// is why this denies labels rather than dropping `StateFlags::SIZE` + /// wholesale. Pinned against the literals rather than trusted to the + /// comment beside the constant: the symptom of a missed entry is a window + /// that opens at the wrong size and nothing else — no error, no failing + /// build. + #[test] + fn fixed_frames_are_not_tracked_by_the_window_state_plugin() { + assert_eq!(DENYLIST, ["node", "ssh-key-unlock"]); + } + /// The Linux directory is NOT the agent CLI's config home. That CLI keeps /// `config.json` and the node key in `~/.config/subshell`; a shared /// directory would mix two programs' state. diff --git a/apps/client/desktop/src-tauri/src/menu.rs b/apps/client/desktop/src-tauri/src/menu.rs index 8e91db8f2..efaa31997 100644 --- a/apps/client/desktop/src-tauri/src/menu.rs +++ b/apps/client/desktop/src-tauri/src/menu.rs @@ -8,8 +8,8 @@ //! load-bearing.** //! Every other item here is a predefined system action, but this app's node //! window can otherwise be reached only from the tray — the plane window is -//! remote content whose one grant opens a browser, so it cannot offer a route -//! back to this app — and a macOS +//! remote content whose grants are a browser path and one-use SSH signing, +//! neither of which is a route back to this app — and a macOS //! status item is silently invisible on a notched display whose Control Center //! has wedged (see `tray.rs`). A menu bar is always drawn, so this is the route //! that cannot disappear. Linux has no menu bar at all, which is why diff --git a/apps/client/desktop/src-tauri/src/ssh_key.rs b/apps/client/desktop/src-tauri/src/ssh_key.rs new file mode 100644 index 000000000..b0e27140b --- /dev/null +++ b/apps/client/desktop/src-tauri/src/ssh_key.rs @@ -0,0 +1,434 @@ +//! Desktop-only local key bridge. No command accepts a filesystem path. +use std::sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Mutex, +}; +use std::time::{Duration, Instant}; +use subshell_desktop_core::ssh_signer::{self, Owner, Reply, Secret, Selected, Signers}; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; + +const DEV_PORT: u16 = 5177; +pub(crate) const UNLOCK: &str = "ssh-key-unlock"; +const REFUSAL: &str = "This page can't use local SSH keys. Return to your control plane and try again."; + +struct Unlock { + sender: mpsc::Sender>, + deadline: Instant, +} +#[derive(Default)] +pub struct State { + signers: Mutex, + // The committed document, never WebKit's provisional active URL. + page: Mutex>, + unlock: Mutex>, + picking: AtomicBool, +} + +impl State { + fn change_page(&self, change: impl FnOnce(&mut Option<(String, String)>)) { + let mut page = self.page.lock().unwrap_or_else(|e| e.into_inner()); + change(&mut page); + self.signers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .close_owner("main"); + // Registration holds the same page mutex, so a canceled generation + // cannot install a fresh passphrase receiver after this revocation. + self.unlock.lock().unwrap_or_else(|e| e.into_inner()).take(); + } + + fn cancel_flow(&self, expected: &(String, String)) -> bool { + let mut page = self.page.lock().unwrap_or_else(|e| e.into_inner()); + if page.as_ref() != Some(expected) { + return false; + } + close_page(&mut page, false); + self.signers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .close_owner("main"); + self.unlock.lock().unwrap_or_else(|e| e.into_inner()).take(); + true + } + + fn register_unlock(&self, expected: &(String, String), pending: Unlock) -> Result<(), String> { + let page = self.page.lock().unwrap_or_else(|e| e.into_inner()); + if page.as_ref() != Some(expected) || pending.deadline <= Instant::now() { + return Err(REFUSAL.into()); + } + *self.unlock.lock().unwrap_or_else(|e| e.into_inner()) = Some(pending); + Ok(()) + } +} + +pub fn committed(app: &AppHandle, url: &tauri::Url) { + app.state::() + .change_page(|page| *page = Some((url.origin().ascii_serialization(), ssh_signer::random_id()))); + close_unlock_window(app); +} + +pub fn closed(app: &AppHandle, label: &str, destroyed: bool) { + let state = app.state::(); + if label == "main" { + state.change_page(|page| close_page(page, destroyed)); + close_unlock_window(app); + } else if label == UNLOCK { + state.unlock.lock().unwrap_or_else(|e| e.into_inner()).take(); + } +} + +fn close_page(page: &mut Option<(String, String)>, destroyed: bool) { + if destroyed { + *page = None; + } else if let Some((_, generation)) = page.as_mut() { + *generation = ssh_signer::random_id(); + } +} + +fn cancel_unlock(app: &AppHandle) { + app.state::() + .unlock + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); + close_unlock_window(app); +} + +fn close_unlock_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window(UNLOCK) { + let _ = window.close(); + } +} + +fn allowed_origin(app: &AppHandle, origin: &str) -> bool { + app.state::().get().as_deref() == Some(origin) +} + +fn secure_origin(origin: &str) -> bool { + let Ok(url) = origin.parse::() else { + return false; + }; + url.scheme() == "https" + || (url.scheme() == "http" && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))) +} + +fn bundled_unlock_url(url: &tauri::Url) -> bool { + url.path() == "/ssh-key-unlock.html" + && url.query().is_none() + && ((url.scheme() == "tauri" && url.host_str() == Some("localhost")) + || (url.scheme() == "http" && url.host_str() == Some("tauri.localhost")) + || (cfg!(debug_assertions) + && url.scheme() == "http" + && url.host_str() == Some("localhost") + && url.port() == Some(DEV_PORT))) +} + +fn owner(window: &WebviewWindow) -> Result<(Owner, String), String> { + if window.label() != "main" { + return Err(REFUSAL.into()); + } + let app = window.app_handle(); + let state = app.state::(); + let (origin, generation) = state + .page + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .ok_or(REFUSAL)?; + if !secure_origin(&origin) || !allowed_origin(app, &origin) { + return Err(REFUSAL.into()); + } + Ok(( + Owner { + window: window.label().to_owned(), + origin, + }, + generation, + )) +} + +pub fn start_expiry(app: AppHandle) { + std::thread::spawn(move || loop { + std::thread::sleep(Duration::from_millis(250)); + let state = app.state::(); + state.signers.lock().unwrap_or_else(|e| e.into_inner()).expire(); + let expired = state + .unlock + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .is_some_and(|pending| pending.deadline <= Instant::now()); + if expired { + cancel_unlock(&app); + } + }); +} + +#[tauri::command] +pub fn desktop_ssh_key_available(window: WebviewWindow) -> bool { + owner(&window).is_ok() +} + +#[tauri::command] +pub async fn desktop_ssh_key_pick( + window: WebviewWindow, + host: String, + port: u16, + user: String, + host_key: String, +) -> Result, String> { + let initial = owner(&window)?; + if host.is_empty() + || host.len() > 253 + || user.is_empty() + || user.len() > 128 + || port == 0 + || !host.is_ascii() + || !user.is_ascii() + || host + .chars() + .chain(user.chars()) + .any(|c| c.is_control() || c.is_whitespace()) + { + return Err("Enter a valid SSH host, port, and username first.".into()); + } + let (host_key, fingerprint) = ssh_signer::host_key(&host_key)?; + let app = window.app_handle().clone(); + if app.state::().picking.swap(true, Ordering::SeqCst) { + return Err("Finish choosing the current SSH key first.".into()); + } + tauri::async_runtime::spawn_blocking(move || { + struct Picking(AppHandle); + impl Drop for Picking { fn drop(&mut self) { self.0.state::().picking.store(false, Ordering::SeqCst); } } + let _picking = Picking(app.clone()); + let approved = app.dialog().message(format!("Allow one SSH authentication as {user} on {host}, port {port}?\n\nHost fingerprint: {fingerprint}\nVerify this fingerprint through a trusted channel before continuing.\n\nThe selected key stays in this desktop app's memory for up to 90 seconds and is released after one signature. Subshell can use the resulting SSH connection as {user} if authentication succeeds.")).title("Use a local SSH key?").buttons(MessageDialogButtons::OkCancelCustom("Choose key".into(), "Cancel".into())).parent(&window).blocking_show(); + if !approved { return Ok(None); } + if owner(&window)? != initial { return Err(REFUSAL.into()); } + let mut picker = app.dialog().file().set_title("Choose an SSH private key").set_parent(&window); + if let Some(home) = std::env::var_os("HOME") { picker = picker.set_directory(std::path::PathBuf::from(home).join(".ssh")); } + let Some(path) = picker.blocking_pick_file() else { return Ok(None); }; + if owner(&window)? != initial { return Err(REFUSAL.into()); } + let deadline = Instant::now() + ssh_signer::LIFETIME; + let mut key = ssh_signer::load_key(&path.into_path().map_err(|_| "Choose a local SSH key file.")?)?; + if key.is_encrypted() { + let (sender, receiver) = mpsc::channel(); + app.state::().register_unlock(&(initial.0.origin.clone(), initial.1.clone()), Unlock { sender, deadline })?; + let unlock = WebviewWindowBuilder::new(&app, UNLOCK, WebviewUrl::App("ssh-key-unlock.html".into())) + .title("Unlock this SSH key").inner_size(460.0, 280.0).resizable(false).center() + // No navigation, popups or global event permissions: this + // bundled form can only submit to its own one-use receiver. + .on_navigation(bundled_unlock_url) + .on_new_window(|_, _| tauri::webview::NewWindowResponse::Deny) + .build().map_err(|_| { cancel_unlock(&app); "Couldn't open the local SSH key unlock window." })?; + // Native construction must happen without the page lock, because + // it can call UI callbacks. A cancellation during construction + // already dropped the receiver's sender; close the late window + // and release the loaded key before waiting for a password. + if owner(&window).ok() != Some(initial.clone()) || Instant::now() >= deadline { + cancel_unlock(&app); + return Ok(None); + } + let passphrase = receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())); + let _ = unlock.close(); + app.state::().unlock.lock().unwrap_or_else(|e| e.into_inner()).take(); + let Ok(passphrase) = passphrase else { return Ok(None); }; + if owner(&window)? != initial || Instant::now() >= deadline { return Err(REFUSAL.into()); } + key = ssh_signer::unlock_key(&key, &passphrase)?; + } + if owner(&window)? != initial || Instant::now() >= deadline { return Err("The local SSH key request expired. Choose the key again.".into()); } + // Page changes and insertion use the same page mutex, so navigation + // cannot leave a key inserted after its cancellation callback ran. + let state = app.state::(); + let page = state.page.lock().unwrap_or_else(|e| e.into_inner()); + if page.as_ref() != Some(&(initial.0.origin.clone(), initial.1)) { return Err(REFUSAL.into()); } + let selected = state.signers.lock().unwrap_or_else(|e| e.into_inner()).insert(initial.0, user, host_key, key, deadline).map(Some); + selected + }).await.map_err(|_| "The local SSH key request couldn't finish.")? +} + +#[tauri::command] +pub fn desktop_ssh_key_request(window: WebviewWindow, id: String, request: String) -> Result { + let (owner, generation) = owner(&window)?; + let state = window.app_handle().state::(); + let page = state.page.lock().unwrap_or_else(|e| e.into_inner()); + if page.as_ref() != Some(&(owner.origin.clone(), generation)) || !allowed_origin(window.app_handle(), &owner.origin) + { + return Err(REFUSAL.into()); + } + let reply = state + .signers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .request(&owner, &id, &request); + Ok(reply) +} + +#[tauri::command] +pub fn desktop_ssh_key_close(window: WebviewWindow, id: Option) -> Result<(), String> { + let (owner, generation) = owner(&window)?; + let app = window.app_handle(); + let state = app.state::(); + if let Some(id) = id { + state + .signers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .close(&owner, &id); + } else if state.cancel_flow(&(owner.origin, generation)) { + // No id exists while the native picker/unlock promise is pending. + // Revoke its generation so a late OS selection cannot read a key. + close_unlock_window(app); + } + Ok(()) +} + +#[tauri::command] +pub fn desktop_ssh_key_unlock(window: WebviewWindow, passphrase: Option) -> Result<(), String> { + let cancel = passphrase.is_none(); + let passphrase = Secret::new(passphrase.unwrap_or_default()); + if window.label() != UNLOCK + || !window.url().is_ok_and(|url| bundled_unlock_url(&url)) + || passphrase.encode_utf16().count() > 4096 + { + return Err("This window can't unlock an SSH key.".into()); + } + let app = window.app_handle(); + let pending = app + .state::() + .unlock + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .ok_or("This SSH key request has expired.")?; + if pending.deadline <= Instant::now() { + return Err("This SSH key request has expired.".into()); + } + if cancel { + return Ok(()); + } + pending + .sender + .send(passphrase) + .map_err(|_| "This SSH key request has expired.".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn explicit_pending_cancel_revokes_receiver_and_generation_without_unloading_page() { + let state = State::default(); + let expected = ("https://plane.example".to_owned(), "pending-generation".to_owned()); + state.change_page(|page| *page = Some(expected.clone())); + let (sender, receiver) = mpsc::channel(); + state + .register_unlock( + &expected, + Unlock { + sender, + deadline: Instant::now() + ssh_signer::LIFETIME, + }, + ) + .unwrap(); + assert!(state.cancel_flow(&expected)); + assert!(matches!(receiver.try_recv(), Err(mpsc::TryRecvError::Disconnected))); + assert!(state.unlock.lock().unwrap().is_none()); + let page = state.page.lock().unwrap().clone().unwrap(); + assert_eq!(page.0, expected.0); + assert_ne!(page.1, expected.1); + // A cancellation from the previous flow cannot revoke a later one. + assert!(!state.cancel_flow(&expected)); + let (sender, _) = mpsc::channel(); + assert!(state + .register_unlock( + &expected, + Unlock { + sender, + deadline: Instant::now() + ssh_signer::LIFETIME + } + ) + .is_err()); + } + + #[test] + fn canceled_page_cannot_register_unlock_and_revocation_disconnects_registered_receiver() { + let state = State::default(); + let expected = ("https://plane.example".to_owned(), "old-generation".to_owned()); + state.change_page(|page| *page = Some(expected.clone())); + let (sender, receiver) = mpsc::channel(); + state + .register_unlock( + &expected, + Unlock { + sender, + deadline: Instant::now() + ssh_signer::LIFETIME, + }, + ) + .unwrap(); + // Models cancellation while the worker constructs its native window. + // The exact production transition disconnects the waiting worker. + state.change_page(|page| close_page(page, false)); + assert!(matches!(receiver.try_recv(), Err(mpsc::TryRecvError::Disconnected))); + assert!(state.unlock.lock().unwrap().is_none()); + let (sender, receiver) = mpsc::channel(); + assert!(state + .register_unlock( + &expected, + Unlock { + sender, + deadline: Instant::now() + ssh_signer::LIFETIME + } + ) + .is_err()); + assert!(matches!(receiver.try_recv(), Err(mpsc::TryRecvError::Disconnected))); + assert!(state.unlock.lock().unwrap().is_none()); + } + + #[test] + fn close_to_tray_revokes_pending_generation_but_keeps_loaded_origin() { + let mut page = Some(("https://plane.example".into(), "initial-generation".into())); + close_page(&mut page, false); + let (origin, generation) = page.as_ref().unwrap(); + assert_eq!(origin, "https://plane.example"); + assert_ne!(generation, "initial-generation"); + close_page(&mut page, true); + assert!(page.is_none()); + } + + #[test] + fn local_keys_require_tls_except_loopback() { + for origin in [ + "https://plane.example", + "http://127.0.0.1:3080", + "http://localhost:5174", + "http://[::1]:3080", + ] { + assert!(secure_origin(origin), "{origin}"); + } + for origin in [ + "http://plane.example", + "http://192.168.1.2:3080", + "http://localhost.evil.test", + "file:///etc/passwd", + "blob:https://plane.example/id", + "null", + ] { + assert!(!secure_origin(origin), "{origin}"); + } + } + #[test] + fn unlock_navigation_is_only_the_bundled_form() { + assert!(bundled_unlock_url( + &"tauri://localhost/ssh-key-unlock.html".parse().unwrap() + )); + for url in [ + "tauri://localhost/index.html", + "https://evil.example/ssh-key-unlock.html", + "http://localhost:3080/ssh-key-unlock.html", + "tauri://localhost/ssh-key-unlock.html?payload=1", + ] { + assert!(!bundled_unlock_url(&url.parse().unwrap())); + } + } +} diff --git a/apps/client/desktop/src-tauri/src/tray.rs b/apps/client/desktop/src-tauri/src/tray.rs index e4809bebd..6469e6f68 100644 --- a/apps/client/desktop/src-tauri/src/tray.rs +++ b/apps/client/desktop/src-tauri/src/tray.rs @@ -248,8 +248,8 @@ fn tray_menu(app: &AppHandle) -> tauri::Result> { app.manage(KeepItem(keep.clone())); // On Linux this is the ONLY route to the text size: there is no menu bar // to hang ⌘+ on (a GTK one is per-window chrome), and the window showing - // the plane's page holds one command that opens a browser and nothing - // that could change the text size, so it cannot offer one either. On + // the plane's page holds five narrow commands, none of which could change + // the text size, so it cannot offer one either. On // macOS it is a second route to what the View menu already carries, which // costs a submenu. let text_size = text_size_submenu(app)?; diff --git a/apps/client/desktop/src-tauri/src/windows.rs b/apps/client/desktop/src-tauri/src/windows.rs index 027170f6c..a83034cb1 100644 --- a/apps/client/desktop/src-tauri/src/windows.rs +++ b/apps/client/desktop/src-tauri/src/windows.rs @@ -9,9 +9,11 @@ //! with `credentials: "include"`, an auth client with no `baseURL`, a //! WebSocket URL built from `window.location.host` — so a bundled copy could //! not carry the `SameSite=Lax` session cookie to any of them. It is remote -//! content, and it is granted exactly ONE command — -//! `desktop_open_in_browser`, which takes a path and can name no host. Every -//! other command is refused. See [`open_plane`]. +//! content, and it is granted five narrow commands: +//! `desktop_open_in_browser`, which takes a path and can name no host, and +//! the four one-use `desktop_ssh_key_*` commands, which never name a file, +//! never see a key or passphrase, and require the person's native approval. +//! Every other command is refused. See [`open_plane`]. //! - **`node`** is ours: a bundled page that must render with the plane //! unreachable, and the only surface allowed to drive the `subshell` CLI — //! enrol, install the agent, drive the service. @@ -33,13 +35,13 @@ use tauri_plugin_opener::OpenerExt; /// It is `main` because that is what it is to the user, and because the /// window-state plugin and the Dock both key off labels. /// -/// `capabilities/main.json` names it and grants it ONE command. That file was -/// an absence until 2026-09-14, and the absence was the security boundary; -/// what replaced it is a boundary of the same kind drawn one level in — the -/// capability's scope is a wildcard, because a plane lives anywhere, so the -/// narrowness has to live in the COMMAND's argument instead. Adding a second -/// permission to that file is a change to the trust model, not a convenience, -/// and `ui/src/__tests__/ipc-acl.test.ts` pins it at one. +/// `capabilities/main.json` names it and grants it five narrow commands. That +/// file was an absence until 2026-09-14, and the absence was the security +/// boundary; what replaced it is a boundary of the same kind drawn one level in +/// — the capability's scope is a wildcard, because a plane lives anywhere, so +/// the narrowness has to live in each COMMAND's argument and checks. Adding a +/// sixth permission to that file is a change to the trust model, not a +/// convenience, and `ui/src/__tests__/ipc-acl.test.ts` pins the exact set. pub const PLANE_LABEL: &str = "main"; /// The bundled node page's label. @@ -99,7 +101,9 @@ impl PlanePin { /// refused one at a time. It carries one now because the SPA's chrome split in /// two — `isServerDesktop()` for everything that drives Subshell Server, and /// `isDesktop()` for the two surfaces that are true of any shell — and this -/// window is granted exactly the one command those two need. +/// window now holds commands for both walks: the browser command that chrome +/// calls, and since 2026-10-14 the four one-use SSH signer commands behind +/// the connection form. /// /// So the token is what matters: `SubshellClient`, which the SPA maps to /// `app: "client"` and which switches on nothing else. @@ -261,9 +265,12 @@ pub fn open_node(app: &AppHandle) -> Result { /// /// Three things about this window are deliberate and belong together: /// -/// - **`capabilities/main.json` names it, and grants it exactly ONE command** -/// (2026-09-14): `desktop_open_in_browser`, which takes a path and joins it -/// onto the origin [`PlanePin`] already holds. A control plane is reachable +/// - **`capabilities/main.json` names it, and grants it five narrow commands** +/// (2026-09-14, extended 2026-10-14): `desktop_open_in_browser`, which takes +/// a path and joins it onto the origin [`PlanePin`] already holds, plus the +/// four one-use `desktop_ssh_key_*` commands, which bind to that same pin, +/// require HTTPS off loopback, and get the person's native approval before +/// any key is touched. A control plane is reachable /// over a LAN or a VPN, so its origin cannot be enumerated the way /// `apps/server/desktop` pins loopback — which is why that capability's scope /// is a wildcard and why the COMMAND, not the scope, has to be the narrow @@ -334,7 +341,7 @@ fn open_plane_window(app: &AppHandle, origin: &str) -> Result Result()`. .on_navigation(|u| subshell_desktop_core::browser::browsable_scheme(u.scheme())) + .on_page_load(|webview, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + crate::ssh_key::committed(webview.app_handle(), payload.url()); + } + }) // WebKitGTK needs a registered handler to save authenticated attachments. // Let the webview choose a unique filename in Downloads; no new IPC grant. .on_download(|webview, event| { @@ -611,8 +623,8 @@ mod tests { // The plane window's label must NOT be the one `node.json` names. If these // two ever became one string, the control plane's own page would inherit - // the whole CLI surface — which is a different thing from the one command - // `main.json` grants it, and the failure this pin exists for. + // the whole CLI surface — which is a different thing from the five narrow + // commands `main.json` grants it, and the failure this pin exists for. #[test] fn the_plane_window_is_not_the_granted_one() { assert_ne!(PLANE_LABEL, NODE_LABEL); diff --git a/apps/client/desktop/src-tauri/src/zoom.rs b/apps/client/desktop/src-tauri/src/zoom.rs index ce759c8ef..b663059dc 100644 --- a/apps/client/desktop/src-tauri/src/zoom.rs +++ b/apps/client/desktop/src-tauri/src/zoom.rs @@ -11,11 +11,12 @@ //! //! The plane window is the reason this could not have been done the built-in //! way. It shows a control plane's OWN page, from an origin this app cannot -//! enumerate ahead of time, so the one command it is granted -//! (`desktop_open_in_browser`, a path and nothing else) is the whole of its -//! surface — the webview-zoom command Tauri's polyfill invokes is not on it, -//! and never will be. Driving `set_zoom` from Rust is what lets that window -//! follow the setting without widening that surface by one command. +//! enumerate ahead of time, so its whole surface is five narrow commands +//! (`desktop_open_in_browser`, a path and nothing else, and the four one-use +//! SSH signer commands of 2026-10-14) — the webview-zoom command Tauri's +//! polyfill invokes is not on it, and never will be. Driving `set_zoom` from +//! Rust is what lets that window follow the setting without adding a command +//! to that surface. use subshell_desktop_core::settings::SettingsState; use subshell_desktop_core::zoom::{clamp_zoom, zoom_in, zoom_out, ZOOM_DEFAULT}; diff --git a/apps/client/desktop/ui/src/__tests__/ipc-acl.test.ts b/apps/client/desktop/ui/src/__tests__/ipc-acl.test.ts index f7869dc40..bd270abf4 100644 --- a/apps/client/desktop/ui/src/__tests__/ipc-acl.test.ts +++ b/apps/client/desktop/ui/src/__tests__/ipc-acl.test.ts @@ -9,9 +9,10 @@ * * `node.json` and not `main.json`, and that is the point of the second block * here: `main` is the window showing a CONTROL PLANE's own page, and it holds - * exactly one command — one that takes a path and can name no host. Nothing - * else about the CLI surface is reachable from a remote origin, which is what - * that block asserts entry by entry. + * five narrow commands — one that takes a path and can name no host, plus the + * four one-use SSH signer commands of 2026-10-14. Nothing else about the CLI + * surface is reachable from a remote origin, which is what that block asserts + * entry by entry. */ import { describe, expect, it } from "bun:test"; import { readdirSync, readFileSync, statSync } from "node:fs"; @@ -61,7 +62,7 @@ function capabilityPermissions(): string[] { return capability.permissions; } -/** What the PLANE's window is granted — one entry, pinned in its own block. */ +/** What the PLANE's window is granted — five entries, pinned in their own block. */ function mainPermissions(): string[] { const capability = JSON.parse(readFileSync(join(TAURI_DIR, "capabilities/main.json"), "utf8")) as { permissions?: string[]; @@ -118,7 +119,10 @@ describe("the IPC contract", () => { // same runtime rejection read from the other end — and after a deletion it // is also how a dead command survives in Rust unnoticed. BOTH files, since // `main.json` arrived: one of the manifest's entries is granted only there. - const granted = new Set([...capabilityPermissions(), ...mainPermissions()]); + const unlock = JSON.parse(readFileSync(join(TAURI_DIR, "capabilities/ssh-key-unlock.json"), "utf8")) as { + permissions: string[]; + }; + const granted = new Set([...capabilityPermissions(), ...mainPermissions(), ...unlock.permissions]); for (const id of manifestPermissions().keys()) expect(granted.has(id), `${id} is granted nowhere`).toBe(true); }); @@ -136,7 +140,7 @@ describe("the IPC contract", () => { }); /** - * The window that is granted ONE command. + * The window that is granted five narrow commands. * * It was granted nothing until 2026-09-14, and the absence WAS the boundary: a * control plane can live on any host, so unlike `apps/server/desktop`'s @@ -144,14 +148,20 @@ describe("the IPC contract", () => { * at all. That has not changed. What changed is that "Open in browser" is * chrome any shell owes its user — a webview has no address bar and no second * tab — so the boundary moved one level in: the SCOPE is a wildcard, and the - * narrowness lives in the command's ARGUMENT. + * narrowness lives in the command's ARGUMENT. The 2026-10-14 SSH signer added + * the four `desktop_ssh_key_*` commands on the same principle: they name no + * file themselves, bind to the committed `PlanePin` over HTTPS off loopback, + * and hand the page only public wire bytes. * * Which makes three things worth pinning rather than one: that the scope is - * exactly what was argued for, that the permission list is exactly one entry, - * and that the Rust signature behind it takes a path and no host. Any of those - * widening quietly is the failure this block exists to make loud. + * exactly what was argued for, that the permission list is exactly the five + * entries argued for (and no more), and that the browser command's signature + * still takes a path and no host. The signer commands' safety is not a + * signature shape; it lives in `ssh_key.rs` and its own tests. Any of this + * block's own invariants widening quietly is the failure it exists to make + * loud. */ -describe("the window that is granted one command", () => { +describe("the window that is granted five narrow commands", () => { /** Every capability file the app ships, parsed. */ function capabilities(): { file: string; @@ -173,14 +183,14 @@ describe("the window that is granted one command", () => { return found; }; - it("is the app's two capability files, and no more", () => { + it("keeps separate capabilities for the assistant, plane, and local key unlock form", () => { // A THIRD file is a third window, and a window added without a deliberate // grant list is exactly what `permissions/desktop.toml` exists to prevent. expect( capabilities() .map((c) => c.file) .sort(), - ).toEqual(["main.json", "node.json"]); + ).toEqual(["main.json", "node.json", "ssh-key-unlock.json"]); }); it("keeps `main`'s scope to what was argued for", () => { @@ -197,9 +207,15 @@ describe("the window that is granted one command", () => { expect(capability.windows).toEqual(["main"]); }); - it("grants `main` exactly one permission, and nothing that drives the CLI", () => { + it("grants `main` browser navigation and constrained local key signing", () => { const permissions = main().permissions ?? []; - expect(permissions).toEqual(["allow-desktop-open-in-browser"]); + expect(permissions).toEqual([ + "allow-desktop-open-in-browser", + "allow-desktop-ssh-key-available", + "allow-desktop-ssh-key-pick", + "allow-desktop-ssh-key-request", + "allow-desktop-ssh-key-close", + ]); // Stated as invariants too, not just as an equality: these are the // properties the equality is protecting, and they should be readable as // the reason it is there. @@ -215,7 +231,7 @@ describe("the window that is granted one command", () => { it("keeps every CLI-driving command granted to `node` alone", () => { const nodeGrants = new Set(capabilityPermissions()); for (const id of manifestPermissions().keys()) { - if (id === "allow-desktop-open-in-browser") continue; + if (id === "allow-desktop-open-in-browser" || id.startsWith("allow-desktop-ssh-key-")) continue; expect(nodeGrants.has(id), `${id} must be the bundled page's`).toBe(true); expect(main().permissions ?? [], id).not.toContain(id); } @@ -248,8 +264,8 @@ describe("the window that is granted one command", () => { // harmless — one GET, nothing changed — and it still does not go on // `main`, because its sibling REPLACES THE APPLICATION and the wildcard // scope this window carries is exactly why nothing is added to it without - // an argument being made. The equality above already says `main` holds one - // permission; this says WHICH two must never move. + // an argument being made. The equality above already says exactly which + // permissions `main` holds; this says WHICH two must never move. const nodeGrants = new Set(capabilityPermissions()); expect(nodeGrants.has("allow-node-check-app-update")).toBe(true); expect(nodeGrants.has("allow-node-install-app-update")).toBe(true); @@ -363,3 +379,28 @@ describe("the invocations that must stay unreachable", () => { expect(verbs).not.toContain("mcp"); }); }); + +describe("local SSH key unlock isolation", () => { + it("grants the bundled unlock form exactly one command and no remote URLs", () => { + const capability = JSON.parse(readFileSync(join(TAURI_DIR, "capabilities/ssh-key-unlock.json"), "utf8")) as { + windows: string[]; + permissions: string[]; + remote?: unknown; + }; + expect(capability.windows).toEqual(["ssh-key-unlock"]); + expect(capability.permissions).toEqual(["allow-desktop-ssh-key-unlock"]); + expect(capability.remote).toBeUndefined(); + expect(JSON.parse(readFileSync(join(TAURI_DIR, "capabilities/main.json"), "utf8")).permissions).not.toContain( + "allow-desktop-ssh-key-unlock", + ); + }); + + it("loads a separate bundled entry that submits only to the local unlock receiver", () => { + const html = readFileSync(join(UI_SRC, "../ssh-key-unlock.html"), "utf8"); + expect(html).toContain('src="/src/ssh-key-unlock.tsx"'); + expect(html).not.toMatch(/]*\bsrc=)/); + const entry = readFileSync(join(UI_SRC, "ssh-key-unlock.tsx"), "utf8"); + expect(entry).toContain('invoke("desktop_ssh_key_unlock", { passphrase })'); + expect(entry).not.toMatch(/fetch\(|localStorage|sessionStorage|emit\(/); + }); +}); diff --git a/apps/client/desktop/ui/src/__tests__/tauri-config.test.ts b/apps/client/desktop/ui/src/__tests__/tauri-config.test.ts index 4027810cc..f82016b26 100644 --- a/apps/client/desktop/ui/src/__tests__/tauri-config.test.ts +++ b/apps/client/desktop/ui/src/__tests__/tauri-config.test.ts @@ -83,9 +83,10 @@ describe("the window's ambient surface", () => { * design — it must not pull `@tauri-apps/api` into a bundle that is served * to browsers, and the repo forbids the dynamic import that would avoid it. * - * The global GRANTS nothing: `capabilities/main.json` does, and it names one - * command. Turning this back off would not close a hole — it would make that - * one command silently unreachable, because the bridge never throws, exactly + * The global GRANTS nothing: `capabilities/main.json` does, and it names + * five narrow commands. Turning this back off would not close a hole — it + * would make those five silently unreachable, because the bridge never + * throws, exactly * the failure `apps/server/desktop` measured on 2026-09-10 when its own * config was copied from this file. * diff --git a/apps/client/desktop/ui/src/app.tsx b/apps/client/desktop/ui/src/app.tsx index 5100ceaee..786dde1ec 100644 --- a/apps/client/desktop/ui/src/app.tsx +++ b/apps/client/desktop/ui/src/app.tsx @@ -2,9 +2,10 @@ * Subshell Client's bundled page — the `node` window, as an assistant. * * NOT the app's only window. `main` shows a control plane's own UI, loaded - * from the plane's own origin and granted exactly one command (open a page of - * that plane in the system browser); this page is the other half, and the only - * surface that drives the `subshell` CLI. + * from the plane's own origin and granted five narrow commands (open a page of + * that plane in the system browser, plus the four one-use SSH signer commands + * of 2026-10-14, which never see a key or passphrase); this page is the other + * half, and the only surface that drives the `subshell` CLI. * * **The first run asks what you came to do** (spec 2026-09-18). A fresh * install walks Welcome → Choice → either *register this machine as a node* diff --git a/apps/client/desktop/ui/src/lib/ipc.ts b/apps/client/desktop/ui/src/lib/ipc.ts index 52767f0f8..90d1f631e 100644 --- a/apps/client/desktop/ui/src/lib/ipc.ts +++ b/apps/client/desktop/ui/src/lib/ipc.ts @@ -478,11 +478,12 @@ export function nodeLogs(): Promise { * settled. Rejects with a string for anything that is not an http(s) URL, and * for "nothing to open" when no address is known yet. * - * **The window this opens is granted exactly one command.** A control plane can + * **The window this opens is granted five narrow commands.** A control plane can * live on any host, so its origin cannot be enumerated in a capability file; * what it gets instead is `desktop_open_in_browser`, which takes a path and - * joins it onto that window's own pinned origin — see - * `src-tauri/src/windows.rs`. Everything privileged stays on this page. + * joins it onto that window's own pinned origin, plus the four one-use + * `desktop_ssh_key_*` signer commands - see `src-tauri/src/windows.rs`. + * Everything privileged stays on this page. */ export function nodeOpenPlane(args: { url: string }): Promise { return invoke("node_open_plane", args); diff --git a/apps/client/desktop/ui/src/ssh-key-unlock.tsx b/apps/client/desktop/ui/src/ssh-key-unlock.tsx new file mode 100644 index 000000000..567716904 --- /dev/null +++ b/apps/client/desktop/ui/src/ssh-key-unlock.tsx @@ -0,0 +1,80 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { Button } from "./components/ui/button"; +import { Input } from "./components/ui/input"; +import { Label } from "./components/ui/label"; +import "./styles.css"; + +// This entry is loaded only by the bundled unlock window. It never imports +// the control plane bridge, sends events, or stores the password in React state. +function UnlockKey() { + const input = useRef(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + async function submit(cancel: boolean) { + if (pending) return; + setPending(true); + const passphrase = cancel ? null : (input.current?.value ?? ""); + if (input.current) input.current.value = ""; + try { + await invoke("desktop_ssh_key_unlock", { passphrase }); + } catch { + setError("This SSH key request has expired. Close this window and choose the key again."); + setPending(false); + } + } + return ( +
+

Unlock this SSH key

+

+ Your passphrase stays in this desktop app. The key can authorize one SSH login and expires after 90 seconds. +

+
{ + event.preventDefault(); + void submit(false); + }} + > +
+ + +
+ {error && ( + + )} +
+ + +
+
+
+ ); +} + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); +createRoot(root).render(); diff --git a/apps/client/desktop/ui/ssh-key-unlock.html b/apps/client/desktop/ui/ssh-key-unlock.html new file mode 100644 index 000000000..77b7502bb --- /dev/null +++ b/apps/client/desktop/ui/ssh-key-unlock.html @@ -0,0 +1,12 @@ + + + + + + Unlock this SSH key + + +
+ + + diff --git a/apps/client/desktop/vite.config.ts b/apps/client/desktop/vite.config.ts index 175f5c7c0..ec636c8e6 100644 --- a/apps/client/desktop/vite.config.ts +++ b/apps/client/desktop/vite.config.ts @@ -46,6 +46,12 @@ export default defineConfig({ // INLINE + + diff --git a/apps/server/desktop/vite.config.ts b/apps/server/desktop/vite.config.ts index 3aec1ca43..562ffc140 100644 --- a/apps/server/desktop/vite.config.ts +++ b/apps/server/desktop/vite.config.ts @@ -58,12 +58,16 @@ export default defineConfig({ modulePreload: { polyfill: false }, assetsInlineLimit: 0, rollupOptions: { - // ONE bundled page: the assistant. `wizard.html` keeps that name - // because the name is an IDENTIFIER — `WebviewUrl::App("wizard.html")` - // in `windows.rs`, the `wizard` window label, and the capability file - // keyed to it — while the page it carries is first run, recovery, - // update and reset. Renaming it to `index.html` would move four things - // to rename one. + // TWO bundled pages, no more: the assistant and the SSH-key unlock + // form. `wizard.html` keeps that name because the name is an IDENTIFIER + // — `WebviewUrl::App("wizard.html")` in `windows.rs`, the `wizard` + // window label, and the capability file keyed to it — while the page it + // carries is first run, recovery, update and reset. Renaming it to + // `index.html` would move four things to rename one. `ssh-key-unlock.html` + // is the pinned 2026-10-14 addition: a fixed-position form that can only + // hand a passphrase to its own one-use receiver (`tauri-config.test.ts` + // keeps the count honest; a third page is a third window and is the loud + // case). // // Named explicitly rather than left to Vite's default, which is // `index.html`: there is no `index.html` here, and a default that found @@ -71,6 +75,7 @@ export default defineConfig({ // 404 with no build error anywhere. Pinned by tauri-config.test.ts. input: { wizard: path.resolve(dirname, "ui/wizard.html"), + sshKeyUnlock: path.resolve(dirname, "ui/ssh-key-unlock.html"), }, }, }, diff --git a/apps/server/web/AGENTS.md b/apps/server/web/AGENTS.md index 6c9af3cce..97262e88b 100644 --- a/apps/server/web/AGENTS.md +++ b/apps/server/web/AGENTS.md @@ -26,6 +26,9 @@ and membership disclosures remain table detail rows. - Dev server: http://localhost:5174 (Vite), which proxies `/api` and `/ws` to the backend at 127.0.0.1:3080 so cookies and WS auth just work on one origin. + Set `DEV_API_ORIGIN` to use an isolated backend instead, for example + `DEV_API_ORIGIN=http://127.0.0.1:3199 bun run dev`. Use this hot-reload URL + for interactive UI testing so edits appear without rebuilding or refreshing. - Production: the app is built to `dist/` and served by the backend itself. ## Commands diff --git a/apps/server/web/package.json b/apps/server/web/package.json index 0355d4e9c..9592a8416 100644 --- a/apps/server/web/package.json +++ b/apps/server/web/package.json @@ -35,6 +35,7 @@ "@xterm/addon-unicode-graphemes": "0.5.0-beta.301", "@xterm/addon-web-links": "0.13.0-beta.301", "@xterm/xterm": "6.1.0-beta.304", + "bcrypt-pbkdf": "1.0.2", "better-auth": "1.7.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx b/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx index f8dc16f60..36c7066eb 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx +++ b/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx @@ -955,8 +955,11 @@ describe("the workspace search filter (operator ask 2026-09-27)", () => { expect(screen.getByText("Docs")).toBeTruthy(); // A partial narrows to the one hit. fireEvent.change(input, { target: { value: "Doc" } }); - await waitFor(() => expect(screen.queryByText("Alpha") === null).toBe(true)); - expect(screen.getByText("Docs")).toBeTruthy(); + // An empty loading state also lacks Alpha; wait for the matching row. + await waitFor(() => { + expect(screen.getByText("Docs")).toBeTruthy(); + expect(screen.queryByText("Alpha") === null).toBe(true); + }); // A no-match clears the list but KEEPS the box, so the text can be cleared. fireEvent.change(input, { target: { value: "zzz" } }); await waitFor(() => expect(screen.getByText("No workspaces match.")).toBeTruthy()); diff --git a/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx index 8d229bfcc..10c9cadfd 100644 --- a/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx +++ b/apps/server/web/src/components/__tests__/ssh-pane-labels.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { SSH_VIEW_ONLY_COPY, SshPaneLabels } from "@/components/ssh-pane-labels"; import type { SubshellView } from "@/types/subshell"; @@ -11,21 +11,21 @@ function row(overrides: Partial = {}): SubshellView { } describe("SshPaneLabels", () => { - it("labels an owner's ssh pane with the SSH badge and says nothing more", () => { + it("labels an owner's ssh pane with the SSH icon and says nothing more", () => { render(); - expect(screen.queryByText("SSH")).not.toBeNull(); + expect(screen.queryByRole("button", { name: "SSH connection" })).not.toBeNull(); expect(screen.queryByText(SSH_VIEW_ONLY_COPY)).toBeNull(); }); - it("labels an edit grantee's ssh pane with the badge only (the sentence is the viewer's)", () => { + it("labels an edit grantee's ssh pane with the icon only (the sentence is the viewer's)", () => { render(); - expect(screen.queryByText("SSH")).not.toBeNull(); + expect(screen.queryByRole("button", { name: "SSH connection" })).not.toBeNull(); expect(screen.queryByText(SSH_VIEW_ONLY_COPY)).toBeNull(); }); it("tells a view-only ssh viewer why typing is off", () => { render(); - expect(screen.queryByText("SSH")).not.toBeNull(); + expect(screen.queryByRole("button", { name: "SSH connection" })).not.toBeNull(); expect(screen.queryByText("Only the owner can type in this SSH subshell.")).not.toBeNull(); // One sentence pinned exactly (copy rule), and no em/en dash in it. expect(SSH_VIEW_ONLY_COPY).toBe("Only the owner can type in this SSH subshell."); @@ -53,3 +53,9 @@ describe("SshPaneLabels", () => { expect(container.textContent).toBe(""); }); }); + +it("explains the SSH icon on keyboard focus", async () => { + render(); + await act(async () => screen.getByRole("button", { name: "SSH connection" }).focus()); + await waitFor(() => expect(screen.getByText("This subshell connects to another machine over SSH.")).toBeTruthy()); +}); diff --git a/apps/server/web/src/components/__tests__/updates-desktop-rows.test.tsx b/apps/server/web/src/components/__tests__/updates-desktop-rows.test.tsx index 371f658bf..f192f6784 100644 --- a/apps/server/web/src/components/__tests__/updates-desktop-rows.test.tsx +++ b/apps/server/web/src/components/__tests__/updates-desktop-rows.test.tsx @@ -101,7 +101,7 @@ describe("inside Subshell Client", () => { setUA(CLIENT_UA); renderRows(); expect(screen.getByText("Open the tray menu → This Machine… → Update.")).toBeTruthy(); - // Its remote window holds exactly one command, and it is not this one. + // Its remote window holds five narrow commands, and an updater is not one of them. expect(screen.queryByRole("button")).toBeNull(); expect(screen.queryByRole("link")).toBeNull(); }); diff --git a/apps/server/web/src/components/connect/__tests__/destination-options.test.ts b/apps/server/web/src/components/connect/__tests__/destination-options.test.ts index 6b9151e7b..dcb4bc2e7 100644 --- a/apps/server/web/src/components/connect/__tests__/destination-options.test.ts +++ b/apps/server/web/src/components/connect/__tests__/destination-options.test.ts @@ -103,3 +103,19 @@ it("displays and searches configured destinations while still launching the alia expect(candidates.get("config:demo")?.destination).toBe("demo"); expect(options[1]?.reason).toBe("Destination determined when connecting"); }); + +it("shows the concrete destination beneath saved and recent aliases", () => { + const destination = "developer@127.0.0.1:22222"; + const { options, candidates } = buildDestinationOptions({ + saved: [row({ id: "saved", alias: "saved-demo", destination })], + recent: [row({ id: "recent", alias: "subshell-demo", destination }), row({ id: "plain", destination })], + aliases: [], + machineName: "Server", + typed: "", + }); + expect(options[0]?.reason).toBe(destination); + expect(options[1]?.reason).toBe(destination); + expect(options[2]?.label).toBe(destination); + expect(options[2]?.reason).toBeUndefined(); + expect(candidates.get("recent:recent")).toEqual({ destination }); +}); diff --git a/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts b/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts index f0cdd0d0a..02ef0bb9c 100644 --- a/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts +++ b/apps/server/web/src/components/connect/__tests__/ssh-refusal.test.ts @@ -169,3 +169,8 @@ describe("sshLaunchRefusal", () => { expect(isSshResolveRefusal("outcome")).toBe(false); }); }); + +it("preserves a local key unlock failure instead of the generic launch fallback", () => { + const message = "Couldn't unlock that SSH key. Check its passphrase and try again."; + expect(sshLaunchRefusal(new Error(message), null, true)).toEqual({ field: "keys", text: message }); +}); diff --git a/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx b/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx index 832384bab..fed3e6287 100644 --- a/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx +++ b/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx @@ -11,7 +11,9 @@ import { import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { ConnectPanel } from "@/components/connect/connect-panel"; import type { SshInitialChoices } from "@/components/connect/ssh-session-draft"; +import { resetDesktopShellForTests } from "@/lib/desktop"; import type { SshMachineReadiness } from "@/lib/ssh"; +import { browserSigningUnavailable } from "@/lib/ssh-browser-key"; const original = globalThis.fetch; afterEach(() => { @@ -180,7 +182,8 @@ async function task() { } function disabled(name = "Start SSH subshell") { - return (screen.getByRole("button", { name }) as HTMLButtonElement).disabled; + const button = screen.getByRole("button", { name }) as HTMLButtonElement; + return button.disabled || button.getAttribute("aria-disabled") === "true"; } it("opens directly on the connection form", async () => { const { calls } = await setup({ initial: { node: "desk", destination: "target" } }); @@ -327,11 +330,11 @@ it("keeps the chosen remote node selected while choosing keys", async () => { expect(disabled()).toBe(true); }); -it("selects SSH options by clicking their descriptions", async () => { +it("selects SSH options by clicking their labels", async () => { await setup({ initial: { node: "desk", destination: "target" } }); fireEvent.click(screen.getByRole("radio", { name: "Use another node’s keys" })); await settle(); - fireEvent.click(document.getElementById("ssh-keys-node-name")!); + fireEvent.click(screen.getByText("Use the connecting node’s keys")); await settle(); expect(screen.getByRole("radio", { name: "Use the connecting node’s keys" }).getAttribute("aria-checked")).toBe( "true", @@ -438,9 +441,7 @@ it("switches from another node's keys to an interactive password without sending const { calls } = await setup({ initial: { node: "desk", keyHome: "keys", destination: "target", fingerprints: ["SHA256:AAAA"] }, }); - fireEvent.click( - screen.getByText("Enter your password in the terminal when SSH asks for it. Subshell won’t save it."), - ); + fireEvent.click(screen.getByRole("radio", { name: "Enter a password when connecting" })); await settle(); expect(screen.queryByRole("combobox", { name: "Use keys from" })).toBeNull(); expect(document.querySelector('input[type="password"]')).toBeNull(); @@ -462,3 +463,134 @@ for (const ownerLabel of ["Alex Smith", "alex@example.com"]) { expect(disabled()).toBe(true); }); } + +it("shows authentication details only for the selected option and gates browser keys on trust", async () => { + const { calls } = await setup({ initial: { node: "desk", destination: "alice@host" } }); + expect(screen.queryByText(/Compromised page code could steal/)).toBeNull(); + expect(screen.queryByText(/Enter your password in the terminal/)).toBeNull(); + expect(screen.queryByRole("button", { name: "Choose key…" })).toBeNull(); + fireEvent.click(screen.getByRole("radio", { name: "Use a key from my machine" })); + expect(screen.getByText(/Compromised page code could steal/)).toBeTruthy(); + expect(document.getElementById("ssh-keys-node-name")).toBeNull(); + expect(screen.getByRole("button", { name: "Choose key…" }).getAttribute("aria-disabled")).toBe("true"); + await waitFor(() => expect(disabled()).toBe(true)); + await click("Start SSH subshell"); + expect(calls.some((call) => call.path === "/api/ssh/local-key" || call.path === "/api/ssh/launch")).toBe(false); + fireEvent.click(screen.getByRole("radio", { name: "Enter a password when connecting" })); + expect(screen.queryByText(/Compromised page code could steal/)).toBeNull(); + expect(screen.getByText(/Enter your password in the terminal/)).toBeTruthy(); +}); + +for (const reset of ["switch", "pagehide"] as const) { + it(`allows browser key selection after acknowledgement and clears it on ${reset}`, async () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, "isSecureContext"); + Object.defineProperty(globalThis, "isSecureContext", { value: true, configurable: true }); + try { + expect(browserSigningUnavailable()).toBeNull(); + await setup({ initial: { node: "desk", destination: "alice@host" } }); + fireEvent.click(screen.getByRole("radio", { name: "Use a key from my machine" })); + await settle(); + fireEvent.click(screen.getByRole("checkbox", { name: "I trust this website and its operator" })); + await waitFor(() => + expect( + screen.getByRole("checkbox", { name: "I trust this website and its operator" }).getAttribute("aria-checked"), + ).toBe("true"), + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "Choose key…" }).getAttribute("aria-disabled")).not.toBe("true"), + ); + fireEvent.change(screen.getByLabelText("SSH private key"), { + target: { files: [new File(["fixture-key"], "id_ed25519")] }, + }); + await waitFor(() => expect(disabled()).toBe(false)); + const password = screen.getByLabelText("Key passphrase (if encrypted)") as HTMLInputElement; + fireEvent.change(password, { target: { value: "fixture-only" } }); + expect(screen.getByText("id_ed25519")).toBeTruthy(); + fireEvent.change(screen.getByLabelText("SSH destination"), { target: { value: "alice@another-host" } }); + await settle(); + expect(screen.getByText("id_ed25519")).toBeTruthy(); + expect(password.value).toBe("fixture-only"); + expect(disabled()).toBe(false); + expect( + screen.getByRole("checkbox", { name: "I trust this website and its operator" }).getAttribute("aria-checked"), + ).toBe("true"); + if (reset === "pagehide") { + fireEvent(window, new Event("pagehide")); + await settle(); + expect(password.value).toBe(""); + } else { + fireEvent.click(screen.getByRole("radio", { name: "Enter a password when connecting" })); + await settle(); + fireEvent.click(screen.getByRole("radio", { name: "Use a key from my machine" })); + await settle(); + } + expect(screen.queryByText("id_ed25519")).toBeNull(); + expect(screen.queryByLabelText("Key passphrase (if encrypted)")).toBeNull(); + expect( + screen.getByRole("checkbox", { name: "I trust this website and its operator" }).getAttribute("aria-checked"), + ).toBe("false"); + } finally { + if (previous) Object.defineProperty(globalThis, "isSecureContext", previous); + else Reflect.deleteProperty(globalThis, "isSecureContext"); + } + }); +} + +for (const action of ["cancel", "unmount"] as const) { + it(`does not open the native picker after ${action} during SSH preparation`, async () => { + const oldUA = Object.getOwnPropertyDescriptor(navigator, "userAgent"); + const native = window as unknown as { __TAURI__?: { core: { invoke: (command: string) => Promise } } }; + const oldNative = native.__TAURI__; + const invoked: string[] = []; + const readiness = Promise.withResolvers(); + const entered = Promise.withResolvers(); + try { + Object.defineProperty(navigator, "userAgent", { + value: "SubshellClient/2.1.0 (linux; p=1)", + configurable: true, + }); + resetDesktopShellForTests(); + native.__TAURI__ = { + core: { + invoke: async (command) => { + invoked.push(command); + return true; + }, + }, + }; + const { calls } = await setup({ + initial: { node: "desk", destination: "alice@host" }, + machines: [machine("desk", false)], + }); + fireEvent.click(screen.getByRole("radio", { name: "Use a key from my machine" })); + await waitFor(() => expect(disabled()).toBe(false)); + const fetchReady = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + if (String(input).includes("/api/ssh/readiness")) { + entered.resolve(); + await readiness.promise; + } + return fetchReady(input, init); + }) as typeof fetch; + fireEvent.click(screen.getByRole("button", { name: "Start SSH subshell" })); + await entered.promise; + if (action === "cancel") await click("Cancel"); + else cleanup(); + readiness.resolve(); + await settle(); + expect(invoked).toEqual([]); + expect( + calls.some( + (call) => + call.path.endsWith("/ssh-enabled") || call.path === "/api/ssh/local-key" || call.path === "/api/ssh/launch", + ), + ).toBe(false); + } finally { + readiness.resolve(); + native.__TAURI__ = oldNative; + if (oldUA) Object.defineProperty(navigator, "userAgent", oldUA); + else Reflect.deleteProperty(navigator, "userAgent"); + resetDesktopShellForTests(); + } + }); +} diff --git a/apps/server/web/src/components/connect/browser-key-fields.tsx b/apps/server/web/src/components/connect/browser-key-fields.tsx new file mode 100644 index 000000000..26e8cedef --- /dev/null +++ b/apps/server/web/src/components/connect/browser-key-fields.tsx @@ -0,0 +1,199 @@ +import { Button, Input } from "@internal/node-admin"; +import { SSH_LOCAL_KEY_MAX_BYTES, SSH_LOCAL_KEY_MAX_PASSPHRASE_BYTES } from "@internal/subshell-protocol"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { makeForm, useSubmitDisabled } from "@/lib/form"; +import { isAndroid, isIOS } from "@/lib/platform"; +import { type BrowserKeyMaterial, browserSigningUnavailable } from "@/lib/ssh-browser-key"; + +function hiddenFilesTip(): string | null { + const ua = typeof navigator === "undefined" ? "" : navigator.userAgent; + if (isIOS() || isAndroid(ua)) return null; + if (/Macintosh|Mac OS X/.test(ua)) + return "Press Command + Shift + . in the file dialog to show hidden files and folders."; + if (/Windows/.test(ua)) + return "To show hidden files and folders, enable Hidden items in File Explorer’s View menu, then reopen the file dialog."; + if (/Linux/.test(ua)) + return "Press Ctrl + H in the file dialog to show hidden files and folders. In KDE dialogs, use Alt + . instead."; + return null; +} + +/** File contents and passphrases never enter form state, query caches, or the shared SSH draft. */ +export function useBrowserKeySelection() { + const input = useRef(null); + const passphrase = useRef(null); + const file = useRef(null); + const [name, setName] = useState(""); + const [trusted, setTrusted] = useState(false); + const [error, setError] = useState(null); + const clear = useCallback(() => { + file.current = null; + if (input.current) input.current.value = ""; + if (passphrase.current) passphrase.current.value = ""; + setName(""); + setTrusted(false); + setError(null); + }, []); + useEffect(() => { + // A page in the back/forward cache stays mounted, so navigation must also + // release a selection that hasn't reached the worker yet. + window.addEventListener("pagehide", clear); + return () => { + window.removeEventListener("pagehide", clear); + file.current = null; + if (passphrase.current) passphrase.current.value = ""; + if (input.current) input.current.value = ""; + }; + }, [clear]); + const problem = () => + browserSigningUnavailable() ?? + (!trusted + ? "Confirm that you trust this website before choosing a key." + : !file.current + ? "Choose an SSH private key from your machine." + : null); + return { + input, + passphrase, + name, + trusted, + error, + clear, + problem, + acknowledge(value: boolean) { + if (!value) clear(); + else setTrusted(true); + }, + choose() { + if (!trusted || browserSigningUnavailable()) return; + const chosen = input.current?.files?.[0]; + if (!chosen) return; + file.current = null; + setName(""); + if (passphrase.current) passphrase.current.value = ""; + if (input.current) input.current.value = ""; + if (!chosen.size || chosen.size > SSH_LOCAL_KEY_MAX_BYTES) { + setError("Choose a private key no larger than 64 KiB."); + return; + } + file.current = chosen; + setName(chosen.name); + setError(null); + }, + take(): BrowserKeyMaterial { + const reason = problem(); + const chosen = file.current; + if (reason || !chosen) throw new Error(reason ?? "Choose an SSH private key."); + const password = new TextEncoder().encode(passphrase.current?.value ?? ""); + // Keep the selection and input for retry. The worker gets a disposable + // byte copy; success or leaving the flow clears the input separately. + if (password.length > SSH_LOCAL_KEY_MAX_PASSPHRASE_BYTES) { + password.fill(0); + throw new Error("Use a key passphrase no longer than 4 KiB once encoded."); + } + return { file: chosen, passphrase: password }; + }, + }; +} + +export type BrowserKeySelection = ReturnType; + +export function BrowserKeyFields({ selection, busy }: { selection: BrowserKeySelection; busy: boolean }) { + const unavailable = browserSigningUnavailable(); + const pickerTip = hiddenFilesTip(); + const form = makeForm({ + defaultValues: { trusted: selection.trusted }, + validator: ({ trusted }): Record => + unavailable + ? { trusted: unavailable } + : !trusted + ? { trusted: "Confirm that you trust this website before choosing a key." } + : {}, + onSubmit: () => {}, + }); + useEffect(() => { + form.setFieldValue("trusted", selection.trusted); + void form.validate("change"); + }, [form, selection.trusted]); + const disabled = useSubmitDisabled(form, busy); + return ( +
+

+ Only continue if you trust this website and its operator. Compromised page code could steal your key. Subshell + does not upload or save your SSH key, but complete erasure from browser memory cannot be guaranteed. +

+

+ Signing is limited to one authentication and 90 seconds. Subshell then releases the copy used for signing from + browser memory. If the attempt fails or you cancel confirmation, this form keeps access to your selected file + and retains your passphrase for retry. The form clears that selection and passphrase when the signing flow + succeeds, you close this form, or you switch authentication methods. +

+ + selection.acknowledge(checked === true)} + aria-describedby="ssh-browser-key-risk" + /> + I trust this website and its operator + + {unavailable && ( +

+ {unavailable} +

+ )} + +
+ + {selection.name && {selection.name}} +
+ {pickerTip && ( +

+ {pickerTip} +

+ )} + {selection.name && ( + + Key passphrase (if encrypted) + + + )} + {selection.error && ( +

+ {selection.error} +

+ )} +
+ ); +} diff --git a/apps/server/web/src/components/connect/connect-panel.tsx b/apps/server/web/src/components/connect/connect-panel.tsx index f49eb0eb3..ee2f91d17 100644 --- a/apps/server/web/src/components/connect/connect-panel.tsx +++ b/apps/server/web/src/components/connect/connect-panel.tsx @@ -3,6 +3,7 @@ import { Button, errMessage, Label } from "@internal/node-admin"; import { useNavigate } from "@tanstack/react-router"; import { ChevronDown } from "lucide-react"; import { type JSX, useCallback, useEffect, useRef, useState } from "react"; +import { useBrowserKeySelection } from "@/components/connect/browser-key-fields"; import { DestinationField } from "@/components/connect/destination-field"; import { buildDestinationOptions } from "@/components/connect/destination-options"; import { NoSshTargets } from "@/components/connect/no-ssh-targets"; @@ -18,6 +19,7 @@ import { sshSessionDraft, } from "@/components/connect/ssh-session-draft"; import { SshWizard } from "@/components/connect/ssh-wizard"; +import { verifyBrowserKeyDestination } from "@/components/connect/verify-browser-key-destination"; import { launchWithDestinationVerification } from "@/components/connect/verify-destination"; import { SshQueryStatus } from "@/components/ssh/query-status"; import { type ComboboxOption, SearchableSelect } from "@/components/ui/combobox"; @@ -33,10 +35,12 @@ import { useSshReadiness, useSshSavedHosts, } from "@/hooks/use-ssh"; +import { isDesktop } from "@/lib/desktop"; import { fieldErrorToned, makeForm, useSubmitDisabled } from "@/lib/form"; import { nodeOptionLabel } from "@/lib/node-label"; import { REQUIREMENT_CAPTION_CLASS } from "@/lib/requirement-tone"; import { sshCanEnableOnStart } from "@/lib/ssh"; +import { launchWithLocalKey } from "@/lib/ssh-local-key"; /** Shared SSH launch form for new subshells, workspace additions and splits. * A picked suggestion retains its canonical destination; editing it submits @@ -79,6 +83,16 @@ export function ConnectPanel({ machines?.some((machine) => machine.node.id === id && machine.canConnect) ?? false; const { data: ledger, isPending: ledgerPending } = useSshSavedHosts(); const launch = useLaunchSsh(); + const browserKey = useBrowserKeySelection(); + const localKeyAbort = useRef(null); + useEffect(() => { + const abort = () => localKeyAbort.current?.abort(); + window.addEventListener("pagehide", abort); + return () => { + window.removeEventListener("pagehide", abort); + abort(); + }; + }, []); const enableSsh = useSetSshEnabled(); const [submitting, setSubmitting] = useState(false); useEffect(() => { @@ -91,6 +105,18 @@ export function ConnectPanel({ const [localDraft, setLocalDraft] = useState(() => sshSessionDraft(initial)); const draft = controlledDraft ?? localDraft; const setDraft = onDraftChange ?? setLocalDraft; + const signingScope = JSON.stringify([draft.authentication, draft.nodeId, sshDestination(draft)]); + const previousSigningScope = useRef(signingScope); + useEffect(() => { + if (previousSigningScope.current === signingScope) return; + previousSigningScope.current = signingScope; + localKeyAbort.current?.abort(); + }, [signingScope]); + useEffect(() => { + // Before Start, this is only a file selection. Destination edits must not + // discard it; approval binds the imported key to the final destination. + if (draft.authentication !== "local-key") browserKey.clear(); + }, [draft.authentication, browserKey.clear]); const nodeId = draft.nodeId; const keyHome = draft.keyHome; const dest = draft.destination; @@ -225,7 +251,11 @@ export function ConnectPanel({ // Belt over gate: the button's own disabled condition already holds both // fields, so this can only fire on a render race, never as a silent no-op. if (!canSubmit || submitGuard.current) return; + if (draft.authentication === "local-key" && !isDesktop() && browserKey.problem()) return; submitGuard.current = true; + const attempt = new AbortController(); + localKeyAbort.current = attempt; + const cancelled = () => draft.authentication === "local-key" && attempt.signal.aborted; setSubmitting(true); setRefusal(null); let launchedId: string | null = null; @@ -234,6 +264,7 @@ export function ConnectPanel({ const nodesToEnable = [...(enableKeysOnStart ? [keyHome] : []), ...(enableOnStart ? [nodeId] : [])]; if (nodesToEnable.length) { const fresh = await nodesQuery.refetch(); + if (cancelled()) return; for (const id of [nodeId, ...(keyHome ? [keyHome] : [])]) { const current = fresh.data?.machines.find((m) => m.node.id === id); if ( @@ -246,8 +277,10 @@ export function ConnectPanel({ for (const id of nodesToEnable) { const current = fresh.data?.machines.find((m) => m.node.id === id); if (!current?.node.sshEnabled) await enableSsh.mutateAsync({ id, on: true }); + if (cancelled()) return; } const refreshed = await nodesQuery.refetch(); + if (cancelled()) return; if ( refreshed.isError || [nodeId, ...(keyHome ? [keyHome] : [])].some( @@ -259,17 +292,54 @@ export function ConnectPanel({ // key subset after it becomes readable; never substitute the entire roster. if (enableKeysOnStart) return; } + if (cancelled()) return; preparing = false; - const created = await launchWithDestinationVerification( - { - ...(draft.authentication === "password" ? { authentication: "password" as const } : {}), - node: nodeId, - destination, - ...(keyHome ? { keyHome, fingerprints } : {}), - }, - launch.mutateAsync, - ); + const launchRequest = { + ...(draft.authentication === "password" ? { authentication: "password" as const } : {}), + node: nodeId, + destination, + ...(keyHome ? { keyHome, fingerprints } : {}), + }; + const created = + draft.authentication === "local-key" + ? await launchWithLocalKey( + launchRequest, + launch.mutateAsync, + (id) => { + launchedId = id; + setCreatedId(id); + }, + attempt.signal, + isDesktop() + ? undefined + : { + take: browserKey.take, + confirm: (target) => { + const node = nodes?.find((candidate) => candidate.id === nodeId); + if (!node) + throw new Error("The connecting node's details are unavailable. Select the node again."); + return verifyBrowserKeyDestination(target, { + keyFileName: browserKey.name, + nodeName: node.name, + }); + }, + }, + () => { + launchedId = null; + setCreatedId(null); + }, + ) + : await launchWithDestinationVerification( + { + ...(draft.authentication === "password" ? { authentication: "password" as const } : {}), + node: nodeId, + destination, + ...(keyHome ? { keyHome, fingerprints } : {}), + }, + launch.mutateAsync, + ); if (!created) return; + if (draft.authentication === "local-key") browserKey.clear(); launchedId = created.subshell.id; setCreatedId(launchedId); if (remember) { @@ -284,17 +354,27 @@ export function ConnectPanel({ if (onCreated) await onCreated(created.subshell.id); else void navigate({ to: "/subshells/$id", params: { id: created.subshell.id } }); } catch (err) { + if (cancelled()) return; setRefusal( launchedId ? { field: "destination", - text: "SSH subshell was created but could not be attached. Add it from the existing subshell list instead.", + text: + draft.authentication === "local-key" + ? errMessage( + err, + "The SSH subshell was created, but temporary key authentication did not finish. Check its terminal before starting another connection.", + ) + : "SSH subshell was created but could not be attached. Add it from the existing subshell list instead.", } : preparing ? { field: "machine", text: errMessage(err, "Couldn’t enable SSH and start the subshell.") } - : sshLaunchRefusal(err, selectedNode), + : sshLaunchRefusal(err, selectedNode, draft.authentication === "local-key"), ); } finally { + if (localKeyAbort.current === attempt) { + localKeyAbort.current = null; + } setSubmitting(false); submitGuard.current = false; } @@ -329,20 +409,21 @@ export function ConnectPanel({
setWizard(false))} + onCancel={() => { + localKeyAbort.current?.abort(); + browserKey.clear(); + (onLeave ?? onDone ?? (() => setWizard(false)))(); + }} onLeave={onLeave} busy={submitting} launchBlocked={createdId !== null} + refusal={refusal?.text} /> - {refusal && ( -

- {refusal.text} -

- )}
); if (nodesQuery.isPending || nodesQuery.isError) diff --git a/apps/server/web/src/components/connect/destination-options.ts b/apps/server/web/src/components/connect/destination-options.ts index beaba97a6..302638a61 100644 --- a/apps/server/web/src/components/connect/destination-options.ts +++ b/apps/server/web/src/components/connect/destination-options.ts @@ -73,7 +73,13 @@ export function buildDestinationOptions(args: { const label = row.alias ?? row.destination; push( `${SAVED_PREFIX}${row.id}`, - { value: `${SAVED_PREFIX}${row.id}`, label, group: SAVED_GROUP, searchText: row.destination }, + { + value: `${SAVED_PREFIX}${row.id}`, + label, + group: SAVED_GROUP, + reason: label !== row.destination ? row.destination : undefined, + searchText: row.destination, + }, { destination: row.destination }, ); } @@ -82,7 +88,13 @@ export function buildDestinationOptions(args: { const label = row.alias ?? row.destination; push( `${RECENT_PREFIX}${row.id}`, - { value: `${RECENT_PREFIX}${row.id}`, label, group: RECENT_GROUP, searchText: row.destination }, + { + value: `${RECENT_PREFIX}${row.id}`, + label, + group: RECENT_GROUP, + reason: label !== row.destination ? row.destination : undefined, + searchText: row.destination, + }, { destination: row.destination }, ); } diff --git a/apps/server/web/src/components/connect/ssh-refusal.ts b/apps/server/web/src/components/connect/ssh-refusal.ts index cce8280ce..c5957a67b 100644 --- a/apps/server/web/src/components/connect/ssh-refusal.ts +++ b/apps/server/web/src/components/connect/ssh-refusal.ts @@ -50,8 +50,15 @@ function refusalFromOutcome(outcome: Extract void; /** Readiness is supplied by the mounted content host. */ @@ -35,10 +39,12 @@ export interface SshWizardProps { busy: boolean; /** A pane already exists; attachment failure must not create a second one. */ launchBlocked?: boolean; + refusal?: string; } export function SshWizard({ draft, + browserKey, onDraftChange, machines, readiness, @@ -47,7 +53,12 @@ export function SshWizard({ onLeave, busy, launchBlocked, + refusal, }: SshWizardProps) { + const errorRef = useRef(null); + useEffect(() => { + if (refusal) errorRef.current?.focus(); + }, [refusal]); const [useRemote, setUseRemote] = useState(!!draft.keyHome); const machine = machines.find((item) => item.node.id === draft.nodeId); const keyMachine = machines.find((item) => item.node.id === draft.keyHome); @@ -61,6 +72,10 @@ export function SshWizard({ const roster = useSshNodeRoster(remote && !keyProblem && keyMachine?.canConnect ? draft.keyHome : null); const patch = (value: Partial) => onDraftChange({ ...draft, ...value }); const problems = (): Record => { + if (draft.authentication === "local-key" && !isDesktop()) { + const problem = browserKey.problem(); + if (problem) return { draft: problem }; + } if (!draft.nodeId) return { draft: "Choose a node." }; if (!sshDestination(draft)) return { draft: "Enter an SSH destination." }; if (remote) { @@ -83,7 +98,7 @@ export function SshWizard({ roster.isError || sshKeySelectionProblem(fingerprints, roster.data?.identities) !== null)); const form = makeForm({ - defaultValues: { draft, remote }, + defaultValues: { draft, remote, keyName: browserKey.name, trusted: browserKey.trusted }, validator: problems, onSubmit: async () => { if (blocked || Object.keys(problems()).length) return; @@ -93,8 +108,10 @@ export function SshWizard({ useEffect(() => { form.setFieldValue("draft", draft); form.setFieldValue("remote", remote); + form.setFieldValue("keyName", browserKey.name); + form.setFieldValue("trusted", browserKey.trusted); void form.validate("change"); - }, [draft, remote, form]); + }, [draft, remote, form, browserKey.name, browserKey.trusted]); const disabled = useSubmitDisabled(form, blocked); return (
@@ -124,11 +141,11 @@ export function SshWizard({ { setUseRemote(value === "remote"); patch({ - authentication: value === "password" ? "password" : undefined, + authentication: value === "password" || value === "local-key" ? value : undefined, ...(value !== "remote" ? { keyHome: "" } : {}), }); }} @@ -138,20 +155,22 @@ export function SshWizard({ id="ssh-keys-connecting" value="connecting" aria-labelledby="ssh-keys-connecting-label" - aria-describedby="ssh-keys-node-name" + aria-describedby={!draft.authentication && !remote ? "ssh-keys-node-name" : undefined} /> Use the connecting node’s keys - - {machine?.node.name ?? "Choose a node to connect from first"} - + {!draft.authentication && !remote && ( + + {machine?.node.name ?? "Choose a node to connect from first"} + + )} @@ -184,18 +203,48 @@ export function SshWizard({ )} + + +
+ + Use a key from my machine + + {draft.authentication === "local-key" && + (isDesktop() ? ( +

+ Choose a key when you start. It stays on your machine and is released after one authentication + attempt or 90 seconds. +

+ ) : ( + + ))} +
+
Enter a password when connecting - - Enter your password in the terminal when SSH asks for it. Subshell won’t save it. - + {draft.authentication === "password" && ( + + Enter your password in the terminal when SSH asks for it. Subshell won’t save it. + + )}
@@ -244,11 +293,48 @@ export function SshWizard({ )} + {refusal && ( +
+
+ )}
- -
diff --git a/apps/server/web/src/components/connect/verify-browser-key-destination.tsx b/apps/server/web/src/components/connect/verify-browser-key-destination.tsx new file mode 100644 index 000000000..f6086a906 --- /dev/null +++ b/apps/server/web/src/components/connect/verify-browser-key-destination.tsx @@ -0,0 +1,50 @@ +import { confirmAction } from "@internal/node-admin"; +import { browserHostFingerprint } from "@/lib/ssh-browser-signer-core"; +import type { LocalKeyPreparation } from "@/lib/ssh-local-key"; + +/** Approval precedes reading the selected private file or sending any authentication signature. */ +export async function verifyBrowserKeyDestination( + target: LocalKeyPreparation, + selection: { keyFileName: string; nodeName: string }, +): Promise { + // The signer core's validated fingerprint is the ONLY fingerprint here: a + // raw digest of whatever bytes arrived would let a malformed blob render a + // number the person then trusts, before the worker refuses the same blob. + let fingerprint: string; + try { + fingerprint = await browserHostFingerprint(target.hostKey); + } catch { + throw new Error("The host key the connecting node supplied isn't a supported SSH key. Ask it to scan again."); + } + const command = `ssh ${target.port === 22 ? "" : `-p ${target.port} `}${target.user}@${target.host}`; + return confirmAction({ + title: "Use this key to connect?", + description: ( +
+
+
+
Connecting node
+
{selection.nodeName}
+
+
+
Key file
+
{selection.keyFileName}
+
+
+
SSH command
+
{command}
+
+
+
Host fingerprint
+
{fingerprint}
+
+
+

+ The node “{selection.nodeName}” supplied this fingerprint. Verify it through a trusted channel before + continuing. +

+
+ ), + confirmLabel: "Use key and connect", + }); +} diff --git a/apps/server/web/src/components/ssh-pane-labels.tsx b/apps/server/web/src/components/ssh-pane-labels.tsx index bce97be76..db6158933 100644 --- a/apps/server/web/src/components/ssh-pane-labels.tsx +++ b/apps/server/web/src/components/ssh-pane-labels.tsx @@ -1,10 +1,12 @@ +import { SquareTerminal } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import type { SubshellView } from "@/types/subshell"; /** The header sentence a non-owner of an ssh pane sees (spec 2026-10-07 §5.4). */ export const SSH_VIEW_ONLY_COPY = "Only the owner can type in this SSH subshell."; /** - * The ssh facts a pane header states beside its other labels: an SSH badge + * The ssh facts a pane header states beside its other labels: an SSH icon * naming what kind of pane this is, and — only for a viewer who is not the * owner — the one sentence explaining why the terminal takes no input from * them. Enforcement is NOT here: the view layer already answers a non-owner @@ -20,7 +22,14 @@ export function SshPaneLabels({ subshell }: { subshell?: SubshellView }) { if (subshell?.ssh !== true) return null; return ( <> - SSH + + + + + This subshell connects to another machine over SSH. + + {subshell.access === "view" && {SSH_VIEW_ONLY_COPY}} ); diff --git a/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx index 9490b2918..3d9fd4d13 100644 --- a/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx +++ b/apps/server/web/src/components/subshell-picker/__tests__/ssh-launch-integration.test.tsx @@ -119,7 +119,7 @@ async function launchSsh() { fireEvent.change(field, { target: { value: "deploy@example.com:2222" } }); await settle(); const start = screen.getByRole("button", { name: "Start SSH subshell" }); - await waitFor(() => expect((start as HTMLButtonElement).disabled).toBe(false)); + await waitFor(() => expect(start.getAttribute("aria-disabled")).not.toBe("true")); fireEvent.click(start); } @@ -146,7 +146,7 @@ it("adds the SSH result to the current split and blocks duplicate launches while await launchSsh(); await waitFor(() => expect(adds).toEqual([["ssh-new", "right"]])); const start = screen.getByRole("button", { name: "Starting SSH subshell…" }); - expect((start as HTMLButtonElement).disabled).toBe(true); + expect(start.getAttribute("aria-disabled")).toBe("true"); fireEvent.click(start); expect(calls.filter((c) => c.path === "/api/ssh/launch")).toHaveLength(1); expect(screen.getByRole("button", { name: "Existing subshell" }).closest("fieldset[disabled]")).not.toBeNull(); diff --git a/apps/server/web/src/components/updates/desktop-rows.tsx b/apps/server/web/src/components/updates/desktop-rows.tsx index c9121660e..877ac1b2e 100644 --- a/apps/server/web/src/components/updates/desktop-rows.tsx +++ b/apps/server/web/src/components/updates/desktop-rows.tsx @@ -9,8 +9,9 @@ import type { ReleaseRef, UpdatesView } from "@/types/updates"; * Each surface gets what it is ALLOWED to do, which is why they differ: * * - **Subshell Client** gets a SENTENCE, because its remote window is granted - * exactly one command (`desktop_open_in_browser`) and no design here widens - * it. The update lives on its bundled node page, reached from the tray. + * only five narrow commands (the browser opener and the four one-use SSH + * signer commands) and no design here widens that. The update lives on its + * bundled node page, reached from the tray. * - **A browser** gets links. Nothing here can install anything on a machine * the page is not running on. * diff --git a/apps/server/web/src/hooks/use-instance-plugins.ts b/apps/server/web/src/hooks/use-instance-plugins.ts index 7440acaa2..680272c37 100644 --- a/apps/server/web/src/hooks/use-instance-plugins.ts +++ b/apps/server/web/src/hooks/use-instance-plugins.ts @@ -17,6 +17,13 @@ export interface InstancePluginRow { description: string; /** Icon label */ icon?: string; + /** + * Present only as false: the plugin declares its own refusal to be listed + * in launch pickers (it drives a pane that needs more than a pick, like + * SSH needing a destination). Absent means pickable; the row still exists + * and a launch by id still works. + */ + pickable?: false; /** Driven program's command name, from the plugin this process resolves */ binary?: string; /** Installed package version (absent when not installed) */ diff --git a/apps/server/web/src/lib/__tests__/ssh-browser-key.test.ts b/apps/server/web/src/lib/__tests__/ssh-browser-key.test.ts new file mode 100644 index 000000000..6347ef924 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-browser-key.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, expect, it } from "bun:test"; +import { openBrowserKey } from "../ssh-browser-key"; + +const originalWorker = globalThis.Worker; +const originalSecure = Object.getOwnPropertyDescriptor(globalThis, "isSecureContext"); +const workers: FakeWorker[] = []; +class FakeWorker { + onmessage?: (event: { data: unknown }) => void; + onerror?: (event: { preventDefault(): void }) => void; + onmessageerror?: () => void; + messages: Record[] = []; + transfers: Transferable[][] = []; + terminated = false; + constructor() { + workers.push(this); + } + postMessage(value: Record, transfer: Transferable[]) { + this.messages.push(value); + this.transfers.push(transfer); + } + terminate() { + this.terminated = true; + } + reply(result: unknown) { + this.onmessage?.({ data: { id: this.messages.at(-1)?.id, result } }); + } +} +beforeEach(() => { + workers.length = 0; + globalThis.Worker = FakeWorker as unknown as typeof Worker; + Object.defineProperty(globalThis, "isSecureContext", { value: true, configurable: true }); +}); +afterEach(() => { + globalThis.Worker = originalWorker; + if (originalSecure) Object.defineProperty(globalThis, "isSecureContext", originalSecure); + else Reflect.deleteProperty(globalThis, "isSecureContext"); +}); +const scope = { user: "alice", hostKey: "approved-public-host" }; +const material = () => ({ file: new File(["fixture-key"], "id_ed25519"), passphrase: new Uint8Array([1, 2, 3]) }); + +it("transfers the passphrase buffer to a disposable worker and terminates after one signature", async () => { + const key = material(); + const opening = openBrowserKey(key, scope, new AbortController().signal); + const worker = workers[0]; + expect(worker.messages[0].file).toBe(key.file); + expect(worker.messages[0].user).toBe("alice"); + expect(worker.messages[0].hostKey).toBe(scope.hostKey); + expect(worker.transfers[0]).toEqual([key.passphrase.buffer]); + worker.reply({ publicKey: "public" }); + const signer = await opening; + expect([...key.passphrase]).toEqual([0, 0, 0]); + const request = signer.request("public-request"); + worker.reply({ response: "Dg==", done: true }); + expect(await request).toEqual({ response: "Dg==", done: true }); + expect(worker.terminated).toBe(true); + await expect(signer.request("second")).rejects.toThrow("ended"); +}); + +it("cancels a worker even while key decryption is pending", async () => { + const abort = new AbortController(); + const key = material(); + const opening = openBrowserKey(key, scope, abort.signal); + abort.abort(); + await expect(opening).rejects.toThrow("ended"); + expect(workers[0].terminated).toBe(true); + expect([...key.passphrase]).toEqual([0, 0, 0]); +}); + +it("cleans up failed construction and refuses non-secure pages before creating a worker", async () => { + Object.defineProperty(globalThis, "isSecureContext", { value: false, configurable: true }); + const key = material(); + await expect(openBrowserKey(key, scope, new AbortController().signal)).rejects.toThrow("HTTPS"); + expect(workers).toHaveLength(0); + expect([...key.passphrase]).toEqual([0, 0, 0]); +}); + +it("rejects an out-of-sequence worker response and cancels on pagehide", async () => { + const key = material(); + const opening = openBrowserKey(key, scope, new AbortController().signal); + workers[0].onmessage?.({ data: { id: 200, result: {} } }); + await expect(opening).rejects.toThrow("ended"); + const next = openBrowserKey(material(), scope, new AbortController().signal); + workers[1].reply({ publicKey: "public" }); + const signer = await next; + window.dispatchEvent(new Event("pagehide")); + expect(workers[1].terminated).toBe(true); + await expect(signer.request("request")).rejects.toThrow("ended"); +}); diff --git a/apps/server/web/src/lib/__tests__/ssh-browser-signer-core.test.ts b/apps/server/web/src/lib/__tests__/ssh-browser-signer-core.test.ts new file mode 100644 index 000000000..ba7a1bebb --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-browser-signer-core.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { base64, concat, decode64, field, Reader } from "../ssh-browser-signer/wire"; +import { browserHostFingerprint, inspectBrowserSshKey } from "../ssh-browser-signer-core"; +import { + authData, + bindingPacket, + generateFixture, + hostFixture, + PASSWORD, + signingPacket, + verifyResponse, +} from "./ssh-browser-signer-fixtures"; +import { browserSignerFixtures, variants } from "./ssh-browser-signer-test-context"; + +const { host, fixture, create, directory } = browserSignerFixtures(); + +describe("OpenSSH browser imports and independent signature verification", () => { + for (const [name] of variants) { + for (const encrypted of [false, true]) { + const variant = `${name}${encrypted ? "-encrypted" : ""}`; + test(`${variant}: inspect, import, bind, sign and consume`, async () => { + expect(inspectBrowserSshKey(fixture(variant).bytes)).toEqual({ encrypted }); + const signer = await create(variant); + expect(signer.publicKey).toBe(fixture(variant).publicKey); + const identities = new Reader(decode64((await signer.request("Cw==")).response)); + expect(identities.byte()).toBe(12); + expect(identities.u32()).toBe(1); + expect(base64(identities.string())).toBe(signer.publicKey); + identities.string(); + identities.end(); + expect(await signer.request(base64(bindingPacket(host)))).toEqual({ response: "Bg==", done: false }); + const algorithm = name === "rsa" ? "rsa-sha2-256" : undefined; + const data = authData(signer.publicKey, host, { algorithm }); + const packet = base64(signingPacket(signer.publicKey, data, name === "rsa" ? 2 : 0)); + const reply = await signer.request(packet); + expect(reply.done).toBe(true); + expect(verifyResponse(signer.publicKey, data, reply.response)).toBe(true); + expect(await signer.request(packet)).toEqual({ response: "BQ==", done: true }); + }); + } + } + + test("RSA SHA-512 works and SHA-1 or mismatched flags fail", async () => { + const signer = await create("rsa"); + await signer.request(base64(bindingPacket(host))); + for (const [algorithm, flags] of [ + ["ssh-rsa", 0], + ["rsa-sha2-512", 2], + ["rsa-sha2-256", 4], + ]) { + const data = authData(signer.publicKey, host, { algorithm: String(algorithm) }); + expect(await signer.request(base64(signingPacket(signer.publicKey, data, Number(flags))))).toEqual({ + response: "BQ==", + done: false, + }); + } + const data = authData(signer.publicKey, host, { algorithm: "rsa-sha2-512" }); + const reply = await signer.request(base64(signingPacket(signer.publicKey, data, 4))); + expect(verifyResponse(signer.publicKey, data, reply.response)).toBe(true); + }); + + test("RSA and every ECDSA curve can prove the approved host", async () => { + for (const [kind, curve] of [ + ["rsa", undefined], + ["ec", "prime256v1"], + ["ec", "secp384r1"], + ["ec", "secp521r1"], + ] as const) { + const otherHost = hostFixture(kind, curve); + const signer = await create("ed25519", { hostKey: otherHost.encoded }); + expect(await signer.request(base64(bindingPacket(otherHost)))).toEqual({ response: "Bg==", done: false }); + const data = authData(signer.publicKey, otherHost); + const reply = await signer.request(base64(signingPacket(signer.publicKey, data))); + expect(verifyResponse(signer.publicKey, data, reply.response)).toBe(true); + } + }); + + test("AES-128 and AES-192 encrypted files interoperate with ssh-keygen", async () => { + for (const cipher of ["aes128-ctr", "aes192-ctr"]) { + const item = generateFixture(directory(), cipher, "ed25519", undefined, true, cipher); + const signer = await create("ed25519", { bytes: item.bytes, passphrase: new TextEncoder().encode(PASSWORD) }); + expect(signer.publicKey).toBe(item.publicKey); + signer.close(); + } + }); + + test("the default ssh-keygen encrypted format and bcrypt rounds are supported", async () => { + const item = generateFixture(directory(), "default-encryption", "ed25519", undefined, true, undefined, null); + const signer = await create("ed25519", { bytes: item.bytes, passphrase: new TextEncoder().encode(PASSWORD) }); + expect(signer.publicKey).toBe(item.publicKey); + await signer.request(base64(bindingPacket(host))); + const data = authData(signer.publicKey, host); + expect( + verifyResponse( + signer.publicKey, + data, + (await signer.request(base64(signingPacket(signer.publicKey, data)))).response, + ), + ).toBe(true); + }); + + test("fingerprints match OpenSSH SHA-256 and invalid or trailing host blobs fail", async () => { + expect(await browserHostFingerprint(host.encoded)).toBe( + `SHA256:${createHash("sha256").update(host.blob).digest("base64").replace(/=+$/, "")}`, + ); + await expect(browserHostFingerprint(base64(concat(host.blob, new Uint8Array([0]))))).rejects.toThrow("host key"); + await expect(browserHostFingerprint(`${host.encoded}\n`)).rejects.toThrow("host key"); + }); +}); + +describe("authorization and lifecycle", () => { + test("forged, wrong-host, forwarded, malformed and trailing bindings don't arm the signer", async () => { + const signer = await create(); + const forged = bindingPacket(host); + forged[forged.length - 2] ^= 1; + for (const packet of [ + forged, + bindingPacket(hostFixture()), + bindingPacket(host, { forwarding: 1 }), + concat(bindingPacket(host), new Uint8Array([0])), + bindingPacket(host, { session: new Uint8Array(15) }), + bindingPacket(host, { session: new Uint8Array(129) }), + bindingPacket(host, { signature: concat(field("ssh-ed25519"), field(new Uint8Array(64)), new Uint8Array([0])) }), + ]) { + expect(await signer.request(base64(packet))).toEqual({ response: "BQ==", done: false }); + } + const data = authData(signer.publicKey, host); + expect(await signer.request(base64(signingPacket(signer.publicKey, data)))).toEqual({ + response: "BQ==", + done: false, + }); + expect(await signer.request(base64(bindingPacket(host)))).toEqual({ response: "Bg==", done: false }); + expect(await signer.request(base64(bindingPacket(host, { session: new Uint8Array(32).fill(2) })))).toEqual({ + response: "BQ==", + done: false, + }); + signer.close(); + }); + + test("only exact host-bound public-key userauth is accepted", async () => { + const signer = await create(); + await signer.request(base64(bindingPacket(host))); + const data = authData(signer.publicKey, host); + for (const bad of [ + authData(signer.publicKey, host, { user: "bob" }), + authData(signer.publicKey, host, { session: new Uint8Array(32) }), + authData(signer.publicKey, host, { method: "publickey" }), + authData(signer.publicKey, host, { service: "other" }), + authData(signer.publicKey, host, { boolean: 0 }), + authData(signer.publicKey, host, { algorithm: "ssh-rsa" }), + authData(signer.publicKey, host, { publicKey: fixture("p256").publicKey }), + authData(signer.publicKey, host, { hostKey: hostFixture().blob }), + concat(data, new Uint8Array([0])), + new TextEncoder().encode("arbitrary unbound challenge"), + ]) + expect(await signer.request(base64(signingPacket(signer.publicKey, bad)))).toEqual({ + response: "BQ==", + done: false, + }); + for (const packet of [ + signingPacket(fixture("p256").publicKey, data), + signingPacket(signer.publicKey, data, 2), + concat(signingPacket(signer.publicKey, data), new Uint8Array([0])), + ]) { + expect(await signer.request(base64(packet))).toEqual({ response: "BQ==", done: false }); + } + const reply = await signer.request(base64(signingPacket(signer.publicKey, data))); + expect(verifyResponse(signer.publicKey, data, reply.response)).toBe(true); + }); + + test("unknown commands and noncanonical, oversized or malformed packets are denied", async () => { + const signer = await create(); + for (const payload of [ + "", + "!!!", + "Cx==", + "Cw", + "Cw==\n", + base64(new Uint8Array(65_537)), + base64(new Uint8Array([13, 255, 255, 255, 255])), + "Ew==", + "CwA=", + "Gw==", + "DQ==", + ]) { + expect(await signer.request(payload)).toEqual({ response: "BQ==", done: false }); + } + signer.close(); + expect(await signer.request("Cw==")).toEqual({ response: "BQ==", done: true }); + }); + + test("concurrent binding requests can't overwrite an approved session", async () => { + const signer = await create(); + const replies = await Promise.all([ + signer.request(base64(bindingPacket(host))), + signer.request(base64(bindingPacket(host, { session: new Uint8Array(32).fill(7) }))), + ]); + expect(replies.filter((reply) => reply.response === "Bg==").length).toBe(1); + const data = authData(signer.publicKey, host); + expect( + verifyResponse( + signer.publicKey, + data, + (await signer.request(base64(signingPacket(signer.publicKey, data)))).response, + ), + ).toBe(true); + }); + + test("concurrent signing has exactly one winner", async () => { + const signer = await create(); + await signer.request(base64(bindingPacket(host))); + const data = authData(signer.publicKey, host); + const packet = base64(signingPacket(signer.publicKey, data)); + const replies = await Promise.all(Array.from({ length: 8 }, () => signer.request(packet))); + expect(replies.filter((reply) => reply.response !== "BQ==").length).toBe(1); + expect(replies.every((reply) => reply.done)).toBe(true); + }); + + test("close during crypto discards the pending signature", async () => { + const signer = await create(); + await signer.request(base64(bindingPacket(host))); + const data = authData(signer.publicKey, host); + const pending = signer.request(base64(signingPacket(signer.publicKey, data))); + signer.close(); + expect(await pending).toEqual({ response: "BQ==", done: true }); + }); + + test("failed signing still consumes the private key and returns only agent failure", async () => { + const signer = await create(); + await signer.request(base64(bindingPacket(host))); + const data = authData(signer.publicKey, host); + const packet = base64(signingPacket(signer.publicKey, data)); + const failure = spyOn(crypto.subtle, "sign").mockRejectedValueOnce(new Error("private engine diagnostic")); + try { + expect(await signer.request(packet)).toEqual({ response: "BQ==", done: true }); + expect(await signer.request(packet)).toEqual({ response: "BQ==", done: true }); + } finally { + failure.mockRestore(); + } + }); + + test("binding verification that crosses the deadline is discarded", async () => { + const signer = await create(); + const pending = signer.request(base64(bindingPacket(host))); + const clock = spyOn(Date, "now").mockReturnValue(Date.now() + 90_000); + try { + expect(await pending).toEqual({ response: "BQ==", done: true }); + } finally { + clock.mockRestore(); + } + }); + + test("expiration is enforced before and after crypto, even if the wall clock moves backward", async () => { + const signer = await create(); + const clock = spyOn(Date, "now").mockReturnValue(Date.now() + 90_000); + try { + expect(await signer.request("Cw==")).toEqual({ response: "BQ==", done: true }); + } finally { + clock.mockRestore(); + } + const other = await create(); + await other.request(base64(bindingPacket(host))); + const data = authData(other.publicKey, host); + const pending = other.request(base64(signingPacket(other.publicKey, data))); + const later = spyOn(Date, "now").mockReturnValue(Date.now() + 90_000); + try { + expect(await pending).toEqual({ response: "BQ==", done: true }); + } finally { + later.mockRestore(); + } + const bounded = await create(); + const monotonic = spyOn(performance, "now").mockReturnValue(performance.now() + 90_000); + const backward = spyOn(Date, "now").mockReturnValue(Date.now() - 90_000); + try { + expect(await bounded.request("Cw==")).toEqual({ response: "BQ==", done: true }); + } finally { + monotonic.mockRestore(); + backward.mockRestore(); + } + }); +}); diff --git a/apps/server/web/src/lib/__tests__/ssh-browser-signer-fixtures.ts b/apps/server/web/src/lib/__tests__/ssh-browser-signer-fixtures.ts new file mode 100644 index 000000000..c69a61194 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-browser-signer-fixtures.ts @@ -0,0 +1,189 @@ +import { execFileSync } from "node:child_process"; +import { createPublicKey, generateKeyPairSync, type KeyObject, sign, verify } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { base64, concat, decode64, field, mpint, padded, positive, Reader, u32 } from "../ssh-browser-signer/wire"; + +export const SESSION = new Uint8Array(32).fill(42); +export const PASSWORD = "ephemeral-fixture-passphrase"; + +export interface Fixture { + bytes: Uint8Array; + publicKey: string; +} + +export function generateFixture( + directory: string, + name: string, + type: string, + bits?: number, + encrypted = false, + cipher?: string, + rounds: number | null = 1, +): Fixture { + const path = join(directory, name); + const args = ["-q", "-t", type, "-f", path, "-N", encrypted ? PASSWORD : "", "-C", ""]; + if (bits) args.push("-b", String(bits)); + if (encrypted && rounds !== null) args.push("-a", String(rounds)); + if (cipher) args.push("-Z", cipher); + execFileSync("ssh-keygen", args, { stdio: "pipe" }); + return { bytes: new Uint8Array(readFileSync(path)), publicKey: readFileSync(`${path}.pub`, "utf8").split(" ")[1] }; +} + +export function hostFixture(kind: "ed25519" | "rsa" | "ec" = "ed25519", curve = "prime256v1") { + const pair = + kind === "ed25519" + ? generateKeyPairSync("ed25519") + : kind === "rsa" + ? generateKeyPairSync("rsa", { modulusLength: 2048 }) + : generateKeyPairSync("ec", { namedCurve: curve }); + const jwk = pair.publicKey.export({ format: "jwk" }); + const algorithm = + kind === "ed25519" + ? "ssh-ed25519" + : kind === "rsa" + ? "rsa-sha2-256" + : `ecdsa-sha2-${{ "P-256": "nistp256", "P-384": "nistp384", "P-521": "nistp521" }[jwk.crv ?? ""]}`; + const blob = + kind === "ed25519" + ? concat(field(algorithm), field(Buffer.from(jwk.x ?? "", "base64url"))) + : kind === "rsa" + ? concat( + field("ssh-rsa"), + mpint(Buffer.from(jwk.e ?? "", "base64url")), + mpint(Buffer.from(jwk.n ?? "", "base64url")), + ) + : concat( + field(algorithm), + field(algorithm.slice("ecdsa-sha2-".length)), + field( + concat(new Uint8Array([4]), Buffer.from(jwk.x ?? "", "base64url"), Buffer.from(jwk.y ?? "", "base64url")), + ), + ); + return { privateKey: pair.privateKey, blob, encoded: base64(blob), algorithm, kind, curve: jwk.crv }; +} + +export type Host = ReturnType; + +export function bindingPacket( + host: Host, + options: { session?: Uint8Array; forwarding?: number; algorithm?: string; signature?: Uint8Array } = {}, +) { + const session = options.session ?? SESSION; + const algorithm = options.algorithm ?? host.algorithm; + const hash = + host.kind === "ed25519" + ? null + : host.kind === "rsa" || host.curve === "P-256" + ? "sha256" + : host.curve === "P-384" + ? "sha384" + : "sha512"; + const raw = new Uint8Array(sign(hash, session, { key: host.privateKey, dsaEncoding: "ieee-p1363" })); + const encodedSignature = + host.kind === "ec" ? concat(mpint(raw.subarray(0, raw.length / 2)), mpint(raw.subarray(raw.length / 2))) : raw; + return concat( + new Uint8Array([27]), + field("session-bind@openssh.com"), + field(host.blob), + field(session), + field(options.signature ?? concat(field(algorithm), field(encodedSignature))), + new Uint8Array([options.forwarding ?? 0]), + ); +} + +export function authData( + publicKey: string, + host: Host, + options: { + session?: Uint8Array; + user?: string; + service?: string; + method?: string; + algorithm?: string; + publicKey?: string; + hostKey?: Uint8Array; + boolean?: number; + } = {}, +) { + const publicBytes = decode64(publicKey); + const algorithm = options.algorithm ?? new Reader(publicBytes).text(); + return concat( + field(options.session ?? SESSION), + new Uint8Array([50]), + field(options.user ?? "alice"), + field(options.service ?? "ssh-connection"), + field(options.method ?? "publickey-hostbound-v00@openssh.com"), + new Uint8Array([options.boolean ?? 1]), + field(algorithm), + field(decode64(options.publicKey ?? publicKey)), + field(options.hostKey ?? host.blob), + ); +} + +export function signingPacket(publicKey: string, data: Uint8Array, flags = 0) { + return concat(new Uint8Array([13]), field(decode64(publicKey)), field(data), u32(flags)); +} + +function nodePublic(publicKey: string): { key: KeyObject; size: number; kind: string } { + const reader = new Reader(decode64(publicKey)); + const algorithm = reader.text(); + if (algorithm === "ssh-ed25519") { + return { + key: createPublicKey({ + key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(reader.string()).toString("base64url") }, + format: "jwk", + }), + size: 32, + kind: "ed25519", + }; + } + if (algorithm === "ssh-rsa") { + const e = Buffer.from(positive(reader)).toString("base64url"); + const n = Buffer.from(positive(reader)).toString("base64url"); + return { key: createPublicKey({ key: { kty: "RSA", e, n }, format: "jwk" }), size: 0, kind: "rsa" }; + } + const curve = reader.text(); + const raw = reader.string(); + const size = (raw.length - 1) / 2; + const crv = { nistp256: "P-256", nistp384: "P-384", nistp521: "P-521" }[curve]; + return { + key: createPublicKey({ + key: { + kty: "EC", + crv, + x: Buffer.from(raw.subarray(1, 1 + size)).toString("base64url"), + y: Buffer.from(raw.subarray(1 + size)).toString("base64url"), + }, + format: "jwk", + }), + size, + kind: "ec", + }; +} + +/** Independently verify worker wire signatures through node:crypto. */ +export function verifyResponse(publicKey: string, data: Uint8Array, response: string): boolean { + const packet = new Reader(decode64(response)); + if (packet.byte() !== 14) return false; + const signature = new Reader(packet.string()); + packet.end(); + const algorithm = signature.text(); + let raw = signature.string(); + signature.end(); + const { key, kind, size } = nodePublic(publicKey); + if (kind === "ec") { + const pair = new Reader(raw); + raw = concat(padded(positive(pair, size), size), padded(positive(pair, size), size)); + pair.end(); + } + const hash = + kind === "ed25519" + ? null + : algorithm === "rsa-sha2-512" || size === 66 + ? "sha512" + : size === 48 + ? "sha384" + : "sha256"; + return verify(hash, data, { key, dsaEncoding: "ieee-p1363" }, raw); +} diff --git a/apps/server/web/src/lib/__tests__/ssh-browser-signer-parsing.test.ts b/apps/server/web/src/lib/__tests__/ssh-browser-signer-parsing.test.ts new file mode 100644 index 000000000..bd1cc6fe5 --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-browser-signer-parsing.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { readEnvelope } from "../ssh-browser-signer/envelope"; +import { base64, concat, decode64, field, Reader, u32 } from "../ssh-browser-signer/wire"; +import { createBrowserSshSigner, inspectBrowserSshKey } from "../ssh-browser-signer-core"; +import { browserSignerFixtures } from "./ssh-browser-signer-test-context"; + +const { host, fixture, create } = browserSignerFixtures(); + +function rewrittenEnvelope( + name: string, + changes: { + cipher?: string; + kdf?: string; + options?: Uint8Array; + publicKey?: Uint8Array; + privateBlock?: Uint8Array; + count?: number; + trailing?: Uint8Array; + }, +) { + const envelope = readEnvelope(fixture(name).bytes); + const encrypted = name.endsWith("-encrypted"); + const out = concat( + new TextEncoder().encode("openssh-key-v1\0"), + field(changes.cipher ?? (encrypted ? "aes256-ctr" : "none")), + field(changes.kdf ?? (encrypted ? "bcrypt" : "none")), + field(changes.options ?? (encrypted ? concat(field(envelope.salt), u32(envelope.rounds)) : new Uint8Array())), + u32(changes.count ?? 1), + field(changes.publicKey ?? envelope.publicKey), + field(changes.privateBlock ?? envelope.privateBlock), + changes.trailing ?? new Uint8Array(), + ); + envelope.buffer.fill(0); + const encoded = new TextEncoder().encode( + `-----BEGIN OPENSSH PRIVATE KEY-----\n${base64(out)}\n-----END OPENSSH PRIVATE KEY-----\n`, + ); + out.fill(0); + return encoded; +} + +describe("key parsing and secret cleanup", () => { + test("wrong or missing passphrases fail with fixed errors and scrub inputs", async () => { + const bytes = fixture("ed25519-encrypted").bytes.slice(); + const passphrase = new TextEncoder().encode("wrong secret which must not appear in errors"); + await expect(create("ed25519-encrypted", { bytes, passphrase })).rejects.toThrow("passphrase"); + expect(bytes.every((byte) => byte === 0)).toBe(true); + expect(passphrase.every((byte) => byte === 0)).toBe(true); + await expect( + createBrowserSshSigner({ + user: "alice", + hostKey: host.encoded, + expiresAt: Date.now() + 89_000, + bytes: fixture("ed25519-encrypted").bytes.slice(), + }), + ).rejects.toThrow("passphrase"); + }); + + test("plaintext input buffers are scrubbed on success and invalid scope", async () => { + const bytes = fixture().bytes.slice(); + const signer = await create("ed25519", { bytes }); + expect(bytes.every((byte) => byte === 0)).toBe(true); + signer.close(); + for (const expiresAt of [Date.now() - 1, Date.now() + 91_000, Number.NaN, Number.POSITIVE_INFINITY]) { + const invalid = fixture().bytes.slice(); + await expect(create("ed25519", { bytes: invalid, expiresAt })).rejects.toThrow("expired"); + expect(invalid.every((byte) => byte === 0)).toBe(true); + } + }); + + test("key import that crosses the deadline is discarded and scrubbed", async () => { + const bytes = fixture().bytes.slice(); + const initial = Date.now(); + let reads = 0; + const clock = spyOn(Date, "now").mockImplementation(() => initial + (reads++ ? 90_000 : 0)); + try { + await expect(create("ed25519", { bytes, expiresAt: initial + 89_000 })).rejects.toThrow("expired"); + expect(bytes.every((byte) => byte === 0)).toBe(true); + } finally { + clock.mockRestore(); + } + }); + + test("limits, legacy formats, unsupported encryption and KDF work are rejected before decryption", async () => { + for (const bytes of [ + new Uint8Array(), + new Uint8Array(65_537), + new TextEncoder().encode("-----BEGIN RSA PRIVATE KEY-----\n"), + new TextEncoder().encode("not an SSH key"), + ]) { + expect(() => inspectBrowserSshKey(bytes)).toThrow(); + } + for (const changes of [ + { cipher: "aes256-cbc" }, + { kdf: "unknown" }, + { count: 2 }, + { trailing: new Uint8Array([0]) }, + { options: concat(field(new Uint8Array(16)), u32(1025)) }, + { options: concat(field(new Uint8Array(16)), u32(0)) }, + { options: concat(field(new Uint8Array(65)), u32(1)) }, + ]) + expect(() => inspectBrowserSshKey(rewrittenEnvelope("ed25519-encrypted", changes))).toThrow(); + await expect(create("rsa-small")).rejects.toThrow("2,048"); + const tooLargeModulus = new Uint8Array(1025).fill(255); + await expect( + create("ed25519", { + bytes: rewrittenEnvelope("ed25519", { + publicKey: concat( + field("ssh-rsa"), + field(new Uint8Array([1, 0, 1])), + field(concat(new Uint8Array([0]), tooLargeModulus)), + ), + }), + }), + ).rejects.toThrow(); + await expect( + create("ed25519", { bytes: rewrittenEnvelope("ed25519", { publicKey: concat(field("ssh-dss")) }) }), + ).rejects.toThrow("DSA"); + await expect( + create("ed25519", { bytes: rewrittenEnvelope("ed25519", { publicKey: field("sk-ssh-ed25519@openssh.com") }) }), + ).rejects.toThrow("Hardware"); + }); + + test("checksums, padding, private components and public identities must agree", async () => { + const envelope = readEnvelope(fixture().bytes); + const original = envelope.privateBlock.slice(); + envelope.buffer.fill(0); + const check = original.slice(); + check[0] ^= 1; + const padding = original.slice(); + padding[padding.length - 1] = 0; + const seed = original.slice(); + const reader = new Reader(seed); + reader.u32(); + reader.u32(); + reader.string(); + reader.string(); + reader.string()[0] ^= 1; + for (const block of [check, padding, seed]) { + await expect( + create("ed25519", { bytes: rewrittenEnvelope("ed25519", { privateBlock: block }) }), + ).rejects.toThrow(); + } + await expect( + create("ed25519", { bytes: rewrittenEnvelope("ed25519", { publicKey: decode64(fixture("p256").publicKey) }) }), + ).rejects.toThrow("don't match"); + original.fill(0); + check.fill(0); + padding.fill(0); + seed.fill(0); + }); + + test("ECDSA scalars and RSA factors must match the advertised public key", async () => { + for (const name of ["p256", "rsa"]) { + const envelope = readEnvelope(fixture(name).bytes); + const block = envelope.privateBlock.slice(); + envelope.buffer.fill(0); + const reader = new Reader(block); + reader.u32(); + reader.u32(); + reader.string(); + if (name === "p256") { + reader.string(); + reader.string(); + } else { + // n, e, d and iqmp precede the first RSA factor. + reader.string(); + reader.string(); + reader.string(); + reader.string(); + } + const component = reader.string(); + component[component.length - 1] ^= 2; + await expect(create(name, { bytes: rewrittenEnvelope(name, { privateBlock: block }) })).rejects.toThrow(); + block.fill(0); + } + }); +}); diff --git a/apps/server/web/src/lib/__tests__/ssh-browser-signer-test-context.ts b/apps/server/web/src/lib/__tests__/ssh-browser-signer-test-context.ts new file mode 100644 index 000000000..e7f699d3b --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-browser-signer-test-context.ts @@ -0,0 +1,55 @@ +import { afterAll, beforeAll } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createBrowserSshSigner } from "../ssh-browser-signer-core"; +import { type Fixture, generateFixture, hostFixture, PASSWORD } from "./ssh-browser-signer-fixtures"; + +export const variants = [ + ["ed25519", "ed25519", undefined], + ["rsa", "rsa", 2048], + ["p256", "ecdsa", 256], + ["p384", "ecdsa", 384], + ["p521", "ecdsa", 521], +] as const; + +export function browserSignerFixtures() { + const host = hostFixture(); + const fixtures = new Map(); + let directory: string; + + beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), "subshell-browser-signer-")); + for (const [name, type, bits] of variants) { + fixtures.set(name, generateFixture(directory, name, type, bits)); + fixtures.set(`${name}-encrypted`, generateFixture(directory, `${name}-encrypted`, type, bits, true)); + } + fixtures.set("rsa-small", generateFixture(directory, "rsa-small", "rsa", 1024)); + }); + + afterAll(() => { + for (const fixture of fixtures.values()) fixture.bytes.fill(0); + rmSync(directory, { recursive: true, force: true }); + }); + + function fixture(name = "ed25519"): Fixture { + const found = fixtures.get(name); + if (!found) throw new Error("Test fixture wasn't generated."); + return found; + } + + function create( + name = "ed25519", + options: { hostKey?: string; expiresAt?: number; passphrase?: Uint8Array; bytes?: Uint8Array } = {}, + ) { + return createBrowserSshSigner({ + user: "alice", + hostKey: options.hostKey ?? host.encoded, + expiresAt: options.expiresAt ?? Date.now() + 89_000, + bytes: options.bytes ?? fixture(name).bytes.slice(), + passphrase: options.passphrase ?? (name.endsWith("-encrypted") ? new TextEncoder().encode(PASSWORD) : undefined), + }); + } + + return { host, fixture, create, directory: () => directory }; +} diff --git a/apps/server/web/src/lib/__tests__/ssh-local-key.test.ts b/apps/server/web/src/lib/__tests__/ssh-local-key.test.ts new file mode 100644 index 000000000..3b72b742f --- /dev/null +++ b/apps/server/web/src/lib/__tests__/ssh-local-key.test.ts @@ -0,0 +1,299 @@ +import { afterEach, expect, it } from "bun:test"; +import { launchWithLocalKey } from "../ssh-local-key"; + +const originalFetch = globalThis.fetch; +const nativeWindow = window as unknown as { + __TAURI__?: { core: { invoke: (command: string, args?: Record) => Promise } }; +}; +const originalNative = nativeWindow.__TAURI__; +afterEach(() => { + globalThis.fetch = originalFetch; + nativeWindow.__TAURI__ = originalNative; +}); +const prepared = { id: "request", host: "host", port: 22, user: "alice", hostKey: "public-host-key" }; +const selected = { id: "native-handle", publicKey: "public-user-key", expiresAt: Date.now() + 90_000 }; +const json = (value: unknown) => new Response(JSON.stringify(value)); +it("routes only public replies and closes both sides after a single signature", async () => { + const calls: string[] = []; + const bodies: unknown[] = []; + let exchanges = 0; + nativeWindow.__TAURI__ = { + core: { + invoke: async (command) => { + calls.push(command); + if (command === "desktop_ssh_key_available") return true; + if (command === "desktop_ssh_key_pick") return selected; + if (command === "desktop_ssh_key_request") return { response: "Dg==", done: true }; + return null; + }, + }, + }; + globalThis.fetch = (async (url, init) => { + calls.push(String(url)); + if (init?.body) bodies.push(JSON.parse(String(init.body))); + if (String(url).endsWith("/local-key")) return json(prepared); + if (String(url).endsWith("/exchange")) + return json( + ++exchanges === 1 ? { done: false, request: { seq: 0, payload: "DQ==" } } : { done: true, request: null }, + ); + return json({ closed: true }); + }) as typeof fetch; + let launched: string | undefined; + const result = await launchWithLocalKey( + { node: "n", destination: "alice@host" }, + async (request) => { + expect(request.localKeyId).toBe("request"); + return { subshell: { id: "pane" } }; + }, + (id) => { + launched = id; + }, + new AbortController().signal, + ); + expect(result?.subshell.id).toBe("pane"); + expect(launched).toBe("pane"); + expect(bodies).toContainEqual({ reply: { seq: 0, payload: "Dg==" } }); + expect(JSON.stringify(bodies)).not.toContain("native-handle"); + expect(calls).toContain("desktop_ssh_key_close"); + expect(calls.at(-1)).toContain("/local-key/request"); +}); +it("cancelling the file picker never launches and deletes the preparation", async () => { + const calls: string[] = []; + nativeWindow.__TAURI__ = { + core: { invoke: async (command) => (command === "desktop_ssh_key_available" ? true : null) }, + }; + globalThis.fetch = (async (url) => { + calls.push(String(url)); + return json(prepared); + }) as typeof fetch; + const result = await launchWithLocalKey( + { node: "n", destination: "alice@host" }, + async () => { + throw new Error("must not launch"); + }, + () => {}, + new AbortController().signal, + ); + expect(result).toBeNull(); + expect(calls).toHaveLength(2); + expect(calls.at(-1)).toContain("/local-key/request"); +}); +it("releases the native key when launch fails", async () => { + const calls: string[] = []; + nativeWindow.__TAURI__ = { + core: { + invoke: async (command) => { + calls.push(command); + return command === "desktop_ssh_key_available" ? true : command === "desktop_ssh_key_pick" ? selected : null; + }, + }, + }; + globalThis.fetch = (async (_input: RequestInfo | URL, _init?: RequestInit) => json(prepared)) as typeof fetch; + await expect( + launchWithLocalKey( + { node: "n", destination: "alice@host" }, + async () => { + throw new Error("launch refused"); + }, + () => {}, + new AbortController().signal, + ), + ).rejects.toThrow("launch refused"); + expect(calls).toContain("desktop_ssh_key_close"); +}); + +it("cancels native unlock while selection is pending without waiting for a handle", async () => { + const picking = Promise.withResolvers(); + const selection = Promise.withResolvers(); + const closes: unknown[] = []; + const abort = new AbortController(); + nativeWindow.__TAURI__ = { + core: { + invoke: async (command, args) => { + if (command === "desktop_ssh_key_available") return true; + if (command === "desktop_ssh_key_pick") { + picking.resolve(); + return selection.promise; + } + if (command === "desktop_ssh_key_close") closes.push(args); + return null; + }, + }, + }; + globalThis.fetch = (async (_input: RequestInfo | URL, _init?: RequestInit) => json(prepared)) as typeof fetch; + let launches = 0; + const result = launchWithLocalKey( + { node: "n", destination: "alice@host" }, + async () => { + launches++; + return { subshell: { id: "unexpected" } }; + }, + () => {}, + abort.signal, + ); + await picking.promise; + abort.abort(); + expect(closes).toEqual([{}]); + selection.resolve(null); + await expect(result).rejects.toThrow("cancelled"); + expect(launches).toBe(0); +}); + +it("declining browser destination confirmation never reads the key or calls native IPC", async () => { + const calls: string[] = []; + nativeWindow.__TAURI__ = { + core: { + invoke: async () => { + throw new Error("Unexpected native IPC"); + }, + }, + }; + globalThis.fetch = (async (url) => { + calls.push(String(url)); + return json(prepared); + }) as typeof fetch; + const result = await launchWithLocalKey( + { node: "n", destination: "alice@host" }, + async () => { + throw new Error("Must not launch"); + }, + () => {}, + new AbortController().signal, + { + confirm: async (target) => { + expect(target).toEqual(prepared); + return false; + }, + take: () => { + throw new Error("Must not read the key"); + }, + }, + ); + expect(result).toBeNull(); + expect(calls).toEqual(["/api/ssh/local-key", "/api/ssh/local-key/request"]); +}); + +it("cancellation while browser confirmation is pending prevents reading the key", async () => { + const asked = Promise.withResolvers(); + const answer = Promise.withResolvers(); + const abort = new AbortController(); + nativeWindow.__TAURI__ = undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, _init?: RequestInit) => json(prepared)) as typeof fetch; + const operation = launchWithLocalKey( + { node: "n", destination: "alice@host" }, + async () => { + throw new Error("Must not launch"); + }, + () => {}, + abort.signal, + { + confirm: async () => { + asked.resolve(); + return answer.promise; + }, + take: () => { + throw new Error("Must not read the key"); + }, + }, + ); + await asked.promise; + abort.abort(); + answer.resolve(true); + await expect(operation).rejects.toThrow("cancelled"); +}); + +for (const outcome of ["unsigned", "signed", "cleanup-failed"] as const) { + it(`handles failed initial authentication safely when ${outcome}`, async () => { + nativeWindow.__TAURI__ = { + core: { + invoke: async (command) => { + if (command === "desktop_ssh_key_available") return true; + if (command === "desktop_ssh_key_pick") return selected; + if (command === "desktop_ssh_key_request") return { response: "Dg==", done: true }; + return null; + }, + }, + }; + const deletions: string[] = []; + let exchanges = 0; + let discarded = false; + globalThis.fetch = (async (url, init) => { + const path = String(url); + if (path === "/api/ssh/local-key") return json(prepared); + if (path.endsWith("/exchange")) { + if (outcome === "signed") { + if (++exchanges === 1) return json({ done: false, request: { seq: 0, payload: "DQ==" } }); + throw new Error("Connection interrupted"); + } + return json({ done: true, request: null }); + } + if (path === "/api/subshells/pane" && init?.method === "DELETE") { + deletions.push(path); + if (outcome === "cleanup-failed") return new Response("Unavailable", { status: 503 }); + } + return json({ ok: true }); + }) as typeof fetch; + await expect( + launchWithLocalKey( + { node: "n", destination: "alice@host" }, + async () => ({ subshell: { id: "pane" } }), + () => {}, + new AbortController().signal, + undefined, + () => { + discarded = true; + }, + ), + ).rejects.toThrow( + outcome === "signed" + ? "Connection interrupted" + : outcome === "cleanup-failed" + ? "couldn't be removed" + : "ended before requesting a signature", + ); + expect(deletions).toEqual(outcome === "signed" ? [] : ["/api/subshells/pane"]); + expect(discarded).toBe(outcome === "unsigned"); + }); +} + +it("keeps the pane after a signature even when a later answer fails and the next post breaks", async () => { + // The desktop bridge answers a CLOSED signer's request with a normal + // {done:true, response:"BQ=="} reply: the loop must not let that later + // negative answer un-record the signature already delivered, or the catch + // block would delete a pane that may have authenticated. + let invoke = 0; + nativeWindow.__TAURI__ = { + core: { + invoke: async (command) => { + if (command === "desktop_ssh_key_available") return true; + if (command === "desktop_ssh_key_pick") return selected; + if (command === "desktop_ssh_key_request") return { response: ++invoke === 1 ? "Dg==" : "BQ==", done: true }; + return null; + }, + }, + }; + const deletions: string[] = []; + let exchanges = 0; + globalThis.fetch = (async (url, init) => { + const path = String(url); + if (path === "/api/ssh/local-key") return json(prepared); + if (path.endsWith("/exchange")) { + if (++exchanges === 1) return json({ done: false, request: { seq: 0, payload: "DQ==" } }); + if (exchanges === 2) return json({ done: false, request: { seq: 1, payload: "DQ==" } }); + throw new Error("Connection interrupted"); + } + if (path === "/api/subshells/pane" && init?.method === "DELETE") deletions.push(path); + return json({ ok: true }); + }) as typeof fetch; + await expect( + launchWithLocalKey( + { node: "n", destination: "alice@host" }, + async () => ({ subshell: { id: "pane" } }), + () => {}, + new AbortController().signal, + undefined, + () => {}, + ), + ).rejects.toThrow("Connection interrupted"); + expect(deletions).toEqual([]); +}); diff --git a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts index 6f8c75f5f..a6baaaaae 100644 --- a/apps/server/web/src/lib/__tests__/subshell-compat.test.ts +++ b/apps/server/web/src/lib/__tests__/subshell-compat.test.ts @@ -51,6 +51,7 @@ function plugin(p: { broken?: string; type?: "agent-harness" | "terminal" | "network"; icon?: string; + pickable?: boolean; }): LaunchAgent { const row: InstancePluginRow = { id: p.id, @@ -61,6 +62,7 @@ function plugin(p: { builtIn: true, ...(p.broken !== undefined ? { broken: p.broken } : {}), ...(p.type !== undefined ? { type: p.type } : {}), + ...(p.pickable === false ? { pickable: false as const } : {}), ...(p.icon !== undefined ? { icon: p.icon } : {}), }; return row; @@ -223,24 +225,41 @@ describe("defaultAgentId", () => { const plugins = [plugin({ id: "claude-code", name: "Claude Code", installed: false }), TERM]; expect(defaultAgentId(buildAgentOptions(plugins, null), plugins, null)).toBe("terminal"); }); - it("a second terminal-type plugin does not steal the shell default", () => { - // The clean-machine regression (CI e2e 2026-10-08): the ssh plugin is - // type `terminal`, its binary exists on nearly every host, and the - // catalog arrives id-sorted, so `ssh` stands BEFORE `terminal` and the - // old last-resort `usable[0]` defaulted a first launch to a bare `ssh` - // that exits without a destination. The plain shell stays the fallback. - const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal" }); - const plugins = [SSH, TERM]; - expect(defaultAgentId(buildAgentOptions(plugins, null), plugins, null)).toBe("terminal"); - // And it stays so under an agent catalog too: the agent tier still wins. - const withAgent = [SSH, TERM, CLAUDE]; - expect(defaultAgentId(buildAgentOptions(withAgent, null), withAgent, null)).toBe("claude-code"); - }); - it("an ssh-only catalog still defaults to ssh", () => { - // The shell tier is a preference, not a filter: when the plain shell is - // not usable, the only pickable plugin must still fill the field. - const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal" }); - expect(defaultAgentId(buildAgentOptions([SSH], null), [SSH], null)).toBe("ssh"); + it("never offers the ssh plugin as a pickable agent", () => { + // Operator ruling 2026-10-15: a bare ssh launch has no destination and + // dies at ssh's usage prompt, so the harness picker drops the plugin the + // way it drops networks. Every real SSH flow is the connect form on the + // SSH terminal tab. The 2026-10-08 clean-machine regression (a defaulted + // first launch landing on `ssh`) is now prevented by the drop itself. + // The fixture mirrors the shipping manifest: the row carries the + // plugin's own `pickable: false`, and the id is no part of the rule. + const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal", pickable: false }); + const plugins = [SSH, TERM, CLAUDE]; + expect( + buildAgentOptions(plugins, null) + .map((o) => o.value) + .sort(), + ).toEqual(["claude-code", "terminal"]); + expect(defaultAgentId(buildAgentOptions(plugins, null), plugins, null)).toBe("claude-code"); + // A recent SSH pane (launched through the tab, harness `ssh`) must not + // reintroduce it as the default either. + expect(defaultAgentId(buildAgentOptions([SSH, TERM], null), [SSH, TERM], "ssh")).toBe("terminal"); + }); + it("an ssh-only catalog offers nothing", () => { + // Not even as the last resort: an option that cannot do anything is not + // made pickable by being alone. + const SSH = plugin({ id: "ssh", name: "SSH", type: "terminal", pickable: false }); + expect(buildAgentOptions([SSH], null)).toEqual([]); + expect(defaultAgentId(buildAgentOptions([SSH], null), [SSH], null)).toBeNull(); + }); + it("the refusal is the manifest flag, never the id", () => { + // A plugin with any other id that says `pickable: false` is dropped the + // same way, and `ssh` WITHOUT the flag is offered: the exclusion lives + // in the declaration, so no surface accumulates an id list. + const MYSTERY = plugin({ id: "tardis", name: "TARDIS", type: "terminal", pickable: false }); + expect(buildAgentOptions([MYSTERY, TERM], null).map((o) => o.value)).toEqual(["terminal"]); + const SSH_PLAIN = plugin({ id: "ssh", name: "SSH", type: "terminal" }); + expect(buildAgentOptions([SSH_PLAIN], null).map((o) => o.value)).toEqual(["ssh"]); }); it("null when nothing is usable — and on an empty catalog", () => { const plugins = [plugin({ id: "x", name: "X", installed: false })]; diff --git a/apps/server/web/src/lib/desktop.ts b/apps/server/web/src/lib/desktop.ts index de27fb127..da1265d6f 100644 --- a/apps/server/web/src/lib/desktop.ts +++ b/apps/server/web/src/lib/desktop.ts @@ -30,9 +30,9 @@ export type DesktopPlatform = "macos" | "linux"; * it drops its title bar for the SPA's overlay chrome, and it holds the * commands behind the update, reset, supervision and notification surfaces. * - `client` — `apps/client/desktop` (Subshell Client). It is a window onto a - * plane that can live anywhere. It keeps a normal title bar and is granted - * exactly ONE command, so every server-only surface above must stay absent - * there. + * plane that can live anywhere. It keeps a normal title bar and grants only + * the path-only browser command and temporary native SSH signer commands. + * Every server management surface above must stay absent there. */ export type DesktopApp = "server" | "client"; diff --git a/apps/server/web/src/lib/ssh-browser-key.ts b/apps/server/web/src/lib/ssh-browser-key.ts new file mode 100644 index 000000000..bef832f1a --- /dev/null +++ b/apps/server/web/src/lib/ssh-browser-key.ts @@ -0,0 +1,125 @@ +import { SSH_LOCAL_KEY_TTL_MS } from "@internal/subshell-protocol"; + +export interface BrowserKeyMaterial { + file: File; + passphrase: Uint8Array; +} +export interface BrowserKeyScope { + user: string; + hostKey: string; +} +export interface LocalKeySigner { + expiresAt: number; + request(payload: string): Promise<{ response: string; done: boolean }>; + close(): void; +} + +export function browserSigningUnavailable(): string | null { + if (!globalThis.isSecureContext || !globalThis.crypto?.subtle) + return "Open Subshell over HTTPS to use a key in your browser. Localhost also works."; + if (typeof Worker === "undefined") return "This browser can't run the temporary SSH signer. Try an updated browser."; + return null; +} + +/** The page passes a File handle; only the disposable worker reads its contents. */ +export async function openBrowserKey( + material: BrowserKeyMaterial, + scope: BrowserKeyScope, + signal: AbortSignal, +): Promise { + let worker: Worker | undefined; + let timer: ReturnType | undefined; + let pending: { id: number; resolve: (value: unknown) => void; reject: (error: Error) => void } | undefined; + let sequence = 0; + let ended = false; + const expiresAt = Date.now() + SSH_LOCAL_KEY_TTL_MS; + const close = () => { + if (ended) return; + ended = true; + clearTimeout(timer); + signal.removeEventListener("abort", close); + window.removeEventListener("pagehide", close); + worker?.terminate(); + pending?.reject(new Error("Temporary SSH key access ended. Choose the key again.")); + pending = undefined; + }; + const call = (body: Record, transfer: Transferable[] = []): Promise => { + if (ended || pending || Date.now() >= expiresAt) { + close(); + return Promise.reject(new Error("Temporary SSH key access ended. Choose the key again.")); + } + return new Promise((resolve, reject) => { + const id = ++sequence; + pending = { id, resolve, reject }; + try { + worker?.postMessage({ ...body, id }, transfer); + } catch { + close(); + } + }); + }; + try { + const unavailable = browserSigningUnavailable(); + if (unavailable) throw new Error(unavailable); + if (signal.aborted) throw new Error("SSH key selection was cancelled."); + worker = new Worker(new URL("./ssh-browser-key.worker.ts", import.meta.url), { type: "module" }); + worker.onmessage = ({ data }: MessageEvent) => { + if (Date.now() >= expiresAt) { + close(); + return; + } + if (!pending || !data || typeof data !== "object" || !("id" in data) || data.id !== pending.id) { + close(); + return; + } + const current = pending; + pending = undefined; + if ("error" in data) { + current.reject(new Error(typeof data.error === "string" ? data.error : "Couldn't use that SSH key.")); + close(); + } else if ("result" in data) current.resolve(data.result); + else { + current.reject(new Error("The temporary SSH signer returned an invalid reply.")); + close(); + } + }; + worker.onerror = (event) => { + event.preventDefault(); + close(); + }; + worker.onmessageerror = close; + signal.addEventListener("abort", close, { once: true }); + window.addEventListener("pagehide", close, { once: true }); + timer = setTimeout(close, SSH_LOCAL_KEY_TTL_MS); + await call({ type: "open", file: material.file, passphrase: material.passphrase, ...scope, expiresAt }, [ + material.passphrase.buffer as ArrayBuffer, + ]); + if (ended || signal.aborted) throw new Error("SSH key selection was cancelled."); + return { + expiresAt, + close, + async request(payload) { + const reply = await call({ type: "request", payload }); + if ( + !reply || + typeof reply !== "object" || + !("response" in reply) || + typeof reply.response !== "string" || + !("done" in reply) || + typeof reply.done !== "boolean" + ) { + close(); + throw new Error("The temporary SSH signer returned an invalid reply."); + } + if (reply.done) close(); + return { response: reply.response, done: reply.done }; + }, + }; + } catch (error) { + close(); + throw error; + } finally { + // Successful transfer detaches this buffer. Failed construction must wipe it too. + if (material.passphrase.byteLength) material.passphrase.fill(0); + } +} diff --git a/apps/server/web/src/lib/ssh-browser-key.worker.ts b/apps/server/web/src/lib/ssh-browser-key.worker.ts new file mode 100644 index 000000000..e82bd522e --- /dev/null +++ b/apps/server/web/src/lib/ssh-browser-key.worker.ts @@ -0,0 +1,65 @@ +import { + SSH_LOCAL_KEY_MAX_BYTES, + SSH_LOCAL_KEY_MAX_PASSPHRASE_BYTES, + SSH_LOCAL_KEY_TTL_MS, +} from "@internal/subshell-protocol"; +import { createBrowserSshSigner } from "./ssh-browser-signer-core"; + +let signer: Awaited> | undefined; +let opened = false; +let busy = false; +let stopped = false; +let timer: ReturnType | undefined; +const stop = () => { + stopped = true; + signer?.close(); + signer = undefined; + clearTimeout(timer); + self.close(); +}; + +// Messages originate in our wrapper. Keys/passphrases are never returned, even on failure. +self.onmessage = async ({ data }: MessageEvent) => { + const id = data?.id; + if (stopped || busy || !Number.isSafeInteger(id) || id < 1) { + stop(); + return; + } + busy = true; + let bytes: Uint8Array | undefined; + let passphrase: Uint8Array | undefined; + try { + if (data.type === "open" && !opened) { + opened = true; + const expiresAt = Math.min(data.expiresAt, Date.now() + SSH_LOCAL_KEY_TTL_MS); + passphrase = data.passphrase; + if ( + !(data.file instanceof Blob) || + data.file.size === 0 || + data.file.size > SSH_LOCAL_KEY_MAX_BYTES || + !(passphrase instanceof Uint8Array) || + passphrase.length > SSH_LOCAL_KEY_MAX_PASSPHRASE_BYTES || + !Number.isFinite(expiresAt) || + expiresAt <= Date.now() + ) + throw new Error("Choose a private key no larger than 64 KiB and a passphrase no longer than 4 KiB."); + timer = setTimeout(stop, expiresAt - Date.now()); + bytes = new Uint8Array(await data.file.arrayBuffer()); + signer = await createBrowserSshSigner({ bytes, passphrase, user: data.user, hostKey: data.hostKey, expiresAt }); + if (stopped || Date.now() >= expiresAt) throw new Error("The temporary SSH key expired. Choose it again."); + self.postMessage({ id, result: { publicKey: signer.publicKey } }); + } else if (data.type === "request" && signer && typeof data.payload === "string") { + const result = await signer.request(data.payload); + if (stopped) return; + self.postMessage({ id, result }); + if (result.done) stop(); + } else throw new Error("The temporary SSH key request is no longer available."); + } catch (error) { + self.postMessage({ id, error: error instanceof Error ? error.message : "Couldn't use that SSH key." }); + stop(); + } finally { + bytes?.fill(0); + passphrase?.fill(0); + busy = false; + } +}; diff --git a/apps/server/web/src/lib/ssh-browser-signer-core.ts b/apps/server/web/src/lib/ssh-browser-signer-core.ts new file mode 100644 index 000000000..70c3a266d --- /dev/null +++ b/apps/server/web/src/lib/ssh-browser-signer-core.ts @@ -0,0 +1,221 @@ +import { SSH_AGENT_FAILURE_BASE64, SSH_LOCAL_KEY_MAX_BYTES } from "@internal/subshell-protocol"; +import { readEnvelope, unlockEnvelope } from "./ssh-browser-signer/envelope"; +import { importPrivate, importPublic, type PrivateIdentity, type PublicIdentity } from "./ssh-browser-signer/keys"; +import { base64, concat, decode64, equal, field, Reader, SignerError, u32 } from "./ssh-browser-signer/wire"; + +/** The destination approved locally before any private key is imported. */ +export interface BrowserSignerScope { + /** The exact SSH username authorized for this login. */ + user: string; + /** The approved host's canonical base64 SSH public key wire blob. */ + hostKey: string; + /** The absolute expiration time in milliseconds, at most 90 seconds away. */ + expiresAt: number; +} + +/** The agent's SSH_AGENT_FAILURE reply (byte 5, base64) — the bridge's only negative answer. */ +const FAILURE = SSH_AGENT_FAILURE_BASE64; +const EXPIRED = "The local SSH key request expired. Start the connection again."; +const KEY_ERROR = "Couldn't read that SSH key. Choose a supported OpenSSH private key and check its passphrase."; + +function safeError(error: unknown): Error { + return new Error(error instanceof SignerError ? error.message : KEY_ERROR); +} + +/** + * Parse-only probe of the bounded OpenSSH envelope: it reports the encrypted + * flag and nothing else. The form draws the passphrase field unconditionally + * ("if encrypted"), so this is exercised by the signer tests as a parse- + * strictness contract, not consumed by the UI - the file bytes never need to + * reach the main thread to answer "ask or not ask". + */ +export function inspectBrowserSshKey(bytes: Uint8Array): { encrypted: boolean } { + try { + const envelope = readEnvelope(bytes); + try { + return { encrypted: envelope.encrypted }; + } finally { + envelope.buffer.fill(0); + } + } catch (error) { + throw safeError(error); + } +} + +/** Validate the host's full public wire blob and return OpenSSH's SHA-256 fingerprint. */ +export async function browserHostFingerprint(hostKey: string): Promise { + try { + const bytes = decode64(hostKey); + await importPublic(bytes); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return `SHA256:${base64(new Uint8Array(digest)).replace(/=+$/, "")}`; + } catch { + throw new Error("The SSH host key isn't supported or isn't valid."); + } +} + +/** + * Import a key inside a dedicated worker and authorize one verified, host-bound + * SSH login. The caller owns worker termination; this core never performs I/O. + * Mutable input arrays are scrubbed after import, including failed imports. + */ +export async function createBrowserSshSigner( + args: BrowserSignerScope & { bytes: Uint8Array; passphrase?: Uint8Array }, +): Promise<{ + publicKey: string; + request(payload: string): Promise<{ response: string; done: boolean }>; + close(): void; +}> { + let privateKey: PrivateIdentity | undefined; + let host: PublicIdentity | undefined; + let binding: Uint8Array | undefined; + let state: "open" | "binding" | "signing" | "closed" = "open"; + const remaining = args.expiresAt - Date.now(); + const monotonicDeadline = performance.now() + Math.min(remaining, 90_000); + const expired = () => Date.now() >= args.expiresAt || performance.now() >= monotonicDeadline; + const close = () => { + state = "closed"; + privateKey = undefined; + host = undefined; + binding?.fill(0); + binding = undefined; + }; + const isClosed = () => state === "closed"; + try { + if (!Number.isFinite(args.expiresAt) || remaining <= 0 || remaining > 90_000) throw new SignerError(EXPIRED); + if (!args.bytes.length || args.bytes.length > SSH_LOCAL_KEY_MAX_BYTES) { + throw new SignerError("Choose an SSH private key file no larger than 64 KiB."); + } + if (args.user.length > 256) throw new SignerError("Choose a valid SSH username before selecting a key."); + const user = new TextEncoder().encode(args.user); + if ( + !user.length || + user.length > 256 || + user.some((byte) => byte < 32 || byte === 127) || + new TextDecoder().decode(user) !== args.user + ) { + throw new SignerError("Choose a valid SSH username before selecting a key."); + } + if (args.passphrase && args.passphrase.length > SSH_LOCAL_KEY_MAX_BYTES) + throw new SignerError("That SSH key passphrase is too large."); + const hostBytes = decode64(args.hostKey); + host = await importPublic(hostBytes); + if (expired()) throw new SignerError(EXPIRED); + const envelope = readEnvelope(args.bytes); + try { + const plaintext = await unlockEnvelope(envelope, args.passphrase); + try { + if (expired()) throw new SignerError(EXPIRED); + privateKey = await importPrivate(plaintext, concat(envelope.publicKey), envelope.encrypted ? 16 : 8); + } finally { + plaintext.fill(0); + } + } finally { + envelope.buffer.fill(0); + } + if (expired()) throw new SignerError(EXPIRED); + const publicBytes = privateKey.publicKey; + const publicKey = base64(publicBytes); + + const request = async (payload: string): Promise<{ response: string; done: boolean }> => { + if (expired()) close(); + if (state !== "open") return { response: FAILURE, done: state !== "binding" }; + let packet: Uint8Array | undefined; + let response = FAILURE; + try { + packet = decode64(payload); + const reader = new Reader(packet); + const command = reader.byte(); + if (command === 11) { + reader.end(); + response = base64( + concat(new Uint8Array([12]), u32(1), field(publicBytes), field("Subshell one-time SSH key")), + ); + } else if (command === 27) { + const extension = reader.text(); + const approvedHost = reader.string(); + const session = reader.string(); + const signature = reader.string(); + const forwarding = reader.byte(); + reader.end(); + if ( + extension !== "session-bind@openssh.com" || + binding || + !host || + !equal(approvedHost, hostBytes) || + session.length < 16 || + session.length > 128 || + forwarding !== 0 + ) + throw new SignerError("The SSH session binding wasn't approved."); + // Reserve the binding before awaiting verification so concurrent + // requests can't replace it or sign against an unverified session. + state = "binding"; + const verified = await host.verify(session, signature); + if (state !== "binding" || expired()) { + close(); + } else { + state = "open"; + if (verified) { + binding = concat(session); + response = "Bg=="; + } + } + } else if (command === 13) { + const identity = reader.string(); + const data = reader.string(); + const flags = reader.u32(); + reader.end(); + if (!privateKey || !binding || !equal(identity, publicBytes)) + throw new SignerError("The SSH signature wasn't approved."); + const auth = new Reader(data); + if ( + !equal(auth.string(), binding) || + auth.byte() !== 50 || + !equal(auth.string(), user) || + auth.text() !== "ssh-connection" || + auth.text() !== "publickey-hostbound-v00@openssh.com" || + auth.byte() !== 1 + ) + throw new SignerError("The SSH login doesn't match the approved destination."); + const algorithm = auth.text(); + if ( + !equal(auth.string(), publicBytes) || + !equal(auth.string(), hostBytes) || + !privateKey.accepts(algorithm, flags) + ) + throw new SignerError("The SSH signature wasn't approved."); + auth.end(); + // Consume before the first signing await. Every simultaneous or later + // request fails, including a second request during slow browser crypto. + const key = privateKey; + state = "signing"; + privateKey = undefined; + host = undefined; + binding.fill(0); + binding = undefined; + const signature = await key.sign(data, algorithm); + if (state === "signing" && !expired()) response = base64(concat(new Uint8Array([14]), field(signature))); + close(); + } + } catch { + if (state === "signing") close(); + if (state === "binding") state = "open"; + } finally { + packet?.fill(0); + if (expired()) { + close(); + response = FAILURE; + } + } + return { response, done: isClosed() }; + }; + return { publicKey, request, close }; + } catch (error) { + close(); + throw safeError(error); + } finally { + args.bytes.fill(0); + args.passphrase?.fill(0); + } +} diff --git a/apps/server/web/src/lib/ssh-browser-signer/bcrypt-pbkdf.d.ts b/apps/server/web/src/lib/ssh-browser-signer/bcrypt-pbkdf.d.ts new file mode 100644 index 000000000..9166a87ea --- /dev/null +++ b/apps/server/web/src/lib/ssh-browser-signer/bcrypt-pbkdf.d.ts @@ -0,0 +1,12 @@ +declare module "bcrypt-pbkdf" { + /** Derive OpenSSH's AES key and counter; returns zero on success. */ + export function pbkdf( + pass: Uint8Array, + passLength: number, + salt: Uint8Array, + saltLength: number, + output: Uint8Array, + outputLength: number, + rounds: number, + ): number; +} diff --git a/apps/server/web/src/lib/ssh-browser-signer/envelope.ts b/apps/server/web/src/lib/ssh-browser-signer/envelope.ts new file mode 100644 index 000000000..e635ee0b4 --- /dev/null +++ b/apps/server/web/src/lib/ssh-browser-signer/envelope.ts @@ -0,0 +1,116 @@ +import { SSH_LOCAL_KEY_MAX_BYTES } from "@internal/subshell-protocol"; +import { pbkdf } from "bcrypt-pbkdf"; +import { concat, decode64, equal, Reader, SignerError } from "./wire"; + +const MAGIC = new TextEncoder().encode("openssh-key-v1\0"); +const BAD_KEY = "That file isn't a supported OpenSSH private key."; + +/** Parsed slices share the decoded buffer, which the caller must scrub. */ +export interface Envelope { + /** The decoded file, including any private bytes. */ + buffer: Uint8Array; + /** Whether the private block needs a passphrase. */ + encrypted: boolean; + /** The public identity advertised outside the private block. */ + publicKey: Uint8Array; + /** The encrypted or plaintext private block. */ + privateBlock: Uint8Array; + /** AES key length in bytes, or zero for an unencrypted block. */ + keyLength: number; + /** Bcrypt salt, or an empty slice for an unencrypted block. */ + salt: Uint8Array; + /** Bcrypt work factor, bounded before any password work. */ + rounds: number; +} + +/** Parse the OpenSSH envelope without decrypting its private block. */ +export function readEnvelope(bytes: Uint8Array): Envelope { + if (!bytes.length || bytes.length > SSH_LOCAL_KEY_MAX_BYTES) { + throw new SignerError("Choose an SSH private key file no larger than 64 KiB."); + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + if (/-----BEGIN (?:RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----/.test(text)) { + throw new SignerError("Choose a key in OpenSSH format. Convert a copy with ssh-keygen -p -o first."); + } + const match = + /^-----BEGIN OPENSSH PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END OPENSSH PRIVATE KEY-----(?:\r?\n)?$/.exec( + text, + ); + if (!match) throw new SignerError(BAD_KEY); + const buffer = decode64(match[1].replace(/[\r\n]/g, "")); + try { + if (!equal(buffer.subarray(0, MAGIC.length), MAGIC)) throw new SignerError(BAD_KEY); + const reader = new Reader(buffer.subarray(MAGIC.length)); + const cipher = reader.text(); + const kdf = reader.text(); + const options = reader.string(); + if (reader.u32() !== 1) throw new SignerError("Choose an OpenSSH file containing exactly one private key."); + const publicKey = reader.string(); + const privateBlock = reader.string(); + reader.end(); + if (cipher === "none") { + if (kdf !== "none" || options.length || !privateBlock.length || privateBlock.length % 8) { + throw new SignerError(BAD_KEY); + } + return { buffer, encrypted: false, publicKey, privateBlock, keyLength: 0, salt: options, rounds: 0 }; + } + const keyLength = new Map([ + ["aes128-ctr", 16], + ["aes192-ctr", 24], + ["aes256-ctr", 32], + ]).get(cipher); + if (!keyLength || kdf !== "bcrypt") { + throw new SignerError("This key's encryption isn't supported. Choose an OpenSSH key using bcrypt and AES-CTR."); + } + const parameters = new Reader(options); + const salt = parameters.string(); + const rounds = parameters.u32(); + parameters.end(); + if (!salt.length || salt.length > 64 || rounds < 1 || rounds > 1024) { + throw new SignerError( + "This key's bcrypt settings aren't supported. Choose a key with 1 to 1,024 rounds and a salt no larger than 64 bytes.", + ); + } + if (!privateBlock.length || privateBlock.length % 16) throw new SignerError(BAD_KEY); + return { buffer, encrypted: true, publicKey, privateBlock, keyLength, salt, rounds }; + } catch (error) { + buffer.fill(0); + throw error; + } +} + +/** Decrypt into an owned allocation; AES itself stays in Web Crypto. */ +export async function unlockEnvelope(envelope: Envelope, passphrase?: Uint8Array): Promise> { + if (!envelope.encrypted) return concat(envelope.privateBlock); + if (!passphrase?.length || passphrase.length > SSH_LOCAL_KEY_MAX_BYTES) { + throw new SignerError("Enter this SSH key's passphrase."); + } + const material = new Uint8Array(envelope.keyLength + 16); + try { + if ( + pbkdf( + passphrase, + passphrase.length, + envelope.salt, + envelope.salt.length, + material, + material.length, + envelope.rounds, + ) !== 0 + ) { + throw new SignerError("Couldn't unlock that SSH key. Check its passphrase and choose the key again."); + } + const aes = await crypto.subtle.importKey("raw", material.subarray(0, envelope.keyLength), "AES-CTR", false, [ + "decrypt", + ]); + return new Uint8Array( + await crypto.subtle.decrypt( + { name: "AES-CTR", counter: material.subarray(envelope.keyLength), length: 128 }, + aes, + concat(envelope.privateBlock), + ), + ); + } finally { + material.fill(0); + } +} diff --git a/apps/server/web/src/lib/ssh-browser-signer/keys.ts b/apps/server/web/src/lib/ssh-browser-signer/keys.ts new file mode 100644 index 000000000..ac5736283 --- /dev/null +++ b/apps/server/web/src/lib/ssh-browser-signer/keys.ts @@ -0,0 +1,280 @@ +import { base64url, concat, equal, field, mpint, padded, positive, Reader, SignerError } from "./wire"; + +const CURVES = { + nistp256: { name: "P-256", size: 32, hash: "SHA-256" }, + nistp384: { name: "P-384", size: 48, hash: "SHA-384" }, + nistp521: { name: "P-521", size: 66, hash: "SHA-512" }, +} as const; + +type Curve = (typeof CURVES)[keyof typeof CURVES]; +type PublicParts = + | { kind: "ed25519"; algorithm: string; raw: Uint8Array } + | { kind: "rsa"; algorithm: string; n: Uint8Array; e: Uint8Array } + | { kind: "ecdsa"; algorithm: string; raw: Uint8Array; curve: Curve }; + +/** Public identity and verifier for the approved host or selected private key. */ +export interface PublicIdentity { + /** Canonical SSH public key wire bytes. */ + blob: Uint8Array; + /** Verify a complete, bounded SSH signature frame. */ + verify(data: Uint8Array, signature: Uint8Array): Promise; +} + +/** Imported, nonextractable private key and its narrowly selected algorithms. */ +export interface PrivateIdentity { + /** The public identity advertised by the private file. */ + publicKey: Uint8Array; + /** Whether the requested SSH algorithm and agent flags match this key. */ + accepts(algorithm: string, flags: number): boolean; + /** Sign authorized host-bound data, returning a complete SSH signature. */ + sign(data: Uint8Array, algorithm: string): Promise; +} + +function readPublic(blob: Uint8Array): PublicParts { + const reader = new Reader(blob); + const algorithm = reader.text(); + let parts: PublicParts; + if (algorithm === "ssh-ed25519") { + const raw = reader.string(); + if (raw.length !== 32) throw new SignerError("The Ed25519 public key isn't valid."); + parts = { kind: "ed25519", algorithm, raw }; + } else if (algorithm === "ssh-rsa") { + const e = positive(reader, 8); + const n = positive(reader); + const bits = n.length * 8 - Math.clz32(n[0]) + 24; + if (bits < 2048 || bits > 8192 || integer(e) < 3n || (e[e.length - 1] & 1) === 0 || (n[n.length - 1] & 1) === 0) { + throw new SignerError("Choose an RSA SSH key with 2,048 to 8,192 bits and a valid public exponent."); + } + parts = { kind: "rsa", algorithm, n, e }; + } else if (algorithm.startsWith("ecdsa-sha2-")) { + const name = reader.text(); + const curve = CURVES[name as keyof typeof CURVES]; + const raw = reader.string(); + if (!curve || algorithm !== `ecdsa-sha2-${name}` || raw.length !== curve.size * 2 + 1 || raw[0] !== 4) { + throw new SignerError("Choose an ECDSA SSH key using P-256, P-384, or P-521."); + } + parts = { kind: "ecdsa", algorithm, raw, curve }; + } else { + throw new SignerError( + "Choose an Ed25519, ECDSA, or RSA SSH key. Hardware-backed and DSA keys aren't supported here.", + ); + } + reader.end(); + return parts; +} + +function rsaJwk(parts: Extract): JsonWebKey { + return { kty: "RSA", n: base64url(parts.n), e: base64url(parts.e), ext: false }; +} + +function ecJwk(parts: Extract): JsonWebKey { + return { + kty: "EC", + crv: parts.curve.name, + x: base64url(parts.raw.subarray(1, 1 + parts.curve.size)), + y: base64url(parts.raw.subarray(1 + parts.curve.size)), + ext: false, + }; +} + +function integer(bytes: Uint8Array): bigint { + let result = 0n; + for (const byte of bytes) result = (result << 8n) | BigInt(byte); + return result; +} + +function integerBytes(value: bigint): Uint8Array { + const bytes: number[] = []; + while (value > 0n) { + bytes.push(Number(value & 255n)); + value >>= 8n; + } + const result = Uint8Array.from(bytes.reverse()); + bytes.fill(0); + return result; +} + +function ecdsaRaw(signature: Uint8Array, size: number): Uint8Array { + const reader = new Reader(signature); + const r = positive(reader, size); + const s = positive(reader, size); + reader.end(); + return concat(padded(r, size), padded(s, size)); +} + +function ecdsaWire(signature: Uint8Array, size: number): Uint8Array { + if (signature.length !== size * 2) throw new SignerError("Your browser returned an unsupported ECDSA signature."); + return concat(mpint(signature.subarray(0, size)), mpint(signature.subarray(size))); +} + +/** Import and validate a canonical supported public key using Web Crypto. */ +export async function importPublic(blob: Uint8Array): Promise { + const parts = readPublic(blob); + const keys = new Map(); + if (parts.kind === "ed25519") { + keys.set(parts.algorithm, await crypto.subtle.importKey("raw", concat(parts.raw), "Ed25519", false, ["verify"])); + } else if (parts.kind === "ecdsa") { + keys.set( + parts.algorithm, + await crypto.subtle.importKey("jwk", ecJwk(parts), { name: "ECDSA", namedCurve: parts.curve.name }, false, [ + "verify", + ]), + ); + } else { + for (const [algorithm, hash] of [ + ["rsa-sha2-256", "SHA-256"], + ["rsa-sha2-512", "SHA-512"], + ]) { + keys.set( + algorithm, + await crypto.subtle.importKey("jwk", rsaJwk(parts), { name: "RSASSA-PKCS1-v1_5", hash }, false, ["verify"]), + ); + } + } + return { + blob, + async verify(data, signature) { + try { + const reader = new Reader(signature); + const algorithm = reader.text(); + const raw = reader.string(); + reader.end(); + const key = keys.get(algorithm); + if (!key) return false; + if (parts.kind === "ecdsa") { + return await crypto.subtle.verify( + { name: "ECDSA", hash: parts.curve.hash }, + key, + ecdsaRaw(raw, parts.curve.size), + concat(data), + ); + } + if (parts.kind === "ed25519" && raw.length !== 64) return false; + if (parts.kind === "rsa" && raw.length !== parts.n.length) return false; + return await crypto.subtle.verify(key.algorithm.name, key, concat(raw), concat(data)); + } catch { + return false; + } + }, + }; +} + +/** Validate OpenSSH's checks, components and padding before importing a key. */ +export async function importPrivate( + block: Uint8Array, + publicKey: Uint8Array, + blockSize: number, +): Promise { + const reader = new Reader(block); + const check = reader.u32(); + if (check !== reader.u32()) + throw new SignerError("Couldn't unlock that SSH key. Check its passphrase and try again."); + const algorithm = reader.text(); + const parts = readPublic(publicKey); + if (algorithm !== parts.algorithm) throw new SignerError("The SSH key's public and private identities don't match."); + let jwk: JsonWebKey | undefined; + let seed: Uint8Array | undefined; + const temporary: Uint8Array[] = []; + try { + if (parts.kind === "ed25519") { + const raw = reader.string(); + const secret = reader.string(); + if (secret.length !== 64 || !equal(raw, parts.raw) || !equal(secret.subarray(32), parts.raw)) { + throw new SignerError("The SSH key's public and private identities don't match."); + } + seed = concat(new Uint8Array([48, 46, 2, 1, 0, 48, 5, 6, 3, 43, 101, 112, 4, 34, 4, 32]), secret.subarray(0, 32)); + temporary.push(seed); + } else if (parts.kind === "ecdsa") { + const name = reader.text(); + const raw = reader.string(); + const d = padded(positive(reader, parts.curve.size), parts.curve.size); + temporary.push(d); + if (algorithm !== `ecdsa-sha2-${name}` || !equal(raw, parts.raw)) { + throw new SignerError("The SSH key's public and private identities don't match."); + } + jwk = { ...ecJwk(parts), d: base64url(d) }; + } else { + const n = positive(reader); + const e = positive(reader, 8); + const d = positive(reader); + const qi = positive(reader); + const p = positive(reader); + const q = positive(reader); + const pNumber = integer(p); + const qNumber = integer(q); + if ( + !equal(n, parts.n) || + !equal(e, parts.e) || + pNumber < 3n || + qNumber < 3n || + pNumber * qNumber !== integer(n) || + (qNumber * integer(qi)) % pNumber !== 1n + ) { + throw new SignerError("The SSH key's public and private identities don't match."); + } + const dp = integerBytes(integer(d) % (pNumber - 1n)); + const dq = integerBytes(integer(d) % (qNumber - 1n)); + temporary.push(dp, dq); + jwk = { + ...rsaJwk(parts), + d: base64url(d), + p: base64url(p), + q: base64url(q), + qi: base64url(qi), + dp: base64url(dp), + dq: base64url(dq), + }; + } + reader.string(); // Comments are display data, never part of the authorization. + if (reader.remaining >= blockSize) throw new SignerError("The SSH key's padding isn't valid."); + let padding = 1; + while (reader.remaining) { + if (reader.byte() !== padding++) throw new SignerError("The SSH key's padding isn't valid."); + } + const keys = new Map(); + if (parts.kind === "ed25519" && seed) { + keys.set(algorithm, await crypto.subtle.importKey("pkcs8", seed, "Ed25519", false, ["sign"])); + } else if (parts.kind === "ecdsa" && jwk) { + keys.set( + algorithm, + await crypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: parts.curve.name }, false, ["sign"]), + ); + } else if (parts.kind === "rsa" && jwk) { + for (const [name, hash] of [ + ["rsa-sha2-256", "SHA-256"], + ["rsa-sha2-512", "SHA-512"], + ]) { + keys.set(name, await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash }, false, ["sign"])); + } + } + const identity: PrivateIdentity = { + publicKey, + accepts(name, flags) { + return parts.kind === "rsa" + ? (flags === 2 && name === "rsa-sha2-256") || (flags === 4 && name === "rsa-sha2-512") + : flags === 0 && name === algorithm; + }, + async sign(data, name) { + const key = keys.get(name); + if (!key) throw new SignerError("The SSH signature algorithm isn't supported."); + const parameters = parts.kind === "ecdsa" ? { name: "ECDSA", hash: parts.curve.hash } : key.algorithm.name; + const raw = new Uint8Array(await crypto.subtle.sign(parameters, key, concat(data))); + return concat(field(name), field(parts.kind === "ecdsa" ? ecdsaWire(raw, parts.curve.size) : raw)); + }, + }; + // Importing a JWK isn't enough: some engines accept inconsistent public + // fields. Prove the private component matches the advertised identity. + const verifier = await importPublic(publicKey); + const challenge = new TextEncoder().encode("Subshell SSH private-key consistency check"); + const testAlgorithm = parts.kind === "rsa" ? "rsa-sha2-256" : algorithm; + if (!(await verifier.verify(challenge, await identity.sign(challenge, testAlgorithm)))) { + throw new SignerError("The SSH key's public and private identities don't match."); + } + return identity; + } finally { + for (const bytes of temporary) bytes.fill(0); + // JWK strings and CryptoKeys are managed by the engine and can't be erased. + jwk = undefined; + seed = undefined; + } +} diff --git a/apps/server/web/src/lib/ssh-browser-signer/wire.ts b/apps/server/web/src/lib/ssh-browser-signer/wire.ts new file mode 100644 index 000000000..ec04acb3d --- /dev/null +++ b/apps/server/web/src/lib/ssh-browser-signer/wire.ts @@ -0,0 +1,134 @@ +import { SSH_LOCAL_KEY_MAX_BASE64, SSH_LOCAL_KEY_MAX_BYTES } from "@internal/subshell-protocol"; + +/** A fixed message safe to pass through the worker's public error boundary. */ +export class SignerError extends Error {} + +/** Read bounded SSH wire fields; slices stay within the original allocation. */ +export class Reader { + private offset = 0; + + constructor(private readonly bytes: Uint8Array) {} + + byte(): number { + if (this.offset >= this.bytes.length) throw new SignerError("The SSH data isn't valid."); + return this.bytes[this.offset++]; + } + + u32(): number { + if (this.remaining < 4) throw new SignerError("The SSH data isn't valid."); + const value = new DataView(this.bytes.buffer, this.bytes.byteOffset + this.offset, 4).getUint32(0); + this.offset += 4; + return value; + } + + string(): Uint8Array { + const length = this.u32(); + if (length > this.remaining) throw new SignerError("The SSH data isn't valid."); + const value = this.bytes.subarray(this.offset, this.offset + length); + this.offset += length; + return value; + } + + text(): string { + const bytes = this.string(); + if (bytes.some((byte) => byte < 32 || byte > 126)) throw new SignerError("The SSH data isn't valid."); + return String.fromCharCode(...bytes); + } + + end(): void { + if (this.remaining !== 0) throw new SignerError("The SSH data contains unexpected extra bytes."); + } + + get remaining(): number { + return this.bytes.length - this.offset; + } +} + +/** Compare public wire fields without converting them into strings. */ +export function equal(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, i) => byte === b[i]); +} + +/** Join bounded wire fields. Callers never use this for unbounded input. */ +export function concat(...parts: Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((length, part) => length + part.length, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +/** Encode an unsigned SSH integer. */ +export function u32(value: number): Uint8Array { + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, value); + return out; +} + +/** Encode an SSH length-prefixed byte string. */ +export function field(value: Uint8Array | string): Uint8Array { + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value; + return concat(u32(bytes.length), bytes); +} + +/** Standard base64 with padding, as required by the SSH relay. */ +export function base64(bytes: Uint8Array): string { + let text = ""; + for (const byte of bytes) text += String.fromCharCode(byte); + return btoa(text); +} + +/** Refuse noncanonical encodings and oversized packets before allocating them. */ +export function decode64(value: string): Uint8Array { + if ( + value.length > SSH_LOCAL_KEY_MAX_BASE64 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + throw new SignerError("The SSH data isn't valid base64 or is too large."); + } + const bytes = Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); + if (bytes.length > SSH_LOCAL_KEY_MAX_BYTES || base64(bytes) !== value) { + bytes.fill(0); + throw new SignerError("The SSH data isn't valid base64 or is too large."); + } + return bytes; +} + +/** JWK fields use the unpadded URL-safe variant of base64. */ +export function base64url(bytes: Uint8Array): string { + return base64(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +/** Decode one positive, minimally encoded SSH mpint into unsigned bytes. */ +export function positive(reader: Reader, maximum = 1024): Uint8Array { + const bytes = reader.string(); + if ( + bytes.length === 0 || + bytes.length > maximum + 1 || + (bytes[0] & 0x80) !== 0 || + (bytes[0] === 0 && (bytes.length === 1 || (bytes[1] & 0x80) === 0)) + ) { + throw new SignerError("The SSH key contains an invalid integer."); + } + const unsigned = bytes[0] === 0 ? bytes.subarray(1) : bytes; + if (unsigned.length > maximum) throw new SignerError("The SSH key is too large."); + return unsigned; +} + +/** Encode an unsigned integer as the SSH mpint representation. */ +export function mpint(value: Uint8Array): Uint8Array { + let offset = 0; + while (offset < value.length && value[offset] === 0) offset++; + const bytes = value.subarray(offset); + return field(bytes.length && (bytes[0] & 0x80) !== 0 ? concat(new Uint8Array([0]), bytes) : bytes); +} + +/** Left-pad a scalar to the fixed width Web Crypto expects. */ +export function padded(value: Uint8Array, size: number): Uint8Array { + if (value.length > size) throw new SignerError("The SSH key contains an invalid integer."); + const out = new Uint8Array(size); + out.set(value, size - value.length); + return out; +} diff --git a/apps/server/web/src/lib/ssh-local-key.ts b/apps/server/web/src/lib/ssh-local-key.ts new file mode 100644 index 000000000..18cd38b6e --- /dev/null +++ b/apps/server/web/src/lib/ssh-local-key.ts @@ -0,0 +1,145 @@ +import { apiFetch, apiPost, errMessage, isAlreadyGone } from "@internal/node-admin"; +import { parseSshKeyBridgeResult, SSH_AGENT_FAILURE_BASE64, SSH_LOCAL_KEY_TTL_MS } from "@internal/subshell-protocol"; +import { desktopInvoke, desktopInvokeStrict } from "@/lib/desktop"; +import type { SshLaunchRequest, SshLaunchResponse } from "@/lib/ssh"; +import { type BrowserKeyMaterial, type LocalKeySigner, openBrowserKey } from "@/lib/ssh-browser-key"; + +export interface LocalKeyPreparation { + id: string; + host: string; + port: number; + user: string; + hostKey: string; +} +interface NativeSelection { + id: string; + publicKey: string; + expiresAt: number; +} + +/** Only public agent traffic crosses the API; private signing stays on this device. */ +export async function launchWithLocalKey( + request: SshLaunchRequest, + launch: (request: SshLaunchRequest) => Promise, + onLaunched: (id: string) => void, + signal: AbortSignal, + browser?: { + take: () => BrowserKeyMaterial; + confirm: (target: LocalKeyPreparation) => Promise; + }, + onDiscarded?: () => void, +): Promise { + if (!browser && !(await desktopInvoke("desktop_ssh_key_available"))) + throw new Error("Open this server in an updated Subshell desktop app to use a key from your machine."); + let prepared: LocalKeyPreparation | undefined; + let signer: LocalKeySigner | undefined; + let selected: NativeSelection | null = null; + let picking = false; + let created: SshLaunchResponse | undefined; + let signed = false; + const check = () => { + if (signal.aborted) throw new Error("SSH key selection was cancelled."); + }; + const closeNative = () => { + signer?.close(); + if (selected) void desktopInvoke("desktop_ssh_key_close", { id: selected.id }); + else if (picking) void desktopInvoke("desktop_ssh_key_close", {}); + }; + signal.addEventListener("abort", closeNative); + window.addEventListener("pagehide", closeNative); + try { + check(); + prepared = await apiPost("/api/ssh/local-key", { + node: request.node, + destination: request.destination, + }); + check(); + if (browser) { + if (!(await browser.confirm(prepared))) return null; + check(); + signer = await openBrowserKey(browser.take(), prepared, signal); + check(); + } else { + picking = true; + try { + selected = await desktopInvokeStrict("desktop_ssh_key_pick", { + host: prepared.host, + port: prepared.port, + user: prepared.user, + hostKey: prepared.hostKey, + }); + } finally { + picking = false; + } + check(); + if (!selected) return null; + const handle = selected.id; + signer = { + expiresAt: selected.expiresAt, + request: (payload) => + desktopInvokeStrict<{ response: string; done: boolean }>("desktop_ssh_key_request", { + id: handle, + request: payload, + }), + close: () => { + void desktopInvoke("desktop_ssh_key_close", { id: handle }); + }, + }; + } + created = await launch({ ...request, localKeyId: prepared.id }); + onLaunched(created.subshell.id); + const deadline = Math.min(signer.expiresAt, Date.now() + SSH_LOCAL_KEY_TTL_MS); + let reply: { seq: number; payload: string } | undefined; + while (Date.now() < deadline) { + check(); + const raw = await apiPost(`/api/ssh/local-key/${prepared.id}/exchange`, reply ? { reply } : {}); + reply = undefined; + const state = parseSshKeyBridgeResult(raw); + if (!state) throw new Error("The connecting node returned an invalid SSH signing request."); + if (state.done) { + if (!signed) + throw new Error( + "SSH ended before requesting a signature. Confirm that your selected key is authorized for the destination account. If it is, check that the node and destination support host-bound SSH authentication.", + ); + return created; + } + if (state.request) { + check(); + const answer = await signer.request(state.request.payload); + // Accumulate, never re-derive: once one signature has been issued, + // every later answer - including a failure reply - keeps the attempt + // "signed", and the catch block's keep-the-pane rule must hold. + signed ||= answer.done && answer.response !== SSH_AGENT_FAILURE_BASE64; + reply = { seq: state.request.seq, payload: answer.response }; + } else { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + throw new Error( + "The temporary key expired before SSH authentication finished. Start a new connection to choose the key again.", + ); + } catch (error) { + // Before a signature is issued, this attempt cannot have authenticated. + // Once one is issued, a transport error leaves the outcome uncertain: + // preserve the subshell instead of potentially killing a working login. + if (created && !signed) { + signer?.close(); + try { + await apiFetch(`/api/subshells/${created.subshell.id}`, { method: "DELETE" }); + } catch (cleanupError) { + if (!isAlreadyGone(cleanupError)) + throw new Error( + `${errMessage(error, "SSH authentication failed.")} The failed subshell couldn't be removed. Remove it from the subshell list before retrying.`, + ); + } + onDiscarded?.(); + } + throw error; + } finally { + signal.removeEventListener("abort", closeNative); + window.removeEventListener("pagehide", closeNative); + signer?.close(); + if (selected) await desktopInvoke("desktop_ssh_key_close", { id: selected.id }); + if (prepared) await apiFetch(`/api/ssh/local-key/${prepared.id}`, { method: "DELETE" }).catch(() => {}); + } +} diff --git a/apps/server/web/src/lib/ssh.ts b/apps/server/web/src/lib/ssh.ts index 555bf093a..bd8e35060 100644 --- a/apps/server/web/src/lib/ssh.ts +++ b/apps/server/web/src/lib/ssh.ts @@ -142,6 +142,8 @@ export interface SshSaveHostRequest { /** `POST /api/ssh/launch` body: name is the pane's display name, optional. */ export interface SshLaunchRequest { + /** One-use native signing preparation, never key material. */ + localKeyId?: string; authentication?: "password"; /** Optional machine whose SSH agent supplies keys; absent uses the connecting machine. */ keyHome?: string; diff --git a/apps/server/web/src/lib/subshell-compat.tsx b/apps/server/web/src/lib/subshell-compat.tsx index 3d773ee47..43248487a 100644 --- a/apps/server/web/src/lib/subshell-compat.tsx +++ b/apps/server/web/src/lib/subshell-compat.tsx @@ -15,7 +15,10 @@ import { usableFirst } from "@/lib/option-order"; */ /** The plugin fields the Agent pickers read. */ -export type LaunchAgent = Pick; +export type LaunchAgent = Pick< + InstancePluginRow, + "id" | "name" | "icon" | "installed" | "enabled" | "broken" | "type" | "pickable" +>; /** * Why a harness cannot launch on a node. @@ -67,7 +70,14 @@ export function buildAgentOptions(plugins: readonly LaunchAgent[], node: Node | // never be launched under any conditions, so a greyed row with a reason // would be inventing a story about a choice that does not exist. plugins - .filter((p) => p.type !== "network") + // `pickable: false` drops the same way, and it is the PLUGIN's own + // word: only a plugin knows whether a bare launch means something. + // SSH declares it in its manifest (a bare ssh launch carries no + // destination and dies at the usage prompt; the connect form on the + // SSH terminal tab is its door). Absence means pickable, so every + // plugin written before the field is untouched, and no surface + // accumulates a list of ids to special-case. + .filter((p) => p.type !== "network" && p.pickable !== false) .map((p) => { const opt: ComboboxOption = { value: p.id, label: p.name, disabled: false }; // Every option gets a mark: `PluginIcon` draws the plugin's own when it diff --git a/apps/server/web/vite.config.ts b/apps/server/web/vite.config.ts index 52b2c82fa..e1a7168ca 100644 --- a/apps/server/web/vite.config.ts +++ b/apps/server/web/vite.config.ts @@ -10,12 +10,14 @@ import { devProxyOrigin } from "./src/lib/dev-proxy-origin"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, ""); + const backendOrigin = env.DEV_API_ORIGIN || "http://127.0.0.1:3080"; // Hostnames the dev server may be reached under, comma-separated — set // DEV_ALLOWED_HOSTS in .env.local when a reverse proxy sits in front of // it (otherwise Vite's dev-server host check answers "Blocked request"). // No VITE_ prefix on purpose: it configures the server and must never // ship inside the client bundle. Unset keeps the localhost-only default. - const allowedHosts = (loadEnv(mode, __dirname, "").DEV_ALLOWED_HOSTS ?? "") + const allowedHosts = (env.DEV_ALLOWED_HOSTS ?? "") .split(",") .map((host) => host.trim()) .filter(Boolean); @@ -55,7 +57,7 @@ export default defineConfig(({ mode }) => { // one origin (cookies + WS auth just work). proxy: { "/api": { - target: "http://127.0.0.1:3080", + target: backendOrigin, changeOrigin: true, configure(proxy) { proxy.on("proxyReq", (proxyReq, req) => { @@ -65,7 +67,7 @@ export default defineConfig(({ mode }) => { }, }, "/ws": { - target: "ws://127.0.0.1:3080", + target: backendOrigin, ws: true, configure(proxy) { proxy.on("proxyReqWs", (proxyReq, req) => { diff --git a/apps/website/AGENTS.md b/apps/website/AGENTS.md index 0a343ee4c..13b5f4f6b 100644 --- a/apps/website/AGENTS.md +++ b/apps/website/AGENTS.md @@ -81,3 +81,11 @@ choices use “or the CLI” above their terminal installation command. The clie command installs the desktop app, including its bundled node; headless node setup uses a command and setup key from the server dashboard. Use sentence case, the serial comma, and descriptive captions that do not require knowing abbreviations such as PWA. + +## Feature screenshots + +The five feature screenshots come from `e2e/capture-docs.ts`, shared with the +documentation site. Capture at 2× with `e2e/screenshot.ts`, retain lossless WebP +encoding, and use the product-colored screenshot frame tokens. The PNGs already +include a consistent 12px margin; don't add another inset or a modal backdrop. +Keep the image dimensions in `feature-showcase.tsx` in sync with the actual files. diff --git a/apps/website/app/globals.css b/apps/website/app/globals.css index c436877b2..f7b16c09e 100644 --- a/apps/website/app/globals.css +++ b/apps/website/app/globals.css @@ -13,6 +13,9 @@ --card: #241e33; --term: #181226; --border: #332c48; + /* Match the product screenshots and the documentation site's image frame. */ + --screenshot-background: oklch(0.224 0.035 296); + --screenshot-border: oklch(0.33 0.035 296); --frost: #e6dbef; --dim: #b3a5c4; --dimmer: #7d7195; diff --git a/apps/website/components/feature-showcase.tsx b/apps/website/components/feature-showcase.tsx index 91a49eda4..a1daa3c1e 100644 --- a/apps/website/components/feature-showcase.tsx +++ b/apps/website/components/feature-showcase.tsx @@ -5,36 +5,36 @@ import { SelfHostedSection } from "./self-hosted-section"; const SHOTS = { workspace: { name: "workspace-layout", - width: 1056, - height: 310, + width: 2112, + height: 668, alt: "A project shell and test results side by side in a Subshell workspace", caption: "Two subshells, one workspace. On your phone, panes become tabs.", }, prompts: { name: "prompt-stack-members", - width: 932, - height: 357, + width: 1864, + height: 714, alt: "A saved review checklist combining project context, review instructions, and report format in order", caption: "Build a prompt stack from instructions you already use.", }, presets: { name: "preset-library", - width: 932, - height: 430, + width: 1864, + height: 860, alt: "Saved launch presets for a Codex repository review and a project terminal", caption: "Save launch settings for each agent as a preset.", }, sharing: { name: "session-sharing", - width: 536, - height: 384, + width: 1072, + height: 768, alt: "Subshell sharing dialog with separate View and View + edit permissions", caption: "Choose who can watch and who can type into the subshell.", }, nodes: { name: "node-enrollment", - width: 536, - height: 520, + width: 1072, + height: 1040, alt: "Add node instructions for connecting another machine with Subshell Client, a server address, and a setup key", caption: "Connect another machine using a setup key from your dashboard.", }, @@ -44,7 +44,7 @@ function Screenshot({ shot }: { shot: (typeof SHOTS)[keyof typeof SHOTS] }) { const src = `/shots/${shot.name}.webp`; return (
-
+
{shot.alt}How to share a subshell
-
+
diff --git a/apps/website/public/shots/node-enrollment.webp b/apps/website/public/shots/node-enrollment.webp index 9010f0efb..d10ed3bd2 100644 Binary files a/apps/website/public/shots/node-enrollment.webp and b/apps/website/public/shots/node-enrollment.webp differ diff --git a/apps/website/public/shots/preset-library.webp b/apps/website/public/shots/preset-library.webp index 90ee14fca..2a8153228 100644 Binary files a/apps/website/public/shots/preset-library.webp and b/apps/website/public/shots/preset-library.webp differ diff --git a/apps/website/public/shots/prompt-stack-members.webp b/apps/website/public/shots/prompt-stack-members.webp index 8a0ec9f63..a89df2861 100644 Binary files a/apps/website/public/shots/prompt-stack-members.webp and b/apps/website/public/shots/prompt-stack-members.webp differ diff --git a/apps/website/public/shots/session-sharing.webp b/apps/website/public/shots/session-sharing.webp index f256af025..c115f0fc3 100644 Binary files a/apps/website/public/shots/session-sharing.webp and b/apps/website/public/shots/session-sharing.webp differ diff --git a/apps/website/public/shots/workspace-layout.webp b/apps/website/public/shots/workspace-layout.webp index 43a36acc1..d27c966a0 100644 Binary files a/apps/website/public/shots/workspace-layout.webp and b/apps/website/public/shots/workspace-layout.webp differ diff --git a/bun.lock b/bun.lock index 93dd16890..4c8f0df2f 100644 --- a/bun.lock +++ b/bun.lock @@ -126,7 +126,7 @@ }, "apps/node/agent": { "name": "@internal/node", - "version": "2.0.0", + "version": "2.1.0", "dependencies": { "@clack/prompts": "1.8.1", "@internal/backend-errors": "workspace:*", @@ -278,6 +278,7 @@ "@xterm/addon-unicode-graphemes": "0.5.0-beta.301", "@xterm/addon-web-links": "0.13.0-beta.301", "@xterm/xterm": "6.1.0-beta.304", + "bcrypt-pbkdf": "1.0.2", "better-auth": "1.7.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/crates/desktop-core/Cargo.lock b/crates/desktop-core/Cargo.lock index 97f23a2db..4bf150976 100644 --- a/crates/desktop-core/Cargo.lock +++ b/crates/desktop-core/Cargo.lock @@ -2,38 +2,654 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "num-integer" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] [[package]] -name = "libc" -version = "0.2.189" +name = "num-iter" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] [[package]] -name = "memchr" -version = "2.8.3" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] [[package]] name = "objc2" @@ -83,6 +699,184 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "aws-lc-sys", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -101,6 +895,137 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -128,7 +1053,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -144,18 +1069,147 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "subshell-desktop-core" version = "0.1.0" dependencies = [ + "base64", "block2", + "libc", "objc2", "objc2-foundation", "objc2-photos", "objc2-user-notifications", + "openssl", + "rand", "serde", "serde_json", + "signature", + "ssh-key", "wait-timeout", + "zeroize", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -169,12 +1223,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -184,6 +1266,44 @@ dependencies = [ "libc", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/desktop-core/Cargo.toml b/crates/desktop-core/Cargo.toml index c156c109d..81558c092 100644 --- a/crates/desktop-core/Cargo.toml +++ b/crates/desktop-core/Cargo.toml @@ -17,6 +17,14 @@ rust-version = "1.82" serde = { version = "1", features = ["derive"] } serde_json = "1" wait-timeout = "0.2" +base64 = "0.22" +rand = "0.8" +ssh-key = { version = "0.6.7", features = ["ed25519", "p256", "p384", "p521", "encryption", "getrandom"] } +zeroize = "1" +libc = "0.2" +signature = "2" +# Keep ssh-key's RSA backend disabled; AWS-LC performs native RSA operations. +openssl = { version = "0.10.81", default-features = false, features = ["aws-lc"] } # `permissions` only, and macOS only — Linux has none of the prompts this # answers for, so every one of these would be dead weight in the Linux build diff --git a/crates/desktop-core/src/browser.rs b/crates/desktop-core/src/browser.rs index 06033fe9a..ac536c8d6 100644 --- a/crates/desktop-core/src/browser.rs +++ b/crates/desktop-core/src/browser.rs @@ -1,7 +1,7 @@ //! "Open in browser": turning a page-supplied PATH into a URL this app is //! willing to hand to the system browser. //! -//! Both desktop apps grow one command that a REMOTE page may invoke — +//! One of the commands both desktop apps grant to a REMOTE page — //! `desktop_open_in_browser` — and the whole of its safety is that the page //! names a path and never a host. The origin comes from the window the page is //! already in (the server app's loopback origin, the client's pinned plane), diff --git a/crates/desktop-core/src/lib.rs b/crates/desktop-core/src/lib.rs index e5a90a3f0..7e8f59a58 100644 --- a/crates/desktop-core/src/lib.rs +++ b/crates/desktop-core/src/lib.rs @@ -81,6 +81,7 @@ pub mod reset_guards; pub mod settings; pub mod shell_env; pub mod sidecar; +pub mod ssh_signer; pub mod tmux; pub mod tray; pub mod version; diff --git a/crates/desktop-core/src/ssh_signer.rs b/crates/desktop-core/src/ssh_signer.rs new file mode 100644 index 000000000..b7329fa9e --- /dev/null +++ b/crates/desktop-core/src/ssh_signer.rs @@ -0,0 +1,719 @@ +//! A short-lived SSH agent that authorizes exactly one host-bound login. +//! +//! The browser sees only public wire replies. OpenSSH's verified session bind +//! and host-bound userauth keep an untrusted relay from repurposing the key. +use base64::{engine::general_purpose::STANDARD, Engine}; +use rand::{rngs::OsRng, RngCore}; +use serde::Serialize; +use signature::{Signer, Verifier}; +use ssh_key::{HashAlg, PrivateKey, PublicKey, Signature}; +use std::{ + collections::HashMap, + io::Read, + path::Path, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use zeroize::Zeroizing; + +mod rsa; + +enum SigningKey { + Ssh(Box), + Rsa(openssl::pkey::PKey), +} + +pub const MAX_BYTES: usize = 65536; +pub const LIFETIME: Duration = Duration::from_secs(90); +const FAILURE: &[u8] = &[5]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Owner { + pub window: String, + pub origin: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Selected { + pub id: String, + pub public_key: String, + pub expires_at: u64, +} + +#[derive(Serialize)] +pub struct Reply { + pub response: String, + pub done: bool, +} + +pub fn decode(value: &str) -> Result, String> { + if value.len() > (MAX_BYTES * 4 / 3 + 4) { + return Err("The SSH request is too large.".into()); + } + let bytes = STANDARD + .decode(value) + .map_err(|_| "The SSH request isn't valid base64.")?; + if bytes.len() > MAX_BYTES { + return Err("The SSH request is too large.".into()); + } + Ok(bytes) +} + +pub fn load_key(path: &Path) -> Result { + // Open nonblocking first: picking a FIFO must not hang a GUI thread before + // metadata can reject it. O_NOFOLLOW also refuses a symlink swapped in + // between the picker and this open; normal symlinks are intentionally refused. + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) + .open(path) + } + .map_err(|_| "Couldn't open that SSH key. Choose a regular private key file.")?; + #[cfg(not(unix))] + let mut file = std::fs::File::open(path).map_err(|_| "Couldn't open that SSH key file.")?; + let metadata = file.metadata().map_err(|_| "Couldn't check that SSH key file.")?; + if !metadata.is_file() || metadata.len() > MAX_BYTES as u64 { + return Err("Choose a regular SSH private key file smaller than 64 KiB.".into()); + } + let mut bytes = Zeroizing::new(Vec::with_capacity(MAX_BYTES + 1)); + (&mut file) + .take(MAX_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Couldn't read that SSH key file.")?; + if bytes.len() > MAX_BYTES { + return Err("That SSH key file is too large.".into()); + } + PrivateKey::from_openssh(&*bytes).map_err(|_| "That file isn't a supported OpenSSH private key.".into()) +} + +pub fn host_key(encoded: &str) -> Result<(Vec, String), String> { + let bytes = decode(encoded)?; + let key = PublicKey::from_bytes(&bytes).map_err(|_| "The SSH host key isn't valid.")?; + match key.key_data() { + ssh_key::public::KeyData::Rsa(parts) if rsa::public(parts).is_some() => {} + ssh_key::public::KeyData::Ed25519(_) | ssh_key::public::KeyData::Ecdsa(_) => {} + _ => return Err("Choose a destination with an Ed25519, ECDSA, or 2,048–8,192-bit RSA host key.".into()), + } + if key.to_bytes().map_err(|_| "The SSH host key isn't valid.")? != bytes { + return Err("The SSH host key isn't valid.".into()); + } + Ok((bytes, key.fingerprint(HashAlg::Sha256).to_string())) +} + +pub fn random_id() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + STANDARD.encode(bytes) +} + +struct Session { + owner: Owner, + user: String, + host_key: Vec, + public_key: Vec, + key: SigningKey, + expires: Instant, + binding: Option>, +} + +#[derive(Default)] +pub struct Signers { + sessions: HashMap, +} + +impl Signers { + pub fn insert( + &mut self, + owner: Owner, + user: String, + host_key: Vec, + key: PrivateKey, + deadline: Instant, + ) -> Result { + if deadline <= Instant::now() || deadline > Instant::now() + LIFETIME { + return Err("The local SSH key request expired.".into()); + } + if key.is_encrypted() { + return Err("Unlock the SSH key first.".into()); + } + let public_key = key + .public_key() + .to_bytes() + .map_err(|_| "Couldn't read the key's public identity.")?; + let key = match key.key_data() { + ssh_key::private::KeypairData::Rsa(parts) => SigningKey::Rsa(rsa::private(parts) + .ok_or("That RSA key isn't valid or supported. Choose a 2,048–8,192-bit RSA key with a public exponent no larger than 33 bits.")?), + ssh_key::private::KeypairData::Ed25519(_) | ssh_key::private::KeypairData::Ecdsa(_) => SigningKey::Ssh(Box::new(key)), + _ => return Err("Choose an Ed25519, ECDSA, or RSA SSH key.".into()), + }; + // Key validation can take time; never publish a handle past its deadline. + if deadline <= Instant::now() { + return Err("The local SSH key request expired.".into()); + } + self.close_owner(&owner.window); + let id = random_id(); + let selected = Selected { + id: id.clone(), + public_key: STANDARD.encode(&public_key), + expires_at: (SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default() + + deadline.saturating_duration_since(Instant::now())) + .as_millis() as u64, + }; + self.sessions.insert( + id, + Session { + owner, + user, + host_key, + public_key, + key, + expires: deadline, + binding: None, + }, + ); + Ok(selected) + } + + pub fn expire(&mut self) { + self.sessions.retain(|_, s| s.expires > Instant::now()); + } + pub fn close_owner(&mut self, window: &str) { + self.sessions.retain(|_, s| s.owner.window != window); + } + pub fn close(&mut self, owner: &Owner, id: &str) { + if self.sessions.get(id).is_some_and(|s| s.owner == *owner) { + self.sessions.remove(id); + } + } + + pub fn request(&mut self, owner: &Owner, id: &str, encoded: &str) -> Reply { + self.request_with_clock(owner, id, encoded, Instant::now) + } + + fn request_with_clock( + &mut self, + owner: &Owner, + id: &str, + encoded: &str, + mut now: impl FnMut() -> Instant, + ) -> Reply { + let started = now(); + self.sessions.retain(|_, session| session.expires > started); + let mut done = !self.sessions.contains_key(id); + let mut response = (|| { + let bytes = decode(encoded).ok()?; + let session = self.sessions.get_mut(id)?; + if session.owner != *owner { + return None; + } + let mut r = Reader(&bytes); + let response = match r.byte()? { + 11 if r.finished() => { + let mut out = vec![12]; + out.extend(1u32.to_be_bytes()); + put(&mut out, &session.public_key); + put(&mut out, b"Subshell one-time SSH key"); + out + } + 27 => session.bind(&mut r)?, + 13 => { + let response = session.sign(&mut r)?; + done = true; + response + } + _ => return None, + }; + Some(response) + })(); + // Verification/signing can cross the deadline while the native timer + // waits for this mutex. Recheck even failed operations, discard any + // late signature, and release every expired key before returning. + let finished = now(); + if self.sessions.get(id).is_some_and(|session| session.expires <= finished) { + response = None; + done = true; + } + self.sessions.retain(|_, session| session.expires > finished); + let response = response.unwrap_or_else(|| FAILURE.to_vec()); + // Remove before the caller receives a signature, under the same mutex + // the native wrapper holds throughout this operation. + if done { + self.close(owner, id); + } + Reply { + response: STANDARD.encode(response), + done, + } + } +} + +impl Session { + fn bind(&mut self, r: &mut Reader<'_>) -> Option> { + if r.string()? != b"session-bind@openssh.com" || self.binding.is_some() || r.string()? != self.host_key { + return None; + } + let session = r.string()?; + if !(16..=128).contains(&session.len()) { + return None; + } + let signature_bytes = r.string()?; + // ssh-key's TryFrom decodes a signature but does not check trailing + // bytes. Validate the two-string wire frame before handing it over. + let mut sr = Reader(signature_bytes); + sr.string()?; + sr.string()?; + if !sr.finished() || r.byte()? != 0 || !r.finished() { + return None; + } + let signature = Signature::try_from(signature_bytes).ok()?; + let key = PublicKey::from_bytes(&self.host_key).ok()?; + match key.key_data() { + ssh_key::public::KeyData::Rsa(parts) => rsa::verify(parts, session, &signature)?, + _ => Verifier::verify(&key, session, &signature).ok()?, + } + self.binding = Some(session.to_vec()); + Some(vec![6]) + } + + fn sign(&self, r: &mut Reader<'_>) -> Option> { + if r.string()? != self.public_key { + return None; + } + let data = r.string()?; + let flags = r.u32()?; + if !r.finished() { + return None; + } + let mut auth = Reader(data); + if auth.string()? != self.binding.as_ref()?.as_slice() + || auth.byte()? != 50 + || auth.string()? != self.user.as_bytes() + || auth.string()? != b"ssh-connection" + || auth.string()? != b"publickey-hostbound-v00@openssh.com" + || auth.byte()? != 1 + { + return None; + } + let algorithm = auth.string()?; + if auth.string()? != self.public_key || auth.string()? != self.host_key || !auth.finished() { + return None; + } + let signature = match &self.key { + SigningKey::Rsa(key) => rsa::sign(key, data, algorithm, flags)?, + SigningKey::Ssh(key) => { + if flags != 0 || algorithm != key.algorithm().as_str().as_bytes() { + return None; + } + key.try_sign(data).ok()? + } + }; + let wire = Vec::::try_from(signature).ok()?; + let mut response = vec![14]; + put(&mut response, &wire); + Some(response) + } +} + +struct Reader<'a>(&'a [u8]); +impl<'a> Reader<'a> { + fn byte(&mut self) -> Option { + let (&byte, rest) = self.0.split_first()?; + self.0 = rest; + Some(byte) + } + fn u32(&mut self) -> Option { + let bytes = self.0.get(..4)?.try_into().ok()?; + self.0 = &self.0[4..]; + Some(u32::from_be_bytes(bytes)) + } + fn string(&mut self) -> Option<&'a [u8]> { + let len = self.u32()? as usize; + let bytes = self.0.get(..len)?; + self.0 = &self.0[len..]; + Some(bytes) + } + fn finished(&self) -> bool { + self.0.is_empty() + } +} +fn put(out: &mut Vec, value: &[u8]) { + out.extend((value.len() as u32).to_be_bytes()); + out.extend(value); +} + +/// Bound the CPU work of a selected encrypted file before accepting a password. +pub fn unlock_key(key: &PrivateKey, passphrase: &str) -> Result { + if let ssh_key::Kdf::Bcrypt { rounds, .. } = key.kdf() { + if *rounds > 1024 { + return Err("This key's encryption takes too long for a temporary login. Choose a key with fewer than 1,025 bcrypt rounds.".into()); + } + } + key.decrypt(passphrase) + .map_err(|_| "Couldn't unlock that SSH key. Check its passphrase and choose the key again.".into()) +} + +pub use zeroize::Zeroizing as Secret; + +#[cfg(test)] +mod tests { + use super::*; + use ssh_key::Algorithm; + fn owner() -> Owner { + Owner { + window: "main".into(), + origin: "https://plane.example".into(), + } + } + fn key() -> PrivateKey { + PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap() + } + fn fixture() -> (Signers, Selected, PrivateKey, PrivateKey) { + let private = key(); + let host = key(); + let mut signers = Signers::default(); + let selected = signers + .insert( + owner(), + "alice".into(), + host.public_key().to_bytes().unwrap(), + private.clone(), + Instant::now() + LIFETIME, + ) + .unwrap(); + (signers, selected, private, host) + } + fn bind(host: &PrivateKey, session: &[u8]) -> Vec { + let mut request = vec![27]; + put(&mut request, b"session-bind@openssh.com"); + put(&mut request, &host.public_key().to_bytes().unwrap()); + put(&mut request, session); + put( + &mut request, + &Vec::::try_from(host.try_sign(session).unwrap()).unwrap(), + ); + request.push(0); + request + } + fn auth(private: &PrivateKey, host: &PrivateKey, session: &[u8], user: &[u8]) -> Vec { + let mut data = Vec::new(); + put(&mut data, session); + data.push(50); + put(&mut data, user); + put(&mut data, b"ssh-connection"); + put(&mut data, b"publickey-hostbound-v00@openssh.com"); + data.push(1); + put(&mut data, private.algorithm().as_str().as_bytes()); + put(&mut data, &private.public_key().to_bytes().unwrap()); + put(&mut data, &host.public_key().to_bytes().unwrap()); + data + } + fn sign(private: &PrivateKey, data: &[u8]) -> Vec { + let mut request = vec![13]; + put(&mut request, &private.public_key().to_bytes().unwrap()); + put(&mut request, data); + request.extend(0u32.to_be_bytes()); + request + } + fn request(signers: &mut Signers, selected: &Selected, bytes: &[u8]) -> Reply { + signers.request(&owner(), &selected.id, &STANDARD.encode(bytes)) + } + const SESSION: &[u8] = &[42; 32]; + #[test] + fn one_verified_hostbound_signature_then_key_is_gone() { + let (mut signers, selected, private, host) = fixture(); + let identities = request(&mut signers, &selected, &[11]); + let bytes = STANDARD.decode(identities.response).unwrap(); + let mut r = Reader(&bytes); + assert_eq!(r.byte(), Some(12)); + assert_eq!(r.u32(), Some(1)); + assert_eq!(r.string().unwrap(), private.public_key().to_bytes().unwrap()); + r.string().unwrap(); + assert!(r.finished()); + assert_eq!(request(&mut signers, &selected, &bind(&host, SESSION)).response, "Bg=="); + let data = auth(&private, &host, SESSION, b"alice"); + let signed = request(&mut signers, &selected, &sign(&private, &data)); + assert!(signed.done); + let bytes = STANDARD.decode(signed.response).unwrap(); + let mut r = Reader(&bytes); + assert_eq!(r.byte(), Some(14)); + let signature = Signature::try_from(r.string().unwrap()).unwrap(); + assert!(r.finished()); + Verifier::verify(private.public_key(), &data, &signature).unwrap(); + assert!(signers.sessions.is_empty()); + let reused = request(&mut signers, &selected, &sign(&private, &data)); + assert_eq!(reused.response, "BQ=="); + assert!(reused.done); + } + #[test] + fn forged_or_mismatched_host_bindings_never_arm_signer() { + let (mut signers, selected, _, host) = fixture(); + let mut forged = bind(&host, SESSION); + let last = forged.len() - 2; + forged[last] ^= 1; + assert_eq!(request(&mut signers, &selected, &forged).response, "BQ=="); + assert_eq!( + request(&mut signers, &selected, &bind(&key(), SESSION)).response, + "BQ==" + ); + assert!(signers.sessions[&selected.id].binding.is_none()); + } + #[test] + fn forwarding_trailing_bytes_and_rebinding_are_refused() { + let (mut signers, selected, _, host) = fixture(); + let mut forwarded = bind(&host, SESSION); + *forwarded.last_mut().unwrap() = 1; + assert_eq!(request(&mut signers, &selected, &forwarded).response, "BQ=="); + let mut trailing = bind(&host, SESSION); + trailing.push(0); + assert_eq!(request(&mut signers, &selected, &trailing).response, "BQ=="); + assert_eq!(request(&mut signers, &selected, &bind(&host, SESSION)).response, "Bg=="); + assert_eq!( + request(&mut signers, &selected, &bind(&host, &[7; 32])).response, + "BQ==" + ); + assert_eq!(signers.sessions[&selected.id].binding.as_deref(), Some(SESSION)); + } + #[test] + fn unbound_wrong_user_wrong_session_wrong_host_wrong_key_and_raw_data_fail() { + let (mut signers, selected, private, host) = fixture(); + let data = auth(&private, &host, SESSION, b"alice"); + assert_eq!( + request(&mut signers, &selected, &sign(&private, &data)).response, + "BQ==" + ); + request(&mut signers, &selected, &bind(&host, SESSION)); + for data in [ + auth(&private, &host, SESSION, b"bob"), + auth(&private, &host, &[7; 32], b"alice"), + auth(&private, &key(), SESSION, b"alice"), + auth(&key(), &host, SESSION, b"alice"), + b"sign this arbitrary challenge".to_vec(), + ] { + assert_eq!( + request(&mut signers, &selected, &sign(&private, &data)).response, + "BQ==" + ); + } + assert_eq!(request(&mut signers, &selected, &sign(&key(), &data)).response, "BQ=="); + assert_eq!(signers.sessions.len(), 1); + } + #[test] + fn ordinary_unhostbound_userauth_and_unrecognized_requests_fail() { + let (mut signers, selected, private, host) = fixture(); + request(&mut signers, &selected, &bind(&host, SESSION)); + let mut data = Vec::new(); + put(&mut data, SESSION); + data.push(50); + put(&mut data, b"alice"); + put(&mut data, b"ssh-connection"); + put(&mut data, b"publickey"); + data.push(1); + put(&mut data, b"ssh-ed25519"); + put(&mut data, &private.public_key().to_bytes().unwrap()); + for packet in [sign(&private, &data), vec![17], vec![11, 0], vec![27], vec![13]] { + assert_eq!(request(&mut signers, &selected, &packet).response, "BQ=="); + } + } + #[test] + fn rsa_sha256_and_sha512_flags_are_respected_and_sha1_is_refused() { + let private = rsa::fixture_key(2048); + let host = key(); + for (flags, algorithm, expected_hash) in [ + (2u32, b"rsa-sha2-256".as_slice(), HashAlg::Sha256), + (4u32, b"rsa-sha2-512".as_slice(), HashAlg::Sha512), + ] { + let mut signers = Signers::default(); + let selected = signers + .insert( + owner(), + "alice".into(), + host.public_key().to_bytes().unwrap(), + private.clone(), + Instant::now() + LIFETIME, + ) + .unwrap(); + request(&mut signers, &selected, &bind(&host, SESSION)); + let mut data = Vec::new(); + put(&mut data, SESSION); + data.push(50); + put(&mut data, b"alice"); + put(&mut data, b"ssh-connection"); + put(&mut data, b"publickey-hostbound-v00@openssh.com"); + data.push(1); + put(&mut data, algorithm); + put(&mut data, &private.public_key().to_bytes().unwrap()); + put(&mut data, &host.public_key().to_bytes().unwrap()); + let mut packet = sign(&private, &data); + // A legacy (SHA1) flags field and a mismatching SHA2 flags field + // must both fail while leaving the valid one-use request possible. + assert_eq!(request(&mut signers, &selected, &packet).response, "BQ=="); + let end = packet.len(); + packet[end - 4..].copy_from_slice(&(6 - flags).to_be_bytes()); + assert_eq!(request(&mut signers, &selected, &packet).response, "BQ=="); + packet[end - 4..].copy_from_slice(&flags.to_be_bytes()); + let reply = request(&mut signers, &selected, &packet); + assert!(reply.done); + let bytes = STANDARD.decode(reply.response).unwrap(); + let mut r = Reader(&bytes); + assert_eq!(r.byte(), Some(14)); + let signature = Signature::try_from(r.string().unwrap()).unwrap(); + assert_eq!( + signature.algorithm(), + Algorithm::Rsa { + hash: Some(expected_hash) + } + ); + rsa::verify(private.public_key().key_data().rsa().unwrap(), &data, &signature).unwrap(); + assert_eq!(request(&mut signers, &selected, &packet).response, "BQ=="); + assert!(signers.sessions.is_empty()); + } + } + + #[test] + fn undersized_rsa_keys_are_refused_before_creating_a_signer() { + // Tiny mathematical RSA fixture, never used for cryptography: parsing + // must remain possible so users get the specific unsupported-key error. + let private = PrivateKey::new( + ssh_key::private::KeypairData::Rsa(ssh_key::private::RsaKeypair { + public: ssh_key::public::RsaPublicKey { + n: ssh_key::Mpint::from_bytes(&[0x0c, 0xa1]).unwrap(), + e: ssh_key::Mpint::from_bytes(&[17]).unwrap(), + }, + private: ssh_key::private::RsaPrivateKey { + d: ssh_key::Mpint::from_bytes(&[0x0a, 0xc1]).unwrap(), + iqmp: ssh_key::Mpint::from_bytes(&[38]).unwrap(), + p: ssh_key::Mpint::from_bytes(&[61]).unwrap(), + q: ssh_key::Mpint::from_bytes(&[53]).unwrap(), + }, + }), + "", + ) + .unwrap(); + let encoded_host = STANDARD.encode(private.public_key().to_bytes().unwrap()); + assert!(host_key(&encoded_host).unwrap_err().contains("RSA host key")); + let mut signers = Signers::default(); + let result = signers.insert( + owner(), + "alice".into(), + key().public_key().to_bytes().unwrap(), + private, + Instant::now() + LIFETIME, + ); + assert!(result.err().unwrap().contains("RSA key isn't valid or supported")); + assert!(signers.sessions.is_empty()); + } + + #[test] + fn origin_and_window_scope_apply_to_requests_and_close() { + let (mut signers, selected, _, _) = fixture(); + for other in [ + Owner { + window: "main".into(), + origin: "https://evil.example".into(), + }, + Owner { + window: "node".into(), + origin: owner().origin, + }, + ] { + assert_eq!(signers.request(&other, &selected.id, "Cw==").response, "BQ=="); + signers.close(&other, &selected.id); + assert_eq!(signers.sessions.len(), 1); + } + signers.close(&owner(), &selected.id); + signers.close(&owner(), &selected.id); + assert!(signers.sessions.is_empty()); + } + #[test] + fn expiration_and_navigation_release_without_another_request() { + let (mut signers, selected, _, _) = fixture(); + signers.sessions.get_mut(&selected.id).unwrap().expires = Instant::now(); + signers.expire(); + assert!(signers.sessions.is_empty()); + assert!(request(&mut signers, &selected, &[11]).done); + let (mut signers, _, _, _) = fixture(); + signers.close_owner("main"); + assert!(signers.sessions.is_empty()); + } + #[test] + fn operation_crossing_deadline_discards_success_and_releases_key_on_failure_too() { + for valid in [true, false] { + let (mut signers, selected, private, host) = fixture(); + request(&mut signers, &selected, &bind(&host, SESSION)); + let packet = if valid { + sign(&private, &auth(&private, &host, SESSION, b"alice")) + } else { + sign(&private, b"invalid auth payload") + }; + let deadline = signers.sessions[&selected.id].expires; + let mut times = [deadline - Duration::from_millis(1), deadline].into_iter(); + let reply = signers.request_with_clock(&owner(), &selected.id, &STANDARD.encode(packet), || { + times.next().unwrap() + }); + assert_eq!(reply.response, "BQ=="); + assert!(reply.done); + assert!(signers.sessions.is_empty()); + } + } + + #[test] + fn malformed_and_oversized_payloads_fail_without_signing() { + let (mut signers, selected, _, _) = fixture(); + for data in [ + vec![13, 255, 255, 255, 255], + vec![11; MAX_BYTES + 1], + vec![27, 0, 0, 0, 255], + ] { + assert_eq!(request(&mut signers, &selected, &data).response, "BQ=="); + } + assert_eq!(signers.request(&owner(), &selected.id, "!!!").response, "BQ=="); + assert_eq!(signers.sessions.len(), 1); + } + #[test] + fn concurrent_signatures_have_one_winner() { + let (mut signers, selected, private, host) = fixture(); + request(&mut signers, &selected, &bind(&host, SESSION)); + let packet = STANDARD.encode(sign(&private, &auth(&private, &host, SESSION, b"alice"))); + let state = std::sync::Arc::new(std::sync::Mutex::new(signers)); + let id = selected.id; + let threads: Vec<_> = (0..8) + .map(|_| { + let state = state.clone(); + let id = id.clone(); + let packet = packet.clone(); + std::thread::spawn(move || state.lock().unwrap().request(&owner(), &id, &packet)) + }) + .collect(); + let responses: Vec<_> = threads.into_iter().map(|t| t.join().unwrap()).collect(); + assert_eq!(responses.iter().filter(|r| r.response != "BQ==").count(), 1); + assert!(state.lock().unwrap().sessions.is_empty()); + } + #[test] + fn encrypted_key_unlock_stays_native_and_wrong_password_fails() { + let key = key(); + let encrypted = key.encrypt(&mut OsRng, "secret").unwrap(); + assert!(encrypted.is_encrypted()); + assert!(unlock_key(&encrypted, "wrong").is_err()); + assert_eq!(unlock_key(&encrypted, "secret").unwrap().public_key(), key.public_key()); + } + #[cfg(unix)] + #[test] + fn selected_files_are_bounded_regular_files_and_never_symlinks() { + use std::os::unix::fs::symlink; + let dir = std::env::temp_dir().join(format!("subshell-key-{}", random_id().replace('/', "_"))); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("key"); + std::fs::write(&path, vec![b'x'; MAX_BYTES + 1]).unwrap(); + assert!(load_key(&path).is_err()); + assert!(load_key(&dir).is_err()); + let link = dir.join("link"); + symlink(&path, &link).unwrap(); + assert!(load_key(&link).is_err()); + std::fs::write(&path, key().to_openssh(ssh_key::LineEnding::LF).unwrap().as_bytes()).unwrap(); + assert!(load_key(&path).is_ok()); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/desktop-core/src/ssh_signer/rsa.rs b/crates/desktop-core/src/ssh_signer/rsa.rs new file mode 100644 index 000000000..c64d57044 --- /dev/null +++ b/crates/desktop-core/src/ssh_signer/rsa.rs @@ -0,0 +1,160 @@ +//! RSA operations use AWS-LC, never ssh-key's optional RustCrypto RSA backend. +use openssl::{ + bn::BigNum, + hash::MessageDigest, + pkey::{PKey, Private, Public}, + rsa::{Padding, Rsa, RsaPrivateKeyBuilder}, + sign::{Signer, Verifier}, +}; +use ssh_key::{private::RsaKeypair, public::RsaPublicKey, Algorithm, HashAlg, Mpint, Signature}; + +fn number(value: &Mpint) -> Option { + let bytes = value.as_positive_bytes()?; + if bytes.is_empty() || bytes.len() > 1024 { + return None; + } + BigNum::from_slice(bytes).ok() +} + +fn public_parts(key: &RsaPublicKey) -> Option<(BigNum, BigNum)> { + let n = number(&key.n)?; + let e = number(&key.e)?; + if !(2048..=8192).contains(&n.num_bits()) || !n.is_odd() || e.num_bits() > 33 || e.num_bits() < 2 || !e.is_odd() { + return None; + } + Some((n, e)) +} + +pub(super) fn public(key: &RsaPublicKey) -> Option> { + let (n, e) = public_parts(key)?; + PKey::from_rsa(Rsa::from_public_components(n, e).ok()?).ok() +} + +pub(super) fn private(key: &RsaKeypair) -> Option> { + let (n, e) = public_parts(&key.public)?; + // OpenSSH doesn't store dP/dQ. Give AWS-LC the essential components and + // let it manage the private operation, rather than deriving CRT values in + // application code or using the affected RustCrypto backend. The file's + // cached iqmp is unused. RSA_check_key verifies n=p*q and d/e consistency. + let rsa = RsaPrivateKeyBuilder::new(n, e, number(&key.private.d)?) + .ok()? + .set_factors(number(&key.private.p)?, number(&key.private.q)?) + .ok()? + .build(); + if !rsa.check_key().ok()? { + return None; + } + PKey::from_rsa(rsa).ok() +} + +fn digest(hash: HashAlg) -> Option { + Some(match hash { + HashAlg::Sha256 => MessageDigest::sha256(), + HashAlg::Sha512 => MessageDigest::sha512(), + _ => return None, + }) +} + +pub(super) fn sign(key: &PKey, data: &[u8], algorithm: &[u8], flags: u32) -> Option { + let hash = match (algorithm, flags) { + (b"rsa-sha2-256", 2) => HashAlg::Sha256, + (b"rsa-sha2-512", 4) => HashAlg::Sha512, + _ => return None, + }; + let mut signer = Signer::new(digest(hash)?, key).ok()?; + signer.set_rsa_padding(Padding::PKCS1).ok()?; + let bytes = signer.sign_oneshot_to_vec(data).ok()?; + Signature::new(Algorithm::Rsa { hash: Some(hash) }, bytes).ok() +} + +pub(super) fn verify(key: &RsaPublicKey, data: &[u8], signature: &Signature) -> Option<()> { + let Algorithm::Rsa { hash: Some(hash) } = signature.algorithm() else { + return None; + }; + let key = public(key)?; + let mut verifier = Verifier::new(digest(hash)?, &key).ok()?; + verifier.set_rsa_padding(Padding::PKCS1).ok()?; + verifier.verify_oneshot(signature.as_bytes(), data).ok()?.then_some(()) +} + +#[cfg(test)] +pub(super) fn fixture_key(bits: u32) -> ssh_key::PrivateKey { + let rsa = Rsa::generate(bits).unwrap(); + let mpint = |n: &openssl::bn::BigNumRef| Mpint::from_positive_bytes(&n.to_vec()).unwrap(); + ssh_key::PrivateKey::new( + ssh_key::private::KeypairData::Rsa(RsaKeypair { + public: RsaPublicKey { + n: mpint(rsa.n()), + e: mpint(rsa.e()), + }, + private: ssh_key::private::RsaPrivateKey { + d: mpint(rsa.d()), + iqmp: mpint(rsa.iqmp().unwrap()), + p: mpint(rsa.p().unwrap()), + q: mpint(rsa.q().unwrap()), + }, + }), + "test-only", + ) + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rsa_backend_is_aws_lc() { + // A dependency-feature change must not silently select system OpenSSL. + assert!(openssl::version::version().contains("AWS-LC")); + } + + #[test] + fn validates_components_and_bounds_before_accepting_private_keys() { + let key = fixture_key(2048); + let parts = key.key_data().rsa().unwrap(); + assert!(private(parts).is_some()); + for component in ["d", "p", "q", "n", "e"] { + let mut bad = parts.clone(); + let target = match component { + "d" => &mut bad.private.d, + "p" => &mut bad.private.p, + "q" => &mut bad.private.q, + "n" => &mut bad.public.n, + _ => &mut bad.public.e, + }; + *target = Mpint::from_bytes(&[3]).unwrap(); + assert!(private(&bad).is_none(), "accepted invalid {component}"); + } + let mut oversized = parts.clone(); + oversized.private.d = Mpint::from_positive_bytes(&[1; 1025]).unwrap(); + assert!(private(&oversized).is_none()); + oversized.public.n = Mpint::from_positive_bytes(&[1; 1025]).unwrap(); + assert!(public(&oversized.public).is_none()); + let small = fixture_key(1024); + assert!(private(small.key_data().rsa().unwrap()).is_none()); + assert!(public(small.public_key().key_data().rsa().unwrap()).is_none()); + } + + #[test] + fn verifies_only_sha2_signatures_for_the_exact_host_and_message() { + let host = fixture_key(2048); + let other = fixture_key(2048); + let key = private(host.key_data().rsa().unwrap()).unwrap(); + for (algorithm, flags) in [(b"rsa-sha2-256".as_slice(), 2), (b"rsa-sha2-512".as_slice(), 4)] { + let signature = sign(&key, b"session-id", algorithm, flags).unwrap(); + assert!(verify(host.public_key().key_data().rsa().unwrap(), b"session-id", &signature).is_some()); + assert!(verify( + host.public_key().key_data().rsa().unwrap(), + b"different-session", + &signature + ) + .is_none()); + assert!(verify(other.public_key().key_data().rsa().unwrap(), b"session-id", &signature).is_none()); + assert!(sign(&key, b"session-id", algorithm, 6 - flags).is_none()); + } + assert!(sign(&key, b"session-id", b"ssh-rsa", 0).is_none()); + // The SSH signature parser rejects legacy SHA-1 before verification. + assert!(Signature::new(Algorithm::Rsa { hash: None }, vec![0; 256]).is_err()); + } +} diff --git a/crates/desktop-core/src/zoom.rs b/crates/desktop-core/src/zoom.rs index 737d235ba..6a3fb5a71 100644 --- a/crates/desktop-core/src/zoom.rs +++ b/crates/desktop-core/src/zoom.rs @@ -4,10 +4,10 @@ //! than a style choice. Tauri's built-in `zoom_hotkeys_enabled` injects a page //! script that invokes `plugin:webview|set_webview_zoom`, so it only works on //! a window that has been granted that command — and `apps/client/desktop`'s -//! `main` window is granted exactly ONE command on purpose, which opens a page -//! in the system browser rather than resizing anything, because it shows a -//! control plane's own page from an origin this app cannot enumerate ahead of -//! time. +//! `main` window is granted a handful of narrow commands on purpose, none of +//! which resizes anything (one opens a page in the system browser, and since +//! 2026-10-14 four sign one-use SSH auths), because it shows a control plane's +//! own page from an origin this app cannot enumerate ahead of time. //! `WebviewWindow::set_zoom()` called from Rust goes nowhere near the IPC ACL, //! so the level below reaches that window without widening its surface by one //! command. (The polyfill also keeps its level in a page-local variable, which diff --git a/crates/desktop-core/tests/ssh_signer_openssh.rs b/crates/desktop-core/tests/ssh_signer_openssh.rs new file mode 100644 index 000000000..f39d68d25 --- /dev/null +++ b/crates/desktop-core/tests/ssh_signer_openssh.rs @@ -0,0 +1,280 @@ +//! Explicit real-wire check: requires local OpenSSH client + sshd executables. +//! Run with `cargo test --test ssh_signer_openssh -- --ignored`. +#![cfg(unix)] +use base64::{engine::general_purpose::STANDARD, Engine}; +use rand::rngs::OsRng; +use ssh_key::{Algorithm, PrivateKey}; +use std::{ + fs, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + os::unix::{ + fs::PermissionsExt, + net::{UnixListener, UnixStream}, + }, + path::PathBuf, + process::{Child, Command, Stdio}, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; +use subshell_desktop_core::ssh_signer::{self, Owner, Signers}; +use wait_timeout::ChildExt; + +struct Fixture { + root: PathBuf, + daemon: Option, + stopped: Arc, + agent: Option>, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.stopped.store(true, Ordering::SeqCst); + let _ = UnixStream::connect(self.root.join("agent.sock")); + if let Some(agent) = self.agent.take() { + let _ = agent.join(); + } + if let Some(mut daemon) = self.daemon.take() { + let _ = daemon.kill(); + let _ = daemon.wait(); + } + let _ = fs::remove_dir_all(&self.root); + } +} + +#[test] +#[ignore = "requires an installed OpenSSH sshd; run explicitly for SSH signer changes"] +fn real_openssh_accepts_one_native_hostbound_signature() { + run_connection(false, false, "rsa-sha2-512"); +} + +#[test] +#[ignore = "requires an installed OpenSSH sshd; run explicitly for SSH signer changes"] +fn real_openssh_accepts_encrypted_rsa_and_rsa_host_keys() { + for algorithm in ["rsa-sha2-256", "rsa-sha2-512"] { + run_connection(true, false, algorithm); + run_connection(true, true, algorithm); + run_connection(false, true, algorithm); + } +} + +fn run_connection(rsa_private: bool, rsa_host: bool, algorithm: &str) { + let root = std::env::temp_dir().join(format!( + "subshell-native-wire-{}", + ssh_signer::random_id().replace('/', "_") + )); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let mut fixture = Fixture { + root: root.clone(), + daemon: None, + stopped: Arc::new(AtomicBool::new(false)), + agent: None, + }; + let generate = |name: &str, password: &str| { + let path = root.join(name); + let status = Command::new("ssh-keygen") + .args(["-q", "-t", "rsa", "-b", "2048", "-N", password, "-f"]) + .arg(&path) + .status() + .unwrap(); + assert!(status.success()); + let loaded = ssh_signer::load_key(&path).unwrap(); + if password.is_empty() { + loaded + } else { + ssh_signer::unlock_key(&loaded, password).unwrap() + } + }; + let host = if rsa_host { + generate("generated-host", "") + } else { + PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap() + }; + let private = if rsa_private { + generate("generated-client", "test-passphrase") + } else { + PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap() + }; + ssh_signer::host_key(&STANDARD.encode(host.public_key().to_bytes().unwrap())).unwrap(); + fs::write( + root.join("hostkey"), + host.to_openssh(ssh_key::LineEnding::LF).unwrap().as_bytes(), + ) + .unwrap(); + fs::set_permissions(root.join("hostkey"), fs::Permissions::from_mode(0o600)).unwrap(); + let public = private.public_key().to_openssh().unwrap(); + fs::write(root.join("authorized_keys"), &public).unwrap(); + fs::write(root.join("identity.pub"), &public).unwrap(); + let user_output = Command::new("id").arg("-un").output().unwrap(); + assert!(user_output.status.success()); + let user = String::from_utf8(user_output.stdout).unwrap().trim().to_owned(); + let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + fs::write( + root.join("known_hosts"), + format!("[127.0.0.1]:{port} {}\n", host.public_key().to_openssh().unwrap()), + ) + .unwrap(); + let config = format!("Port {port}\nListenAddress 127.0.0.1\nAddressFamily inet\nHostKey {}\nPidFile {}\nAuthorizedKeysFile {}\nStrictModes no\nUsePAM no\nUseDNS no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\nPermitRootLogin yes\nPrintMotd no\nLogLevel VERBOSE\n",root.join("hostkey").display(),root.join("sshd.pid").display(),root.join("authorized_keys").display()); + fs::write(root.join("sshd_config"), config).unwrap(); + let daemon_path = ["/usr/sbin/sshd", "/usr/local/sbin/sshd", "/opt/homebrew/sbin/sshd"] + .into_iter() + .find(|path| std::path::Path::new(path).is_file()) + .expect("install sshd before running this explicit test"); + fixture.daemon = Some( + Command::new(daemon_path) + .args(["-D", "-e", "-f"]) + .arg(root.join("sshd_config")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(fs::File::create(root.join("sshd.log")).unwrap()) + .spawn() + .unwrap(), + ); + let deadline = Instant::now() + Duration::from_secs(5); + while TcpStream::connect(("127.0.0.1", port)).is_err() { + if Instant::now() > deadline || fixture.daemon.as_mut().unwrap().try_wait().unwrap().is_some() { + panic!( + "isolated sshd did not start: {}", + fs::read_to_string(root.join("sshd.log")).unwrap() + ); + } + std::thread::sleep(Duration::from_millis(20)); + } + let listener = UnixListener::bind(root.join("agent.sock")).unwrap(); + let owner = Owner { + window: "main".into(), + origin: "https://plane.example".into(), + }; + let mut signers = Signers::default(); + let selected = signers + .insert( + owner.clone(), + user.clone(), + host.public_key().to_bytes().unwrap(), + private, + Instant::now() + ssh_signer::LIFETIME, + ) + .unwrap(); + let selected_id = selected.id.clone(); + let signers = Arc::new(Mutex::new(signers)); + let state = signers.clone(); + let stopped = fixture.stopped.clone(); + let signed = Arc::new(AtomicUsize::new(0)); + let signed_count = signed.clone(); + fixture.agent = Some(std::thread::spawn(move || { + for connection in listener.incoming() { + if stopped.load(Ordering::SeqCst) { + break; + } + let mut connection = connection.unwrap(); + connection.set_read_timeout(Some(Duration::from_millis(250))).unwrap(); + loop { + if stopped.load(Ordering::SeqCst) { + return; + } + let mut length = [0u8; 4]; + match connection.read_exact(&mut length) { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue + } + Err(_) => break, + } + let length = u32::from_be_bytes(length) as usize; + assert!(length <= ssh_signer::MAX_BYTES); + let mut payload = vec![0u8; length]; + connection.read_exact(&mut payload).unwrap(); + let reply = state + .lock() + .unwrap() + .request(&owner, &selected.id, &STANDARD.encode(&payload)); + let response = STANDARD.decode(reply.response).unwrap(); + if response.first() == Some(&14) { + signed_count.fetch_add(1, Ordering::SeqCst); + } + connection.write_all(&(response.len() as u32).to_be_bytes()).unwrap(); + connection.write_all(&response).unwrap(); + } + } + })); + let mut ssh = Command::new("ssh"); + if rsa_private { + ssh.arg("-o").arg(format!("PubkeyAcceptedAlgorithms={algorithm}")); + } + if rsa_host { + ssh.arg("-o").arg(format!("HostKeyAlgorithms={algorithm}")); + } + let mut ssh = ssh + .args([ + "-F", + "/dev/null", + "-o", + "BatchMode=yes", + "-o", + "IdentitiesOnly=yes", + "-o", + "PubkeyAuthentication=host-bound", + "-o", + "PreferredAuthentications=publickey", + "-o", + "ForwardAgent=no", + "-o", + "GlobalKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=yes", + "-o", + "ConnectTimeout=5", + ]) + .arg("-o") + .arg(format!("IdentityAgent={}", root.join("agent.sock").display())) + .arg("-o") + .arg(format!("UserKnownHostsFile={}", root.join("known_hosts").display())) + .arg("-i") + .arg(root.join("identity.pub")) + .arg("-p") + .arg(port.to_string()) + .arg(format!("{user}@127.0.0.1")) + .arg("printf local-key-ok") + .stdin(Stdio::null()) + .stdout(fs::File::create(root.join("ssh.out")).unwrap()) + .stderr(fs::File::create(root.join("ssh.err")).unwrap()) + .spawn() + .unwrap(); + let status = ssh.wait_timeout(Duration::from_secs(10)).unwrap(); + if status.is_none() { + let _ = ssh.kill(); + let _ = ssh.wait(); + } + assert!( + status.is_some_and(|status| status.success()), + "OpenSSH login failed: {}\nsshd: {}", + fs::read_to_string(root.join("ssh.err")).unwrap(), + fs::read_to_string(root.join("sshd.log")).unwrap() + ); + assert_eq!(fs::read_to_string(root.join("ssh.out")).unwrap(), "local-key-ok"); + assert_eq!(signed.load(Ordering::SeqCst), 1); + // The second connection gets no identity: the native private key has + // already been released, even while the fixture's agent socket lives. + assert!( + signers + .lock() + .unwrap() + .request( + &Owner { + window: "main".into(), + origin: "https://plane.example".into() + }, + &selected_id, + "Cw==" + ) + .done + ); +} diff --git a/docker/desktop-builder.Dockerfile b/docker/desktop-builder.Dockerfile index 265027be4..aa2d88eb7 100644 --- a/docker/desktop-builder.Dockerfile +++ b/docker/desktop-builder.Dockerfile @@ -25,6 +25,7 @@ ENV DEBIAN_FRONTEND=noninteractive # tmux — this is now the CI image too (test.yml), and both the # TmuxRunner suite and the e2e session-lifecycle specs drive a # REAL tmux server +# cmake, clang, libclang-dev — build AWS-LC and its Rust bindings for native SSH RSA # jq — the compiled-binary smoke reads package.json's version with it # # Deliberately NOT installed: patchelf. It is a linuxdeploy requirement, and @@ -38,6 +39,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libssl-dev \ libgtk-3-dev \ build-essential \ + cmake \ + clang \ + libclang-dev \ pkg-config \ ca-certificates \ curl \ diff --git a/docs/security.md b/docs/security.md index 090198b48..4b60e64ae 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1123,6 +1123,101 @@ consequences are: ### SSH surfaces and credential boundaries +**One-use keys from the desktop machine.** The authentication form's +**Use a key from my machine** option uses a native signer in either desktop +app. It never uploads the private key. The remote page receives an opaque +handle and public SSH-agent replies; encrypted-key passphrases are entered in +a separately bundled window with its own narrow capability. A native +confirmation shows the destination, username, port and host-key fingerprint +before opening the file picker at `~/.ssh`. The operator must independently +verify an unfamiliar fingerprint: a scan through the connecting node is not +independent evidence of identity. + +The native signer checks the calling window's committed origin against the +client app's pinned control plane or the server app's trusted-origin guard. +It requires HTTPS except on loopback and binds a random handle to that +window and origin. It verifies the destination's signature on +the SSH session identifier before accepting a strictly parsed host-bound +user-authentication request for the chosen username and key. Arbitrary +signing, ordinary unbound public-key authentication, forwarding, different +hosts or users, and a second signature are refused. This requires a client +and destination that support OpenSSH's host-bound authentication extension. +The native picker accepts OpenSSH-format Ed25519, ECDSA and RSA keys. Native +RSA signing and host-proof verification use AWS-LC through the OpenSSL Rust +bindings; `ssh-key` parses the file with its optional RustCrypto RSA backend +disabled, keeping the crate affected by RUSTSEC-2023-0071 out of the active +dependency graph. RSA moduli are bounded to 2,048–8,192 bits and public +exponents to 33 bits. AWS-LC checks the private components before a signer is +created and performs blinded, constant-time private exponentiation; application +code does not derive CRT parameters. Its default allocator cleanses released +key allocations. SHA-256 and SHA-512 flags must match the requested algorithm; +SHA-1 is refused for both signing and host proofs. Browser RSA uses Web Crypto. No jump hosts or host-key aliases are supported here. + +The signer drops its key before returning its one signature, and has an +independent 90-second lifetime. Cancellation, navigation, and window closure +also revoke native access. Mutable native input buffers are zeroized, but +neither allocator copies nor OS swap/crash dumps can be promised erased. The +original selected file is never deleted or changed. The web page cannot read +arbitrary files or request a raw signature through these commands. + +**One-use keys in a browser.** The same option supports a browser signer in +secure contexts (HTTPS or loopback). Its risk warning and explicit trust +acknowledgment appear only when selected, before enabling the file picker. +The browser cannot default that picker to `~/.ssh`. The page holds a File +reference and an uncontrolled passphrase input until destination confirmation; +a dedicated worker then reads and imports the key. No private key or +passphrase is uploaded, persisted, or included in launch metadata. + +The worker strictly parses OpenSSH files, including bcrypt/AES-CTR encrypted +keys, and uses non-extractable WebCrypto signing keys. It verifies the host's +session-binding proof and accepts exactly one host-bound authentication for +the confirmed host, username, key and session. RSA uses SHA-2. Parent and +worker lifetimes are bounded at 90 seconds, and expiry is checked again after +asynchronous cryptographic operations. Signing completion, cancellation, +navigation, errors and timeout terminate the worker. Mutable input buffers +are cleared. A failed or declined attempt retains the selected File reference, +passphrase input, and website acknowledgment for retry. The passphrase stays +in the password input, outside form state and caches; each worker receives a +disposable byte copy. Success, switching authentication, closing the form, +and navigation clear the selection and input; choosing another key also +clears the passphrase. Before Start, changing the destination retains the file +selection; destination confirmation still precedes import and scopes signing +to the approved destination. Changing the destination cancels any active attempt. + +This worker is a lifetime boundary, not protection against malicious page +code or a compromised server distributing that code. Either could steal the +selected file or passphrase before import, or replace the signer. The warning +requires trusting the website and its operator and explicitly says complete +browser memory erasure cannot be guaranteed. Garbage collection, immutable +strings, browser internals, swap and crash dumps remain outside that promise. +The resulting SSH connection still grants the connecting node the account's +session authority; one signature does not limit what that connection can do. + +The server routes public agent traffic through cookie-only `/api/ssh/local-key` +operations. Preparations are user-and-session scoped, expire after five +minutes, and can be consumed once for the exact resolved destination, node, +and pane. Every exchange checks current node launch permission, SSH readiness, +and the same session. Messages are capped at 64 KiB; each node bridge permits +one agent connection, one outstanding request, at most eight requests and a +90-second lifetime. Replies are sequence-bound. Node transport uses the +existing signed commands and encrypted link; the server sees public host +proofs and authentication signatures, but no private keys or passphrases. +The local host-bound verifier is the signing authorization boundary, subject +to the browser code trust limitation above. + +The launch pins the destination host key, disables password fallback and +ambient identity files, and requires host-bound public-key authentication. +If initial local-key authentication fails before a signature is issued, the +browser deletes the subshell created by that attempt and allows retry while +retaining the selected file and passphrase input (the native path re-picks +both). A failed cleanup blocks retry and reports that the subshell still needs +removal. After a signature +has been issued, a transport failure leaves the outcome uncertain, so the +client preserves the subshell rather than risking a working connection. +Its saved metadata records that authentication was single-use. A later +**Set up Subshell here** request refuses instead of falling back to the +connecting node's credentials. Restart still requires a new SSH connection. + The connection form is **New subshell → SSH terminal**. **Settings → SSH** manages saved and recent destinations; it has no host-key editor. Node Overview's **SSH → Allow SSH** switch controls outgoing connections and supplying agent @@ -2085,17 +2180,19 @@ below. ### The window that loads someone else's page -The split is the boundary, and it is a split by window KIND: every command -that touches a CLI, a config file, a service manager or the filesystem is -granted to BUNDLED pages alone. Each app has **two** windows now, one remote, -one bundled. The server app's `console` was deleted by spec 2026-09-12 and its +The split is the boundary, and it is a split by window KIND: general commands +that touch a CLI, a config file, a service manager or the filesystem are +granted to BUNDLED pages alone. The SSH signer is a narrow exception: a remote +page can request native confirmation and a native file picker, but cannot name +a file or receive its contents. Each app has a remote main window, a bundled +management window, and a temporary bundled SSH-key unlock window. The server app's `console` was deleted by spec 2026-09-12 and its management surface moved into the SPA, leaving the remote SPA page plus one bundled ASSISTANT (`capabilities/wizard.json`) that owns first run, recovery, update and reset. What differs between the apps is how much the remote window gets, and the difference follows from whether its origin can be known ahead of time. -**Subshell Server: two trusted origins, seven commands.** Its `main` window +**Subshell Server: two trusted origins, eleven commands.** Its `main` window opens on `Probe::window_origin`: the instance's configured `APP_BASE_URL` when that is https, and the SPA served by the very server this app manages at `http://127.0.0.1:` otherwise. The https case is why the default @@ -2107,25 +2204,26 @@ was the whole story; the window could go nowhere else, and the capability's loopback scope was the boundary. It is not any more (spec 2026-09-18 § 15, §11.11a below): the window may NAVIGATE anywhere http(s), because a control plane behind an OAuth proxy cannot be signed into otherwise, and what bounds -the seven commands is a runtime guard instead. +the commands is a runtime guard instead. The four SSH signer commands also +require HTTPS except on loopback and bind each selection to its committed page. | Gate | What it does | | --- | --- | -| `capabilities/main.json` | grants only `desktop_open_assistant`, `desktop_shell_ready`, `desktop_notify`, `desktop_open_in_browser`, `desktop_permissions`, `desktop_app_update`, `desktop_set_supervision` and window dragging, to one window (`local: false`, `windows: ["main"]`). Its `remote.urls` is a WILDCARD and no longer bounds anything | +| `capabilities/main.json` | grants only `desktop_open_assistant`, `desktop_shell_ready`, `desktop_notify`, `desktop_open_in_browser`, `desktop_permissions`, `desktop_app_update`, `desktop_set_supervision`, the four `desktop_ssh_key_*` commands and window dragging, to one window (`local: false`, `windows: ["main"]`). Its `remote.urls` is a WILDCARD and no longer bounds anything | | `src/trust.rs` | refuses every command invoked from that window unless the page is on one of TWO origins: this machine's loopback (either spelling, http, any port) or the instance's configured `APP_BASE_URL`. Recomputed for every page the window COMMITS to, so a redirect chain cannot leave it true | | `Probe::origin` | builds the loopback URL the app PROBES to decide whether the server is READY: a validated port and a loopback host, never `APP_BASE_URL`'s own scheme or port. Since 2026-09-19 it is NOT what the window opens on (`Probe::window_origin` is, above); "Open in browser" joins its path onto the origin the window has COMMITTED to (`control.rs:371,507-516,2667-2676,2929-2942`) | | `open_main` | points the window at those same two origins and refuses every other, so reaching a third one is always a page's doing, never the app's | | `on_navigation` | refuses any scheme the OS would act on (`file:`, a custom handler) and nothing else. It arms nothing: it runs at REQUEST time and fires for subframes, so a navigation that never commits, and an embedded iframe, would otherwise move a flag that belongs to the document on screen | | `on_page_load` | recomputes the flag, on `PageLoadEvent::Started`, raised from `didCommitNavigation:` (macOS) and `LoadEvent::Committed` (GTK), main-frame only, after the document is really this window's | -Six of the seven are chosen for what they cannot do: raise the assistant at a +Six of the original seven are chosen for what they cannot do: raise the assistant at a named screen, drop this app's own title bar, display one fixed-shape notification, open a page of THIS server in the system browser (a path only; see below), read this app's own macOS permission states (no argument at all; see the paragraph after the table), and read this app's own two update version facts (no argument, no fetch; see the paragraph after that). The seventh, `desktop_set_supervision`, -**does** drive the CLI and is the one deliberate exception; its accounting, including the three +**does** drive the CLI and is the original deliberate exception; its accounting, including the three caveats that make it honest, is §11.11 below. The count is pinned by `ui/src/__tests__/ipc-acl.test.ts` and again in Rust by `control.rs`, because "a few harmless ones" is how a boundary erodes. What that file used to pin @@ -2188,7 +2286,7 @@ must know whether an update is waiting before anyone presses anything, and only the app knows the version of its own binary. The server can tell a page what SERVER build is running; it cannot see the `.app` wrapped around it. -**Subshell Client: any origin, and therefore ONE command.** Its `main` window +**Subshell Client: configurable origin, five narrow commands.** Its `main` window loads a control plane's own UI, and a control plane can live on any host: a LAN address, a VPN name, a public hostname. That origin cannot be enumerated in a capability file, and that has not changed. What changed on 2026-09-14 is that @@ -2197,15 +2295,19 @@ so the boundary moved one level in rather than away: the capability's scope is a wildcard (`http://*:*`, `https://*:*`; `http://*` alone does not match a non-default port in the `urlpattern` crate Tauri 2.11.5 uses, which would silently exclude the default `:3080` deployment), and the narrowness lives in -the command's ARGUMENT instead. +the browser command's ARGUMENT instead. Four SSH signer commands now accompany +it. Unlike the browser command, those require the committed page to match +`PlanePin`, with HTTPS except on loopback, and require native approval before +loading a key. Their key scope and residual risks are described under +**Use a key from my machine** above. -The accounting, stated plainly: +The browser command's accounting, stated plainly: - **What it can do.** `desktop_open_in_browser` opens one page of the plane this window is already on, in the person's default browser. The page supplies a PATH; the ORIGIN is `windows.rs`'s `PlanePin`, the same value that - window's navigation guard already enforces. So the widest thing an XSS in a - control plane's SPA gains is opening a page of that same plane, which the + window's navigation guard already enforces. So this command lets an XSS in a + control plane's SPA open a page of that same plane, which the person can do by typing the address. - **What it cannot do.** Name a host. The path must begin with `/`, must not begin with `//` (protocol-relative is another HOST), must contain no `://`, @@ -2297,15 +2399,16 @@ same one this subsection has stated all along, applied to the second app: command that changes this machine: `node_reset`, `node_enroll`, `node_unenroll`, `node_service`, `node_install_agent`, and the plane add/remove verbs (`apps/client/desktop/src-tauri/capabilities/node.json`). -Its remote window still holds its single path-argument command and nothing -more (§ above). The boundary is window KIND in both apps, not app identity, +Its remote window holds the path-argument browser command and four native +SSH signer commands (§ above). The signer cannot expose a key or accept a path. The boundary is window KIND in both apps, not app identity, which is exactly why the second app's privileged surface belongs in this model's prose and not only in its capability file. Two limits worth stating rather than implying. Each `csp` in `tauri.conf.json` applies to that app's bundled page **only**: a remote window's page carries whatever CSP its origin sends, so an XSS in the server app's SPA reaches those -seven commands, and in the client app exactly one. And each app's ACL manifest is +eleven commands, and in the client app five. The signing commands require +separate native consent and enforce destination and lifetime limits. And each app's ACL manifest is what makes any of this apply at all: Tauri leaves app commands ungated for local windows when no manifest exists. @@ -2949,7 +3052,8 @@ What an XSS in the SPA can now do that it could not before: flip the machine to app mode, so the server dies when the app quits, or back. It cannot reach `desktop_reset`, `desktop_setup`, `desktop_service` or any other CLI verb; those stay assistant-only, and `ipc-acl.test.ts` (TS) and `control.rs` (Rust) -both pin `main` at exactly seven app commands plus +both pin `main` at exactly eleven app commands (the original seven and four +SSH signer commands) plus `core:window:allow-start-dragging`. The dialog on the page is a confirmation for the PERSON, and is not counted as a defence against the page. @@ -3044,7 +3148,8 @@ app stops the server and keeps every live pane. **Operator's decision, 2026-09-18, taken with the trade stated** (spec 2026-09-18 § 15). Subshell Server's `main` window may now load and follow any -http(s) address, and it keeps all seven commands. What forced it: a control +http(s) address, and it kept the original seven commands. Four native SSH +signer commands were added later with the additional restrictions above. What forced it: a control plane behind an OAuth proxy could not be shown in this app at all. The window refused any URL whose origin was not the one it opened with, and a proxied sign-in is precisely a bounce to an identity provider on a third origin and @@ -3055,7 +3160,8 @@ back. Subshell Client was unblocked the same way for the same report static and an instance's address is a config value, so `remote.urls` could never name it; the scope is a wildcard now, as the client's already was. That makes the scope stop being a boundary, and unlike the client, which grants its plane -window ONE path-only command, this window holds seven, one of which switches +window a path-only browser command, this window holds seven management commands +plus the four SSH signer commands shared by both apps. One management command switches who runs the server. So: - **The window may NAVIGATE anywhere http(s)**, which is what makes a proxied @@ -3066,12 +3172,13 @@ who runs the server. So: main-frame load COMMITS (`on_page_load`), so an embedded iframe and a navigation that never arrives both leave it exactly as the document on screen earned it. -- **The seven commands answer only while the page is on a TRUSTED origin**: +- **The commands answer only while the page is on a TRUSTED origin**: this machine's loopback (either spelling, http, any port: exactly what the old scope named) or the instance's configured `APP_BASE_URL`. The check (`src-tauri/src/trust.rs`) runs at the invoke handler, in front of every app command, keyed on the calling webview's label, and it is uniform across all - seven. Three of them take no argument by design (`desktop_permissions` takes + commands. The SSH signer imposes its additional origin checks too. Three of + the original commands take no argument by design (`desktop_permissions` takes nothing at all), so a per-command check would have had to widen the very signatures §11.11 relies on being narrow; and a rule with exceptions is a rule someone has to re-derive. @@ -3079,7 +3186,7 @@ who runs the server. So: navigation REQUEST.** It is recomputed for every document the window commits to, so a redirect chain that ends elsewhere cannot leave it true, and it is cleared when the window is destroyed. A navigation that is merely asked for - moves nothing, which is what stops an untrusted page arming all seven + moves nothing, which is what stops an untrusted page arming the commands by aiming at a loopback port that refuses the connection, and stops an embedded iframe arming them without the top frame moving at all. One second writer exists, and it only ever JUDGES the committed URL: when an diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 61219e74d..a90d3d059 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -25,6 +25,9 @@ backend, `NODE_ENV=development`, `SUBSHELL_TEST_MODE=false`) with: with a running `turbo watch dev`. - a fresh `mkdtemp` dir for `DATABASE_PATH` and `SUBSHELL_SERVER_DATA_DIR`, wiped on teardown, so every run is a pristine first-boot instance. +- an isolated `HOME` with an empty `.ssh/config`, so SSH resolution uses an + explicit config instead of inheriting user or system settings such as Ubuntu's + environment forwarding defaults. - `PI_PATH` pointed at `e2e/stub/pi`, a loop-forever script that answers `--version` for detection, so the harness registry reports `pi` installed and subshells launch a real tmux pane without any agent CLI. @@ -150,6 +153,9 @@ lifecycle and cross-node cryptographic relay coverage. Spec 24 covers the browser’s first-connection confirmation and cancellation; spec 23-ssh-relay checks real discovery and acceptance before relay authentication. +Spec 25 runs the browser signer against a real sshd on the host (not +Testcontainers): it needs `ssh`, `sshd` and `ssh-keygen` present, and it skips +loudly naming the missing binaries where they are not. Run all SSH specs together with `01-setup-wizard.spec.ts` (which seeds the account for spec 24). CI mounts its Docker socket into the e2e job so Testcontainers can start the same destination fixture. @@ -162,7 +168,7 @@ a disposable SSH agent, and a Testcontainers destination. Docker and the Playwright Chromium browser are required. The normal `E2E_PORT_*` overrides apply when another suite is running. -The script writes three cropped PNGs to `apps/docs/public/screenshots/`, using +The script writes two cropped PNGs to `apps/docs/public/screenshots/`, using real form state and a real first-connection refusal. It cancels confirmation and checks that no destination trust was saved, then cleans up its processes and temporary state. Review the images and update their intrinsic dimensions @@ -178,3 +184,9 @@ Review every capture, update intrinsic dimensions in the referring MDX and `apps/website/components/feature-showcase.tsx`, and check the captions against the actual controls. The example address and QR code are public sample values; no setup key is generated. Remove obsolete assets and their references together. + +Both capture scripts use `screenshot.ts` for a 12px page-colored margin and 2× +pixel density. The helper hides unrelated UI and modal backdrops only during +capture; the photographed controls and their content remain unchanged. Keep +PNG dimensions and the lossless marketing WebP copies at native resolution. +The sites supply the same single border around each image, without extra padding. diff --git a/e2e/capture-docs.ts b/e2e/capture-docs.ts index 55643fb96..72925e9fb 100644 --- a/e2e/capture-docs.ts +++ b/e2e/capture-docs.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { chromium, expect, type Locator, type Page, request } from "@playwright/test"; import { BASE_URL, PORTS } from "./ports"; +import { captureScreenshot } from "./screenshot"; import { startStack, stopStack } from "./stack"; // Real UI, real API records, and real terminal commands on a disposable stack. @@ -16,21 +17,7 @@ const cleanup: (() => Promise | void)[] = [() => rmSync(project, { recursi const dimensions: Record = {}; async function capture(page: Page, name: string, first: Locator, last = first) { - await first.scrollIntoViewIfNeeded(); - const a = await first.boundingBox(); - const b = await last.boundingBox(); - if (!a || !b) throw new Error(`Missing screenshot bounds: ${name}`); - const x = Math.max(0, Math.floor(Math.min(a.x, b.x) - 12)); - const y = Math.max(0, Math.floor(Math.min(a.y, b.y) - 12)); - const width = Math.ceil(Math.max(a.x + a.width, b.x + b.width) - x + 12); - const height = Math.ceil(Math.max(a.y + a.height, b.y + b.height) - y + 12); - await page.screenshot({ - path: path.join(output, `${name}.png`), - clip: { x, y, width, height }, - animations: "disabled", - }); - dimensions[name] = { width, height }; - console.log(`${name}: ${width} x ${height}`); + dimensions[name] = await captureScreenshot(page, path.join(output, `${name}.png`), first, last); } try { @@ -100,6 +87,7 @@ try { storageState: await api.storageState(), viewport: { width: 1180, height: 1200 }, colorScheme: "dark", + deviceScaleFactor: 2, }); const page = await context.newPage(); page.setDefaultTimeout(15000); @@ -167,6 +155,8 @@ try { await page.setViewportSize({ width: 1280, height: 540 }); await page.goto(`/workspaces/${workspace.id}`); + // Leave room around the full-bleed workspace for the same capture margin. + await page.addStyleTag({ content: "main { margin: 12px; max-width: calc(100% - 24px); }" }); const tab = page.getByRole("tab", { name: "Test results", exact: true }); await expect(tab).toBeVisible(); const source = await tab.boundingBox(); @@ -184,11 +174,13 @@ try { await expect.poll(async () => (await api.get(`/api/subshells/${panes[1].id}/log`)).text()).toContain("2 pass"); await page.waitForTimeout(500); // Keep the workspace header and actual output, omitting empty terminal space. - const workspaceBounds = await page.locator("main").boundingBox(); - if (!workspaceBounds) throw new Error("Workspace bounds unavailable"); - const clip = { ...workspaceBounds, height: 310 }; - await page.screenshot({ path: path.join(output, "workspace-layout.png"), clip, animations: "disabled" }); - dimensions["workspace-layout"] = { width: clip.width, height: clip.height }; + dimensions["workspace-layout"] = await captureScreenshot( + page, + path.join(output, "workspace-layout.png"), + page.locator("main"), + page.locator("main"), + 310, + ); for (const name of [ "workspace-layout", "prompt-stack-members", diff --git a/e2e/capture-ssh-docs.ts b/e2e/capture-ssh-docs.ts index f4ea00bb7..ba45063fa 100644 --- a/e2e/capture-ssh-docs.ts +++ b/e2e/capture-ssh-docs.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { chromium, expect, type Locator, type Page, request } from "@playwright/test"; import { startContainerSshFixture } from "./fixtures/sshd-container"; import { BASE_URL, PORTS } from "./ports"; +import { captureScreenshot } from "./screenshot"; import { shortTmuxBase, startStack, stopStack } from "./stack"; import { startNode } from "./stub/client"; import { sweepTmux } from "./stub/relay-spec"; @@ -17,16 +18,7 @@ const output = path.resolve(import.meta.dirname, "../apps/docs/public/screenshot const cleanup: (() => Promise | void)[] = [() => rmSync(root, { recursive: true, force: true })]; async function capture(page: Page, name: string, first: Locator, last = first) { - await first.scrollIntoViewIfNeeded(); - const a = await first.boundingBox(); - const b = await last.boundingBox(); - if (!a || !b) throw new Error(`Missing screenshot bounds: ${name}`); - const x = Math.min(a.x, b.x) - 16; - const y = Math.min(a.y, b.y) - 16; - const width = Math.max(a.x + a.width, b.x + b.width) - x + 16; - const height = Math.max(a.y + a.height, b.y + b.height) - y + 16; - await page.screenshot({ path: path.join(output, name), clip: { x, y, width, height }, animations: "disabled" }); - console.log(`${name}: ${Math.round(width)} × ${Math.round(height)}`); + await captureScreenshot(page, path.join(output, name), first, last); } try { @@ -100,6 +92,7 @@ try { storageState: await api.storageState(), viewport: { width: 1000, height: 1400 }, colorScheme: "dark", + deviceScaleFactor: 2, }); const page = await context.newPage(); page.setDefaultTimeout(20_000); @@ -115,12 +108,6 @@ try { page.getByRole("combobox", { name: "Connect from" }).locator(".."), page.getByLabel("Remember this destination").locator("../.."), ); - await capture( - page, - "ssh-authentication.png", - page.locator("fieldset").filter({ has: page.getByRole("radio", { name: "Enter a password when connecting" }) }), - ); - await page.getByLabel("SSH destination", { exact: true }).fill("build"); await page.getByLabel("Remember this destination", { exact: true }).uncheck(); await page.getByRole("radio", { name: "Use another node’s keys", exact: true }).check(); diff --git a/e2e/screenshot.ts b/e2e/screenshot.ts new file mode 100644 index 000000000..0b84557a8 --- /dev/null +++ b/e2e/screenshot.ts @@ -0,0 +1,52 @@ +import type { Locator, Page } from "@playwright/test"; + +/** Keep real UI surfaces, with one page-colored margin and no unrelated modal backdrop. */ +export async function captureScreenshot( + page: Page, + file: string, + first: Locator, + last = first, + contentHeight?: number, +) { + await first.scrollIntoViewIfNeeded(); + await page.evaluate(() => document.fonts.ready); + // Let opening and scrolling transitions settle before measuring the crop. + await page.waitForTimeout(250); + const a = await first.boundingBox(); + const b = await last.boundingBox(); + if (!a || !b) throw new Error(`Missing screenshot bounds: ${file}`); + const lastElement = await last.elementHandle(); + await first.evaluate((element, end) => { + let surface = element; + while (end && !surface.contains(end) && surface.parentElement) surface = surface.parentElement; + surface.setAttribute("data-docs-capture", ""); + }, lastElement); + const margin = 12; + const x = Math.max(0, Math.floor(Math.min(a.x, b.x) - margin)); + const y = Math.max(0, Math.floor(Math.min(a.y, b.y) - margin)); + const width = Math.ceil(Math.max(a.x + a.width, b.x + b.width) - x + margin); + const height = contentHeight + ? contentHeight + 2 * margin + : Math.ceil(Math.max(a.y + a.height, b.y + b.height) - y + margin); + try { + await page.screenshot({ + path: file, + clip: { x, y, width, height }, + animations: "disabled", + scale: "device", + // This only isolates the photographed surface; its controls and content + // are untouched. Every capture retains the application's page background. + style: `body * { visibility: hidden !important; } + [data-docs-capture], [data-docs-capture] * { visibility: visible !important; } + ${contentHeight ? `[data-docs-capture] { clip-path: inset(0 0 calc(100% - ${contentHeight}px) 0); }` : ""}`, + }); + } finally { + await page.locator("[data-docs-capture]").evaluateAll((elements) => { + for (const element of elements) element.removeAttribute("data-docs-capture"); + }); + await lastElement?.dispose(); + } + const scale = await page.evaluate(() => window.devicePixelRatio); + console.log(`${file}: ${width * scale} x ${height * scale} pixels (${width} x ${height} CSS)`); + return { width: width * scale, height: height * scale }; +} diff --git a/e2e/stack.ts b/e2e/stack.ts index 55315745f..9df483d95 100644 --- a/e2e/stack.ts +++ b/e2e/stack.ts @@ -1,5 +1,5 @@ import { spawn, spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { BASE_URL, FAKE_IDP_URL, FAKE_REGISTRY_URL, PORTS } from "./ports"; @@ -121,6 +121,11 @@ async function waitForFakeIdp(idp: ReturnType, timeoutMs = 30_000) export async function startStack(): Promise { // Test-owned source/config paths must not retain macOS's standard /var -> /private/var alias. const dir = mkdtempSync(path.join(realpathSync(tmpdir()), "subshell-e2e-")); + const home = path.join(dir, "home"); + mkdirSync(path.join(home, ".ssh"), { recursive: true, mode: 0o700 }); + // An explicit config makes the resolver use ssh -F, excluding host defaults + // such as Ubuntu's SendEnv. Real users' SSH settings must not steer fixtures. + writeFileSync(path.join(home, ".ssh/config"), "", { mode: 0o600 }); chmodSync(STUB_PI, 0o755); // tmux only honours TMUX_TMPDIR when the directory already exists (it does // not mkdir the base itself on this build), so create it before the backend @@ -166,6 +171,7 @@ export async function startStack(): Promise { // ~/.config/subshell-server/config.env happens to carry // (SUBSHELL_EMERGENCY_PASSWORD, TRUSTED_ORIGINS, …) would leak in. SUBSHELL_SERVER_CONFIG_DIR: dir, + HOME: home, SERVER_PORT: String(PORTS.backend), HOST: "127.0.0.1", DATABASE_PATH: path.join(dir, "subshell.db"), diff --git a/e2e/tests/16-server-service.spec.ts b/e2e/tests/16-server-service.spec.ts index 899be4238..f5f5f746b 100644 --- a/e2e/tests/16-server-service.spec.ts +++ b/e2e/tests/16-server-service.spec.ts @@ -114,11 +114,9 @@ test.describe("server service page", () => { }); test("answers whether this server comes back, and never offers a switch a browser cannot mean", async ({ page }) => { - // Deliberately not "nothing is installed". The stack is hand-spawned, but - // it runs with the developer's own HOME, so whether a launchd plist or a - // systemd unit exists is a property of the machine running the suite — - // true on a laptop with Subshell Server installed, false in CI. Asserting - // either would be a test that passes in one place and fails in the other. + // Deliberately not "nothing is installed". The stack is hand-spawned and + // its HOME is isolated, but service-manager availability still depends on + // the machine running the suite. Don't assume one host's service state. // // What IS invariant is the pair that can actually regress: this page is a // BROWSER, so the choice must be absent, and whatever sentence it does diff --git a/e2e/tests/25-browser-ssh-key.spec.ts b/e2e/tests/25-browser-ssh-key.spec.ts new file mode 100644 index 000000000..6987c4aaf --- /dev/null +++ b/e2e/tests/25-browser-ssh-key.spec.ts @@ -0,0 +1,114 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { missingSshBins, type SshFixture, startSshFixture } from "../fixtures/sshd"; +import { ADMIN_STATE } from "./helpers"; + +test.use({ storageState: ADMIN_STATE }); +let root: string; +let fixture: SshFixture; +const passphrase = "disposable-browser-signing-fixture"; +const missing = missingSshBins(); +// A host without the OpenSSH binaries cannot run this suite. Skipping LOUDLY, +// naming the missing binaries at file level, is the sibling SSH specs' +// convention; a skip inside beforeAll reads as a pass to the reporter. +test.skip(missing.length > 0, `SSH binaries absent on this host (${missing.join(", ")}) - spec 25 needs a real sshd`); +test.beforeAll(async () => { + root = mkdtempSync(path.join(tmpdir(), "subshell-browser-ssh-")); + fixture = await startSshFixture(root); + execFileSync("ssh-keygen", ["-p", "-P", "", "-N", passphrase, "-f", fixture.trustedKeyPath]); +}); +test.afterAll(async () => { + await fixture?.stop(); + if (root) rmSync(root, { recursive: true, force: true }); +}); + +test("an encrypted browser key signs a real SSH login without uploading the key or passphrase", async ({ page }) => { + const enabled = await page.request.put("/api/nodes/local/ssh-enabled", { data: { on: true } }); + expect(enabled.ok()).toBe(true); + const outgoing: string[] = []; + page.on("request", (request) => { + const body = request.postData(); + if (body) outgoing.push(body); + }); + await page.goto("/connect"); + // The absence proof is only real once the form has rendered: count(0) on a + // still-loading page passes even if the warning were unconditional. + await expect(page.getByRole("radio", { name: "Use a key from my machine", exact: true })).toBeVisible(); + await expect(page.getByText(/Compromised page code could steal your key/)).toHaveCount(0); + await page.getByRole("combobox", { name: "Connect from" }).click(); + await page.getByRole("option", { name: "Server", exact: true }).click(); + await page.getByRole("radio", { name: "Use a key from my machine", exact: true }).check(); + await expect(page.getByText(/Compromised page code could steal your key/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Choose key…", exact: true })).toBeDisabled(); + await page.getByRole("checkbox", { name: "I trust this website and its operator", exact: true }).check(); + await page.getByLabel("SSH private key", { exact: true }).setInputFiles(fixture.trustedKeyPath); + await page.getByLabel("Key passphrase (if encrypted)", { exact: true }).fill("wrong-disposable-passphrase"); + // Choosing the key first must survive the later destination choice. + await page.getByLabel("SSH destination", { exact: true }).fill(`${fixture.user}@127.0.0.1:${fixture.port}`); + await expect(page.getByText(path.basename(fixture.trustedKeyPath), { exact: true })).toBeVisible(); + await expect(page.getByLabel("Key passphrase (if encrypted)", { exact: true })).toHaveValue( + "wrong-disposable-passphrase", + ); + await page.getByRole("button", { name: "Start SSH subshell", exact: true }).click(); + const confirmation = page.getByRole("dialog", { name: "Use this key to connect?", exact: true }); + await expect(confirmation.getByText(path.basename(fixture.trustedKeyPath), { exact: true })).toBeVisible(); + await expect(confirmation.getByText("Server", { exact: true })).toBeVisible(); + await expect( + confirmation.getByText(`ssh -p ${fixture.port} ${fixture.user}@127.0.0.1`, { exact: true }), + ).toBeVisible(); + await expect(confirmation.getByText(/SHA256:/)).toBeVisible(); + await confirmation.getByRole("button", { name: "Use key and connect", exact: true }).click(); + await expect(confirmation).toHaveCount(0); + const failure = page.getByRole("alert").filter({ hasText: "Couldn't unlock that SSH key" }); + await expect(failure).toBeVisible(); + await expect(failure).toContainText("Check its passphrase and try again."); + await expect(page.getByText(path.basename(fixture.trustedKeyPath), { exact: true })).toBeVisible(); + await expect( + page.getByRole("checkbox", { name: "I trust this website and its operator", exact: true }), + ).toBeChecked(); + await expect(page.getByLabel("Key passphrase (if encrypted)", { exact: true })).toHaveValue( + "wrong-disposable-passphrase", + ); + const errorBounds = await failure.boundingBox(); + const startBounds = await page.getByRole("button", { name: "Start SSH subshell", exact: true }).boundingBox(); + expect(errorBounds && startBounds && errorBounds.y < startBounds.y).toBe(true); + // Retry the same File without reopening the picker, with a fresh passphrase and approval. + await page.getByLabel("Key passphrase (if encrypted)", { exact: true }).fill(passphrase); + await page.getByLabel("SSH destination", { exact: true }).fill(`missing-browser-key-user@127.0.0.1:${fixture.port}`); + await page.getByRole("button", { name: "Start SSH subshell", exact: true }).click(); + await expect(confirmation).toBeVisible(); + const refusedLaunch = page.waitForResponse( + (response) => response.url().endsWith("/api/ssh/launch") && response.request().method() === "POST", + ); + await confirmation.getByRole("button", { name: "Use key and connect", exact: true }).click(); + const refused = await (await refusedLaunch).json(); + await expect(page.getByRole("alert").filter({ hasText: "SSH ended before requesting a signature" })).toBeVisible(); + await expect.poll(async () => (await page.request.get(`/api/subshells/${refused.subshell.id}`)).status()).toBe(404); + await expect(page.getByRole("button", { name: "Start SSH subshell", exact: true })).toBeEnabled(); + await expect(page.getByLabel("Key passphrase (if encrypted)", { exact: true })).toHaveValue(passphrase); + await expect(page.getByText(path.basename(fixture.trustedKeyPath), { exact: true })).toBeVisible(); + await page.getByLabel("SSH destination", { exact: true }).fill(`${fixture.user}@127.0.0.1:${fixture.port}`); + await page.getByRole("button", { name: "Start SSH subshell", exact: true }).click(); + await expect(confirmation).toBeVisible(); + const launched = page.waitForResponse( + (response) => response.url().endsWith("/api/ssh/launch") && response.request().method() === "POST", + ); + await confirmation.getByRole("button", { name: "Use key and connect", exact: true }).click(); + const response = await launched; + expect(response.ok(), await response.text()).toBe(true); + const created = await response.json(); + try { + await expect.poll(() => readFileSync(fixture.logPath, "utf8"), { timeout: 30_000 }).toContain("Accepted publickey"); + const bodies = outgoing.join("\n"); + expect(bodies).not.toContain(passphrase); + expect(bodies).not.toContain("wrong-disposable-passphrase"); + expect(bodies).not.toContain("OPENSSH PRIVATE KEY"); + expect(bodies).not.toContain(readFileSync(fixture.trustedKeyPath, "utf8").split("\n")[1]); + expect(bodies).toContain('"reply"'); + } finally { + await page.request.delete(`/api/subshells/${created.subshell.id}`); + } +}); diff --git a/packages/pane-runtime/src/index.ts b/packages/pane-runtime/src/index.ts index 5fd96d9bb..214499fe8 100644 --- a/packages/pane-runtime/src/index.ts +++ b/packages/pane-runtime/src/index.ts @@ -139,6 +139,7 @@ export { runBounded, } from "./run-bounded.js"; export { shellQuote } from "./shell.js"; +export { closeSshKeyBridges, machineSshKeyBridge, SSH_KEY_BRIDGE_REFUSALS } from "./ssh/key-bridge.js"; export { type AgentIdentitiesSeams, readMachineAgentIdentities } from "./ssh/relay/agent-identities.js"; export { machineSshExec, machineSshExecStatus, resetSshExecRunsForTests, type SshExecSeams } from "./ssh/relay/exec.js"; export { type HostKeySeams, readMachineHostKey, sshHostKeyCandidates } from "./ssh/relay/host-key.js"; diff --git a/packages/pane-runtime/src/plugin-adapter.ts b/packages/pane-runtime/src/plugin-adapter.ts index b1e2047ae..b7cb52253 100644 --- a/packages/pane-runtime/src/plugin-adapter.ts +++ b/packages/pane-runtime/src/plugin-adapter.ts @@ -84,6 +84,9 @@ export function adaptPlugin(manifest: SubshellManifest, plugin: SubshellPlugin): ...(manifest.hostEnv ? { hostEnv: [...manifest.hostEnv] } : {}), description: manifest.description, icon: manifest.icon, + // The refusal to be listed rides through as the manifest's own word; + // consumers (the web Agent picker) never special-case an id. + ...(manifest.pickable === false ? { pickable: false as const } : {}), installHint: manifest.install ?? { command: "", docsUrl: "" }, // A legacy field the plugin model replaces: "enabled by default" becomes // "installed" once the node owns its plugin set. Not worth a manifest diff --git a/packages/pane-runtime/src/plugin-report.ts b/packages/pane-runtime/src/plugin-report.ts index cc5b6c232..392847131 100644 --- a/packages/pane-runtime/src/plugin-report.ts +++ b/packages/pane-runtime/src/plugin-report.ts @@ -46,6 +46,7 @@ export async function buildPluginReports(dataDir: string): Promise { + for (const socket of sockets.splice(0)) socket.destroy(); + closeSshKeyBridges(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +async function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "keybridge-"))); + roots.push(root); + const id = crypto.randomUUID(); + await machineSshKeyBridge(root, { type: "ssh_key_bridge", op: "open", id, hostPin: pin }); + const path = buildAgentSocketPath(root, id); + const socket = connect(path); + sockets.push(socket); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + const exchange = (reply?: { seq: number; payload: string }) => + machineSshKeyBridge(root, { type: "ssh_key_bridge", op: "exchange", id, ...(reply ? { reply } : {}) }); + return { root, id, socket, path, exchange }; +} +function frame(bytes: number[]) { + const header = Buffer.alloc(4); + header.writeUInt32BE(bytes.length); + return Buffer.concat([header, Buffer.from(bytes)]); +} +async function tick() { + await new Promise((resolve) => setTimeout(resolve, 10)); +} + +it("routes a fragmented agent request once and closes after delivering the signature", async () => { + const { socket, exchange, path } = await fixture(); + expect(statSync(path).mode & 0o777).toBe(0o600); + const request = frame([13, 1, 2, 3]); + socket.write(request.subarray(0, 2)); + await tick(); + expect((await exchange()).request).toBeNull(); + socket.write(request.subarray(2)); + await tick(); + const pending = (await exchange()).request!; + expect(pending.payload).toBe(Buffer.from([13, 1, 2, 3]).toString("base64")); + expect((await exchange()).request).toEqual(pending); + const received = new Promise((resolve) => socket.once("data", resolve)); + expect((await exchange({ seq: pending.seq, payload: Buffer.from([14, 8, 9]).toString("base64") })).done).toBe(true); + expect(await received).toEqual(frame([14, 8, 9])); + expect((await exchange()).done).toBe(true); +}); +it("rejects mismatched sequence without sending a response", async () => { + const { socket, exchange } = await fixture(); + socket.write(frame([11])); + await tick(); + await expect(exchange({ seq: 99, payload: "Bg==" })).rejects.toThrow("out of sequence"); + expect((await exchange()).done).toBe(true); +}); +it("passes host-binding extensions and only then permits the next request", async () => { + const { socket, exchange } = await fixture(); + socket.write(frame([27, 1])); + await tick(); + const pending = (await exchange()).request!; + const response = new Promise((resolve) => socket.once("data", resolve)); + await exchange({ seq: pending.seq, payload: "Bg==" }); + expect(await response).toEqual(frame([6])); + socket.write(frame([11])); + await tick(); + expect((await exchange()).request?.seq).toBe(1); +}); +it("closes on pipelining, oversized framing, and disallowed key-mutation methods", async () => { + for (const payload of [Buffer.concat([frame([11]), frame([11])]), Buffer.from([0, 2, 0, 0]), frame([17])]) { + const { socket, exchange } = await fixture(); + socket.write(payload); + await tick(); + expect((await exchange()).done).toBe(true); + } +}); +it("serves one agent connection at a time: a second connect is destroyed, the first holds", async () => { + const { socket, path, exchange } = await fixture(); + const second = connect(path); + sockets.push(second); + const gone = new Promise<"closed" | "errored">((resolve) => { + second.once("close", () => resolve("closed")); + second.once("error", () => resolve("errored")); + }); + await new Promise((resolve, reject) => { + second.once("connect", resolve); + second.once("error", reject); + second.once("close", resolve); + }); + expect(await gone).toBe("closed"); + socket.write(frame([11])); + await tick(); + expect((await exchange()).request?.payload).toBe(Buffer.from([11]).toString("base64")); +}); +it("ends the bridge at the ninth agent request, not after eight", async () => { + const { socket, exchange } = await fixture(); + for (let i = 0; i < 8; i++) { + socket.write(frame([11])); + await tick(); + const pending = (await exchange()).request!; + expect(pending.seq).toBe(i); + await exchange({ seq: pending.seq, payload: "Bg==" }); + await tick(); + } + socket.write(frame([11])); + await tick(); + expect((await exchange()).done).toBe(true); +}); +it("rejects path traversal and invalid host pins before writing", async () => { + const { root } = await fixture(); + await expect( + machineSshKeyBridge(root, { type: "ssh_key_bridge", op: "open", id: "../outside", hostPin: pin }), + ).rejects.toThrow(); + await expect( + machineSshKeyBridge(root, { type: "ssh_key_bridge", op: "open", id: crypto.randomUUID(), hostPin: "bad\nHost *" }), + ).rejects.toThrow(); +}); diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts index cea23f7eb..0e1f25ac0 100644 --- a/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-resolve.test.ts @@ -505,3 +505,12 @@ describe("parseProxyHop", () => { expect(parseProxyHop("host:0", null)).toBeNull(); }); }); + +it("preserves an explicit username matching the connecting account", async () => { + const { out } = await resolveWith("deploy@resolved.example:2222", CLEAN_G, { + configBody: "", + account: "deploy", + }); + expect(out.accepted).toBe(true); + if (out.accepted) expect(out.snapshot.user).toBe("deploy"); +}); diff --git a/packages/pane-runtime/src/ssh/key-bridge.ts b/packages/pane-runtime/src/ssh/key-bridge.ts new file mode 100644 index 000000000..4e4518d29 --- /dev/null +++ b/packages/pane-runtime/src/ssh/key-bridge.ts @@ -0,0 +1,178 @@ +import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; +import { createServer, type Server, type Socket } from "node:net"; +import { dirname } from "node:path"; +import { + parseSshNodeCommandBody, + SSH_LOCAL_KEY_MAX_BYTES, + SSH_LOCAL_KEY_TTL_MS, + type SshKeyBridgeCommand, + type SshKeyBridgeResult, +} from "@internal/subshell-protocol"; +import { pathAllowed, realpathRoots } from "./relay/path-policy.js"; +import { buildAgentSocketPath, buildSshKnownHostsPath } from "./ssh-render.js"; + +/** + * The bridge's terminal-state refusals, as exact strings. + * + * They are the whole set of reasons `machineSshKeyBridge` can refuse a caller + * (a stale sequence, a second open, an over-cap census, a refused path, a body + * that failed the frozen grammar). The control plane maps them to 409 refusals + * by MEMBERSHIP in this set rather than substring-matching messages it does + * not own; anything else that throws here is a real fault and stays a 500. + */ +export const SSH_KEY_BRIDGE_REFUSALS: ReadonlySet = new Set([ + "Invalid SSH signing request.", + "SSH signing reply is out of sequence.", + "SSH signing bridge already exists.", + "Too many SSH signing requests are active.", + "SSH signing directory refused.", +]); + +interface Bridge { + /** Map key (`dataDir\0id`), kept so `end` can evict itself from any callback. */ + key: string; + /** The one listening unix server; closed when the bridge ends. */ + server: Server; + /** The single accepted agent socket; a second connect is destroyed. */ + socket?: Socket; + /** Partial frame bytes until a full length-prefixed request arrives. */ + buffer: Buffer; + /** The one outstanding request awaiting the signer's reply, or null. */ + request: SshKeyBridgeResult["request"]; + /** Next request's sequence number; the bridge ends instead of minting a ninth. */ + seq: number; + /** Terminal flag: set once by `end`, never cleared. */ + done: boolean; + /** TTL sweep unref'd so an idle bridge can never hold the process. */ + timer: ReturnType; + /** Agent socket path, unlinked on teardown. */ + socketPath: string; +} +const bridges = new Map(); +const opening = new Set(); +const keyFor = (dataDir: string, id: string) => `${dataDir}\0${id}`; + +function end(bridge: Bridge, graceful = false): void { + if (bridge.done) return; + bridge.done = true; + bridges.delete(bridge.key); + bridge.request = null; + bridge.buffer = Buffer.alloc(0); + clearTimeout(bridge.timer); + if (graceful) bridge.socket?.end(); + else bridge.socket?.destroy(); + bridge.server.close(); + void rm(bridge.socketPath, { force: true }).catch(() => {}); +} + +/** A bounded, single-connection agent socket. The native signer alone authorizes signatures. */ +export async function machineSshKeyBridge(dataDir: string, input: SshKeyBridgeCommand): Promise { + const cmd = parseSshNodeCommandBody(input); + if (cmd?.type !== "ssh_key_bridge") throw new Error("Invalid SSH signing request."); + const key = keyFor(dataDir, cmd.id); + const existing = bridges.get(key); + if (cmd.op === "close") { + if (existing) end(existing); + bridges.delete(key); + return { done: true, request: null }; + } + if (cmd.op === "exchange") { + if (!existing || existing.done) return { done: true, request: null }; + if (cmd.reply) { + if (!existing.request || existing.request.seq !== cmd.reply.seq) { + end(existing); + throw new Error("SSH signing reply is out of sequence."); + } + const reply = Buffer.from(cmd.reply.payload, "base64"); + const requestType = Buffer.from(existing.request.payload, "base64")[0]; + existing.request = null; + const length = Buffer.alloc(4); + length.writeUInt32BE(reply.length); + existing.socket?.write(Buffer.concat([length, reply])); + // A signature is useful only for its bound SSH exchange. Close access + // after delivering it; never leave the signer available to the shell. + if (requestType === 13 || reply[0] === 5) end(existing, true); + } + return { done: existing.done, request: existing.request }; + } + if (existing || opening.has(key)) throw new Error("SSH signing bridge already exists."); + if (bridges.size + opening.size >= 64) throw new Error("Too many SSH signing requests are active."); + opening.add(key); + const socketPath = buildAgentSocketPath(dataDir, cmd.id); + const dir = dirname(socketPath); + let bridge: Bridge | undefined; + try { + if (!(await pathAllowed(dir, await realpathRoots([dataDir])))) throw new Error("SSH signing directory refused."); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await chmod(dir, 0o700); + const pinPath = buildSshKnownHostsPath(dataDir, cmd.id); + await writeFile(pinPath, cmd.hostPin, { mode: 0o600, flag: "wx" }); + const server = createServer((socket) => { + if (!bridge || bridge.done || bridge.socket) { + socket.destroy(); + return; + } + const state = bridge; + state.socket = socket; + socket.on("error", () => end(state)); + socket.on("close", () => end(state)); + socket.on("data", (chunk: Buffer) => { + if (state.done) return; + if (state.request || state.buffer.length + chunk.length > SSH_LOCAL_KEY_MAX_BYTES + 4) { + end(state); + return; + } + state.buffer = Buffer.concat([state.buffer, chunk]); + if (state.buffer.length < 4) return; + const length = state.buffer.readUInt32BE(0); + if (!length || length > SSH_LOCAL_KEY_MAX_BYTES || state.buffer.length > length + 4) { + end(state); + return; + } + if (state.buffer.length < length + 4) return; + const payload = state.buffer.subarray(4); + if (![11, 13, 27].includes(payload[0]) || state.seq >= 8) { + end(state); + return; + } + state.request = { seq: state.seq++, payload: payload.toString("base64") }; + state.buffer = Buffer.alloc(0); + }); + }); + bridge = { + key, + server, + buffer: Buffer.alloc(0), + request: null, + seq: 0, + done: false, + socketPath, + timer: setTimeout(() => { + if (bridge) end(bridge); + bridges.delete(key); + }, SSH_LOCAL_KEY_TTL_MS), + }; + bridge.timer.unref(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + await chmod(socketPath, 0o600); + bridges.set(key, bridge); + return { done: false, request: null }; + } catch (error) { + if (bridge) end(bridge); + throw error; + } finally { + opening.delete(key); + } +} + +/** Close every ephemeral bridge owned by this runtime during teardown. */ +export function closeSshKeyBridges(dataDir?: string): void { + for (const [key, bridge] of bridges) { + if (dataDir !== undefined && !key.startsWith(`${dataDir}\0`)) continue; + end(bridge); + bridges.delete(key); + } +} diff --git a/packages/pane-runtime/src/ssh/ssh-resolve.ts b/packages/pane-runtime/src/ssh/ssh-resolve.ts index ed75d2120..96287ec97 100644 --- a/packages/pane-runtime/src/ssh/ssh-resolve.ts +++ b/packages/pane-runtime/src/ssh/ssh-resolve.ts @@ -292,11 +292,11 @@ async function resolveConfig( const portRaw = Number(valuesOf(g, "port")[0] ?? "22"); const port = Number.isInteger(portRaw) ? portRaw : NaN; const userRaw = valuesOf(g, "user")[0] ?? null; - // -G always prints SOME user (the connecting account's own default); null - // means exactly that default, so the snapshot stores null for it and a real - // value only when the config chose a different account. + // Preserve an explicitly chosen account even when it matches this node's + // default: local signers must bind approval to a concrete username. + // With no explicit choice, null still means the connecting account's default. // Login names are case-sensitive, even though hostnames are not. - const user = userRaw !== null && userRaw !== connectingAccount ? userRaw : null; + const user = userRaw !== null && (target.user !== null || userRaw !== connectingAccount) ? userRaw : null; // --- trust + identity refs (paths on THIS machine; the snapshot carries refs, never contents) --- // `-G` prints the DEFAULT identity files in tilde form (`identityfile diff --git a/packages/pane-runtime/src/types.ts b/packages/pane-runtime/src/types.ts index 093e9bb75..8033ccd88 100644 --- a/packages/pane-runtime/src/types.ts +++ b/packages/pane-runtime/src/types.ts @@ -104,6 +104,13 @@ export interface HarnessPlugin { description: string; /** Optional emoji/icon label */ icon?: string; + /** + * The manifest's `subshell.pickable`, carried only when it says false: a + * plugin that drives a pane but must not appear in the launch pickers. + * Absent means pickable; this is the plugin's own declaration, not a + * surface's special case. + */ + pickable?: false; /** Official install instructions, shown when detection fails */ installHint: InstallHint; /** Whether the harness ships enabled out of the box */ diff --git a/packages/plugin-api/src/__tests__/manifest.test.ts b/packages/plugin-api/src/__tests__/manifest.test.ts index ca6332a49..b44fbb09b 100644 --- a/packages/plugin-api/src/__tests__/manifest.test.ts +++ b/packages/plugin-api/src/__tests__/manifest.test.ts @@ -128,6 +128,27 @@ describe("parseManifest", () => { expect("error" in parseManifest(pkg({ hostEnv: [" "] }))).toBe(true); }); + it("carries `pickable` only when it says false", () => { + // Absence and `true` mean the same thing (pickable), so the parsed + // manifest stays minimal; a surface reads `pickable === false` as the + // plugin's own refusal to be listed. + const falseRow = parseManifest(pkg({ pickable: false })); + expect("error" in falseRow).toBe(false); + if (!("error" in falseRow)) expect(falseRow.pickable).toBe(false); + for (const raw of [{ pickable: true }, {}]) { + const row = parseManifest(pkg(raw)); + expect("error" in row).toBe(false); + if (!("error" in row)) expect(row.pickable).toBeUndefined(); + } + }); + + it("refuses a `pickable` that is not a boolean", () => { + // The field's contract is that ONLY a false hides the plugin, so a + // truthy-looking string must not get to be the hider or the non-hider. + expect("error" in parseManifest(pkg({ pickable: "false" }))).toBe(true); + expect("error" in parseManifest(pkg({ pickable: 0 }))).toBe(true); + }); + it("carries the optional icon and install block through", () => { const result = parseManifest(pkg({ icon: "icon.svg" })); expect("error" in result).toBe(false); diff --git a/packages/plugin-api/src/manifest.ts b/packages/plugin-api/src/manifest.ts index 0e0377061..3090ab140 100644 --- a/packages/plugin-api/src/manifest.ts +++ b/packages/plugin-api/src/manifest.ts @@ -227,6 +227,18 @@ export interface SubshellManifest { hostEnv?: string[]; /** Network facts. Present exactly when `type` is `network`. */ network?: NetworkManifest; + /** + * False means the launch pickers must not offer this plugin, even though + * it drives a pane. Absent means true. + * + * The declaration belongs to the plugin because only it knows whether a + * bare launch means something: a harness whose useful launch needs inputs + * no picker carries (SSH needs a destination, which the connect form + * supplies through its own door) would otherwise be a row that starts a + * dying pane. This hides the entry, not the route: the plugin stays + * launchable by id for whatever flow does supply the inputs. + */ + pickable?: boolean; } /** A parse failure, carrying the sentence to render. */ @@ -385,6 +397,13 @@ export function parseManifest(pkgJson: unknown): SubshellManifest | ManifestErro } } + // A declared boolean or nothing. `"false"`-as-string is refused rather than + // truthed, because the point of the field is that absence means pickable: + // a string here must not silently hide a plugin. + if (block.pickable !== undefined && typeof block.pickable !== "boolean") { + return { error: "`subshell.pickable` must be a boolean when present" }; + } + if (typeof block.entry !== "string" || block.entry.trim() === "") { return { error: "`subshell.entry` must be a non-empty relative path" }; } @@ -464,6 +483,9 @@ export function parseManifest(pkgJson: unknown): SubshellManifest | ManifestErro ...(install ? { install } : {}), ...(hostEnv ? { hostEnv } : {}), ...(parsedNetwork ? { network: parsedNetwork } : {}), + // Carried only when it says false: absence and `true` mean the same + // thing (pickable), so the parsed manifest stays minimal on the wire. + ...(block.pickable === false ? { pickable: false } : {}), }; } diff --git a/packages/plugins/ssh/package.json b/packages/plugins/ssh/package.json index 5894f782d..2ffb3ee9a 100644 --- a/packages/plugins/ssh/package.json +++ b/packages/plugins/ssh/package.json @@ -55,6 +55,7 @@ "binaryName": "ssh", "envOverride": "SUBSHELL_SSH_PATH", "knownPaths": ["/usr/bin/ssh", "/bin/ssh", "/usr/local/bin/ssh", "/opt/homebrew/bin/ssh"] - } + }, + "pickable": false } } diff --git a/packages/plugins/ssh/src/__tests__/ssh.test.ts b/packages/plugins/ssh/src/__tests__/ssh.test.ts index 3d96c298f..dd6e1188e 100644 --- a/packages/plugins/ssh/src/__tests__/ssh.test.ts +++ b/packages/plugins/ssh/src/__tests__/ssh.test.ts @@ -23,6 +23,15 @@ describe("ssh manifest", () => { expect(manifest.type).toBe("terminal"); }); + it("declares itself out of the launch pickers", () => { + // The web Agent list drops this plugin because the manifest says so, not + // because a surface special-cases the id (operator ruling 2026-10-15: a + // bare ssh launch carries no destination and dies at ssh's usage prompt). + // Flipping this flag would silently put a dead pick back in the list, + // which is why it is pinned rather than trusted to package.json. + expect(manifest.pickable).toBe(false); + }); + it("declares the ssh binary and the operator's pin seam", () => { // `SUBSHELL_SSH_PATH` is the same override the agent's tier-1 ssh-shared // ladder reads, so one operator setting names one binary everywhere. diff --git a/packages/plugins/ssh/src/index.ts b/packages/plugins/ssh/src/index.ts index bbe12d452..9767d323e 100644 --- a/packages/plugins/ssh/src/index.ts +++ b/packages/plugins/ssh/src/index.ts @@ -58,6 +58,10 @@ const createPlugin: HarnessPluginFactory = (_host: PluginHost): SubshellPlugin = // `SSH_AUTH_SOCK`, resolve/discovery, and the owner-only-input kind, none // of which a preset-flag launch carries. Do not "fix" this: closing it // would mean taking preset flags off the terminal path too. + // This plugin DECLARED itself out of the launch pickers (`pickable: false` + // in package.json; operator ruling 2026-10-15: a bare ssh launch dies at + // ssh's usage prompt, and the real SSH flow is the connect form). Hiding + // the entry is not closing the route, which is still the one ruled above. buildCommand(input: BuildCommandInput): string[] { const { binary, preset, extraFlags } = input; // No name flag: ssh has no session-title notion, so the pane title is diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 3a6e84d52..4ca9b2ddb 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -380,7 +380,7 @@ describe("parseNodeCommandBody", () => { // / `ssh_relay_close` signed commands. A tier-17 agent understands no relay // frame, and the exact-match gate refuses it BEFORE any relay command // (spec §11: never half-relaying). - expect(NODE_PROTOCOL_VERSION).toBe(20); + expect(NODE_PROTOCOL_VERSION).toBe(21); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { @@ -1110,31 +1110,34 @@ describe("ssh command arms and the launch ssh block", () => { "ssh_exec", "ssh_exec_status", "ssh_host_key", + "ssh_key_bridge", "ssh_machine_pin_repair", "ssh_register_identity", "ssh_relay_close", "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(10); + expect(SSH_COMMAND_TYPES).toHaveLength(11); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { const body = - t === "ssh_resolve_config" - ? { type: t, alias: "box-a" } - : t === "ssh_relay_open" - ? relayOpenCmd - : t === "ssh_relay_close" - ? { type: t, ref: "r-1", reason: "lifetime-expiry" } - : t === "ssh_host_key" - ? { type: t, host: "git.example.test", port: 22, user: null } - : t === "ssh_exec" - ? execCmdFixture() - : t === "ssh_exec_status" - ? { type: t, execId: EXEC_ID } - : t === "ssh_machine_pin_repair" - ? repairCmd - : { type: t }; + t === "ssh_key_bridge" + ? { type: t, op: "close", id: "00000000-0000-4000-8000-000000000001" } + : t === "ssh_resolve_config" + ? { type: t, alias: "box-a" } + : t === "ssh_relay_open" + ? relayOpenCmd + : t === "ssh_relay_close" + ? { type: t, ref: "r-1", reason: "lifetime-expiry" } + : t === "ssh_host_key" + ? { type: t, host: "git.example.test", port: 22, user: null } + : t === "ssh_exec" + ? execCmdFixture() + : t === "ssh_exec_status" + ? { type: t, execId: EXEC_ID } + : t === "ssh_machine_pin_repair" + ? repairCmd + : { type: t }; expect(parseNodeCommandBody(body)).not.toBeNull(); } }); diff --git a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts index f0358bc31..c7206d4c1 100644 --- a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts @@ -32,12 +32,12 @@ const good = { blob: b64OfRawLength(1024), } as const; -describe("NODE_PROTOCOL_VERSION 20 (spec 2026-10-08 §5.1)", () => { - it("19 carries user-authorized relay selections: relay frames + the open/close commands", () => { +describe("NODE_PROTOCOL_VERSION 21 (desktop signer bridge)", () => { + it("carries relay selections alongside the temporary desktop key bridge", () => { // The bump is load-bearing: a tier-17 agent (M1) understands no relay // frame, and the exact-match gate is what refuses it BEFORE any frame is // accepted. Never half-relay. - expect(NODE_PROTOCOL_VERSION).toBe(20); + expect(NODE_PROTOCOL_VERSION).toBe(21); }); }); diff --git a/packages/subshell-protocol/src/__tests__/ssh-key-bridge.test.ts b/packages/subshell-protocol/src/__tests__/ssh-key-bridge.test.ts new file mode 100644 index 000000000..79389b2fb --- /dev/null +++ b/packages/subshell-protocol/src/__tests__/ssh-key-bridge.test.ts @@ -0,0 +1,70 @@ +import { expect, it } from "bun:test"; +import { parseNodeCommandBody } from "../node-frames.js"; +import { + isSshKeyBridgePayload, + isSshLocalKeyHostPin, + parseSshKeyBridgeResult, + SSH_LOCAL_KEY_MAX_BYTES, +} from "../ssh-key-bridge.js"; + +it("bounds and canonicalizes native signing traffic", () => { + expect(isSshKeyBridgePayload("BQ==")).toBe(true); + for (const value of ["BR==", "BQ", "", "!!!=", Buffer.alloc(SSH_LOCAL_KEY_MAX_BYTES + 1).toString("base64")]) + expect(isSshKeyBridgePayload(value)).toBe(false); + expect(parseSshKeyBridgeResult({ done: false, request: { seq: 0, payload: "Cw==" }, privateKey: "discard" })).toEqual( + { done: false, request: { seq: 0, payload: "Cw==" } }, + ); + expect(parseSshKeyBridgeResult({ done: true, request: { seq: 0, payload: "Cw==" } })).toBeNull(); + expect(parseSshKeyBridgeResult({ done: false, request: { seq: -1, payload: "Cw==" } })).toBeNull(); +}); +it("pins the hostPin line grammar the bridge will write verbatim", () => { + const key = "AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + // Accepted: the shapes honest capture composes, including keyscan's + // port-qualified bracket form, IPv6, and the canonical hashed spelling + // (salt/hash carry +, / and = padding; the operator's file may use it). + for (const line of [ + `git.example.test ssh-ed25519 ${key}`, + `Git.Example.TEST ssh-ed25519 ${key}`, + `[git.example.test]:2222 ssh-ed25519 ${key}`, + `[2001:db8::1] ssh-ed25519 ${key}`, + `[::1]:22 ssh-ed25519 ${key}`, + `|1|c2FsdCsK/88=|aGFzaC8K+Q== ssh-ed25519 ${key}`, + `git.example.test ssh-ed25519 ${key} # operator comment`, + ]) + expect(isSshLocalKeyHostPin(line), line).toBe(true); + // Refused: the line-hygiene spellings the relay sibling also refuses + // (trim, control chars, comment lead), plus every host-token and + // key-grammar widening this bridge adds on top of it. The sibling is + // shape-only and lets the glob, algorithm and junk-field rows through; + // here they are the whole point. + for (const line of [ + `*.example.test ssh-ed25519 ${key}`, + `git?.example ssh-ed25519 ${key}`, + `port=22,*.example.test ssh-ed25519 ${key}`, + `@cert-authority git.example.test ssh-ed25519 ${key}`, + `!git.example.test ssh-ed25519 ${key}`, + `#git.example.test ssh-ed25519 ${key}`, + `git.example.test\tssh-ed25519 ${key}`, // tab-separated + ` git.example.test ssh-ed25519 ${key}`, // leading space + `git.example.test ssh-ed25519 ${key} junkfield`, + `git.example.test ssh-ed25519 ${key}\u001b[0m`, // ESC smuggle + `git.example|test ssh-ed25519 ${key}`, + `|1|c2FsdA==bad|aGFzaA== ssh-ed25519 ${key}`, // padding mid-token is not a hashed host + `git.example.test ssh-dss ${key}`, + ]) + expect(isSshLocalKeyHostPin(line), line).toBe(false); +}); +it("validates the command independently of sender authentication", () => { + const id = crypto.randomUUID(); + expect(parseNodeCommandBody({ type: "ssh_key_bridge", op: "close", id })).toEqual({ + type: "ssh_key_bridge", + op: "close", + id, + }); + for (const command of [ + { op: "open", id, hostPin: "not-a-pin" }, + { op: "close", id: "../../escape" }, + { op: "exchange", id, reply: { seq: 0, payload: "BR==" } }, + ]) + expect(parseNodeCommandBody({ type: "ssh_key_bridge", ...command })).toBeNull(); +}); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index bbea265e4..91f1737b4 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -285,6 +285,19 @@ export { type SshRelayRole, type SshResolveConfigCommand, } from "./ssh-frames.js"; +export { + isSshKeyBridgePayload, + isSshLocalKeyHostPin, + parseSshKeyBridgeReply, + parseSshKeyBridgeResult, + SSH_AGENT_FAILURE_BASE64, + SSH_LOCAL_KEY_MAX_BASE64, + SSH_LOCAL_KEY_MAX_BYTES, + SSH_LOCAL_KEY_MAX_PASSPHRASE_BYTES, + SSH_LOCAL_KEY_TTL_MS, + type SshKeyBridgeCommand, + type SshKeyBridgeResult, +} from "./ssh-key-bridge.js"; export { SSH_CONFIG_FILE_MAX_BYTES, SSH_EXEC_COMMAND_MAX_CHARS, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 02a58cc73..6da597a98 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -176,8 +176,11 @@ import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS, SSH_RELAY_FRAME_MAX_BYTE * **19 -> 20:** ssh_host_key can discover a destination’s presented identity * without writing trust. The server requires this distinction before offering * a first-connection confirmation. + * + * **20 -> 21:** ssh_key_bridge carries bounded public SSH-agent messages to + * a one-use desktop signer. Older nodes cannot provide this authentication path. */ -export const NODE_PROTOCOL_VERSION = 20; +export const NODE_PROTOCOL_VERSION = 21; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -1363,6 +1366,12 @@ export type PluginReportWire = { icon?: string; /** One-line description */ description: string; + /** + * The manifest's own refusal to be listed in launch pickers (present only + * as `false`; absence means pickable). The control plane's row carries it + * so the web picker drops the plugin without knowing its id. + */ + pickable?: false; /** Which optional members it implements */ capabilities: string[]; /** Settings rendered in the preset editor */ @@ -1995,6 +2004,7 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_register_identity": case "ssh_agent_identities": case "ssh_host_key": + case "ssh_key_bridge": case "ssh_relay_open": case "ssh_relay_close": case "ssh_exec": diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index f2c27adf1..7eadbdbdc 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -43,6 +43,7 @@ */ import { BASE64_RE, isBool, isInt, isRecord, isStr, isStrArray } from "./guards.js"; +import { isSshLocalKeyHostPin, parseSshKeyBridgeReply, type SshKeyBridgeCommand } from "./ssh-key-bridge.js"; import { SSH_CONFIG_FILE_MAX_BYTES, SSH_EXEC_COMMAND_MAX_CHARS, @@ -358,6 +359,7 @@ export interface SshExecStatusCommand { /** Every SSH-family command body, as one union the {@link NodeCommandBody} union folds in. */ export type SshNodeCommandBody = + | SshKeyBridgeCommand | SshDiscoverAliasesCommand | SshResolveConfigCommand | SshRegisterIdentityCommand @@ -371,6 +373,7 @@ export type SshNodeCommandBody = /** Every SSH command `type`, for census tests and dispatch tables. */ export const SSH_COMMAND_TYPES = [ + "ssh_key_bridge", "ssh_discover_aliases", "ssh_resolve_config", "ssh_register_identity", @@ -444,6 +447,9 @@ export function isSshPaneId(value: unknown): value is string { return isStr(value) && SSH_PANE_ID_RE.test(value); } +/** The id grammar the local-key bridge accepts: a pane id that is also a lowercase uuid. */ +const SSH_LOCAL_KEY_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + /** * The selected fingerprint set: strings, every one in the `SHA256:` * display form (an empty or free-text entry matches no key yet would ride a @@ -500,6 +506,22 @@ export function isSshKnownHostsPinLine(value: unknown): value is string { export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | null { if (!isRecord(value) || !isStr(value.type)) return null; switch (value.type) { + case "ssh_key_bridge": { + // The id IS the pane id (the launcher passes subshellId), but the bridge + // narrows further than SSH_PANE_ID_RE to the lowercase-uuid spelling it + // is always minted with: a stranger id never reaches this command. + if (typeof value.id !== "string" || !SSH_LOCAL_KEY_ID_RE.test(value.id)) return null; + const id = value.id; + if (value.op === "open") + return isSshLocalKeyHostPin(value.hostPin) + ? { type: "ssh_key_bridge", op: "open", id, hostPin: value.hostPin } + : null; + if (value.op === "close") return { type: "ssh_key_bridge", op: "close", id }; + if (value.op !== "exchange") return null; + if (value.reply === undefined) return { type: "ssh_key_bridge", op: "exchange", id }; + const reply = parseSshKeyBridgeReply(value.reply); + return reply ? { type: "ssh_key_bridge", op: "exchange", id, reply } : null; + } case "ssh_discover_aliases": return { type: "ssh_discover_aliases" }; case "ssh_resolve_config": diff --git a/packages/subshell-protocol/src/ssh-key-bridge.ts b/packages/subshell-protocol/src/ssh-key-bridge.ts new file mode 100644 index 000000000..0ed5da18f --- /dev/null +++ b/packages/subshell-protocol/src/ssh-key-bridge.ts @@ -0,0 +1,101 @@ +import { isRecord } from "./guards.js"; +import { base64ToBytes } from "./ssh-base64-std.js"; +import { SSH_MAX_HOST_PIN_LINE_CHARS } from "./ssh-limits.js"; + +/** Native signing ends after one authentication, even if a caller disappears. */ +export const SSH_LOCAL_KEY_TTL_MS = 90_000; +export const SSH_LOCAL_KEY_MAX_BYTES = 65_536; +export const SSH_LOCAL_KEY_MAX_BASE64 = 87_384; +/** Encoded-passphrase bound (bytes): both signing paths check this single number. */ +export const SSH_LOCAL_KEY_MAX_PASSPHRASE_BYTES = 4_096; + +/** The agent's SSH_AGENT_FAILURE reply, as it crosses the bridge: byte 5, base64. */ +export const SSH_AGENT_FAILURE_BASE64 = "BQ=="; + +/** The only data exchanged is public SSH agent traffic, never a private key. */ +export type SshKeyBridgeCommand = + | { type: "ssh_key_bridge"; op: "open"; id: string; hostPin: string } + | { type: "ssh_key_bridge"; op: "exchange"; id: string; reply?: { seq: number; payload: string } } + | { type: "ssh_key_bridge"; op: "close"; id: string }; + +export interface SshKeyBridgeResult { + /** True once signing access has ended. */ + done: boolean; + /** One outstanding, length-bounded SSH-agent request. */ + request: { seq: number; payload: string } | null; +} + +/** Reject noncanonical or oversized messages before allocating decoded bytes. */ +export function isSshKeyBridgePayload(value: unknown): value is string { + if (typeof value !== "string" || !value.length || value.length > SSH_LOCAL_KEY_MAX_BASE64) return false; + try { + return base64ToBytes(value).length <= SSH_LOCAL_KEY_MAX_BYTES; + } catch { + return false; + } +} + +/** One plain host key, with the wire algorithm agreeing with the known_hosts column. */ +export function isSshLocalKeyHostPin(value: unknown): value is string { + // Same line hygiene as isSshKnownHostsPinLine (ssh-frames.ts): this string is + // written verbatim into the known_hosts the pane's ssh trusts with + // StrictHostKeyChecking, so a smuggled control character or option token + // would become part of that trust, not just noise. The host token is a + // POSITIVE allow-list: a plain host label, a bracketed address (IPv6 or + // host) with optional `:port`, or a canonical hashed `|1|salt|hash` token + // (how the operator's own file may spell the pin). Globs, comma + // option-lists, `@`/`!` markers, and trailing + // junk are refused here, at the last check before the file is written. + if ( + typeof value !== "string" || + !value.length || + value.length > SSH_MAX_HOST_PIN_LINE_CHARS || + value !== value.trim() || + /\p{Cc}/u.test(value) || + value.startsWith("#") + ) + return false; + const [host, algorithm, encoded, ...rest] = value.split(/\s+/); + if ( + !host || + !( + /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host) || + /^\[[A-Za-z0-9._:-]+\](?::\d+)?$/.test(host) || + /^\|1\|[A-Za-z0-9+/]+={0,2}\|[A-Za-z0-9+/]+={0,2}$/.test(host) + ) || + (rest.length > 0 && !rest[0]?.startsWith("#")) || + !algorithm || + !/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(?:256|384|521))$/.test(algorithm) || + !isSshKeyBridgePayload(encoded) + ) + return false; + const bytes = base64ToBytes(encoded); + if (bytes.length < 5) return false; + const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0); + return ( + length === algorithm.length && + bytes.length > length + 4 && + new TextDecoder().decode(bytes.subarray(4, 4 + length)) === algorithm + ); +} + +/** Rebuild an agent reply without preserving unknown fields. */ +export function parseSshKeyBridgeReply(value: unknown): { seq: number; payload: string } | null { + if ( + !isRecord(value) || + !Number.isSafeInteger(value.seq) || + (value.seq as number) < 0 || + !isSshKeyBridgePayload(value.payload) + ) + return null; + return { seq: value.seq as number, payload: value.payload }; +} + +/** Validate node results independently of the authenticated transport. */ +export function parseSshKeyBridgeResult(value: unknown): SshKeyBridgeResult | null { + if (!isRecord(value) || typeof value.done !== "boolean") return null; + const request = value.request === null ? null : parseSshKeyBridgeReply(value.request); + if (value.request !== null && request === null) return null; + if (value.done && request) return null; + return { done: value.done, request }; +} diff --git a/packages/subshell-protocol/src/versions.ts b/packages/subshell-protocol/src/versions.ts index ec2f2b1bb..42b66182e 100644 --- a/packages/subshell-protocol/src/versions.ts +++ b/packages/subshell-protocol/src/versions.ts @@ -113,8 +113,11 @@ export function semverLt(a: string, b: string): boolean { * `apps/node/agent/package.json` is raised to the same floor in this commit. * * Raised to 1.6.0 with protocol 20 for destination identity discovery. + * + * Raised to 2.1.0 with protocol 21 for the temporary desktop SSH signer bridge. + * The node package moves to the same floor so older nodes cannot accept this flow. */ -export const MIN_NODE_VERSION = "1.6.0"; +export const MIN_NODE_VERSION = "2.1.0"; /** * Whether an agent reporting `version` may connect.