rustpbx 0.3.19

A SIP PBX implementation in Rust
Documentation
//! Mailbox lifecycle and PIN management.
//!
//! # PIN storage
//!
//! PINs are stored as an **Argon2id** hash (via the `argon2` crate that
//! rustpbx already ships).  The raw digits are **never** stored.
//!
//! # Typical call flows
//!
//! ## Initial provisioning (admin / CLI / system)
//! ```text
//! create_mailbox("1001", Some("user@corp.com"), "1234")
//!   → validates PIN strength
//!   → hashes with Argon2id
//!   → inserts voicemail_box row
//! ```
//!
//! ## Incoming call → VoicemailApp construction
//! ```text
//! get_or_create("1001")
//!   → if row exists: return it
//!   → else: create row with random PIN, email = None
//!           (admin must set a proper PIN afterwards)
//! ```
//!
//! ## User calls *97 to check messages
//! ```text
//! verify_pin("1001", "1234")  →  Ok(true) / Ok(false)
//! ```
//!
//! ## User changes their PIN
//! ```text
//! set_pin("1001", "5678")
//!   → validates new PIN strength
//!   → re-hashes and updates row
//! ```

use anyhow::{anyhow, bail, Result};
use argon2::{
    Argon2,
    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
};
use chrono::Utc;
use sea_orm::{
    ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
};
use uuid::Uuid;

use crate::addons::voicemail::models::mailbox::{
    ActiveModel as MailboxActiveModel, Column as MailboxColumn, Entity as MailboxEntity,
    Model as Mailbox,
};

// ─── PIN policy ──────────────────────────────────────────────────────────────

/// Minimum number of digits required for a PIN.
const PIN_MIN_LEN: usize = 4;
/// Maximum number of digits allowed for a PIN.
const PIN_MAX_LEN: usize = 8;

/// Validate a candidate PIN against the policy.
///
/// Rules:
/// - Must be `PIN_MIN_LEN`…`PIN_MAX_LEN` digits (digits only).
/// - Must not be all the same digit (e.g. `1111`).
/// - Must not be an ascending sequence (e.g. `1234`).
/// - Must not be a descending sequence (e.g. `4321`).
pub fn validate_pin(pin: &str) -> Result<()> {
    if pin.len() < PIN_MIN_LEN || pin.len() > PIN_MAX_LEN {
        bail!(
            "PIN must be {}-{} digits",
            PIN_MIN_LEN,
            PIN_MAX_LEN
        );
    }
    if !pin.chars().all(|c| c.is_ascii_digit()) {
        bail!("PIN must contain digits only");
    }

    let digits: Vec<u8> = pin.bytes().map(|b| b - b'0').collect();

    // All same digit
    if digits.windows(2).all(|w| w[0] == w[1]) {
        bail!("PIN must not be all the same digit");
    }

    // Simple ascending sequence  (e.g. 1234, 23456)
    if digits.windows(2).all(|w| w[1] == w[0] + 1) {
        bail!("PIN must not be a simple ascending sequence");
    }

    // Simple descending sequence (e.g. 4321, 65432)
    if digits.windows(2).all(|w| w[0] == w[1] + 1) {
        bail!("PIN must not be a simple descending sequence");
    }

    Ok(())
}

// ─── hashing helpers ─────────────────────────────────────────────────────────

fn hash_pin(pin: &str) -> Result<String> {
    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
    let hash = argon2
        .hash_password(pin.as_bytes(), &salt)
        .map_err(|e| anyhow::anyhow!("PIN hashing failed: {}", e))?;
    Ok(hash.to_string())
}

fn verify_pin_hash(pin: &str, hash: &str) -> Result<bool> {
    let parsed = PasswordHash::new(hash)
        .map_err(|e| anyhow::anyhow!("invalid PIN hash in database: {}", e))?;
    Ok(Argon2::default()
        .verify_password(pin.as_bytes(), &parsed)
        .is_ok())
}

// ─── service ─────────────────────────────────────────────────────────────────

/// Mailbox management service.  Stateless; owns only a `DatabaseConnection`.
#[derive(Clone)]
pub struct MailboxService {
    pub db: DatabaseConnection,
}

impl MailboxService {
    pub fn new(db: DatabaseConnection) -> Self {
        Self { db }
    }

