use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use ring::{hkdf, hmac};
use serde::{Deserialize, Serialize};
const KEY_LEN: usize = 32;
const HKDF_SALT: &[u8] = b"skilj-blind-index-v1";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error(
"tag key `{tag_key}` of bounded context `{bounded_context}` is blind, but this \
process declares no BlindTag for it (or has no blind_index_key)"
)]
NotDeclared {
bounded_context: String,
tag_key: String,
},
#[error(
"looking up blind tag `{tag_key}` by its plaintext value needs a grant that can read \
sensitive fields"
)]
LookupNotGranted {
tag_key: String,
},
#[error(
"blind tag `{tag_key}` of bounded context `{bounded_context}` is at key generation \
{generation}, and this process has no blind_index_key for it"
)]
KeyNotConfigured {
bounded_context: String,
tag_key: String,
generation: u32,
},
#[error("the blind tags of bounded context `{bounded_context}` were rotated meanwhile; retry")]
Rotated {
bounded_context: String,
},
}
impl crate::error::SkiljRejection for Error {
fn code(&self) -> &str {
match self {
Error::NotDeclared { .. } => "blind_tag_not_declared",
Error::LookupNotGranted { .. } => "blind_tag_lookup_not_granted",
Error::KeyNotConfigured { .. } => "blind_index_key_not_configured",
Error::Rotated { .. } => "blind_tags_rotated",
}
}
fn message(&self) -> String {
self.to_string()
}
}
#[derive(Clone)]
pub struct BlindIndexKey([u8; KEY_LEN]);
impl BlindIndexKey {
pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
Self(bytes)
}
fn constraint_key(&self, bounded_context: &str, tag_key: &str) -> hmac::Key {
self.derive(&[], bounded_context, tag_key)
}
fn fingerprint(&self, bounded_context: &str, tag_key: &str, previous: &str) -> String {
let key = self.derive(FINGERPRINT_LABEL, bounded_context, tag_key);
hex(hmac::sign(&key, previous.as_bytes()).as_ref())
}
fn wrap(&self, bounded_context: &str, tag_key: &str, tag: &str) -> String {
let key = self.constraint_key(bounded_context, tag_key);
hex(hmac::sign(&key, tag.as_bytes()).as_ref())
}
fn derive(&self, label: &[u8], bounded_context: &str, tag_key: &str) -> hmac::Key {
let salt = hkdf::Salt::new(hkdf::HKDF_SHA256, HKDF_SALT);
let prk = salt.extract(&self.0);
let bc_len = (bounded_context.len() as u64).to_be_bytes();
let key_len = (tag_key.len() as u64).to_be_bytes();
let info: [&[u8]; 5] = [
label,
&bc_len,
bounded_context.as_bytes(),
&key_len,
tag_key.as_bytes(),
];
let okm = prk
.expand(&info, hmac::HMAC_SHA256)
.expect("HMAC_SHA256's key length is a valid HKDF output length");
hmac::Key::from(okm)
}
}
const FINGERPRINT_LABEL: &[u8] = b"skilj-blind-index-fingerprint-v1";
impl std::fmt::Debug for BlindIndexKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("BlindIndexKey(REDACTED)")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
#[non_exhaustive]
pub enum Erasure {
Keep,
WithSubject {
subject_key: String,
subject_tag: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlindTagDeclaration {
pub tag_key: String,
pub normalizer_version: u32,
pub erasure: Erasure,
pub key_fingerprint: Option<String>,
pub key_generation: u32,
}
pub type Normalizer = fn(&str) -> String;
pub mod normalize {
pub fn email(value: &str) -> String {
value.trim().to_lowercase()
}
pub fn compact_uppercase(value: &str) -> String {
value
.chars()
.filter(|c| !c.is_whitespace())
.flat_map(char::to_uppercase)
.collect()
}
}
struct Inner {
keys: Vec<BlindIndexKey>,
normalizers: HashMap<(String, String), Normalizer>,
templates: Option<crate::template_cache::TemplateCache>,
generations: Arc<RwLock<HashMap<(String, String), u32>>>,
}
impl Inner {
fn key(
&self,
generation: u32,
bounded_context: &str,
tag_key: &str,
) -> Result<&BlindIndexKey, Error> {
self.keys
.get(generation as usize)
.ok_or_else(|| Error::KeyNotConfigured {
bounded_context: bounded_context.to_string(),
tag_key: tag_key.to_string(),
generation,
})
}
}
#[derive(Clone, Default)]
pub struct BlindIndex(Option<Arc<Inner>>);
impl std::fmt::Debug for BlindIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let keys = self.0.iter().flat_map(|inner| inner.normalizers.keys());
f.debug_set().entries(keys).finish()
}
}
impl BlindIndex {
pub fn new(
root: BlindIndexKey,
constraints: impl IntoIterator<Item = (String, String, Normalizer)>,
) -> Self {
let normalizers = constraints
.into_iter()
.map(|(bounded_context, tag_key, normalize)| ((bounded_context, tag_key), normalize))
.collect();
Self(Some(Arc::new(Inner {
keys: vec![root],
normalizers,
templates: None,
generations: Arc::default(),
})))
}
pub fn with_previous_keys(self, previous: impl IntoIterator<Item = BlindIndexKey>) -> Self {
Self(self.0.map(|inner| {
let mut keys: Vec<BlindIndexKey> = previous.into_iter().collect();
keys.extend(inner.keys.iter().cloned());
Arc::new(Inner {
keys,
normalizers: inner.normalizers.clone(),
templates: inner.templates.clone(),
generations: inner.generations.clone(),
})
}))
}
#[doc(hidden)]
pub fn with_templates(self, templates: crate::template_cache::TemplateCache) -> Self {
Self(self.0.map(|inner| {
Arc::new(Inner {
keys: inner.keys.clone(),
normalizers: inner.normalizers.clone(),
templates: Some(templates),
generations: inner.generations.clone(),
})
}))
}
fn normalizer(&self, bounded_context: &str, tag_key: &str) -> Option<(&Inner, Normalizer)> {
let inner = self.0.as_deref()?;
let lookup = |bc: &str| {
inner
.normalizers
.get(&(bc.to_string(), tag_key.to_string()))
.copied()
};
let normalize = lookup(bounded_context).or_else(|| {
let template = inner
.templates
.as_ref()?
.effective_bounded_context(bounded_context);
(template != bounded_context)
.then(|| lookup(&template))
.flatten()
})?;
Some((inner, normalize))
}
pub fn declares(&self, bounded_context: &str, tag_key: &str) -> bool {
self.normalizer(bounded_context, tag_key).is_some()
}
pub fn declares_any(&self, bounded_context: &str) -> bool {
let Some(inner) = self.0.as_deref() else {
return false;
};
let template = inner
.templates
.as_ref()
.map(|templates| templates.effective_bounded_context(bounded_context));
inner
.normalizers
.keys()
.any(|(bc, _)| bc == bounded_context || template.as_deref() == Some(bc.as_str()))
}
pub fn current_generation(&self) -> u32 {
self.0
.as_deref()
.map_or(0, |inner| inner.keys.len().saturating_sub(1) as u32)
}
pub fn generation(&self, bounded_context: &str, tag_key: &str) -> u32 {
let Some(inner) = self.0.as_deref() else {
return 0;
};
inner
.generations
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&(bounded_context.to_string(), tag_key.to_string()))
.copied()
.unwrap_or_else(|| self.current_generation())
}
pub fn observe_generation(
&self,
bounded_context: &str,
tag_key: &str,
generation: u32,
) -> bool {
let Some(inner) = self.0.as_deref() else {
return false;
};
let changed = self.generation(bounded_context, tag_key) != generation;
inner
.generations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
(bounded_context.to_string(), tag_key.to_string()),
generation,
);
changed
}
pub fn blind(
&self,
bounded_context: &str,
tag_key: &str,
value: &str,
) -> Result<String, Error> {
let (inner, normalize) =
self.normalizer(bounded_context, tag_key)
.ok_or_else(|| Error::NotDeclared {
bounded_context: bounded_context.to_string(),
tag_key: tag_key.to_string(),
})?;
let generation = self.generation(bounded_context, tag_key);
let first = inner.key(0, bounded_context, tag_key)?;
let key = first.constraint_key(bounded_context, tag_key);
let mut tag = hex(hmac::sign(&key, normalize(value).as_bytes()).as_ref());
for g in 1..=generation {
tag = inner
.key(g, bounded_context, tag_key)?
.wrap(bounded_context, tag_key, &tag);
}
Ok(tag)
}
pub fn wrap(
&self,
bounded_context: &str,
tag_key: &str,
generation: u32,
tag: &str,
) -> Result<String, Error> {
let inner = self.0.as_deref().ok_or_else(|| Error::NotDeclared {
bounded_context: bounded_context.to_string(),
tag_key: tag_key.to_string(),
})?;
Ok(inner
.key(generation, bounded_context, tag_key)?
.wrap(bounded_context, tag_key, tag))
}
pub fn fingerprint(
&self,
bounded_context: &str,
tag_key: &str,
generation: u32,
) -> Option<String> {
let (inner, _) = self.normalizer(bounded_context, tag_key)?;
chain_fingerprint(inner, bounded_context, tag_key, generation)
}
pub fn tenant_fingerprint(
&self,
template: &str,
tenant: &str,
tag_key: &str,
generation: u32,
) -> Option<String> {
let (inner, _) = self.normalizer(template, tag_key)?;
chain_fingerprint(inner, tenant, tag_key, generation)
}
}
fn chain_fingerprint(
inner: &Inner,
bounded_context: &str,
tag_key: &str,
generation: u32,
) -> Option<String> {
let mut fingerprint = inner
.keys
.first()?
.fingerprint(bounded_context, tag_key, "");
for g in 1..=generation {
fingerprint =
inner
.keys
.get(g as usize)?
.fingerprint(bounded_context, tag_key, &fingerprint);
}
Some(fingerprint)
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(DIGITS[(byte >> 4) as usize] as char);
out.push(DIGITS[(byte & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn index(root: u8) -> BlindIndex {
BlindIndex::new(
BlindIndexKey::from_bytes([root; KEY_LEN]),
[
("crm", "email"),
("crm", "backup_email"),
("billing", "email"),
]
.map(|(bc, key)| {
(
bc.to_string(),
key.to_string(),
normalize::email as Normalizer,
)
}),
)
}
#[test]
fn the_same_normalized_value_blinds_the_same() {
let index = index(1);
let a = index.blind("crm", "email", "Alice@Example.com ").unwrap();
let b = index.blind("crm", "email", "alice@example.com").unwrap();
assert_eq!(a, b);
assert_eq!(a.len(), 64);
assert!(!a.contains("alice"));
}
#[test]
fn each_constraint_and_root_key_blinds_differently() {
let blind = index(1);
let crm = blind.blind("crm", "email", "alice@example.com").unwrap();
let backup = blind
.blind("crm", "backup_email", "alice@example.com")
.unwrap();
let billing = blind
.blind("billing", "email", "alice@example.com")
.unwrap();
let other_root = index(2).blind("crm", "email", "alice@example.com").unwrap();
assert_ne!(crm, backup);
assert_ne!(crm, billing);
assert_ne!(crm, other_root);
}
#[test]
fn an_undeclared_constraint_is_refused() {
let err = index(1).blind("crm", "phone", "123").unwrap_err();
assert!(matches!(err, Error::NotDeclared { .. }));
let err = BlindIndex::default()
.blind("crm", "email", "a")
.unwrap_err();
assert!(matches!(err, Error::NotDeclared { .. }));
}
#[test]
fn a_fingerprint_tells_root_keys_apart_and_is_no_tag() {
let one = index(1);
let fingerprint = one.fingerprint("crm", "email", 0).unwrap();
assert_eq!(fingerprint.len(), 64);
assert_eq!(
Some(fingerprint.clone()),
index(1).fingerprint("crm", "email", 0)
);
assert_ne!(
Some(fingerprint.clone()),
index(2).fingerprint("crm", "email", 0)
);
assert_ne!(
Some(fingerprint.clone()),
one.fingerprint("crm", "backup_email", 0)
);
assert_ne!(
Some(fingerprint.clone()),
one.fingerprint("billing", "email", 0)
);
assert_ne!(fingerprint, one.blind("crm", "email", "").unwrap());
assert_eq!(one.fingerprint("crm", "phone", 0), None);
assert_eq!(
one.tenant_fingerprint("crm", "acme", "email", 0),
BlindIndex::new(
BlindIndexKey::from_bytes([1; KEY_LEN]),
[(
"acme".to_string(),
"email".to_string(),
normalize::email as Normalizer
)],
)
.fingerprint("acme", "email", 0)
);
assert_eq!(one.tenant_fingerprint("acme", "acme", "email", 0), None);
assert_eq!(BlindIndex::default().fingerprint("crm", "email", 0), None);
}
#[test]
fn a_rotated_tag_wraps_the_previous_generation() {
let old = index(1);
let rotated = index(2).with_previous_keys([BlindIndexKey::from_bytes([1; KEY_LEN])]);
assert_eq!(rotated.current_generation(), 1);
let before = old.blind("crm", "email", "Alice@Example.com").unwrap();
rotated.observe_generation("crm", "email", 0);
assert_eq!(
rotated.blind("crm", "email", "alice@example.com").unwrap(),
before
);
let wrapped = rotated.wrap("crm", "email", 1, &before).unwrap();
assert!(rotated.observe_generation("crm", "email", 1));
assert!(!rotated.observe_generation("crm", "email", 1));
let after = rotated.blind("crm", "email", " alice@example.com").unwrap();
assert_eq!(after, wrapped);
assert_ne!(after, before);
assert_ne!(
after,
index(2).blind("crm", "email", "alice@example.com").unwrap()
);
assert_eq!(
rotated.fingerprint("crm", "email", 0),
old.fingerprint("crm", "email", 0)
);
assert_ne!(
rotated.fingerprint("crm", "email", 1),
rotated.fingerprint("crm", "email", 0)
);
assert_ne!(
rotated.fingerprint("crm", "email", 1),
index(2).fingerprint("crm", "email", 0)
);
assert_eq!(rotated.fingerprint("crm", "email", 2), None);
}
#[test]
fn a_generation_past_the_configured_keys_is_refused() {
let index = index(1);
index.observe_generation("crm", "email", 1);
let err = index.blind("crm", "email", "a").unwrap_err();
assert!(matches!(err, Error::KeyNotConfigured { generation: 1, .. }));
assert!(index.declares_any("crm"));
assert!(!index.declares_any("sales"));
}
#[test]
fn compact_uppercase_drops_every_space() {
assert_eq!(
normalize::compact_uppercase(" nl91 abna 0417 1643 00 "),
"NL91ABNA0417164300"
);
}
}