arcly-http-identity 0.9.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! Storage traits — the app supplies the backends (Postgres, Redis, …). The
//! crate links no database. An in-memory implementation ([`MemoryStore`]) is
//! provided for tests, examples, and local development.

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};

/// Persistence for [`Identity`] records.
///
/// Implementations are responsible for encrypting PII at rest (e.g. via the
/// compliance `CryptoVault`) and for enforcing the tenant + email uniqueness
/// constraint. Lookups are tenant-scoped so the same email can exist in two
/// tenants (B2B) while remaining unique within one.
#[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>>;

    /// Insert a new identity. Must return [`IdentityError::AlreadyExists`] on a
    /// tenant+email collision.
    async fn insert(&self, identity: &Identity) -> Result<()>;

    /// Overwrite an existing identity (profile edits, status transitions,
    /// verification flags, MFA state).
    async fn update(&self, identity: &Identity) -> Result<()>;

    /// Set only the account status (lock / suspend / activate).
    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)
        }
    }

    /// Update just the MFA enrolment summary (called after TOTP/passkey changes).
    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)
        }
    }
}

/// Persistence for password hashes, kept separate from the identity record so
/// credentials never ride along with profile reads.
#[async_trait]
pub trait CredentialStore: Send + Sync + 'static {
    /// Store (or replace) the PHC-string password hash for a user.
    async fn set_password(&self, user_id: &str, phc: &str) -> Result<()>;

    /// Fetch the stored PHC hash, if the user has a password set.
    async fn get_password(&self, user_id: &str) -> Result<Option<String>>;

    /// Remove the password credential (e.g. converting to passwordless-only).
    async fn clear_password(&self, user_id: &str) -> Result<()>;
}

// ─── PII encryption-at-rest decorator ───────────────────────────────────────

/// Encrypts/decrypts a single PII field. Implement over the compliance
/// `CryptoVault` (per-subject AES-256-GCM + crypto-shred) or a KMS. The crate
/// links no cipher — this is the seam, matching the framework's trait-for-I/O
/// rule.
pub trait PiiCipher: Send + Sync + 'static {
    /// Encrypt `plaintext`, returning an opaque wire string safe to store.
    fn encrypt(&self, plaintext: &str) -> Result<String>;
    /// Decrypt a wire string produced by [`encrypt`](Self::encrypt).
    fn decrypt(&self, ciphertext: &str) -> Result<String>;
}

const PII_EMAIL: &str = "_pii_email";
const PII_PHONE: &str = "_pii_phone";

/// Prefix on a stored blind index so it is visibly distinct from a plaintext
/// email in the backing store. `$` cannot appear before the `@` of a real
/// address here, so a value starting `bi$` is unambiguously an index.
pub const BLIND_INDEX_PREFIX: &str = "bi$";

/// A [`UserStore`] decorator that keeps **email/phone encrypted at rest** while
/// preserving equality lookup via a keyed **blind index** (HMAC-SHA256).
///
/// The wrapped store never sees plaintext PII: on write, the real address is
/// AES-encrypted into the identity's `attributes` and the indexed `email` field
/// is replaced by its blind index; on read, the plaintext is transparently
/// restored. `find_by_email` hashes the query with the same key, so exact-match
/// lookup still works without exposing PII — closing the "plaintext PII in the
/// store" gap while remaining GDPR-shreddable (drop the subject's key in the
/// vault and the ciphertext is unrecoverable).
///
/// Storing the index in the `email` field is intrinsic to decorating an opaque
/// `UserStore` (whose `find_by_email` matches on that field). To make the value
/// unmistakably **not** a plaintext address — for anyone inspecting the backing
/// store — the blind index is prefixed with [`BLIND_INDEX_PREFIX`]
/// (`"bi$"`), which no real email can contain in that position.
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}")
    }

    /// Transform a caller-facing identity into its at-rest form (PII encrypted,
    /// indexed field blinded).
    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)
    }

    /// Restore plaintext PII from an at-rest identity.
    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>> {
        // Look the address up by its blind index — the store never receives the
        // plaintext address.
        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
    }
}

// ─── In-memory reference implementation ─────────────────────────────────────

/// Thread-safe in-memory store implementing every persistence trait. **For
/// tests / examples / local dev only** — data is lost on restart and there is
/// no encryption at rest.
#[derive(Default)]
pub struct MemoryStore {
    users: RwLock<HashMap<String, Identity>>,
    // (tenant, email) -> id  secondary index
    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);
        }
        // Refresh the email index in case the address changed.
        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::*;

    /// Reversible test cipher (base64) — proves the decorator's transform, not
    /// cryptographic strength.
    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();

        // The underlying store never holds the plaintext email/phone; the stored
        // "email" is an obviously-non-plaintext blind index (prefixed).
        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"));

        // Case-insensitive lookup by plaintext still resolves via the blind index.
        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")); // stripped on read
    }
}