open_envault/crypto/
value.rs1use 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
35pub fn encrypt(plain: &[u8], key: &[u8; 32], aad: &[u8]) -> anyhow::Result<String> {
39 encrypt_kind(plain, key, aad, "str")
40}
41
42pub 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
110pub 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}