1use aes_gcm::{
2 aead::{Aead, Generate, KeyInit, Nonce},
3 Aes256Gcm,
4};
5use anyhow::{anyhow, Context, Result};
6use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
7use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11const NONCE_SIZE: usize = 12;
13
14#[derive(Debug, Serialize, Deserialize)]
16pub struct EncryptedCredentials {
17 pub version: u32,
19 pub salt: String,
21 pub credentials: HashMap<String, EncryptedToken>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EncryptedToken {
28 pub nonce: String,
30 pub ciphertext: String,
32}
33
34impl Default for EncryptedCredentials {
35 fn default() -> Self {
36 Self {
37 version: 1,
38 salt: String::new(),
39 credentials: HashMap::new(),
40 }
41 }
42}
43
44pub fn derive_key() -> Result<[u8; 32]> {
47 let machine_id = machine_uid::get().map_err(|e| anyhow!("Failed to get machine ID: {}", e))?;
48 let username = whoami::username().unwrap_or_else(|_| "unknown".to_string());
49
50 let password = format!("{}:{}", machine_id, username);
52
53 let salt_string = SaltString::encode_b64(machine_id.as_bytes())
56 .map_err(|e| anyhow!("Failed to encode salt: {}", e))?;
57
58 let argon2 = Argon2::default();
59
60 let hash = argon2
62 .hash_password(password.as_bytes(), &salt_string)
63 .map_err(|e| anyhow!("Failed to hash password: {}", e))?;
64
65 let hash_bytes = hash.hash.ok_or_else(|| anyhow!("Hash output is missing"))?;
67
68 let mut key = [0u8; 32];
69 key.copy_from_slice(&hash_bytes.as_bytes()[..32]);
70
71 Ok(key)
72}
73
74pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result<(String, String)> {
76 let cipher = Aes256Gcm::new(key.into());
77
78 let nonce = Nonce::<Aes256Gcm>::try_generate()
83 .map_err(|e| anyhow!("Failed to generate nonce from system RNG: {}", e))?;
84
85 let ciphertext = cipher
87 .encrypt(&nonce, plaintext.as_bytes())
88 .map_err(|e| anyhow!("Encryption failed: {}", e))?;
89
90 let nonce_b64 = BASE64.encode(nonce);
92 let ciphertext_b64 = BASE64.encode(ciphertext);
93
94 Ok((nonce_b64, ciphertext_b64))
95}
96
97pub fn decrypt(ciphertext_b64: &str, nonce_b64: &str, key: &[u8; 32]) -> Result<String> {
99 let cipher = Aes256Gcm::new(key.into());
100
101 let nonce_bytes = BASE64
103 .decode(nonce_b64)
104 .context("Failed to decode nonce from base64")?;
105 let ciphertext = BASE64
106 .decode(ciphertext_b64)
107 .context("Failed to decode ciphertext from base64")?;
108
109 if nonce_bytes.len() != NONCE_SIZE {
110 return Err(anyhow!(
111 "Invalid nonce size: expected {}, got {}",
112 NONCE_SIZE,
113 nonce_bytes.len()
114 ));
115 }
116
117 let nonce = Nonce::<Aes256Gcm>::try_from(nonce_bytes.as_slice())
120 .map_err(|_| anyhow!("Invalid nonce size: expected {}", NONCE_SIZE))?;
121
122 let plaintext = cipher
124 .decrypt(&nonce, ciphertext.as_ref())
125 .map_err(|e| anyhow!("Decryption failed: {}", e))?;
126
127 String::from_utf8(plaintext).context("Decrypted data is not valid UTF-8")
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
141 fn decrypts_ciphertext_written_by_aes_gcm_0_10() {
142 let key = [7u8; 32];
143 let nonce_b64 = "Rm9oYDGUcR47yGPD";
144 let ciphertext_b64 = "iwXkjpxTMOS8N/vvR/y0Yvt3G7fE0GW4sl7KvlyPIJ3rW50U/81e";
145
146 let plaintext = decrypt(ciphertext_b64, nonce_b64, &key)
147 .expect("aes-gcm 0.11 must decrypt ciphertext written by 0.10");
148 assert_eq!(plaintext, "hunter2-atlassian-token");
149 }
150
151 #[test]
152 fn nonce_is_12_bytes() {
153 let (nonce_b64, _) = encrypt("x", &[0u8; 32]).unwrap();
155 assert_eq!(BASE64.decode(nonce_b64).unwrap().len(), NONCE_SIZE);
156 assert_eq!(NONCE_SIZE, 12);
157 }
158
159 #[test]
160 fn test_derive_key_deterministic() {
161 let key1 = derive_key().expect("Failed to derive key");
163 let key2 = derive_key().expect("Failed to derive key");
164 assert_eq!(key1, key2, "Key derivation should be deterministic");
165 }
166
167 #[test]
168 fn test_encrypt_decrypt_roundtrip() {
169 let key = derive_key().expect("Failed to derive key");
170 let plaintext = "my-secret-token-12345";
171
172 let (nonce, ciphertext) = encrypt(plaintext, &key).expect("Encryption failed");
173
174 assert_ne!(ciphertext, plaintext);
176 assert!(!ciphertext.contains("secret"));
177
178 let decrypted = decrypt(&ciphertext, &nonce, &key).expect("Decryption failed");
179 assert_eq!(decrypted, plaintext, "Decrypted text should match original");
180 }
181
182 #[test]
183 fn test_encrypt_produces_different_ciphertext() {
184 let key = derive_key().expect("Failed to derive key");
185 let plaintext = "same-plaintext";
186
187 let (nonce1, ciphertext1) = encrypt(plaintext, &key).expect("Encryption failed");
189 let (nonce2, ciphertext2) = encrypt(plaintext, &key).expect("Encryption failed");
190
191 assert_ne!(nonce1, nonce2, "Nonces should be randomly generated");
193
194 assert_ne!(
196 ciphertext1, ciphertext2,
197 "Ciphertexts should differ with different nonces"
198 );
199
200 assert_eq!(decrypt(&ciphertext1, &nonce1, &key).unwrap(), plaintext);
202 assert_eq!(decrypt(&ciphertext2, &nonce2, &key).unwrap(), plaintext);
203 }
204
205 #[test]
206 fn test_decrypt_with_wrong_key_fails() {
207 let key1 = derive_key().expect("Failed to derive key");
208 let mut key2 = key1;
209 key2[0] ^= 0xFF; let plaintext = "secret-data";
212 let (nonce, ciphertext) = encrypt(plaintext, &key1).expect("Encryption failed");
213
214 let result = decrypt(&ciphertext, &nonce, &key2);
216 assert!(result.is_err(), "Decryption with wrong key should fail");
217 }
218
219 #[test]
220 fn test_decrypt_with_wrong_nonce_fails() {
221 let key = derive_key().expect("Failed to derive key");
222 let plaintext = "secret-data";
223
224 let (_, ciphertext) = encrypt(plaintext, &key).expect("Encryption failed");
225 let (wrong_nonce, _) = encrypt("other", &key).expect("Encryption failed");
226
227 let result = decrypt(&ciphertext, &wrong_nonce, &key);
229 assert!(result.is_err(), "Decryption with wrong nonce should fail");
230 }
231
232 #[test]
233 fn test_encrypted_credentials_serialization() {
234 let mut creds = EncryptedCredentials {
235 salt: "test-salt".to_string(),
236 ..Default::default()
237 };
238 creds.credentials.insert(
239 "account1".to_string(),
240 EncryptedToken {
241 nonce: "nonce-b64".to_string(),
242 ciphertext: "cipher-b64".to_string(),
243 },
244 );
245
246 let json = serde_json::to_string(&creds).expect("Serialization failed");
247 let deserialized: EncryptedCredentials =
248 serde_json::from_str(&json).expect("Deserialization failed");
249
250 assert_eq!(deserialized.version, 1);
251 assert_eq!(deserialized.salt, "test-salt");
252 assert_eq!(deserialized.credentials.len(), 1);
253 }
254}