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