Skip to main content

ssh_vault/vault/ssh/
ed25519.rs

1use crate::vault::{
2    Vault, crypto, crypto::Crypto, crypto::chacha20poly1305::ChaCha20Poly1305Crypto,
3};
4use anyhow::{Context, Result};
5use base64ct::{Base64, Encoding};
6use secrecy::{ExposeSecret, SecretSlice};
7use sha2::{Digest, Sha512};
8use ssh_key::{
9    HashAlg, PrivateKey, PublicKey,
10    private::{Ed25519PrivateKey, KeypairData},
11    public::KeyData,
12};
13use x25519_dalek::{EphemeralSecret, PublicKey as X25519PublicKey, StaticSecret};
14use zeroize::Zeroize;
15
16#[allow(clippy::struct_field_names)]
17pub struct Ed25519Vault {
18    montgomery_key: X25519PublicKey,
19    private_key: Option<Ed25519PrivateKey>,
20    public_key: PublicKey,
21}
22
23fn x25519_public_key(ed25519_public: &[u8; 32]) -> Result<X25519PublicKey> {
24    let verifying_key =
25        ed25519_dalek::VerifyingKey::from_bytes(ed25519_public).context("Could not load key")?;
26    Ok(verifying_key.to_montgomery().to_bytes().into())
27}
28
29impl Vault for Ed25519Vault {
30    fn new(public: Option<PublicKey>, private: Option<PrivateKey>) -> Result<Self> {
31        match (public, private) {
32            (Some(public), None) => match public.key_data() {
33                KeyData::Ed25519(key_data) => {
34                    let montgomery_key = x25519_public_key(&key_data.0)?;
35
36                    Ok(Self {
37                        montgomery_key,
38                        private_key: None,
39                        public_key: public,
40                    })
41                }
42                _ => Err(anyhow::anyhow!("Invalid key type for Ed25519Vault")),
43            },
44            (None, Some(private)) => match private.key_data() {
45                KeypairData::Ed25519(key_data) => {
46                    let public_key = private.public_key().clone();
47                    let montgomery_key = x25519_public_key(&key_data.public.0)?;
48
49                    Ok(Self {
50                        montgomery_key,
51                        private_key: Some(key_data.private.clone()),
52                        public_key,
53                    })
54                }
55                KeypairData::Encrypted(_) => Err(anyhow::anyhow!("Private key is encrypted")),
56                _ => Err(anyhow::anyhow!("Invalid key type for Ed25519Vault")),
57            },
58            _ => Err(anyhow::anyhow!("Missing public and private key")),
59        }
60    }
61
62    fn create(&self, password: SecretSlice<u8>, data: &mut [u8]) -> Result<String> {
63        let crypto = ChaCha20Poly1305Crypto::new(password.clone());
64
65        // get the fingerprint of the public key
66        let fingerprint = self.public_key.fingerprint(HashAlg::Sha256);
67
68        // encrypt the data with the password
69        let encrypted_data = crypto.encrypt(data, fingerprint.as_bytes())?;
70
71        // zeroize data
72        data.zeroize();
73
74        // generate an ephemeral key pair
75        let e_secret = EphemeralSecret::random();
76        let e_public: X25519PublicKey = (&e_secret).into();
77
78        let shared_secret: StaticSecret =
79            (*e_secret.diffie_hellman(&self.montgomery_key).as_bytes()).into();
80
81        // the salt is the concatenation of the
82        // ephemeral public key and the receiver's public key
83        let salt = [*e_public.as_bytes(), *self.montgomery_key.as_bytes()];
84
85        let mut enc_key = crypto::hkdf(
86            salt.as_flattened(),
87            fingerprint.as_bytes(),
88            shared_secret.as_bytes(),
89        )?;
90
91        // encrypt the password with the derived key
92        let crypto = ChaCha20Poly1305Crypto::new(SecretSlice::new(enc_key.into()));
93        let encrypted_password =
94            crypto.encrypt(password.expose_secret(), fingerprint.as_bytes())?;
95
96        // scrub the derived-key copy left on the stack
97        enc_key.zeroize();
98
99        // create vault payload
100        Ok(format!(
101            "SSH-VAULT;CHACHA20-POLY1305;{};{};{};{}",
102            fingerprint,
103            Base64::encode_string(e_public.as_bytes()),
104            Base64::encode_string(&encrypted_password),
105            Base64::encode_string(&encrypted_data)
106        )
107        .chars()
108        .collect::<Vec<_>>()
109        .chunks(64)
110        .map(|chunk| chunk.iter().collect::<String>())
111        .collect::<Vec<_>>()
112        .join("\n"))
113    }
114
115    fn view(&self, password: &[u8], data: &[u8], fingerprint: &str) -> Result<String> {
116        let get_fingerprint = self.public_key.fingerprint(HashAlg::Sha256);
117
118        if get_fingerprint.to_string() != fingerprint {
119            return Err(anyhow::anyhow!("Fingerprint mismatch, use correct key"));
120        }
121
122        match &self.private_key {
123            Some(private_key) => {
124                // Validate password length before slicing
125                if password.len() < 32 {
126                    return Err(anyhow::anyhow!(
127                        "Invalid password data: too short (expected at least 32 bytes, got {})",
128                        password.len()
129                    ));
130                }
131
132                // extract the ephemeral public key
133                let (ephemeral_bytes, encrypted_password) = password.split_at(32);
134                let mut epk: [u8; 32] = [0; 32];
135                epk.copy_from_slice(ephemeral_bytes);
136
137                // decode the ephemeral public key
138                let epk = X25519PublicKey::from(epk);
139
140                // generate the static secret and public key
141                let sk: StaticSecret = {
142                    let mut digest = Sha512::digest(private_key.as_ref());
143                    let mut sk_bytes = [0u8; 32];
144                    sk_bytes.copy_from_slice(
145                        digest
146                            .as_slice()
147                            .get(..32)
148                            .ok_or_else(|| anyhow::anyhow!("digest too short"))?,
149                    );
150                    let sk = StaticSecret::from(sk_bytes);
151                    // scrub the private-key-derived digest and scalar copy
152                    digest.as_mut_slice().zeroize();
153                    sk_bytes.zeroize();
154                    sk
155                };
156                let pk = X25519PublicKey::from(&sk);
157
158                // generate the shared secret
159                let shared_secret: StaticSecret = (*sk.diffie_hellman(&epk).as_bytes()).into();
160
161                let salt = [*epk.as_bytes(), *pk.as_bytes()];
162
163                let mut enc_key = crypto::hkdf(
164                    salt.as_flattened(),
165                    get_fingerprint.as_bytes(),
166                    shared_secret.as_bytes(),
167                )?;
168
169                // use the enc_key to decrypt the password
170                let crypto = ChaCha20Poly1305Crypto::new(SecretSlice::new(enc_key.into()));
171                enc_key.zeroize();
172
173                let mut password =
174                    crypto.decrypt(encrypted_password, get_fingerprint.as_bytes())?;
175
176                // Validate decrypted password length before slicing
177                if password.len() < 32 {
178                    return Err(anyhow::anyhow!(
179                        "Invalid decrypted password: too short (expected at least 32 bytes, got {})",
180                        password.len()
181                    ));
182                }
183
184                let mut p: [u8; 32] = [0; 32];
185                p.copy_from_slice(
186                    password
187                        .get(..32)
188                        .ok_or_else(|| anyhow::anyhow!("password too short"))?,
189                );
190                // the decrypted inner password has been copied into `p`
191                password.zeroize();
192
193                // decrypt the data with the derived key
194                let crypto = ChaCha20Poly1305Crypto::new(SecretSlice::new(p.into()));
195                p.zeroize();
196
197                let out = crypto.decrypt(data, get_fingerprint.as_bytes())?;
198                Ok(String::from_utf8(out)?)
199            }
200            None => Err(anyhow::anyhow!("Private key is required to view vault")),
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use anyhow::Result;
209
210    const TEST_ED25519_PUBLIC_KEY: &str =
211        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILr6U238r+PD4rSvZAu/RNJfaNgzglzSvdLKA28h4kB1";
212
213    #[test]
214    fn test_ed25519_view_short_password_data() -> Result<()> {
215        // Create an Ed25519 vault with a public key
216        let public_key = TEST_ED25519_PUBLIC_KEY.parse::<PublicKey>()?;
217        let vault = Ed25519Vault::new(Some(public_key), None)?;
218
219        // Test with password data shorter than 32 bytes
220        for len in 0..32 {
221            let short_password = vec![0u8; len];
222            let data = vec![0u8; 50];
223            let fingerprint = "SHA256:test";
224
225            let result = vault.view(&short_password, &data, fingerprint);
226            assert!(result.is_err(), "Should fail with {len} bytes");
227            if let Err(err) = result {
228                let err_msg = err.to_string();
229                assert!(err_msg.contains("too short") || err_msg.contains("Fingerprint mismatch"));
230            }
231        }
232        Ok(())
233    }
234
235    #[test]
236    fn test_ed25519_view_empty_password() -> Result<()> {
237        let public_key = TEST_ED25519_PUBLIC_KEY.parse::<PublicKey>()?;
238        let vault = Ed25519Vault::new(Some(public_key), None)?;
239
240        let result = vault.view(&[], &[0u8; 50], "SHA256:test");
241        assert!(result.is_err());
242        if let Err(err) = result {
243            let err_msg = err.to_string();
244            assert!(err_msg.contains("too short") || err_msg.contains("Fingerprint mismatch"));
245        }
246        Ok(())
247    }
248
249    #[test]
250    fn test_ed25519_new_with_valid_public_key() -> Result<()> {
251        let public_key = TEST_ED25519_PUBLIC_KEY.parse::<PublicKey>()?;
252        let result = Ed25519Vault::new(Some(public_key), None);
253        assert!(result.is_ok());
254        Ok(())
255    }
256
257    #[test]
258    fn test_ed25519_new_with_encrypted_private_key() -> Result<()> {
259        let private_key =
260            PrivateKey::read_openssh_file(std::path::Path::new("test_data/ed25519_password"))?;
261        let result = Ed25519Vault::new(None, Some(private_key));
262        assert!(result.is_err());
263        if let Err(e) = result {
264            assert!(e.to_string().contains("Private key is encrypted"));
265        }
266        Ok(())
267    }
268
269    #[test]
270    fn test_ed25519_new_without_keys() {
271        let result = Ed25519Vault::new(None, None);
272        assert!(result.is_err());
273        if let Err(e) = result {
274            assert!(e.to_string().contains("Missing public and private key"));
275        }
276    }
277}