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;
8use serde_json::Value;
9
10use crate::errors::HaysonCipherError;
11use crate::schema::{HaysonRecord, TableSchema};
12
13const ENVELOPE_PREFIX: &str = "cypher";
14const ENVELOPE_VERSION: &str = "v1";
15const IV_BYTES: usize = 12;
16const TAG_BYTES: usize = 16;
17const KEY_BYTES: usize = 32;
18const AAD_SEPARATOR: u8 = 0x1f;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum MissingKeyPolicy {
26 #[default]
27 Throw,
28 Omit,
29 Raw,
30}
31
32#[derive(Debug, Clone, Default)]
35pub struct CipherOptions {
36 pub keys: HashMap<String, Vec<u8>>,
38 pub key_id: Option<String>,
40 pub on_missing_key: Option<MissingKeyPolicy>,
41}
42
43fn is_key_id(id: &str) -> bool {
44 !id.is_empty()
45 && id
46 .bytes()
47 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
48}
49
50pub fn is_envelope(value: &Value) -> bool {
51 matches!(value, Value::String(s) if s.starts_with(&format!("${ENVELOPE_PREFIX}$")))
52}
53
54fn envelope_shape(text: &str) -> Option<&str> {
61 let parts: Vec<&str> = text.strip_prefix('$')?.split('$').collect();
62 if parts.len() != 5 || parts[0] != ENVELOPE_PREFIX || parts[1] != ENVELOPE_VERSION || !is_key_id(parts[2]) {
63 return None;
64 }
65 let iv = URL_SAFE_NO_PAD.decode(parts[3]).ok()?;
66 let body = URL_SAFE_NO_PAD.decode(parts[4]).ok()?;
67 if iv.len() != IV_BYTES || body.len() < TAG_BYTES {
68 return None;
69 }
70 Some(parts[2])
71}
72
73pub fn envelope_key_id(value: &Value) -> Option<&str> {
78 let text = value.as_str()?;
79 if !is_envelope(value) {
80 return None;
81 }
82 let parts: Vec<&str> = text.get(1..)?.split('$').collect();
84 if parts.len() != 5 || parts[0] != ENVELOPE_PREFIX || parts[1] != ENVELOPE_VERSION {
85 return None;
86 }
87 Some(parts[2])
88}
89
90pub fn cypher_fields_of(schema: &TableSchema) -> Vec<String> {
92 let mut fields: Vec<String> = schema
93 .fields
94 .iter()
95 .filter(|(_, spec)| spec.cypher)
96 .map(|(name, _)| name.clone())
97 .collect();
98 fields.sort();
99 fields
100}
101
102fn key_from_slice(material: &[u8]) -> Key<Aes256Gcm> {
103 Key::<Aes256Gcm>::try_from(material).expect("key material is checked to be KEY_BYTES long")
104}
105
106fn aad_of(table: &str, key: &str, field: &str) -> Vec<u8> {
107 let mut aad = Vec::with_capacity(table.len() + key.len() + field.len() + 2);
108 aad.extend_from_slice(table.as_bytes());
109 aad.push(AAD_SEPARATOR);
110 aad.extend_from_slice(key.as_bytes());
111 aad.push(AAD_SEPARATOR);
112 aad.extend_from_slice(field.as_bytes());
113 aad
114}
115
116#[derive(Debug, Clone)]
123pub struct FieldCipher {
124 keys: HashMap<String, Vec<u8>>,
125 write_key_id: String,
126 on_missing_key: MissingKeyPolicy,
127}
128
129impl FieldCipher {
130 pub fn new(options: CipherOptions) -> Result<Self, HaysonCipherError> {
131 let mut keys = HashMap::with_capacity(options.keys.len());
132 for (id, material) in options.keys {
133 if !is_key_id(&id) {
134 return Err(HaysonCipherError::new(
135 "",
136 "",
137 format!("key id \"{id}\" may only hold letters, digits, \".\", \"_\" and \"-\""),
138 ));
139 }
140 if material.len() != KEY_BYTES {
141 return Err(HaysonCipherError::new(
142 "",
143 "",
144 format!("key \"{id}\" must be {KEY_BYTES} bytes, not {}", material.len()),
145 ));
146 }
147 keys.insert(id, material);
148 }
149 if keys.is_empty() {
150 return Err(HaysonCipherError::new("", "", "needs at least one key"));
151 }
152 if let Some(key_id) = &options.key_id {
153 if !keys.contains_key(key_id) {
154 return Err(HaysonCipherError::new(
155 "",
156 "",
157 format!("keyId \"{key_id}\" is not among the keys given"),
158 ));
159 }
160 }
161 if options.key_id.is_none() && keys.len() > 1 {
162 return Err(HaysonCipherError::new(
163 "",
164 "",
165 "several keys were given, so \"keyId\" has to say which one writes",
166 ));
167 }
168 let write_key_id = match options.key_id {
169 Some(id) => id,
170 None => keys.keys().next().expect("checked non-empty above").clone(),
171 };
172 Ok(FieldCipher {
173 keys,
174 write_key_id,
175 on_missing_key: options.on_missing_key.unwrap_or_default(),
176 })
177 }
178
179 pub fn write_key_id(&self) -> &str {
180 &self.write_key_id
181 }
182
183 pub fn on_missing_key(&self) -> MissingKeyPolicy {
186 self.on_missing_key
187 }
188
189 pub fn knows(&self, key_id: &str) -> bool {
190 self.keys.contains_key(key_id)
191 }
192
193 pub fn encrypt(&self, table: &str, key: &str, field: &str, value: &Value) -> String {
194 let material = self
195 .keys
196 .get(&self.write_key_id)
197 .expect("write_key_id is always among the keys");
198 let cipher = Aes256Gcm::new(&key_from_slice(material));
199
200 let mut iv = [0u8; IV_BYTES];
201 rand::rng().fill_bytes(&mut iv);
202 let nonce = Nonce::from(iv);
203
204 let plaintext = serde_json::to_vec(value).expect("a validated field value always serializes to JSON");
205 let aad = aad_of(table, key, field);
206 let body = cipher
207 .encrypt(
208 &nonce,
209 Payload {
210 msg: &plaintext,
211 aad: &aad,
212 },
213 )
214 .expect("AES-256-GCM encryption with a valid 32-byte key must not fail");
215
216 format!(
217 "${ENVELOPE_PREFIX}${ENVELOPE_VERSION}${}${}${}",
218 self.write_key_id,
219 URL_SAFE_NO_PAD.encode(iv),
220 URL_SAFE_NO_PAD.encode(body),
221 )
222 }
223
224 pub fn decrypt(
226 &self,
227 table: &str,
228 key: &str,
229 field: &str,
230 envelope: &str,
231 ) -> Result<Option<Value>, HaysonCipherError> {
232 let parts: Vec<&str> = envelope.get(1..).unwrap_or("").split('$').collect();
234 if parts.len() != 5 || parts[0] != ENVELOPE_PREFIX {
235 return Err(HaysonCipherError::new(
236 table,
237 field,
238 "holds a value that is not a cypher envelope",
239 ));
240 }
241 let (version, key_id, iv_raw, body_raw) = (parts[1], parts[2], parts[3], parts[4]);
242 if version != ENVELOPE_VERSION {
243 return Err(HaysonCipherError::new(
244 table,
245 field,
246 format!("was written in envelope \"{version}\", which this build cannot read"),
247 ));
248 }
249
250 let Some(material) = self.keys.get(key_id) else {
251 return match self.on_missing_key {
252 MissingKeyPolicy::Omit => Ok(None),
253 MissingKeyPolicy::Raw => Ok(Some(Value::String(envelope.to_string()))),
254 MissingKeyPolicy::Throw => Err(HaysonCipherError::new(
255 table,
256 field,
257 format!("was written with key \"{key_id}\", which was not given"),
258 )
259 .with_key_id(key_id)),
260 };
261 };
262
263 let iv = URL_SAFE_NO_PAD.decode(iv_raw).map_err(|_| {
264 HaysonCipherError::new(table, field, "holds a truncated cypher envelope").with_key_id(key_id)
265 })?;
266 let body = URL_SAFE_NO_PAD.decode(body_raw).map_err(|_| {
267 HaysonCipherError::new(table, field, "holds a truncated cypher envelope").with_key_id(key_id)
268 })?;
269 if iv.len() != IV_BYTES || body.len() < TAG_BYTES {
270 return Err(HaysonCipherError::new(table, field, "holds a truncated cypher envelope").with_key_id(key_id));
271 }
272
273 let cipher = Aes256Gcm::new(&key_from_slice(material));
274 let iv_arr: [u8; IV_BYTES] = iv.as_slice().try_into().expect("checked length above");
275 let nonce = Nonce::from(iv_arr);
276 let aad = aad_of(table, key, field);
277 let plain = cipher.decrypt(&nonce, Payload { msg: &body, aad: &aad }).map_err(|_| {
278 HaysonCipherError::new(
279 table,
280 field,
281 "failed its authentication tag — the value, the record it sits on or the field name changed under it",
282 )
283 .with_key_id(key_id)
284 })?;
285
286 let value = serde_json::from_slice(&plain).map_err(|_| {
287 HaysonCipherError::new(
288 table,
289 field,
290 "holds a cypher envelope whose plaintext is not valid JSON",
291 )
292 .with_key_id(key_id)
293 })?;
294 Ok(Some(value))
295 }
296}
297
298pub fn assert_carried_seals(
312 schema: &TableSchema,
313 key: &str,
314 record: &HaysonRecord,
315 cipher: Option<&FieldCipher>,
316 carried: &[String],
317) -> Result<(), HaysonCipherError> {
318 for field in carried {
319 let Some(value) = record.get(field) else { continue };
320 if value.is_null() {
321 continue;
322 }
323 let shaped = value
324 .as_str()
325 .and_then(|envelope| Some((envelope, envelope_shape(envelope)?)));
326 let Some((envelope, key_id)) = shaped else {
327 return Err(HaysonCipherError::new(
328 &schema.table,
329 field.clone(),
330 "was handed over as already sealed, but it is not a cypher envelope",
331 ));
332 };
333 let Some(cipher) = cipher.filter(|cipher| cipher.knows(key_id)) else {
334 continue;
335 };
336 cipher.decrypt(&schema.table, key, field, envelope).map_err(|_| {
337 HaysonCipherError::new(
338 &schema.table,
339 field.clone(),
340 "was handed over as already sealed, but it does not open for this record and field",
341 )
342 .with_key_id(key_id)
343 })?;
344 }
345 Ok(())
346}
347
348pub fn seal_record(
356 schema: &TableSchema,
357 key: &str,
358 record: &HaysonRecord,
359 cipher: Option<&FieldCipher>,
360 fields: &[String],
361 carried: &[String],
362) -> Result<HaysonRecord, HaysonCipherError> {
363 if fields.is_empty() {
364 return Ok(record.clone());
365 }
366 assert_carried_seals(schema, key, record, cipher, carried)?;
367 let mut sealed = record.clone();
368 for field in fields {
369 let Some(value) = sealed.get(field) else { continue };
370 if value.is_null() || carried.iter().any(|carried| carried == field) {
371 continue;
372 }
373 let Some(cipher) = cipher else {
374 return Err(HaysonCipherError::new(
375 &schema.table,
376 field.clone(),
377 "cannot be written without a key",
378 ));
379 };
380 let envelope = cipher.encrypt(&schema.table, key, field, value);
381 sealed.insert(field.clone(), Value::String(envelope));
382 }
383 Ok(sealed)
384}
385
386pub fn open_record(
388 schema: &TableSchema,
389 key: &str,
390 record: &HaysonRecord,
391 cipher: Option<&FieldCipher>,
392 fields: &[String],
393) -> Result<HaysonRecord, HaysonCipherError> {
394 if fields.is_empty() {
395 return Ok(record.clone());
396 }
397 let mut opened = record.clone();
398 for field in fields {
399 let Some(value) = opened.get(field) else { continue };
400 if value.is_null() || !is_envelope(value) {
401 continue;
402 }
403 let Some(cipher) = cipher else {
404 return Err(HaysonCipherError::new(
405 &schema.table,
406 field.clone(),
407 "cannot be read without a key",
408 ));
409 };
410 let envelope = value.as_str().expect("is_envelope guarantees a string").to_string();
411 match cipher.decrypt(&schema.table, key, field, &envelope)? {
412 Some(plain) => {
413 opened.insert(field.clone(), plain);
414 }
415 None => {
416 opened.remove(field);
417 }
418 }
419 }
420 Ok(opened)
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::schema::parse_schema;
427 use serde_json::json;
428
429 fn key(byte: u8) -> Vec<u8> {
430 vec![byte; KEY_BYTES]
431 }
432
433 fn secrets_schema() -> TableSchema {
434 parse_schema(&json!({
435 "schemaVersion": 1,
436 "table": "secrets",
437 "revision": 1,
438 "primaryKey": "id",
439 "fields": {
440 "id": { "type": "string" },
441 "label": { "type": "string", "index": "exact" },
442 "token": { "type": "string", "cypher": true },
443 "pin": { "type": "int", "cypher": true },
444 },
445 }))
446 .unwrap()
447 }
448
449 fn cipher_with(keys: &[(&str, u8)], key_id: Option<&str>) -> FieldCipher {
450 FieldCipher::new(CipherOptions {
451 keys: keys.iter().map(|(id, byte)| (id.to_string(), key(*byte))).collect(),
452 key_id: key_id.map(str::to_string),
453 on_missing_key: None,
454 })
455 .unwrap()
456 }
457
458 #[test]
459 fn round_trips_a_value_with_the_correct_key() {
460 let cipher = cipher_with(&[("k1", 1)], None);
461 let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
462 assert!(envelope.starts_with("$cypher$v1$k1$"));
463 let plain = cipher.decrypt("secrets", "s1", "token", &envelope).unwrap();
464 assert_eq!(plain, Some(json!("hunter2")));
465 }
466
467 #[test]
468 fn two_equal_values_get_two_different_envelopes() {
469 let cipher = cipher_with(&[("k1", 1)], None);
470 let a = cipher.encrypt("secrets", "s1", "token", &json!("same"));
471 let b = cipher.encrypt("secrets", "s2", "token", &json!("same"));
472 assert_ne!(a, b);
473 }
474
475 #[test]
476 fn decryption_fails_with_the_wrong_key() {
477 let writer = cipher_with(&[("k1", 1)], None);
478 let envelope = writer.encrypt("secrets", "s1", "token", &json!("hunter2"));
479
480 let reader = cipher_with(&[("k1", 9)], None);
481 let error = reader.decrypt("secrets", "s1", "token", &envelope).unwrap_err();
482 assert!(error.reason.contains("authentication tag"));
483 }
484
485 #[test]
486 fn decryption_fails_when_the_ciphertext_is_tampered_with() {
487 let cipher = cipher_with(&[("k1", 1)], None);
488 let mut envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
489 envelope.push('A');
490 let error = cipher.decrypt("secrets", "s1", "token", &envelope).unwrap_err();
491 assert!(error.reason.contains("authentication tag") || error.reason.contains("truncated"));
492 }
493
494 #[test]
495 fn moving_a_value_to_another_field_fails_authentication() {
496 let cipher = cipher_with(&[("k1", 1)], None);
497 let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
498 let error = cipher.decrypt("secrets", "s1", "pin", &envelope).unwrap_err();
499 assert!(error.reason.contains("authentication tag"));
500 }
501
502 #[test]
503 fn moving_a_value_to_another_record_fails_authentication() {
504 let cipher = cipher_with(&[("k1", 1)], None);
505 let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
506 let error = cipher.decrypt("secrets", "s2", "token", &envelope).unwrap_err();
507 assert!(error.reason.contains("authentication tag"));
508 }
509
510 #[test]
511 fn moving_a_value_to_another_table_fails_authentication() {
512 let cipher = cipher_with(&[("k1", 1)], None);
513 let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
514 let error = cipher.decrypt("other", "s1", "token", &envelope).unwrap_err();
515 assert!(error.reason.contains("authentication tag"));
516 }
517
518 #[test]
519 fn missing_key_policy_throw_is_the_default() {
520 let cipher = FieldCipher::new(CipherOptions {
521 keys: HashMap::from([("k1".to_string(), key(1))]),
522 key_id: None,
523 on_missing_key: None,
524 })
525 .unwrap();
526 let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
527 let other = cipher_with(&[("other", 2)], None);
528 let error = other.decrypt("secrets", "s1", "token", &envelope).unwrap_err();
529 assert!(error.reason.contains("was not given"));
530 assert_eq!(error.key_id.as_deref(), Some("k1"));
531 }
532
533 #[test]
534 fn missing_key_policy_omit_drops_the_field() {
535 let cipher = cipher_with(&[("k1", 1)], None);
536 let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
537
538 let reader = FieldCipher::new(CipherOptions {
539 keys: HashMap::from([("other".to_string(), key(2))]),
540 key_id: None,
541 on_missing_key: Some(MissingKeyPolicy::Omit),
542 })
543 .unwrap();
544 assert_eq!(reader.decrypt("secrets", "s1", "token", &envelope).unwrap(), None);
545 }
546
547 #[test]
548 fn missing_key_policy_raw_returns_the_envelope_unchanged() {
549 let cipher = cipher_with(&[("k1", 1)], None);
550 let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
551
552 let reader = FieldCipher::new(CipherOptions {
553 keys: HashMap::from([("other".to_string(), key(2))]),
554 key_id: None,
555 on_missing_key: Some(MissingKeyPolicy::Raw),
556 })
557 .unwrap();
558 assert_eq!(
559 reader.decrypt("secrets", "s1", "token", &envelope).unwrap(),
560 Some(Value::String(envelope))
561 );
562 }
563
564 #[test]
565 fn rejects_a_key_that_is_not_32_bytes() {
566 let error = FieldCipher::new(CipherOptions {
567 keys: HashMap::from([("k".to_string(), vec![0u8; 16])]),
568 key_id: None,
569 on_missing_key: None,
570 })
571 .unwrap_err();
572 assert!(error.reason.contains("must be 32 bytes"));
573 }
574
575 #[test]
576 fn rejects_an_unknown_key_id() {
577 let error = FieldCipher::new(CipherOptions {
578 keys: HashMap::from([("a".to_string(), key(1)), ("b".to_string(), key(2))]),
579 key_id: Some("c".to_string()),
580 on_missing_key: None,
581 })
582 .unwrap_err();
583 assert!(error.reason.contains("is not among the keys given"));
584 }
585
586 #[test]
587 fn insists_on_a_key_id_when_several_keys_are_given() {
588 let error = FieldCipher::new(CipherOptions {
589 keys: HashMap::from([("a".to_string(), key(1)), ("b".to_string(), key(2))]),
590 key_id: None,
591 on_missing_key: None,
592 })
593 .unwrap_err();
594 assert!(error.reason.contains("keyId"));
595 }
596
597 #[test]
598 fn seal_record_leaves_a_null_value_alone() {
599 let schema = secrets_schema();
600 let cipher = cipher_with(&[("k1", 1)], None);
601 let fields = cypher_fields_of(&schema);
602 let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), Value::Null)]);
603 let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &[]).unwrap();
604 assert_eq!(sealed.get("token"), Some(&Value::Null));
605 }
606
607 #[test]
608 fn seal_record_seals_a_plaintext_that_only_looks_like_an_envelope() {
609 let schema = secrets_schema();
610 let cipher = cipher_with(&[("k1", 1)], None);
611 let fields = cypher_fields_of(&schema);
612 let masquerade = "$cypher$v1$k1$not$real";
613 let record = HaysonRecord::from([
614 ("id".to_string(), json!("s1")),
615 ("token".to_string(), json!(masquerade)),
616 ]);
617
618 let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &[]).unwrap();
619
620 let stored = sealed.get("token").unwrap().as_str().unwrap();
621 assert_ne!(stored, masquerade, "the value reached the log in the clear");
622 let opened = open_record(&schema, "s1", &sealed, Some(&cipher), &fields).unwrap();
623 assert_eq!(opened.get("token"), Some(&json!(masquerade)));
624 }
625
626 #[test]
627 fn seal_record_seals_a_genuine_envelope_again_when_it_is_not_carried() {
628 let schema = secrets_schema();
629 let cipher = cipher_with(&[("k1", 1)], None);
630 let fields = cypher_fields_of(&schema);
631 let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
632 let record = HaysonRecord::from([
633 ("id".to_string(), json!("s1")),
634 ("token".to_string(), json!(envelope.clone())),
635 ]);
636
637 let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &[]).unwrap();
638
639 assert_ne!(sealed.get("token").unwrap().as_str().unwrap(), envelope);
640 }
641
642 #[test]
643 fn seal_record_carries_a_verified_envelope_byte_for_byte() {
644 let schema = secrets_schema();
645 let cipher = cipher_with(&[("k1", 1)], None);
646 let fields = cypher_fields_of(&schema);
647 let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
648 let record = HaysonRecord::from([
649 ("id".to_string(), json!("s1")),
650 ("token".to_string(), json!(envelope.clone())),
651 ]);
652
653 let carried = vec!["token".to_string()];
654 let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &carried).unwrap();
655
656 assert_eq!(sealed.get("token").unwrap().as_str().unwrap(), envelope);
657 let opened = open_record(&schema, "s1", &sealed, Some(&cipher), &fields).unwrap();
658 assert_eq!(opened.get("token"), Some(&json!("hunter2")));
659 }
660
661 #[test]
662 fn a_carried_value_that_is_not_an_envelope_is_refused() {
663 let schema = secrets_schema();
664 let cipher = cipher_with(&[("k1", 1)], None);
665 let fields = cypher_fields_of(&schema);
666 let record = HaysonRecord::from([
667 ("id".to_string(), json!("s1")),
668 ("token".to_string(), json!("$cypher$v1$k1$not$real")),
669 ]);
670
671 let error = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &["token".to_string()]).unwrap_err();
672
673 assert!(error.reason.contains("is not a cypher envelope"), "{}", error.reason);
674 }
675
676 #[test]
677 fn a_carried_envelope_sealed_for_another_record_is_refused() {
678 let schema = secrets_schema();
679 let cipher = cipher_with(&[("k1", 1)], None);
680 let fields = cypher_fields_of(&schema);
681 let stolen = cipher.encrypt("secrets", "s2", "token", &json!("yours"));
682 let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), json!(stolen))]);
683
684 let error = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &["token".to_string()]).unwrap_err();
685
686 assert!(
687 error.reason.contains("does not open for this record and field"),
688 "{}",
689 error.reason
690 );
691 assert_eq!(error.key_id.as_deref(), Some("k1"));
692 }
693
694 #[test]
695 fn a_carried_envelope_under_a_key_that_is_not_on_hand_is_taken_on_trust() {
696 let schema = secrets_schema();
697 let writer = cipher_with(&[("k1", 1)], None);
698 let fields = cypher_fields_of(&schema);
699 let envelope = writer.encrypt("secrets", "s1", "token", &json!("hunter2"));
700 let record = HaysonRecord::from([
701 ("id".to_string(), json!("s1")),
702 ("token".to_string(), json!(envelope.clone())),
703 ]);
704
705 let stranger = cipher_with(&[("other", 2)], None);
706 let sealed = seal_record(&schema, "s1", &record, Some(&stranger), &fields, &["token".to_string()]).unwrap();
707
708 assert_eq!(sealed.get("token").unwrap().as_str().unwrap(), envelope);
709 }
710
711 #[test]
712 fn seal_record_without_a_cipher_refuses_to_write_a_cypher_field() {
713 let schema = secrets_schema();
714 let fields = cypher_fields_of(&schema);
715 let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), json!("secret"))]);
716 let error = seal_record(&schema, "s1", &record, None, &fields, &[]).unwrap_err();
717 assert!(error.reason.contains("cannot be written without a key"));
718 }
719
720 #[test]
721 fn seal_record_without_a_cipher_still_refuses_a_carried_value_that_is_not_an_envelope() {
722 let schema = secrets_schema();
723 let fields = cypher_fields_of(&schema);
724 let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), json!("secret"))]);
725 let error = seal_record(&schema, "s1", &record, None, &fields, &["token".to_string()]).unwrap_err();
726 assert!(error.reason.contains("is not a cypher envelope"), "{}", error.reason);
727 }
728
729 #[test]
730 fn seal_then_open_round_trips_every_cypher_field() {
731 let schema = secrets_schema();
732 let cipher = cipher_with(&[("k1", 1)], None);
733 let fields = cypher_fields_of(&schema);
734 let record = HaysonRecord::from([
735 ("id".to_string(), json!("s1")),
736 ("token".to_string(), json!("hunter2")),
737 ("pin".to_string(), json!(4321)),
738 ]);
739 let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &[]).unwrap();
740 assert!(is_envelope(sealed.get("token").unwrap()));
741 assert!(is_envelope(sealed.get("pin").unwrap()));
742
743 let opened = open_record(&schema, "s1", &sealed, Some(&cipher), &fields).unwrap();
744 assert_eq!(opened.get("token"), Some(&json!("hunter2")));
745 assert_eq!(opened.get("pin"), Some(&json!(4321)));
746 }
747
748 #[test]
749 fn open_record_with_omit_policy_drops_the_field() {
750 let schema = secrets_schema();
751 let writer = cipher_with(&[("k1", 1)], None);
752 let fields = cypher_fields_of(&schema);
753 let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), json!("hunter2"))]);
754 let sealed = seal_record(&schema, "s1", &record, Some(&writer), &fields, &[]).unwrap();
755
756 let reader = FieldCipher::new(CipherOptions {
757 keys: HashMap::from([("other".to_string(), key(2))]),
758 key_id: None,
759 on_missing_key: Some(MissingKeyPolicy::Omit),
760 })
761 .unwrap();
762 let opened = open_record(&schema, "s1", &sealed, Some(&reader), &fields).unwrap();
763 assert!(!opened.contains_key("token"));
764 }
765}