    // ── read ──────────────────────────────────────────────────────────────────

    /// Find a mailbox by extension.  Returns `None` if not provisioned.
    pub async fn find_by_extension(&self, extension: &str) -> Result<Option<Mailbox>> {
        let row = MailboxEntity::find()
            .filter(MailboxColumn::Extension.eq(extension))
            .one(&self.db)
            .await?;
        Ok(row)
    }

    // ── create ────────────────────────────────────────────────────────────────

    /// Provision a new mailbox.
    ///
    /// * `extension`  – SIP extension number (unique).
    /// * `email`      – Optional notification e-mail.
    /// * `pin`        – Initial PIN (validated + hashed before storage).
    ///
    /// Returns an error if the extension already has a mailbox or if the PIN
    /// fails the policy check.
    pub async fn create_mailbox(
        &self,
        extension: &str,
        email: Option<&str>,
        pin: &str,
    ) -> Result<Mailbox> {
        validate_pin(pin)?;

        // Guard against duplicates.
        if self.find_by_extension(extension).await?.is_some() {
            bail!("mailbox for extension {} already exists", extension);
        }

        let now = Utc::now().naive_utc();
        let row = MailboxActiveModel {
            id: Set(Uuid::new_v4()),
            extension: Set(extension.to_string()),
            pin: Set(hash_pin(pin)?),
            email: Set(email.map(str::to_string)),
            greeting_path: Set(None),
            created_at: Set(now),
            updated_at: Set(now),
        };

        // sea-orm's `insert()` calls `last_insert_id()` which maps to SQLite's
        // integer ROWID — not the UUID we set.  Use `exec_without_returning()`
        // to avoid that conversion, then re-fetch by the UUID we generated.
        let id = row.id.clone().unwrap();
        MailboxEntity::insert(row)
            .exec_without_returning(&self.db)
            .await?;
        let model = MailboxEntity::find_by_id(id)
            .one(&self.db)
            .await?
            .ok_or_else(|| anyhow!("mailbox not found after insert"))?;
        Ok(model)
    }

    // ── idempotent get-or-create ───────────────────────────────────────────────

    /// Return the existing mailbox for `extension`, or create one with a
    /// random PIN if none exists yet.
    ///
    /// The auto-generated PIN is returned alongside the model so the caller
    /// can surface it to the administrator (it is never stored in plain text).
    /// If the mailbox already existed, `generated_pin` is `None`.
    pub async fn get_or_create(
        &self,
        extension: &str,
    ) -> Result<(Mailbox, Option<String>)> {
        if let Some(existing) = self.find_by_extension(extension).await? {
            return Ok((existing, None));
        }

        // Generate a 6-digit PIN that passes policy.
        let pin = generate_safe_pin();
        let mailbox = self.create_mailbox(extension, None, &pin).await?;
        Ok((mailbox, Some(pin)))
    }

    // ── PIN verification ──────────────────────────────────────────────────────

    /// Verify `candidate_pin` for `extension`.
    ///
    /// Returns `Ok(true)` on a match, `Ok(false)` on a mismatch.
    /// Returns an error if the mailbox is not found or the stored hash is
    /// corrupt.
    pub async fn verify_pin(&self, extension: &str, candidate_pin: &str) -> Result<bool> {
        let mailbox = self
            .find_by_extension(extension)
            .await?
            .ok_or_else(|| anyhow::anyhow!("no mailbox for extension {}", extension))?;

        verify_pin_hash(candidate_pin, &mailbox.pin)
    }

    // ── PIN update ────────────────────────────────────────────────────────────

    /// Change the PIN for `extension`.
    ///
    /// * `_old_pin` – Required when `require_old_pin = true` (self-service).
    ///   Pass `None` to skip the old-PIN check (admin reset path).
    /// * `new_pin` – Must pass the policy check.
    pub async fn set_pin(
        &self,
        extension: &str,
        old_pin: Option<&str>,
        new_pin: &str,
    ) -> Result<()> {
        validate_pin(new_pin)?;

        let mailbox = self
            .find_by_extension(extension)
            .await?
            .ok_or_else(|| anyhow::anyhow!("no mailbox for extension {}", extension))?;

        // If caller provided the old PIN, verify it first.
        if let Some(old) = old_pin {
            if !verify_pin_hash(old, &mailbox.pin)? {
                bail!("incorrect current PIN");
            }
        }

        // Reject re-use of the current PIN.
        if verify_pin_hash(new_pin, &mailbox.pin)? {
            bail!("new PIN must differ from the current PIN");
        }

        let mut active: MailboxActiveModel = mailbox.into();
        active.pin = Set(hash_pin(new_pin)?);
        active.updated_at = Set(Utc::now().naive_utc());
        active.update(&self.db).await?;

        Ok(())
    }

