Coverage for gws-app/gws/lib/password/__init__.py: 96%

55 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Password hashing, checking and generation. 

2 

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. 

6 

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``). 

10 

11Example:: 

12 

13 import gws.lib.password 

14 

15 encoded = gws.lib.password.encode('secret') 

16 gws.lib.password.check('secret', encoded) # True 

17 

18 pw = gws.lib.password.generate(min_len=12, max_len=16, min_digit=2) 

19""" 

20 

21import hashlib 

22import hmac 

23import random 

24import string 

25 

26import gws.lib.text 

27 

28 

29def compare(a: str, b: str) -> bool: 

30 """Compare two strings in constant time, to prevent timing attacks. 

31 

32 Args: 

33 a: First string. 

34 b: Second string. 

35 

36 Returns: 

37 ``True`` if the strings are equal, ``False`` otherwise. 

38 """ 

39 

40 return hmac.compare_digest(a.encode('utf8'), b.encode('utf8')) 

41 

42 

43def encode(password: str, algo: str = 'sha512') -> str: 

44 """Encode a password into a salted PBKDF2 hash. 

45 

46 Args: 

47 password: Plain text password. 

48 algo: Hash algorithm name, as in ``hashlib``. 

49 

50 Returns: 

51 The encoded hash in the format ``$algorithm$salt$hash``. 

52 """ 

53 

54 salt = _random_string(8) 

55 h = _pbkdf2(password, salt, algo) 

56 return '$'.join(['', algo, salt, gws.lib.text.to_base64(h, url_safe=True)]) 

57 

58 

59def check(password: str, encoded: str) -> bool: 

60 """Check if a password matches an encoded hash. 

61 

62 Args: 

63 password: Plain text password. 

64 encoded: Encoded hash, as returned by ``encode``. 

65 

66 Returns: 

67 ``True`` if the password matches, ``False`` if it does not or if the encoded hash is invalid. 

68 """ 

69 

70 try: 

71 _, algo, salt, hs = str(encoded).split('$') 

72 h1 = gws.lib.text.from_base64(hs, url_safe=True) 

73 h2 = _pbkdf2(password, salt, algo) 

74 except (TypeError, ValueError, gws.lib.text.Error): 

75 return False 

76 

77 return hmac.compare_digest(h1, h2) 

78 

79 

80class SymbolGroup: 

81 """A group of characters with the minimum and maximum number of occurrences in a generated password.""" 

82 

83 def __init__(self, s, min_len, max_len): 

84 """Create a symbol group. 

85 

86 Args: 

87 s: Characters of the group. 

88 min_len: Minimum number of characters from this group. 

89 max_len: Maximum number of characters from this group. 

90 """ 

91 self.chars = s 

92 self.max = max_len 

93 self.min = min_len 

94 self.count = 0 

95 

96 

97def generate( 

98 min_len: int = 16, 

99 max_len: int = 16, 

100 min_lower: int = 0, 

101 max_lower: int = 255, 

102 min_upper: int = 0, 

103 max_upper: int = 255, 

104 min_digit: int = 0, 

105 max_digit: int = 255, 

106 min_punct: int = 0, 

107 max_punct: int = 255, 

108) -> str: 

109 """Generate a random password. 

110 

111 Args: 

112 min_len: Minimum password length. 

113 max_len: Maximum password length. 

114 min_lower: Minimum number of lowercase letters. 

115 max_lower: Maximum number of lowercase letters. 

116 min_upper: Minimum number of uppercase letters. 

117 max_upper: Maximum number of uppercase letters. 

118 min_digit: Minimum number of digits. 

119 max_digit: Maximum number of digits. 

120 min_punct: Minimum number of punctuation characters. 

121 max_punct: Maximum number of punctuation characters. 

122 

123 Returns: 

124 The password. 

125 

126 Raises: 

127 ``ValueError``: If the constraints cannot be satisfied. 

128 """ 

129 

130 groups = [ 

131 SymbolGroup(string.ascii_lowercase, min_lower, max_lower), 

132 SymbolGroup(string.ascii_uppercase, min_upper, max_upper), 

133 SymbolGroup(string.digits, min_digit, max_digit), 

134 SymbolGroup(string.punctuation, min_punct, max_punct), 

135 ] 

136 return generate_with_groups(groups, min_len, max_len) 

137 

138 

139def generate_with_groups( 

140 groups: list[SymbolGroup], 

141 min_len: int = 16, 

142 max_len: int = 16, 

143) -> str: 

144 """Generate a random password from a list of symbol groups. 

145 

146 The ``count`` attribute of each group is updated. 

147 

148 Args: 

149 groups: Symbol groups. 

150 min_len: Minimum password length. 

151 max_len: Maximum password length. 

152 

153 Returns: 

154 The password. 

155 

156 Raises: 

157 ``ValueError``: If the constraints cannot be satisfied. 

158 """ 

159 

160 r = random.SystemRandom() 

161 p = [] 

162 

163 for g in groups: 

164 p.extend(r.choices(g.chars, k=g.min)) 

165 g.count = g.min 

166 

167 if len(p) > max_len: 

168 raise ValueError('invalid parameters') 

169 

170 size = r.randint(max(min_len, len(p)), max_len) 

171 

172 while len(p) < size: 

173 sel = ''.join(g.chars for g in groups if g.count < g.max) 

174 if not sel: 

175 raise ValueError('invalid parameters') 

176 c = r.choice(sel) 

177 for g in groups: 

178 if c in g.chars: 

179 g.count += 1 

180 break 

181 p.append(c) 

182 

183 r.shuffle(p) 

184 

185 return ''.join(p) 

186 

187 

188## 

189 

190 

191def _pbkdf2(password, salt, algo): 

192 return hashlib.pbkdf2_hmac(algo, password.encode('utf8'), salt.encode('utf8'), 100000) 

193 

194 

195def _random_string(length): 

196 a = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 

197 r = random.SystemRandom() 

198 return ''.join(r.choice(a) for _ in range(length))