Repository navigation
Add local SSH key signing in browsers and desktop apps - #384
Merged
Merged
Conversation
Round one of the PR #384 review loop, per-slice findings all closed: - bridge: hostPin validator now a positive allow-list (plain host, bracketed host:port, canonical hashed token) sharing the sibling's control-char/length hygiene and SSH_MAX_HOST_PIN_LINE_CHARS; the agent refusal set is published (SSH_KEY_BRIDGE_REFUSALS) so the plane maps terminal states to 409 refusals instead of 500s; bridge refuses a second agent connection and a ninth request, now test-pinned - web: the signed flag accumulates (a later BQ== answer can no longer un-record a delivered signature and let the catch delete a live pane); the destination dialog uses the signer core's validated fingerprint instead of a raw atob digest; shared SSH_AGENT_FAILURE/cap constants deduped; boundary copy aligned to the actual > comparisons - server: NodeRpcError/SshRpcError/SshHostPinError mapped to the ssh family's coded refusals (one sentence per cause via rpcRefusal), LocalKeySession documented, reply decoded-size checked at the route, seq bound 0..7, per-viewer/prepare-TTL constants named, bearer-403 pinned on the local-key routes - desktop: window-state DENYLIST covers the unlock window in both apps; unlock form uses text-heading and destructive-red for refusals; the approval dialog is parented to the plane window; passphrase cap counted in UTF-16 units matching the form's maxLength - docs: stale ONE/SEVEN/EIGHTH count comments and the security-context summary updated to the five/eleven-command grants and the unlock window; e2e spec 25 gained a loud file-level skip and a readiness gate before its absence assertion; capture script no longer regenerates the deleted ssh-authentication.png Tests: bridge refusal arms, second-connect, ninth-request, signed-flag regression, service error-mapping codes. Co-Authored-By: Claude <noreply@anthropic.com>
- pin the rewritten hostPin grammar at its own layer: accepted shapes
(plain label incl. uppercase, keyscan's bracketed [host]:port, IPv6,
canonical |1|salt|hash with padded +/ base64, trailing # comment) and
every refused widening (globs, comma option-lists, @/!/# markers,
tab/untrimmed/ESC/junk-token, non-canonical hashed, foreign algorithm)
- name the passphrase bound in @internal/subshell-protocol
(SSH_LOCAL_KEY_MAX_PASSPHRASE_BYTES); both web check sites and the
copy now share the one number with its siblings
- drop the never-imported FAILURE re-export (the constant stays the
file-local name for the shared protocol value)
- finish the stale-count sweep the round-2 grep surfaced: client
app.tsx / ipc.ts / zoom.md ("exactly one command"), server desktop
ipc.ts ("its seven"), ipc-boundary.md ("one command now"),
desktop-rows.tsx and its test, server AGENTS.md ("one of main's
seven" -> "original seven"); rewrap the security.md sentence
Co-Authored-By: Claude <noreply@anthropic.com>
- client: the ACL-test headers, desktop.toml descriptions and the windows/tray/zoom/menu/tray.md/tauri-config comments still said `main` holds ONE command; they now say five narrow ones, and the sentences whose safety argument needs the singular name the browser command (or the destructive one) specifically - server: the ipc-acl header and seven-acts comment now cover the four signer commands (ten of eleven cannot reach the CLI; supervision stays the one that does), desktop-core's zoom/browser module docs reworded, AGENTS.md names both denylisted frames - add the client mirror of the server's window-state DENYLIST pin test; the client comment promised a test that only existed server-side Co-Authored-By: Claude <noreply@anthropic.com>
- ipc-boundary.md's capability-set sentence names all three files now (the pin gained ssh-key-unlock.json), and windows.md's window-state sentence names both denylisted labels - drop the claimed-but-absent signer-signature pin from the client AGENTS.md sentence and the ACL test's own header (the signer's safety lives in ssh_key.rs, and the test says so); server AGENTS.md scopes the open_main inheritance reason to the assistant, where it holds - server dev_spa_origin comment: "seven commands" -> "everything the window holds", so the argument outlives the next count change - client windows.rs: the signer commands belong to the connection form, not the two isDesktop() chrome surfaces the sentence claimed Co-Authored-By: Claude <noreply@anthropic.com>
The bridge's host-token and key-grammar refusals are exactly where isSshKnownHostsPinLine (shape-only, by design) lets globs, ssh-dss and junk fields through; the pin test's comment said "also refuses". Reword it to what is actually shared (the hygiene refusals) and what is added. Also: the validator accepts any bracketed address with optional port, not only IPv6. Co-Authored-By: Claude <noreply@anthropic.com>
"Native buffers are zeroized" overstates the RSA path: AWS-LC cleanses the key allocations it releases, but the parsed components in ssh-key's own structs are not claimed, matching what docs/security.md already says. Also fix the spec comment whose two "but" clauses read as one concession stacked on another. Co-Authored-By: Claude <noreply@anthropic.com>
Contributor
Author
|
RSA real-wire proof, run explicitly for the AWS-LC change (closes the round where review asked for recorded evidence). Host: Linux x86_64, OpenSSH_10.2p1 (Ubuntu-2ubuntu3.6). The RSA pair exercises the non-canonical- Both tests spin up their own isolated sshd on 127.0.0.1 with a temp root, so no host state is involved. They stay |
A pick there launches bare `ssh` with no destination, which dies at ssh's usage prompt; every real SSH flow is the connect form on the SSH terminal tab. The drop lives in buildAgentOptions, so the Agent select, the launch default and the preset harness picker all stop offering it, and the recent-SSH-pane default can no longer resurface it. The terminal-equivalent API route #379 ruled stays exactly as it was; the plugin comment now says the picker hides the entry without closing the route. Co-Authored-By: Claude <noreply@anthropic.com>
Replaces the id check shipped an hour ago: `subshell.pickable: false` in the plugin's own package.json is the rule now, carried through the parsed manifest, the HarnessPlugin record, the report wire, the control-plane row, and the web row type. Absence means pickable, so every plugin written before the field is untouched and no surface accumulates an id list. SSH carries the declaration; the route PR 379 ruled stays open, as before. Pins: manifest parse carries false only and refuses non-booleans, the ssh package's own test pins the declaration (flipping it would reintroduce the dead pick), and the web list proves the refusal is the flag, not the id. Co-Authored-By: Claude <noreply@anthropic.com>
theogravity
force-pushed
the
feat/local-ssh-key-signing
branch
from
October 15, 2026 07:13
27a4aac to
a632e1e
Compare
The New screen's Agent chips are a second consumer of the same GET /api/plugins rows, and the declaration had not crossed to it: a phone still listed SSH and tapping the chip launched a dying pane. `lib/plugin-visibility` mirrors the web's exclusion at the one choke point the chips and the default rule both read, PluginView carries the field, and agent-default's stale "ssh alone still fills" prose and test note that the row is dropped upstream now. Review NITs closed on the same pass: the row schema is `t.Literal(false)` (so the wire can only say what the description says), the stale on-disk built-in reasoning is written at the fallback, and ssh's package.json indent (which my earlier rewrite had flattened) is restored. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes
Adds Use a key from my machine to SSH authentication. Browsers sign locally in a disposable worker; desktop apps use a native picker and a separate encrypted-key unlock window. Private keys and passphrases are never uploaded. Both paths approve a destination and verify host-bound authentication before issuing one signature, with a 90-second lifetime.
The form explains browser trust and memory-erasure limits only when selected. It preserves the chosen file and passphrase for retry, shows actionable inline failures, and removes failed pre-authentication subshells. Once a signature has been issued, uncertain outcomes preserve the subshell to avoid terminating a working connection. Destination confirmation uses labeled connection details, recent destinations show their resolved address, and SSH headers have a tooltip icon.
The server/node bridge is session-scoped, permission-checked, bounded, and sequence-checked. This includes protocol 21 compatibility, native IPC restrictions, documentation, and changesets. Development testing can point the hot-reload frontend at an isolated backend with
DEV_API_ORIGIN.Security limits
Browser signing requires trusting the website and its operator: compromised page code could steal a selected key or passphrase. Worker termination and buffer clearing cannot guarantee physical memory erasure. The native path keeps private material outside the remote page and supports Ed25519, ECDSA and 2,048–8,192-bit RSA private and host keys. Native RSA signing and host-proof verification use AWS-LC through OpenSSL Rust bindings; ssh-key’s affected RustCrypto RSA backend stays disabled. RSA accepts SHA-256 and SHA-512 only, with exact algorithm/flag matching and library validation of private components. Browser RSA uses Web Crypto. Failed browser attempts retain file access and the passphrase for retry; the 90-second signing limit does not clear those form inputs. Both paths require host-bound SSH authentication; jump hosts and host-key aliases are unsupported.
Validation
Full TypeScript test suite: all 54 tasks passed; all 56 type-check/build prerequisite tasks passed.
Lint, package consistency, license, design-token, prose, and whitespace checks passed.
Full Rust formatting, clippy, and tests passed in all three crates; native signer authenticated against real OpenSSH.
Browser signer tests cover OpenSSH parsing, encrypted keys, independent signature verification, host/session/user binding, replay, concurrency, expiry, and cleanup. Real Chromium worker checks passed for Ed25519, RSA, and all supported ECDSA curves, encrypted and unencrypted.
Real browser/OpenSSH E2E passed: failed unlock retains selection/passphrase, rejected authentication removes its subshell, retry succeeds, and outgoing requests contain no private key or passphrase. Native graphical picker/unlock interaction has not been manually exercised headlessly.
Native RSA validation: all three Rust crates passed formatting, Clippy and tests. Seven real OpenSSH scenarios passed, including encrypted RSA keys, RSA host keys, and both SHA-256/SHA-512. Tests cover invalid components, size bounds, mismatched flags, wrong hosts/messages, SHA-1 rejection and single-use consumption. A backend identity test confirms AWS-LC is linked.
Dependency trees for both desktop apps across all targets contain no active
rsacrate. Linux and macOS CI build prerequisites include CMake and Clang/libclang.Connection form: 54 focused tests passed, including retry retention. Linux native tests passed locally; native graphical picker/unlock behavior and macOS builds still require their platform checks.