Skip to main content

hayson_engine/crypto/
seal.rs

1use std::collections::HashMap;
2
3use aes_gcm::aead::{Aead, KeyInit, Payload};
4use aes_gcm::{Aes256Gcm, Key, Nonce};
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use base64::Engine;
7use rand::Rng;
8
9use crate::errors::HaysonCipherError;
10
11use super::field_cipher::CipherOptions;
12
13/// The field name an error about the storage layer's encryption carries, so that its message does
14/// not read as one about an encrypted field.
15pub const STORAGE_FIELD: &str = "storage";
16
17const LINE_PREFIX: &str = "$hayson-seal$";
18const LINE_VERSION: &str = "v1";
19/// Opens every sealed file that is not made of lines (the catalog, a snapshot, an image, the journal).
20const BLOB_MAGIC: &[u8; 6] = b"HSEAL\x01";
21const IV_BYTES: usize = 12;
22const TAG_BYTES: usize = 16;
23const KEY_BYTES: usize = 32;
24const AAD_SEPARATOR: u8 = 0x1f;
25
26fn is_key_id(id: &str) -> bool {
27    !id.is_empty()
28        && id.len() <= 64
29        && id
30            .bytes()
31            .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
32}
33
34fn error(reason: impl Into<String>) -> HaysonCipherError {
35    HaysonCipherError::new("", STORAGE_FIELD, reason)
36}
37
38/// Seals what the storage layer writes — every log line, and every file that is not a log — with
39/// AES-256-GCM under a key the host hands over at `open()`, and opens it again on the way in.
40///
41/// Nothing here knows what it seals. The `scope` of each call (`"log:users"`, `"catalog"`,
42/// `"image:docs"`…) goes into the additional authenticated data, so a sealed unit moved to another
43/// table or another kind of file fails authentication instead of being read.
44#[derive(Debug, Clone)]
45pub struct Sealer {
46    keys: HashMap<String, Vec<u8>>,
47    write_key_id: String,
48}
49
50impl Sealer {
51    pub fn new(options: &CipherOptions) -> Result<Self, HaysonCipherError> {
52        let mut keys = HashMap::with_capacity(options.keys.len());
53        for (id, material) in &options.keys {
54            if !is_key_id(id) {
55                return Err(error(format!(
56                    "key id \"{id}\" may only hold letters, digits, \".\", \"_\" and \"-\" (64 at most)"
57                )));
58            }
59            if material.len() != KEY_BYTES {
60                return Err(error(format!(
61                    "key \"{id}\" must be {KEY_BYTES} bytes, not {}",
62                    material.len()
63                )));
64            }
65            keys.insert(id.clone(), material.clone());
66        }
67        if keys.is_empty() {
68            return Err(error("needs at least one key"));
69        }
70        let write_key_id = match &options.key_id {
71            Some(id) if keys.contains_key(id) => id.clone(),
72            Some(id) => return Err(error(format!("keyId \"{id}\" is not among the keys given"))),
73            None if keys.len() == 1 => keys.keys().next().expect("checked non-empty above").clone(),
74            None => {
75                return Err(error(
76                    "several keys were given, so \"keyId\" has to say which one writes",
77                ))
78            }
79        };
80        Ok(Sealer { keys, write_key_id })
81    }
82
83    pub fn write_key_id(&self) -> &str {
84        &self.write_key_id
85    }
86
87    pub fn knows(&self, key_id: &str) -> bool {
88        self.keys.contains_key(key_id)
89    }
90
91    fn aad(scope: &str) -> Vec<u8> {
92        let mut aad = Vec::with_capacity(scope.len() + 8);
93        aad.extend_from_slice(b"hayson-seal");
94        aad.push(AAD_SEPARATOR);
95        aad.extend_from_slice(scope.as_bytes());
96        aad
97    }
98
99    fn encrypt(&self, scope: &str, plain: &[u8]) -> ([u8; IV_BYTES], Vec<u8>) {
100        let material = &self.keys[&self.write_key_id];
101        let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::try_from(material.as_slice()).expect("32 bytes checked"));
102        let mut iv = [0u8; IV_BYTES];
103        rand::rng().fill_bytes(&mut iv);
104        let body = cipher
105            .encrypt(
106                &Nonce::from(iv),
107                Payload {
108                    msg: plain,
109                    aad: &Self::aad(scope),
110                },
111            )
112            .expect("AES-256-GCM encryption with a valid 32-byte key must not fail");
113        (iv, body)
114    }
115
116    fn decrypt(&self, scope: &str, key_id: &str, iv: &[u8], body: &[u8]) -> Result<Vec<u8>, HaysonCipherError> {
117        let Some(material) = self.keys.get(key_id) else {
118            return Err(error(format!("was written with key \"{key_id}\", which was not given")).with_key_id(key_id));
119        };
120        if iv.len() != IV_BYTES || body.len() < TAG_BYTES {
121            return Err(error(format!("holds a truncated sealed unit ({scope})")).with_key_id(key_id));
122        }
123        let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::try_from(material.as_slice()).expect("32 bytes checked"));
124        let iv: [u8; IV_BYTES] = iv.try_into().expect("length checked above");
125        cipher
126            .decrypt(
127                &Nonce::from(iv),
128                Payload {
129                    msg: body,
130                    aad: &Self::aad(scope),
131                },
132            )
133            .map_err(|_| {
134                error(format!(
135                    "failed its authentication tag ({scope}) — wrong key, or the data was changed or moved"
136                ))
137                .with_key_id(key_id)
138            })
139    }
140
141    /// One log line as `$hayson-seal$v1$<keyId>$<iv>$<ciphertext+tag>` — text without a newline, so a
142    /// sealed log keeps the line framing the engine's offsets and tail detection rest on.
143    pub fn seal_line(&self, scope: &str, plain: &str) -> String {
144        let (iv, body) = self.encrypt(scope, plain.as_bytes());
145        format!(
146            "{LINE_PREFIX}{LINE_VERSION}${}${}${}",
147            self.write_key_id,
148            URL_SAFE_NO_PAD.encode(iv),
149            URL_SAFE_NO_PAD.encode(body)
150        )
151    }
152
153    pub fn open_line(&self, scope: &str, sealed: &str) -> Result<String, HaysonCipherError> {
154        let rest = sealed
155            .strip_prefix(LINE_PREFIX)
156            .ok_or_else(|| error(format!("holds a line that is not sealed ({scope})")))?;
157        let parts: Vec<&str> = rest.split('$').collect();
158        if parts.len() != 4 {
159            return Err(error(format!("holds a malformed sealed line ({scope})")));
160        }
161        if parts[0] != LINE_VERSION {
162            return Err(error(format!(
163                "was sealed in format \"{}\", which this build cannot read",
164                parts[0]
165            )));
166        }
167        let (key_id, iv, body) = (parts[1], parts[2], parts[3]);
168        let iv = URL_SAFE_NO_PAD
169            .decode(iv)
170            .map_err(|_| error(format!("holds a malformed sealed line ({scope})")))?;
171        let body = URL_SAFE_NO_PAD
172            .decode(body)
173            .map_err(|_| error(format!("holds a malformed sealed line ({scope})")))?;
174        let plain = self.decrypt(scope, key_id, &iv, &body)?;
175        String::from_utf8(plain)
176            .map_err(|_| error(format!("sealed a line that is not UTF-8 ({scope})")).with_key_id(key_id))
177    }
178
179    /// A whole file: `HSEAL\x01`, the key id (length-prefixed), the IV and the ciphertext with its tag.
180    pub fn seal_blob(&self, scope: &str, plain: &[u8]) -> Vec<u8> {
181        let (iv, body) = self.encrypt(scope, plain);
182        let key_id = self.write_key_id.as_bytes();
183        let mut out = Vec::with_capacity(BLOB_MAGIC.len() + 1 + key_id.len() + IV_BYTES + body.len());
184        out.extend_from_slice(BLOB_MAGIC);
185        out.push(key_id.len() as u8);
186        out.extend_from_slice(key_id);
187        out.extend_from_slice(&iv);
188        out.extend_from_slice(&body);
189        out
190    }
191
192    pub fn open_blob(&self, scope: &str, bytes: &[u8]) -> Result<Vec<u8>, HaysonCipherError> {
193        let rest = bytes
194            .strip_prefix(BLOB_MAGIC.as_slice())
195            .ok_or_else(|| error(format!("holds a file that is not sealed ({scope})")))?;
196        let (&key_len, rest) = rest
197            .split_first()
198            .ok_or_else(|| error(format!("holds a truncated sealed file ({scope})")))?;
199        let key_len = key_len as usize;
200        if rest.len() < key_len + IV_BYTES {
201            return Err(error(format!("holds a truncated sealed file ({scope})")));
202        }
203        let key_id = std::str::from_utf8(&rest[..key_len])
204            .map_err(|_| error(format!("holds a malformed sealed file ({scope})")))?;
205        let (iv, body) = rest[key_len..].split_at(IV_BYTES);
206        self.decrypt(scope, key_id, iv, body)
207    }
208}
209
210/// Whether a log line is one of this layer's sealed lines (as opposed to a plain JSON record).
211pub fn is_sealed_line(line: &str) -> bool {
212    line.starts_with(LINE_PREFIX)
213}
214
215/// Whether a file's bytes open with this layer's seal marker.
216pub fn is_sealed_blob(bytes: &[u8]) -> bool {
217    bytes.starts_with(BLOB_MAGIC)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn sealer(byte: u8, id: &str) -> Sealer {
225        Sealer::new(&CipherOptions {
226            keys: [(id.to_string(), vec![byte; KEY_BYTES])].into(),
227            key_id: None,
228            on_missing_key: None,
229        })
230        .unwrap()
231    }
232
233    #[test]
234    fn a_line_round_trips_and_stays_one_line_of_text() {
235        let sealer = sealer(1, "k1");
236        let plain = r#"{"key":"a","name":"Zażółć\ngęślą"}"#;
237
238        let sealed = sealer.seal_line("log:users", plain);
239
240        assert!(sealed.starts_with("$hayson-seal$v1$k1$"));
241        assert!(!sealed.contains('\n'));
242        assert!(!sealed.contains("Zażółć"));
243        assert!(is_sealed_line(&sealed));
244        assert_eq!(sealer.open_line("log:users", &sealed).unwrap(), plain);
245    }
246
247    #[test]
248    fn equal_lines_seal_differently_and_a_moved_line_fails_authentication() {
249        let sealer = sealer(1, "k1");
250        let first = sealer.seal_line("log:users", "x");
251        assert_ne!(first, sealer.seal_line("log:users", "x"));
252
253        let moved = sealer.open_line("log:orders", &first).unwrap_err();
254
255        assert_eq!(moved.field, STORAGE_FIELD);
256        assert_eq!(moved.key_id.as_deref(), Some("k1"));
257    }
258
259    #[test]
260    fn the_wrong_key_and_a_missing_key_are_told_apart() {
261        let sealed = sealer(1, "k1").seal_line("log:users", "x");
262
263        let wrong = sealer(2, "k1").open_line("log:users", &sealed).unwrap_err();
264        let missing = sealer(2, "other").open_line("log:users", &sealed).unwrap_err();
265
266        assert!(wrong.reason.contains("authentication"), "{wrong}");
267        assert!(missing.reason.contains("not given"), "{missing}");
268    }
269
270    #[test]
271    fn a_tampered_or_truncated_line_is_refused() {
272        let sealer = sealer(1, "k1");
273        let sealed = sealer.seal_line("log:users", "hello");
274        let mut tampered = sealed.clone().into_bytes();
275        let last = tampered.len() - 2;
276        tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' };
277
278        assert!(sealer
279            .open_line("log:users", &String::from_utf8(tampered).unwrap())
280            .is_err());
281        assert!(sealer.open_line("log:users", &sealed[..sealed.len() - 10]).is_err());
282        assert!(sealer.open_line("log:users", "{\"plain\":true}").is_err());
283    }
284
285    #[test]
286    fn a_blob_round_trips_any_bytes_and_is_bound_to_its_scope() {
287        let sealer = sealer(1, "k1");
288        let plain: Vec<u8> = (0..=255).collect();
289
290        let sealed = sealer.seal_blob("image:docs", &plain);
291
292        assert!(is_sealed_blob(&sealed));
293        assert_eq!(sealer.open_blob("image:docs", &sealed).unwrap(), plain);
294        assert!(sealer.open_blob("catalog", &sealed).is_err());
295        assert!(sealer.open_blob("image:docs", &sealed[..sealed.len() - 1]).is_err());
296        assert!(sealer.open_blob("image:docs", &plain).is_err());
297    }
298
299    #[test]
300    fn keys_are_validated_like_the_field_cipher_validates_them() {
301        let bad = |keys: Vec<(&str, Vec<u8>)>, key_id: Option<&str>| {
302            Sealer::new(&CipherOptions {
303                keys: keys.into_iter().map(|(id, k)| (id.to_string(), k)).collect(),
304                key_id: key_id.map(str::to_string),
305                on_missing_key: None,
306            })
307            .is_err()
308        };
309        assert!(bad(vec![], None));
310        assert!(bad(vec![("k", vec![0; 31])], None));
311        assert!(bad(vec![("bad id", vec![0; 32])], None));
312        assert!(bad(vec![("a", vec![0; 32]), ("b", vec![1; 32])], None));
313        assert!(bad(vec![("a", vec![0; 32])], Some("zzz")));
314    }
315}
316
317/// Image file names in an encrypted database: the content hash is keyed, so a file name tells
318/// nothing about the bytes to someone without the key (a plain FNV-1a name would let anyone confirm
319/// a guessed image). The same `<16 hex digits>.bin` shape as an unencrypted name; "v1" is the
320/// BLAKE2b-MAC of the bytes under a key derived from the database key.
321pub fn keyed_physical_path(name_key: &[u8; 32], bytes: &[u8]) -> String {
322    use blake2::digest::consts::U8;
323    use blake2::digest::{KeyInit, Mac};
324    let mut mac =
325        <blake2::Blake2bMac<U8> as KeyInit>::new_from_slice(name_key).expect("a 32-byte key is a valid MAC key");
326    mac.update(bytes);
327    let tag = mac.finalize().into_bytes();
328    format!("{}.bin", tag.iter().map(|b| format!("{b:02x}")).collect::<String>())
329}
330
331impl Sealer {
332    /// The key image names are made under, derived from the write key so that the name key and the
333    /// encryption key are never the same bytes.
334    pub fn image_name_key(&self) -> [u8; 32] {
335        use blake2::digest::consts::U32;
336        use blake2::digest::{KeyInit, Mac};
337        let material = &self.keys[&self.write_key_id];
338        let mut mac =
339            <blake2::Blake2bMac<U32> as KeyInit>::new_from_slice(material).expect("a 32-byte key is a valid MAC key");
340        mac.update(b"hayson image names v1");
341        mac.finalize().into_bytes().into()
342    }
343
344    /// The bytes an unsealed blob would hold for a sealed one of `sealed_len` whose header is
345    /// `header` (at least the first 7 bytes) — what an image's size is reported as.
346    pub fn plain_len_of_blob(&self, sealed_len: u64, header: &[u8]) -> u64 {
347        if !is_sealed_blob(header) || header.len() < BLOB_MAGIC.len() + 1 {
348            return sealed_len;
349        }
350        let overhead = (BLOB_MAGIC.len() + 1 + header[BLOB_MAGIC.len()] as usize + IV_BYTES + TAG_BYTES) as u64;
351        sealed_len.saturating_sub(overhead)
352    }
353}
354
355#[cfg(test)]
356mod name_tests {
357    use super::*;
358
359    #[test]
360    fn a_keyed_name_is_stable_per_key_and_differs_between_keys_and_bytes() {
361        let a = [1u8; 32];
362        let b = [2u8; 32];
363        let name = keyed_physical_path(&a, b"bytes");
364
365        assert_eq!(name, keyed_physical_path(&a, b"bytes"));
366        assert_ne!(name, keyed_physical_path(&b, b"bytes"));
367        assert_ne!(name, keyed_physical_path(&a, b"other"));
368        assert!(crate::schema::is_physical_path(&name), "{name}");
369    }
370}