Skip to main content

arcly_http/compliance/
crypto.rs

1//! Field-level envelope encryption + crypto-shredding.
2//!
3//! ## Why this is load-bearing
4//!
5//! The durability machinery (outbox rows, idempotency replay cache, DLQ,
6//! hash-chained audit) copies payloads into stores that are append-only *on
7//! purpose*. Masking protects what leaves over HTTP; this module protects
8//! what stays. Sensitive fields are sealed with a per-tenant or per-subject
9//! data key (DEK) before they reach any sink, and GDPR Art. 17 erasure is
10//! satisfied by destroying the DEK (**crypto-shredding**) — every ciphertext
11//! copy, including ones inside the sealed audit chain, becomes permanently
12//! unreadable without breaking the chain's integrity.
13//!
14//! ## Key hierarchy
15//!
16//! - **KEK** (key-encryption-key) lives in the [`KekSource`] — Vault / cloud
17//!   KMS in production, an env-derived source in dev. The framework never
18//!   sees it; the source hands back *unwrapped DEKs*.
19//! - **DEK** (data-encryption-key, AES-256) is scoped by [`KeyId`]:
20//!   `tenant:acme` for tenant-wide data, `subject:user-42` for per-person
21//!   shredding. Rotation adds a new DEK *version*; old versions stay
22//!   readable until re-encryption, because every ciphertext records the
23//!   version that sealed it.
24//!
25//! ## Zero-lock mechanics
26//!
27//! The unwrapped key ring lives behind one `ArcSwap` snapshot — the proven
28//! pattern from secrets / tenants / masking. `encrypt`/`decrypt` cost one
29//! atomic pointer load, one hash probe, and one AES-256-GCM operation
30//! (AES-NI). All I/O — provisioning, rotation, shredding — happens on the
31//! control plane, which serializes through a tokio mutex that no request
32//! path ever touches.
33//!
34//! ## Usage
35//!
36//! ```ignore
37//! // boot (plugin on_init):
38//! let vault = CryptoVault::bootstrap(Arc::new(VaultKekSource::new(...))).await?;
39//! ctx.provide(vault);
40//!
41//! // declare what to seal on the DTO:
42//! #[EncryptFields(key = "tenant:acme", fields("ssn", "card.number"))]
43//! #[derive(serde::Serialize, serde::Deserialize)]
44//! struct PatientRecord { ssn: String, card: Card, name: String }
45//!
46//! // write path — seal before any sink:
47//! let sealed: serde_json::Value = record.seal(vault)?;
48//!
49//! // read path — unseal after load:
50//! let record = PatientRecord::unseal(sealed, vault)?;
51//!
52//! // GDPR erasure — every copy of this subject's data dies at once:
53//! vault.shred(&KeyId::subject("user-42")).await?;
54//! ```
55
56use std::collections::HashMap;
57use std::fmt;
58use std::sync::Arc;
59
60use aes_gcm::aead::{Aead, KeyInit, OsRng};
61use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
62use arc_swap::ArcSwap;
63use base64::Engine;
64use futures::future::BoxFuture;
65use secrecy::{ExposeSecret, Secret};
66use serde_json::Value;
67use smol_str::SmolStr;
68
69const WIRE_PREFIX: &str = "enc:v1:";
70const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD_NO_PAD;
71
72// ─── Errors ───────────────────────────────────────────────────────────────────
73
74#[derive(Debug)]
75pub enum CryptoError {
76    /// No DEK provisioned for this `KeyId`.
77    UnknownKey(KeyId),
78    /// The DEK (or this version of it) was destroyed — the data is erased
79    /// by definition. Callers should surface this as "gone", not "error".
80    Shredded(KeyId),
81    /// AEAD failure: wrong key, corrupted ciphertext, or tampering.
82    Aead,
83    /// Malformed `enc:v1:` wire string.
84    WireFormat,
85    /// The KEK source failed (network, auth, KMS outage).
86    Kek(Box<dyn std::error::Error + Send + Sync>),
87    /// (De)serialization of a field value failed.
88    Codec(serde_json::Error),
89}
90
91impl fmt::Display for CryptoError {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Self::UnknownKey(k) => write!(f, "no data key provisioned for `{k}`"),
95            Self::Shredded(k) => write!(f, "data key `{k}` has been shredded — data is erased"),
96            Self::Aead => write!(f, "AEAD failure: wrong key or tampered ciphertext"),
97            Self::WireFormat => write!(f, "malformed encrypted-field wire format"),
98            Self::Kek(e) => write!(f, "KEK source error: {e}"),
99            Self::Codec(e) => write!(f, "field codec error: {e}"),
100        }
101    }
102}
103impl std::error::Error for CryptoError {}
104
105// ─── Key identity & material ──────────────────────────────────────────────────
106
107/// Identifies one DEK lineage. Conventional scopes:
108/// `tenant:<id>` (tenant-wide) and `subject:<id>` (per-person, shreddable).
109#[derive(Clone, Debug, PartialEq, Eq, Hash)]
110pub struct KeyId(pub SmolStr);
111
112impl KeyId {
113    pub fn new(id: impl AsRef<str>) -> Self {
114        Self(SmolStr::new(id.as_ref()))
115    }
116    pub fn tenant(id: impl AsRef<str>) -> Self {
117        Self(SmolStr::new(format!("tenant:{}", id.as_ref())))
118    }
119    pub fn subject(id: impl AsRef<str>) -> Self {
120        Self(SmolStr::new(format!("subject:{}", id.as_ref())))
121    }
122    pub fn as_str(&self) -> &str {
123        &self.0
124    }
125}
126
127impl fmt::Display for KeyId {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.write_str(&self.0)
130    }
131}
132
133/// One unwrapped AES-256 DEK version. Material is zeroized on drop
134/// (`secrecy`) and never serialized.
135pub struct DataKey {
136    version: u32,
137    material: Secret<[u8; 32]>,
138}
139
140impl DataKey {
141    pub fn new(version: u32, material: [u8; 32]) -> Self {
142        Self {
143            version,
144            material: Secret::new(material),
145        }
146    }
147    pub fn version(&self) -> u32 {
148        self.version
149    }
150}
151
152// ─── KEK source — the only I/O boundary ──────────────────────────────────────
153
154/// Bridges to the key-management system. Production implementations wrap
155/// Vault Transit / AWS KMS / GCP KMS; dev uses an env-derived source.
156/// Every method runs on the control plane — never on a request path.
157/// A full key ring as loaded from the KMS: every key with all live versions.
158pub type LoadedKeyring = Vec<(KeyId, Vec<DataKey>)>;
159
160pub trait KekSource: Send + Sync + 'static {
161    /// Unwrap and return every (key, all live versions) pair. Called once at
162    /// boot; the result seeds the in-memory ring.
163    fn load_keyring(&self) -> BoxFuture<'_, Result<LoadedKeyring, CryptoError>>;
164
165    /// Create + persist (wrapped) the next version of `id` — version 1 if
166    /// the key is new. Returns the unwrapped DEK.
167    fn provision(&self, id: &KeyId) -> BoxFuture<'_, Result<DataKey, CryptoError>>;
168
169    /// Destroy every wrapped version of `id` permanently. After this returns
170    /// the data sealed under `id` is unrecoverable from any sink.
171    fn destroy(&self, id: &KeyId) -> BoxFuture<'_, Result<(), CryptoError>>;
172}
173
174// ─── Wire format ──────────────────────────────────────────────────────────────
175
176/// Self-describing ciphertext for one field:
177/// `enc:v1:<key_id>:<key_version>:<b64(nonce ‖ ciphertext ‖ tag)>`.
178/// Records the key version so rotation never forces immediate re-encryption.
179#[derive(Clone, Debug, PartialEq, Eq)]
180pub struct EncryptedField {
181    pub key_id: KeyId,
182    pub key_version: u32,
183    /// 12-byte GCM nonce followed by ciphertext+tag.
184    pub blob: Vec<u8>,
185}
186
187impl EncryptedField {
188    pub fn to_wire(&self) -> String {
189        format!(
190            "{WIRE_PREFIX}{}:{}:{}",
191            self.key_id,
192            self.key_version,
193            B64.encode(&self.blob)
194        )
195    }
196
197    /// Parse the wire form. `key_id` may itself contain `:` (e.g.
198    /// `tenant:acme`), so version and blob are taken from the *right*.
199    pub fn from_wire(s: &str) -> Result<Self, CryptoError> {
200        let rest = s.strip_prefix(WIRE_PREFIX).ok_or(CryptoError::WireFormat)?;
201        let mut it = rest.rsplitn(3, ':');
202        let blob_b64 = it.next().ok_or(CryptoError::WireFormat)?;
203        let version = it
204            .next()
205            .and_then(|v| v.parse::<u32>().ok())
206            .ok_or(CryptoError::WireFormat)?;
207        let key_id = it
208            .next()
209            .filter(|k| !k.is_empty())
210            .ok_or(CryptoError::WireFormat)?;
211        let blob = B64.decode(blob_b64).map_err(|_| CryptoError::WireFormat)?;
212        if blob.len() < 12 + 16 {
213            return Err(CryptoError::WireFormat);
214        }
215        Ok(Self {
216            key_id: KeyId::new(key_id),
217            key_version: version,
218            blob,
219        })
220    }
221
222    /// Cheap check sinks can use to tell sealed values from plaintext.
223    pub fn is_wire(s: &str) -> bool {
224        s.starts_with(WIRE_PREFIX)
225    }
226}
227
228// ─── Key ring snapshot ────────────────────────────────────────────────────────
229
230/// Immutable snapshot of every unwrapped DEK. Replaced whole on any control
231/// plane change; readers always see a consistent ring.
232struct KeyRingSnapshot {
233    /// Versions sorted ascending; the last one is active (used to encrypt).
234    keys: HashMap<KeyId, Vec<DataKey>>,
235    epoch: u64,
236}
237
238impl KeyRingSnapshot {
239    fn active_key(&self, id: &KeyId) -> Option<&DataKey> {
240        self.keys.get(id).and_then(|v| v.last())
241    }
242    fn key_version(&self, id: &KeyId, version: u32) -> Option<&DataKey> {
243        self.keys
244            .get(id)
245            .and_then(|v| v.iter().find(|k| k.version == version))
246    }
247}
248
249// ─── The vault ────────────────────────────────────────────────────────────────
250
251/// Process-wide encryption service. Provide once via
252/// `ctx.provide(CryptoVault::bootstrap(source).await?)`, resolve anywhere
253/// with `Inject<CryptoVault>` / `ctx.inject::<CryptoVault>()`.
254pub struct CryptoVault {
255    ring: ArcSwap<KeyRingSnapshot>,
256    source: Arc<dyn KekSource>,
257    /// Serializes control-plane rebuilds (provision / rotate / shred).
258    /// Never touched by `encrypt` / `decrypt` — the hot path stays lock-free.
259    rebuild: tokio::sync::Mutex<()>,
260}
261
262impl CryptoVault {
263    /// Load every DEK from the KEK source and build the initial ring.
264    pub async fn bootstrap(source: Arc<dyn KekSource>) -> Result<Self, CryptoError> {
265        let mut keys: HashMap<KeyId, Vec<DataKey>> = HashMap::new();
266        for (id, mut versions) in source.load_keyring().await? {
267            versions.sort_by_key(|k| k.version);
268            keys.insert(id, versions);
269        }
270        Ok(Self {
271            ring: ArcSwap::from_pointee(KeyRingSnapshot { keys, epoch: 0 }),
272            source,
273            rebuild: tokio::sync::Mutex::new(()),
274        })
275    }
276
277    /// Hot path: seal `plaintext` under the active version of `key`.
278    /// One atomic load + one hash probe + AES-256-GCM. No locks, no I/O.
279    pub fn encrypt(&self, key: &KeyId, plaintext: &[u8]) -> Result<EncryptedField, CryptoError> {
280        let ring = self.ring.load();
281        let dk = ring
282            .active_key(key)
283            .ok_or_else(|| CryptoError::UnknownKey(key.clone()))?;
284        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(dk.material.expose_secret()));
285        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
286        let ct = cipher
287            .encrypt(&nonce, plaintext)
288            .map_err(|_| CryptoError::Aead)?;
289        let mut blob = Vec::with_capacity(12 + ct.len());
290        blob.extend_from_slice(&nonce);
291        blob.extend_from_slice(&ct);
292        Ok(EncryptedField {
293            key_id: key.clone(),
294            key_version: dk.version,
295            blob,
296        })
297    }
298
299    /// Hot path: open a sealed field with the exact version that sealed it.
300    /// Returns `Shredded` when the key (or version) is gone — by design.
301    pub fn decrypt(&self, field: &EncryptedField) -> Result<Secret<Vec<u8>>, CryptoError> {
302        let ring = self.ring.load();
303        let dk = ring
304            .key_version(&field.key_id, field.key_version)
305            .ok_or_else(|| CryptoError::Shredded(field.key_id.clone()))?;
306        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(dk.material.expose_secret()));
307        let (nonce, ct) = field.blob.split_at(12);
308        let pt = cipher
309            .decrypt(Nonce::from_slice(nonce), ct)
310            .map_err(|_| CryptoError::Aead)?;
311        Ok(Secret::new(pt))
312    }
313
314    /// `true` when an active DEK exists for `key`.
315    pub fn has_key(&self, key: &KeyId) -> bool {
316        self.ring.load().active_key(key).is_some()
317    }
318
319    /// Control plane: provision the key if absent (idempotent). Use for
320    /// per-subject keys minted on first write.
321    pub async fn ensure_key(&self, key: &KeyId) -> Result<(), CryptoError> {
322        if self.has_key(key) {
323            return Ok(());
324        }
325        let _g = self.rebuild.lock().await;
326        if self.has_key(key) {
327            return Ok(()); // raced with another provisioner
328        }
329        let dk = self.source.provision(key).await?;
330        self.swap_ring(|keys| {
331            keys.entry(key.clone()).or_default().push(dk);
332        });
333        Ok(())
334    }
335
336    /// Control plane: add a new active version. Old versions keep decrypting
337    /// existing ciphertext; new writes seal under the new version.
338    /// Returns the new active version number.
339    pub async fn rotate(&self, key: &KeyId) -> Result<u32, CryptoError> {
340        let _g = self.rebuild.lock().await;
341        let dk = self.source.provision(key).await?;
342        let v = dk.version;
343        self.swap_ring(|keys| {
344            let versions = keys.entry(key.clone()).or_default();
345            versions.push(dk);
346            versions.sort_by_key(|k| k.version);
347        });
348        Ok(v)
349    }
350
351    /// Control plane: **crypto-shredding**. Destroys the wrapped DEK at the
352    /// KEK source, then drops it from the ring. Every ciphertext sealed
353    /// under `key` — in the DB, outbox, idempotency cache, DLQ, audit chain
354    /// — is permanently unreadable the moment this returns.
355    pub async fn shred(&self, key: &KeyId) -> Result<(), CryptoError> {
356        let _g = self.rebuild.lock().await;
357        self.source.destroy(key).await?;
358        self.swap_ring(|keys| {
359            keys.remove(key);
360        });
361        tracing::info!(key = %key, "data key shredded — subject data erased");
362        Ok(())
363    }
364
365    /// Clone-and-swap the ring snapshot. Caller holds the rebuild mutex.
366    fn swap_ring(&self, mutate: impl FnOnce(&mut HashMap<KeyId, Vec<DataKey>>)) {
367        let cur = self.ring.load();
368        // DataKey isn't Clone (secret material) — rebuild by re-wrapping the
369        // secrets we already hold in memory.
370        let mut keys: HashMap<KeyId, Vec<DataKey>> = cur
371            .keys
372            .iter()
373            .map(|(k, vs)| {
374                (
375                    k.clone(),
376                    vs.iter()
377                        .map(|d| DataKey::new(d.version, *d.material.expose_secret()))
378                        .collect(),
379                )
380            })
381            .collect();
382        mutate(&mut keys);
383        self.ring.store(Arc::new(KeyRingSnapshot {
384            keys,
385            epoch: cur.epoch + 1,
386        }));
387    }
388}
389
390// ─── JSON field walker ────────────────────────────────────────────────────────
391
392/// One segment of a compiled field path. `*` fans out over arrays/objects,
393/// mirroring the masking path engine.
394#[derive(Clone, Debug)]
395enum Seg {
396    Key(&'static str),
397    Any,
398}
399
400fn compile(spec: &'static str) -> Vec<Seg> {
401    spec.split('.')
402        .map(|s| if s == "*" { Seg::Any } else { Seg::Key(s) })
403        .collect()
404}
405
406/// Seal every leaf matched by `path`. The leaf's full JSON encoding is
407/// encrypted (types round-trip exactly), and the leaf is replaced by the
408/// wire string.
409fn seal_at(
410    vault: &CryptoVault,
411    key: &KeyId,
412    v: &mut Value,
413    path: &[Seg],
414) -> Result<(), CryptoError> {
415    match path.split_first() {
416        None => {
417            let plain = serde_json::to_vec(v).map_err(CryptoError::Codec)?;
418            *v = Value::String(vault.encrypt(key, &plain)?.to_wire());
419            Ok(())
420        }
421        Some((Seg::Key(k), rest)) => match v.get_mut(*k) {
422            Some(child) => seal_at(vault, key, child, rest),
423            None => Ok(()), // absent field: nothing to seal
424        },
425        Some((Seg::Any, rest)) => {
426            match v {
427                Value::Array(items) => {
428                    for item in items {
429                        seal_at(vault, key, item, rest)?;
430                    }
431                }
432                Value::Object(map) => {
433                    for child in map.values_mut() {
434                        seal_at(vault, key, child, rest)?;
435                    }
436                }
437                _ => {}
438            }
439            Ok(())
440        }
441    }
442}
443
444/// Inverse of [`seal_at`]: decrypt matched leaves that carry the wire prefix.
445fn unseal_at(vault: &CryptoVault, v: &mut Value, path: &[Seg]) -> Result<(), CryptoError> {
446    match path.split_first() {
447        None => {
448            let Value::String(s) = &*v else { return Ok(()) };
449            if !EncryptedField::is_wire(s) {
450                return Ok(()); // already plaintext (e.g. pre-rollout rows)
451            }
452            let field = EncryptedField::from_wire(s)?;
453            let plain = vault.decrypt(&field)?;
454            *v = serde_json::from_slice(plain.expose_secret()).map_err(CryptoError::Codec)?;
455            Ok(())
456        }
457        Some((Seg::Key(k), rest)) => match v.get_mut(*k) {
458            Some(child) => unseal_at(vault, child, rest),
459            None => Ok(()),
460        },
461        Some((Seg::Any, rest)) => {
462            match v {
463                Value::Array(items) => {
464                    for item in items {
465                        unseal_at(vault, item, rest)?;
466                    }
467                }
468                Value::Object(map) => {
469                    for child in map.values_mut() {
470                        unseal_at(vault, child, rest)?;
471                    }
472                }
473                _ => {}
474            }
475            Ok(())
476        }
477    }
478}
479
480// ─── Declarative record contract (#[EncryptFields]) ──────────────────────────
481
482/// Implemented by `#[EncryptFields(...)]` on a DTO. The default methods are
483/// the whole write/read contract:
484///
485/// - **seal**: serialize → encrypt the declared fields → `Value` safe for
486///   any sink (DB row, outbox payload, idempotency cache, DLQ).
487/// - **unseal**: decrypt the declared fields → deserialize back to `Self`.
488///
489/// `KEY_ID` is the default scope; use the `*_with_key` variants for
490/// per-subject keys resolved at runtime.
491pub trait EncryptRecord: serde::Serialize + serde::de::DeserializeOwned {
492    /// Dotted paths (`"card.number"`, `"items.*.ssn"`) to seal.
493    const ENCRYPT_FIELDS: &'static [&'static str];
494    /// Default key scope, e.g. `"tenant:acme"`.
495    const KEY_ID: &'static str;
496
497    fn seal(&self, vault: &CryptoVault) -> Result<Value, CryptoError> {
498        self.seal_with_key(vault, &KeyId::new(Self::KEY_ID))
499    }
500
501    fn seal_with_key(&self, vault: &CryptoVault, key: &KeyId) -> Result<Value, CryptoError> {
502        let mut v = serde_json::to_value(self).map_err(CryptoError::Codec)?;
503        for spec in Self::ENCRYPT_FIELDS {
504            seal_at(vault, key, &mut v, &compile(spec))?;
505        }
506        Ok(v)
507    }
508
509    fn unseal(mut sealed: Value, vault: &CryptoVault) -> Result<Self, CryptoError> {
510        for spec in Self::ENCRYPT_FIELDS {
511            unseal_at(vault, &mut sealed, &compile(spec))?;
512        }
513        serde_json::from_value(sealed).map_err(CryptoError::Codec)
514    }
515}
516
517// ─── Tests ────────────────────────────────────────────────────────────────────
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use sha2::{Digest, Sha256};
523
524    /// Deterministic in-memory source: DEK = SHA-256(master ‖ key ‖ version).
525    struct TestKek {
526        shredded: std::sync::Mutex<std::collections::HashSet<KeyId>>,
527        versions: std::sync::Mutex<HashMap<KeyId, u32>>,
528    }
529
530    impl TestKek {
531        fn new() -> Self {
532            Self {
533                shredded: Default::default(),
534                versions: Default::default(),
535            }
536        }
537        fn derive(id: &KeyId, version: u32) -> [u8; 32] {
538            let mut h = Sha256::new();
539            h.update(b"test-master");
540            h.update(id.as_str().as_bytes());
541            h.update(version.to_be_bytes());
542            h.finalize().into()
543        }
544    }
545
546    impl KekSource for TestKek {
547        fn load_keyring(&self) -> BoxFuture<'_, Result<Vec<(KeyId, Vec<DataKey>)>, CryptoError>> {
548            Box::pin(async { Ok(Vec::new()) })
549        }
550        fn provision(&self, id: &KeyId) -> BoxFuture<'_, Result<DataKey, CryptoError>> {
551            let id = id.clone();
552            Box::pin(async move {
553                let mut versions = self.versions.lock().unwrap();
554                let v = versions.entry(id.clone()).or_insert(0);
555                *v += 1;
556                Ok(DataKey::new(*v, Self::derive(&id, *v)))
557            })
558        }
559        fn destroy(&self, id: &KeyId) -> BoxFuture<'_, Result<(), CryptoError>> {
560            let id = id.clone();
561            Box::pin(async move {
562                self.shredded.lock().unwrap().insert(id);
563                Ok(())
564            })
565        }
566    }
567
568    async fn vault() -> CryptoVault {
569        CryptoVault::bootstrap(Arc::new(TestKek::new()))
570            .await
571            .unwrap()
572    }
573
574    #[tokio::test]
575    async fn roundtrip_and_wire_format() {
576        let v = vault().await;
577        let key = KeyId::tenant("acme");
578        v.ensure_key(&key).await.unwrap();
579
580        let sealed = v.encrypt(&key, b"4242-4242").unwrap();
581        let wire = sealed.to_wire();
582        assert!(EncryptedField::is_wire(&wire));
583
584        let parsed = EncryptedField::from_wire(&wire).unwrap();
585        assert_eq!(parsed, sealed);
586        assert_eq!(parsed.key_id, key); // key id with ':' survives the wire
587
588        let plain = v.decrypt(&parsed).unwrap();
589        assert_eq!(plain.expose_secret().as_slice(), b"4242-4242");
590    }
591
592    #[tokio::test]
593    async fn rotation_keeps_old_ciphertext_readable() {
594        let v = vault().await;
595        let key = KeyId::tenant("acme");
596        v.ensure_key(&key).await.unwrap();
597
598        let old = v.encrypt(&key, b"before-rotation").unwrap();
599        let new_version = v.rotate(&key).await.unwrap();
600        assert_eq!(new_version, 2);
601
602        // old ciphertext still opens; new writes use v2
603        assert_eq!(
604            v.decrypt(&old).unwrap().expose_secret().as_slice(),
605            b"before-rotation"
606        );
607        assert_eq!(v.encrypt(&key, b"x").unwrap().key_version, 2);
608    }
609
610    #[tokio::test]
611    async fn shred_makes_data_unrecoverable() {
612        let v = vault().await;
613        let key = KeyId::subject("user-42");
614        v.ensure_key(&key).await.unwrap();
615        let sealed = v.encrypt(&key, b"phi").unwrap();
616
617        v.shred(&key).await.unwrap();
618        assert!(matches!(v.decrypt(&sealed), Err(CryptoError::Shredded(_))));
619        assert!(matches!(
620            v.encrypt(&key, b"more"),
621            Err(CryptoError::UnknownKey(_))
622        ));
623    }
624
625    #[tokio::test]
626    async fn tampered_ciphertext_fails_aead() {
627        let v = vault().await;
628        let key = KeyId::tenant("acme");
629        v.ensure_key(&key).await.unwrap();
630        let mut sealed = v.encrypt(&key, b"secret").unwrap();
631        *sealed.blob.last_mut().unwrap() ^= 0xFF;
632        assert!(matches!(v.decrypt(&sealed), Err(CryptoError::Aead)));
633    }
634
635    #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
636    struct Patient {
637        name: String,
638        ssn: String,
639        visits: Vec<Visit>,
640    }
641    #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
642    struct Visit {
643        diagnosis: String,
644        year: u32,
645    }
646
647    impl EncryptRecord for Patient {
648        const ENCRYPT_FIELDS: &'static [&'static str] = &["ssn", "visits.*.diagnosis"];
649        const KEY_ID: &'static str = "tenant:clinic";
650    }
651
652    #[tokio::test]
653    async fn record_seal_unseal_with_wildcards() {
654        let v = vault().await;
655        v.ensure_key(&KeyId::new("tenant:clinic")).await.unwrap();
656
657        let p = Patient {
658            name: "Jane".into(),
659            ssn: "123-45-6789".into(),
660            visits: vec![
661                Visit {
662                    diagnosis: "A".into(),
663                    year: 2024,
664                },
665                Visit {
666                    diagnosis: "B".into(),
667                    year: 2025,
668                },
669            ],
670        };
671
672        let sealed = p.seal(&v).unwrap();
673        // declared fields are wire strings; everything else is plaintext
674        assert!(EncryptedField::is_wire(sealed["ssn"].as_str().unwrap()));
675        assert!(EncryptedField::is_wire(
676            sealed["visits"][0]["diagnosis"].as_str().unwrap()
677        ));
678        assert_eq!(sealed["name"], "Jane");
679        assert_eq!(sealed["visits"][1]["year"], 2025);
680
681        let back = Patient::unseal(sealed, &v).unwrap();
682        assert_eq!(back, p);
683    }
684
685    #[tokio::test]
686    async fn unseal_tolerates_pre_rollout_plaintext() {
687        let v = vault().await;
688        v.ensure_key(&KeyId::new("tenant:clinic")).await.unwrap();
689        // a row written before encryption was enabled
690        let legacy = serde_json::json!({
691            "name": "Old", "ssn": "raw-ssn", "visits": []
692        });
693        let p = Patient::unseal(legacy, &v).unwrap();
694        assert_eq!(p.ssn, "raw-ssn");
695    }
696}