tuitbot-core 0.1.47

Core library for Tuitbot autonomous X growth assistant
Documentation
//! Passphrase generation and verification.
//!
//! Generates 4-word passphrases from the EFF short wordlist (1,296 words).
//! Passphrases are hashed with bcrypt before storage.

use std::path::Path;

use rand::seq::IndexedRandom;

use super::error::AuthError;

/// EFF short wordlist (1,296 words), one per line.
const WORDLIST: &str = include_str!("../../assets/eff_short_wordlist.txt");

/// Number of words in the generated passphrase.
const PASSPHRASE_WORD_COUNT: usize = 4;

/// Bcrypt cost factor (12 = ~250ms on modern hardware).
const BCRYPT_COST: u32 = 12;

/// Generate a random 4-word passphrase from the EFF short wordlist.
pub fn generate_passphrase() -> String {
    let words: Vec<&str> = WORDLIST.lines().filter(|l| !l.is_empty()).collect();
    let mut rng = rand::rng();
    let selected: Vec<&&str> = words
        .choose_multiple(&mut rng, PASSPHRASE_WORD_COUNT)
        .collect();
    selected
        .into_iter()
        .copied()
        .collect::<Vec<&str>>()
        .join(" ")
}

/// Hash a passphrase with bcrypt.
pub fn hash_passphrase(passphrase: &str) -> Result<String, AuthError> {
    bcrypt::hash(passphrase, BCRYPT_COST).map_err(|e| AuthError::HashError {
        message: e.to_string(),
    })
}

/// Verify a passphrase against a bcrypt hash.
pub fn verify_passphrase(passphrase: &str, hash: &str) -> Result<bool, AuthError> {
    bcrypt::verify(passphrase, hash).map_err(|e| AuthError::HashError {
        message: e.to_string(),
    })
}

/// Ensure a passphrase hash file exists in the data directory.
///
/// If the file doesn't exist, generates a new passphrase, hashes it, writes
/// the hash to `passphrase_hash`, and returns `Ok(Some(plaintext))` so the
/// caller can print it to the terminal.
///
/// If the file already exists, returns `Ok(None)`.
pub fn ensure_passphrase(data_dir: &Path) -> Result<Option<String>, AuthError> {
    let hash_path = data_dir.join("passphrase_hash");

    if hash_path.exists() {
        let existing = std::fs::read_to_string(&hash_path).map_err(|e| AuthError::Storage {
            message: format!("failed to read passphrase hash: {e}"),
        })?;
        if !existing.trim().is_empty() {
            return Ok(None);
        }
    }

    let passphrase = generate_passphrase();
    let hash = hash_passphrase(&passphrase)?;

    std::fs::create_dir_all(data_dir).map_err(|e| AuthError::Storage {
        message: format!("failed to create data directory: {e}"),
    })?;
    std::fs::write(&hash_path, &hash).map_err(|e| AuthError::Storage {
        message: format!("failed to write passphrase hash: {e}"),
    })?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&hash_path, std::fs::Permissions::from_mode(0o600));
    }

    Ok(Some(passphrase))
}

/// Load the passphrase hash from disk. Returns `None` if the file doesn't exist.
pub fn load_passphrase_hash(data_dir: &Path) -> Result<Option<String>, AuthError> {
    let hash_path = data_dir.join("passphrase_hash");
    if !hash_path.exists() {
        return Ok(None);
    }
    let hash = std::fs::read_to_string(&hash_path).map_err(|e| AuthError::Storage {
        message: format!("failed to read passphrase hash: {e}"),
    })?;
    let trimmed = hash.trim().to_string();
    if trimmed.is_empty() {
        return Ok(None);
    }
    Ok(Some(trimmed))
}

/// Check if a passphrase hash file exists (i.e., the instance has been claimed).
pub fn is_claimed(data_dir: &Path) -> bool {
    let hash_path = data_dir.join("passphrase_hash");
    if !hash_path.exists() {
        return false;
    }
    std::fs::read_to_string(&hash_path)
        .map(|s| !s.trim().is_empty())
        .unwrap_or(false)
}

