Skip to content

Commit

Permalink
v2.7.0rc3: YesCrypt old format
Browse files Browse the repository at this point in the history
src/hashpw/algs/yescrypt/YescryptSettings.py: Added Yescrypt7Params

src/hashpw/algs/yescrypt/__init__.py:
  + Added YesCrypt::generate_raw_salt()
  + YesCrypt::set_other_params() now has an optional `allowed_keys` parameter
  + Added YesCrypt7 (uses implementation inheritance from YesCrypt)
  • Loading branch information
unixnut committed Oct 31, 2024
1 parent bfd30a1 commit aa9e95b
Show file tree
Hide file tree
Showing 6 changed files with 243 additions and 22 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
----
Expand All @@ -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:`)
4 changes: 2 additions & 2 deletions src/hashpw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ***
Expand Down
80 changes: 80 additions & 0 deletions src/hashpw/algs/yescrypt/YescryptSettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
139 changes: 121 additions & 18 deletions src/hashpw/algs/yescrypt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]
"""
Expand All @@ -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:
Expand All @@ -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())))

Expand All @@ -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 }
11 changes: 11 additions & 0 deletions tests/unit/test_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
27 changes: 26 additions & 1 deletion tests/unit/test_yescrypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit aa9e95b

Please sign in to comment.