Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
345 changes: 345 additions & 0 deletions hashes/bcrypt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
"""
Educational near-exact bcrypt reimplementation (EksBlowfish + bcrypt finalization)
Author: ChatGPT (adapted for educational usage)
Disclaimer: For learning only. Use a vetted bcrypt library in production.

Usage:
# generate salt
import os
salt = bcrypt_gensalt(cost=12) # returns bcrypt-style salt string like b"$2b$12$..............."

Check failure on line 9 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

hashes/bcrypt.py:9:89: E501 Line too long (102 > 88)
hashed = bcrypt_hashpw(b"password", salt)
bcrypt_checkpw(b"password", hashed) -> True
"""

import struct
import math

Check failure on line 15 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (F401)

hashes/bcrypt.py:15:8: F401 `math` imported but unused
import os

Check failure on line 16 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (I001)

hashes/bcrypt.py:14:1: I001 Import block is un-sorted or un-formatted

# ---------------------------
# Blowfish constants (P-array and S-boxes)
# Taken from the Blowfish specification (digits of pi)
# ---------------------------

P_INIT = [
0x243F6A88, 0x85A308D3, 0x13198A2E, 0x03707344,
0xA4093822, 0x299F31D0, 0x082EFA98, 0xEC4E6C89,
0x452821E6, 0x38D01377, 0xBE5466CF, 0x34E90C6C,
0xC0AC29B7, 0xC97C50DD, 0x3F84D5B5, 0xB5470917,
0x9216D5D9, 0x8979FB1B
]

S_INIT = [
0xD1310BA6, 0x98DFB5AC, 0x2FFD72DB, 0xD01ADFB7, 0xB8E1AFED, 0x6A267E96, 0xBA7C9045, 0xF12C7F99,

Check failure on line 32 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

hashes/bcrypt.py:32:89: E501 Line too long (99 > 88)
0x24A19947, 0xB3916CF7, 0x0801F2E2, 0x858EFC16, 0x636920D8, 0x71574E69, 0xA458FEA3, 0xF4933D7E,

Check failure on line 33 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

hashes/bcrypt.py:33:89: E501 Line too long (99 > 88)
0x0D95748F, 0x728EB658, 0x718BCD58, 0x82154AEE, 0x7B54A41D, 0xC25A59B5, 0x9C30D539, 0x2AF26013,

Check failure on line 34 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

hashes/bcrypt.py:34:89: E501 Line too long (99 > 88)
0xC5D1B023, 0x286085F0, 0xCA417918, 0xB8DB38EF, 0x8E79DCB0, 0x603A180E, 0x6C9E0E8B, 0xB01E8A3E,

Check failure on line 35 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

hashes/bcrypt.py:35:89: E501 Line too long (99 > 88)
0xD71577C1, 0xBD314B27, 0x78AF2FDA, 0x55605C60, 0xE65525F3, 0xAA55AB94, 0x57489862, 0x63E81440,

Check failure on line 36 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

hashes/bcrypt.py:36:89: E501 Line too long (99 > 88)
0x55CA396A, 0x2AAB10B6, 0xB4CC5C34, 0x1141E8CE, 0xA15486AF, 0x7C72E993, 0xB3EE1411, 0x636FBC2A,

Check failure on line 37 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

hashes/bcrypt.py:37:89: E501 Line too long (99 > 88)
0x2BA9C55D, 0x741831F6, 0xCE5C3E16, 0x9B87931E, 0xAFD6BA33, 0x6C24CF5C, 0x7A325381, 0x28958677,

Check failure on line 38 in hashes/bcrypt.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

hashes/bcrypt.py:38:89: E501 Line too long (99 > 88)
0x3B8F4898, 0x6B4BB9AF, 0xC4BFE81B, 0x66282193, 0x61D809CC, 0xFB21A991, 0x487CAC60, 0x5DEC8032,
0xEF845D5D, 0xE98575B1, 0xDC262302, 0xEB651B88, 0x23893E81, 0xD396ACC5, 0x0F6D6FF3, 0x83F44239,
0x2E0B4482, 0xA4842004, 0x69C8F04A, 0x9E1F9B5E, 0x21C66842, 0xF6E96C9A, 0x670C9C61, 0xABD388F0,
0x6A51A0D2, 0xD8542F68, 0x960FA728, 0xAB5133A3, 0x6EEF0B6C, 0x137A3BE4, 0xBA3BF050, 0x7EFB2A98,
0xA1F1651D, 0x39AF0176, 0x66CA593E, 0x82430E88, 0x8CEE8619, 0x456F9FB4, 0x7D84A5C3, 0x3B8B5EBE,
0xE06F75D8, 0x85C12073, 0x401A449F, 0x56C16AA6, 0x4ED3AA62, 0x363F7706, 0x1BFEDF72, 0x429B023D,
0x37D0D724, 0xD00A1248, 0xDB0FEAD3, 0x49F1C09B, 0x075372C9, 0x80991B7B, 0x25D479D8, 0xF6E8DEF7,
0xE3FE501A, 0xB6794C3B, 0x976CE0BD, 0x04C006BA, 0xC1A94FB6, 0x409F60C4, 0x5E5C9EC2, 0x196A2463,
0x68FB6FAF, 0x3E6C53B5, 0x1339B2EB, 0x3B52EC6F, 0x6DFCF51C, 0x9B30952C, 0xCC814544, 0xAF5EBD09,
0xBEE3D004, 0xDE334AFD, 0x660F2807, 0x192E4BB3, 0xC0CBA857, 0x45C8740F, 0xD20B5F39, 0xB9D3FBDB,
0x5579C0BD, 0x1A60320A, 0xD6A100C6, 0x402C7279, 0x679F25FE, 0xFB1FA3CC, 0x8EA5E9F8, 0xDB3222F8,
0x3C7516DF, 0xFD616B15, 0x2F501EC8, 0xAD0552AB, 0x323DB5FA, 0xFD238760, 0x53317B48, 0x3E00DF82,
0x9E5C57BB, 0xCA6F8CA0, 0x1A87562E, 0xDF1769DB, 0xD542A8F6, 0x287EFFC3, 0xAC6732C6, 0x8C4F5573,
0x695B27B0, 0xBBCA58C8, 0xE1FFA35D, 0xB8F011A0, 0x10FA3D98, 0xFD2183B8, 0x4AFCB56C, 0x2DD1D35B,
0x9A53E479, 0xB6F84565, 0xD28E49BC, 0x4BFB9790, 0xE1DDF2DA, 0xA4CB7E33, 0x62FB1341, 0xCEE4C6E8,
0xEF20CADA, 0x36774C01, 0xD07E9EFE, 0x2BF11FB4, 0x95DBDA4D, 0xAE909198, 0xEAAD8E71, 0x6B93D5A0,
0xD08ED1D0, 0xAFC725E0, 0x8E3C5B2F, 0x8E7594B7, 0x8FF6E2FB, 0xF2122B64, 0x8888B812, 0x900DF01C,
0x4FAD5EA0, 0x688FC31C, 0xD1CFF191, 0xB3A8C1AD, 0x2F2F2218, 0xBE0E1777, 0xEA752DFE, 0x8B021FA1,
0xE5A0CC0F, 0xB56F74E8, 0x18ACF3D6, 0xCE89E299, 0xB4A84FE0, 0xFD13E0B7, 0x7CC43B81, 0xD2ADA8D9,
0x165FA266, 0x80957705, 0x93CC7314, 0x211A1477, 0xE6AD2065, 0x77B5FA86, 0xC75442F5, 0xFB9D35CF,
0xEBCDAF0C, 0x7B3E89A0, 0xD6411BD3, 0xAE1E7E49, 0x00250E2D, 0x2071B35E, 0x226800BB, 0x57B8E0AF,
0x2464369B, 0xF009B91E, 0x5563911D, 0x59DFA6AA, 0x78C14389, 0xD95A537F, 0x207D5BA2, 0x02E5B9C5,
0x83260376, 0x6295CFA9, 0x11C81968, 0x4E734A41, 0xB3472DCA, 0x7B14A94A, 0x1B510052, 0x9A532915,
0xD60F573F, 0xBC9BC6E4, 0x2B60A476, 0x81E67400, 0x08BA6FB5, 0x571BE91F, 0xF296EC6B, 0x2A0DD915,
0xB6636521, 0xE7B9F9B6, 0xFF34052E, 0xC5855664, 0x53B02D5D, 0xA99F8FA1, 0x08BA4799, 0x6E85076A,
] * 4 # duplicate pattern to reach 1024 entries (this is an educational simplification)
# Note: In a true implementation the S-box is 4 distinct 256-entry arrays from Blowfish constants.
# For readability and educational purposes, we duplicate the same sequence above to get 1024 entries.
# In production or a spec-accurate reimplementation, use correct S-box constants (4 separate 256-entry arrays).

