Skip to content

Passphrase Generator — Rust source

Generate memorable, high-entropy passphrases from the EFF diceware wordlist using cryptographic randomness. Each word is drawn with crypto.getRandomValues and rejection sampling (no modulo bias), and every passphrase shows its exact entropy in bits plus the estimated crack time at 1 trillion guesses/sec. Everything runs locally - nothing is sent anywhere.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! Passphrase Generator — diceware generation + entropy scoring.
//!
//! Language: Rust (edition 2021, standard library only)
//! Source:   CosmoDev polyglot showcase port of the Passphrase Generator tool,
//!           ported from src/lib/passphrase-generator.ts (the canonical
//!           TypeScript implementation).
//! License:  display source — part of CosmoDev's polyglot tool pages.
//!
//! Word selection draws from the OS CSPRNG with rejection sampling, so every
//! word index is uniform over the 7,776-word EFF long wordlist — no modulo
//! bias. Stdlib-only (no `rand`/`getrandom` crates — same trade-off as the
//! mac-address-generator snippet): the default entropy source reads
//! `/dev/urandom` via `std::fs` + `std::io`. The wordlist itself is too
//! large to inline; pass it in as a slice of strings.

use std::fs::File;
use std::io::Read;

/// Size of the EFF long wordlist (6^5).
pub const EFF_WORDLIST_LENGTH: usize = 7776;

pub const MIN_WORDS: u32 = 3;
pub const MAX_WORDS: u32 = 10;
pub const DEFAULT_WORDS: u32 = 6;
pub const DEFAULT_GUESSES_PER_SECOND: f64 = 1e12;

/// Rejection-sampling ceiling for a uniform uint16 over 7,776: the largest
/// multiple of 7,776 that fits in [0, 65536). 7776 x 8 = 62208; draws at or
/// above it are rejected so `v % 7776` is uniform.
const WORD_LIMIT: u32 = 62_208;

/// Largest multiple of 10 in [0, 256) — the digit rejection ceiling.
const DIGIT_LIMIT: u8 = 250;

const YEAR: f64 = 31_557_600.0; // seconds (Julian year)
const SCALES: [(f64, &str); 4] =
    [(1e12, "trillion"), (1e9, "billion"), (1e6, "million"), (1e3, "thousand")];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Separator {
    Space,
    Dash,
    Dot,
    Underscore,
    None,
}

impl Separator {
    fn as_str(self) -> &'static str {
        match self {
            Separator::Space => " ",
            Separator::Dash => "-",
            Separator::Dot => ".",
            Separator::Underscore => "_",
            Separator::None => "",
        }
    }
}

impl Default for Separator {
    fn default() -> Self {
        Separator::Space
    }
}

#[derive(Debug, Clone)]
pub struct PassphraseOptions {
    pub word_count: Option<u32>,
    pub separator: Separator,
    pub capitalize: bool,
    pub append_digit: bool,
    pub guesses_per_second: f64,
}

impl Default for PassphraseOptions {
    fn default() -> Self {
        PassphraseOptions {
            word_count: None,
            separator: Separator::default(),
            capitalize: false,
            append_digit: false,
            guesses_per_second: DEFAULT_GUESSES_PER_SECOND,
        }
    }
}

#[derive(Debug, Clone)]
pub struct CrackTimeEstimate {
    pub seconds: f64,
    pub human: String,
}

#[derive(Debug, Clone)]
pub struct Passphrase {
    pub words: Vec<String>,
    pub passphrase: String,
    pub entropy: f64,
    pub crack_time: CrackTimeEstimate,
}

/// `n` random octets from /dev/urandom (Unix). Stdlib-only: no `rand` crate.
/// On platforms where /dev/urandom is unavailable this returns zeros — the
/// public functions below still run (degenerate randomness), so the display
/// source never fails to compile or panic.
fn urandom(n: usize) -> Vec<u8> {
    let mut buf = vec![0u8; n];
    if let Ok(mut f) = File::open("/dev/urandom") {
        let _ = f.read_exact(&mut buf);
    }
    buf
}

/// One uniformly-random word from the EFF long wordlist (CSPRNG).
pub fn random_word(wordlist: &[&str]) -> String {
    loop {
        let b = urandom(2);
        let v = ((b[0] as u32) << 8) | b[1] as u32;
        if v < WORD_LIMIT {
            return wordlist[(v as usize) % EFF_WORDLIST_LENGTH].to_string();
        }
    }
}

/// One uniformly-random digit 0-9 (CSPRNG, rejection-sampled on uint8).
pub fn random_digit() -> char {
    loop {
        let v = urandom(1)[0];
        if v < DIGIT_LIMIT {
            return (v % 10) as u8 as char;
        }
    }
}

