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 mut salt = [0; 64];
84 salt[..32].copy_from_slice(e_public.as_bytes());
85 salt[32..].copy_from_slice(self.montgomery_key.as_bytes());
86
87 let mut enc_key = crypto::hkdf(&salt, fingerprint.as_bytes(), shared_secret.as_bytes())?;
88
89 let crypto = ChaCha20Poly1305Crypto::new(SecretSlice::new(enc_key.into()));
91 let encrypted_password =
92 crypto.encrypt(password.expose_secret(), fingerprint.as_bytes())?;
93
94 enc_key.zeroize();
96
97 Ok(format!(
99 "SSH-VAULT;CHACHA20-POLY1305;{};{};{};{}",
100 fingerprint,
101 Base64::encode_string(e_public.as_bytes()),
102 Base64::encode_string(&encrypted_password),
103 Base64::encode_string(&encrypted_data)
104 )
105 .chars()
106 .collect::<Vec<_>>()
107 .chunks(64)
108 .map(|chunk| chunk.iter().collect::<String>())
109 .collect::<Vec<_>>()
110 .join("\n"))
111 }
112
113 fn view(&self, password: &[u8], data: &[u8], fingerprint: &str) -> Result<String> {
114 let get_fingerprint = self.public_key.fingerprint(HashAlg::Sha256);
115
116 if get_fingerprint.to_string() != fingerprint {
117 return Err(anyhow::anyhow!("Fingerprint mismatch, use correct key"));
118 }
119
120 match &self.private_key {
121 Some(private_key) => {
122 if password.len() < 32 {
124 return Err(anyhow::anyhow!(
125 "Invalid password data: too short (expected at least 32 bytes, got {})",
126 password.len()
127 ));
128 }
129
130 let (ephemeral_bytes, encrypted_password) = password.split_at(32);
132 let mut epk: [u8; 32] = [0; 32];
133 epk.copy_from_slice(ephemeral_bytes);
134
135 let epk = X25519PublicKey::from(epk);
137
138 let sk: StaticSecret = {
140 let mut digest = Sha512::digest(private_key.as_ref());
141 let mut sk_bytes = [0u8; 32];
142 sk_bytes.copy_from_slice(
143 digest
144 .as_slice()
145 .get(..32)
146 .ok_or_else(|| anyhow::anyhow!("digest too short"))?,
147 );
148 let sk = StaticSecret::from(sk_bytes);
149 digest.as_mut_slice().zeroize();
151 sk_bytes.zeroize();
152 sk
153 };
154 let pk = X25519PublicKey::from(&sk);
155
156 let shared_secret: StaticSecret = (*sk.diffie_hellman(&epk).as_bytes()).into();
158
159 let mut salt = [0; 64];
160 salt[..32].copy_from_slice(epk.as_bytes());
161 salt[32..].copy_from_slice(pk.as_bytes());
162
163 let mut enc_key =
164 crypto::hkdf(&salt, get_fingerprint.as_bytes(), shared_secret.as_bytes())?;
165
166 let crypto = ChaCha20Poly1305Crypto::new(SecretSlice::new(enc_key.into()));
168 enc_key.zeroize();
169
170 let mut password =
171 crypto.decrypt(encrypted_password, get_fingerprint.as_bytes())?;
172
173 if password.len() < 32 {
175 return Err(anyhow::anyhow!(
176 "Invalid decrypted password: too short (expected at least 32 bytes, got {})",
177 password.len()
178 ));
179 }
180
181 let mut p: [u8; 32] = [0; 32];
182 p.copy_from_slice(
183 password
184 .get(..32)
185 .ok_or_else(|| anyhow::anyhow!("password too short"))?,
186 );
187 password.zeroize();
189
190 let crypto = ChaCha20Poly1305Crypto::new(SecretSlice::new(p.into()));
192 p.zeroize();
193
194 let out = crypto.decrypt(data, get_fingerprint.as_bytes())?;
195 Ok(String::from_utf8(out)?)
196 }
197 None => Err(anyhow::anyhow!("Private key is required to view vault")),
198 }
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use anyhow::Result;
206
207 const TEST_ED25519_PUBLIC_KEY: &str =
208 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILr6U238r+PD4rSvZAu/RNJfaNgzglzSvdLKA28h4kB1";
209
210 #[test]
211 fn test_ed25519_view_short_password_data() -> Result<()> {
212 let public_key = TEST_ED25519_PUBLIC_KEY.parse::<PublicKey>()?;
214 let vault = Ed25519Vault::new(Some(public_key), None)?;
215
216 for len in 0..32 {
218 let short_password = vec![0u8; len];
219 let data = vec![0u8; 50];
220 let fingerprint = "SHA256:test";
221
222 let result = vault.view(&short_password, &data, fingerprint);
223 assert!(result.is_err(), "Should fail with {len} bytes");
224 if let Err(err) = result {
225 let err_msg = err.to_string();
226 assert!(err_msg.contains("too short") || err_msg.contains("Fingerprint mismatch"));
227 }
228 }
229 Ok(())
230 }
231
232 #[test]
233 fn test_ed25519_view_empty_password() -> Result<()> {
234 let public_key = TEST_ED25519_PUBLIC_KEY.parse::<PublicKey>()?;
235 let vault = Ed25519Vault::new(Some(public_key), None)?;
236
237 let result = vault.view(&[], &[0u8; 50], "SHA256:test");
238 assert!(result.is_err());
239 if let Err(err) = result {
240 let err_msg = err.to_string();
241 assert!(err_msg.contains("too short") || err_msg.contains("Fingerprint mismatch"));
242 }
243 Ok(())
244 }
245
246 #[test]
247 fn test_ed25519_new_with_valid_public_key() -> Result<()> {
248 let public_key = TEST_ED25519_PUBLIC_KEY.parse::<PublicKey>()?;
249 let result = Ed25519Vault::new(Some(public_key), None);
250 assert!(result.is_ok());
251 Ok(())
252 }
253
254 #[test]
255 fn test_ed25519_new_with_encrypted_private_key() -> Result<()> {
256 let private_key =
257 PrivateKey::read_openssh_file(std::path::Path::new("test_data/ed25519_password"))?;
258 let result = Ed25519Vault::new(None, Some(private_key));
259 assert!(result.is_err());
260 if let Err(e) = result {
261 assert!(e.to_string().contains("Private key is encrypted"));
262 }
263 Ok(())
264 }
265
266 #[test]
267 fn test_ed25519_new_without_keys() {
268 let result = Ed25519Vault::new(None, None);
269 assert!(result.is_err());
270 if let Err(e) = result {
271 assert!(e.to_string().contains("Missing public and private key"));
272 }
273 }
274}