/// Create a passphrase hash from a user-provided plaintext.
///
/// This is the "claim" operation for first-time web setup. It:
/// - Validates the passphrase (minimum 8 characters)
/// - Checks that no hash file exists (returns `AlreadyClaimed` if it does)
/// - Hashes with bcrypt (cost 12)
/// - Writes the hash to `data_dir/passphrase_hash` with 0600 permissions
///
/// The plaintext is never logged or persisted.
pub fn create_passphrase_hash(data_dir: &Path, plaintext: &str) -> Result<(), AuthError> {
    if plaintext.len() < 8 {
        return Err(AuthError::Storage {
            message: "passphrase must be at least 8 characters".to_string(),
        });
    }

    let hash_path = data_dir.join("passphrase_hash");

    // Reject if already claimed.
    if hash_path.exists() {
        let existing = std::fs::read_to_string(&hash_path).map_err(|e| AuthError::Storage {
            message: format!("failed to read passphrase hash: {e}"),
        })?;
        if !existing.trim().is_empty() {
            return Err(AuthError::AlreadyClaimed);
        }
    }

    let hash = hash_passphrase(plaintext)?;

    std::fs::create_dir_all(data_dir).map_err(|e| AuthError::Storage {
        message: format!("failed to create data directory: {e}"),
    })?;
    std::fs::write(&hash_path, &hash).map_err(|e| AuthError::Storage {
        message: format!("failed to write passphrase hash: {e}"),
    })?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&hash_path, std::fs::Permissions::from_mode(0o600));
    }

    Ok(())
}

/// Return the modification time of the `passphrase_hash` file.
///
/// Returns `None` if the file does not exist or its metadata cannot be read.
pub fn passphrase_hash_mtime(data_dir: &Path) -> Option<std::time::SystemTime> {
    let hash_path = data_dir.join("passphrase_hash");
    std::fs::metadata(&hash_path)
        .ok()
        .and_then(|m| m.modified().ok())
}

