1use std::collections::HashMap;
25use std::sync::{Arc, RwLock};
26
27use ring::{hkdf, hmac};
28use serde::{Deserialize, Serialize};
29
30const KEY_LEN: usize = 32;
31
32const HKDF_SALT: &[u8] = b"skilj-blind-index-v1";
35
36#[derive(Debug, thiserror::Error)]
38#[non_exhaustive]
39pub enum Error {
40 #[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 bounded_context: String,
50 tag_key: String,
52 },
53
54 #[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 tag_key: String,
65 },
66
67 #[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 bounded_context: String,
78 tag_key: String,
80 generation: u32,
82 },
83
84 #[error("the blind tags of bounded context `{bounded_context}` were rotated meanwhile; retry")]
89 Rotated {
90 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#[derive(Clone)]
118pub struct BlindIndexKey([u8; KEY_LEN]);
119
120impl BlindIndexKey {
121 pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
124 Self(bytes)
125 }
126
127 fn constraint_key(&self, bounded_context: &str, tag_key: &str) -> hmac::Key {
131 self.derive(&[], bounded_context, tag_key)
132 }
133
134 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 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 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
177const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case", tag = "kind")]
191#[non_exhaustive]
192pub enum Erasure {
193 Keep,
197 WithSubject {
201 subject_key: String,
203 subject_tag: String,
206 },
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct BlindTagDeclaration {
214 pub tag_key: String,
216 pub normalizer_version: u32,
218 pub erasure: Erasure,
220 pub key_fingerprint: Option<String>,
227 pub key_generation: u32,
231}
232
233pub type Normalizer = fn(&str) -> String;
235
236pub mod normalize {
238 pub fn email(value: &str) -> String {
242 value.trim().to_lowercase()
243 }
244
245 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 keys: Vec<BlindIndexKey>,
261 normalizers: HashMap<(String, String), Normalizer>,
262 templates: Option<crate::template_cache::TemplateCache>,
263 generations: Arc<RwLock<HashMap<(String, String), u32>>>,
268}
269
270impl Inner {
271 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#[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 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 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 #[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 pub fn declares(&self, bounded_context: &str, tag_key: &str) -> bool {
389 self.normalizer(bounded_context, tag_key).is_some()
390 }
391
392 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 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 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 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 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 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 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 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
529fn 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 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 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 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}