rsord 0.1.0

A fast CLI password generator, based on a public wordlist, masterpassword and string
use clap::Parser;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use reqwest;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
 

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
     
    master_password: String, 
    input_string: String, 
    #[arg(default_value_t = 4)]
    num_words: usize,

    /// The wordlist URL to use. Defaults to EFF's large wordlist.
    #[arg(default_value = "https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt")]
    wordlist_url: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Args::parse(); 
    let wordlist = fetch_wordlist(&args.wordlist_url)?; 
    let seed = generate_seed(&args.master_password, &args.input_string); 
    let mut rng = ChaCha8Rng::from_seed(seed);

    // 4. Generate the passphrase
    let passphrase = generate_passphrase(
        &wordlist,
        &mut rng,
        args.num_words,
        &args.master_password,
        &args.input_string,
    )?;

    println!("Generated Passphrase: {}", passphrase);

    Ok(())
}
 
fn fetch_wordlist(url: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
    // Create cache directory
    let cache_dir = get_cache_dir()?;
    fs::create_dir_all(&cache_dir)?;
    
 
    let url_hash = format!("{:x}", Sha256::digest(url.as_bytes()));
    let cache_file = cache_dir.join(format!("wordlist_{}.txt", &url_hash[..16]));
    
 
    if cache_file.exists() {
        println!("Loading wordlist from cache: {:?}", cache_file);
        match fs::read_to_string(&cache_file) {
            Ok(cached_content) => {
                let wordlist = parse_wordlist(&cached_content);
                if !wordlist.is_empty() {
                    println!("Successfully loaded {} words from cache.", wordlist.len());
                    return Ok(wordlist);
                }
            }
            Err(e) => {
                println!("Failed to read cache file: {}, fetching fresh copy", e);
            }
        }
    }
    
 
    println!("Fetching wordlist from: {}", url);
    let response = reqwest::blocking::get(url)?;
    let body = response.error_for_status()?.text()?;
    
 
    let wordlist = parse_wordlist(&body);
    
    if wordlist.is_empty() {
        return Err("Fetched wordlist is empty.".into());
    }
    
 
    if let Err(e) = fs::write(&cache_file, &body) {
        println!("Warning: Failed to cache wordlist: {}", e);
    } else {
        println!("Wordlist cached to: {:?}", cache_file);
    }
    
    println!("Successfully fetched {} words.", wordlist.len());
    Ok(wordlist)
}

 
fn parse_wordlist(content: &str) -> Vec<String> {
    content
        .lines()
        .map(|line| {
            // Handle EFF wordlist format: "11111	abacus"
            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.len() >= 2 {
                parts[1].to_string() // Take the word part, skip the number
            } else {
                line.trim().to_string() // Fallback for simple wordlists
            }
        })
        .filter(|line| !line.is_empty())
        .collect()
}
 
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
    let home_dir = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .map_err(|_| "Unable to determine home directory")?;
    
    Ok(PathBuf::from(home_dir).join(".passphrase_generator"))
}

 
fn generate_seed(master_password: &str, input_string: &str) -> [u8; 32] {
    let mut hasher = Sha256::new();
    hasher.update(master_password.as_bytes());
    hasher.update(input_string.as_bytes());
    
    let result = hasher.finalize();
    let mut seed = [0u8; 32];
    seed.copy_from_slice(&result);
    seed
}

 
fn generate_passphrase(
    wordlist: &[String],
    rng: &mut impl rand::Rng,
    num_words: usize,
    _master_password: &str, // Unused parameters prefixed with underscore
    _input_string: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    if wordlist.is_empty() {
        return Err("Wordlist is empty, cannot generate passphrase.".into());
    }

    let mut passphrase_words = Vec::with_capacity(num_words);
    let mut has_capital = false;

 
    for _ in 0..num_words {
        let index = rng.gen_range(0..wordlist.len());
        let mut word = wordlist[index].clone();

        // Ensure at least one word is capitalized
        if !has_capital && rng.gen_bool(0.5) { // 50% chance to capitalize this word
            word = word.to_uppercase();
            has_capital = true;
        }
        passphrase_words.push(word);
    }

 
    if !has_capital {
        if let Some(first_word) = passphrase_words.first_mut() {
            *first_word = first_word.to_uppercase();
        }
    }
 
    let current_year = get_current_year()?;

    // Combine words with '-' separator and append the year
    let mut passphrase = passphrase_words.join("-");
    passphrase.push('-');
    passphrase.push_str(&current_year.to_string());

    Ok(passphrase)
}

 
fn get_current_year() -> Result<i32, Box<dyn std::error::Error>> {
    let duration = SystemTime::now().duration_since(UNIX_EPOCH)?;
    let years_since_epoch = duration.as_secs() / (365 * 24 * 60 * 60);
    let current_year = 1970 + years_since_epoch as i32;
    Ok(current_year)
}