/// Re-generate a passphrase (for `--reset-passphrase`).
///
/// Overwrites the existing hash file and returns the new plaintext passphrase.
pub fn reset_passphrase(data_dir: &Path) -> Result<String, AuthError> {
    let hash_path = data_dir.join("passphrase_hash");
    let passphrase = generate_passphrase();
    let hash = hash_passphrase(&passphrase)?;

    std::fs::write(&hash_path, &hash).map_err(|e| AuthError::Storage {
        message: format!("failed to write passphrase hash: {e}"),
    })?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&hash_path, std::fs::Permissions::from_mode(0o600));
    }

    Ok(passphrase)
}

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

    #[test]
    fn generate_passphrase_has_four_words() {
        let passphrase = generate_passphrase();
        let words: Vec<&str> = passphrase.split_whitespace().collect();
        assert_eq!(words.len(), 4);
    }

    #[test]
    fn generate_passphrase_uses_wordlist_words() {
        let valid_words: Vec<&str> = WORDLIST.lines().filter(|l| !l.is_empty()).collect();
        let passphrase = generate_passphrase();
        for word in passphrase.split_whitespace() {
            assert!(valid_words.contains(&word), "word not in wordlist: {word}");
        }
    }

    #[test]
    fn hash_and_verify_roundtrip() {
        let passphrase = "alpha bravo charlie delta";
        let hash = hash_passphrase(passphrase).unwrap();
        assert!(verify_passphrase(passphrase, &hash).unwrap());
        assert!(!verify_passphrase("wrong passphrase here", &hash).unwrap());
    }

    #[test]
    fn ensure_passphrase_creates_file() {
        let dir = tempfile::tempdir().unwrap();
        let result = ensure_passphrase(dir.path()).unwrap();
        assert!(result.is_some());
        let passphrase = result.unwrap();
        assert_eq!(passphrase.split_whitespace().count(), 4);

        // Second call should return None (already exists)
        let result2 = ensure_passphrase(dir.path()).unwrap();
        assert!(result2.is_none());
    }

    #[test]
    fn ensure_passphrase_verifies_against_hash() {
        let dir = tempfile::tempdir().unwrap();
        let passphrase = ensure_passphrase(dir.path()).unwrap().unwrap();
        let hash = load_passphrase_hash(dir.path()).unwrap().unwrap();
        assert!(verify_passphrase(&passphrase, &hash).unwrap());
    }

    #[test]
    fn is_claimed_returns_false_on_empty_dir() {
        let dir = tempfile::tempdir().unwrap();
        assert!(!is_claimed(dir.path()));
    }

    #[test]
    fn is_claimed_returns_true_after_create() {
        let dir = tempfile::tempdir().unwrap();
        create_passphrase_hash(dir.path(), "test passphrase here").unwrap();
        assert!(is_claimed(dir.path()));
    }

    #[test]
    fn create_passphrase_hash_creates_file() {
        let dir = tempfile::tempdir().unwrap();
        let plaintext = "alpha bravo charlie delta";
        create_passphrase_hash(dir.path(), plaintext).unwrap();

        let hash = load_passphrase_hash(dir.path()).unwrap().unwrap();
        assert!(verify_passphrase(plaintext, &hash).unwrap());
    }

    #[test]
    fn create_passphrase_hash_rejects_short_passphrase() {
        let dir = tempfile::tempdir().unwrap();
        let result = create_passphrase_hash(dir.path(), "short");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("at least 8 characters"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn create_passphrase_hash_rejects_already_claimed() {
        let dir = tempfile::tempdir().unwrap();
        create_passphrase_hash(dir.path(), "first passphrase ok").unwrap();
        let result = create_passphrase_hash(dir.path(), "second passphrase ok");
        assert!(result.is_err());
        assert!(
            matches!(result.unwrap_err(), AuthError::AlreadyClaimed),
            "expected AlreadyClaimed error"
        );
    }

    #[test]
    fn passphrase_hash_mtime_returns_some_after_create() {
        let dir = tempfile::tempdir().unwrap();
        assert!(passphrase_hash_mtime(dir.path()).is_none());
        ensure_passphrase(dir.path()).unwrap();
        assert!(passphrase_hash_mtime(dir.path()).is_some());
    }

    #[test]
    fn passphrase_hash_mtime_changes_after_reset() {
        let dir = tempfile::tempdir().unwrap();
        ensure_passphrase(dir.path()).unwrap();
        let mtime1 = passphrase_hash_mtime(dir.path()).unwrap();
        // Small sleep to ensure filesystem mtime granularity advances.
        std::thread::sleep(std::time::Duration::from_millis(50));
        reset_passphrase(dir.path()).unwrap();
        let mtime2 = passphrase_hash_mtime(dir.path()).unwrap();
        assert!(mtime2 >= mtime1);
    }

    #[test]
    fn reset_passphrase_overwrites() {
        let dir = tempfile::tempdir().unwrap();
        let first = ensure_passphrase(dir.path()).unwrap().unwrap();
        let second = reset_passphrase(dir.path()).unwrap();
        assert_ne!(first, second);
        let hash = load_passphrase_hash(dir.path()).unwrap().unwrap();
        assert!(verify_passphrase(&second, &hash).unwrap());
        assert!(!verify_passphrase(&first, &hash).unwrap());
    }

    #[test]
    fn load_passphrase_hash_nonexistent_dir() {
        let dir = tempfile::tempdir().unwrap();
        let result = load_passphrase_hash(dir.path()).unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn load_passphrase_hash_empty_file() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("passphrase_hash"), "").unwrap();
        let result = load_passphrase_hash(dir.path()).unwrap();
        assert!(result.is_none(), "empty hash file should return None");
    }

    #[test]
    fn load_passphrase_hash_whitespace_only_file() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("passphrase_hash"), "   \n  ").unwrap();
        let result = load_passphrase_hash(dir.path()).unwrap();
        assert!(result.is_none(), "whitespace-only should return None");
    }

    #[test]
    fn is_claimed_false_on_empty_hash_file() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("passphrase_hash"), "").unwrap();
        assert!(!is_claimed(dir.path()));
    }

    #[test]
    fn generate_passphrase_produces_different_values() {
        let p1 = generate_passphrase();
        let p2 = generate_passphrase();
        // Extremely unlikely to collide with 1296^4 possibilities
        assert_ne!(p1, p2, "two generated passphrases should differ");
    }

    #[test]
    fn verify_passphrase_wrong_input_returns_false() {
        let hash = hash_passphrase("correct horse battery staple").unwrap();
        assert!(!verify_passphrase("wrong phrase entirely", &hash).unwrap());
    }

    #[test]
    fn create_passphrase_hash_exactly_8_chars() {
        let dir = tempfile::tempdir().unwrap();
        // 8 characters should be accepted
        create_passphrase_hash(dir.path(), "12345678").unwrap();
        assert!(is_claimed(dir.path()));
    }

    #[test]
    fn ensure_passphrase_on_empty_hash_file_regenerates() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("passphrase_hash"), "").unwrap();
        let result = ensure_passphrase(dir.path()).unwrap();
        assert!(
            result.is_some(),
            "empty hash file should trigger regeneration"
        );
    }

    #[test]
    fn reset_passphrase_verifies_against_new_hash() {
        let dir = tempfile::tempdir().unwrap();
        ensure_passphrase(dir.path()).unwrap();
        let new_passphrase = reset_passphrase(dir.path()).unwrap();
        let hash = load_passphrase_hash(dir.path()).unwrap().unwrap();
        assert!(verify_passphrase(&new_passphrase, &hash).unwrap());
    }
}