From 7abb63e8400027dcca5a3855c383b2f2412ecca5 Mon Sep 17 00:00:00 2001 From: Thierry Parmentelat Date: Wed, 20 Nov 2024 16:25:22 +0100 Subject: [PATCH] replacement for the standard crypt.crypt done by introducing our own module PLC.crypt and testing against the standard crypt.crypt --- PLC/Auth.py | 4 +- PLC/Persons.py | 7 ++- PLC/plc_crypt.py | 149 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 PLC/plc_crypt.py diff --git a/PLC/Auth.py b/PLC/Auth.py index 927850ee..5cea3205 100644 --- a/PLC/Auth.py +++ b/PLC/Auth.py @@ -5,7 +5,6 @@ # Copyright (C) 2006 The Trustees of Princeton University # -import crypt from hashlib import sha1 as sha import hmac import time @@ -21,6 +20,7 @@ from PLC.Peers import Peer, Peers from PLC.Keys import Keys from PLC.Boot import notify_owners from PLC.Logger import logger +from PLC.plc_crypt import plc_crypt class Auth(Parameter): """ @@ -319,7 +319,7 @@ class PasswordAuth(Auth): # Protect against blank passwords in the DB if password is None or password[:12] == "" or \ - crypt.crypt(plaintext, password[:12]) != password: + plc_crypt(plaintext, password[:12]) != password: raise PLCAuthenticationFailure( "PasswordAuth: Password verification failed") diff --git a/PLC/Persons.py b/PLC/Persons.py index 6e1b2400..2239c508 100644 --- a/PLC/Persons.py +++ b/PLC/Persons.py @@ -9,7 +9,6 @@ from hashlib import md5 import time from random import Random import re -import crypt from PLC.Faults import * from PLC.Parameter import Parameter, Mixed @@ -18,6 +17,7 @@ from PLC.Table import Row, Table from PLC.Roles import Role, Roles from PLC.Keys import Key, Keys from PLC.Messages import Message, Messages +from PLC.plc_crypt import plc_crypt class Person(Row): """ @@ -102,6 +102,7 @@ class Person(Row): database. """ + # this for crypt.crypt means MD5 magic = "$1$" if len(password) > len(magic) and \ @@ -111,7 +112,9 @@ class Person(Row): # Generate a somewhat unique 8 character salt string salt = str(time.time()) + str(Random().random()) salt = md5(salt.encode()).hexdigest()[:8] - return crypt.crypt(password, magic + salt + "$") + # magic:3 salt:8 $:1 means the total salt is 12 characters + # as can be seen in Auth.py + return plc_crypt(password, magic + salt + "$") validate_date_created = Row.validate_timestamp validate_last_updated = Row.validate_timestamp diff --git a/PLC/plc_crypt.py b/PLC/plc_crypt.py new file mode 100644 index 00000000..f8ba4da8 --- /dev/null +++ b/PLC/plc_crypt.py @@ -0,0 +1,149 @@ +""" +compatibility replacement for our needs among the old crypt module +that was removed in 3.13 (failed to see the earlier warnings...) + +were only using the crypt.crypt() function +plus, we only use MD5 for hashing, and we always provide our salt +so we only need to replace that + +this is stolen from (and a thousands thanks to) the author of this code: +https://github.com/guffre/python-crypt/blob/main/crypt.py +""" + +import base64 +import hashlib + + +def crypt_base64(buffer): + """ + The custom base64 that is specific to these crypt algorithms + I know it looks weird, but this is apparently what the spec is + """ + unix_crypt_base = ( + b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + ) + normal_base = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + ret = b"" + if len(buffer) == 16: + c1 = list(range(0, 5)) + c2 = list(range(6, 11)) + c3 = list(range(12, 16)) + [5] + elif len(buffer) == 32: + c1 = [(n * 21 + 00) % 30 for n in range(10)] + c2 = [(n * 21 + 10) % 30 for n in range(10)] + c3 = [(n * 21 + 20) % 30 for n in range(10)] + elif len(buffer) == 64: + c1 = [(n * 22 + 00) % 63 for n in range(21)] + c2 = [(n * 22 + 21) % 63 for n in range(21)] + c3 = [(n * 22 + 42) % 63 for n in range(21)] + else: + return None + for block in zip(c1, c2, c3): + collect = b"".join(buffer[n : n + 1] for n in block) + ret += base64.b64encode(collect)[::-1] + if len(buffer) == 16: + ret += base64.b64encode(b"00" + buffer[11:12])[::-1][:2] + elif len(buffer) == 32: + ret += base64.b64encode(b"0" + buffer[31:32] + buffer[30:31])[::-1][:2] + elif len(buffer) == 64: + ret += base64.b64encode(b"00" + buffer[63:64])[::-1][:2] + # Python 2/3 compatability + trans = string if bytes == str else bytes + ret = ret.translate(trans.maketrans(normal_base, unix_crypt_base)) + return ret + + +def bin_crypt_md5(key:bytes, salt:bytes) -> bytes: + + salt = salt[:8] + rounds = 1000 + + # Initialize contexts + ctx = hashlib.md5(key + b"$1$" + salt) + alt_ctx = hashlib.md5(key + salt + key) + alt_result = alt_ctx.digest() + + # Add hash-bytes of second context to first + cnt = len(key) + while cnt > ctx.digest_size: + ctx.update(alt_result) + cnt -= ctx.digest_size + ctx.update(alt_result[:cnt]) + + # Binary mix + cnt = len(key) + while cnt > 0: + if (cnt & 1) != 0: + ctx.update(b"\0") + else: + ctx.update(key[0:1]) + cnt = cnt >> 1 + + alt_result = ctx.digest() + + # Perform rounds of hashing + for cnt in range(rounds): + ctx = hashlib.md5() + if (cnt & 1) != 0: + ctx.update(key) + else: + ctx.update(alt_result) + if cnt % 3 != 0: + ctx.update(salt) + if cnt % 7 != 0: + ctx.update(key) + if (cnt & 1) != 0: + ctx.update(alt_result) + else: + ctx.update(key) + alt_result = ctx.digest() + + # Crypt-base64 the hash digest + encoded = crypt_base64(alt_result).decode() + + # Make some nice output + hashnumber = 1 + formatted = "${}${}${}".format(hashnumber, salt.decode(), encoded) + return (alt_result, formatted) + + +def crypt_md5(password: str, salt: str) -> str: + """ + same as bin_crypt_md5 but with strings + """ + key = password.encode() + salt = salt[3:].encode() + return bin_crypt_md5(key, salt)[1] + +crypt = crypt_md5 + + +# some test cases +def test_crypt(): + # these were produced by the crypt.crypt() function + BY_STANDARD_CRYPT = [ + ["password", "$1$6167a424$9DuVAlIQ8zHRnth8ZUVRk1"], + ["password", "$1$d2b53fd1$nhHpCadNhArACvELo1Ugm0"], + ["password1", "$1$ab75e533$MXdWjzcZ9HR3B5x8uBCkC."], + ["password1", "$1$38520235$sRIMkorcm235lA0V6IKdN1"], + ["password123", "$1$5012ee70$a.557l7kfK/ksQ5dinvIW/"], + ["password123", "$1$f76ad85d$bli2s3/GQ8C5452km3Bdd."], + ["tarabiscota", "$1$b426146a$IkJK0vYHkWGV4KHDJ7wKw/"], + ["tarabiscota", "$1$9bd6a92f$/uObTeHg3wlDwJB7YO8DZ."], + ["56890&^*(HGJkjh", "$1$b5bf816d$HqfMqyXloPHo3mabIiSAt1"], + ["56890&^*(HGJkjh", "$1$a9c16150$n0yY5WatF7noVf8wyTHR51"], + [ "a2357A2357", "$1$e846962a$L/RA8n/sWWyvaE7YsjKOp0" ], + [ "hello-world", "$1$91ed0406$g2THz.r6/4Zhu3EHZzpfW." ], + [ "sodfisj0395u023-0=-a]\\df[skg]", "$1$e51c6cb6$rj.okkN6wwLR8djoiElK3." ], + [ "`)(*678(*(67HGJ)))", "$1$762b471f$tUY9KN9QsngZ4hpuEuyWo1" ], + ] + + for plaintext_password, expected in BY_STANDARD_CRYPT: + computed = crypt(plaintext_password, expected) + if computed == expected: + print(f"match: {plaintext_password:>30} == {computed}") + else: + print(f"ERROR: {plaintext_password:>30}: {computed} != {expected} !!!") + +if __name__ == "__main__": + test_crypt() -- 2.47.0