• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / AuthEncoding / 9731793428

15 Jun 2024 07:23AM UTC coverage: 97.01% (+0.3%) from 96.751%
9731793428

push

github

web-flow
Merge pull request #16 from zopefoundation/config-with-pure-python-template-42418b51

Add support for Python 3.12, drop 3.7

64 of 67 branches covered (95.52%)

Branch coverage included in aggregate %.

0 of 1 new or added line in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

228 of 234 relevant lines covered (97.44%)

0.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.58
/src/AuthEncoding/AuthEncoding.py
1
##############################################################################
2
#
3
# Copyright (c) 2002, 2015 Zope Foundation and Contributors.
4
#
5
# This software is subject to the provisions of the Zope Public License,
6
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
7
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
8
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
9
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
10
# FOR A PARTICULAR PURPOSE
11
#
12
##############################################################################
13

14
import binascii
1✔
15
# Use the system PRNG if possible
16
import random
1✔
17
import time
1✔
18
import warnings
1✔
19
from binascii import a2b_base64
1✔
20
from binascii import b2a_base64
1✔
21
from hashlib import sha1 as sha
1✔
22
from hashlib import sha256
1✔
23
from os import getpid
1✔
24
from os import urandom
1✔
25

26
from .compat import b
1✔
27
from .compat import u
1✔
28

29

30
try:
1✔
31
    random = random.SystemRandom()
1✔
32
    using_sysrandom = True
1✔
33
except NotImplementedError:  # pragma: no cover
34
    using_sysrandom = False
35

36

37
def _reseed():
1✔
38
    if not using_sysrandom:
1✔
39
        # This is ugly, and a hack, but it makes things better than
40
        # the alternative of predictability. This re-seeds the PRNG
41
        # using a value that is hard for an attacker to predict, every
42
        # time a random string is required. This may change the
43
        # properties of the chosen random sequence slightly, but this
44
        # is better than absolute predictability.
45
        random.seed(sha256(  # pragma: no cover
46
            f'{random.getstate()}{time.time()}{getpid()}'
47
        ).digest())
48

49

50
def _choice(c):
1✔
51
    _reseed()
1✔
52
    return random.choice(c)
1✔
53

54

55
def _randrange(r):
1✔
56
    _reseed()
×
57
    return random.randrange(r)
×
58

59

60
def constant_time_compare(val1, val2):
1✔
61
    """
62
    Returns True if the two strings are equal, False otherwise.
63

64
    The time taken is independent of the number of characters that match.
65
    """
66
    if len(val1) != len(val2):
1✔
67
        return False
1✔
68
    result = 0
1✔
69
    for x, y in zip(iter(val1), iter(val2)):
1✔
70
        result |= x ^ y
1✔
71
    return result == 0
1✔
72

73

74
class PasswordEncryptionScheme:  # An Interface
1✔
75

76
    def encrypt(pw):
1✔
77
        """
78
        Encrypt the provided plain text password.
79
        """
80

81
    def validate(reference, attempt):
1✔
82
        """
83
        Validate the provided password string.  Reference is the
84
        correct password, which may be encrypted; attempt is clear text
85
        password attempt.
86
        """
87

88

89
_schemes = []
1✔
90

91

92
def registerScheme(id, s):
1✔
93
    '''
94
    Registers an LDAP password encoding scheme.
95
    '''
96
    _schemes.append((id, '{%s}' % id, s))
1✔
97

98

99
def listSchemes():
1✔
100
    return [id for id, prefix, scheme in _getSortedSchemes()]
1✔
101

102

103
def _getSortedSchemes():
1✔
104
    """ Return a reversed sorted sequence of encryption schemes.
105

106
    Several places iterate over schemes and compare the prefix with the start
107
    of a password string. Using a reverse-sorted sequence makes sure that e.g.
108
    'SHA256' is always tested before 'SHA' to prevent bad matches.
109
    """
110
    return sorted(_schemes, reverse=True)
1✔
111

112

113
class SSHADigestScheme:
1✔
114
    '''
115
    SSHA is a modification of the SHA digest scheme with a salt
116
    starting at byte 20 of the base64-encoded string.
117
    '''
118
    # Source: http://developer.netscape.com/docs/technote/ldap/pass_sha.html
119

120
    def generate_salt(self):
1✔
121
        # Salt can be any length, but not more than about 37 characters
122
        # because of limitations of the binascii module.
123
        # 7 is what Netscape's example used and should be enough.
124
        # All 256 characters are available.
125
        return urandom(7)
1✔
126

127
    def encrypt(self, pw):
1✔
128
        return self._encrypt_with_salt(pw, self.generate_salt())
1✔
129

130
    def validate(self, reference, attempt):
1✔
131
        try:
1✔
132
            ref = a2b_base64(reference)
1✔
133
        except binascii.Error:  # pragma: no cover
134
            # Not valid base64.
135
            return 0
136
        salt = ref[20:]
1✔
137
        compare = self._encrypt_with_salt(attempt, salt)
1✔
138
        return constant_time_compare(compare, reference)
1✔
139

140
    def _encrypt_with_salt(self, pw, salt):
1✔
141
        pw = b(pw)
1✔
142
        return b2a_base64(sha(pw + salt).digest() + salt)[:-1]
1✔
143

144

145
registerScheme('SSHA', SSHADigestScheme())
1✔
146

147

148
class SHADigestScheme:
1✔
149

150
    def encrypt(self, pw):
1✔
151
        return self._encrypt(pw)
1✔
152

153
    def validate(self, reference, attempt):
1✔
154
        compare = self._encrypt(attempt)
1✔
155
        return constant_time_compare(compare, reference)
1✔
156

157
    def _encrypt(self, pw):
1✔
158
        pw = b(pw)
1✔
159
        return b2a_base64(sha(pw).digest())[:-1]
1✔
160

161

162
registerScheme('SHA', SHADigestScheme())
1✔
163

164

165
class SHA256DigestScheme:
1✔
166

167
    def encrypt(self, pw):
1✔
168
        return b(sha256(b(pw)).hexdigest())
1✔
169

170
    def validate(self, reference, attempt):
1✔
171
        a = self.encrypt(attempt)
1✔
172
        return constant_time_compare(a, reference)
1✔
173

174

175
registerScheme('SHA256', SHA256DigestScheme())
1✔
176

177

178
# Bcrypt support may not have been requested at installation time
179
# - installed via the 'bcrypt' extra
180
try:
1✔
181
    import bcrypt
1✔
NEW
182
except ModuleNotFoundError:
×
UNCOV
183
    bcrypt = None
×
184

185

186
class BCRYPTHashingScheme:
1✔
187
    """A BCRYPT hashing scheme."""
188

189
    @staticmethod
1✔
190
    def _ensure_bytes(pw, encoding='utf-8'):
1✔
191
        """Ensures the given password `pw` is returned as bytes."""
192
        if isinstance(pw, str):
1✔
193
            pw = pw.encode(encoding)
1✔
194
        return pw
1✔
195

196
    def encrypt(self, pw):
1✔
197
        return bcrypt.hashpw(self._ensure_bytes(pw), bcrypt.gensalt())
1✔
198

199
    def validate(self, reference, attempt):
1✔
200
        try:
1✔
201
            return bcrypt.checkpw(self._ensure_bytes(attempt), reference)
1✔
202
        except ValueError:
×
203
            # Usually due to an invalid salt
204
            return False
×
205

206

207
if bcrypt is not None:
1!
208
    registerScheme('BCRYPT', BCRYPTHashingScheme())
1✔
209

210

211
# Suppress deprecation warnings on Python 3.11 and up
212
with warnings.catch_warnings():
1✔
213
    warnings.simplefilter('ignore')
1✔
214

215
    # crypt is not available on all platforms
216
    try:
1✔
217
        from crypt import crypt
1✔
218
    except ImportError:
219
        crypt = None
220

221

222
if crypt is not None:
1!
223

224
    class CryptDigestScheme:
1✔
225

226
        def generate_salt(self):
1✔
227
            choices = ("ABCDEFGHIJKLMNOPQRSTUVWXYZ"
1✔
228
                       "abcdefghijklmnopqrstuvwxyz"
229
                       "0123456789./")
230
            return _choice(choices) + _choice(choices)
1✔
231

232
        def encrypt(self, pw):
1✔
233
            return b(crypt(self._recode_password(pw), self.generate_salt()))
1✔
234

235
        def validate(self, reference, attempt):
1✔
236
            attempt = self._recode_password(attempt)
1✔
237
            a = b(crypt(attempt, reference[:2].decode('ascii')))
1✔
238
            return constant_time_compare(a, reference)
1✔
239

240
        def _recode_password(self, pw):
1✔
241
            # crypt always requires `str`
242
            return u(pw)
1✔
243

244
    registerScheme('CRYPT', CryptDigestScheme())
1✔
245

246

247
class MySQLDigestScheme:
1✔
248

249
    def encrypt(self, pw):
1✔
250
        pw = u(pw)
1✔
251
        nr = 1345345333
1✔
252
        add = 7
1✔
253
        nr2 = int(0x12345671)
1✔
254
        for i in pw:
1✔
255
            if i == ' ' or i == '\t':
1✔
256
                continue
1✔
257
            nr ^= (((nr & 63) + add) * ord(i)) + (nr << 8)
1✔
258
            nr2 += (nr2 << 8) ^ nr
1✔
259
            add += ord(i)
1✔
260
        r0 = nr & ((1 << 31) - 1)
1✔
261
        r1 = nr2 & ((1 << 31) - 1)
1✔
262
        return f'{r0:08x}{r1:08x}'.encode('ascii')
1✔
263

264
    def validate(self, reference, attempt):
1✔
265
        a = self.encrypt(attempt)
1✔
266
        return constant_time_compare(a, reference)
1✔
267

268

269
registerScheme('MYSQL', MySQLDigestScheme())
1✔
270

271

272
def pw_validate(reference, attempt):
1✔
273
    """Validate the provided password string, which uses LDAP-style encoding
274
    notation.  Reference is the correct password, attempt is clear text
275
    password attempt."""
276
    reference = b(reference)
1✔
277
    for id, prefix, scheme in _getSortedSchemes():
1✔
278
        lp = len(prefix)
1✔
279
        if reference[:lp] == b(prefix):
1✔
280
            return scheme.validate(reference[lp:], attempt)
1✔
281
    # Assume cleartext.
282
    return constant_time_compare(reference, b(attempt))
1✔
283

284

285
def is_encrypted(pw):
1✔
286
    pw = b(pw)
1✔
287
    for id, prefix, scheme in _getSortedSchemes():
1✔
288
        lp = len(prefix)
1✔
289
        if pw[:lp] == b(prefix):
1✔
290
            return 1
1✔
291
    return 0
1✔
292

293

294
def pw_encrypt(pw, encoding='SSHA'):
1✔
295
    """Encrypt the provided plain text password using the encoding if provided
296
    and return it in an LDAP-style representation."""
297
    encoding = u(encoding)
1✔
298
    for id, prefix, scheme in _getSortedSchemes():
1✔
299
        if encoding == id:
1✔
300
            return b(prefix) + scheme.encrypt(pw)
1✔
301
    raise ValueError('Not supported: %s' % encoding)
1✔
302

303

304
pw_encode = pw_encrypt  # backward compatibility
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc