git_sshripped_ssh_agent/
lib.rs1#![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
19const DOMAIN_SEPARATOR: &[u8] = b"git-sshripped-agent-v1";
22
23const HKDF_INFO: &[u8] = b"git-sshripped-agent-wrap-v1";
25
26#[derive(Debug, Clone)]
28pub struct AgentKey {
29 pub fingerprint: String,
31 pub public_key: ssh_key::PublicKey,
33}
34
35fn 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
52pub 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
95fn 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
103fn 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
112pub 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
144pub 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()) }
177
178fn 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
196fn extract_ed25519_signature_bytes(sig: &ssh_key::Signature) -> Vec<u8> {
201 sig.as_bytes().to_vec()
202}