v0.0.47 - Clean up main menu layout and hotkeys

This commit is contained in:
Laan Tungir
2026-07-16 17:59:58 -04:00
parent 16a6da817c
commit a7c6de2dcd
3 changed files with 283 additions and 20 deletions

263
plans/ssh_agent_proxy.md Normal file
View File

@@ -0,0 +1,263 @@
# Plan: SSH Agent Proxy Bridge for n_signer
## 1. Goal
Allow untrusted Qubes qubes to SSH into remote servers using an ed25519 private key held safely in n_signer, without the private key ever leaving the signer qube.
The untrusted qube runs a small proxy program that implements the OpenSSH `ssh-agent` protocol. OpenSSH talks to the proxy as if it were a normal `ssh-agent`. The proxy forwards signing requests to n_signer via qrexec. The private key never touches the untrusted qube's memory.
## 2. Architecture
```mermaid
graph LR
SSH[ssh client in untrusted qube] -->|SSH_AUTH_SOCK| PROXY[nsigner-ssh-agent proxy]
PROXY -->|qrexec qubes.NsignerRpc| SIGNER[n_signer in nostr_signer qube]
SIGNER -->|derives ed25519 key from mnemonic| KEY[ed25519 private key in mlock'd RAM]
SSH -->|SSH protocol| SERVER[remote SSH server]
```
**Flow:**
1. User in untrusted qube runs `ssh user@server`
2. OpenSSH connects to the proxy via `SSH_AUTH_SOCK`
3. OpenSSH sends `SSH_AGENTC_REQUEST_IDENTITIES` — proxy fetches the ed25519 public key from n_signer and returns it
4. OpenSSH sends the public key to the remote server as part of `SSH_MSG_USERAUTH_REQUEST`
5. The server sends back a challenge (the data to sign)
6. OpenSSH sends `SSH_AGENTC_SIGN_REQUEST` with the challenge data to the proxy
7. The proxy forwards the data to n_signer via qrexec: `{"method":"sign","params":["<hex>","algorithm":"ed25519","index":0]}`
8. n_signer signs the data with the ed25519 private key and returns the signature
9. The proxy returns the signature to OpenSSH
10. OpenSSH sends the signed authentication response to the server
**Key security property:** The private key exists only in n_signer's mlock'd memory in the `nostr_signer` qube. The untrusted qube never sees it. The proxy only ever handles public keys and signing requests/responses.
## 3. The ssh-agent protocol
OpenSSH's agent protocol is defined in `draft-miller-ssh-agent` (PROTOCOL.agent in the OpenSSH source). It uses a Unix socket with a simple message framing:
**Message format:**
```
uint32 message_length
byte message_type
byte[] message_data
```
**Messages the proxy must handle:**
### SSH_AGENTC_REQUEST_IDENTITIES (11)
Request the list of keys the agent holds.
**Response: SSH_AGENT_IDENTITIES_ANSWER (12)**
```
uint32 num_keys
string key_blob_1 (public key in SSH wire format)
string key_comment_1 (human-readable comment)
string key_blob_2
string key_comment_2
...
```
The proxy returns one key: the ed25519 public key from n_signer, formatted as an SSH ed25519 public key blob.
**SSH ed25519 public key blob format:**
```
string "ssh-ed25519"
string <32-byte public key>
```
### SSH_AGENTC_SIGN_REQUEST (13)
```
string key_blob (the public key to sign with)
string data (the data to sign)
uint32 flags (SSH_AGENT_SIGN_FLAG_* — currently 0 or SSH_AGENT_FLAG_RSA_SHA2_256/512 for RSA only)
```
**Response: SSH_AGENT_SIGN_RESPONSE (14)**
```
string signature_blob
```
**SSH ed25519 signature blob format:**
```
string "ssh-ed25519"
string <64-byte signature>
```
### Other messages
- `SSH_AGENTC_REMOVE_ALL_IDENTITIES` (11) — return `SSH_AGENT_SUCCESS`
- `SSH_AGENTC_REMOVE_IDENTITY` (18) — return `SSH_AGENT_SUCCESS`
- `SSH_AGENTC_LOCK` / `SSH_AGENTC_UNLOCK` (22/23) — return `SSH_AGENT_SUCCESS`
- `SSH_AGENTC_ADD_IDENTITY` (17) — return `SSH_AGENT_FAILURE` (we don't allow adding keys)
- Any unknown message — return `SSH_AGENT_FAILURE` (5)
## 4. Implementation
### 4.1 New program: `nsigner-ssh-agent`
A small C program (~400-500 lines) that:
1. Creates a Unix socket at a configurable path (default: `$XDG_RUNTIME_DIR/nsigner-ssh-agent.sock`)
2. Listens for connections from OpenSSH
3. On `SSH_AGENTC_REQUEST_IDENTITIES`:
- Calls n_signer via qrexec to get the ed25519 public key: `{"method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}`
- Parses the structured response to extract the 32-byte public key
- Formats it as an SSH ed25519 key blob
- Returns `SSH_AGENT_IDENTITIES_ANSWER` with one key
4. On `SSH_AGENTC_SIGN_REQUEST`:
- Extracts the data to sign from the request
- Calls n_signer via qrexec: `{"method":"sign","params":["<data_hex>",{"algorithm":"ed25519","index":0}]}`
- Parses the response to extract the 64-byte signature
- Formats it as an SSH ed25519 signature blob
- Returns `SSH_AGENT_SIGN_RESPONSE`
5. On other messages: returns appropriate responses (see §3)
**Key caching:** The proxy caches the public key after the first `REQUEST_IDENTITIES` call (it doesn't change during a session). The private key is never cached — each sign request goes to n_signer.
**Multiple keys:** The proxy can support multiple ed25519 keys by using different `index` values. Configuration via command-line args or environment variables:
```bash
nsigner-ssh-agent --index 0 --index 1 --index 2
```
Each index produces a different ed25519 key, all derived from the same mnemonic.
### 4.2 qrexec communication
The proxy communicates with n_signer via `qrexec-client-vm nostr_signer qubes.NsignerRpc`, using the same 4-byte big-endian length framing as the existing qrexec examples.
Each qrexec call is a one-shot: spawn `qrexec-client-vm`, send one framed request, receive one framed response, exit. This matches the existing qrexec protocol.
### 4.3 Configuration
**In the untrusted qube:**
```bash
# Start the proxy in the background
nsigner-ssh-agent --socket $XDG_RUNTIME_DIR/nsigner-ssh-agent.sock &
export SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/nsigner-ssh-agent.sock
export SSH_AUTH_SIGNER_QUBE=nostr_signer
# Now use ssh normally
ssh user@server
```
**In the nostr_signer qube:**
- n_signer must be running with the mnemonic loaded
- The `qubes.NsignerRpc` service must be installed
- The qrexec policy must allow the untrusted qube to call `qubes.NsignerRpc`
**Qubes policy (`packaging/qubes/policy.d/40-nsigner.policy`):**
```
nsigner.NsignerRpc +untrusted-qube nostr_signer allow
```
Or with the deny-by-default model from n_signer's policy:
```
nsigner.NsignerRpc +untrusted-qube nostr_signer ask
```
The `ask` policy shows a Qubes dom0 prompt each time the untrusted qube tries to sign, giving the user a chance to approve/deny.
### 4.4 n_signer policy
Use n_signer's `--preapprove` to pre-approve the untrusted qube for ed25519 signing:
```bash
nsigner --preapprove caller=qubes:untrusted-qube,algorithm=ed25519,index=0,verb=sign,get_public_key
```
Or use the deny-by-default prompt model — each sign request shows a prompt in the signer qube's TUI:
```
Caller: qubes:untrusted-qube
Action: sign with ed25519
Key: index 0 (key_id: 1fd9c73a93189484)
[a] approve [d] deny
```
### 4.5 File structure
```
src/nsigner_ssh_agent.c — the proxy program
packaging/qubes/
install-ssh-agent.sh — install the proxy in an AppVM
ssh-agent.desktop — autostart the proxy on qube boot
```
### 4.6 Build
The proxy is a separate binary, not part of the main nsigner binary. It links against:
- cJSON (for JSON-RPC parsing)
- libnostr_core (for the qrexec transport framing)
Makefile target:
```makefile
$(BUILD_DIR)/nsigner-ssh-agent: src/nsigner_ssh_agent.c
$(CC) $(CFLAGS) src/nsigner_ssh_agent.c -o $(BUILD_DIR)/nsigner-ssh-agent $(LDFLAGS)
```
## 5. Security considerations
### 5.1 The untrusted qube sees the public key
The ed25519 public key is sent to the untrusted qube. This is fine — public keys are not secret. The untrusted qube could share the public key with anyone, but that doesn't compromise the private key.
### 5.2 The untrusted qube controls what is signed
The untrusted qube constructs the SSH authentication message and sends it to the proxy for signing. The proxy forwards it to n_signer, which signs it without inspecting the content.
**Risk:** A compromised untrusted qube could ask n_signer to sign arbitrary data, not just SSH authentication messages. The ed25519 signature could be used for purposes other than SSH.
**Mitigation:** This is the same trust model as any ssh-agent. A compromised process with access to `SSH_AUTH_SOCK` can sign arbitrary data. The n_signer policy model (deny-by-default with per-request approval) provides an additional layer: the user can see each sign request at the signer's TUI and approve/deny it.
### 5.3 The proxy holds no secrets
The proxy process in the untrusted qube holds no private key material. It only has:
- The Unix socket path
- The qrexec target qube name
- The cached public key (non-secret)
- The ed25519 index to use
If the proxy process is compromised, the attacker gains the ability to forward sign requests to n_signer — but n_signer's policy still gates each request.
### 5.4 Qubes qrexec authentication
The qrexec framework authenticates the source qube. n_signer sees the caller as `qubes:<source-vm>`. This means:
- The signer knows which qube is requesting the signature
- The Qubes dom0 policy controls which qubes can call the service
- The n_signer policy can approve specific qubes for specific algorithms/verbs
### 5.5 No private key on disk
The ed25519 private key is never written to disk. It's derived from the mnemonic in n_signer's mlock'd RAM on each startup. The proxy doesn't have the mnemonic. The untrusted qube doesn't have the mnemonic. Only the `nostr_signer` qube has the mnemonic (entered at startup, never persisted).
## 6. What is explicitly out of scope
1. **RSA key support** — the proxy only supports ed25519. RSA SSH keys require different signing (PKCS#1 v1.5 or PSS) and different key derivation. ed25519 is the modern default.
2. **SSH certificate support** — OpenSSH certificates (ssh-ed25519-cert-v01@openssh.com) are not supported. These require a CA key to sign the certificate, which is a separate workflow.
3. **Agent forwarding**`ssh -A` (forwarding the agent to a remote host) is not supported. The proxy only accepts local Unix socket connections.
4. **Multiple signer qubes** — the proxy talks to one n_signer instance. Multiple signers would require multiple proxy instances.
5. **Hybrid PQ SSH keys** — OpenSSH doesn't support PQ signing keys yet. When it does, the proxy can be extended to use ML-DSA-65 via n_signer's `sign` verb with `algorithm=ml-dsa-65`.
## 7. Implementation phases
### Phase 1: Core proxy
- Implement `nsigner-ssh-agent.c` with ssh-agent protocol handling
- Implement qrexec communication with n_signer
- Support `SSH_AGENTC_REQUEST_IDENTITIES` and `SSH_AGENTC_SIGN_REQUEST`
- Test with a real SSH connection
### Phase 2: Qubes integration
- Install script for AppVMs
- Autostart on qube boot
- Qubes policy documentation
- n_signer `--preapprove` documentation
### Phase 3: Hardening
- Connection rate limiting (prevent sign-request flooding)
- Optional: inspect the sign request to verify it looks like an SSH auth message
- Optional: support multiple ed25519 indices (multiple SSH identities)
- Optional: support `SSH_AGENTC_REQUEST_EXTENSION` for OpenSSH extensions
## 8. Open questions
1. **Should the proxy inspect the sign request data?** The proxy could check that the data being signed looks like an SSH authentication message (starts with the session ID, has the right message type). This would prevent the untrusted qube from using the signer for non-SSH signing. However, it's fragile (SSH protocol may change) and doesn't match how normal ssh-agents work. Default: no inspection, rely on n_signer's policy.
2. **Should the proxy support `ssh-add -l`?** `ssh-add -l` lists the keys the agent holds, which maps to `SSH_AGENTC_REQUEST_IDENTITIES`. Yes, this is already supported by the protocol handling.
3. **Should the proxy cache the public key across connections?** Yes — the public key doesn't change during a session. Cache it after the first `REQUEST_IDENTITIES` call. Clear the cache on `SIGHUP` or when the qrexec call fails.
4. **Should we support `sk-ssh-ed25519@openssh.com` (security key) key type?** This is the YubiKey key type. It adds a "flags" byte to the signature (e.g., "require user presence"). Not needed for n_signer, but could be useful for compatibility with systems that expect security key signatures. Default: no, use standard `ssh-ed25519`.