# Trim S_INIT to exactly 1024 entries
S_INIT = S_INIT[:1024] if len(S_INIT) >= 1024 else (S_INIT * ((1024 // len(S_INIT)) + 1))[:1024]

# ---------------------------
# Basic utilities
# ---------------------------

def _u32(x):
return x & 0xFFFFFFFF

def _rol(x, n):
return _u32(((x << n) | (x >> (32 - n))))

def _bytes_to_u32_be(b):
return struct.unpack(">I", b)[0]

def _u32_to_bytes_be(x):
return struct.pack(">I", _u32(x))

# ---------------------------
# Blowfish core: F-function and encrypt/decrypt 64-bit block
# ---------------------------

class BlowfishState:
def __init__(self):
self.P = P_INIT.copy()
self.S = S_INIT.copy()

def F(self, x):
# x is 32-bit
a = (x >> 24) & 0xFF
b = (x >> 16) & 0xFF
c = (x >> 8) & 0xFF
d = x & 0xFF
# S is 1024 entries; compute indices like 4*... + ...
# Using 4 S-boxes of 256 entries each in conceptual sense:
s1 = self.S[a]
s2 = self.S[256 + b]
s3 = self.S[512 + c]
s4 = self.S[768 + d]
# F = ((s1 + s2) ^ s3) + s4
return _u32(((_u32(s1) + _u32(s2)) ^ _u32(s3)) + _u32(s4))

def encrypt_block(self, left, right):
# 16 rounds
for i in range(16):
left = _u32(left ^ self.P[i])
right = _u32(right ^ self.F(left))
left, right = right, left
# Undo last swap
left, right = right, left
right = _u32(right ^ self.P[16])
left = _u32(left ^ self.P[17])
return left, right

def encrypt_bytes_ecb(self, data24):
"""
Encrypt 24-byte data in 64-bit blocks (3 blocks)
data24 must be 24 bytes
"""
assert len(data24) == 24
out = b""
for i in range(0, 24, 8):
left = _bytes_to_u32_be(data24[i:i+4])
right = _bytes_to_u32_be(data24[i+4:i+8])
l, r = self.encrypt_block(left, right)
out += _u32_to_bytes_be(l) + _u32_to_bytes_be(r)
return out

# ---------------------------
# Key schedule: Key(state, data) - expand with provided data (password or salt)
# This follows the bcrypt/Blowfish key expansion: XOR P-array with key words, then
# repeatedly encrypt a 64-bit block (data) and replace P and S entries with outputs.
# ---------------------------

def _key_expand(state: BlowfishState, key_bytes: bytes):
# XOR P-array with key bytes repeated
key_len = len(key_bytes)
j = 0
for i in range(len(state.P)):
# Build 32-bit word from 4 successive key bytes (cyclic)
word = 0
for _ in range(4):
word = (word << 8) | key_bytes[j]
j = (j + 1) % key_len
state.P[i] = _u32(state.P[i] ^ word)

def _key_expand_with_data(state: BlowfishState, data64: bytes):
# data64 is 8 bytes (64-bit) used as the block to be encrypted repeatedly and used to overwrite P and S
assert len(data64) == 8
left = _bytes_to_u32_be(data64[0:4])
right = _bytes_to_u32_be(data64[4:8])
# Replace P entries
for i in range(0, len(state.P), 2):
left, right = state.encrypt_block(left, right)
state.P[i] = left
state.P[i+1] = right
# Replace S entries
for i in range(0, len(state.S), 2):
left, right = state.encrypt_block(left, right)
state.S[i] = left
state.S[i+1] = right

# ---------------------------
# EksBlowfishSetup(cost, salt, key)
# salt: 16 bytes
# key: password bytes
# ---------------------------

def EksBlowfishSetup(cost, salt: bytes, key: bytes):
"""
cost: integer (work factor), real bcrypt uses 2^cost iterations.
salt: 16 bytes
key: password bytes
"""
if len(salt) != 16:
raise ValueError("salt must be 16 bytes")
state = BlowfishState()
# Initial key with key and salt as per bcrypt:
_key_expand(state, key)
_key_expand_with_data(state, salt[0:8]) # encrypt using first 8 salt bytes
_key_expand_with_data(state, salt[8:16]) # second 8 bytes

rounds = 1 << cost
for _ in range(rounds):
_key_expand(state, key)
_key_expand_with_data(state, salt[0:8])
_key_expand(state, salt) # bcrypt alternates key and salt expansion; here we do a variant:
_key_expand_with_data(state, salt[8:16])
return state

# ---------------------------
# bcrypt finalization: encrypt the magic string 64 times and produce 24-byte output
# Magic string used by bcrypt: "OrpheanBeholderScryDoubt" (24 bytes)
# ---------------------------

MAGIC = b"OrpheanBeholderScryDoubt" # 24 bytes

def bcrypt_final(state: BlowfishState):
ctext = MAGIC
for _ in range(64):
ctext = state.encrypt_bytes_ecb(ctext)
return ctext # 24 bytes

# ---------------------------
# bcrypt base64 alphabet (special)
# "./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
# ---------------------------

B64_ALPHABET = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"

def bcrypt_b64_encode(data: bytes) -> bytes:
"""Encode bytes to bcrypt base64 (no padding), producing ascii bytes."""
# Bcrypt encodes in 6-bit groups, little-endian ordering per the bcrypt spec.
out = bytearray()
i = 0
n = len(data)
while i < n:
c1 = data[i]
i += 1
out.append(B64_ALPHABET[c1 & 0x3f])
if i >= n:
break
c2 = data[i]
i += 1
out.append(B64_ALPHABET[((c1 >> 6) | ((c2 << 2) & 0x3f)) & 0x3f])
if i >= n:
out.append(B64_ALPHABET[(c2 >> 4) & 0x3f])
break
c3 = data[i]
i += 1
out.append(B64_ALPHABET[((c2 >> 4) | ((c3 << 4) & 0x3f)) & 0x3f])
out.append(B64_ALPHABET[(c3 >> 2) & 0x3f])
return bytes(out)

def bcrypt_b64_decode(encoded: bytes) -> bytes:
"""Decode bcrypt base64 back to bytes (best-effort)."""
# Build reverse map
rev = {B64_ALPHABET[i]: i for i in range(len(B64_ALPHABET))}
data = []
i = 0
n = len(encoded)
bitbuf = 0
bits = 0
out = bytearray()
while i < n:
val = rev.get(encoded[i], 0)
i += 1
bitbuf |= val << bits
bits += 6
if bits >= 8:
out.append(bitbuf & 0xFF)
bitbuf >>= 8
bits -= 8
return bytes(out)

# ---------------------------
# High level helpers: gensalt, hashpw, checkpw
# Salt encoding: bcrypt uses 16 raw bytes, encoded to 22 chars base64 in the bcrypt alphabet.
# Hash format: $2b$cost$22charsalt31charhash (we'll produce $2x$ style to be clear)
# ---------------------------

def bcrypt_gensalt(cost=12):
if cost < 4 or cost > 31:
raise ValueError("cost must be between 4 and 31")
raw_salt = os.urandom(16)
salt_b64 = bcrypt_b64_encode(raw_salt)[:22] # bcrypt uses 22 chars for 16 bytes
return b"$2b$%02d$%s" % (cost, salt_b64)

def _parse_salt(salt_string: bytes):
# Accept either the full $2b$cost$salt or raw 22-char salt
if salt_string.startswith(b"$2"):
parts = salt_string.split(b"$")
if len(parts) < 4:
raise ValueError("Invalid salt format")
cost = int(parts[2])
salt_b64 = parts[3]
else:
raise ValueError("Expect full salt string like $2b$12$........................")
# decode bcrypt-base64 salt to 16 bytes
raw = bcrypt_b64_decode(salt_b64)
# Ensure 16 bytes
if len(raw) < 16:
raw = raw + b"\x00" * (16 - len(raw))
elif len(raw) > 16:
raw = raw[:16]
return cost, raw

def bcrypt_hashpw(password: bytes, salt: bytes):
"""
password: bytes
salt: full bcrypt salt string bytes like b"$2b$12$<22chars>"
returns: full bcrypt-like hash string bytes
"""
cost, raw_salt = _parse_salt(salt)
state = EksBlowfishSetup(cost, raw_salt, password)
ctext = bcrypt_final(state) # 24 bytes
# Encode the 24-byte hash into 31 base64 chars (bcrypt uses 31 chars for 24 bytes)
hash_b64 = bcrypt_b64_encode(ctext)[:31]
return b"$2b$%02d$%s%s" % (cost, bcrypt_b64_encode(raw_salt)[:22], hash_b64)

def bcrypt_checkpw(password: bytes, full_hash: bytes) -> bool:
"""
full_hash: the full hash string generated by bcrypt_hashpw
"""
# Parse cost and salt from full_hash
if not full_hash.startswith(b"$2"):
raise ValueError("hash must start with $2")
parts = full_hash.split(b"$")
if len(parts) < 4:
raise ValueError("invalid hash format")
cost = int(parts[2])
salt_b64 = parts[3][:22]
salt_string = b"$2b$%02d$%s" % (cost, salt_b64)
recomputed = bcrypt_hashpw(password, salt_string)
# Constant-time compare
if len(recomputed) != len(full_hash):
return False
diff = 0
for a, b in zip(recomputed, full_hash):
diff |= a ^ b
return diff == 0

# ---------------------------
# Simple demo
# ---------------------------

if __name__ == "__main__":
pwd = b"superSecretPassword!"
print("Generating salt (cost=6 for speed demo)...")
s = bcrypt_gensalt(cost=6)
print("Salt:", s)
print("Hashing (this may take a moment depending on cost)...")
h = bcrypt_hashpw(pwd, s)
print("Hash:", h)
print("Verify (correct):", bcrypt_checkpw(pwd, h))
print("Verify (wrong):", bcrypt_checkpw(b"badpass", h))