Files
n_signer/plans/ssh_agent_proxy.md
2026-07-16 17:59:58 -04:00

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:

  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:

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.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:

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

  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 forwardingssh -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.