/// Theoretical word entropy in bits: wordCount x log2(7776).
pub fn entropy_bits(word_count: u32) -> f64 {
    if word_count == 0 {
        0.0
    } else {
        word_count as f64 * (EFF_WORDLIST_LENGTH as f64).log2()
    }
}

/// Average time to crack (seconds) = 2^(bits-1) / guessesPerSecond.
pub fn crack_time_seconds(bits: f64, guesses_per_second: f64) -> f64 {
    2f64.powf(bits - 1.0) / guesses_per_second
}

fn round_value(v: f64) -> f64 {
    // 1 decimal below 10, whole numbers above.
    if v >= 10.0 {
        v.round()
    } else {
        (v * 10.0).round() / 10.0
    }
}

fn locale(n: f64) -> String {
    // Thousands-grouped integers; floats below 1000 keep their single decimal.
    if n.fract() != 0.0 && n < 1000.0 {
        return format!("{n:.1}");
    }
    let digits = n.trunc().abs().to_string();
    let mut grouped = String::new();
    for (i, c) in digits.chars().enumerate() {
        if i > 0 && (digits.len() - i) % 3 == 0 {
            grouped.push(',');
        }
        grouped.push(c);
    }
    if n < 0.0 {
        grouped.insert(0, '-');
    }
    grouped
}

fn span(v: f64, singular: &str, plural: &str) -> String {
    let n = round_value(v);
    let unit = if n == 1.0 { singular } else { plural };
    format!("{} {}", locale(n), unit)
}

/// Human-readable crack-time span; collapses to scaled words at diceware
/// entropies so the string stays readable ("39 trillion centuries").
pub fn format_crack_time(seconds: f64) -> String {
    if !seconds.is_finite() || seconds < 0.0 {
        return "-".to_string();
    }
    if seconds < 1.0 {
        return "< 1 second".to_string();
    }
    if seconds < 60.0 {
        return span(seconds, "second", "seconds");
    }
    if seconds < 3600.0 {
        return span(seconds / 60.0, "minute", "minutes");
    }
    if seconds < 86_400.0 {
        return span(seconds / 3600.0, "hour", "hours");
    }
    if seconds < YEAR {
        return span(seconds / 86_400.0, "day", "days");
    }

    let years = seconds / YEAR;
    if years < 100.0 {
        return span(years, "year", "years");
    }
    if years < 1000.0 {
        return span(years / 100.0, "century", "centuries");
    }
    if years < 1e6 {
        return span(years / 1000.0, "millennium", "millennia");
    }

    // years >= 1e6 here, so centuries >= 10,000: collapse to a scaled plural.
    let centuries = years / 100.0;
    for (factor, name) in SCALES {
        if centuries >= factor {
            return format!("{} {} centuries", locale(round_value(centuries / factor)), name);
        }
    }
    format!("{} centuries", locale(round_value(centuries)))
}

/// Generate a diceware passphrase from the EFF long wordlist.
pub fn generate_passphrase(wordlist: &[&str], options: &PassphraseOptions) -> Passphrase {
    let count = options
        .word_count
        .unwrap_or(DEFAULT_WORDS)
        .clamp(MIN_WORDS, MAX_WORDS);
    let gps = if options.guesses_per_second > 0.0 {
        options.guesses_per_second
    } else {
        DEFAULT_GUESSES_PER_SECOND
    };

    let words: Vec<String> = (0..count).map(|_| random_word(wordlist)).collect();
    let shown: Vec<String> = if options.capitalize {
        words
            .iter()
            .map(|w| {
                let mut c = w.chars();
                match c.next() {
                    Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
                    None => String::new(),
                }
            })
            .collect()
    } else {
        words.clone()
    };
    let mut passphrase = shown.join(options.separator.as_str());
    if options.append_digit {
        passphrase.push(random_digit());
    }

    let entropy = entropy_bits(count);
    let seconds = crack_time_seconds(entropy, gps);
    Passphrase {
        words,
        passphrase,
        entropy,
        crack_time: CrackTimeEstimate { seconds, human: format_crack_time(seconds) },
    }
}

// Example (deterministic parts only):
//   entropy_bits(6)                            // 77.548875... bits
//   format_crack_time(crack_time_seconds(entropy_bits(6), 1e12))
//   // -> "3.5 millennia"
//   format_crack_time(crack_time_seconds(entropy_bits(8), 1e12))
//   // -> "2.1 billion centuries"
//   generate_passphrase(&EFF_WORDLIST, &PassphraseOptions {
//       separator: Separator::Dash, capitalize: true, append_digit: true, ..Default::default()
//   });
//   // -> passphrase "Oatmeal-Marker-Ranger-...-7", entropy 77.55

Also available in 12 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →