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

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 base64 

22import hashlib 

23import hmac 

24import random 

25import string 

26 

27 

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

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

30 

31 Args: 

32 a: First string. 

33 b: Second string. 

34 

35 Returns: 

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

37 """ 

38 

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

40 

41 

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

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

44 

45 Args: 

46 password: Plain text password. 

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

48 

49 Returns: 

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

51 """ 

52 

53 salt = _random_string(8) 

54 h = _pbkdf2(password, salt, algo) 

55 return '$'.join(['', algo, salt, base64.urlsafe_b64encode(h).decode('utf8')]) 

56 

57 

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

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

60 

61 Args: 

62 password: Plain text password. 

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

64 

65 Returns: 

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

67 """ 

68 

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 

75 

76 return hmac.compare_digest(h1, h2) 

77 

78 

79class SymbolGroup: 

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

81 

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

83 """Create a symbol group. 

84 

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 

94 

95 

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. 

109 

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. 

121 

122 Returns: 

123 The password. 

124 

125 Raises: 

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

127 """ 

128 

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) 

136 

137 

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. 

144 

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

146 

147 Args: 

148 groups: Symbol groups. 

149 min_len: Minimum password length. 

150 max_len: Maximum password length. 

151 

152 Returns: 

153 The password. 

154 

155 Raises: 

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

157 """ 

158 

159 r = random.SystemRandom() 

160 p = [] 

161 

162 for g in groups: 

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

164 g.count = g.min 

165 

166 if len(p) > max_len: 

167 raise ValueError('invalid parameters') 

168 

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

170 

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) 

181 

182 r.shuffle(p) 

183 

184 return ''.join(p) 

185 

186 

187## 

188 

189 

190def _pbkdf2(password, salt, algo): 

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

192 

193 

194def _random_string(length): 

195 a = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 

196 r = random.SystemRandom() 

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