v0.0.47 - Clean up main menu layout and hotkeys
This commit is contained in:
263
plans/ssh_agent_proxy.md
Normal file
263
plans/ssh_agent_proxy.md
Normal 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`.
|
||||
40
src/main.c
40
src/main.c
@@ -757,8 +757,8 @@ int socket_name_random(char *out, size_t out_len);
|
||||
/* Version information (auto-updated by build/version tooling) */
|
||||
#define NSIGNER_VERSION_MAJOR 0
|
||||
#define NSIGNER_VERSION_MINOR 0
|
||||
#define NSIGNER_VERSION_PATCH 46
|
||||
#define NSIGNER_VERSION "v0.0.46"
|
||||
#define NSIGNER_VERSION_PATCH 47
|
||||
#define NSIGNER_VERSION "v0.0.47"
|
||||
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
@@ -819,10 +819,10 @@ static char g_connection_info[CONNECTION_INFO_CAP][192];
|
||||
static int g_connection_info_count = 0;
|
||||
|
||||
static const TuiMenuItem g_main_menu_items[] = {
|
||||
{"^_q^:/x quit", 'q'},
|
||||
{"^_l^: lock/reunlock", 'l'},
|
||||
{"^_r^: refresh", 'r'},
|
||||
{"^_A^: toggle auto-approve", 'a'}
|
||||
{"^_a^: toggle auto-approve", 'a'},
|
||||
{"^_q^:/x quit", 'q'}
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
@@ -1437,7 +1437,7 @@ static void render_status(const role_table_t *role_table,
|
||||
TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Main Menu" };
|
||||
TuiMenu menu = { g_main_menu_items, (int)(sizeof(g_main_menu_items) / sizeof(g_main_menu_items[0])) };
|
||||
TuiSize size = tui_terminal_size();
|
||||
int left_col = tui_menu_left_col(&frame, size.width);
|
||||
const int left_col = 0;
|
||||
char status_buf[256];
|
||||
|
||||
tui_clear_continuous(size.height);
|
||||
@@ -1453,19 +1453,6 @@ static void render_status(const role_table_t *role_table,
|
||||
}
|
||||
|
||||
tui_print("");
|
||||
tui_render_menu(&menu, left_col);
|
||||
tui_print("");
|
||||
|
||||
(void)snprintf(status_buf,
|
||||
sizeof(status_buf),
|
||||
"session=%s (%d words) signer=%s derived=%d auto-approve=%s",
|
||||
mnemonic_is_loaded(mnemonic) ? "unlocked" : "locked",
|
||||
(mnemonic != NULL) ? mnemonic->word_count : 0,
|
||||
(socket_name != NULL) ? socket_name : "(none)",
|
||||
derived_count,
|
||||
g_auto_approve ? "ON" : "OFF");
|
||||
tui_print("%s", status_buf);
|
||||
|
||||
tui_print("^*Roles^:");
|
||||
if (role_table == NULL || role_table->count == 0) {
|
||||
tui_print("(none)");
|
||||
@@ -1502,6 +1489,19 @@ static void render_status(const role_table_t *role_table,
|
||||
}
|
||||
}
|
||||
|
||||
tui_print("");
|
||||
(void)snprintf(status_buf,
|
||||
sizeof(status_buf),
|
||||
"session=%s (%d words) signer=%s derived=%d auto-approve=%s",
|
||||
mnemonic_is_loaded(mnemonic) ? "unlocked" : "locked",
|
||||
(mnemonic != NULL) ? mnemonic->word_count : 0,
|
||||
(socket_name != NULL) ? socket_name : "(none)",
|
||||
derived_count,
|
||||
g_auto_approve ? "ON" : "OFF");
|
||||
tui_print("%s", status_buf);
|
||||
tui_print("");
|
||||
tui_render_menu(&menu, left_col);
|
||||
|
||||
tui_anchor_prompt(0, left_col);
|
||||
fflush(stdout);
|
||||
}
|
||||
@@ -1848,7 +1848,7 @@ static void apply_test_overrides(policy_table_t *policy) {
|
||||
const char *p = hotkeys;
|
||||
while (*p != '\0') {
|
||||
char ch = *p;
|
||||
if (ch == 'A') {
|
||||
if (ch == 'a') {
|
||||
g_auto_approve = g_auto_approve ? 0 : 1;
|
||||
server_set_prompt_always_allow(g_auto_approve);
|
||||
}
|
||||
@@ -2612,7 +2612,7 @@ int main(int argc, char *argv[]) {
|
||||
g_running = 0;
|
||||
} else if (lower == 'r') {
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
} else if (ch == 'A') {
|
||||
} else if (ch == 'a') {
|
||||
g_auto_approve = g_auto_approve ? 0 : 1;
|
||||
server_set_prompt_always_allow(g_auto_approve);
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
|
||||
Reference in New Issue
Block a user