Coverage for gws-app/gws/lib/password/__init__.py: 96%
55 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Password hashing, checking and generation.
3Passwords are hashed with PBKDF2 (100000 iterations) and a random salt. The encoded hash
4has the form ``$algorithm$salt$hash``, where the hash is base64 (URL-safe) encoded.
5``check`` reads the algorithm and salt back from the encoded value.
7``generate`` creates random passwords with a configurable length and number of lowercase,
8uppercase, digit and punctuation characters. ``generate_with_groups`` does the same for
9arbitrary character groups (``SymbolGroup``).
11Example::
13 import gws.lib.password
15 encoded = gws.lib.password.encode('secret')
16 gws.lib.password.check('secret', encoded) # True
18 pw = gws.lib.password.generate(min_len=12, max_len=16, min_digit=2)
19"""
21import base64
22import hashlib
23import hmac
24import random
25import string
28def compare(a: str, b: str) -> bool:
29 """Compare two strings in constant time, to prevent timing attacks.
31 Args:
32 a: First string.
33 b: Second string.
35 Returns:
36 ``True`` if the strings are equal, ``False`` otherwise.
37 """
39 return hmac.compare_digest(a.encode('utf8'), b.encode('utf8'))
42def encode(password: str, algo: str = 'sha512') -> str:
43 """Encode a password into a salted PBKDF2 hash.
45 Args:
46 password: Plain text password.
47 algo: Hash algorithm name, as in ``hashlib``.
49 Returns:
50 The encoded hash in the format ``$algorithm$salt$hash``.
51 """
53 salt = _random_string(8)
54 h = _pbkdf2(password, salt, algo)
55 return '$'.join(['', algo, salt, base64.urlsafe_b64encode(h).decode('utf8')])
58def check(password: str, encoded: str) -> bool:
59 """Check if a password matches an encoded hash.
61 Args:
62 password: Plain text password.
63 encoded: Encoded hash, as returned by ``encode``.
65 Returns:
66 ``True`` if the password matches, ``False`` if it does not or if the encoded hash is invalid.
67 """
69 try:
70 _, algo, salt, hs = str(encoded).split('$')
71 h1 = base64.urlsafe_b64decode(hs)
72 h2 = _pbkdf2(password, salt, algo)
73 except (TypeError, ValueError):
74 return False
76 return hmac.compare_digest(h1, h2)
79class SymbolGroup:
80 """A group of characters with the minimum and maximum number of occurrences in a generated password."""
82 def __init__(self, s, min_len, max_len):
83 """Create a symbol group.
85 Args:
86 s: Characters of the group.
87 min_len: Minimum number of characters from this group.
88 max_len: Maximum number of characters from this group.
89 """
90 self.chars = s
91 self.max = max_len
92 self.min = min_len
93 self.count = 0
96def generate(
97 min_len: int = 16,
98 max_len: int = 16,
99 min_lower: int = 0,
100 max_lower: int = 255,
101 min_upper: int = 0,
102 max_upper: int = 255,
103 min_digit: int = 0,
104 max_digit: int = 255,
105 min_punct: int = 0,
106 max_punct: int = 255,
107) -> str:
108 """Generate a random password.
110 Args:
111 min_len: Minimum password length.
112 max_len: Maximum password length.
113 min_lower: Minimum number of lowercase letters.
114 max_lower: Maximum number of lowercase letters.
115 min_upper: Minimum number of uppercase letters.
116 max_upper: Maximum number of uppercase letters.
117 min_digit: Minimum number of digits.
118 max_digit: Maximum number of digits.
119 min_punct: Minimum number of punctuation characters.
120 max_punct: Maximum number of punctuation characters.
122 Returns:
123 The password.
125 Raises:
126 ``ValueError``: If the constraints cannot be satisfied.
127 """
129 groups = [
130 SymbolGroup(string.ascii_lowercase, min_lower, max_lower),
131 SymbolGroup(string.ascii_uppercase, min_upper, max_upper),
132 SymbolGroup(string.digits, min_digit, max_digit),
133 SymbolGroup(string.punctuation, min_punct, max_punct),
134 ]
135 return generate_with_groups(groups, min_len, max_len)
138def generate_with_groups(
139 groups: list[SymbolGroup],
140 min_len: int = 16,
141 max_len: int = 16,
142) -> str:
143 """Generate a random password from a list of symbol groups.
145 The ``count`` attribute of each group is updated.
147 Args:
148 groups: Symbol groups.
149 min_len: Minimum password length.
150 max_len: Maximum password length.
152 Returns:
153 The password.
155 Raises:
156 ``ValueError``: If the constraints cannot be satisfied.
157 """
159 r = random.SystemRandom()
160 p = []
162 for g in groups:
163 p.extend(r.choices(g.chars, k=g.min))
164 g.count = g.min
166 if len(p) > max_len:
167 raise ValueError('invalid parameters')
169 size = r.randint(max(min_len, len(p)), max_len)
171 while len(p) < size:
172 sel = ''.join(g.chars for g in groups if g.count < g.max)
173 if not sel:
174 raise ValueError('invalid parameters')
175 c = r.choice(sel)
176 for g in groups:
177 if c in g.chars:
178 g.count += 1
179 break
180 p.append(c)
182 r.shuffle(p)
184 return ''.join(p)
187##
190def _pbkdf2(password, salt, algo):
191 return hashlib.pbkdf2_hmac(algo, password.encode('utf8'), salt.encode('utf8'), 100000)
194def _random_string(length):
195 a = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
196 r = random.SystemRandom()
197 return ''.join(r.choice(a) for _ in range(length))