12 KiB
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
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:
- User in untrusted qube runs
ssh user@server - OpenSSH connects to the proxy via
SSH_AUTH_SOCK - OpenSSH sends
SSH_AGENTC_REQUEST_IDENTITIES— proxy fetches the ed25519 public key from n_signer and returns it - OpenSSH sends the public key to the remote server as part of
SSH_MSG_USERAUTH_REQUEST - The server sends back a challenge (the data to sign)
- OpenSSH sends
SSH_AGENTC_SIGN_REQUESTwith the challenge data to the proxy - The proxy forwards the data to n_signer via qrexec:
{"method":"sign","params":["<hex>","algorithm":"ed25519","index":0]} - n_signer signs the data with the ed25519 private key and returns the signature
- The proxy returns the signature to OpenSSH
- 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) — returnSSH_AGENT_SUCCESSSSH_AGENTC_REMOVE_IDENTITY(18) — returnSSH_AGENT_SUCCESSSSH_AGENTC_LOCK/SSH_AGENTC_UNLOCK(22/23) — returnSSH_AGENT_SUCCESSSSH_AGENTC_ADD_IDENTITY(17) — returnSSH_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:
- Creates a Unix socket at a configurable path (default:
$XDG_RUNTIME_DIR/nsigner-ssh-agent.sock) - Listens for connections from OpenSSH
- 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_ANSWERwith one key
- Calls n_signer via qrexec to get the ed25519 public key:
- 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
- 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:
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:
# 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.NsignerRpcservice 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:
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:
$(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
- 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.
- 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.
- Agent forwarding —
ssh -A(forwarding the agent to a remote host) is not supported. The proxy only accepts local Unix socket connections. - Multiple signer qubes — the proxy talks to one n_signer instance. Multiple signers would require multiple proxy instances.
- 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
signverb withalgorithm=ml-dsa-65.
7. Implementation phases
Phase 1: Core proxy
- Implement
nsigner-ssh-agent.cwith ssh-agent protocol handling - Implement qrexec communication with n_signer
- Support
SSH_AGENTC_REQUEST_IDENTITIESandSSH_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
--preapprovedocumentation
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_EXTENSIONfor OpenSSH extensions
8. Open questions
-
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.
-
Should the proxy support
ssh-add -l?ssh-add -llists the keys the agent holds, which maps toSSH_AGENTC_REQUEST_IDENTITIES. Yes, this is already supported by the protocol handling. -
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_IDENTITIEScall. Clear the cache onSIGHUPor when the qrexec call fails. -
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 standardssh-ed25519.