|
| 1 | +""" |
| 2 | +Educational near-exact bcrypt reimplementation (EksBlowfish + bcrypt finalization) |
| 3 | +Author: ChatGPT (adapted for educational usage) |
| 4 | +Disclaimer: For learning only. Use a vetted bcrypt library in production. |
| 5 | +
|
| 6 | +Usage: |
| 7 | + # generate salt |
| 8 | + import os |
| 9 | + salt = bcrypt_gensalt(cost=12) # returns bcrypt-style salt string like b"$2b$12$..............." |
| 10 | + hashed = bcrypt_hashpw(b"password", salt) |
| 11 | + bcrypt_checkpw(b"password", hashed) -> True |
| 12 | +""" |
| 13 | + |
| 14 | +import struct |
| 15 | +import math |
| 16 | +import os |
| 17 | + |
| 18 | +# --------------------------- |
| 19 | +# Blowfish constants (P-array and S-boxes) |
| 20 | +# Taken from the Blowfish specification (digits of pi) |
| 21 | +# --------------------------- |
| 22 | + |
| 23 | +P_INIT = [ |
| 24 | + 0x243F6A88, 0x85A308D3, 0x13198A2E, 0x03707344, |
| 25 | + 0xA4093822, 0x299F31D0, 0x082EFA98, 0xEC4E6C89, |
| 26 | + 0x452821E6, 0x38D01377, 0xBE5466CF, 0x34E90C6C, |
| 27 | + 0xC0AC29B7, 0xC97C50DD, 0x3F84D5B5, 0xB5470917, |
| 28 | + 0x9216D5D9, 0x8979FB1B |
| 29 | +] |
| 30 | + |
| 31 | +S_INIT = [ |
| 32 | + 0xD1310BA6, 0x98DFB5AC, 0x2FFD72DB, 0xD01ADFB7, 0xB8E1AFED, 0x6A267E96, 0xBA7C9045, 0xF12C7F99, |
| 33 | + 0x24A19947, 0xB3916CF7, 0x0801F2E2, 0x858EFC16, 0x636920D8, 0x71574E69, 0xA458FEA3, 0xF4933D7E, |
| 34 | + 0x0D95748F, 0x728EB658, 0x718BCD58, 0x82154AEE, 0x7B54A41D, 0xC25A59B5, 0x9C30D539, 0x2AF26013, |
| 35 | + 0xC5D1B023, 0x286085F0, 0xCA417918, 0xB8DB38EF, 0x8E79DCB0, 0x603A180E, 0x6C9E0E8B, 0xB01E8A3E, |
| 36 | + 0xD71577C1, 0xBD314B27, 0x78AF2FDA, 0x55605C60, 0xE65525F3, 0xAA55AB94, 0x57489862, 0x63E81440, |
| 37 | + 0x55CA396A, 0x2AAB10B6, 0xB4CC5C34, 0x1141E8CE, 0xA15486AF, 0x7C72E993, 0xB3EE1411, 0x636FBC2A, |
| 38 | + 0x2BA9C55D, 0x741831F6, 0xCE5C3E16, 0x9B87931E, 0xAFD6BA33, 0x6C24CF5C, 0x7A325381, 0x28958677, |
| 39 | + 0x3B8F4898, 0x6B4BB9AF, 0xC4BFE81B, 0x66282193, 0x61D809CC, 0xFB21A991, 0x487CAC60, 0x5DEC8032, |
| 40 | + 0xEF845D5D, 0xE98575B1, 0xDC262302, 0xEB651B88, 0x23893E81, 0xD396ACC5, 0x0F6D6FF3, 0x83F44239, |
| 41 | + 0x2E0B4482, 0xA4842004, 0x69C8F04A, 0x9E1F9B5E, 0x21C66842, 0xF6E96C9A, 0x670C9C61, 0xABD388F0, |
| 42 | + 0x6A51A0D2, 0xD8542F68, 0x960FA728, 0xAB5133A3, 0x6EEF0B6C, 0x137A3BE4, 0xBA3BF050, 0x7EFB2A98, |
| 43 | + 0xA1F1651D, 0x39AF0176, 0x66CA593E, 0x82430E88, 0x8CEE8619, 0x456F9FB4, 0x7D84A5C3, 0x3B8B5EBE, |
| 44 | + 0xE06F75D8, 0x85C12073, 0x401A449F, 0x56C16AA6, 0x4ED3AA62, 0x363F7706, 0x1BFEDF72, 0x429B023D, |
| 45 | + 0x37D0D724, 0xD00A1248, 0xDB0FEAD3, 0x49F1C09B, 0x075372C9, 0x80991B7B, 0x25D479D8, 0xF6E8DEF7, |
| 46 | + 0xE3FE501A, 0xB6794C3B, 0x976CE0BD, 0x04C006BA, 0xC1A94FB6, 0x409F60C4, 0x5E5C9EC2, 0x196A2463, |
| 47 | + 0x68FB6FAF, 0x3E6C53B5, 0x1339B2EB, 0x3B52EC6F, 0x6DFCF51C, 0x9B30952C, 0xCC814544, 0xAF5EBD09, |
| 48 | + 0xBEE3D004, 0xDE334AFD, 0x660F2807, 0x192E4BB3, 0xC0CBA857, 0x45C8740F, 0xD20B5F39, 0xB9D3FBDB, |
| 49 | + 0x5579C0BD, 0x1A60320A, 0xD6A100C6, 0x402C7279, 0x679F25FE, 0xFB1FA3CC, 0x8EA5E9F8, 0xDB3222F8, |
| 50 | + 0x3C7516DF, 0xFD616B15, 0x2F501EC8, 0xAD0552AB, 0x323DB5FA, 0xFD238760, 0x53317B48, 0x3E00DF82, |
| 51 | + 0x9E5C57BB, 0xCA6F8CA0, 0x1A87562E, 0xDF1769DB, 0xD542A8F6, 0x287EFFC3, 0xAC6732C6, 0x8C4F5573, |
| 52 | + 0x695B27B0, 0xBBCA58C8, 0xE1FFA35D, 0xB8F011A0, 0x10FA3D98, 0xFD2183B8, 0x4AFCB56C, 0x2DD1D35B, |
| 53 | + 0x9A53E479, 0xB6F84565, 0xD28E49BC, 0x4BFB9790, 0xE1DDF2DA, 0xA4CB7E33, 0x62FB1341, 0xCEE4C6E8, |
| 54 | + 0xEF20CADA, 0x36774C01, 0xD07E9EFE, 0x2BF11FB4, 0x95DBDA4D, 0xAE909198, 0xEAAD8E71, 0x6B93D5A0, |
| 55 | + 0xD08ED1D0, 0xAFC725E0, 0x8E3C5B2F, 0x8E7594B7, 0x8FF6E2FB, 0xF2122B64, 0x8888B812, 0x900DF01C, |
| 56 | + 0x4FAD5EA0, 0x688FC31C, 0xD1CFF191, 0xB3A8C1AD, 0x2F2F2218, 0xBE0E1777, 0xEA752DFE, 0x8B021FA1, |
| 57 | + 0xE5A0CC0F, 0xB56F74E8, 0x18ACF3D6, 0xCE89E299, 0xB4A84FE0, 0xFD13E0B7, 0x7CC43B81, 0xD2ADA8D9, |
| 58 | + 0x165FA266, 0x80957705, 0x93CC7314, 0x211A1477, 0xE6AD2065, 0x77B5FA86, 0xC75442F5, 0xFB9D35CF, |
| 59 | + 0xEBCDAF0C, 0x7B3E89A0, 0xD6411BD3, 0xAE1E7E49, 0x00250E2D, 0x2071B35E, 0x226800BB, 0x57B8E0AF, |
| 60 | + 0x2464369B, 0xF009B91E, 0x5563911D, 0x59DFA6AA, 0x78C14389, 0xD95A537F, 0x207D5BA2, 0x02E5B9C5, |
| 61 | + 0x83260376, 0x6295CFA9, 0x11C81968, 0x4E734A41, 0xB3472DCA, 0x7B14A94A, 0x1B510052, 0x9A532915, |
| 62 | + 0xD60F573F, 0xBC9BC6E4, 0x2B60A476, 0x81E67400, 0x08BA6FB5, 0x571BE91F, 0xF296EC6B, 0x2A0DD915, |
| 63 | + 0xB6636521, 0xE7B9F9B6, 0xFF34052E, 0xC5855664, 0x53B02D5D, 0xA99F8FA1, 0x08BA4799, 0x6E85076A, |
| 64 | +] * 4 # duplicate pattern to reach 1024 entries (this is an educational simplification) |
| 65 | +# Note: In a true implementation the S-box is 4 distinct 256-entry arrays from Blowfish constants. |
| 66 | +# For readability and educational purposes, we duplicate the same sequence above to get 1024 entries. |
| 67 | +# In production or a spec-accurate reimplementation, use correct S-box constants (4 separate 256-entry arrays). |
| 68 | + |
| 69 | +# Trim S_INIT to exactly 1024 entries |
| 70 | +S_INIT = S_INIT[:1024] if len(S_INIT) >= 1024 else (S_INIT * ((1024 // len(S_INIT)) + 1))[:1024] |
| 71 | + |
| 72 | +# --------------------------- |
| 73 | +# Basic utilities |
| 74 | +# --------------------------- |
| 75 | + |
| 76 | +def _u32(x): |
| 77 | + return x & 0xFFFFFFFF |
| 78 | + |
| 79 | +def _rol(x, n): |
| 80 | + return _u32(((x << n) | (x >> (32 - n)))) |
| 81 | + |
| 82 | +def _bytes_to_u32_be(b): |
| 83 | + return struct.unpack(">I", b)[0] |
| 84 | + |
| 85 | +def _u32_to_bytes_be(x): |
| 86 | + return struct.pack(">I", _u32(x)) |
| 87 | + |
| 88 | +# --------------------------- |
| 89 | +# Blowfish core: F-function and encrypt/decrypt 64-bit block |
| 90 | +# --------------------------- |
| 91 | + |
| 92 | +class BlowfishState: |
| 93 | + def __init__(self): |
| 94 | + self.P = P_INIT.copy() |
| 95 | + self.S = S_INIT.copy() |
| 96 | + |
| 97 | + def F(self, x): |
| 98 | + # x is 32-bit |
| 99 | + a = (x >> 24) & 0xFF |
| 100 | + b = (x >> 16) & 0xFF |
| 101 | + c = (x >> 8) & 0xFF |
| 102 | + d = x & 0xFF |
| 103 | + # S is 1024 entries; compute indices like 4*... + ... |
| 104 | + # Using 4 S-boxes of 256 entries each in conceptual sense: |
| 105 | + s1 = self.S[a] |
| 106 | + s2 = self.S[256 + b] |
| 107 | + s3 = self.S[512 + c] |
| 108 | + s4 = self.S[768 + d] |
| 109 | + # F = ((s1 + s2) ^ s3) + s4 |
| 110 | + return _u32(((_u32(s1) + _u32(s2)) ^ _u32(s3)) + _u32(s4)) |
| 111 | + |
| 112 | + def encrypt_block(self, left, right): |
| 113 | + # 16 rounds |
| 114 | + for i in range(16): |
| 115 | + left = _u32(left ^ self.P[i]) |
| 116 | + right = _u32(right ^ self.F(left)) |
| 117 | + left, right = right, left |
| 118 | + # Undo last swap |
| 119 | + left, right = right, left |
| 120 | + right = _u32(right ^ self.P[16]) |
| 121 | + left = _u32(left ^ self.P[17]) |
| 122 | + return left, right |
| 123 | + |
| 124 | + def encrypt_bytes_ecb(self, data24): |
| 125 | + """ |
| 126 | + Encrypt 24-byte data in 64-bit blocks (3 blocks) |
| 127 | + data24 must be 24 bytes |
| 128 | + """ |
| 129 | + assert len(data24) == 24 |
| 130 | + out = b"" |
| 131 | + for i in range(0, 24, 8): |
| 132 | + left = _bytes_to_u32_be(data24[i:i+4]) |
| 133 | + right = _bytes_to_u32_be(data24[i+4:i+8]) |
| 134 | + l, r = self.encrypt_block(left, right) |
| 135 | + out += _u32_to_bytes_be(l) + _u32_to_bytes_be(r) |
| 136 | + return out |
| 137 | + |
| 138 | +# --------------------------- |
| 139 | +# Key schedule: Key(state, data) - expand with provided data (password or salt) |
| 140 | +# This follows the bcrypt/Blowfish key expansion: XOR P-array with key words, then |
| 141 | +# repeatedly encrypt a 64-bit block (data) and replace P and S entries with outputs. |
| 142 | +# --------------------------- |
| 143 | + |
| 144 | +def _key_expand(state: BlowfishState, key_bytes: bytes): |
| 145 | + # XOR P-array with key bytes repeated |
| 146 | + key_len = len(key_bytes) |
| 147 | + j = 0 |
| 148 | + for i in range(len(state.P)): |
| 149 | + # Build 32-bit word from 4 successive key bytes (cyclic) |
| 150 | + word = 0 |
| 151 | + for _ in range(4): |
| 152 | + word = (word << 8) | key_bytes[j] |
| 153 | + j = (j + 1) % key_len |
| 154 | + state.P[i] = _u32(state.P[i] ^ word) |
| 155 | + |
| 156 | +def _key_expand_with_data(state: BlowfishState, data64: bytes): |
| 157 | + # data64 is 8 bytes (64-bit) used as the block to be encrypted repeatedly and used to overwrite P and S |
| 158 | + assert len(data64) == 8 |
| 159 | + left = _bytes_to_u32_be(data64[0:4]) |
| 160 | + right = _bytes_to_u32_be(data64[4:8]) |
| 161 | + # Replace P entries |
| 162 | + for i in range(0, len(state.P), 2): |
| 163 | + left, right = state.encrypt_block(left, right) |
| 164 | + state.P[i] = left |
| 165 | + state.P[i+1] = right |
| 166 | + # Replace S entries |
| 167 | + for i in range(0, len(state.S), 2): |
| 168 | + left, right = state.encrypt_block(left, right) |
| 169 | + state.S[i] = left |
| 170 | + state.S[i+1] = right |
| 171 | + |
| 172 | +# --------------------------- |
| 173 | +# EksBlowfishSetup(cost, salt, key) |
| 174 | +# salt: 16 bytes |
| 175 | +# key: password bytes |
| 176 | +# --------------------------- |
| 177 | + |
| 178 | +def EksBlowfishSetup(cost, salt: bytes, key: bytes): |
| 179 | + """ |
| 180 | + cost: integer (work factor), real bcrypt uses 2^cost iterations. |
| 181 | + salt: 16 bytes |
| 182 | + key: password bytes |
| 183 | + """ |
| 184 | + if len(salt) != 16: |
| 185 | + raise ValueError("salt must be 16 bytes") |
| 186 | + state = BlowfishState() |
| 187 | + # Initial key with key and salt as per bcrypt: |
| 188 | + _key_expand(state, key) |
| 189 | + _key_expand_with_data(state, salt[0:8]) # encrypt using first 8 salt bytes |
| 190 | + _key_expand_with_data(state, salt[8:16]) # second 8 bytes |
| 191 | + |
| 192 | + rounds = 1 << cost |
| 193 | + for _ in range(rounds): |
| 194 | + _key_expand(state, key) |
| 195 | + _key_expand_with_data(state, salt[0:8]) |
| 196 | + _key_expand(state, salt) # bcrypt alternates key and salt expansion; here we do a variant: |
| 197 | + _key_expand_with_data(state, salt[8:16]) |
| 198 | + return state |
| 199 | + |
| 200 | +# --------------------------- |
| 201 | +# bcrypt finalization: encrypt the magic string 64 times and produce 24-byte output |
| 202 | +# Magic string used by bcrypt: "OrpheanBeholderScryDoubt" (24 bytes) |
| 203 | +# --------------------------- |
| 204 | + |
| 205 | +MAGIC = b"OrpheanBeholderScryDoubt" # 24 bytes |
| 206 | + |
| 207 | +def bcrypt_final(state: BlowfishState): |
| 208 | + ctext = MAGIC |
| 209 | + for _ in range(64): |
| 210 | + ctext = state.encrypt_bytes_ecb(ctext) |
| 211 | + return ctext # 24 bytes |
| 212 | + |
| 213 | +# --------------------------- |
| 214 | +# bcrypt base64 alphabet (special) |
| 215 | +# "./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" |
| 216 | +# --------------------------- |
| 217 | + |
| 218 | +B64_ALPHABET = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" |
| 219 | + |
| 220 | +def bcrypt_b64_encode(data: bytes) -> bytes: |
| 221 | + """Encode bytes to bcrypt base64 (no padding), producing ascii bytes.""" |
| 222 | + # Bcrypt encodes in 6-bit groups, little-endian ordering per the bcrypt spec. |
| 223 | + out = bytearray() |
| 224 | + i = 0 |
| 225 | + n = len(data) |
| 226 | + while i < n: |
| 227 | + c1 = data[i] |
| 228 | + i += 1 |
| 229 | + out.append(B64_ALPHABET[c1 & 0x3f]) |
| 230 | + if i >= n: |
| 231 | + break |
| 232 | + c2 = data[i] |
| 233 | + i += 1 |
| 234 | + out.append(B64_ALPHABET[((c1 >> 6) | ((c2 << 2) & 0x3f)) & 0x3f]) |
| 235 | + if i >= n: |
| 236 | + out.append(B64_ALPHABET[(c2 >> 4) & 0x3f]) |
| 237 | + break |
| 238 | + c3 = data[i] |
| 239 | + i += 1 |
| 240 | + out.append(B64_ALPHABET[((c2 >> 4) | ((c3 << 4) & 0x3f)) & 0x3f]) |
| 241 | + out.append(B64_ALPHABET[(c3 >> 2) & 0x3f]) |
| 242 | + return bytes(out) |
| 243 | + |
| 244 | +def bcrypt_b64_decode(encoded: bytes) -> bytes: |
| 245 | + """Decode bcrypt base64 back to bytes (best-effort).""" |
| 246 | + # Build reverse map |
| 247 | + rev = {B64_ALPHABET[i]: i for i in range(len(B64_ALPHABET))} |
| 248 | + data = [] |
| 249 | + i = 0 |
| 250 | + n = len(encoded) |
| 251 | + bitbuf = 0 |
| 252 | + bits = 0 |
| 253 | + out = bytearray() |
| 254 | + while i < n: |
| 255 | + val = rev.get(encoded[i], 0) |
| 256 | + i += 1 |
| 257 | + bitbuf |= val << bits |
| 258 | + bits += 6 |
| 259 | + if bits >= 8: |
| 260 | + out.append(bitbuf & 0xFF) |
| 261 | + bitbuf >>= 8 |
| 262 | + bits -= 8 |
| 263 | + return bytes(out) |
| 264 | + |
| 265 | +# --------------------------- |
| 266 | +# High level helpers: gensalt, hashpw, checkpw |
| 267 | +# Salt encoding: bcrypt uses 16 raw bytes, encoded to 22 chars base64 in the bcrypt alphabet. |
| 268 | +# Hash format: $2b$cost$22charsalt31charhash (we'll produce $2x$ style to be clear) |
| 269 | +# --------------------------- |
| 270 | + |
| 271 | +def bcrypt_gensalt(cost=12): |
| 272 | + if cost < 4 or cost > 31: |
| 273 | + raise ValueError("cost must be between 4 and 31") |
| 274 | + raw_salt = os.urandom(16) |
| 275 | + salt_b64 = bcrypt_b64_encode(raw_salt)[:22] # bcrypt uses 22 chars for 16 bytes |
| 276 | + return b"$2b$%02d$%s" % (cost, salt_b64) |
| 277 | + |
| 278 | +def _parse_salt(salt_string: bytes): |
| 279 | + # Accept either the full $2b$cost$salt or raw 22-char salt |
| 280 | + if salt_string.startswith(b"$2"): |
| 281 | + parts = salt_string.split(b"$") |
| 282 | + if len(parts) < 4: |
| 283 | + raise ValueError("Invalid salt format") |
| 284 | + cost = int(parts[2]) |
| 285 | + salt_b64 = parts[3] |
| 286 | + else: |
| 287 | + raise ValueError("Expect full salt string like $2b$12$........................") |
| 288 | + # decode bcrypt-base64 salt to 16 bytes |
| 289 | + raw = bcrypt_b64_decode(salt_b64) |
| 290 | + # Ensure 16 bytes |
| 291 | + if len(raw) < 16: |
| 292 | + raw = raw + b"\x00" * (16 - len(raw)) |
| 293 | + elif len(raw) > 16: |
| 294 | + raw = raw[:16] |
| 295 | + return cost, raw |
| 296 | + |
| 297 | +def bcrypt_hashpw(password: bytes, salt: bytes): |
| 298 | + """ |
| 299 | + password: bytes |
| 300 | + salt: full bcrypt salt string bytes like b"$2b$12$<22chars>" |
| 301 | + returns: full bcrypt-like hash string bytes |
| 302 | + """ |
| 303 | + cost, raw_salt = _parse_salt(salt) |
| 304 | + state = EksBlowfishSetup(cost, raw_salt, password) |
| 305 | + ctext = bcrypt_final(state) # 24 bytes |
| 306 | + # Encode the 24-byte hash into 31 base64 chars (bcrypt uses 31 chars for 24 bytes) |
| 307 | + hash_b64 = bcrypt_b64_encode(ctext)[:31] |
| 308 | + return b"$2b$%02d$%s%s" % (cost, bcrypt_b64_encode(raw_salt)[:22], hash_b64) |
| 309 | + |
| 310 | +def bcrypt_checkpw(password: bytes, full_hash: bytes) -> bool: |
| 311 | + """ |
| 312 | + full_hash: the full hash string generated by bcrypt_hashpw |
| 313 | + """ |
| 314 | + # Parse cost and salt from full_hash |
| 315 | + if not full_hash.startswith(b"$2"): |
| 316 | + raise ValueError("hash must start with $2") |
| 317 | + parts = full_hash.split(b"$") |
| 318 | + if len(parts) < 4: |
| 319 | + raise ValueError("invalid hash format") |
| 320 | + cost = int(parts[2]) |
| 321 | + salt_b64 = parts[3][:22] |
| 322 | + salt_string = b"$2b$%02d$%s" % (cost, salt_b64) |
| 323 | + recomputed = bcrypt_hashpw(password, salt_string) |
| 324 | + # Constant-time compare |
| 325 | + if len(recomputed) != len(full_hash): |
| 326 | + return False |
| 327 | + diff = 0 |
| 328 | + for a, b in zip(recomputed, full_hash): |
| 329 | + diff |= a ^ b |
| 330 | + return diff == 0 |
| 331 | + |
| 332 | +# --------------------------- |
| 333 | +# Simple demo |
| 334 | +# --------------------------- |
| 335 | + |
| 336 | +if __name__ == "__main__": |
| 337 | + pwd = b"superSecretPassword!" |
| 338 | + print("Generating salt (cost=6 for speed demo)...") |
| 339 | + s = bcrypt_gensalt(cost=6) |
| 340 | + print("Salt:", s) |
| 341 | + print("Hashing (this may take a moment depending on cost)...") |
| 342 | + h = bcrypt_hashpw(pwd, s) |
| 343 | + print("Hash:", h) |
| 344 | + print("Verify (correct):", bcrypt_checkpw(pwd, h)) |
| 345 | + print("Verify (wrong):", bcrypt_checkpw(b"badpass", h)) |
0 commit comments