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
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 let fingerprint = self.public_key.fingerprint(HashAlg::Sha256);
67
68 let encrypted_data = crypto.encrypt(data, fingerprint.as_bytes())?;
70
71 data.zeroize();
73
74 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 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 let crypto = ChaCha20Poly1305Crypto::new(SecretSlice::new(enc_key.into()));
93 let encrypted_password =
94 crypto.encrypt(password.expose_secret(), fingerprint.as_bytes())?;
95
96 enc_key.zeroize();
98
99 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 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 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 let epk = X25519PublicKey::from(epk);
139
140 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 digest.as_mut_slice().zeroize();
153 sk_bytes.zeroize();
154 sk
155 };
156 let pk = X25519PublicKey::from(&sk);
157
158 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 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 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 password.zeroize();
192
193 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 let public_key = TEST_ED25519_PUBLIC_KEY.parse::<PublicKey>()?;
217 let vault = Ed25519Vault::new(Some(public_key), None)?;
218
219 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}