use std::path::Path;
use rand::seq::IndexedRandom;
use super::error::AuthError;
const WORDLIST: &str = include_str!("../../assets/eff_short_wordlist.txt");
const PASSPHRASE_WORD_COUNT: usize = 4;
const BCRYPT_COST: u32 = 12;
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(" ")
}
pub fn hash_passphrase(passphrase: &str) -> Result<String, AuthError> {
bcrypt::hash(passphrase, BCRYPT_COST).map_err(|e| AuthError::HashError {
message: e.to_string(),
})
}
pub fn verify_passphrase(passphrase: &str, hash: &str) -> Result<bool, AuthError> {
bcrypt::verify(passphrase, hash).map_err(|e| AuthError::HashError {
message: e.to_string(),
})
}
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))
}
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))
}
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)
}
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");
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(())
}
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())
}
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);
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();
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();
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();
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());
}
}