Files
signer/plans/ssh_implementation_plan.md

15 KiB

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

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 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 only allows nostr_* verbs on RolePurpose::Nostr + RoleCurve::Secp256k1 roles. Even though the TUI (src/tui.rs) and GUI (src/gui.rs) 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 (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.

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 avoids pulling in heavy external SSH crates.

3.1 Public Key Blob (ssh-ed25519)

For a 32-byte Ed25519 public key pk:

uint32(11) || "ssh-ed25519" || uint32(32) || pk[0..32]

Total length: 4 + 11 + 4 + 32 = 51 bytes.

3.2 authorized_keys Line Format

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:

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'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:
    • 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).
  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:
      {
        "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 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):

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

    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:

    # 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

# 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):

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

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

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 Create OpenSSH wire encoding/decoding (ssh-ed25519 key blob, authorized_keys line, signature blob, agent frame helpers)
src/lib.rs Modify Export pub mod ssh_wire;
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 Modify Add KeyStore::ssh_sign using the role's derived Ed25519 key in SecureBuf
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 Modify Add ssh_get_public_key and ssh_sign methods on SignerSigner
src/client/cli.rs Modify Add SshPubkey, SshSign, and SshAgent variants to Verb and --comment option
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 Modify Wire SshPubkey, SshSign, and SshAgent into role validation and command dispatch
README.md & src/client/README.md Modify Document SSH verbs, public key export, and ssh-agent usage