Skip to main content

envo/
crypto.rs

1//! Encryption at rest for secrets files.
2//!
3//! A passphrase is stretched into a 32-byte key with Argon2id (memory-hard),
4//! then the plaintext is sealed with XChaCha20-Poly1305 (authenticated, 24-byte
5//! random nonce). The on-disk envelope is a small text format so encrypted
6//! secrets can be committed and diffed line by line.
7
8use 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
30/// Encrypt `plaintext` with `passphrase`, returning the text envelope.
31pub 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
52/// Decrypt an envelope produced by [`encrypt`].
53pub 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
89/// Quick check used by tooling to decide whether a file is an envo envelope.
90pub fn looks_encrypted(text: &str) -> bool {
91    text.trim_start().starts_with(MAGIC)
92}
93
94/// Find the passphrase for crypto operations, in priority order:
95/// 1. an explicit `--key-file`
96/// 2. the `ENVO_KEY` environment variable
97/// 3. `<project>/.envo.key`
98/// 4. `$XDG_CONFIG_HOME/envo/key` (or `~/.config/envo/key`)
99pub 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        // Random salt + nonce => same plaintext encrypts differently each time.
165        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}