diff --git a/README.md b/README.md index 3a5d8ee..bc3d133 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ Supported algorithms (hash type identifiers `highlighted`): + HTTP basic authentication + Grub's PBKDF2 SHA512 (`grub.pbkdf2.sha512`) + Django: PBKDF2 (`pbkdf2_sha256`), PBKDF2 SHA1 (`pbkdf2_sha1`), Bcrypt SHA256 (`bcrypt_sha256`), Argon2 (`argon2`) - + SCrypt (`$scrypt$`) + + passlib's SCrypt (`$scrypt$`) + YesCrypt (`$y$`) + + YesCrypt old, a.k.a. SCrypt (`$7$`) Bugs ---- @@ -43,3 +44,4 @@ TO-DO + Accept password on standard input (without confirmation) + Support "doveadm pw" encoding scheme suffixes (.b64, .base64 and .hex); see http://wiki2.dovecot.org/Authentication/PasswordSchemes + + Support MediaWiki's SHA-512-based secure hash (hash type identifier = `:pbkdf2:sha512:`) diff --git a/src/hashpw/__init__.py b/src/hashpw/__init__.py index 46c767a..5fdaaa1 100644 --- a/src/hashpw/__init__.py +++ b/src/hashpw/__init__.py @@ -35,12 +35,12 @@ from .algs.SHA256 import SHA256 from .algs.SHA512 import SHA512 from .algs.SSHA import SSHA -from .algs.yescrypt import YesCrypt +from .algs.yescrypt import YesCrypt, YesCrypt7 # *** DEFINITIONS *** # Algorithms with longer prefixes need to appear earlier in this list -algorithms = (DjangoBcryptSHA256, DjangoBcryptSHA256Orig, GrubPBKDF2SHA512, DjangoArgon2, PBKDF2, DjangoPBKDF2SHA1, Argon2id, Argon2i, Argon2d, LDAPv2SSHA256, LDAPv2SSHA512, SCrypt, LDAPv2SMD5, ApacheMD5, SSHA, ApacheSHA1, BCrypt, BCryptVariant, Blowfish, MD5, SHA256, SHA512, Phpass, PhpBB3, YesCrypt, MySqlSHA1, BasicMD5, ExtDes, Crypt, OldPassword, HTTPBasic) +algorithms = (DjangoBcryptSHA256, DjangoBcryptSHA256Orig, GrubPBKDF2SHA512, DjangoArgon2, PBKDF2, DjangoPBKDF2SHA1, Argon2id, Argon2i, Argon2d, LDAPv2SSHA256, LDAPv2SSHA512, SCrypt, LDAPv2SMD5, ApacheMD5, SSHA, ApacheSHA1, BCrypt, BCryptVariant, Blowfish, MD5, SHA256, SHA512, Phpass, PhpBB3, YesCrypt, YesCrypt7, MySqlSHA1, BasicMD5, ExtDes, Crypt, OldPassword, HTTPBasic) # *** FUNCTIONS *** diff --git a/src/hashpw/algs/yescrypt/YescryptSettings.py b/src/hashpw/algs/yescrypt/YescryptSettings.py index e2be9c5..11b7fe3 100644 --- a/src/hashpw/algs/yescrypt/YescryptSettings.py +++ b/src/hashpw/algs/yescrypt/YescryptSettings.py @@ -40,6 +40,10 @@ def decode_hash(context, s: str): # -> YescryptParams @classmethod def decode(context, s: str): # -> YescryptParams + """ + Extract params from raw string, i.e. not a whole/partial hash + """ + reader = B64StringReader(s) # Decode flavor (which fits 10 bits into 8 with a magic algorithm) @@ -142,6 +146,82 @@ def __str__(self) -> str: ## return "YescryptParams(flags=%03x, N=%d, p=%d, t=%d, g=%d, ROM=%dA)" % (self.flags, self.N, self.r, self.p, self.t, self.g, self.ROM) + +@dataclass +class Yescrypt7Params: + """Old $7$ format.""" + + N: int = 4096 + r: int = 32 + p: int = 1 + + + @classmethod + def decode_hash(context, s: str): # -> YescryptParams + parts = s.split('$') + + # check for correct marker prefix + if len(parts) != 4: + raise ValueError("Invalid encoding. Valid yescrypt encoding should have 4 values separated by '$'") + + # we only support "y" (not "7") + if parts[1] != "7": + raise ValueError("Unsupported prefix: " + parts[1]) + + return context.decode(parts[2]) + + + @classmethod + def decode(context, s: str): # -> YescryptParams + """ + Extract params from raw string, i.e. not a whole/partial hash + """ + + reader = B64StringReader(s) + + # Decode N + nlog2: int = reader.readUint32Bits(6) # Read single encoded byte + if nlog2 > 31: + raise ValueError("Invalid N. Nlog2 must be < 32") + N: int = 1 << nlog2 + + # Decode r + r: int = reader.readUint32Bits(30) # Read 5 encoded bytes + + # Decode p + p: int = reader.readUint32Bits(30) # Read 5 encoded bytes + + return Yescrypt7Params(N, r, p) + + + def encode(self) -> str: + """ + Encode the params to a string, not including the "$7$" prefix, any + delimeters or a salt. + """ + + writer = B64StringWriter() + + nlog2: int = N2log2(self.N) + if nlog2 == 0: + raise ValueError("N must be power of 2") + + writer.writeUint32Bits(nlog2, 6) + + if self.r * self.p >= (1 << 30): + raise ValueError("Invalid r") + + writer.writeUint32Bits(self.r, 30) + writer.writeUint32Bits(self.p, 30) + + return str(writer) + + + def __str__(self) -> str: + return self.encode() + + + # *** FUNCTIONS *** def N2log2(N: int) -> int: if N < 2: return 0 diff --git a/src/hashpw/algs/yescrypt/__init__.py b/src/hashpw/algs/yescrypt/__init__.py index 5569cae..4648dc8 100644 --- a/src/hashpw/algs/yescrypt/__init__.py +++ b/src/hashpw/algs/yescrypt/__init__.py @@ -7,7 +7,7 @@ from ... import errors from ...structure import SaltedAlgorithm -from .YescryptSettings import YescryptParams +from .YescryptSettings import YescryptParams, Yescrypt7Params from .YescryptSettings import N2log2 from .YescryptFlags import YescryptFlags @@ -60,8 +60,22 @@ def generate_salt(c) -> str: [Override] """ + salt_chars = c.generate_raw_salt() + # Format: see https://unix.stackexchange.com/a/724514 + + ## user_params = copy.copy(c.get_default_params()) + params = { 'N': int(math.pow(2, c.rounds)), 'r': c.params['block_size'], + 'p': c.params['parallelism'], 't': c.params['time_factor'] } + encoded_params = str(YescryptParams(**params)) + s = "%s%s$%s%s" % (c.prefix, encoded_params, salt_chars, c.suffix) + logging.debug("Full salt, len(s)=%d: %s", len(s), s) + return s + + + @classmethod + def generate_raw_salt(c) -> str: # Use bits and then encode them (instead of randomly generating encoded characters) - rand_salt_chars = c.generate_raw_salt(raw_byte_count=16) + rand_salt_chars = super().generate_raw_salt(raw_byte_count=16) # For YesCrypt, the salt is actually 132 bits (instead of 128 bits # normally used by algorithms with a 22 char salt, which ignore the # extra 4 bits). The 6 least significant bits must be one of the @@ -71,15 +85,7 @@ def generate_salt(c) -> str: char_list = random.sample('01./', 1) salt_chars = rand_salt_chars[:c.salt_length-1] + char_list[0] logging.debug("Generated salt, len(s)=%d: %s", len(salt_chars), salt_chars) - # Format: see https://unix.stackexchange.com/a/724514 - - ## user_params = copy.copy(c.get_default_params()) - params = { 'N': int(math.pow(2, c.rounds)), 'r': c.params['block_size'], - 'p': c.params['parallelism'], 't': c.params['time_factor'] } - encoded_params = str(YescryptParams(**params)) - s = "%s%s$%s%s" % (c.prefix, encoded_params, salt_chars, c.suffix) - logging.debug("Full salt, len(s)=%d: %s", len(s), s) - return s + return salt_chars @classmethod @@ -97,8 +103,8 @@ def extract_salt(c, s: str) -> str: @classmethod def get_salt_info(c, s: str) -> Tuple[str, Dict]: """ - Extract a full salt string and a mapping of information about it (called the - parameters) from a hash. + Extract a full salt string (a.k.a. partial hash) and a mapping of + information about it (called the parameters) from a hash. [Override] """ @@ -111,10 +117,10 @@ def get_salt_info(c, s: str) -> Tuple[str, Dict]: logging.debug("%d tokens found", len(tokens)) if len(tokens) == 5: ## salt = c.build_full_salt(rounds=, salt_chars) - salt = s[:len(c.prefix) + len(tokens[2]) + 1 + c.salt_length + len(c.suffix)] + partial_hash = s[:len(c.prefix) + len(tokens[2]) + 1 + c.salt_length + len(c.suffix)] params = c.parse_params(tokens[2]) - return salt, params + return partial_hash, params else: raise ValueError("Hash format invalid") else: @@ -138,15 +144,15 @@ def get_default_params(c) -> Dict: @classmethod - def set_other_params(c, p: Dict) -> Dict: + def set_other_params(c, p: Dict, allowed_keys: Sequence = ('block_size', 'parallelism', 'time_factor')) -> Dict: local_p = copy.copy(p) - for param in ('block_size', 'parallelism', 'time_factor'): + for param in allowed_keys: try: val = local_p.pop(param) c.params[param] = int(val) except KeyError: pass - # Check if any elements weren't popped + # Check if any elements weren't popped and mention the first one if local_p: raise errors.InvalidArgException("Unknown parameter name '%s'" % next(iter(p.keys()))) @@ -155,3 +161,100 @@ def set_other_params(c, p: Dict) -> Dict: ## def check_salt(c, salt: str): ## logging.debug("salt: %s, comp_len=%d", salt, c.comp_len) ## super().check_salt(salt) + + +class YesCrypt7(YesCrypt): + """ + Designed by Solar Designer + + Old format a.k.a. SCrypt (not to be confused with the passlib one) + """ + + prefix = "$7$" + name = "yescrypt7" + option = "7" + suffix = "$" + min_length = 80 + salt_length = 22 # doesn't include prefix or params; not including "==" needed to decode base64 + encoded_digest_length = 43 + rounds_strategy = 'logarithmic' + default_rounds = 15 + params = { 'block_size': 32, 'parallelism': 2 } + vanilla_default_rounds = 14 + + + # This can't be a @classmethod because parent classes have to work with its properties + @staticmethod + def init(c, **kwargs: Dict): + """ + Ensure that check_salt() checks the length of the whole hash. + [Override] + """ + + c.set_rounds(extra_args=kwargs) + if 'params' in kwargs and kwargs['params']: + c.set_other_params(kwargs['params'], allowed_keys = ('block_size', 'parallelism')) + + n = 11 # params chars (no delimiter) + # Number of characters before salt + c.salt_prefix_len = len(c.prefix) + n + + # Skip the parent + SaltedAlgorithm.init(c, comp_extra=n, **kwargs) + + + @classmethod + def generate_salt(c) -> str: + """ + Calculates an encoded salt string, including prefix, for this algorithm. + This doesn't include base64 padding characters (2 "="). + + [Override] + """ + + salt_chars = c.generate_raw_salt() + # Format: see https://unix.stackexchange.com/a/724514 + + ## user_params = copy.copy(c.get_default_params()) + params = { 'N': int(math.pow(2, c.rounds)), 'r': c.params['block_size'], + 'p': c.params['parallelism'] } + encoded_params = str(Yescrypt7Params(**params)) + # Note lack of delimiter after params + s = "%s%s%s%s" % (c.prefix, encoded_params, salt_chars, c.suffix) + logging.debug("Full salt, len(s)=%d: %s", len(s), s) + return s + + + @classmethod + def get_salt_info(c, s: str) -> Tuple[str, Dict]: + """ + Extract a full salt string (a.k.a. partial hash) and a mapping of + information about it (called the parameters) from a hash. + + [Override] + """ + + if c.recognise_salt_internal(s): + tokens = s.split("$") + logging.debug("%d tokens found", len(tokens)) + # No delimiter between params and salt chars + if len(tokens) == 4: + ## salt = c.build_full_salt(rounds=, salt_chars) + partial_hash = s[:c.comp_len] + params = c.parse_params(tokens[2][:-c.salt_length]) + + return partial_hash, params + else: + raise ValueError("Hash format invalid") + else: + raise ValueError("Hash does not start with " + c.prefix) + + + @classmethod + def parse_params(c, s: str) -> Dict: + """YesCrypt7 params parsing""" + + params = Yescrypt7Params.decode(s) + return { 'rounds': c.rounds_to_logarithmic(params.N), + 'block_size': params.r, + 'parallelism': params.p } diff --git a/tests/unit/test_algorithms.py b/tests/unit/test_algorithms.py index 56f21a5..07c98a6 100644 --- a/tests/unit/test_algorithms.py +++ b/tests/unit/test_algorithms.py @@ -503,5 +503,16 @@ def setUpClass(cls): cls.alg_class = hashpw.YesCrypt +class TestYesCrypt7(AlgorithmSaltedMixin, unittest.TestCase): + """Tests for YesCrypt algorithm.""" + + foobie_bletch_hash = "$7$DU....0....ZPYwbM6PY155RiWHJ1SEU1$4wFVgFwEqIyWNwbpLRs75zR4CM27EbXDXP48vzw8qXA" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.alg_class = hashpw.YesCrypt7 + + if __name__ == '__main__': unittest.main() diff --git a/tests/unit/test_yescrypt.py b/tests/unit/test_yescrypt.py index e3b138f..ba9dec1 100644 --- a/tests/unit/test_yescrypt.py +++ b/tests/unit/test_yescrypt.py @@ -13,7 +13,7 @@ ## from hashpw import hashpw import hashpw ## from ..scaffolding.algorithms import AlgorithmGenericTests -from hashpw.algs.yescrypt.YescryptSettings import YescryptParams +from hashpw.algs.yescrypt.YescryptSettings import YescryptParams, Yescrypt7Params from hashpw.algs.yescrypt.YescryptSettings import N2log2 from hashpw.algs.yescrypt.YescryptFlags import YescryptFlags @@ -102,6 +102,31 @@ def test_decode_jBT1_(self): +class TestYesCrypt7(unittest.TestCase): + def test_encode_logN14(self): + """Default params as used by mkpasswd(1)""" + + t = Yescrypt7Params(N=16384) + result = t.encode() + self.assertEqual("CU..../....", result, msg="Encoded params are wrong") + + def test_encode_logN15(self): + t = Yescrypt7Params(N=32768) + result = t.encode() + self.assertEqual("DU..../....", result, msg="Encoded params are wrong") + + def test_encode_logN15_p2(self): + t = Yescrypt7Params(N=32768, p=2) + result = t.encode() + self.assertEqual("DU....0....", result, msg="Encoded params are wrong") + + def test_decode(self): + params = Yescrypt7Params.decode_hash('$7$CU..../....bUMwNqEOZzE9RqPZH5o9W0$') + expected_params = Yescrypt7Params(N=16384) + self.assertEqual(expected_params, params, msg="params are wrong") + + + class TestLog2(unittest.TestCase): def test_4096(self): result = N2log2(4096)