lollipopkit / lollipopkit/flutter_server_box

feat: reuse local ssh-agent socket and ControlMaster connections on desktop

Open
#1,262 3 comments 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement feature lib
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.