1use std::collections::HashMap;
6use std::sync::{Arc, RwLock};
7
8use async_trait::async_trait;
9use hmac::{Hmac, Mac};
10use sha2::Sha256;
11
12use crate::error::{IdentityError, Result};
13use crate::model::{AccountStatus, Identity, MfaState};
14
15#[async_trait]
22pub trait UserStore: Send + Sync + 'static {
23 async fn find_by_id(&self, id: &str) -> Result<Option<Identity>>;
24
25 async fn find_by_email(&self, tenant: Option<&str>, email: &str) -> Result<Option<Identity>>;
26
27 async fn insert(&self, identity: &Identity) -> Result<()>;
30
31 async fn update(&self, identity: &Identity) -> Result<()>;
34
35 async fn set_status(&self, id: &str, status: AccountStatus) -> Result<()> {
37 if let Some(mut u) = self.find_by_id(id).await? {
38 u.status = status;
39 self.update(&u).await
40 } else {
41 Err(IdentityError::NotFound)
42 }
43 }
44
45 async fn set_mfa(&self, id: &str, mfa: MfaState) -> Result<()> {
47 if let Some(mut u) = self.find_by_id(id).await? {
48 u.mfa = mfa;
49 self.update(&u).await
50 } else {
51 Err(IdentityError::NotFound)
52 }
53 }
54}
55
56#[async_trait]
59pub trait CredentialStore: Send + Sync + 'static {
60 async fn set_password(&self, user_id: &str, phc: &str) -> Result<()>;
62
63 async fn get_password(&self, user_id: &str) -> Result<Option<String>>;
65
66 async fn clear_password(&self, user_id: &str) -> Result<()>;
68}
69
70pub trait PiiCipher: Send + Sync + 'static {
77 fn encrypt(&self, plaintext: &str) -> Result<String>;
79 fn decrypt(&self, ciphertext: &str) -> Result<String>;
81}
82
83const PII_EMAIL: &str = "_pii_email";
84const PII_PHONE: &str = "_pii_phone";
85
86pub const BLIND_INDEX_PREFIX: &str = "bi$";
90
91pub struct EncryptingUserStore {
108 inner: Arc<dyn UserStore>,
109 cipher: Arc<dyn PiiCipher>,
110 index_key: Vec<u8>,
111}
112
113impl EncryptingUserStore {
114 pub fn new(
115 inner: Arc<dyn UserStore>,
116 cipher: Arc<dyn PiiCipher>,
117 index_key: impl Into<Vec<u8>>,
118 ) -> Self {
119 Self {
120 inner,
121 cipher,
122 index_key: index_key.into(),
123 }
124 }
125
126 fn blind_index(&self, value: &str) -> String {
127 let mut mac =
128 Hmac::<Sha256>::new_from_slice(&self.index_key).expect("HMAC accepts any key length");
129 mac.update(value.trim().to_ascii_lowercase().as_bytes());
130 let hex: String = mac
131 .finalize()
132 .into_bytes()
133 .iter()
134 .map(|b| format!("{b:02x}"))
135 .collect();
136 format!("{BLIND_INDEX_PREFIX}{hex}")
137 }
138
139 fn protect(&self, id: &Identity) -> Result<Identity> {
142 let mut stored = id.clone();
143 if let Some(email) = &id.email {
144 stored
145 .attributes
146 .insert(PII_EMAIL.into(), self.cipher.encrypt(email)?.into());
147 stored.email = Some(self.blind_index(email));
148 }
149 if let Some(phone) = &id.phone {
150 stored
151 .attributes
152 .insert(PII_PHONE.into(), self.cipher.encrypt(phone)?.into());
153 stored.phone = None;
154 }
155 Ok(stored)
156 }
157
158 fn restore(&self, mut stored: Identity) -> Result<Identity> {
160 if let Some(enc) = stored
161 .attributes
162 .remove(PII_EMAIL)
163 .and_then(|v| v.as_str().map(str::to_owned))
164 {
165 stored.email = Some(self.cipher.decrypt(&enc)?);
166 }
167 if let Some(enc) = stored
168 .attributes
169 .remove(PII_PHONE)
170 .and_then(|v| v.as_str().map(str::to_owned))
171 {
172 stored.phone = Some(self.cipher.decrypt(&enc)?);
173 }
174 Ok(stored)
175 }
176}
177
178#[async_trait]
179impl UserStore for EncryptingUserStore {
180 async fn find_by_id(&self, id: &str) -> Result<Option<Identity>> {
181 match self.inner.find_by_id(id).await? {
182 Some(u) => Ok(Some(self.restore(u)?)),
183 None => Ok(None),
184 }
185 }
186
187 async fn find_by_email(&self, tenant: Option<&str>, email: &str) -> Result<Option<Identity>> {
188 let idx = self.blind_index(email);
191 match self.inner.find_by_email(tenant, &idx).await? {
192 Some(u) => Ok(Some(self.restore(u)?)),
193 None => Ok(None),
194 }
195 }
196
197 async fn insert(&self, identity: &Identity) -> Result<()> {
198 self.inner.insert(&self.protect(identity)?).await
199 }
200
201 async fn update(&self, identity: &Identity) -> Result<()> {
202 self.inner.update(&self.protect(identity)?).await
203 }
204}
205
206#[derive(Default)]
212pub struct MemoryStore {
213 users: RwLock<HashMap<String, Identity>>,
214 by_email: RwLock<HashMap<String, String>>,
216 passwords: RwLock<HashMap<String, String>>,
217}
218
219impl MemoryStore {
220 pub fn new() -> Self {
221 Self::default()
222 }
223
224 fn email_key(tenant: Option<&str>, email: &str) -> String {
225 format!("{}::{}", tenant.unwrap_or("-"), email.to_ascii_lowercase())
226 }
227}
228
229#[async_trait]
230impl UserStore for MemoryStore {
231 async fn find_by_id(&self, id: &str) -> Result<Option<Identity>> {
232 Ok(self.users.read().unwrap().get(id).cloned())
233 }
234
235 async fn find_by_email(&self, tenant: Option<&str>, email: &str) -> Result<Option<Identity>> {
236 let key = Self::email_key(tenant, email);
237 let id = self.by_email.read().unwrap().get(&key).cloned();
238 match id {
239 Some(id) => self.find_by_id(&id).await,
240 None => Ok(None),
241 }
242 }
243
244 async fn insert(&self, identity: &Identity) -> Result<()> {
245 let mut users = self.users.write().unwrap();
246 let mut by_email = self.by_email.write().unwrap();
247 if users.contains_key(&identity.id) {
248 return Err(IdentityError::AlreadyExists);
249 }
250 if let Some(email) = &identity.email {
251 let key = Self::email_key(identity.tenant.as_deref(), email);
252 if by_email.contains_key(&key) {
253 return Err(IdentityError::AlreadyExists);
254 }
255 by_email.insert(key, identity.id.clone());
256 }
257 users.insert(identity.id.clone(), identity.clone());
258 Ok(())
259 }
260
261 async fn update(&self, identity: &Identity) -> Result<()> {
262 let mut users = self.users.write().unwrap();
263 if !users.contains_key(&identity.id) {
264 return Err(IdentityError::NotFound);
265 }
266 if let Some(email) = &identity.email {
268 let key = Self::email_key(identity.tenant.as_deref(), email);
269 self.by_email
270 .write()
271 .unwrap()
272 .insert(key, identity.id.clone());
273 }
274 users.insert(identity.id.clone(), identity.clone());
275 Ok(())
276 }
277}
278
279#[async_trait]
280impl CredentialStore for MemoryStore {
281 async fn set_password(&self, user_id: &str, phc: &str) -> Result<()> {
282 self.passwords
283 .write()
284 .unwrap()
285 .insert(user_id.to_owned(), phc.to_owned());
286 Ok(())
287 }
288
289 async fn get_password(&self, user_id: &str) -> Result<Option<String>> {
290 Ok(self.passwords.read().unwrap().get(user_id).cloned())
291 }
292
293 async fn clear_password(&self, user_id: &str) -> Result<()> {
294 self.passwords.write().unwrap().remove(user_id);
295 Ok(())
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 struct B64Cipher;
306 impl PiiCipher for B64Cipher {
307 fn encrypt(&self, p: &str) -> Result<String> {
308 use base64::engine::general_purpose::STANDARD;
309 use base64::Engine;
310 Ok(format!("enc:{}", STANDARD.encode(p)))
311 }
312 fn decrypt(&self, c: &str) -> Result<String> {
313 use base64::engine::general_purpose::STANDARD;
314 use base64::Engine;
315 let raw = c
316 .strip_prefix("enc:")
317 .ok_or(IdentityError::Internal("bad ct".into()))?;
318 let bytes = STANDARD
319 .decode(raw)
320 .map_err(|_| IdentityError::Internal("b64".into()))?;
321 String::from_utf8(bytes).map_err(|_| IdentityError::Internal("utf8".into()))
322 }
323 }
324
325 #[tokio::test]
326 async fn encrypting_store_hides_plaintext_but_keeps_lookup() {
327 let inner = Arc::new(MemoryStore::new());
328 let enc =
329 EncryptingUserStore::new(inner.clone(), Arc::new(B64Cipher), b"index-key".to_vec());
330
331 let identity = Identity {
332 id: "u1".into(),
333 tenant: None,
334 email: Some("Alice@Corp.com".into()),
335 email_verified: true,
336 phone: Some("+15551234".into()),
337 phone_verified: false,
338 status: AccountStatus::Active,
339 roles: vec!["user".into()],
340 perms: vec![],
341 mfa: Default::default(),
342 attributes: Default::default(),
343 };
344 enc.insert(&identity).await.unwrap();
345
346 let raw = inner.find_by_id("u1").await.unwrap().unwrap();
349 assert_ne!(raw.email.as_deref(), Some("Alice@Corp.com"));
350 assert!(raw
351 .email
352 .as_deref()
353 .unwrap()
354 .starts_with(BLIND_INDEX_PREFIX));
355 assert!(!raw.email.as_deref().unwrap().contains('@'));
356 assert!(raw.phone.is_none());
357 assert!(raw.attributes.contains_key("_pii_email"));
358
359 let found = enc
361 .find_by_email(None, "alice@corp.com")
362 .await
363 .unwrap()
364 .unwrap();
365 assert_eq!(found.email.as_deref(), Some("Alice@Corp.com"));
366 assert_eq!(found.phone.as_deref(), Some("+15551234"));
367 assert!(!found.attributes.contains_key("_pii_email")); }
369}