Skip to main content

open_envault/crypto/
value.rs

1//! SOPS value encryption: the `ENC[AES256_GCM,...]` strings.
2//!
3//! Mirrors `github.com/getsops/sops/v3/aes` (v3.13.x). AES-256-GCM with a
4//! 32-byte nonce, the authentication tag stored separately, and the AAD equal
5//! to the value's tree path (e.g. `"KEY:"` for a top-level dotenv variable, or
6//! `"sops_mac"`-style paths for the metadata MAC). An empty plaintext encrypts
7//! to the empty string (no `ENC[...]` wrapper), exactly like SOPS.
8
9use aes_gcm::aead::consts::U32;
10use aes_gcm::{
11    AesGcm,
12    aead::{Aead, KeyInit, Nonce, Payload},
13    aes::Aes256,
14};
15use anyhow::{Context, bail};
16use base64::Engine;
17use rand::{TryRng, rngs::SysRng};
18
19type Cipher = AesGcm<Aes256, U32>;
20
21const PREFIX: &str = "ENC[AES256_GCM,data:";
22
23fn random_bytes<const N: usize>() -> anyhow::Result<[u8; N]> {
24    let mut buf = [0u8; N];
25    let mut rng = SysRng;
26    rng.try_fill_bytes(&mut buf)
27        .map_err(|_| anyhow::anyhow!("system RNG unavailable"))?;
28    Ok(buf)
29}
30
31fn b64(bytes: &[u8]) -> String {
32    base64::engine::general_purpose::STANDARD.encode(bytes)
33}
34
35/// Encrypt `plain` under `key` with `aad`, returning the SOPS value string.
36///
37/// Returns the empty string when `plain` is empty (matching SOPS `isEmpty`).
38pub fn encrypt(plain: &[u8], key: &[u8; 32], aad: &[u8]) -> anyhow::Result<String> {
39    encrypt_kind(plain, key, aad, "str")
40}
41
42/// Like [`encrypt`] but with an explicit SOPS value type (e.g. `comment`).
43pub fn encrypt_kind(
44    plain: &[u8],
45    key: &[u8; 32],
46    aad: &[u8],
47    kind: &str,
48) -> anyhow::Result<String> {
49    if plain.is_empty() {
50        return Ok(String::new());
51    }
52    let cipher =
53        Cipher::new_from_slice(key).map_err(|_| anyhow::anyhow!("invalid AES key length"))?;
54    let iv = random_bytes::<32>().context("generate AES-GCM nonce")?;
55    let nonce = Nonce::<Cipher>::try_from(iv.as_slice())
56        .map_err(|_| anyhow::anyhow!("invalid AES-GCM nonce length"))?;
57    let encrypted = cipher
58        .encrypt(&nonce, Payload { msg: plain, aad })
59        .map_err(|_| anyhow::anyhow!("AES-GCM encryption failed"))?;
60    let split = encrypted
61        .len()
62        .checked_sub(16)
63        .context("ciphertext shorter than GCM tag")?;
64    let (data, tag) = encrypted.split_at(split);
65    Ok(format!(
66        "ENC[AES256_GCM,data:{},iv:{},tag:{},type:{kind}]",
67        b64(data),
68        b64(&iv),
69        b64(tag)
70    ))
71}
72
73struct Parsed {
74    data: Vec<u8>,
75    iv: Vec<u8>,
76    tag: Vec<u8>,
77}
78
79fn parse(enc: &str) -> anyhow::Result<Parsed> {
80    let rest = enc
81        .strip_prefix(PREFIX)
82        .with_context(|| "value is not in SOPS ENC format")?;
83    let data_b64 = rest
84        .split_once(",iv:")
85        .context("malformed ENC value: missing iv")?
86        .0;
87    let (iv_b64, tag_and_type) = rest
88        .split_once(",iv:")
89        .context("malformed ENC value: missing iv")?
90        .1
91        .split_once(",tag:")
92        .context("malformed ENC value: missing tag")?;
93    let (tag_b64, kind) = tag_and_type
94        .split_once(",type:")
95        .context("malformed ENC value: missing type")?;
96    let kind = kind
97        .strip_suffix(']')
98        .context("malformed ENC value: missing ]")?;
99    if !matches!(kind, "str" | "comment") {
100        bail!("unsupported SOPS value type: {kind}");
101    }
102    let engine = base64::engine::general_purpose::STANDARD;
103    Ok(Parsed {
104        data: engine.decode(data_b64).context("invalid data base64")?,
105        iv: engine.decode(iv_b64).context("invalid iv base64")?,
106        tag: engine.decode(tag_b64).context("invalid tag base64")?,
107    })
108}
109
110/// Decrypt a SOPS value string under `key` with `aad`.
111///
112/// An empty string decrypts to an empty plaintext (matching SOPS `isEmpty`).
113pub fn decrypt(enc: &str, key: &[u8; 32], aad: &[u8]) -> anyhow::Result<String> {
114    if enc.is_empty() {
115        return Ok(String::new());
116    }
117    let parsed = parse(enc)?;
118    if parsed.iv.len() != 32 {
119        bail!("unexpected IV length {} (expected 32)", parsed.iv.len());
120    }
121    let cipher =
122        Cipher::new_from_slice(key).map_err(|_| anyhow::anyhow!("invalid AES key length"))?;
123    let mut combined = Vec::with_capacity(parsed.data.len() + parsed.tag.len());
124    combined.extend_from_slice(&parsed.data);
125    combined.extend_from_slice(&parsed.tag);
126    let nonce = Nonce::<Cipher>::try_from(parsed.iv.as_slice())
127        .map_err(|_| anyhow::anyhow!("invalid AES-GCM nonce length"))?;
128    let plain = cipher
129        .decrypt(
130            &nonce,
131            Payload {
132                msg: &combined,
133                aad,
134            },
135        )
136        .map_err(|_| anyhow::anyhow!("AES-GCM decryption failed (wrong key or corrupted value)"))?;
137    String::from_utf8(plain).map_err(|_| anyhow::anyhow!("decrypted value was not UTF-8"))
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn roundtrip() {
146        let key = [7u8; 32];
147        let plain = b"production";
148        let enc = encrypt(plain, &key, b"APP_ENV:").unwrap();
149        assert!(enc.starts_with(PREFIX));
150        assert!(enc.ends_with(",type:str]"));
151        assert_eq!(decrypt(&enc, &key, b"APP_ENV:").unwrap(), "production");
152    }
153
154    #[test]
155    fn empty_plaintext_stays_empty() {
156        let key = [1u8; 32];
157        assert_eq!(encrypt(b"", &key, b"EMPTY:").unwrap(), "");
158        assert_eq!(decrypt("", &key, b"EMPTY:").unwrap(), "");
159    }
160
161    #[test]
162    fn wrong_aad_fails() {
163        let key = [2u8; 32];
164        let enc = encrypt(b"secret", &key, b"A:").unwrap();
165        assert!(decrypt(&enc, &key, b"B:").is_err());
166    }
167
168    #[test]
169    fn wrong_key_fails() {
170        let enc = encrypt(b"secret", &[3u8; 32], b"A:").unwrap();
171        assert!(decrypt(&enc, &[4u8; 32], b"A:").is_err());
172    }
173}