use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::error::{IdentityError, Result};
use crate::model::{AccountStatus, Identity, MfaState};
#[async_trait]
pub trait UserStore: Send + Sync + 'static {
async fn find_by_id(&self, id: &str) -> Result<Option<Identity>>;
async fn find_by_email(&self, tenant: Option<&str>, email: &str) -> Result<Option<Identity>>;
async fn insert(&self, identity: &Identity) -> Result<()>;
async fn update(&self, identity: &Identity) -> Result<()>;
async fn set_status(&self, id: &str, status: AccountStatus) -> Result<()> {
if let Some(mut u) = self.find_by_id(id).await? {
u.status = status;
self.update(&u).await
} else {
Err(IdentityError::NotFound)
}
}
async fn set_mfa(&self, id: &str, mfa: MfaState) -> Result<()> {
if let Some(mut u) = self.find_by_id(id).await? {
u.mfa = mfa;
self.update(&u).await
} else {
Err(IdentityError::NotFound)
}
}
}
#[async_trait]
pub trait CredentialStore: Send + Sync + 'static {
async fn set_password(&self, user_id: &str, phc: &str) -> Result<()>;
async fn get_password(&self, user_id: &str) -> Result<Option<String>>;
async fn clear_password(&self, user_id: &str) -> Result<()>;
}
pub trait PiiCipher: Send + Sync + 'static {
fn encrypt(&self, plaintext: &str) -> Result<String>;
fn decrypt(&self, ciphertext: &str) -> Result<String>;
}
const PII_EMAIL: &str = "_pii_email";
const PII_PHONE: &str = "_pii_phone";
pub const BLIND_INDEX_PREFIX: &str = "bi$";
pub struct EncryptingUserStore {
inner: Arc<dyn UserStore>,
cipher: Arc<dyn PiiCipher>,
index_key: Vec<u8>,
}
impl EncryptingUserStore {
pub fn new(
inner: Arc<dyn UserStore>,
cipher: Arc<dyn PiiCipher>,
index_key: impl Into<Vec<u8>>,
) -> Self {
Self {
inner,
cipher,
index_key: index_key.into(),
}
}
fn blind_index(&self, value: &str) -> String {
let mut mac =
Hmac::<Sha256>::new_from_slice(&self.index_key).expect("HMAC accepts any key length");
mac.update(value.trim().to_ascii_lowercase().as_bytes());
let hex: String = mac
.finalize()
.into_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
format!("{BLIND_INDEX_PREFIX}{hex}")
}
fn protect(&self, id: &Identity) -> Result<Identity> {
let mut stored = id.clone();
if let Some(email) = &id.email {
stored
.attributes
.insert(PII_EMAIL.into(), self.cipher.encrypt(email)?.into());
stored.email = Some(self.blind_index(email));
}
if let Some(phone) = &id.phone {
stored
.attributes
.insert(PII_PHONE.into(), self.cipher.encrypt(phone)?.into());
stored.phone = None;
}
Ok(stored)
}
fn restore(&self, mut stored: Identity) -> Result<Identity> {
if let Some(enc) = stored
.attributes
.remove(PII_EMAIL)
.and_then(|v| v.as_str().map(str::to_owned))
{
stored.email = Some(self.cipher.decrypt(&enc)?);
}
if let Some(enc) = stored
.attributes
.remove(PII_PHONE)
.and_then(|v| v.as_str().map(str::to_owned))
{
stored.phone = Some(self.cipher.decrypt(&enc)?);
}
Ok(stored)
}
}
#[async_trait]
impl UserStore for EncryptingUserStore {
async fn find_by_id(&self, id: &str) -> Result<Option<Identity>> {
match self.inner.find_by_id(id).await? {
Some(u) => Ok(Some(self.restore(u)?)),
None => Ok(None),
}
}
async fn find_by_email(&self, tenant: Option<&str>, email: &str) -> Result<Option<Identity>> {
let idx = self.blind_index(email);
match self.inner.find_by_email(tenant, &idx).await? {
Some(u) => Ok(Some(self.restore(u)?)),
None => Ok(None),
}
}
async fn insert(&self, identity: &Identity) -> Result<()> {
self.inner.insert(&self.protect(identity)?).await
}
async fn update(&self, identity: &Identity) -> Result<()> {
self.inner.update(&self.protect(identity)?).await
}
}
#[derive(Default)]
pub struct MemoryStore {
users: RwLock<HashMap<String, Identity>>,
by_email: RwLock<HashMap<String, String>>,
passwords: RwLock<HashMap<String, String>>,
}
impl MemoryStore {
pub fn new() -> Self {
Self::default()
}
fn email_key(tenant: Option<&str>, email: &str) -> String {
format!("{}::{}", tenant.unwrap_or("-"), email.to_ascii_lowercase())
}
}
#[async_trait]
impl UserStore for MemoryStore {
async fn find_by_id(&self, id: &str) -> Result<Option<Identity>> {
Ok(self.users.read().unwrap().get(id).cloned())
}
async fn find_by_email(&self, tenant: Option<&str>, email: &str) -> Result<Option<Identity>> {
let key = Self::email_key(tenant, email);
let id = self.by_email.read().unwrap().get(&key).cloned();
match id {
Some(id) => self.find_by_id(&id).await,
None => Ok(None),
}
}
async fn insert(&self, identity: &Identity) -> Result<()> {
let mut users = self.users.write().unwrap();
let mut by_email = self.by_email.write().unwrap();
if users.contains_key(&identity.id) {
return Err(IdentityError::AlreadyExists);
}
if let Some(email) = &identity.email {
let key = Self::email_key(identity.tenant.as_deref(), email);
if by_email.contains_key(&key) {
return Err(IdentityError::AlreadyExists);
}
by_email.insert(key, identity.id.clone());
}
users.insert(identity.id.clone(), identity.clone());
Ok(())
}
async fn update(&self, identity: &Identity) -> Result<()> {
let mut users = self.users.write().unwrap();
if !users.contains_key(&identity.id) {
return Err(IdentityError::NotFound);
}
if let Some(email) = &identity.email {
let key = Self::email_key(identity.tenant.as_deref(), email);
self.by_email
.write()
.unwrap()
.insert(key, identity.id.clone());
}
users.insert(identity.id.clone(), identity.clone());
Ok(())
}
}
#[async_trait]
impl CredentialStore for MemoryStore {
async fn set_password(&self, user_id: &str, phc: &str) -> Result<()> {
self.passwords
.write()
.unwrap()
.insert(user_id.to_owned(), phc.to_owned());
Ok(())
}
async fn get_password(&self, user_id: &str) -> Result<Option<String>> {
Ok(self.passwords.read().unwrap().get(user_id).cloned())
}
async fn clear_password(&self, user_id: &str) -> Result<()> {
self.passwords.write().unwrap().remove(user_id);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct B64Cipher;
impl PiiCipher for B64Cipher {
fn encrypt(&self, p: &str) -> Result<String> {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
Ok(format!("enc:{}", STANDARD.encode(p)))
}
fn decrypt(&self, c: &str) -> Result<String> {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
let raw = c
.strip_prefix("enc:")
.ok_or(IdentityError::Internal("bad ct".into()))?;
let bytes = STANDARD
.decode(raw)
.map_err(|_| IdentityError::Internal("b64".into()))?;
String::from_utf8(bytes).map_err(|_| IdentityError::Internal("utf8".into()))
}
}
#[tokio::test]
async fn encrypting_store_hides_plaintext_but_keeps_lookup() {
let inner = Arc::new(MemoryStore::new());
let enc =
EncryptingUserStore::new(inner.clone(), Arc::new(B64Cipher), b"index-key".to_vec());
let identity = Identity {
id: "u1".into(),
tenant: None,
email: Some("Alice@Corp.com".into()),
email_verified: true,
phone: Some("+15551234".into()),
phone_verified: false,
status: AccountStatus::Active,
roles: vec!["user".into()],
perms: vec![],
mfa: Default::default(),
attributes: Default::default(),
};
enc.insert(&identity).await.unwrap();
let raw = inner.find_by_id("u1").await.unwrap().unwrap();
assert_ne!(raw.email.as_deref(), Some("Alice@Corp.com"));
assert!(raw
.email
.as_deref()
.unwrap()
.starts_with(BLIND_INDEX_PREFIX));
assert!(!raw.email.as_deref().unwrap().contains('@'));
assert!(raw.phone.is_none());
assert!(raw.attributes.contains_key("_pii_email"));
let found = enc
.find_by_email(None, "alice@corp.com")
.await
.unwrap()
.unwrap();
assert_eq!(found.email.as_deref(), Some("Alice@Corp.com"));
assert_eq!(found.phone.as_deref(), Some("+15551234"));
assert!(!found.attributes.contains_key("_pii_email")); }
}