use aes_gcm::aead::consts::U32;
use aes_gcm::{
AesGcm,
aead::{Aead, KeyInit, Nonce, Payload},
aes::Aes256,
};
use anyhow::{Context, bail};
use base64::Engine;
use rand::RngCore;
type Cipher = AesGcm<Aes256, U32>;
const PREFIX: &str = "ENC[AES256_GCM,data:";
fn random_bytes<const N: usize>() -> [u8; N] {
let mut buf = [0u8; N];
rand::rngs::OsRng.fill_bytes(&mut buf);
buf
}
fn b64(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub fn encrypt(plain: &[u8], key: &[u8; 32], aad: &[u8]) -> anyhow::Result<String> {
encrypt_kind(plain, key, aad, "str")
}
pub fn encrypt_kind(
plain: &[u8],
key: &[u8; 32],
aad: &[u8],
kind: &str,
) -> anyhow::Result<String> {
if plain.is_empty() {
return Ok(String::new());
}
let cipher =
Cipher::new_from_slice(key).map_err(|_| anyhow::anyhow!("invalid AES key length"))?;
let iv = random_bytes::<32>();
let nonce = Nonce::<Cipher>::from_slice(&iv);
let encrypted = cipher
.encrypt(nonce, Payload { msg: plain, aad })
.map_err(|_| anyhow::anyhow!("AES-GCM encryption failed"))?;
let split = encrypted
.len()
.checked_sub(16)
.context("ciphertext shorter than GCM tag")?;
let (data, tag) = encrypted.split_at(split);
Ok(format!(
"ENC[AES256_GCM,data:{},iv:{},tag:{},type:{kind}]",
b64(data),
b64(&iv),
b64(tag)
))
}
struct Parsed {
data: Vec<u8>,
iv: Vec<u8>,
tag: Vec<u8>,
}
fn parse(enc: &str) -> anyhow::Result<Parsed> {
let rest = enc
.strip_prefix(PREFIX)
.with_context(|| "value is not in SOPS ENC format")?;
let data_b64 = rest
.split_once(",iv:")
.context("malformed ENC value: missing iv")?
.0;
let (iv_b64, tag_and_type) = rest
.split_once(",iv:")
.context("malformed ENC value: missing iv")?
.1
.split_once(",tag:")
.context("malformed ENC value: missing tag")?;
let (tag_b64, kind) = tag_and_type
.split_once(",type:")
.context("malformed ENC value: missing type")?;
let kind = kind
.strip_suffix(']')
.context("malformed ENC value: missing ]")?;
if !matches!(kind, "str" | "comment") {
bail!("unsupported SOPS value type: {kind}");
}
let engine = base64::engine::general_purpose::STANDARD;
Ok(Parsed {
data: engine.decode(data_b64).context("invalid data base64")?,
iv: engine.decode(iv_b64).context("invalid iv base64")?,
tag: engine.decode(tag_b64).context("invalid tag base64")?,
})
}
pub fn decrypt(enc: &str, key: &[u8; 32], aad: &[u8]) -> anyhow::Result<String> {
if enc.is_empty() {
return Ok(String::new());
}
let parsed = parse(enc)?;
if parsed.iv.len() != 32 {
bail!("unexpected IV length {} (expected 32)", parsed.iv.len());
}
let cipher =
Cipher::new_from_slice(key).map_err(|_| anyhow::anyhow!("invalid AES key length"))?;
let mut combined = Vec::with_capacity(parsed.data.len() + parsed.tag.len());
combined.extend_from_slice(&parsed.data);
combined.extend_from_slice(&parsed.tag);
let nonce = Nonce::<Cipher>::from_slice(&parsed.iv);
let plain = cipher
.decrypt(
nonce,
Payload {
msg: &combined,
aad,
},
)
.map_err(|_| anyhow::anyhow!("AES-GCM decryption failed (wrong key or corrupted value)"))?;
String::from_utf8(plain).map_err(|_| anyhow::anyhow!("decrypted value was not UTF-8"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let key = [7u8; 32];
let plain = b"production";
let enc = encrypt(plain, &key, b"APP_ENV:").unwrap();
assert!(enc.starts_with(PREFIX));
assert!(enc.ends_with(",type:str]"));
assert_eq!(decrypt(&enc, &key, b"APP_ENV:").unwrap(), "production");
}
#[test]
fn empty_plaintext_stays_empty() {
let key = [1u8; 32];
assert_eq!(encrypt(b"", &key, b"EMPTY:").unwrap(), "");
assert_eq!(decrypt("", &key, b"EMPTY:").unwrap(), "");
}
#[test]
fn wrong_aad_fails() {
let key = [2u8; 32];
let enc = encrypt(b"secret", &key, b"A:").unwrap();
assert!(decrypt(&enc, &key, b"B:").is_err());
}
#[test]
fn wrong_key_fails() {
let enc = encrypt(b"secret", &[3u8; 32], b"A:").unwrap();
assert!(decrypt(&enc, &[4u8; 32], b"A:").is_err());
}
}