Skip to main content

git_sshripped_ssh_agent/
lib.rs

1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
2#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5use std::path::Path;
6
7use anyhow::{Context, Result};
8use base64::Engine as _;
9use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD as BASE64URL};
10use chacha20poly1305::aead::Aead;
11use chacha20poly1305::{ChaCha20Poly1305, KeyInit};
12use hkdf::Hkdf;
13use sha2::{Digest, Sha256};
14use ssh_agent_client_rs::Client;
15use ssh_key::public::KeyData;
16
17pub use git_sshripped_ssh_agent_models::AgentWrappedKey;
18
19/// Domain separator prepended to the challenge before signing, preventing
20/// cross-protocol signature reuse.
21const DOMAIN_SEPARATOR: &[u8] = b"git-sshripped-agent-v1";
22
23/// HKDF info string for deriving the wrapping key from a signature.
24const HKDF_INFO: &[u8] = b"git-sshripped-agent-wrap-v1";
25
26/// An SSH key available in the running SSH agent.
27#[derive(Debug, Clone)]
28pub struct AgentKey {
29    /// Fingerprint in git-sshripped's format: `base64url_no_pad(SHA256(key_type + ":" + key_data))`.
30    pub fingerprint: String,
31    /// The parsed public key (used to request signatures from the agent).
32    pub public_key: ssh_key::PublicKey,
33}
34
35/// Compute a fingerprint in the same format that git-sshripped uses for
36/// recipient files and wrapped key filenames.
37///
38/// The format is `base64url_no_pad(SHA256("key_type:base64_key_data"))`,
39/// e.g. `nUy4xy4qXy07aLaplZjYi1K3ybk5-0XS8PWNkGb8vxk`.
40fn git_sshripped_fingerprint(openssh_line: &str) -> Option<String> {
41    let mut parts = openssh_line.split_whitespace();
42    let key_type = parts.next()?;
43    let key_body = parts.next()?;
44
45    let mut hasher = Sha256::new();
46    hasher.update(key_type.as_bytes());
47    hasher.update([b':']);
48    hasher.update(key_body.as_bytes());
49    Some(BASE64URL.encode(hasher.finalize()))
50}
51
52/// Connect to the SSH agent and list all available Ed25519 keys.
53///
54/// Returns an empty vec if `SSH_AUTH_SOCK` is not set or the agent is
55/// unreachable.
56///
57/// # Errors
58///
59/// Returns an error only on unexpected I/O failures *after* a successful
60/// connection.  A missing `SSH_AUTH_SOCK` or connection refusal is treated
61/// as "no agent" and returns `Ok(vec![])`.
62pub fn list_agent_ed25519_keys() -> Result<Vec<AgentKey>> {
63    let Some(sock) = std::env::var_os("SSH_AUTH_SOCK") else {
64        return Ok(Vec::new());
65    };
66    let sock_path = Path::new(&sock);
67    let Ok(mut client) = Client::connect(sock_path) else {
68        return Ok(Vec::new());
69    };
70    let identities = client
71        .list_all_identities()
72        .context("failed to list SSH agent identities")?;
73
74    let mut keys = Vec::new();
75    for identity in identities {
76        let pubkey: &ssh_key::PublicKey = match &identity {
77            ssh_agent_client_rs::Identity::PublicKey(boxed_cow) => boxed_cow.as_ref(),
78            ssh_agent_client_rs::Identity::Certificate(_) => continue,
79        };
80        if !matches!(pubkey.key_data(), KeyData::Ed25519(_)) {
81            continue;
82        }
83        let openssh_line = pubkey.to_openssh().unwrap_or_default();
84        let Some(fingerprint) = git_sshripped_fingerprint(&openssh_line) else {
85            continue;
86        };
87        keys.push(AgentKey {
88            fingerprint,
89            public_key: pubkey.clone(),
90        });
91    }
92    Ok(keys)
93}
94
95/// Build the data blob that gets signed by the agent.
96fn sign_payload(challenge: &[u8]) -> Vec<u8> {
97    let mut payload = Vec::with_capacity(DOMAIN_SEPARATOR.len() + challenge.len());
98    payload.extend_from_slice(DOMAIN_SEPARATOR);
99    payload.extend_from_slice(challenge);
100    payload
101}
102
103/// Derive a 32-byte `ChaCha20Poly1305` key from an Ed25519 signature.
104fn derive_wrap_key(signature_bytes: &[u8], challenge: &[u8]) -> Result<[u8; 32]> {
105    let hk = Hkdf::<Sha256>::new(Some(challenge), signature_bytes);
106    let mut key = [0u8; 32];
107    hk.expand(HKDF_INFO, &mut key)
108        .map_err(|e| anyhow::anyhow!("HKDF expand failed: {e}"))?;
109    Ok(key)
110}
111
112/// Wrap (encrypt) a repo key using the SSH agent.
113///
114/// Asks the agent to sign a fresh random challenge with `agent_key`, then
115/// derives a symmetric key and encrypts `repo_key`.
116///
117/// # Errors
118///
119/// Returns an error if the agent refuses to sign or a cryptographic
120/// operation fails.
121pub fn agent_wrap_repo_key(agent_key: &AgentKey, repo_key: &[u8]) -> Result<AgentWrappedKey> {
122    let challenge: [u8; 32] = rand::random();
123
124    let signature = sign_with_agent(agent_key, &challenge)?;
125    let wrap_key = derive_wrap_key(&signature, &challenge)?;
126
127    let nonce_bytes: [u8; 12] = rand::random();
128    let nonce = chacha20poly1305::Nonce::from(nonce_bytes);
129    let cipher = ChaCha20Poly1305::new_from_slice(&wrap_key)
130        .map_err(|e| anyhow::anyhow!("ChaCha20Poly1305 key init failed: {e}"))?;
131    let ciphertext = cipher
132        .encrypt(&nonce, repo_key)
133        .map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
134
135    Ok(AgentWrappedKey {
136        version: 1,
137        fingerprint: agent_key.fingerprint.clone(),
138        challenge: BASE64.encode(challenge),
139        nonce: BASE64.encode(nonce_bytes),
140        encrypted_repo_key: BASE64.encode(ciphertext),
141    })
142}
143
144/// Unwrap (decrypt) a repo key using the SSH agent.
145///
146/// Asks the agent to re-sign the stored challenge, derives the same
147/// symmetric key, and decrypts. Returns `Ok(None)` if the AEAD tag does
148/// not verify (wrong key or non-deterministic agent).
149///
150/// # Errors
151///
152/// Returns an error on I/O or base64-decoding failures.
153pub fn agent_unwrap_repo_key(
154    agent_key: &AgentKey,
155    wrapped: &AgentWrappedKey,
156) -> Result<Option<Vec<u8>>> {
157    let challenge = BASE64
158        .decode(&wrapped.challenge)
159        .context("invalid base64 in agent-wrap challenge")?;
160    let nonce_bytes = BASE64
161        .decode(&wrapped.nonce)
162        .context("invalid base64 in agent-wrap nonce")?;
163    let ciphertext = BASE64
164        .decode(&wrapped.encrypted_repo_key)
165        .context("invalid base64 in agent-wrap ciphertext")?;
166
167    let nonce = chacha20poly1305::Nonce::from_slice(&nonce_bytes);
168
169    let signature = sign_with_agent(agent_key, &challenge)?;
170    let wrap_key = derive_wrap_key(&signature, &challenge)?;
171
172    let cipher = ChaCha20Poly1305::new_from_slice(&wrap_key)
173        .map_err(|e| anyhow::anyhow!("ChaCha20Poly1305 key init failed: {e}"))?;
174
175    Ok(cipher.decrypt(nonce, ciphertext.as_ref()).ok()) // Tag mismatch returns None: wrong key or non-deterministic agent
176}
177
178/// Ask the SSH agent to sign a challenge with the given key, returning the
179/// raw signature bytes.
180fn sign_with_agent(agent_key: &AgentKey, challenge: &[u8]) -> Result<Vec<u8>> {
181    let Some(sock) = std::env::var_os("SSH_AUTH_SOCK") else {
182        anyhow::bail!("SSH_AUTH_SOCK is not set");
183    };
184    let sock_path = Path::new(&sock);
185    let mut client =
186        Client::connect(sock_path).context("failed to connect to SSH agent for signing")?;
187
188    let payload = sign_payload(challenge);
189    let signature = client
190        .sign(&agent_key.public_key, &payload)
191        .context("SSH agent refused to sign")?;
192
193    Ok(extract_ed25519_signature_bytes(&signature))
194}
195
196/// Extract the raw 64-byte Ed25519 signature from the SSH wire format.
197///
198/// The `ssh-key` crate's `Signature` contains algorithm-prefixed data;
199/// for Ed25519 the raw bytes are the 64-byte signature itself.
200fn extract_ed25519_signature_bytes(sig: &ssh_key::Signature) -> Vec<u8> {
201    sig.as_bytes().to_vec()
202}