Skip to content

Add local SSH key signing in browsers and desktop apps - #384

Merged
theogravity merged 16 commits into
mainfrom
feat/local-ssh-key-signing
Oct 15, 2026
Merged

theogravity merged 16 commits into
mainfrom
feat/local-ssh-key-signing

Conversation

@theogravity

@theogravity theogravity commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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 rsa crate. 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.

theogravity and others added 13 commits October 14, 2026 17:26
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>
@theogravity

Copy link
Copy Markdown
Contributor Author

RSA real-wire proof, run explicitly for the AWS-LC change (closes the round where review asked for recorded evidence).

cargo test --locked --test ssh_signer_openssh -- --ignored
test real_openssh_accepts_one_native_hostbound_signature ... ok
test real_openssh_accepts_encrypted_rsa_and_rsa_host_keys ... ok
test result: ok. 2 passed; 0 failed; 0 ignored

Host: Linux x86_64, OpenSSH_10.2p1 (Ubuntu-2ubuntu3.6). The RSA pair exercises the non-canonical-s encoding concern directly: impl Encode for Signature in ssh-key 0.6.7 writes the PKCS1 blob as a raw string, and the real sshd accepted the login anyway.

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 #[ignore]d; the file header says to run them explicitly for SSH signer changes, and that is what this note records for HEAD ea40e33.

theogravity and others added 2 commits October 14, 2026 23:54
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
theogravity force-pushed the feat/local-ssh-key-signing branch from 27a4aac to a632e1e Compare October 15, 2026 07:13
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>
@theogravity
theogravity merged commit 448a3c2 into main Oct 15, 2026
21 checks passed
@theogravity
theogravity deleted the feat/local-ssh-key-signing branch October 15, 2026 07:40
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant