Skip to content

Commit b35830e

Browse files
committed
Migrate off passlib to bcrypt, arp1, hashlib and crypt directly.
1 parent ac83cad commit b35830e

File tree

4 files changed

+250
-34
lines changed

4 files changed

+250
-34
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## unreleased v3.9dev
2+
3+
### Add
4+
5+
- Added apr1.py module from https://github.com/Tblue/pyapr1 to support
6+
apr1 hash algorithm.
7+
8+
### Remove
9+
10+
- Removed passlib dependency since it's been unmaintained for 5 years
11+
and is causing compatibility issue with bcrypt module.
12+
13+
### Change
14+
15+
- Used standard library hashlib and crypt to support SHA and CRYPT hash.

requirements.txt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
# passlib can use several libs to provide bcrypt (which is required for htpasswd support),
2-
# but passlib deprecated support for py-bcrypt, a bcrypt lib alternative.
3-
# The [bcrypt] extra ensures we use bcrypt instead of some other lib.
4-
passlib[bcrypt]>=1.7.1,<1.8.0
1+
bcrypt>=4.3.0

st2auth_flat_file_backend/apr1.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# pyapr1 - A Python implementation of the APR1 algorithm
2+
#
3+
# Copyright (c) 2015, Tilman Blumenbach
4+
# All rights reserved.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice, this
10+
# list of conditions and the following disclaimer.
11+
#
12+
# * Redistributions in binary form must reproduce the above copyright notice,
13+
# this list of conditions and the following disclaimer in the documentation
14+
# and/or other materials provided with the distribution.
15+
#
16+
# * Neither the name of pyapr1 nor the names of its
17+
# contributors may be used to endorse or promote products derived from
18+
# this software without specific prior written permission.
19+
#
20+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
31+
# The to64() and hash_apr1() functions are based on code from the Apache Portable
32+
# Runtime Utility Library (namely, on the two functions to64() and
33+
# apr_md5_encode() from the file crypto/apr_md5.c). The licenses for that original
34+
# material are included below:
35+
#
36+
# ============================================================================
37+
#
38+
# Licensed to the Apache Software Foundation (ASF) under one or more
39+
# contributor license agreements. See the NOTICE file distributed with
40+
# this work for additional information regarding copyright ownership.
41+
# The ASF licenses this file to You under the Apache License, Version 2.0
42+
# (the "License"); you may not use this file except in compliance with
43+
# the License. You may obtain a copy of the License at
44+
#
45+
# http://www.apache.org/licenses/LICENSE-2.0
46+
#
47+
# Unless required by applicable law or agreed to in writing, software
48+
# distributed under the License is distributed on an "AS IS" BASIS,
49+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
50+
# See the License for the specific language governing permissions and
51+
# limitations under the License.
52+
#
53+
#
54+
#
55+
# The apr_md5_encode() routine uses much code obtained from the FreeBSD 3.0
56+
# MD5 crypt() function, which is licenced as follows:
57+
# ----------------------------------------------------------------------------
58+
# "THE BEER-WARE LICENSE" (Revision 42):
59+
# <[email protected]> wrote this file. As long as you retain this notice you
60+
# can do whatever you want with this stuff. If we meet some day, and you think
61+
# this stuff is worth it, you can buy me a beer in return. Poul-Henning Kamp
62+
# ----------------------------------------------------------------------------
63+
#
64+
# ============================================================================
65+
66+
# 20250910 - This file was sourced from https://github.com/Tblue/pyapr1 and is used
67+
# to provide backward compatibility with older htpasswd tools, notably
68+
# apache 2.2 and lower. The apr1 algorithm is not considered secure and
69+
# bcrypt is currently the recommended algorithm.
70+
71+
import os
72+
import sys
73+
74+
from hashlib import md5
75+
from time import sleep
76+
77+
78+
def to64(data, n_out):
79+
chars = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
80+
out = ""
81+
82+
for i in range(n_out):
83+
out += chars[data & 0x3F]
84+
data >>= 6
85+
86+
return out
87+
88+
89+
def mkint(data, *indexes):
90+
r = 0
91+
for i, idx in enumerate(indexes):
92+
r |= data[idx] << 8 * (len(indexes) - i - 1)
93+
94+
return r
95+
96+
97+
def hash_apr1(salt, password):
98+
sb = bytes(salt, "utf-8")
99+
pb = bytes(password, "utf-8")
100+
ph = md5()
101+
102+
# First, the password.
103+
ph.update(pb)
104+
# Then, the magic string.
105+
ph.update(b"$apr1$")
106+
# Then, the salt.
107+
ph.update(sb)
108+
109+
# Weird stuff.
110+
sandwich = md5(pb + sb + pb).digest()
111+
ndig, nrem = divmod(len(pb), ph.digest_size)
112+
for n in ndig * [ph.digest_size] + [nrem]:
113+
ph.update(sandwich[:n])
114+
115+
# Even more weird stuff.
116+
i = len(pb)
117+
while i:
118+
if i & 1:
119+
ph.update(b"\0")
120+
else:
121+
ph.update(pb[:1])
122+
123+
i >>= 1
124+
125+
final = ph.digest()
126+
for i in range(1000):
127+
maelstrom = md5()
128+
129+
if i & 1:
130+
maelstrom.update(pb)
131+
else:
132+
maelstrom.update(final)
133+
134+
if i % 3:
135+
maelstrom.update(sb)
136+
137+
if i % 7:
138+
maelstrom.update(pb)
139+
140+
if i & 1:
141+
maelstrom.update(final)
142+
else:
143+
maelstrom.update(pb)
144+
145+
final = maelstrom.digest()
146+
147+
pw_ascii = (
148+
to64(mkint(final, 0, 6, 12), 4)
149+
+ to64(mkint(final, 1, 7, 13), 4)
150+
+ to64(mkint(final, 2, 8, 14), 4)
151+
+ to64(mkint(final, 3, 9, 15), 4)
152+
+ to64(mkint(final, 4, 10, 5), 4)
153+
+ to64(mkint(final, 11), 2)
154+
)
155+
156+
return "$apr1$%s$%s" % (salt, pw_ascii)
157+
158+
159+
def generate_salt():
160+
return to64(mkint(os.urandom(6), *range(6)), 8)

st2auth_flat_file_backend/flat_file.py

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,51 +14,97 @@
1414
# limitations under the License.
1515

1616
import logging
17+
import locale
18+
import bcrypt
19+
import base64
1720
import hashlib
21+
import apr1
22+
import crypt # deprecated in 3.11 and removed in 3.13.
23+
from hmac import compare_digest as compare_hash
24+
25+
# Reference: https://httpd.apache.org/docs/2.4/misc/password_encryptions.html
26+
# https://akkadia.org/drepper/SHA-crypt.txt
1827

1928
# empirical format for htpasswd using apache utils 2.4.41
2029
# md5sum:$apr1$AmfEURVX$U0A7kxYcofNn2J.lptuOn0
2130
# bcrypt:$2y$05$LtdiiELPayMNfwk5PMWA2uOMNAWW9wacCrYgN.lXUR35YEG.kOPWO
22-
# crypt:znExVsGU19vAQ
2331
# sha:{SHA}C5wmJdwh7wX2rU3fR8XyA4N6oyw=
32+
# crypt:znExVsGU19vAQ
2433
# plain:toto
2534

26-
27-
try:
28-
from passlib.apache import HtpasswdFile
29-
except ModuleNotFoundError:
30-
try:
31-
import bcrypt
32-
except Exception as e:
33-
raise e
34-
3535
__all__ = ["FlatFileAuthenticationBackend"]
3636

3737
LOG = logging.getLogger(__name__)
3838

39-
COMMENT_MARKER = "**COMMENTIGNORE**"
4039

41-
42-
class HttpasswdFileWithComments(HtpasswdFile):
40+
class HtpasswdFile(object):
4341
"""
44-
Custom HtpasswdFile implementation which supports comments (lines starting
45-
with #).
42+
Custom HtpasswdFile implementation which supports comments
43+
(lines starting with #).
4644
"""
4745

48-
def _load_lines(self, lines):
49-
super(HttpasswdFileWithComments, self)._load_lines(lines=lines)
50-
51-
# Filter out comments
52-
self._records.pop(COMMENT_MARKER, None)
53-
assert COMMENT_MARKER not in self._records
46+
def __init__(self, filename):
47+
self.filename = filename
48+
self.entries = {}
49+
self._load_file()
5450

55-
def _parse_record(self, record, lineno):
56-
if record.startswith(b"#"):
57-
# Comment, add special marker so we can filter it out later
58-
return (COMMENT_MARKER, None)
51+
def _load_file(self):
52+
"""
53+
Load apache htpasswd formatted file with support for lines starting with "#"
54+
as comments. The format is a single line per record as <username>:<hash>
5955
60-
result = super(HttpasswdFileWithComments, self)._parse_record(record=record, lineno=lineno)
61-
return result
56+
Records are added to the 'entries' dictionary with the username as the key
57+
and hash data as the value.
58+
"""
59+
data = None
60+
with open(self.filename, "r") as f:
61+
data = f.readlines()
62+
for line in data:
63+
line = line.strip()
64+
if line.startswith("#"):
65+
LOG.debug(f"Skip comment {line}")
66+
continue
67+
if ":" not in line:
68+
LOG.debug(f"Malformed entry '{line}'.")
69+
continue
70+
username, hash_data = line.split(":", 1)
71+
self.entries[username] = hash_data
72+
73+
def check_password(self, username, password):
74+
if username in self.entries:
75+
hash_data = self.entries[username]
76+
encode_local = locale.getpreferredencoding()
77+
pw = bytes(password, encoding=encode_local)
78+
if hash_data.startswith("$apr1$"):
79+
LOG.warning(
80+
"%s uses MD5 algorithm to hash the password."
81+
"Rehash the password with bcrypt is strongly recommended.",
82+
username,
83+
)
84+
_, _, salt, md5hash = hash_data.split("$")
85+
return apr1.hash_apr1(salt, password) == hash_data
86+
elif hash_data.startswith("$2y$"):
87+
return bcrypt.checkpw(pw, bytes(hash_data, encoding=encode_local))
88+
elif hash_data.startswith("{SHA}"):
89+
LOG.warning(
90+
"%s uses deprecated SHA algorithm to hash password."
91+
"Rehash the password with bcrypt.",
92+
username,
93+
)
94+
return bytes(hash_data, encoding=encode_local) == b"{SHA}" + base64.b64encode(
95+
hashlib.sha1(pw).digest()
96+
)
97+
else:
98+
# crypt is deprecated and will be dropped in python 3.13.
99+
LOG.warning(
100+
"%s uses deprecated crypt algorithm to hash password."
101+
"Rehash the password with bcrypt.",
102+
username,
103+
)
104+
return compare_hash(crypt.crypt(password, hash_data), hash_data)
105+
else:
106+
# User not found.
107+
return None
62108

63109

64110
class FlatFileAuthenticationBackend(object):
@@ -68,8 +114,6 @@ class FlatFileAuthenticationBackend(object):
68114
Entries need to be in a htpasswd file like format. This means entries can be managed with
69115
the htpasswd utility (https://httpd.apache.org/docs/current/programs/htpasswd.html) which
70116
ships with Apache HTTP server.
71-
72-
Note: This backends depends on the "passlib" library.
73117
"""
74118

75119
def __init__(self, file_path):
@@ -80,7 +124,7 @@ def __init__(self, file_path):
80124
self._file_path = file_path
81125

82126
def authenticate(self, username, password):
83-
htpasswd_file = HttpasswdFileWithComments(path=self._file_path)
127+
htpasswd_file = HtpasswdFile(self._file_path)
84128
result = htpasswd_file.check_password(username, password)
85129

86130
if result is None:

0 commit comments

Comments
 (0)