Skip to content

Passphrase Generator — Ruby 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 Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# Passphrase Generator — diceware generation + entropy scoring.
#
# Language: Ruby (3.1+, 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 uses a CSPRNG (SecureRandom) with rejection sampling, so
# every word index is uniform over the 7,776-word EFF long wordlist - no
# modulo bias.
#
# NOTE: the EFF long wordlist (7,776 words, https://www.eff.org/dice) must be
# loaded separately - it is ~70 KB of data and is deliberately not inlined
# here. Pass it to the functions as the +wordlist+ argument (Array of
# strings), e.g. loaded from eff_large_wordlist.txt with one word per line.

require 'securerandom'

module PassphraseGenerator
  CrackTimeEstimate = Struct.new(:seconds, :human, keyword_init: true)
  Passphrase = Struct.new(:words, :passphrase, :entropy, :crack_time,
                          keyword_init: true)

  # Size of the EFF long wordlist (6^5).
  EFF_WORDLIST_LENGTH = 7776

  MIN_WORDS = 3
  MAX_WORDS = 10
  DEFAULT_WORDS = 6
  DEFAULT_GUESSES_PER_SECOND = 1e12

  SEPARATORS = {
    'space' => ' ',
    'dash' => '-',
    'dot' => '.',
    'underscore' => '_',
    'none' => ''
  }.freeze

  # 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 >= 62208
  # are rejected so `v % 7776` is uniform (a plain `% 7776` would favor the
  # first 65536 % 7776 = 2048 indexes).
  WORD_LIMIT = EFF_WORDLIST_LENGTH * (65_536 / EFF_WORDLIST_LENGTH)

  YEAR = 31_557_600 # seconds (Julian year)
  SCALES = [
    [1e12, 'trillion'],
    [1e9, 'billion'],
    [1e6, 'million'],
    [1e3, 'thousand']
  ].freeze

  class << self
    # Draw one uniformly-random word from the EFF long wordlist (CSPRNG).
    def random_word(wordlist)
      v = nil
      loop do
        v = SecureRandom.random_bytes(2).unpack1('S>')
        break if v < WORD_LIMIT
      end
      wordlist[v % EFF_WORDLIST_LENGTH]
    end

    # Draw one uniformly-random digit 0-9 (CSPRNG, rejection-sampled on uint8).
    def random_digit
      v = nil
      loop do
        v = SecureRandom.random_bytes(1).getbyte(0)
        break if v < 250 # largest multiple of 10 in [0, 256)
      end
      (v % 10).to_s
    end

    # Theoretical word entropy in bits: wordCount x log2(7776).
    def entropy_bits(word_count)
      return 0 if word_count <= 0

      word_count * Math.log2(EFF_WORDLIST_LENGTH)
    end

    # Human-readable crack-time span. Uses centuries/millennia past a year,
    # then collapses to scaled words ("2.4 billion centuries") so the string
    # stays readable at diceware entropies (60-130 bits).
    def format_crack_time(seconds)
      return '-' unless seconds.finite? && seconds >= 0
      return '< 1 second' if seconds < 1
      return span(seconds, 'second') if seconds < 60
      return span(seconds / 60, 'minute') if seconds < 3600
      return span(seconds / 3600, 'hour') if seconds < 86_400
      return span(seconds / 86_400, 'day') if seconds < YEAR

      years = seconds / YEAR
      return span(years, 'year') if years < 100
      return span(years / 100, 'century', 'centuries') if years < 1000
      return span(years / 1000, 'millennium', 'millennia') if years < 1e6

      # years >= 1e6 here, so centuries >= 10,000: collapse to a scaled plural.
      centuries = years / 100
      factor, name = SCALES.find { |f, _name| centuries >= f }
      "#{locale(round(centuries / factor))} #{name} centuries"
    end

    # Average time to crack (seconds) = 2^(bits-1) / guessesPerSecond.
    def crack_time_seconds(bits, guesses_per_second)
      2**(bits - 1) / guesses_per_second
    end

    # Generate a diceware passphrase from the EFF long wordlist (+wordlist+,
    # see the header note). Options (all optional):
    #   word_count:, separator:, capitalize:, append_digit:,
    #   guesses_per_second: (attack speed for the estimate; 1e12 default).
    def generate_passphrase(wordlist, options = {})
      raw = options[:word_count] || DEFAULT_WORDS
      word_count = raw.is_a?(Numeric) ? raw.round : DEFAULT_WORDS
      word_count = word_count.clamp(MIN_WORDS, MAX_WORDS)
      separator = SEPARATORS[options[:separator] || 'space']
      capitalize = options.fetch(:capitalize, false)
      append_digit = options.fetch(:append_digit, false)
      gps = options[:guesses_per_second] || DEFAULT_GUESSES_PER_SECOND

      words = Array.new(word_count) { random_word(wordlist) }
      shown = capitalize ? words.map { |w| cap(w) } : words
      passphrase = shown.join(separator)
      passphrase += random_digit if append_digit

      entropy = entropy_bits(word_count)
      seconds = crack_time_seconds(entropy, gps)
      Passphrase.new(words: words, passphrase: passphrase, entropy: entropy,
                     crack_time: CrackTimeEstimate.new(seconds: seconds,
                                                       human: format_crack_time(seconds)))
    end

    private

    # Round + pluralize `v unit`; 1 decimal below 10, whole numbers above.
    def span(v, singular, plural = "#{singular}s")
      n = round(v)
      "#{locale(n)} #{n == 1 ? singular : plural}"
    end

    def round(v)
      v >= 10 ? v.round : (v * 10).round / 10.0
    end

    # Thousands-grouped integer rendering (toLocaleString equivalent); floats
    # below 1000 keep their single decimal.
    def locale(n)
      i = n.to_i
      return n.to_s if n != i && i < 1000

      i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
    end

    def cap(w)
      w[0].upcase + w[1..].to_s
    end
  end
end

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 →