hayson-engine 0.27.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
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;

/// The field name an error about the storage layer's encryption carries, so that its message does
/// not read as one about an encrypted field.
pub const STORAGE_FIELD: &str = "storage";

const LINE_PREFIX: &str = "$hayson-seal$";
const LINE_VERSION: &str = "v1";
/// Opens every sealed file that is not made of lines (the catalog, a snapshot, an image, the journal).
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)
}

/// Seals what the storage layer writes — every log line, and every file that is not a log — with
/// AES-256-GCM under a key the host hands over at `open()`, and opens it again on the way in.
///
/// Nothing here knows what it seals. The `scope` of each call (`"log:users"`, `"catalog"`,
/// `"image:docs"`…) goes into the additional authenticated data, so a sealed unit moved to another
/// table or another kind of file fails authentication instead of being read.
#[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)
            })
    }

    /// One log line as `$hayson-seal$v1$<keyId>$<iv>$<ciphertext+tag>` — text without a newline, so a
    /// sealed log keeps the line framing the engine's offsets and tail detection rest on.
    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))
    }

    /// A whole file: `HSEAL\x01`, the key id (length-prefixed), the IV and the ciphertext with its tag.
    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)
    }
}

/// Whether a log line is one of this layer's sealed lines (as opposed to a plain JSON record).
pub fn is_sealed_line(line: &str) -> bool {
    line.starts_with(LINE_PREFIX)
}

/// Whether a file's bytes open with this layer's seal marker.
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")));
    }
}

/// Image file names in an encrypted database: the content hash is keyed, so a file name tells
/// nothing about the bytes to someone without the key (a plain FNV-1a name would let anyone confirm
/// a guessed image). The same `<16 hex digits>.bin` shape as an unencrypted name; "v1" is the
/// BLAKE2b-MAC of the bytes under a key derived from the database key.
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 {
    /// The key image names are made under, derived from the write key so that the name key and the
    /// encryption key are never the same bytes.
    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()
    }

    /// The bytes an unsealed blob would hold for a sealed one of `sealed_len` whose header is
    /// `header` (at least the first 7 bytes) — what an image's size is reported as.
    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}");
    }
}