teaser

Checking passwords for breach with a local script

This post presents a simple Python script for checking passwords against the have i been pwned? breach database. It's intended for people who don't trust typing in passwords in an online form to check them. The full version of the script also evaluates the password strength by bits of entropy.

The great HIBP people maintain among other things a database of breached passwords, that just keeps growing and growing unfortunately. It makes sense to check passwords against such a database from time to time as a safety precaution. Their website does provide a form to do so, but even with the alleged safety promises, I prefer not to type in critical passwords in a form on the web.

The password check API

Fortunately, a clever range check API is provided by HIBP that can be used directly and it preserves the secrecy of the password in a convincing way. It works like this:

  • A SHA-1 (or NTLM) hash is made of the password
  • The first 5 digits of the hash is sent to the API (by https)
  • The API responds with hashes and counts of all passwords in the database starting with those digits

Since only 5 digits of a hash is sent to the API, it is totally impossible for an eavesdropper to derive the password being checked if it is not in the database. However, should the password be in the database, it could be argued that an eavesdropper now knows that the user behind the IP might use one of the password hashes returned by the API, and these hashes could be reversed since they are of known breached passwords. But I would argue that any password in use, that was found in the database should be changed immedately, and it's better to know it than not!

Simple script

The script uses no unnecessary libraries and is short enough to be checked by the reader in a few moments (which I encourage). Here it is:

import hashlib
import urllib.request

def sha1HashString(s:str) -> str:
    hash = hashlib.sha1(s.encode())
    return hash.hexdigest().upper()

def pwnedpasswordsCheckRange(hash:str) -> int:
    hash_start = hash[:5]
    hash_end = hash[5:]
    url = 'https://api.pwnedpasswords.com/range/' + hash_start
    with urllib.request.urlopen(url) as response:
        response = response.read().decode('utf-8')
    for hit in response.split("\n"):
        if hit.startswith(hash_end): # Password found!
            count = int(hit.split(":")[1])
            return count
    return 0

if __name__ == "__main__":
    pwd = input('Type in password to check: ')
    count = pwnedpasswordsCheckRange(sha1HashString(pwd))
    print(f"The password appears {count} times in pwdnedpassword's breach database")

Here is an example of running the script in a Linux terminal:

$ python3 checkpwd.py 
Type in password to check: abc123
The password appears 4034851 times in pwdnedpassword's database
$ python3 checkpwd.py 
Type in password to check: qwerty!
The password appears 20677 times in pwdnedpassword's database

Beware that the script simply uses input for the password prompt which shows the typed in password. This might be okay when not running the script in a public place. To get a hidden password prompt, getpass may be used, see here.

Extended script evaluating password strength

Now that we are already dealing with passwords in plain text, why not evaluate the password strength by calculating bits of entropy. Here is an extended version of the script that does that. Note that the entropy estimation is quite basic and does not take things like repetitions and common words into account.

import hashlib
import urllib.request
import math
import string

def sha1HashString(s:str) -> str:
    hash = hashlib.sha1(s.encode())
    return hash.hexdigest().upper()

def pwnedpasswordsCheckRange(hash:str):
    hash_start = hash[:5]
    hash_end = hash[5:]
    url = 'https://api.pwnedpasswords.com/range/' + hash_start
    with urllib.request.urlopen(url) as response:
        response = response.read().decode('utf-8')
    for hit in response.split("\n"):
        if hit.startswith(hash_end):
            # Found!
            count = int(hit.split(":")[1])
            return count
    return 0

def estimateEntropy(password:str) -> float:
    char_set = set(password)
    char_set_size = 0
    # Add character set sizes based on the types of characters used
    if any(c in string.ascii_lowercase for c in char_set):
        char_set_size += 26
    if any(c in string.ascii_uppercase for c in char_set):
        char_set_size += 26
    if any(c in string.digits for c in char_set):
        char_set_size += 10
    if any(c in string.punctuation for c in char_set):
        char_set_size += len(string.punctuation)
    # Return a simple bits of entropy estimation of the password
    return math.log2(char_set_size ** len(password))

def evaluateEntropy(entropy:int) -> str:
    entropy_levels = {0:"very weak", 28: "weak", 36: "moderate", 60: "strong", 120: "very strong"}
    for ent_min in sorted(entropy_levels.keys(), reverse=True):
        if entropy >= ent_min:
            return entropy_levels[ent_min]

if __name__ == "__main__":
    pwd = input('Type in password to check: ')
    count = pwnedpasswordsCheckRange(sha1HashString(pwd))
    entropy = int(estimateEntropy(pwd))
    level = evaluateEntropy(entropy)
    print(f"The password has an estimated entropy of {entropy} bits ({level})")
    print(f"The password appears {count} times in pwdnedpassword's breach database")

Usage example:

$ python3 checkpwd2.py
Type in password to check: test!
The password has an estimated entropy of 29 bits (weak)
The password appears 109 times in pwdnedpassword's breach database
$ python3 checkpwd2.py 
Type in password to check: 349cv8u34jw823h
The password has an estimated entropy of 77 bits (strong)
The password appears 0 times in pwdnedpassword's breach database

I hope you enjoyed this content!

ko-fi donate

Comments

Comments powered by Talkyard