256 lines
15 KiB
Markdown
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 |
|