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:
- 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 asigner://connection URI). - Public key export (
authorized_keysformat): Export the derived Ed25519 public key in standard OpenSSHssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... [comment]format ready to append to~/.ssh/authorized_keyson remote servers. - Built-in
ssh-agentsubcommand (signer-client ssh-agent): Provide an OpenSSH Agent Protocol (draft-miller-ssh-agent) listener insidesigner-clientthat binds a temporary runtime Unix domain socket (0600permissions, 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.rsroutes algorithm verbs (get_public_key,sign) throughprocess_algorithm_verb, which bypasses the role table and derives keys directly from(algorithm, index)without checking a role name.src/enforcement.rsonly allowsnostr_*verbs onRolePurpose::Nostr+RoleCurve::Secp256k1roles. Even though the TUI (src/tui.rs) and GUI (src/gui.rs) have anSSHrole preset (RolePurpose::Ssh,RoleCurve::Ed25519,m/44'/102001'/0'/0'/0'), no role-enforced verbs currently operate onRolePurpose::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 validatingrole_pathagainst 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 existingderive_one_with_pathmechanism insrc/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
- Add verb constants:
pub const VERB_SSH_GET_PUBLIC_KEY: &str = "ssh_get_public_key";pub const VERB_SSH_SIGN: &str = "ssh_sign";
- Update
enforce_verb_role:- When
verbisVERB_SSH_GET_PUBLIC_KEYorVERB_SSH_SIGN:- Require
role.purpose == RolePurpose::Ssh(elseErr(EnforceError::PurposeMismatch)). - Require
role.curve == RoleCurve::Ed25519(elseErr(EnforceError::CurveMismatch)). - Return
Ok(()).
- Require
- When
4.2 src/key_store.rs
- 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 fromself.keys[role_index]. - Calls
pq_crypto::ed25519_sign(msg, &priv_arr, &pub_arr)and returns the 64-byte signature.
- Reads the 32-byte Ed25519 private key from
4.3 src/dispatcher.rs
- Route
VERB_SSH_GET_PUBLIC_KEYandVERB_SSH_SIGNto a role-based handler (sharing role resolution and on-demand derivation withhandle_nostr_verbinsrc/dispatcher.rs). 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 thessh-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>"}.
- Options:
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>" }
- Params:
- Update
handle_get_infoinsrc/dispatcher.rsto advertise"ssh_get_public_key"and"ssh_sign"in theverbsarray. - Also support
format: "openssh"on algorithm-basedget_public_keywhenalgorithm == "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:
-
ssh-pubkey(orget-public-key --format openssh):signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-pubkey [--comment "user@laptop"]- Calls
ssh_get_public_keywithformat: "openssh". - Emits
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... <comment>tostdoutso the user can pipe directly tossh-copy-idor>> ~/.ssh/authorized_keys.
- Calls
-
ssh-sign <msg-hex>:signer-client --role ssh --path "m/44'/102001'/0'/0'/0'" ssh-sign <msg-hex>- Calls
ssh_signfor direct CLI testing and scripting.
- Calls
-
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-hostSubcommand arguments for
Verb::SshAgent:--bind-socket <path>: Optional explicit filesystem Unix socket path.--comment <text>: Optional key comment reported tossh-add -L/ssh(defaults to<role>:<path>).[command]...(via#[arg(last = true)]): Optional command and arguments to execute withSSH_AUTH_SOCKset 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:
- Socket location:
- If
--bind-socket <path>is passed, use<path>. - Otherwise, resolve
$XDG_RUNTIME_DIR(typically/run/user/<uid>, which is atmpfsRAM filesystem on Linux—never touches physical disk) and fall back to/tmpif unset, creating a private directorysigner-ssh-<pid>with0700permissions and socketagent.sockinside it.
- If
- Permissions:
- Set
umask(0o177)beforeUnixListener::bind(orfchmod/ directory0700) so only the current user (uid) can connect. - Verify peer credentials via
SO_PEERCRED(uidmust matchgeteuid()).
- Set
- Startup verification:
- Before binding the socket, connect to
signerand callssh_get_public_keywith the supplied--roleand--path(or--connect signer://...URI). - If the role does not exist or
signeris not running, fail immediately with a clear error message before creating any socket or spawningssh.
- Before binding the socket, connect to
- Connection handling:
- Each
SSH_AGENTC_SIGN_REQUESTopens a fresh request (or reuses the transport) tosignercallingssh_signwith(role, role_path, data_hex). - The private key never leaves
signer;signer-client ssh-agentonly holds the 32-byte public key in memory to answerSSH_AGENTC_REQUEST_IDENTITIESand verify that the requestedkey_blobmatches.
- Each
- Cleanup (RAII + Signal Handler):
- Implement a
SocketGuardstruct whoseDropimplementation unlinks the socket file and removes the temporary runtime directory. - Install
SIGINT/SIGTERM/SIGHUPhandlers (vialibc::sigactionsetting anAtomicBool) soCtrl-Cor termination in foreground mode cleanly exits the accept loop and runsDrop. - In child-command wrapper mode (
-- ssh user@host), run the accept loop on a background thread while the main thread waits onchild.wait(), then shut down the listener and unlink the socket immediately whensshexits.
- Implement a
6. User Workflow Examples
Step 1: Start signer and Add the SSH Role
- Start
signerin terminal 1 and enter your BIP-39 mnemonic. - Press
A(Add Role) →D(SSH role preset:ed25519,m/44'/102001'/0'/0'/0') → confirm namessh.
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 |