use aes_gcm::aead::{Aead, Generate, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use crate::errors::OrionError;
const PREFIX: &str = "enc:v1:";
const NONCE_LEN: usize = 12;
pub struct ConfigCipher {
cipher: Aes256Gcm,
}
impl std::fmt::Debug for ConfigCipher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ConfigCipher")
}
}
impl ConfigCipher {
pub fn from_hex(hex_key: &str) -> Result<Self, OrionError> {
let bytes = hex::decode(hex_key).ok().filter(|b| b.len() == 32);
let Some(bytes) = bytes else {
return Err(OrionError::Config {
message: "storage.connector_encryption_key must be the 64-character hex \
encoding of a 32-byte key. Generate one with `openssl rand -hex 32`"
.to_string(),
});
};
let key = Key::<Aes256Gcm>::try_from(bytes.as_slice()).map_err(|_| {
OrionError::internal("connector encryption key was not 32 bytes after hex decoding")
})?;
Ok(Self {
cipher: Aes256Gcm::new(&key),
})
}
pub fn is_encrypted(stored: &str) -> bool {
stored.starts_with(PREFIX)
}
pub fn encrypt(&self, plaintext: &str) -> Result<String, OrionError> {
let nonce = Nonce::generate();
let ciphertext = self
.cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|_| OrionError::internal("connector config encryption failed"))?;
let mut payload = Vec::with_capacity(NONCE_LEN + ciphertext.len());
payload.extend_from_slice(&nonce);
payload.extend_from_slice(&ciphertext);
Ok(format!("{PREFIX}{}", BASE64.encode(payload)))
}
pub fn decrypt(&self, stored: &str) -> Result<String, OrionError> {
let Some(encoded) = stored.strip_prefix(PREFIX) else {
return Ok(stored.to_string());
};
let payload = BASE64
.decode(encoded)
.ok()
.filter(|p| p.len() > NONCE_LEN)
.ok_or_else(|| {
OrionError::internal("stored connector config has a malformed encryption envelope")
})?;
let (nonce, ciphertext) = payload.split_at(NONCE_LEN);
let nonce = Nonce::try_from(nonce).map_err(|_| {
OrionError::internal("stored connector config has a malformed encryption envelope")
})?;
let plaintext = self.cipher.decrypt(&nonce, ciphertext).map_err(|_| {
OrionError::internal(
"stored connector config failed to decrypt: wrong \
storage.connector_encryption_key, or the row was modified \
outside Orion",
)
})?;
String::from_utf8(plaintext)
.map_err(|_| OrionError::internal("decrypted connector config is not UTF-8"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cipher() -> ConfigCipher {
ConfigCipher::from_hex(&"ab".repeat(32)).expect("valid key")
}
#[test]
fn round_trips_and_fresh_nonce_per_write() {
let c = cipher();
let doc = r#"{"type":"http","auth":{"token":"s3cret"}}"#;
let a = c.encrypt(doc).expect("encrypt");
let b = c.encrypt(doc).expect("encrypt");
assert!(ConfigCipher::is_encrypted(&a));
assert_ne!(a, b, "nonce reuse would break GCM outright");
assert_eq!(c.decrypt(&a).expect("decrypt"), doc);
assert_eq!(c.decrypt(&b).expect("decrypt"), doc);
}
#[test]
fn plaintext_rows_pass_through() {
let c = cipher();
let doc = r#"{"type":"http"}"#;
assert_eq!(c.decrypt(doc).expect("pass-through"), doc);
}
#[test]
fn the_wrong_key_fails_loudly() {
let stored = cipher().encrypt("{}").expect("encrypt");
let other = ConfigCipher::from_hex(&"cd".repeat(32)).expect("valid key");
assert!(other.decrypt(&stored).is_err());
}
#[test]
fn a_tampered_row_fails_loudly() {
let c = cipher();
let stored = c.encrypt("{}").expect("encrypt");
let mut payload = BASE64
.decode(stored.strip_prefix(PREFIX).expect("enveloped"))
.expect("valid base64");
let last = payload.len() - 1;
payload[last] ^= 0x01;
let tampered = format!("{PREFIX}{}", BASE64.encode(payload));
assert!(c.decrypt(&tampered).is_err(), "GCM must refuse a forgery");
}
#[test]
fn key_validation() {
assert!(ConfigCipher::from_hex("").is_err());
assert!(ConfigCipher::from_hex("abcd").is_err());
assert!(ConfigCipher::from_hex(&"zz".repeat(32)).is_err());
assert!(ConfigCipher::from_hex(&"ab".repeat(32)).is_ok());
}
}