module NdrSupport::Password

Contains logic for checking and generating secure passwords, in line with CESG guidelines.

Public Instance Methods

generate(number_of_words: 4, separator: ' ') click to toggle source

Generates a random valid? password, using the 2048-word RFC1751 dictionary. Optionally, specify ‘number_of_words` and/or `separator`.

NdrSupport::Password.generate #=> "sill quod okay phi"
NdrSupport::Password.generate #=> "dint dale pew wane"
NdrSupport::Password.generate #=> "rent jude ding gent"

NdrSupport::Password.generate(number_of_words: 6) #=> "dad bide thee glen road beam"

NdrSupport::Password.generate(separator: '-') #=> "jail-net-skim-cup"

Raises a RuntimeError if a strong enough password was not produced:

NdrSupport::Password.generate(number_of_words: 1) #=>
  RuntimeError: Failed to generate a #valid? password!
# File lib/ndr_support/password.rb, line 41
def generate(number_of_words: 4, separator: ' ')
  attempts = 0

  loop do
    words = Array.new(number_of_words) do
      RFC1751_WORDS[SecureRandom.random_number(RFC1751_WORDS.length)].downcase
    end

    phrase = words.join(separator)
    return phrase if valid?(phrase)

    attempts += 1
    raise 'Failed to generate a #valid? password!' if attempts > 10
  end
end
valid?(string, word_list: []) click to toggle source

Is the given ‘string` deemed a good password? An additional `word_list` can be provided; its entries add only minimally when considering the strength of `string`.

NdrSupport::Password.valid?('google password')    #=> false
NdrSupport::Password.valid?(SecureRandom.hex(12)) #=> true
# File lib/ndr_support/password.rb, line 18
def valid?(string, word_list: [])
  string = prepare_string(string.to_s.dup)
  slug   = slugify(strip_common_words(string, word_list))

  meets_requirements?(slug)
end

Private Instance Methods

meets_requirements?(string) click to toggle source
# File lib/ndr_support/password.rb, line 59
def meets_requirements?(string)
  string.length >= 6 && string.chars.uniq.length >= 5
end
no_added_value?(a, b) click to toggle source
# File lib/ndr_support/password.rb, line 97
def no_added_value?(a, b)
  sequential?(a, b) || repeating?(a, b)
end
prepare_string(string) click to toggle source
# File lib/ndr_support/password.rb, line 117
def prepare_string(string)
  string.downcase
end
repeating?(a, b) click to toggle source
# File lib/ndr_support/password.rb, line 101
def repeating?(a, b)
  a == b
end
sequencible?(string) click to toggle source
# File lib/ndr_support/password.rb, line 113
def sequencible?(string)
  /\A([a-z]|[0-9])\z/i =~ string
end
sequential?(a, b, check_inverse: true) click to toggle source
# File lib/ndr_support/password.rb, line 105
def sequential?(a, b, check_inverse: true)
  # avoid non-alphanumeric characters being considered for sequencing:
  # ';'.next #=> '<'
  return false unless sequencible?(a) && sequencible?(b)

  (a.next == b && b.length == 1) || (check_inverse && sequential?(b, a, check_inverse: false))
end
slugify(string) click to toggle source
# File lib/ndr_support/password.rb, line 82
def slugify(string)
  input  = string.chars
  output = []

  until input.length.zero?
    sequence = [input.shift]
    sequence << input.shift while input.any? && no_added_value?(sequence.last, input.first)

    sequence.slice!(1..-2) # discard interior of sequence
    output.concat(sequence.uniq)
  end

  output.join
end
strip_common_words(string, common_words) click to toggle source
# File lib/ndr_support/password.rb, line 63
def strip_common_words(string, common_words)
  common_words += COMMON_PASSWORDS

  # Try the longest common words first, in case some are substrings of others:
  common_words = common_words.sort_by(&:length).reverse

  common_words.each do |common_word|
    pattern = prepare_string(common_word)

    # Don't try to remove things that #slugify will be able to remove
    # at least as effectively: [#6950#note-12]
    next if slugify(pattern).length <= 2

    string.gsub!(pattern) { |word| word.chars.first + word.chars.last }
  end

  string
end