use std::collections::HashMap;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::Rng;
use crate::errors::HaysonCipherError;
use super::field_cipher::CipherOptions;
pub const STORAGE_FIELD: &str = "storage";
const LINE_PREFIX: &str = "$hayson-seal$";
const LINE_VERSION: &str = "v1";
const BLOB_MAGIC: &[u8; 6] = b"HSEAL\x01";
const IV_BYTES: usize = 12;
const TAG_BYTES: usize = 16;
const KEY_BYTES: usize = 32;
const AAD_SEPARATOR: u8 = 0x1f;
fn is_key_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
}
fn error(reason: impl Into<String>) -> HaysonCipherError {
HaysonCipherError::new("", STORAGE_FIELD, reason)
}
#[derive(Debug, Clone)]
pub struct Sealer {
keys: HashMap<String, Vec<u8>>,
write_key_id: String,
}
impl Sealer {
pub fn new(options: &CipherOptions) -> Result<Self, HaysonCipherError> {
let mut keys = HashMap::with_capacity(options.keys.len());
for (id, material) in &options.keys {
if !is_key_id(id) {
return Err(error(format!(
"key id \"{id}\" may only hold letters, digits, \".\", \"_\" and \"-\" (64 at most)"
)));
}
if material.len() != KEY_BYTES {
return Err(error(format!(
"key \"{id}\" must be {KEY_BYTES} bytes, not {}",
material.len()
)));
}
keys.insert(id.clone(), material.clone());
}
if keys.is_empty() {
return Err(error("needs at least one key"));
}
let write_key_id = match &options.key_id {
Some(id) if keys.contains_key(id) => id.clone(),
Some(id) => return Err(error(format!("keyId \"{id}\" is not among the keys given"))),
None if keys.len() == 1 => keys.keys().next().expect("checked non-empty above").clone(),
None => {
return Err(error(
"several keys were given, so \"keyId\" has to say which one writes",
))
}
};
Ok(Sealer { keys, write_key_id })
}
pub fn write_key_id(&self) -> &str {
&self.write_key_id
}
pub fn knows(&self, key_id: &str) -> bool {
self.keys.contains_key(key_id)
}
fn aad(scope: &str) -> Vec<u8> {
let mut aad = Vec::with_capacity(scope.len() + 8);
aad.extend_from_slice(b"hayson-seal");
aad.push(AAD_SEPARATOR);
aad.extend_from_slice(scope.as_bytes());
aad
}
fn encrypt(&self, scope: &str, plain: &[u8]) -> ([u8; IV_BYTES], Vec<u8>) {
let material = &self.keys[&self.write_key_id];
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::try_from(material.as_slice()).expect("32 bytes checked"));
let mut iv = [0u8; IV_BYTES];
rand::rng().fill_bytes(&mut iv);
let body = cipher
.encrypt(
&Nonce::from(iv),
Payload {
msg: plain,
aad: &Self::aad(scope),
},
)
.expect("AES-256-GCM encryption with a valid 32-byte key must not fail");
(iv, body)
}
fn decrypt(&self, scope: &str, key_id: &str, iv: &[u8], body: &[u8]) -> Result<Vec<u8>, HaysonCipherError> {
let Some(material) = self.keys.get(key_id) else {
return Err(error(format!("was written with key \"{key_id}\", which was not given")).with_key_id(key_id));
};
if iv.len() != IV_BYTES || body.len() < TAG_BYTES {
return Err(error(format!("holds a truncated sealed unit ({scope})")).with_key_id(key_id));
}
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::try_from(material.as_slice()).expect("32 bytes checked"));
let iv: [u8; IV_BYTES] = iv.try_into().expect("length checked above");
cipher
.decrypt(
&Nonce::from(iv),
Payload {
msg: body,
aad: &Self::aad(scope),
},
)
.map_err(|_| {
error(format!(
"failed its authentication tag ({scope}) — wrong key, or the data was changed or moved"
))
.with_key_id(key_id)
})
}
pub fn seal_line(&self, scope: &str, plain: &str) -> String {
let (iv, body) = self.encrypt(scope, plain.as_bytes());
format!(
"{LINE_PREFIX}{LINE_VERSION}${}${}${}",
self.write_key_id,
URL_SAFE_NO_PAD.encode(iv),
URL_SAFE_NO_PAD.encode(body)
)
}
pub fn open_line(&self, scope: &str, sealed: &str) -> Result<String, HaysonCipherError> {
let rest = sealed
.strip_prefix(LINE_PREFIX)
.ok_or_else(|| error(format!("holds a line that is not sealed ({scope})")))?;
let parts: Vec<&str> = rest.split('$').collect();
if parts.len() != 4 {
return Err(error(format!("holds a malformed sealed line ({scope})")));
}
if parts[0] != LINE_VERSION {
return Err(error(format!(
"was sealed in format \"{}\", which this build cannot read",
parts[0]
)));
}
let (key_id, iv, body) = (parts[1], parts[2], parts[3]);
let iv = URL_SAFE_NO_PAD
.decode(iv)
.map_err(|_| error(format!("holds a malformed sealed line ({scope})")))?;
let body = URL_SAFE_NO_PAD
.decode(body)
.map_err(|_| error(format!("holds a malformed sealed line ({scope})")))?;
let plain = self.decrypt(scope, key_id, &iv, &body)?;
String::from_utf8(plain)
.map_err(|_| error(format!("sealed a line that is not UTF-8 ({scope})")).with_key_id(key_id))
}
pub fn seal_blob(&self, scope: &str, plain: &[u8]) -> Vec<u8> {
let (iv, body) = self.encrypt(scope, plain);
let key_id = self.write_key_id.as_bytes();
let mut out = Vec::with_capacity(BLOB_MAGIC.len() + 1 + key_id.len() + IV_BYTES + body.len());
out.extend_from_slice(BLOB_MAGIC);
out.push(key_id.len() as u8);
out.extend_from_slice(key_id);
out.extend_from_slice(&iv);
out.extend_from_slice(&body);
out
}
pub fn open_blob(&self, scope: &str, bytes: &[u8]) -> Result<Vec<u8>, HaysonCipherError> {
let rest = bytes
.strip_prefix(BLOB_MAGIC.as_slice())
.ok_or_else(|| error(format!("holds a file that is not sealed ({scope})")))?;
let (&key_len, rest) = rest
.split_first()
.ok_or_else(|| error(format!("holds a truncated sealed file ({scope})")))?;
let key_len = key_len as usize;
if rest.len() < key_len + IV_BYTES {
return Err(error(format!("holds a truncated sealed file ({scope})")));
}
let key_id = std::str::from_utf8(&rest[..key_len])
.map_err(|_| error(format!("holds a malformed sealed file ({scope})")))?;
let (iv, body) = rest[key_len..].split_at(IV_BYTES);
self.decrypt(scope, key_id, iv, body)
}
}
pub fn is_sealed_line(line: &str) -> bool {
line.starts_with(LINE_PREFIX)
}
pub fn is_sealed_blob(bytes: &[u8]) -> bool {
bytes.starts_with(BLOB_MAGIC)
}
#[cfg(test)]
mod tests {
use super::*;
fn sealer(byte: u8, id: &str) -> Sealer {
Sealer::new(&CipherOptions {
keys: [(id.to_string(), vec![byte; KEY_BYTES])].into(),
key_id: None,
on_missing_key: None,
})
.unwrap()
}
#[test]
fn a_line_round_trips_and_stays_one_line_of_text() {
let sealer = sealer(1, "k1");
let plain = r#"{"key":"a","name":"Zażółć\ngęślą"}"#;
let sealed = sealer.seal_line("log:users", plain);
assert!(sealed.starts_with("$hayson-seal$v1$k1$"));
assert!(!sealed.contains('\n'));
assert!(!sealed.contains("Zażółć"));
assert!(is_sealed_line(&sealed));
assert_eq!(sealer.open_line("log:users", &sealed).unwrap(), plain);
}
#[test]
fn equal_lines_seal_differently_and_a_moved_line_fails_authentication() {
let sealer = sealer(1, "k1");
let first = sealer.seal_line("log:users", "x");
assert_ne!(first, sealer.seal_line("log:users", "x"));
let moved = sealer.open_line("log:orders", &first).unwrap_err();
assert_eq!(moved.field, STORAGE_FIELD);
assert_eq!(moved.key_id.as_deref(), Some("k1"));
}
#[test]
fn the_wrong_key_and_a_missing_key_are_told_apart() {
let sealed = sealer(1, "k1").seal_line("log:users", "x");
let wrong = sealer(2, "k1").open_line("log:users", &sealed).unwrap_err();
let missing = sealer(2, "other").open_line("log:users", &sealed).unwrap_err();
assert!(wrong.reason.contains("authentication"), "{wrong}");
assert!(missing.reason.contains("not given"), "{missing}");
}
#[test]
fn a_tampered_or_truncated_line_is_refused() {
let sealer = sealer(1, "k1");
let sealed = sealer.seal_line("log:users", "hello");
let mut tampered = sealed.clone().into_bytes();
let last = tampered.len() - 2;
tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' };
assert!(sealer
.open_line("log:users", &String::from_utf8(tampered).unwrap())
.is_err());
assert!(sealer.open_line("log:users", &sealed[..sealed.len() - 10]).is_err());
assert!(sealer.open_line("log:users", "{\"plain\":true}").is_err());
}
#[test]
fn a_blob_round_trips_any_bytes_and_is_bound_to_its_scope() {
let sealer = sealer(1, "k1");
let plain: Vec<u8> = (0..=255).collect();
let sealed = sealer.seal_blob("image:docs", &plain);
assert!(is_sealed_blob(&sealed));
assert_eq!(sealer.open_blob("image:docs", &sealed).unwrap(), plain);
assert!(sealer.open_blob("catalog", &sealed).is_err());
assert!(sealer.open_blob("image:docs", &sealed[..sealed.len() - 1]).is_err());
assert!(sealer.open_blob("image:docs", &plain).is_err());
}
#[test]
fn keys_are_validated_like_the_field_cipher_validates_them() {
let bad = |keys: Vec<(&str, Vec<u8>)>, key_id: Option<&str>| {
Sealer::new(&CipherOptions {
keys: keys.into_iter().map(|(id, k)| (id.to_string(), k)).collect(),
key_id: key_id.map(str::to_string),
on_missing_key: None,
})
.is_err()
};
assert!(bad(vec![], None));
assert!(bad(vec![("k", vec![0; 31])], None));
assert!(bad(vec![("bad id", vec![0; 32])], None));
assert!(bad(vec![("a", vec![0; 32]), ("b", vec![1; 32])], None));
assert!(bad(vec![("a", vec![0; 32])], Some("zzz")));
}
}
pub fn keyed_physical_path(name_key: &[u8; 32], bytes: &[u8]) -> String {
use blake2::digest::consts::U8;
use blake2::digest::{KeyInit, Mac};
let mut mac =
<blake2::Blake2bMac<U8> as KeyInit>::new_from_slice(name_key).expect("a 32-byte key is a valid MAC key");
mac.update(bytes);
let tag = mac.finalize().into_bytes();
format!("{}.bin", tag.iter().map(|b| format!("{b:02x}")).collect::<String>())
}
impl Sealer {
pub fn image_name_key(&self) -> [u8; 32] {
use blake2::digest::consts::U32;
use blake2::digest::{KeyInit, Mac};
let material = &self.keys[&self.write_key_id];
let mut mac =
<blake2::Blake2bMac<U32> as KeyInit>::new_from_slice(material).expect("a 32-byte key is a valid MAC key");
mac.update(b"hayson image names v1");
mac.finalize().into_bytes().into()
}
pub fn plain_len_of_blob(&self, sealed_len: u64, header: &[u8]) -> u64 {
if !is_sealed_blob(header) || header.len() < BLOB_MAGIC.len() + 1 {
return sealed_len;
}
let overhead = (BLOB_MAGIC.len() + 1 + header[BLOB_MAGIC.len()] as usize + IV_BYTES + TAG_BYTES) as u64;
sealed_len.saturating_sub(overhead)
}
}
#[cfg(test)]
mod name_tests {
use super::*;
#[test]
fn a_keyed_name_is_stable_per_key_and_differs_between_keys_and_bytes() {
let a = [1u8; 32];
let b = [2u8; 32];
let name = keyed_physical_path(&a, b"bytes");
assert_eq!(name, keyed_physical_path(&a, b"bytes"));
assert_ne!(name, keyed_physical_path(&b, b"bytes"));
assert_ne!(name, keyed_physical_path(&a, b"other"));
assert!(crate::schema::is_physical_path(&name), "{name}");
}
}