    // ── admin reset ───────────────────────────────────────────────────────────

    /// Reset PIN without requiring the old PIN (admin operation).
    /// Returns the new plain-text PIN so the admin can communicate it to
    /// the user.  A random safe PIN is generated.
    pub async fn reset_pin(&self, extension: &str) -> Result<String> {
        let new_pin = generate_safe_pin();
        self.set_pin(extension, None, &new_pin).await?;
        Ok(new_pin)
    }

    // ── greeting path ─────────────────────────────────────────────────────────

    /// Update the custom greeting audio path for a mailbox.
    /// `path` is the logical storage key returned by `VoicemailStorage`.
    pub async fn set_greeting_path(&self, extension: &str, path: Option<&str>) -> Result<()> {
        let mailbox = self
            .find_by_extension(extension)
            .await?
            .ok_or_else(|| anyhow::anyhow!("no mailbox for extension {}", extension))?;

        let mut active: MailboxActiveModel = mailbox.into();
        active.greeting_path = Set(path.map(str::to_string));
        active.updated_at = Set(Utc::now().naive_utc());
        active.update(&self.db).await?;
        Ok(())
    }

    // ── email ─────────────────────────────────────────────────────────────────

    /// Update the notification e-mail for a mailbox.
    pub async fn set_email(&self, extension: &str, email: Option<&str>) -> Result<()> {
        let mailbox = self
            .find_by_extension(extension)
            .await?
            .ok_or_else(|| anyhow::anyhow!("no mailbox for extension {}", extension))?;

        let mut active: MailboxActiveModel = mailbox.into();
        active.email = Set(email.map(str::to_string));
        active.updated_at = Set(Utc::now().naive_utc());
        active.update(&self.db).await?;
        Ok(())
    }
}

// ─── helpers ─────────────────────────────────────────────────────────────────

/// Generate a 6-digit PIN that satisfies the PIN policy.
fn generate_safe_pin() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    // Simple deterministic approach: use last 6 digits of microsecond
    // timestamp shuffled with the nanos portion.  Good enough for an
    // admin-visible one-time reset token; not a CSPRNG requirement.
    let t = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    let seed = t.as_micros() as u64;
    // Mix bits to avoid visible time-based patterns.
    let mixed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
    let digits = format!("{:06}", mixed % 1_000_000);
    // Fallback if policy rejects (e.g. 000000): rotate.
    if validate_pin(&digits).is_ok() {
        digits
    } else {
        format!("{:06}", (mixed.wrapping_add(137) % 900_000) + 100_000)
    }
}

// ─── unit tests ──────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pin_validation_rules() {
        assert!(validate_pin("1234").is_err(), "ascending sequence");
        assert!(validate_pin("4321").is_err(), "descending sequence");
        assert!(validate_pin("1111").is_err(), "all-same");
        assert!(validate_pin("abc").is_err(), "non-digits");
        assert!(validate_pin("12").is_err(), "too short");
        assert!(validate_pin("123456789").is_err(), "too long");
        assert!(validate_pin("1357").is_ok());
        assert!(validate_pin("9182").is_ok());
        assert!(validate_pin("246810").is_ok());
    }

    #[test]
    fn pin_hash_roundtrip() {
        let pin = "9182";
        let hash = hash_pin(pin).unwrap();
        assert!(verify_pin_hash(pin, &hash).unwrap());
        assert!(!verify_pin_hash("9183", &hash).unwrap());
    }

    #[test]
    fn generated_pin_passes_policy() {
        for _ in 0..20 {
            let pin = generate_safe_pin();
            assert_eq!(pin.len(), 6);
            validate_pin(&pin).unwrap_or_else(|e| panic!("generated PIN '{}' failed: {}", pin, e));
        }
    }
}