Skip to main content

hayson_engine/crypto/
field_cipher.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;
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;
18/// The AAD field separator — TS's `String.fromCharCode(31)` (unit separator), not a visually
19/// similar character. Ciphertexts must interop byte-for-byte with the TS engine, so this has to
20/// match exactly.
21const AAD_SEPARATOR: u8 = 0x1f;
22
23/// What a read does when the key a stored value was written with is not on hand.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum MissingKeyPolicy {
26    #[default]
27    Throw,
28    Omit,
29    Raw,
30}
31
32/// Options for `Database::open` — key material and how reads behave when a value was written with
33/// a key that is not among them. Mirrors the TS `CipherOptions`.
34#[derive(Debug, Clone, Default)]
35pub struct CipherOptions {
36    /// Every key that may be needed to read, by id. Each must be 32 bytes for AES-256-GCM.
37    pub keys: HashMap<String, Vec<u8>>,
38    /// Which of them new values are written with. Defaults to the only key, when there is one.
39    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
54/// The key id of a value shaped like a `v1` envelope this build could open — every part present,
55/// the key id legal, the IV exactly `IV_BYTES` long and a body long enough to hold a tag.
56///
57/// Says nothing about the value being a genuine ciphertext; only [`FieldCipher::decrypt`] can say
58/// that, and only while holding the key. This is the cheap half of [`assert_carried_seals`] and is
59/// never on its own a reason to leave a value unsealed.
60fn 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
73/// The key id an envelope was sealed under — `None` for anything that is not an envelope of a
74/// version this build reads. Read straight off the value rather than by decrypting it, which is
75/// what lets a maintenance pass tell an already-current record from one still on a retired key
76/// without holding either.
77pub fn envelope_key_id(value: &Value) -> Option<&str> {
78    let text = value.as_str()?;
79    if !is_envelope(value) {
80        return None;
81    }
82    // NOTE: the envelope opens with "$", so drop it before splitting or part 0 comes back empty.
83    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
90/// The names of the fields a schema marks `cypher`, sorted.
91pub 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/// Encrypts and decrypts the fields a schema marks `cypher`, so their values never reach the disk
117/// in the clear.
118///
119/// The additional authenticated data binds each value to its table, its record and its field name,
120/// which is what stops a stored value from being moved somewhere it did not belong. Nothing that
121/// compaction rewrites takes part in it, so compacting a log needs no key at all.
122#[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    /// What a read of a value sealed under a key this cipher does not hold does — the policy a
184    /// maintenance pass has to honour as well, so that it never turns a missing key into lost data.
185    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    /// `Ok(None)` only when the key is missing and the policy is `omit`.
225    pub fn decrypt(
226        &self,
227        table: &str,
228        key: &str,
229        field: &str,
230        envelope: &str,
231    ) -> Result<Option<Value>, HaysonCipherError> {
232        // NOTE: the envelope opens with "$", so drop it before splitting or part 0 comes back empty.
233        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
298/// Refuses everything `carried` names that the engine cannot recognise as an envelope of its own —
299/// the check that has to pass before [`seal_record`] may store a value as it stands.
300///
301/// Two steps, both needed. The shape has to be a `v1` envelope this build could open, which rules
302/// out a plaintext that merely starts with `$cypher$`. On top of that, when the keyring holds the
303/// key the envelope names, the value also has to authenticate against this very table, record and
304/// field — a caller holding the key cannot smuggle a plaintext past this by calling it sealed, and
305/// an envelope copied into the wrong record or field is refused now rather than found unreadable
306/// later.
307///
308/// A key that is not on hand leaves nothing to verify against, so the claim is taken on the
309/// caller's word: that is the restore-without-the-key case the mechanism exists for. It is why the
310/// entry point carrying seals must never be wired to untrusted input.
311pub 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
348/// Replaces the plaintext of every `cypher` field with its envelope, leaving `null` as it is.
349///
350/// A value is sealed whatever it looks like: a plaintext shaped exactly like an envelope is
351/// ciphertext on disk like any other, because a declared `cypher` field is a promise about the log,
352/// not about the caller's good intentions. The one exception is a field `carried` names, which
353/// [`assert_carried_seals`] has already vouched for and which is stored as it stands — the explicit
354/// replacement for the shape sniffing this used to do.
355pub 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
386/// Replaces the envelope of every `cypher` field with its plaintext.
387pub 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}