bc_components/encrypted_key/
ssh_agent_params.rs

1use 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/// Struct representing SSH Agent parameters.
56///
57/// CDDL:
58/// ```cddl
59/// SSHAgentParams = [4, Salt, id: tstr]
60/// ```
61#[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
123/// Connect to whatever socket/pipe `$SSH_AUTH_SOCK` points at.
124pub 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        // Convert `secret` to a string for the SSH ID.
141        let id = String::from_utf8(secret.as_ref().to_vec())
142            .context("SSH Agent secret must be a valid UTF-8 string")?;
143
144        // If None call connect_to_agent to get the agent.
145        let agent = self
146            .agent
147            .as_ref()
148            .map_or_else(|| connect_to_ssh_agent(), |a| Ok(a.clone()))?;
149
150        // List all identities in the SSH agent.
151        let ids = agent.borrow_mut().list_identities()?;
152
153        // Filter down to the identities that have Ed25519 keys.
154        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        // If `id` is empty, use the first available identity, otherwise find
164        // the one matching `id`.
165        let identity = if id.is_empty() {
166            // If there is more than one identity, throw an error.
167            if ids.len() > 1 {
168                bail!(
169                    "Multiple identities available in SSH agent, but no ID provided"
170                );
171            }
172            // Safe to unwrap because we checked that `ids` is not empty
173            ids.first().unwrap()
174        } else {
175            ids.iter()
176                .find(|k| k.comment() == id)
177                .context("No matching identity found")?
178        };
179
180        // Sign the salt with the identity.
181        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        // Derive the symmetric key using HKDF with HMAC-SHA256.
188        let derived_key = SymmetricKey::from_data_ref(hkdf_hmac_sha256(
189            &sig,
190            &salt,
191            SymmetricKey::SYMMETRIC_KEY_SIZE,
192        ))
193        .unwrap(); // Safe to unwrap because SYMMETRIC_KEY_SIZE is valid.
194
195        // Set the ID in the parameters.
196        self.id = id;
197
198        // Encode the method as CBOR data.
199        let encoded_method = self.to_cbor_data();
200
201        // Encrypt the content key with the derived key, using the
202        // encoded method as additional authenticated data.
203        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        // Convert `secret` to a string for the SSH ID.
216        let id = String::from_utf8(secret.as_ref().to_vec())
217            .context("SSH Agent secret must be a valid UTF-8 string")?;
218
219        // If None call connect_to_agent to get the agent.
220        let agent = self
221            .agent
222            .as_ref()
223            .map_or_else(|| connect_to_ssh_agent(), |a| Ok(a.clone()))?;
224
225        // List all identities in the SSH agent.
226        let ids = agent.borrow_mut().list_identities()?;
227
228        // Filter down to the identities that have Ed25519 keys.
229        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        // id priority:
239        // 1. `id` passed in as secret if not empty,
240        // 2. `self.id` if not empty,
241        // 3. first available identity.
242        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            // Safe to unwrap because we checked that `ids` is not empty
252            ids.first().unwrap()
253        };
254
255        // Sign the salt with the identity.
256        let sig = agent
257            .borrow_mut()
258            .sign(identity, self.salt.as_bytes())
259            .context("SSH agent refused to sign")?;
260
261        // Derive the symmetric key using HKDF with HMAC-SHA256.
262        let derived_key = SymmetricKey::from_data_ref(hkdf_hmac_sha256(
263            &sig,
264            &self.salt,
265            SymmetricKey::SYMMETRIC_KEY_SIZE,
266        ))
267        .unwrap(); // Safe to unwrap because SYMMETRIC_KEY_SIZE is valid.
268
269        // Decrypt the encrypted key with the derived key.
270        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        // If the decryption was successful, return the symmetric key.
279        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        // Create SSHAgentParams with the agent.
341        let params = SSHAgentParams::new_opt(
342            Salt::new_with_len(SALT_LEN).unwrap(),
343            "",
344            Some(agent.clone()),
345        );
346
347        // Create a content key to encrypt.
348        let content_key = crate::SymmetricKey::new();
349
350        // Empty: use the first identity in the agent.
351        let secret = b"";
352
353        // Lock the content key with the SSH agent parameters.
354        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        // Serialize the encrypted key to CBOR.
362        let cbor_data = encrypted_key.to_cbor_data();
363
364        // Deserialize the CBOR data.
365        let cbor = CBOR::try_from_data(cbor_data)
366            .expect("Convert encrypted key to CBOR");
367
368        // Convert the CBOR back to an EncryptedKey.
369        let encrypted_key_2 = EncryptedKey::try_from_cbor(&cbor)
370            .expect("Convert CBOR to EncryptedKey");
371
372        // Extract the SSH agent parameters from the AAD CBOR.
373        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        // Set the mock agent in the parameters.
380        params_2.set_agent(Some(agent.clone()));
381
382        // Unlock the content key using the SSH agent parameters.
383        let decrypted_content_key =
384            params_2.unlock(encrypted_key.encrypted_message(), secret);
385
386        // Assert that the decrypted key matches the original content key.
387        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            // println!("Signing public key: {:?}", &key);
446            // println!("Data: {:?}", hex::encode(data));
447            let private_key = self
448                .identities
449                .get(key.comment())
450                .ok_or_else(|| anyhow::anyhow!("Identity not found"))?;
451            // println!("Signing Private key: {:?}", private_key);
452            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            // println!("Signature: {:?}", sig.signature());
456            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        // Create a mock SSH agent.
491        let agent = mock_agent();
492
493        // Test the SSHAgentParams with the mock agent.
494        test_ssh_agent_params(agent);
495    }
496}
497
498/// For these tests to run correctly, you need to have a real SSH agent running
499/// and have at least one Ed25519 identity added to it with
500/// `your_email@example.com` as the identity comment.
501///
502/// To run these tests, use the following command:
503/// ```bash
504/// cargo test real_agent_tests --features ssh_agent_tests
505/// ```
506///
507/// Your `SSH_AUTH_SOCK` environment variable must be set to the socket
508/// the SSH agent is listening on. This is usually set automatically when you
509/// start your SSH agent, but you can check it with:
510/// ```bash
511/// echo $SSH_AUTH_SOCK
512/// ```
513///
514/// To list the keys in your SSH agent, you can use:
515/// ```bash
516/// ssh-add -l
517/// ```
518///
519/// To generate a new Ed25519 key and add it to your SSH agent as a test
520/// identity, you can use:
521/// ```bash
522/// ssh-keygen -t ed25519 -C "your_email@example.com" -f <your_key_file>
523/// ssh-add <your_key_file>
524/// ```
525#[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        // Connect to the real SSH agent.
540        let agent = connect_to_ssh_agent().expect("Connect to SSH agent");
541
542        // Test the SSHAgentParams with the real agent.
543        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}