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
13pub const STORAGE_FIELD: &str = "storage";
16
17const LINE_PREFIX: &str = "$hayson-seal$";
18const LINE_VERSION: &str = "v1";
19const 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#[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 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 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
210pub fn is_sealed_line(line: &str) -> bool {
212 line.starts_with(LINE_PREFIX)
213}
214
215pub 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
317pub 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 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 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}