1use std::path::{Path, PathBuf};
9
10use anyhow::{anyhow, bail, Context, Result};
11use argon2::Argon2;
12use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
13use chacha20poly1305::aead::Aead;
14use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
15use rand::RngCore;
16use zeroize::Zeroizing;
17
18const MAGIC: &str = "ENVO-ENC-v1";
19const SALT_LEN: usize = 16;
20const NONCE_LEN: usize = 24;
21
22fn derive_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
23 let mut key = Zeroizing::new([0u8; 32]);
24 Argon2::default()
25 .hash_password_into(passphrase, salt, key.as_mut())
26 .map_err(|e| anyhow!("key derivation failed: {e}"))?;
27 Ok(key)
28}
29
30pub fn encrypt(plaintext: &[u8], passphrase: &[u8]) -> Result<String> {
32 let mut salt = [0u8; SALT_LEN];
33 let mut nonce_bytes = [0u8; NONCE_LEN];
34 rand::rngs::OsRng.fill_bytes(&mut salt);
35 rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
36
37 let key = derive_key(passphrase, &salt)?;
38 let cipher = XChaCha20Poly1305::new(Key::from_slice(key.as_ref()));
39 let nonce = XNonce::from_slice(&nonce_bytes);
40 let ciphertext = cipher
41 .encrypt(nonce, plaintext)
42 .map_err(|_| anyhow!("encryption failed"))?;
43
44 Ok(format!(
45 "{MAGIC}\n{}\n{}\n{}\n",
46 B64.encode(salt),
47 B64.encode(nonce_bytes),
48 B64.encode(ciphertext),
49 ))
50}
51
52pub fn decrypt(envelope: &str, passphrase: &[u8]) -> Result<Vec<u8>> {
54 let mut lines = envelope.lines();
55 let magic = lines.next().unwrap_or_default().trim();
56 if magic != MAGIC {
57 bail!("not an envo-encrypted file (missing `{MAGIC}` header)");
58 }
59 let salt = B64
60 .decode(next_field(&mut lines)?)
61 .context("invalid base64 salt")?;
62 let nonce = B64
63 .decode(next_field(&mut lines)?)
64 .context("invalid base64 nonce")?;
65 let ciphertext = B64
66 .decode(next_field(&mut lines)?)
67 .context("invalid base64 ciphertext")?;
68 if nonce.len() != NONCE_LEN {
69 bail!("corrupt envelope: bad nonce length");
70 }
71
72 let key = derive_key(passphrase, &salt)?;
73 let cipher = XChaCha20Poly1305::new(Key::from_slice(key.as_ref()));
74 cipher
75 .decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
76 .map_err(|_| anyhow!("decryption failed (wrong key or corrupted data)"))
77}
78
79fn next_field<'a>(lines: &mut std::str::Lines<'a>) -> Result<&'a str> {
80 loop {
81 let line = lines.next().ok_or_else(|| anyhow!("truncated envelope"))?;
82 let trimmed = line.trim();
83 if !trimmed.is_empty() {
84 return Ok(trimmed);
85 }
86 }
87}
88
89pub fn looks_encrypted(text: &str) -> bool {
91 text.trim_start().starts_with(MAGIC)
92}
93
94pub fn resolve_passphrase(
100 project_dir: &Path,
101 key_file: Option<&Path>,
102) -> Result<Zeroizing<String>> {
103 if let Some(path) = key_file {
104 let raw = std::fs::read_to_string(path)
105 .with_context(|| format!("reading key file {}", path.display()))?;
106 return Ok(Zeroizing::new(raw.trim().to_string()));
107 }
108 if let Ok(val) = std::env::var("ENVO_KEY") {
109 if !val.is_empty() {
110 return Ok(Zeroizing::new(val));
111 }
112 }
113 let local = project_dir.join(".envo.key");
114 if local.exists() {
115 let raw = std::fs::read_to_string(&local)
116 .with_context(|| format!("reading {}", local.display()))?;
117 return Ok(Zeroizing::new(raw.trim().to_string()));
118 }
119 if let Some(global) = global_key_path() {
120 if global.exists() {
121 let raw = std::fs::read_to_string(&global)
122 .with_context(|| format!("reading {}", global.display()))?;
123 return Ok(Zeroizing::new(raw.trim().to_string()));
124 }
125 }
126 bail!(
127 "no encryption key found. Set ENVO_KEY, pass --key-file <path>, \
128 or create a .envo.key file"
129 )
130}
131
132fn global_key_path() -> Option<PathBuf> {
133 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
134 if !xdg.is_empty() {
135 return Some(PathBuf::from(xdg).join("envo").join("key"));
136 }
137 }
138 std::env::var("HOME")
139 .ok()
140 .map(|h| PathBuf::from(h).join(".config").join("envo").join("key"))
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn roundtrip() {
149 let pt = b"API_KEY=super-secret-value\nTOKEN=abc123\n";
150 let env = encrypt(pt, b"correct horse battery staple").unwrap();
151 assert!(looks_encrypted(&env));
152 let back = decrypt(&env, b"correct horse battery staple").unwrap();
153 assert_eq!(back, pt);
154 }
155
156 #[test]
157 fn wrong_key_fails() {
158 let env = encrypt(b"secret", b"right-key").unwrap();
159 assert!(decrypt(&env, b"wrong-key").is_err());
160 }
161
162 #[test]
163 fn distinct_ciphertexts() {
164 let a = encrypt(b"x", b"k").unwrap();
166 let b = encrypt(b"x", b"k").unwrap();
167 assert_ne!(a, b);
168 }
169
170 #[test]
171 fn rejects_non_envelope() {
172 assert!(decrypt("just some text", b"k").is_err());
173 }
174}