Files
signer/plans/ssh_implementation_plan.md

256 lines
15 KiB
Markdown

# Plan: Full SSH Authentication Support in `signer` and `signer-client`
## 1. Goal & Scope
Enable end-to-end OpenSSH public-key authentication backed by `signer`'s in-memory `mlock`'d Ed25519 keys while strictly preserving `signer`'s security invariants:
1. **Role-name-as-password authorization**: All SSH key exports and signature requests must resolve against a registered SSH role (`RolePurpose::Ssh`, `RoleCurve::Ed25519`, e.g., `m/44'/102001'/0'/0'/0'`) via `--role <name> --path <path>` (or a `signer://` connection URI).
2. **Public key export (`authorized_keys` format)**: Export the derived Ed25519 public key in standard OpenSSH `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... [comment]` format ready to append to `~/.ssh/authorized_keys` on remote servers.
3. **Built-in `ssh-agent` subcommand (`signer-client ssh-agent`)**: Provide an OpenSSH Agent Protocol (`draft-miller-ssh-agent`) listener inside `signer-client` that binds a temporary runtime Unix domain socket (`0600` permissions, automatically unlinked on exit or signal) or an explicit `--bind-socket <path>`, and optionally wraps a child command (e.g., `signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-agent -- ssh user@host`).
---
## 2. Architecture & Process Flow
### 2.1 End-to-End SSH Login Sequence
```mermaid
sequenceDiagram
participant U as User
participant S as signer (TUI / GUI)
participant A as signer-client ssh-agent
participant SSH as OpenSSH Client (ssh)
participant D as Remote sshd
U->>S: Unlock mnemonic & register SSH role (role="ssh", path="m/44'/102001'/0'/0'/0'")
U->>S: signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-pubkey
S-->>U: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... ssh:m/44'/102001'/0'/0'/0'
U->>D: Install line in ~/.ssh/authorized_keys
U->>A: Start `signer-client --role ssh --path ... ssh-agent [-- ssh user@host]`
A->>S: JSON-RPC `ssh_get_public_key` (role="ssh", role_path="...")
S-->>A: 32-byte Ed25519 pubkey + OpenSSH blob
Note over A: Binds $XDG_RUNTIME_DIR/signer-ssh-<pid>.sock (0600)
SSH->>D: Connect & start publickey userauth
SSH->>A: SSH_AGENTC_REQUEST_IDENTITIES (11)
A-->>SSH: SSH_AGENT_IDENTITIES_ANSWER (12) [key_blob, comment]
SSH->>D: Offer public key blob
D-->>SSH: Pubkey accepted; send session challenge
SSH->>A: SSH_AGENTC_SIGN_REQUEST (13) [key_blob, data, flags]
A->>S: JSON-RPC `ssh_sign` [data_hex, {role:"ssh", role_path:"..."}]
Note over S: Verifies role+path, signs with Ed25519 in mlock RAM, logs activity
S-->>A: 64-byte Ed25519 signature hex
A-->>SSH: SSH_AGENT_SIGN_RESPONSE (14) [ssh-ed25519 signature blob]
SSH->>D: Send signature
D-->>SSH: Authentication succeeded
Note over A: On exit / Ctrl-C, unlinks temp socket
```
### 2.2 Why Dedicated `ssh_*` Role-Based Verbs are Needed
In the current architecture:
- [`src/server.rs`](../src/server.rs:453) routes algorithm verbs (`get_public_key`, `sign`) through `process_algorithm_verb`, which **bypasses the role table** and derives keys directly from `(algorithm, index)` without checking a role name.
- [`src/enforcement.rs`](../src/enforcement.rs:57) only allows `nostr_*` verbs on `RolePurpose::Nostr` + `RoleCurve::Secp256k1` roles. Even though the TUI ([`src/tui.rs`](../src/tui.rs:1210)) and GUI ([`src/gui.rs`](../src/gui.rs:1171)) have an `SSH` role preset (`RolePurpose::Ssh`, `RoleCurve::Ed25519`, `m/44'/102001'/0'/0'/0'`), no role-enforced verbs currently operate on `RolePurpose::Ssh`.
Adding `ssh_get_public_key` and `ssh_sign` as **role-based verbs** closes this gap:
- Requests go through the role-selector check in [`src/server.rs`](../src/server.rs:479) (`selector_resolve`), enforcing the role name as the capability password and validating `role_path` against fixed or ranged (`%d` / `*`) templates.
- Activity logs in the TUI/GUI clearly record `<caller_id> <role_name> ed25519 <actual_path>`, matching Nostr role activity logging.
- Variable-path SSH roles (e.g., `m/44'/102001'/*'/0'/0'`) work seamlessly using the existing `derive_one_with_path` mechanism in [`src/key_store.rs`](../src/key_store.rs:118).
---
## 3. OpenSSH Wire Formats (`src/ssh_wire.rs`)
OpenSSH uses RFC 4251 `string` framing (`uint32` big-endian length followed by raw bytes). Implementing a small, zero-dependency module [`src/ssh_wire.rs`](../src/ssh_wire.rs) avoids pulling in heavy external SSH crates.
### 3.1 Public Key Blob (`ssh-ed25519`)
For a 32-byte Ed25519 public key `pk`:
```text
uint32(11) || "ssh-ed25519" || uint32(32) || pk[0..32]
```
Total length: `4 + 11 + 4 + 32 = 51 bytes`.
### 3.2 `authorized_keys` Line Format
```text
ssh-ed25519 <base64(pubkey_blob)> <comment>
```
- Default `<comment>` when not overridden: `<role>:<role_path>` (e.g., `ssh:m/44'/102001'/0'/0'/0'`).
- Can be overridden on the CLI via `--comment <text>`.
### 3.3 Signature Blob (`ssh-ed25519`)
For a 64-byte Ed25519 signature `sig`:
```text
uint32(11) || "ssh-ed25519" || uint32(64) || sig[0..64]
```
Total length: `4 + 11 + 4 + 64 = 83 bytes`.
### 3.4 `ssh-agent` Protocol Messages (`draft-miller-ssh-agent`)
Every message over the agent Unix socket is framed with a 4-byte big-endian length prefix (`uint32(payload_len) || payload`), which is identical to [`src/transport.rs`](../src/transport.rs)'s binary framing except carrying raw bytes rather than UTF-8 JSON.
| Message Constant | Byte | Direction | Payload Layout |
|---|---|---|---|
| `SSH_AGENT_FAILURE` | `5` | Agent → Client | `byte(5)` |
| `SSH_AGENTC_REQUEST_IDENTITIES` | `11` | Client → Agent | `byte(11)` |
| `SSH_AGENT_IDENTITIES_ANSWER` | `12` | Agent → Client | `byte(12) \|\| uint32(nkeys) \|\| [string(key_blob) \|\| string(comment)]*` |
| `SSH_AGENTC_SIGN_REQUEST` | `13` | Client → Agent | `byte(13) \|\| string(key_blob) \|\| string(data) \|\| uint32(flags)` |
| `SSH_AGENT_SIGN_RESPONSE` | `14` | Agent → Client | `byte(14) \|\| string(sig_blob)` |
| `SSH_AGENTC_EXTENSION` | `27` | Client → Agent | Responds with `SSH_AGENT_FAILURE` (`5`) |
| Any other message (`ADD_IDENTITY`, `REMOVE_*`, `LOCK`, etc.) | `*` | Client → Agent | Responds with `SSH_AGENT_FAILURE` (`5`) — keys are managed exclusively by `signer` |
---
## 4. Server-Side Changes (`signer`)
### 4.1 `src/enforcement.rs`
1. Add verb constants:
- `pub const VERB_SSH_GET_PUBLIC_KEY: &str = "ssh_get_public_key";`
- `pub const VERB_SSH_SIGN: &str = "ssh_sign";`
2. Update [`enforce_verb_role`](../src/enforcement.rs:57):
- When `verb` is `VERB_SSH_GET_PUBLIC_KEY` or `VERB_SSH_SIGN`:
- Require `role.purpose == RolePurpose::Ssh` (else `Err(EnforceError::PurposeMismatch)`).
- Require `role.curve == RoleCurve::Ed25519` (else `Err(EnforceError::CurveMismatch)`).
- Return `Ok(())`.
### 4.2 `src/key_store.rs`
1. Add `pub fn ssh_sign(&self, role_index: usize, msg: &[u8]) -> Result<[u8; 64], SignerError>`:
- Reads the 32-byte Ed25519 private key from `self.get_private_key(role_index)` and 32-byte public key from `self.keys[role_index]`.
- Calls `pq_crypto::ed25519_sign(msg, &priv_arr, &pub_arr)` and returns the 64-byte signature.
### 4.3 `src/dispatcher.rs`
1. Route `VERB_SSH_GET_PUBLIC_KEY` and `VERB_SSH_SIGN` to a role-based handler (sharing role resolution and on-demand derivation with `handle_nostr_verb` in [`src/dispatcher.rs`](../src/dispatcher.rs:379)).
2. **`ssh_get_public_key`**:
- Options: `{"role": "<name>", "role_path": "<path>", "format": "openssh" | "plain" | "structured", "comment": "<optional>"}`
- Default format for `ssh_get_public_key`: `"openssh"` (returns the `ssh-ed25519 <base64> <comment>` line).
- `"plain"`: returns the 64-char hex public key string.
- `"structured"`: returns JSON `{"algorithm": "ed25519", "public_key": "<hex>", "openssh": "ssh-ed25519 <base64> <comment>", "key_id": "<16hex>"}`.
3. **`ssh_sign`**:
- Params: `["<msg_hex>", {"role": "<name>", "role_path": "<path>"}]`
- Validates non-empty hex message, signs with the role's Ed25519 key, and returns structured JSON:
```json
{
"algorithm": "ed25519",
"key_id": "<16hex>",
"signature": "<128hex>",
"openssh_signature_b64": "<base64 of 83-byte ssh-ed25519 sig blob>"
}
```
4. Update `handle_get_info` in [`src/dispatcher.rs`](../src/dispatcher.rs:809) to advertise `"ssh_get_public_key"` and `"ssh_sign"` in the `verbs` array.
5. Also support `format: "openssh"` on algorithm-based `get_public_key` when `algorithm == "ed25519"` for convenience.
---
## 5. Client-Side Changes (`signer-client`)
### 5.1 CLI Additions (`src/client/cli.rs`)
Add SSH verbs and options to `signer-client`:
1. **`ssh-pubkey`** (or `get-public-key --format openssh`):
```bash
signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-pubkey [--comment "user@laptop"]
```
- Calls `ssh_get_public_key` with `format: "openssh"`.
- Emits `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... <comment>` to `stdout` so the user can pipe directly to `ssh-copy-id` or `>> ~/.ssh/authorized_keys`.
2. **`ssh-sign <msg-hex>`**:
```bash
signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-sign <msg-hex>
```
- Calls `ssh_sign` for direct CLI testing and scripting.
3. **`ssh-agent`**:
```bash
# Mode A: Foreground listener with auto-created runtime socket (prints SSH_AUTH_SOCK export line)
signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-agent
# Mode B: Explicit socket path
signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-agent --bind-socket /run/user/1000/signer.sock
# Mode C: Exec wrapper (starts agent, sets SSH_AUTH_SOCK for child, runs command, cleans up socket on exit)
signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-agent -- ssh user@remote-host
```
Subcommand arguments for `Verb::SshAgent`:
- `--bind-socket <path>`: Optional explicit filesystem Unix socket path.
- `--comment <text>`: Optional key comment reported to `ssh-add -L` / `ssh` (defaults to `<role>:<path>`).
- `[command]...` (via `#[arg(last = true)]`): Optional command and arguments to execute with `SSH_AUTH_SOCK` set in its environment.
### 5.2 Socket Lifecycle & Security (`src/client/ssh_agent.rs`)
Because OpenSSH's `ssh` binary uses standard `AF_UNIX` path sockets for `SSH_AUTH_SOCK`, `signer-client ssh-agent` manages the socket with minimal footprint and guaranteed cleanup:
1. **Socket location**:
- If `--bind-socket <path>` is passed, use `<path>`.
- Otherwise, resolve `$XDG_RUNTIME_DIR` (typically `/run/user/<uid>`, which is a `tmpfs` RAM filesystem on Linux—never touches physical disk) and fall back to `/tmp` if unset, creating a private directory `signer-ssh-<pid>` with `0700` permissions and socket `agent.sock` inside it.
2. **Permissions**:
- Set `umask(0o177)` before `UnixListener::bind` (or `fchmod` / directory `0700`) so only the current user (`uid`) can connect.
- Verify peer credentials via `SO_PEERCRED` (`uid` must match `geteuid()`).
3. **Startup verification**:
- Before binding the socket, connect to `signer` and call `ssh_get_public_key` with the supplied `--role` and `--path` (or `--connect signer://...` URI).
- If the role does not exist or `signer` is not running, fail immediately with a clear error message before creating any socket or spawning `ssh`.
4. **Connection handling**:
- Each `SSH_AGENTC_SIGN_REQUEST` opens a fresh request (or reuses the transport) to `signer` calling `ssh_sign` with `(role, role_path, data_hex)`.
- The private key never leaves `signer`; `signer-client ssh-agent` only holds the 32-byte public key in memory to answer `SSH_AGENTC_REQUEST_IDENTITIES` and verify that the requested `key_blob` matches.
5. **Cleanup (RAII + Signal Handler)**:
- Implement a `SocketGuard` struct whose `Drop` implementation unlinks the socket file and removes the temporary runtime directory.
- Install `SIGINT` / `SIGTERM` / `SIGHUP` handlers (via `libc::sigaction` setting an `AtomicBool`) so `Ctrl-C` or termination in foreground mode cleanly exits the accept loop and runs `Drop`.
- In child-command wrapper mode (`-- ssh user@host`), run the accept loop on a background thread while the main thread waits on `child.wait()`, then shut down the listener and unlink the socket immediately when `ssh` exits.
---
## 6. User Workflow Examples
### Step 1: Start `signer` and Add the SSH Role
1. Start `signer` in terminal 1 and enter your BIP-39 mnemonic.
2. Press `A` (Add Role) → `D` (SSH role preset: `ed25519`, `m/44'/102001'/0'/0'/0'`) → confirm name `ssh`.
### Step 2: Export Public Key to Remote Server
```bash
# Print the authorized_keys line
signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-pubkey
# Or append directly to a remote server
signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-pubkey \
| ssh user@server 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
```
### Step 3: Log In via `signer`
**One-shot wrapper (cleanest — no persistent socket left around):**
```bash
signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-agent -- ssh user@server
```
**Foreground agent session (for multiple SSH connections / `git` / `scp` / `rsync`):**
```bash
# Terminal 2:
signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-agent --bind-socket "$XDG_RUNTIME_DIR/signer-ssh.sock"
# Terminal 3:
export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/signer-ssh.sock"
ssh-add -L
ssh user@server
```
**Across Qubes / TCP / Serial using a `signer://` URI:**
```bash
signer-client --connect "signer://qrexec/sys-signer:qubes.SignerRpc?role=ssh&path=m%2F44%27%2F102001%27%2F0%27%2F0%27%2F0%27" \
ssh-agent -- ssh user@server
```
---
## 7. Files to Create / Modify
| File | Action | Purpose |
|---|---|---|
| [`src/ssh_wire.rs`](../src/ssh_wire.rs) | Create | OpenSSH wire encoding/decoding (`ssh-ed25519` key blob, `authorized_keys` line, signature blob, agent frame helpers) |
| [`src/lib.rs`](../src/lib.rs) | Modify | Export `pub mod ssh_wire;` |
| [`src/enforcement.rs`](../src/enforcement.rs) | Modify | Add `VERB_SSH_GET_PUBLIC_KEY` and `VERB_SSH_SIGN`; allow `RolePurpose::Ssh` + `RoleCurve::Ed25519` in `enforce_verb_role` |
| [`src/key_store.rs`](../src/key_store.rs) | Modify | Add `KeyStore::ssh_sign` using the role's derived Ed25519 key in `SecureBuf` |
| [`src/dispatcher.rs`](../src/dispatcher.rs) | Modify | Handle `ssh_get_public_key` and `ssh_sign` via role selector resolution; advertise verbs in `get_info` |
| [`src/client/signer.rs`](../src/client/signer.rs) | Modify | Add `ssh_get_public_key` and `ssh_sign` methods on `SignerSigner` |
| [`src/client/cli.rs`](../src/client/cli.rs) | Modify | Add `SshPubkey`, `SshSign`, and `SshAgent` variants to `Verb` and `--comment` option |
| [`src/client/ssh_agent.rs`](../src/client/ssh_agent.rs) | Create | Implement `ssh-agent` protocol handler, runtime socket guard, peer-cred check, and child command runner |
| [`src/client/main.rs`](../src/client/main.rs) | Modify | Wire `SshPubkey`, `SshSign`, and `SshAgent` into role validation and command dispatch |
| [`README.md`](../README.md) & [`src/client/README.md`](../src/client/README.md) | Modify | Document SSH verbs, public key export, and `ssh-agent` usage |