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