lollipopkit / lollipopkit/flutter_server_box
feat: reuse local ssh-agent socket and ControlMaster connections on desktop
Nobody has claimed this yet.
- Dominant language
- Dart
- Stars
- 8.7k
- Forks
- 558
- Avg merge
- 4h 31m
- Merged PRs (30d)
- 129
Description
## Motivation
On desktop, the app cannot reuse anything from the SSH setup the user already has on the same machine.
Key auth today requires importing the private key **into** the app: `PrivateKeyInfo.key` holds the PEM as plaintext in Hive, and `getPrivateKey()` (`lib/core/utils/server.dart`) reads it back by id. That rules out every setup where the key material is not extractable:
- hardware-backed keys (YubiKey, Secure Enclave via Secretive)
- password-manager agents (1Password, KeePassXC)
- `gpg-agent`, or keys held by the system keychain
- environments where exporting a private key is against policy
It also means a user with a working `ssh` on the same box has to duplicate — and separately secure — their credentials inside the app.
Two separate things should be reusable from the host. They share a theme but almost no implementation.
---
## Part 1 — ssh-agent authentication
Sign the `publickey` auth challenge with the host's running agent instead of a locally stored PEM. The app never sees the private key.
### Current state
`genClient()` (`lib/core/utils/server.dart:235-260`) offers exactly three auth paths: password, keyboard-interactive, and an in-app PEM. There is no way to reach an external agent.
The vendored fork already contains the agent protocol's **server** side — `SSHKeyPairAgent` / `SSHAgentChannel` in `packages/dartssh2/lib/src/ssh_agent.dart` — which answers `SSH_AGENTC_REQUEST_IDENTITIES` / `SSH_AGENTC_SIGN_REQUEST` from in-app keys, for forwarding to a remote. It is not wired into `lib/` at all (`agentHandler` has zero references there). What is missing is the **client** side: connect to the host agent and ask *it* to sign.
### Blocker: `SSHKeyPair.sign()` is synchronous
`packages/dartssh2/lib/src/ssh_key_pair.dart:66` declares `SSHSignature sign(Uint8List data)`, and both call sites are in synchronous `void` methods:
- `ssh_client.dart:1505` — `_authWithNextPublicKey()`
- `ssh_client.dart:1547` — `_authWithNextHostbased()`
Agent signing is async IPC over a socket, so the fork has to grow an async signing path. Suggested shape, kept additive so existing key types are untouched:
```dart
abstract class SSHSigner {
String get type;
SSHHostKey toPublicKey();
Future sign(Uint8List data);
}
```
`SSHKeyPair` gets an adapter to `SSHSigner`; `SSHClient` takes `List? signers` alongside the existing `identities`, and `_authWithNextPublicKey` becomes `Future`. Auth is strictly serial (one in-flight `SSH_MSG_USERAUTH_REQUEST` at a time), so awaiting inside it should not reorder messages — but this needs a deliberate check, plus a timeout so a hung/unresponsive agent fails the attempt instead of stalling the connection forever.
### Blocker: the app skips the publickey probe phase
`ssh_client.dart:1505-1506` sends a signature immediately; the `signature: null` probe is commented out. RFC 4252 §7 allows sending the public key **without** a signature first and only signing after the server answers `SSH_MSG_USERAUTH_PK_OK`.
With an in-app PEM this only wastes CPU. With an agent it is user-visible: 1Password and Secretive prompt for biometrics **per signature**, so an agent holding N keys would fire up to N prompts while probing for the one the server accepts. The probe phase should be implemented as part of this work.
### Transport per platform
| Platform | Mechanism | Notes |
|---|---|---|
| Linux | Unix socket at `$SSH_AUTH_SOCK` | plain Dart: `Socket.connect(InternetAddress(path, type: unix), 0)` |
| macOS | Unix socket at `$SSH_AUTH_SOCK` or `IdentityAgent` | same, **but see the sandbox section** |
| Windows | Named pipe `\\.\pipe\openssh-ssh-agent` | Dart cannot open named pipes → needs `sbm_ffi` |
Agent-socket resolution order should be: per-server override in the UI → `IdentityAgent` from `~/.ssh/config` → `$SSH_AUTH_SOCK`. The `IdentityAgent` lookup matters in practice — 1Password and Secretive do **not** set `$SSH_AUTH_SOCK`; they expect their own socket path in the config. `SSHConfig._parseSSHConfig` (`lib/core/utils/ssh_config.dart:127-173`) currently parses `hostname`/`user`/`port`/`identityfile`/`proxyjump`/`proxycommand` and would need an `identityagent` case.
Pageant's legacy `WM_COPYDATA` transport is explicitly **out of scope** — recent Pageant also serves the OpenSSH named pipe.
### macOS sandbox
`macos/Runner/*.entitlements` sets `com.apple.security.app-sandbox = true`. A sandboxed process cannot connect to the agent socket — neither launchd's `/private/tmp/com.apple.launchd.*/Listeners` nor 1Password's socket under `~/Library/Group Containers/`.
Decision: **drop the sandbox for the directly distributed DMG / Homebrew cask build, and hide the feature on the Mac App Store build.** A `files.absolute-path.read-write` temporary exception is not workable — the launchd socket path contains a random component, so the exception cannot be written precisely, and it is unlikely to clear App Store review.
This needs a build-time capability flag so the MAS build degrades to "option not shown" rather than "option present and always failing".
Related, worth checking while in here: `ProxyCommandSocket` (`lib/core/utils/proxy_command_socket.dart`) does `Process.start('/bin/sh', ...)`, and the child inherits the sandbox — so `ProxyCommand` on the current sandboxed macOS build is probably already broken. Same fix, same flag.
---
## Part 2 — ControlMaster connection reuse
Obtain the transport socket from an OpenSSH master connection the user already has open, via its `ControlPath` mux socket.
### Scope
`genClient()` builds its socket through one of four paths today (`lib/core/utils/server.dart:111-226`): jump server → `ProxyCommand` → monitor tunnel → direct. This adds a fifth, and it should be limited to **`MUX_C_NEW_STDIO_FWD`** — ask the master to open a `direct-tcpip` forward to the target and hand back a byte stream, i.e. the equivalent of `ssh -W host:port`. The app then runs its own SSH session on top: its own auth, its own host-key verification. Architecturally this is the same layer as the existing jump-server support, just with a zero-config master instead of a configured hop.
**`MUX_C_NEW_SESSION` is out of scope.** Reusing an *authenticated* session that way yields raw stdin/stdout/stderr for one shell, which would cost SFTP, port forwarding, and multiple concurrent channels — everything the app builds on `SSHClient`.
Note that `ProxyCommand ssh -W %h:%p ` already reaches roughly the same result today, since it shells out to the host `ssh`, which honors `ControlMaster`. Native support buys: no subprocess, no shell quoting, works where spawning processes is restricted, and clearer errors.
### Blocker: mux requires SCM_RIGHTS fd passing
The OpenSSH mux protocol is not a plain request/response byte protocol. In `mux.c`, `mux_client_request_stdio_fwd()` sends the request and then passes **two file descriptors** over the socket via `SCM_RIGHTS` (`mm_send_fd(sock, STDIN_FILENO)`, then `STDOUT_FILENO`); `mux_client_request_session()` passes three. `dart:io` has no `sendmsg`/`SCM_RIGHTS`, so **this cannot be done in Dart at all.**
It has to live in `sbm_ffi`: create a `socketpair(AF_UNIX, SOCK_STREAM)`, pass one end to the master as both stdin and stdout, keep the other end, and bridge it to Dart as an `SSHSocket`. Handshake is `MUX_MSG_HELLO` (version 4) → `MUX_C_NEW_STDIO_FWD` → `MUX_S_SESSION_OPENED` / `MUX_S_FAILURE` / `MUX_S_PERMISSION_DENIED`.
### Blocker: Windows has no ControlMaster
Win32-OpenSSH does not implement connection multiplexing — `ControlMaster` / `ControlPath` / `ControlPersist` are unsupported. **Part 2 is Linux + macOS only**, and the UI must not offer it on Windows. *(Worth re-verifying against current Win32-OpenSSH before implementing.)*
The macOS sandbox conclusion from Part 1 applies here too: the mux socket normally lives under `~/.ssh/`, unreachable from a sandboxed process.
`ControlPath` values also need token expansion (`%h`, `%p`, `%r`, `%C`, `%L`, `%l`, `%n`, `%u`, `%i`, `%d`) before the socket can be located. `ProxyCommandSocket._resolveCommand` only handles `%h`/`%p`/`%r`/`%%` today, so this wants a shared, more complete expander.
---
## Platform matrix
| | Linux | macOS | Windows |
|---|---|---|---|
| ssh-agent auth | Yes — Dart unix socket | Yes — non-sandboxed build only | Yes — named pipe via `sbm_ffi` |
| ControlMaster reuse | Yes — via `sbm_ffi` (SCM_RIGHTS) | Non-sandboxed build only, via `sbm_ffi` | No — unsupported by Win32-OpenSSH |
Mobile is unaffected: both features are desktop-only, gated the same way `ProxyCommand` already is (`isDesktop`).
---
## Suggested breakdown
1. `packages/dartssh2`: add `SSHSigner` with async `sign()`, thread it through `SSHClient`, implement the RFC 4252 §7 probe phase.
2. `packages/dartssh2` or app: agent protocol **client** — `SSH_AGENTC_REQUEST_IDENTITIES` / `SSH_AGENTC_SIGN_REQUEST`, with the RSA `SHA2_256`/`SHA2_512` flags. The wire constants already exist in `SSHAgentProtocol`.
3. Dart unix-socket transport (Linux/macOS) + agent-path resolution, incl. `IdentityAgent` in `SSHConfig`.
4. macOS: drop the sandbox from the DMG build, add the capability flag, hide the option on MAS. Re-check `ProxyCommand` under the same flag.
5. `sbm_ffi`: Windows named-pipe agent transport.
6. `SshCredential`: model the choice — a stored key id, the host agent, or neither — plus the edit-page UI. Note `keyId` is currently overloaded: `SSHConfig` writes a raw `IdentityFile` **path** into it (`ssh_config.dart:91`) while `getPrivateKey()` treats it as a `Stores.key` **id**, so this field needs a look regardless.
7. `sbm_ffi`: mux client with SCM_RIGHTS (Linux/macOS), `ControlPath` token expansion, wire in as the fifth socket path in `genClient()`.
8. Once 1–3 land, wiring the existing `SSHKeyPairAgent` to `SSHClient.agentHandler` gives agent **forwarding** (`ssh -A`) nearly for free — forwarding the host agent rather than in-app keys. Separate issue.
Steps 1–6 (agent auth) and step 7 (ControlMaster) are independent and can ship separately.
## Open questions
- Per-server opt-in for the agent, or a global "prefer host agent" setting with per-server override?
- When the agent holds many identities, offer picking one by comment/fingerprint in the UI, or just let the probe phase walk them all?
- ControlMaster: auto-detect an existing master from `~/.ssh/config`, or require an explicit `ControlPath` per server?
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by separating the independent agent-auth work from ControlMaster reuse. Read lib/core/utils/server.dart, lib/core/utils/ssh_config.dart, packages/dartssh2/lib/src/ssh_client.dart, and the referenced agent files; verify the existing authentication and socket paths before choosing one track. Done should include platform-appropriate host-agent or mux support, with the stated desktop capability and platform limitations reflected in the UI.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart, flutter
- Domain
- authentication, desktop, networking, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 30/100