ssh_vault/vault/ssh/
ed25519.rs1use 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 let fingerprint = self.public_key.fingerprint(HashAlg::Sha256);
68
69 let encrypted_data = crypto.encrypt(data, fingerprint.as_bytes())?;
71
72 data.zeroize();
74
75 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 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 let crypto = ChaCha20Poly1305Crypto::new(SecretSlice::new(enc_key.into()));
92 let encrypted_password =
93 crypto.encrypt(password.expose_secret(), fingerprint.as_bytes())?;
94
95 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 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 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 let epk = X25519PublicKey::from(epk);
135
136 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 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 let crypto = ChaCha20Poly1305Crypto::new(SecretSlice::new(enc_key.into()));
162
163 let password = crypto.decrypt(encrypted_password, get_fingerprint.as_bytes())?;
164
165 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 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 let public_key = TEST_ED25519_PUBLIC_KEY.parse::<PublicKey>()?;
203 let vault = Ed25519Vault::new(Some(public_key), None)?;
204
205 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}