Password strength meter
XKCD-compatible
password strength meter in Python. Tr0ub4dor&3
has approx
28 bits of entropy and correcthorsebatterystaple
has approx 44.
Last modified | |
Lines | 234 |
Parent directory Download CGIread sitemap Main page
Quick links: __missing__ get_transformations get_word_list listdict main measure measure_word
#!/usr/bin/python
import math
import string
import sys
def get_transformations():
'''
Return two dictionaries for substring substitution: reverse, forward
`reverse` will map passwordish input to possible lowercase outputs.
`forward` will map lowercase input to possible passwordish outputs.
Ex:
reverse['$'] -> ['s']
forward['s'] -> ['S', 's', 'Z', 'z', '$']
'''
class listdict(dict):
def __missing__(self, key):
self[key] = []
return self[key]
# Dictionary of substring subsition
transformations = listdict({
"ate": ["8",],
"for": ["4",],
"to": ["2",],
"too": ["2",],
"a": ["4", "@", "/-\\", "^",],
"c": ["k",],
"e": ["3",],
"i": ["1", "l", "|",],
"l": ["1", "l", "|",],
"o": ["0",],
"s": ["$", "z", "Z", "5",],
"t": ["7",],
"z": ["s", "S", "2",],
})
for key in range(ord("a"), ord("z")+1):
transformations[chr(key)].append(chr(key))
transformations[chr(key)].append(chr(key).upper())
reverse_lookup = listdict()
for key in transformations:
for value in transformations[key]:
reverse_lookup[value].append(key)
return dict(reverse_lookup), dict(transformations)
def get_word_list():
'''
Return a list of lowercase English words that are long enough.
'''
s = open('/usr/share/dict/words').read() + 'troubador'
words = set()
for line in s.split('\n'):
if len(line) > 3 and "'" not in line:
words.add(line.lower())
return words
def measure_word(password, comb=1, word_list=None):
'''
Decide if `password` begins with an English word.
Return: None
Password does not begin with an English word.
Return: bits, remaining_password
Password does begin with an English word.
`bits` is the entropy of the measuered part of the password.
`remaining_password` is the unmeasured part (after the word).
'''
BITS_PER_CHAR = 1.9
BONUS_BITS = 1.0
if word_list is None:
word_list = get_word_list()
candidate = None
for key in sorted(reverse, key=lambda x: len(x), reverse=True):
if password.startswith(key):
for value in reverse[key]:
new_word_list = []
for word in word_list:
if word == value:
# Must set to variable
# Ex. "hello" would otherwise match "hell"
# because "l" is in the word list, but so is "lo" which does
# not match "l".
# Set the variable but keep searching. In the next level,
# "o" is in the word list and that's the time to return.
candidate = math.log(comb * 2**BONUS_BITS, 2), password[len(key):]
if word.startswith(value):
new_word_list.append(word[len(value):])
if new_word_list:
if key == value:
comb_factor = 1
else:
comb_factor = len(forward[value])
buf = measure_word(
password[len(key):],
comb * comb_factor * 2**BITS_PER_CHAR,
new_word_list
)
if buf is not None:
return buf
else:
# No better match has been found.
return candidate
def measure(password):
'''
Return how many bits of entropy there are in the password.
'''
MAGIC_FACTOR = 0.6
bits = 0
misc_chars = ''
words_cache = {}
chars_cache = set()
while password:
# Check if it is known to not be a word.
for char in chars_cache:
if password[0] == char:
password = password[1:]
misc_chars += char
break
else:
# Check if it is known to be a word.
for word in words_cache:
if password.startswith(word):
password = password[len(word):]
words_cache[word] += 1
break
else:
# Not cached, measure word.
tmp = measure_word(password)
if tmp is not None:
wordlen = len(password) - len(tmp[1])
word = password[:wordlen]
words_cache[word] = 1
# First instance of word.
bits += tmp[0]
password = tmp[1]
else:
# Not a word.
misc_chars += password[0]
chars_cache.add(password[0])
password = password[1:]
# Repeated words.
for key in words_cache:
bits += math.log(words_cache[key], 2)
# Deal with misc_chars.
if misc_chars:
types = {
"upper": string.uppercase,
"lower": string.lowercase,
"digits": string.digits,
"punctuation": string.punctuation,
}
# 26 + 26 + 10 + something
expected_set = 0
for key in types:
value = filter(lambda x: x in types[key], misc_chars)
if value:
expected_set += len(types[key])
if expected_set == 0:
expected_set = 128
# Decide whether the miscellaneous characters are random or not.
poor = False
if len(misc_chars) > expected_set:
for ch in set(misc_chars):
if float(misc_chars.count(ch))/len(misc_chars) > 2.0/expected_set:
poor = True
break
else:
if float(len(misc_chars))/len(set(misc_chars)) > 2.0:
poor = True
if poor:
bits += (
# Repetitive
math.log(len(misc_chars))
# Predictable set
* math.log(len(set(misc_chars)), 2)
# xkcd factor for "Tr0ub4dor&3"
* MAGIC_FACTOR
# Some credit for at least trying
+ 4
)
else:
bits += len(misc_chars) * math.log(expected_set, 2) * MAGIC_FACTOR
return bits
def main():
messages = {
10: "Are you kidding me?",
20: "Vulnerable",
30: "Can't you do better than that?",
40: "Almost good enough",
50: "Good enough",
60: "Good",
70: "Very good",
80: "Secure",
90: "Awesome",
}
try:
while True:
sys.stderr.write('\nMeasure password strength: ')
sys.stderr.flush()
line = sys.stdin.readline()
if not line:
break
bits = measure(line.rstrip('\n'))
sys.stdout.write('~{} bits: '.format(int(bits + 0.5)))
for level in sorted(messages):
if level > bits:
sys.stdout.write(messages[level] + '\n')
break
else:
sys.stdout.write('Speachless\n\n')
except KeyboardInterrupt:
pass
sys.stderr.write('\n')
reverse, forward = get_transformations()
if __name__ == '__main__':
main()