bc_components/encrypted_key/
ssh_agent_params.rs1use std::{cell::RefCell, env, path::Path, rc::Rc};
2
3use anyhow::{Context, Result, bail};
4use bc_crypto::hkdf_hmac_sha256;
5use dcbor::prelude::*;
6use ssh_agent_client_rs::Client;
7
8use super::{KeyDerivation, KeyDerivationMethod, SALT_LEN};
9use crate::{EncryptedMessage, Nonce, Salt, SymmetricKey};
10
11#[allow(dead_code)]
12pub trait SSHAgent {
13 fn list_identities(&mut self) -> Result<Vec<ssh_key::PublicKey>>;
14 fn add_identity(&mut self, key: &ssh_key::PrivateKey) -> Result<()>;
15 fn remove_identity(&mut self, key: &ssh_key::PrivateKey) -> Result<()>;
16 fn remove_all_identities(&mut self) -> Result<()>;
17 fn sign(
18 &mut self,
19 key: &ssh_key::PublicKey,
20 data: &[u8],
21 ) -> Result<ssh_key::Signature>;
22}
23
24impl SSHAgent for Client {
25 fn list_identities(&mut self) -> Result<Vec<ssh_key::PublicKey>> {
26 self.list_identities()
27 .map_err(|e| anyhow::anyhow!(e.to_string()))
28 }
29
30 fn add_identity(&mut self, key: &ssh_key::PrivateKey) -> Result<()> {
31 self.add_identity(key)
32 .map_err(|e| anyhow::anyhow!(e.to_string()))
33 }
34
35 fn remove_identity(&mut self, key: &ssh_key::PrivateKey) -> Result<()> {
36 self.remove_identity(key)
37 .map_err(|e| anyhow::anyhow!(e.to_string()))
38 }
39
40 fn remove_all_identities(&mut self) -> Result<()> {
41 self.remove_all_identities()
42 .map_err(|e| anyhow::anyhow!(e.to_string()))
43 }
44
45 fn sign(
46 &mut self,
47 key: &ssh_key::PublicKey,
48 data: &[u8],
49 ) -> Result<ssh_key::Signature> {
50 self.sign(key, data)
51 .map_err(|e| anyhow::anyhow!(e.to_string()))
52 }
53}
54
55#[derive(Clone)]
62pub struct SSHAgentParams {
63 salt: Salt,
64 id: String,
65
66 agent: Option<Rc<RefCell<dyn SSHAgent + 'static>>>,
67}
68
69impl PartialEq for SSHAgentParams {
70 fn eq(&self, other: &Self) -> bool {
71 self.salt == other.salt && self.id == other.id
72 }
73}
74
75impl Eq for SSHAgentParams {}
76
77impl std::fmt::Debug for SSHAgentParams {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.debug_struct("SSHAgentParams")
80 .field("salt", &self.salt)
81 .field("id", &self.id)
82 .finish()
83 }
84}
85
86impl SSHAgentParams {
87 pub fn new() -> Self {
88 Self::new_opt(
89 Salt::new_with_len(SALT_LEN).unwrap(),
90 String::new(),
91 None,
92 )
93 }
94
95 pub fn new_opt(
96 salt: Salt,
97 id: impl AsRef<str>,
98 agent: Option<Rc<RefCell<dyn SSHAgent + 'static>>>,
99 ) -> Self {
100 Self { salt, id: id.as_ref().to_string(), agent }
101 }
102
103 pub fn salt(&self) -> &Salt { &self.salt }
104
105 pub fn id(&self) -> &String { &self.id }
106
107 pub fn agent(&self) -> Option<Rc<RefCell<dyn SSHAgent + 'static>>> {
108 self.agent.clone()
109 }
110
111 pub fn set_agent(
112 &mut self,
113 agent: Option<Rc<RefCell<dyn SSHAgent + 'static>>>,
114 ) {
115 self.agent = agent;
116 }
117}
118
119impl Default for SSHAgentParams {
120 fn default() -> Self { Self::new() }
121}
122
123pub fn connect_to_ssh_agent() -> Result<Rc<RefCell<dyn SSHAgent + 'static>>> {
125 let sock =
126 env::var("SSH_AUTH_SOCK").context("SSH_AUTH_SOCK env var not set")?;
127 let client =
128 Client::connect(Path::new(&sock)).context("no ssh-agent reachable")?;
129 Ok(Rc::new(RefCell::new(client)))
130}
131
132impl KeyDerivation for SSHAgentParams {
133 const INDEX: usize = KeyDerivationMethod::SSHAgent as usize;
134
135 fn lock(
136 &mut self,
137 content_key: &SymmetricKey,
138 secret: impl AsRef<[u8]>,
139 ) -> Result<EncryptedMessage> {
140 let id = String::from_utf8(secret.as_ref().to_vec())
142 .context("SSH Agent secret must be a valid UTF-8 string")?;
143
144 let agent = self
146 .agent
147 .as_ref()
148 .map_or_else(|| connect_to_ssh_agent(), |a| Ok(a.clone()))?;
149
150 let ids = agent.borrow_mut().list_identities()?;
152
153 let ids: Vec<_> = ids
155 .into_iter()
156 .filter(|k| k.key_data().ed25519().is_some())
157 .collect();
158
159 if ids.is_empty() {
160 bail!("No Ed25519 identities available in SSH agent");
161 }
162
163 let identity = if id.is_empty() {
166 if ids.len() > 1 {
168 bail!(
169 "Multiple identities available in SSH agent, but no ID provided"
170 );
171 }
172 ids.first().unwrap()
174 } else {
175 ids.iter()
176 .find(|k| k.comment() == id)
177 .context("No matching identity found")?
178 };
179
180 let salt = self.salt().clone();
182 let sig = agent
183 .borrow_mut()
184 .sign(identity, salt.as_bytes())
185 .context("SSH agent refused to sign")?;
186
187 let derived_key = SymmetricKey::from_data_ref(hkdf_hmac_sha256(
189 &sig,
190 &salt,
191 SymmetricKey::SYMMETRIC_KEY_SIZE,
192 ))
193 .unwrap(); self.id = id;
197
198 let encoded_method = self.to_cbor_data();
200
201 Ok(derived_key.encrypt(
204 content_key,
205 Some(encoded_method),
206 Option::<Nonce>::None,
207 ))
208 }
209
210 fn unlock(
211 &self,
212 encrypted_message: &EncryptedMessage,
213 secret: impl AsRef<[u8]>,
214 ) -> Result<SymmetricKey> {
215 let id = String::from_utf8(secret.as_ref().to_vec())
217 .context("SSH Agent secret must be a valid UTF-8 string")?;
218
219 let agent = self
221 .agent
222 .as_ref()
223 .map_or_else(|| connect_to_ssh_agent(), |a| Ok(a.clone()))?;
224
225 let ids = agent.borrow_mut().list_identities()?;
227
228 let ids: Vec<_> = ids
230 .into_iter()
231 .filter(|k| k.key_data().ed25519().is_some())
232 .collect();
233
234 if ids.is_empty() {
235 bail!("No Ed25519 identities available in SSH agent");
236 }
237
238 let identity = if !id.is_empty() {
243 ids.iter()
244 .find(|k| k.comment() == id)
245 .context("No matching identity found")?
246 } else if !self.id.is_empty() {
247 ids.iter()
248 .find(|k| k.comment() == self.id)
249 .context("No matching identity found")?
250 } else {
251 ids.first().unwrap()
253 };
254
255 let sig = agent
257 .borrow_mut()
258 .sign(identity, self.salt.as_bytes())
259 .context("SSH agent refused to sign")?;
260
261 let derived_key = SymmetricKey::from_data_ref(hkdf_hmac_sha256(
263 &sig,
264 &self.salt,
265 SymmetricKey::SYMMETRIC_KEY_SIZE,
266 ))
267 .unwrap(); let decrypted_key = derived_key
271 .decrypt(encrypted_message)
272 .context("Failed to decrypt the encrypted key")?;
273
274 let content_key = decrypted_key
275 .try_into()
276 .context("Failed to convert decrypted key to SymmetricKey")?;
277
278 Ok(content_key)
280 }
281}
282
283impl std::fmt::Display for SSHAgentParams {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 write!(f, r#"SSHAgent("{}")"#, self.id)
286 }
287}
288
289impl From<SSHAgentParams> for CBOR {
290 fn from(val: SSHAgentParams) -> Self {
291 vec![
292 CBOR::from(SSHAgentParams::INDEX),
293 val.salt.into(),
294 val.id.into(),
295 ]
296 .into()
297 }
298}
299
300impl TryFrom<CBOR> for SSHAgentParams {
301 type Error = dcbor::Error;
302
303 fn try_from(cbor: CBOR) -> dcbor::Result<Self> {
304 let a = cbor.try_into_array()?;
305 a.len()
306 .eq(&3)
307 .then_some(())
308 .ok_or_else(|| dcbor::Error::msg("Invalid SSHAgentParams"))?;
309 let mut iter = a.into_iter();
310 let _index: usize = iter
311 .next()
312 .ok_or_else(|| dcbor::Error::msg("Missing index"))?
313 .try_into()?;
314 let salt: Salt = iter
315 .next()
316 .ok_or_else(|| dcbor::Error::msg("Missing salt"))?
317 .try_into()?;
318 let id: String = iter
319 .next()
320 .ok_or_else(|| dcbor::Error::msg("Missing id"))?
321 .try_into()?;
322 Ok(SSHAgentParams { salt, id, agent: None })
323 }
324}
325
326#[cfg(test)]
327mod tests_common {
328 use std::{cell::RefCell, rc::Rc};
329
330 use dcbor::prelude::*;
331
332 use crate::{
333 EncryptedKey, KeyDerivation, KeyDerivationParams, SALT_LEN, SSHAgent,
334 SSHAgentParams, Salt,
335 };
336
337 pub fn test_id() -> String { "your_email@example.com".to_string() }
338
339 pub fn test_ssh_agent_params(agent: Rc<RefCell<dyn SSHAgent>>) {
340 let params = SSHAgentParams::new_opt(
342 Salt::new_with_len(SALT_LEN).unwrap(),
343 "",
344 Some(agent.clone()),
345 );
346
347 let content_key = crate::SymmetricKey::new();
349
350 let secret = b"";
352
353 let encrypted_key = EncryptedKey::lock_opt(
355 KeyDerivationParams::SSHAgent(params),
356 secret,
357 &content_key,
358 )
359 .expect("Lock content key with SSH agent params");
360
361 let cbor_data = encrypted_key.to_cbor_data();
363
364 let cbor = CBOR::try_from_data(cbor_data)
366 .expect("Convert encrypted key to CBOR");
367
368 let encrypted_key_2 = EncryptedKey::try_from_cbor(&cbor)
370 .expect("Convert CBOR to EncryptedKey");
371
372 let aad_cbor = encrypted_key_2
374 .aad_cbor()
375 .expect("Get AAD CBOR from EncryptedKey");
376 let mut params_2 = SSHAgentParams::try_from(aad_cbor)
377 .expect("Convert AAD CBOR to SSHAgentParams");
378
379 params_2.set_agent(Some(agent.clone()));
381
382 let decrypted_content_key =
384 params_2.unlock(encrypted_key.encrypted_message(), secret);
385
386 assert_eq!(
388 content_key,
389 decrypted_content_key
390 .expect("Unlock content key with SSH agent params")
391 );
392 }
393}
394
395#[cfg(test)]
396mod mock_agent_tests {
397 use std::{cell::RefCell, collections::HashMap, rc::Rc};
398
399 use anyhow::Result;
400
401 use super::tests_common::{test_id, test_ssh_agent_params};
402 use crate::SSHAgent;
403
404 struct MockSSHAgent {
405 identities: HashMap<String, ssh_key::PrivateKey>,
406 }
407
408 impl MockSSHAgent {
409 fn new() -> Self { Self { identities: HashMap::new() } }
410
411 fn add_identity(&mut self, key: ssh_key::PrivateKey) {
412 self.identities.insert(key.comment().to_string(), key);
413 }
414 }
415
416 impl SSHAgent for MockSSHAgent {
417 fn list_identities(&mut self) -> Result<Vec<ssh_key::PublicKey>> {
418 Ok(self
419 .identities
420 .values()
421 .map(|k| k.public_key().clone())
422 .collect())
423 }
424
425 fn add_identity(&mut self, key: &ssh_key::PrivateKey) -> Result<()> {
426 self.add_identity(key.clone());
427 Ok(())
428 }
429
430 fn remove_identity(&mut self, key: &ssh_key::PrivateKey) -> Result<()> {
431 self.identities.remove(key.comment());
432 Ok(())
433 }
434
435 fn remove_all_identities(&mut self) -> Result<()> {
436 self.identities.clear();
437 Ok(())
438 }
439
440 fn sign(
441 &mut self,
442 key: &ssh_key::PublicKey,
443 data: &[u8],
444 ) -> Result<ssh_key::Signature> {
445 let private_key = self
448 .identities
449 .get(key.comment())
450 .ok_or_else(|| anyhow::anyhow!("Identity not found"))?;
451 let sig: ssh_key::SshSig = private_key
453 .sign("test_namespace", ssh_key::HashAlg::Sha256, data)
454 .map_err(|e| anyhow::anyhow!("Failed to sign data: {}", e))?;
455 Ok(sig.signature().clone())
457 }
458 }
459
460 fn mock_agent() -> Rc<RefCell<dyn SSHAgent>> {
461 let mut agent = MockSSHAgent::new();
462 let mut rng = bc_rand::SecureRandomNumberGenerator;
463 let keypair: ssh_key::private::Ed25519Keypair =
464 ssh_key::private::Ed25519Keypair::random(&mut rng);
465 let private_key =
466 ssh_key::PrivateKey::new(keypair.into(), test_id()).unwrap();
467 agent.add_identity(private_key);
468 Rc::new(RefCell::new(agent))
469 }
470
471 #[test]
472 fn test_mock_agent() {
473 let agent = mock_agent();
474 let identities = agent.borrow_mut().list_identities().unwrap();
475 assert!(!identities.is_empty(), "No identities found in SSH agent");
476
477 let first_identity = &identities[0];
478 assert_eq!(first_identity.comment(), test_id());
479 let data = b"test data";
480 let signature1 = agent.borrow_mut().sign(first_identity, data).unwrap();
481 let signature2 = agent.borrow_mut().sign(first_identity, data).unwrap();
482 assert_eq!(
483 signature1, signature2,
484 "Signatures should match for the same data"
485 );
486 }
487
488 #[test]
489 fn test_ssh_agent_params_with_mock_agent() {
490 let agent = mock_agent();
492
493 test_ssh_agent_params(agent);
495 }
496}
497
498#[cfg(test)]
526#[cfg(feature = "ssh_agent_tests")]
527mod real_agent_tests {
528 use dcbor::prelude::*;
529
530 use super::tests_common::{test_id, test_ssh_agent_params};
531 use crate::{
532 EncryptedKey, KeyDerivationMethod, SymmetricKey, connect_to_ssh_agent,
533 };
534
535 pub fn test_content_key() -> SymmetricKey { SymmetricKey::new() }
536
537 #[test]
538 fn test_ssh_agent_params_with_real_agent() {
539 let agent = connect_to_ssh_agent().expect("Connect to SSH agent");
541
542 test_ssh_agent_params(agent);
544 }
545
546 #[test]
547 fn test_encrypted_key_ssh_agent_roundtrip() {
548 let id = test_id();
549 let content_key = test_content_key();
550
551 let encrypted_key = EncryptedKey::lock(
552 KeyDerivationMethod::SSHAgent,
553 id.clone(),
554 &content_key,
555 )
556 .unwrap();
557 let expected = format!(r#"EncryptedKey(SSHAgent("{}"))"#, id);
558 assert_eq!(format!("{}", encrypted_key), expected);
559 let cbor = encrypted_key.clone().to_cbor();
560 let argon2id2 = EncryptedKey::try_from(cbor).unwrap();
561 let decrypted = EncryptedKey::unlock(&argon2id2, id).unwrap();
562
563 assert_eq!(content_key, decrypted);
564 }
565
566 #[test]
567 fn test_encrypted_key_ssh_agent_wrong_secret_fails() {
568 let secret = test_id();
569 let content_key = test_content_key();
570 let encrypted = EncryptedKey::lock(
571 KeyDerivationMethod::SSHAgent,
572 secret,
573 &content_key,
574 )
575 .unwrap();
576 let wrong_secret = b"wrong secret";
577 let result = EncryptedKey::unlock(&encrypted, wrong_secret);
578 assert!(result.is_err(), "Unlock should fail with wrong secret");
579 }
580
581 #[test]
582 fn test_ssh_agent_lock_fails_with_nonexistent_identity() {
583 let secret = b"nonexistent_identity";
584 let content_key = test_content_key();
585 let encrypted = EncryptedKey::lock(
586 KeyDerivationMethod::SSHAgent,
587 secret,
588 &content_key,
589 );
590 assert!(
591 encrypted.is_err(),
592 "Lock should fail with nonexistent identity"
593 );
594 }
595}