use aes_gcm::Aes256Gcm;
use aes_gcm::aead::array::Array;
use aes_gcm::aead::{Aead as _, Generate, KeyInit, Nonce};
use sha2::{Digest, Sha256};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CryptoError {
#[error("encryption failed")]
Seal,
#[error("decryption failed (tampered or wrong key)")]
Open,
}
pub trait Aead: Send + Sync {
fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError>;
fn open(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError>;
fn is_current(&self, _blob: &[u8]) -> bool {
true
}
fn active_key_id(&self) -> String {
"unversioned".to_string()
}
}
const VERSIONED_MARKER: u8 = 0x01;
const KEY_ID_LEN: usize = 4;
const NONCE_LEN: usize = 12;
pub fn key_id(key: &[u8; 32]) -> u32 {
let digest = Sha256::digest(key);
u32::from_be_bytes(digest[..KEY_ID_LEN].try_into().expect("sha256 is 32 bytes"))
}
struct Key {
id: u32,
cipher: Aes256Gcm,
}
pub struct KeyRing {
active: Key,
retired: Vec<Key>,
}
impl KeyRing {
pub fn new(active: &[u8; 32], retired: &[[u8; 32]]) -> Self {
Self {
active: Key {
id: key_id(active),
cipher: Aes256Gcm::new(active.into()),
},
retired: retired
.iter()
.map(|key| Key {
id: key_id(key),
cipher: Aes256Gcm::new(key.into()),
})
.collect(),
}
}
pub fn active_key_id(&self) -> u32 {
self.active.id
}
pub fn retired_key_count(&self) -> usize {
self.retired.len()
}
fn embedded_key_id(blob: &[u8]) -> Option<u32> {
if blob.len() < 1 + KEY_ID_LEN + NONCE_LEN || blob[0] != VERSIONED_MARKER {
return None;
}
Some(u32::from_be_bytes(
blob[1..1 + KEY_ID_LEN].try_into().expect("checked length"),
))
}
fn keys(&self) -> impl Iterator<Item = &Key> {
std::iter::once(&self.active).chain(self.retired.iter())
}
}
impl Aead for KeyRing {
fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let nonce = Nonce::<Aes256Gcm>::generate();
let ciphertext = self
.active
.cipher
.encrypt(&nonce, plaintext)
.map_err(|_| CryptoError::Seal)?;
let mut out = Vec::with_capacity(1 + KEY_ID_LEN + nonce.len() + ciphertext.len());
out.push(VERSIONED_MARKER);
out.extend_from_slice(&self.active.id.to_be_bytes());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Ok(out)
}
fn open(&self, blob: &[u8]) -> Result<Vec<u8>, CryptoError> {
if let Some(id) = Self::embedded_key_id(blob)
&& let Some(key) = self.keys().find(|key| key.id == id)
{
let (nonce, ciphertext) = blob[1 + KEY_ID_LEN..].split_at(NONCE_LEN);
let nonce = Array::try_from(nonce).map_err(|_| CryptoError::Open)?;
if let Ok(plaintext) = key.cipher.decrypt(&nonce, ciphertext) {
return Ok(plaintext);
}
}
if blob.len() >= NONCE_LEN {
let (nonce, ciphertext) = blob.split_at(NONCE_LEN);
if let Ok(nonce) = Array::try_from(nonce) {
for key in self.keys() {
if let Ok(plaintext) = key.cipher.decrypt(&nonce, ciphertext) {
return Ok(plaintext);
}
}
}
}
Err(CryptoError::Open)
}
fn is_current(&self, blob: &[u8]) -> bool {
Self::embedded_key_id(blob) == Some(self.active.id)
}
fn active_key_id(&self) -> String {
format!("{:08x}", self.active.id)
}
}
pub struct Aes256GcmAead {
cipher: Aes256Gcm,
}
impl Aes256GcmAead {
pub fn new(key: &[u8; 32]) -> Self {
Self {
cipher: Aes256Gcm::new(key.into()),
}
}
}
impl Aead for Aes256GcmAead {
fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let nonce = Nonce::<Aes256Gcm>::generate();
let ciphertext = self
.cipher
.encrypt(&nonce, plaintext)
.map_err(|_| CryptoError::Seal)?;
let mut out = Vec::with_capacity(nonce.len() + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Ok(out)
}
fn open(&self, blob: &[u8]) -> Result<Vec<u8>, CryptoError> {
if blob.len() < 12 {
return Err(CryptoError::Open);
}
let (nonce, ciphertext) = blob.split_at(12);
let nonce = Array::try_from(nonce).map_err(|_| CryptoError::Open)?;
self.cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| CryptoError::Open)
}
}
pub trait MasterKeyProvider: Send + Sync {
fn current_key(&self) -> [u8; 32];
fn retired_keys(&self) -> Vec<[u8; 32]> {
Vec::new()
}
}
pub struct StaticMasterKeyProvider {
key: [u8; 32],
retired: Vec<[u8; 32]>,
}
impl StaticMasterKeyProvider {
pub fn from_hex(hex_key: &str) -> Result<Self, CryptoError> {
Ok(Self {
key: decode_key(hex_key)?,
retired: Vec::new(),
})
}
pub fn from_env(var: &str) -> Result<Self, CryptoError> {
Self::from_hex(&read_env_or_file(var)?)
}
pub fn with_retired_from_env(mut self, var: &str) -> Result<Self, CryptoError> {
let Ok(raw) = read_env_or_file(var) else {
return Ok(self);
};
for candidate in raw.split(',').map(str::trim).filter(|c| !c.is_empty()) {
self.retired.push(decode_key(candidate)?);
}
Ok(self)
}
}
fn decode_key(hex_key: &str) -> Result<[u8; 32], CryptoError> {
let bytes = hex::decode(hex_key.trim()).map_err(|_| CryptoError::Seal)?;
bytes.try_into().map_err(|_| CryptoError::Seal)
}
fn read_env_or_file(var: &str) -> Result<String, CryptoError> {
let value = std::env::var(var).map_err(|_| CryptoError::Seal)?;
Ok(std::fs::read_to_string(&value).unwrap_or(value))
}
impl MasterKeyProvider for StaticMasterKeyProvider {
fn current_key(&self) -> [u8; 32] {
self.key
}
fn retired_keys(&self) -> Vec<[u8; 32]> {
self.retired.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn aead() -> Aes256GcmAead {
Aes256GcmAead::new(&[7u8; 32])
}
#[test]
fn round_trip() {
let aead = aead();
let plaintext = b"super secret value";
let sealed = aead.seal(plaintext).unwrap();
assert_eq!(aead.open(&sealed).unwrap(), plaintext);
}
#[test]
fn tamper_detection() {
let aead = aead();
let mut sealed = aead.seal(b"super secret value").unwrap();
let last = sealed.len() - 1;
sealed[last] ^= 0xFF;
assert!(aead.open(&sealed).is_err());
}
#[test]
fn wrong_key_fails() {
let sealed = aead().seal(b"super secret value").unwrap();
let other = Aes256GcmAead::new(&[9u8; 32]);
assert!(other.open(&sealed).is_err());
}
const OLD: [u8; 32] = [7u8; 32];
const NEW: [u8; 32] = [11u8; 32];
#[test]
fn key_ids_are_derived_and_distinct() {
assert_eq!(key_id(&OLD), key_id(&OLD));
assert_ne!(key_id(&OLD), key_id(&NEW));
}
#[test]
fn keyring_round_trip_and_marks_its_own_output_current() {
let ring = KeyRing::new(&NEW, &[]);
let sealed = ring.seal(b"hunter2").unwrap();
assert_eq!(ring.open(&sealed).unwrap(), b"hunter2");
assert!(ring.is_current(&sealed));
assert_eq!(ring.active_key_id(), key_id(&NEW));
}
#[test]
fn keyring_reads_pre_rotation_values() {
let legacy = Aes256GcmAead::new(&OLD).seal(b"written in 0.1").unwrap();
let ring = KeyRing::new(&OLD, &[]);
assert_eq!(ring.open(&legacy).unwrap(), b"written in 0.1");
assert!(!ring.is_current(&legacy));
}
#[test]
fn keyring_opens_values_sealed_by_a_retired_key() {
let before = KeyRing::new(&OLD, &[]);
let sealed = before.seal(b"sealed under the old key").unwrap();
let rotating = KeyRing::new(&NEW, &[OLD]);
assert_eq!(rotating.open(&sealed).unwrap(), b"sealed under the old key");
assert!(
!rotating.is_current(&sealed),
"a value on the retired key must be reported as needing rewrap"
);
assert_eq!(rotating.retired_key_count(), 1);
}
#[test]
fn keyring_refuses_a_value_whose_key_is_gone() {
let sealed = KeyRing::new(&OLD, &[]).seal(b"orphaned").unwrap();
assert!(KeyRing::new(&NEW, &[]).open(&sealed).is_err());
}
#[test]
fn keyring_detects_tampering() {
let ring = KeyRing::new(&NEW, &[]);
let mut sealed = ring.seal(b"hunter2").unwrap();
let last = sealed.len() - 1;
sealed[last] ^= 0xFF;
assert!(ring.open(&sealed).is_err());
}
#[test]
fn keyring_rejects_short_blobs_without_panicking() {
let ring = KeyRing::new(&NEW, &[]);
for len in 0..20 {
assert!(ring.open(&vec![VERSIONED_MARKER; len]).is_err());
}
}
}