Skip to main content

skilj_core/
blind.rs

1//! Blind-index tags: a tag whose value is a keyed hash of a payload field
2//! rather than the field itself (docs/architecture.md §205).
3//!
4//! A plain tag is stored in the clear - in `events.tags`, `event_tags`,
5//! `commands.consistency_tags`, the event cache and everything that serves
6//! them - which is why a tag mapping may not name a sensitive field. A
7//! blind tag may: its value is `HMAC-SHA256(constraint key,
8//! normalize(value))`, hex-encoded, so two payloads with the same
9//! normalized value carry the same tag and a command's consistency query
10//! finds the earlier one, while the value itself is never stored. That is
11//! what a uniqueness check on personal data (an email address, a national
12//! id) needs: the ordinary DCB conflict check, over a value nobody can read
13//! back.
14//!
15//! Each constraint - one `(bounded context, tag key)` pair, declared once
16//! by a [`crate::plugin::BlindTag`] - gets its own HMAC key, derived with
17//! HKDF-SHA256 from the process's [`BlindIndexKey`]. The root key is
18//! separate from the [`crate::encryption::KeyService`], so either
19//! can be rotated or kept elsewhere without touching the other; deriving
20//! per constraint means the same value hashes differently under two
21//! constraints or two bounded contexts, so the hashes can't be correlated
22//! across them.
23
24use std::collections::HashMap;
25use std::sync::{Arc, RwLock};
26
27use ring::{hkdf, hmac};
28use serde::{Deserialize, Serialize};
29
30const KEY_LEN: usize = 32;
31
32/// Salt for every constraint key derivation. Changing it changes every
33/// blind tag ever written, so it is part of the stored format.
34const HKDF_SALT: &[u8] = b"skilj-blind-index-v1";
35
36/// Errors deriving a blind tag.
37#[derive(Debug, thiserror::Error)]
38#[non_exhaustive]
39pub enum Error {
40    /// A tag mapping is blind but this process can't hash it: it doesn't
41    /// declare the `BlindTag` for that key, or has no [`BlindIndexKey`].
42    /// An instance that does can (`skilj_retry::ANOTHER_INSTANCE_CODES`).
43    #[error(
44        "tag key `{tag_key}` of bounded context `{bounded_context}` is blind, but this \
45         process declares no BlindTag for it (or has no blind_index_key)"
46    )]
47    NotDeclared {
48        /// The bounded context.
49        bounded_context: String,
50        /// The blind tag key.
51        tag_key: String,
52    },
53
54    /// A query asked for a blind tag by its plaintext value without a
55    /// grant that may read sensitive fields (docs/architecture.md §207):
56    /// the answer would say whether a value is in use, which is the value's
57    /// own sensitivity.
58    #[error(
59        "looking up blind tag `{tag_key}` by its plaintext value needs a grant that can read \
60         sensitive fields"
61    )]
62    LookupNotGranted {
63        /// The blind tag key.
64        tag_key: String,
65    },
66
67    /// The constraint is stored at a key generation this process has no
68    /// root key for (docs/architecture.md §209): it was rotated to a key
69    /// this process isn't configured with. An instance that is can do it
70    /// (`skilj_retry::ANOTHER_INSTANCE_CODES`).
71    #[error(
72        "blind tag `{tag_key}` of bounded context `{bounded_context}` is at key generation \
73         {generation}, and this process has no blind_index_key for it"
74    )]
75    KeyNotConfigured {
76        /// The bounded context.
77        bounded_context: String,
78        /// The blind tag key.
79        tag_key: String,
80        /// The key generation the constraint is stored at.
81        generation: u32,
82    },
83
84    /// A command's blind tags were hashed before a key rotation of its
85    /// bounded context committed (docs/architecture.md §209). Submission
86    /// retries it once, rehashed; it surfaces only if a second rotation
87    /// lands in between.
88    #[error("the blind tags of bounded context `{bounded_context}` were rotated meanwhile; retry")]
89    Rotated {
90        /// The bounded context.
91        bounded_context: String,
92    },
93}
94
95impl crate::error::SkiljRejection for Error {
96    fn code(&self) -> &str {
97        match self {
98            Error::NotDeclared { .. } => "blind_tag_not_declared",
99            Error::LookupNotGranted { .. } => "blind_tag_lookup_not_granted",
100            Error::KeyNotConfigured { .. } => "blind_index_key_not_configured",
101            Error::Rotated { .. } => "blind_tags_rotated",
102        }
103    }
104
105    fn message(&self) -> String {
106        self.to_string()
107    }
108}
109
110/// The root secret every constraint's HMAC key is derived from -
111/// `SkiljBuilder::blind_index_key`. Never persisted; like the encryption
112/// master key, the application keeps it stable across restarts and
113/// identical across instances. Losing or changing it changes every blind
114/// tag, so values already stored no longer match new ones; replacing a
115/// leaked one is a rotation (docs/architecture.md §209), which keeps it
116/// configured as a previous key.
117#[derive(Clone)]
118pub struct BlindIndexKey([u8; KEY_LEN]);
119
120impl BlindIndexKey {
121    /// The key from its 32 bytes, which should come from a secure random
122    /// source and be kept secret.
123    pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
124        Self(bytes)
125    }
126
127    /// The HMAC key of one constraint. The info string length-prefixes
128    /// both parts, so no `(bounded_context, tag_key)` pair can collide
129    /// with another by moving a separator.
130    fn constraint_key(&self, bounded_context: &str, tag_key: &str) -> hmac::Key {
131        self.derive(&[], bounded_context, tag_key)
132    }
133
134    /// What `blind_tags.key_fingerprint` stores for one constraint
135    /// (docs/architecture.md §208): the hex HMAC of an empty message under
136    /// a key derived like the constraint's but with [`FINGERPRINT_LABEL`]
137    /// ahead of the info, so it reveals nothing about the constraint key
138    /// and can't equal the tag of any value. Equal for two root keys
139    /// exactly when they hash every value of the constraint alike.
140    ///
141    /// `previous` is the fingerprint of the key chain before this key
142    /// (docs/architecture.md §209), empty for the first.
143    fn fingerprint(&self, bounded_context: &str, tag_key: &str, previous: &str) -> String {
144        let key = self.derive(FINGERPRINT_LABEL, bounded_context, tag_key);
145        hex(hmac::sign(&key, previous.as_bytes()).as_ref())
146    }
147
148    /// `tag`, a tag of the previous generation, wrapped under this key's
149    /// constraint key (docs/architecture.md §209).
150    fn wrap(&self, bounded_context: &str, tag_key: &str, tag: &str) -> String {
151        let key = self.constraint_key(bounded_context, tag_key);
152        hex(hmac::sign(&key, tag.as_bytes()).as_ref())
153    }
154
155    /// HKDF-SHA256 over the root key, with `label` (empty for the
156    /// constraint key itself, the stored format) ahead of the
157    /// length-prefixed `(bounded_context, tag_key)`.
158    fn derive(&self, label: &[u8], bounded_context: &str, tag_key: &str) -> hmac::Key {
159        let salt = hkdf::Salt::new(hkdf::HKDF_SHA256, HKDF_SALT);
160        let prk = salt.extract(&self.0);
161        let bc_len = (bounded_context.len() as u64).to_be_bytes();
162        let key_len = (tag_key.len() as u64).to_be_bytes();
163        let info: [&[u8]; 5] = [
164            label,
165            &bc_len,
166            bounded_context.as_bytes(),
167            &key_len,
168            tag_key.as_bytes(),
169        ];
170        let okm = prk
171            .expand(&info, hmac::HMAC_SHA256)
172            .expect("HMAC_SHA256's key length is a valid HKDF output length");
173        hmac::Key::from(okm)
174    }
175}
176
177/// Prefixes the HKDF info of a key fingerprint. A constraint key's info
178/// starts with the bounded context's 8-byte length, and these 8 bytes read
179/// as a length no name has, so the two derivations never share an info.
180const FINGERPRINT_LABEL: &[u8] = b"skilj-blind-index-fingerprint-v1";
181
182impl std::fmt::Debug for BlindIndexKey {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        f.write_str("BlindIndexKey(REDACTED)")
185    }
186}
187
188/// What `forgetSubject` does with a blind tag (docs/architecture.md §205).
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case", tag = "kind")]
191#[non_exhaustive]
192pub enum Erasure {
193    /// The hash stays on the subject's events and commands after its key
194    /// is destroyed, so the value stays taken: "this address may never
195    /// sign up again".
196    Keep,
197    /// Forgetting the subject removes the tag from every event and command
198    /// carrying `subject_tag` with the forgotten subject's value, so the
199    /// value is free again and no hash of it remains.
200    WithSubject {
201        /// The `SensitiveField.subject_key` whose `forgetSubject` erases.
202        subject_key: String,
203        /// The plain tag key whose value is that subject's value - how the
204        /// subject's events and commands are found without decrypting.
205        subject_tag: String,
206    },
207}
208
209/// One constraint as stored in a bounded context's `blind_tags` table:
210/// what every instance agrees on. The normalizer itself is code; its
211/// version is what's compared.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct BlindTagDeclaration {
214    /// The blind tag key.
215    pub tag_key: String,
216    /// The version of the normalizer values are hashed after.
217    pub normalizer_version: u32,
218    /// What `forgetSubject` does with the hashes.
219    pub erasure: Erasure,
220    /// The constraint's key fingerprint (docs/architecture.md §208), so an
221    /// instance with a different `blind_index_key` refuses to start rather
222    /// than hash values that never match the stored ones. `None` when the
223    /// writer couldn't compute it - a tenant created by an instance that
224    /// doesn't declare its template's `BlindTag` - and recorded by the
225    /// first instance that can.
226    pub key_fingerprint: Option<String>,
227    /// How many times the constraint's tags have been wrapped under a
228    /// later root key (docs/architecture.md §209); `key_fingerprint` is
229    /// the chain's up to it.
230    pub key_generation: u32,
231}
232
233/// A normalizer: what a payload value becomes before it's hashed.
234pub type Normalizer = fn(&str) -> String;
235
236/// Ready-made normalizers for [`crate::plugin::BlindTag::normalize`].
237pub mod normalize {
238    /// Leading and trailing whitespace removed, lowercased - for an email
239    /// address. Deliberately nothing provider-specific: `a.b+x@gmail.com`
240    /// and `ab@gmail.com` stay different addresses.
241    pub fn email(value: &str) -> String {
242        value.trim().to_lowercase()
243    }
244
245    /// Every whitespace character removed, uppercased - for identifiers
246    /// written with optional spacing, like an IBAN or a postcode.
247    pub fn compact_uppercase(value: &str) -> String {
248        value
249            .chars()
250            .filter(|c| !c.is_whitespace())
251            .flat_map(char::to_uppercase)
252            .collect()
253    }
254}
255
256struct Inner {
257    /// Every root key, oldest first; the last is the current one
258    /// (docs/architecture.md §209). One key unless a rotation is underway
259    /// or done.
260    keys: Vec<BlindIndexKey>,
261    normalizers: HashMap<(String, String), Normalizer>,
262    templates: Option<crate::template_cache::TemplateCache>,
263    /// The stored `key_generation` of each constraint this process has
264    /// seen, by `(bounded context, tag key)` - startup loads it, and the
265    /// check under each write's bounded-context lock corrects it after a
266    /// rotation (§209). Shared by every clone.
267    generations: Arc<RwLock<HashMap<(String, String), u32>>>,
268}
269
270impl Inner {
271    /// The constraint key of generation `generation`.
272    fn key(
273        &self,
274        generation: u32,
275        bounded_context: &str,
276        tag_key: &str,
277    ) -> Result<&BlindIndexKey, Error> {
278        self.keys
279            .get(generation as usize)
280            .ok_or_else(|| Error::KeyNotConfigured {
281                bounded_context: bounded_context.to_string(),
282                tag_key: tag_key.to_string(),
283                generation,
284            })
285    }
286}
287
288/// Every constraint this process can hash, by `(bounded context, tag
289/// key)`. Cheap to clone. Empty when no `BlindTag` is declared, which is
290/// every deployment that doesn't use them: deriving a plain tag never
291/// looks here.
292///
293/// A tenant created from a template (issue #13) is resolved to
294/// its template's declaration through the [`TemplateCache`]: the template's
295/// normalizer, but a key derived from the tenant's own name, so two
296/// tenants' hashes of the same value differ.
297///
298/// **Generations** (docs/architecture.md §209). A constraint's tags are
299/// hashed under the first root key and then wrapped once under each later
300/// one, up to the constraint's stored `key_generation`: generation 0 is
301/// `HMAC(k0, normalize(value))`, generation `g` is `HMAC(k_g, tag of g -
302/// 1)`. A rotation can therefore rewrite every stored tag from the tag
303/// alone, without the value - which is encrypted, or forgotten - and a
304/// leaked earlier key no longer lets anyone test a value against the
305/// stored tags.
306///
307/// [`TemplateCache`]: crate::template_cache::TemplateCache
308#[derive(Clone, Default)]
309pub struct BlindIndex(Option<Arc<Inner>>);
310
311impl std::fmt::Debug for BlindIndex {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        let keys = self.0.iter().flat_map(|inner| inner.normalizers.keys());
314        f.debug_set().entries(keys).finish()
315    }
316}
317
318impl BlindIndex {
319    /// An index over `constraints`, each `(bounded context, tag key,
320    /// normalizer)`, its keys derived from `root`.
321    pub fn new(
322        root: BlindIndexKey,
323        constraints: impl IntoIterator<Item = (String, String, Normalizer)>,
324    ) -> Self {
325        let normalizers = constraints
326            .into_iter()
327            .map(|(bounded_context, tag_key, normalize)| ((bounded_context, tag_key), normalize))
328            .collect();
329        Self(Some(Arc::new(Inner {
330            keys: vec![root],
331            normalizers,
332            templates: None,
333            generations: Arc::default(),
334        })))
335    }
336
337    /// The root keys `new`'s replaced, oldest first, for a rotation
338    /// (docs/architecture.md §209): `new`'s key becomes the newest.
339    pub fn with_previous_keys(self, previous: impl IntoIterator<Item = BlindIndexKey>) -> Self {
340        Self(self.0.map(|inner| {
341            let mut keys: Vec<BlindIndexKey> = previous.into_iter().collect();
342            keys.extend(inner.keys.iter().cloned());
343            Arc::new(Inner {
344                keys,
345                normalizers: inner.normalizers.clone(),
346                templates: inner.templates.clone(),
347                generations: inner.generations.clone(),
348            })
349        }))
350    }
351
352    /// Resolves a bounded context that declares nothing itself through
353    /// `templates`, to the template it was created from.
354    #[doc(hidden)]
355    pub fn with_templates(self, templates: crate::template_cache::TemplateCache) -> Self {
356        Self(self.0.map(|inner| {
357            Arc::new(Inner {
358                keys: inner.keys.clone(),
359                normalizers: inner.normalizers.clone(),
360                templates: Some(templates),
361                generations: inner.generations.clone(),
362            })
363        }))
364    }
365
366    fn normalizer(&self, bounded_context: &str, tag_key: &str) -> Option<(&Inner, Normalizer)> {
367        let inner = self.0.as_deref()?;
368        let lookup = |bc: &str| {
369            inner
370                .normalizers
371                .get(&(bc.to_string(), tag_key.to_string()))
372                .copied()
373        };
374        let normalize = lookup(bounded_context).or_else(|| {
375            let template = inner
376                .templates
377                .as_ref()?
378                .effective_bounded_context(bounded_context);
379            (template != bounded_context)
380                .then(|| lookup(&template))
381                .flatten()
382        })?;
383        Some((inner, normalize))
384    }
385
386    /// Whether this process declares the constraint, itself or through
387    /// the bounded context's template.
388    pub fn declares(&self, bounded_context: &str, tag_key: &str) -> bool {
389        self.normalizer(bounded_context, tag_key).is_some()
390    }
391
392    /// Whether this process declares any constraint of `bounded_context`,
393    /// itself or through its template.
394    pub fn declares_any(&self, bounded_context: &str) -> bool {
395        let Some(inner) = self.0.as_deref() else {
396            return false;
397        };
398        let template = inner
399            .templates
400            .as_ref()
401            .map(|templates| templates.effective_bounded_context(bounded_context));
402        inner
403            .normalizers
404            .keys()
405            .any(|(bc, _)| bc == bounded_context || template.as_deref() == Some(bc.as_str()))
406    }
407
408    /// The newest generation this process can hash: one less than its
409    /// number of root keys. What a new constraint is stored with.
410    pub fn current_generation(&self) -> u32 {
411        self.0
412            .as_deref()
413            .map_or(0, |inner| inner.keys.len().saturating_sub(1) as u32)
414    }
415
416    /// The constraint's generation as this process last saw it stored;
417    /// [`current_generation`](Self::current_generation) for one it hasn't
418    /// seen, which the check under the write lock corrects.
419    pub fn generation(&self, bounded_context: &str, tag_key: &str) -> u32 {
420        let Some(inner) = self.0.as_deref() else {
421            return 0;
422        };
423        inner
424            .generations
425            .read()
426            .unwrap_or_else(std::sync::PoisonError::into_inner)
427            .get(&(bounded_context.to_string(), tag_key.to_string()))
428            .copied()
429            .unwrap_or_else(|| self.current_generation())
430    }
431
432    /// Records the constraint's stored generation; whether it differed
433    /// from what this process hashed with.
434    pub fn observe_generation(
435        &self,
436        bounded_context: &str,
437        tag_key: &str,
438        generation: u32,
439    ) -> bool {
440        let Some(inner) = self.0.as_deref() else {
441            return false;
442        };
443        let changed = self.generation(bounded_context, tag_key) != generation;
444        inner
445            .generations
446            .write()
447            .unwrap_or_else(std::sync::PoisonError::into_inner)
448            .insert(
449                (bounded_context.to_string(), tag_key.to_string()),
450                generation,
451            );
452        changed
453    }
454
455    /// The tag value `value` becomes under the constraint: the hex
456    /// HMAC-SHA256 of its normalized form, keyed for `bounded_context`,
457    /// wrapped up to the constraint's generation.
458    pub fn blind(
459        &self,
460        bounded_context: &str,
461        tag_key: &str,
462        value: &str,
463    ) -> Result<String, Error> {
464        let (inner, normalize) =
465            self.normalizer(bounded_context, tag_key)
466                .ok_or_else(|| Error::NotDeclared {
467                    bounded_context: bounded_context.to_string(),
468                    tag_key: tag_key.to_string(),
469                })?;
470        let generation = self.generation(bounded_context, tag_key);
471        let first = inner.key(0, bounded_context, tag_key)?;
472        let key = first.constraint_key(bounded_context, tag_key);
473        let mut tag = hex(hmac::sign(&key, normalize(value).as_bytes()).as_ref());
474        for g in 1..=generation {
475            tag = inner
476                .key(g, bounded_context, tag_key)?
477                .wrap(bounded_context, tag_key, &tag);
478        }
479        Ok(tag)
480    }
481
482    /// A tag of generation `generation - 1` as generation `generation`
483    /// stores it - what a rotation rewrites each stored tag with
484    /// (docs/architecture.md §209).
485    pub fn wrap(
486        &self,
487        bounded_context: &str,
488        tag_key: &str,
489        generation: u32,
490        tag: &str,
491    ) -> Result<String, Error> {
492        let inner = self.0.as_deref().ok_or_else(|| Error::NotDeclared {
493            bounded_context: bounded_context.to_string(),
494            tag_key: tag_key.to_string(),
495        })?;
496        Ok(inner
497            .key(generation, bounded_context, tag_key)?
498            .wrap(bounded_context, tag_key, tag))
499    }
500
501    /// The constraint's key fingerprint at `generation` (docs/architecture.md
502    /// §208, §209), or `None` when this process doesn't declare it or
503    /// lacks a key of that generation.
504    pub fn fingerprint(
505        &self,
506        bounded_context: &str,
507        tag_key: &str,
508        generation: u32,
509    ) -> Option<String> {
510        let (inner, _) = self.normalizer(bounded_context, tag_key)?;
511        chain_fingerprint(inner, bounded_context, tag_key, generation)
512    }
513
514    /// The key fingerprint `tenant` gets for its template's constraint at
515    /// `generation`, or `None` when this process can't compute it - for
516    /// creating a tenant, which the template cache may not know yet.
517    pub fn tenant_fingerprint(
518        &self,
519        template: &str,
520        tenant: &str,
521        tag_key: &str,
522        generation: u32,
523    ) -> Option<String> {
524        let (inner, _) = self.normalizer(template, tag_key)?;
525        chain_fingerprint(inner, tenant, tag_key, generation)
526    }
527}
528
529/// The fingerprint of the key chain up to `generation`: the first key's,
530/// then each later key's fingerprint over the previous one. Equal for two
531/// chains exactly when every key up to `generation` is.
532fn chain_fingerprint(
533    inner: &Inner,
534    bounded_context: &str,
535    tag_key: &str,
536    generation: u32,
537) -> Option<String> {
538    let mut fingerprint = inner
539        .keys
540        .first()?
541        .fingerprint(bounded_context, tag_key, "");
542    for g in 1..=generation {
543        fingerprint =
544            inner
545                .keys
546                .get(g as usize)?
547                .fingerprint(bounded_context, tag_key, &fingerprint);
548    }
549    Some(fingerprint)
550}
551
552fn hex(bytes: &[u8]) -> String {
553    const DIGITS: &[u8; 16] = b"0123456789abcdef";
554    let mut out = String::with_capacity(bytes.len() * 2);
555    for byte in bytes {
556        out.push(DIGITS[(byte >> 4) as usize] as char);
557        out.push(DIGITS[(byte & 0x0f) as usize] as char);
558    }
559    out
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    fn index(root: u8) -> BlindIndex {
567        BlindIndex::new(
568            BlindIndexKey::from_bytes([root; KEY_LEN]),
569            [
570                ("crm", "email"),
571                ("crm", "backup_email"),
572                ("billing", "email"),
573            ]
574            .map(|(bc, key)| {
575                (
576                    bc.to_string(),
577                    key.to_string(),
578                    normalize::email as Normalizer,
579                )
580            }),
581        )
582    }
583
584    #[test]
585    fn the_same_normalized_value_blinds_the_same() {
586        let index = index(1);
587        let a = index.blind("crm", "email", "Alice@Example.com ").unwrap();
588        let b = index.blind("crm", "email", "alice@example.com").unwrap();
589        assert_eq!(a, b);
590        assert_eq!(a.len(), 64);
591        assert!(!a.contains("alice"));
592    }
593
594    #[test]
595    fn each_constraint_and_root_key_blinds_differently() {
596        let blind = index(1);
597        let crm = blind.blind("crm", "email", "alice@example.com").unwrap();
598        let backup = blind
599            .blind("crm", "backup_email", "alice@example.com")
600            .unwrap();
601        let billing = blind
602            .blind("billing", "email", "alice@example.com")
603            .unwrap();
604        let other_root = index(2).blind("crm", "email", "alice@example.com").unwrap();
605        assert_ne!(crm, backup);
606        assert_ne!(crm, billing);
607        assert_ne!(crm, other_root);
608    }
609
610    #[test]
611    fn an_undeclared_constraint_is_refused() {
612        let err = index(1).blind("crm", "phone", "123").unwrap_err();
613        assert!(matches!(err, Error::NotDeclared { .. }));
614        let err = BlindIndex::default()
615            .blind("crm", "email", "a")
616            .unwrap_err();
617        assert!(matches!(err, Error::NotDeclared { .. }));
618    }
619
620    #[test]
621    fn a_fingerprint_tells_root_keys_apart_and_is_no_tag() {
622        let one = index(1);
623        let fingerprint = one.fingerprint("crm", "email", 0).unwrap();
624        assert_eq!(fingerprint.len(), 64);
625        assert_eq!(
626            Some(fingerprint.clone()),
627            index(1).fingerprint("crm", "email", 0)
628        );
629        assert_ne!(
630            Some(fingerprint.clone()),
631            index(2).fingerprint("crm", "email", 0)
632        );
633        assert_ne!(
634            Some(fingerprint.clone()),
635            one.fingerprint("crm", "backup_email", 0)
636        );
637        assert_ne!(
638            Some(fingerprint.clone()),
639            one.fingerprint("billing", "email", 0)
640        );
641        assert_ne!(fingerprint, one.blind("crm", "email", "").unwrap());
642        assert_eq!(one.fingerprint("crm", "phone", 0), None);
643        assert_eq!(
644            one.tenant_fingerprint("crm", "acme", "email", 0),
645            BlindIndex::new(
646                BlindIndexKey::from_bytes([1; KEY_LEN]),
647                [(
648                    "acme".to_string(),
649                    "email".to_string(),
650                    normalize::email as Normalizer
651                )],
652            )
653            .fingerprint("acme", "email", 0)
654        );
655        assert_eq!(one.tenant_fingerprint("acme", "acme", "email", 0), None);
656        assert_eq!(BlindIndex::default().fingerprint("crm", "email", 0), None);
657    }
658
659    #[test]
660    fn a_rotated_tag_wraps_the_previous_generation() {
661        let old = index(1);
662        let rotated = index(2).with_previous_keys([BlindIndexKey::from_bytes([1; KEY_LEN])]);
663        assert_eq!(rotated.current_generation(), 1);
664
665        // Before the rotation is stored, generation 0: the old key's hash.
666        let before = old.blind("crm", "email", "Alice@Example.com").unwrap();
667        rotated.observe_generation("crm", "email", 0);
668        assert_eq!(
669            rotated.blind("crm", "email", "alice@example.com").unwrap(),
670            before
671        );
672
673        // After: the old tag wrapped, which is what a rotation rewrites to,
674        // and neither key alone gives it.
675        let wrapped = rotated.wrap("crm", "email", 1, &before).unwrap();
676        assert!(rotated.observe_generation("crm", "email", 1));
677        assert!(!rotated.observe_generation("crm", "email", 1));
678        let after = rotated.blind("crm", "email", " alice@example.com").unwrap();
679        assert_eq!(after, wrapped);
680        assert_ne!(after, before);
681        assert_ne!(
682            after,
683            index(2).blind("crm", "email", "alice@example.com").unwrap()
684        );
685
686        // The chain's fingerprint at generation 0 is the old key's.
687        assert_eq!(
688            rotated.fingerprint("crm", "email", 0),
689            old.fingerprint("crm", "email", 0)
690        );
691        assert_ne!(
692            rotated.fingerprint("crm", "email", 1),
693            rotated.fingerprint("crm", "email", 0)
694        );
695        assert_ne!(
696            rotated.fingerprint("crm", "email", 1),
697            index(2).fingerprint("crm", "email", 0)
698        );
699        assert_eq!(rotated.fingerprint("crm", "email", 2), None);
700    }
701
702    #[test]
703    fn a_generation_past_the_configured_keys_is_refused() {
704        let index = index(1);
705        index.observe_generation("crm", "email", 1);
706        let err = index.blind("crm", "email", "a").unwrap_err();
707        assert!(matches!(err, Error::KeyNotConfigured { generation: 1, .. }));
708        assert!(index.declares_any("crm"));
709        assert!(!index.declares_any("sales"));
710    }
711
712    #[test]
713    fn compact_uppercase_drops_every_space() {
714        assert_eq!(
715            normalize::compact_uppercase(" nl91 abna 0417 1643 00 "),
716            "NL91ABNA0417164300"
717        );
718    }
719}