Skip to content

Commit 008f554

Browse files
committed
feat(authentication.digest): add support for SHA-512-256 algorithm
1 parent 138ba85 commit 008f554

2 files changed

Lines changed: 98 additions & 23 deletions

File tree

httoop/authentication/digest.py

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,43 @@
11
from __future__ import annotations
22

3-
from hashlib import md5, sha256
3+
from hashlib import md5, new, sha256
44
from hmac import compare_digest
5+
from time import time
56
from typing import Callable
7+
from uuid import uuid4
68

79
from httoop.exceptions import InvalidHeader
810
from httoop.header.element import HeaderElement
911
from httoop.util import ByteUnicodeDict, _
1012

1113

1214
class DigestAuthScheme:
13-
1415
algorithms = {
15-
'MD5': lambda val: md5(val).hexdigest().encode('ASCII'), # nosec
16-
'MD5-sess': lambda val: md5(val).hexdigest().encode('ASCII'), # nosec
17-
'SHA-256': lambda val: sha256(val).hexdigest().encode('ASCII'),
18-
'SHA-256-sess': lambda val: sha256(val).hexdigest().encode('ASCII'),
19-
# 'SHA-512-256': lambda val: sha256(val).hexdigest().encode('ASCII'), TODO: ??
20-
# 'SHA-512-256-sess': lambda val: sha256(val).hexdigest().encode('ASCII'), TODO: ??
21-
}
16+
'MD5': lambda: md5(), # noqa: S324
17+
'SHA-256': lambda: sha256(),
18+
'SHA-512-256': lambda: new('sha512_256'),
19+
} # not case insensitive per RFC
20+
algorithms['MD5-sess'] = algorithms['MD5']
21+
algorithms['SHA-256-sess'] = algorithms['SHA-256']
22+
algorithms['SHA-512-256-sess'] = algorithms['SHA-512-256']
2223
qops = (b'auth', b'auth-int') # quality of protection
2324

2425
@classmethod
25-
def get_algorithm(cls, algorithm: bytes | str) -> Callable:
26+
def get_algorithm(cls, algorithm: bytes | str) -> Callable[bytes, bytes]:
2627
try:
27-
return cls.algorithms[algorithm.decode('ASCII', 'ignore') if isinstance(algorithm, bytes) else algorithm]
28+
H = cls.algorithms[algorithm.decode('ASCII', 'ignore') if isinstance(algorithm, bytes) else algorithm]
2829
except KeyError:
29-
raise InvalidHeader(_('Unknown digest authentication algorithm: %r'), algorithm)
30+
raise InvalidHeader(_('Unknown digest authentication algorithm: %r'), algorithm) from None
31+
32+
def _algo(value) -> bytes:
33+
h = H()
34+
h.update(value)
35+
return h.hexdigest().encode('ASCII')
36+
37+
return _algo
3038

3139
@classmethod
3240
def generate_nonce(cls, authinfo: ByteUnicodeDict) -> bytes:
33-
from time import time
34-
from uuid import uuid4
35-
3641
nonce = b'%d:%s:%s' % (
3742
time(),
3843
authinfo.get('etag', authinfo.get('realm', b'')),
@@ -176,7 +181,7 @@ def calculate_request_digest(cls, authinfo: ByteUnicodeDict) -> bytes:
176181
algorithm = authinfo.get('algorithm', b'MD5').decode('ASCII', 'replace')
177182
H = cls.get_algorithm(algorithm)
178183

179-
if algorithm == 'MD5-sess' and authinfo.get('A1'): # noqa: SIM108
184+
if algorithm.endswith('-sess') and authinfo.get('A1'): # noqa: SIM108
180185
secret = H(authinfo['A1'])
181186
else:
182187
secret = H(cls.A1(authinfo))
@@ -198,18 +203,17 @@ def A2(cls, params: ByteUnicodeDict) -> bytes:
198203
if not qop or qop == b'auth':
199204
return b'%s:%s' % (params['method'], params['uri'])
200205
if qop == b'auth-int':
201-
H = cls.get_algorithm(params['algorithm'])
206+
H = cls.get_algorithm(params.get('algorithm', b'MD5'))
202207
return b'%s:%s:%s' % (params['method'], params['uri'], H(params['entity_body']))
203208
raise NotImplementedError(f'Unknown quality of protection: {qop!r}') # pragma: no cover
204209

205210
@classmethod
206211
def A1(cls, params: ByteUnicodeDict) -> bytes:
207212
algorithm = params.get('algorithm', b'')
208213

209-
if not algorithm or algorithm == b'MD5':
214+
if not algorithm or not algorithm.endswith(b'-sess'):
210215
return b'%s:%s:%s' % (params['username'], params['realm'], params['password'])
211-
if algorithm == b'MD5-sess':
212-
H = cls.get_algorithm(algorithm)
213-
s = b'%s:%s:%s' % (params['username'], params['realm'], params['password'])
214-
return b'%s:%s:%s' % (H(s), params['nonce'], params['cnonce'])
215-
raise NotImplementedError(f'Unknown algorithm: {algorithm}') # pragma: no cover
216+
217+
H = cls.get_algorithm(algorithm)
218+
s = b'%s:%s:%s' % (params['username'], params['realm'], params['password'])
219+
return b'%s:%s:%s' % (H(s), params['nonce'], params['cnonce'])

tests/authentication/test_digest.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@
77
from httoop.header import Authorization, WWWAuthenticate
88

99

10+
RFC7616_MUFASA = {
11+
'username': 'Mufasa',
12+
'realm': 'http-auth@example.org',
13+
'password': 'Circle of Life',
14+
'method': 'GET',
15+
'uri': '/dir/index.html',
16+
'nonce': '7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v',
17+
'nc': '00000001',
18+
'cnonce': 'f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ',
19+
'qop': 'auth',
20+
}
21+
22+
RFC7616_JASON_DOE = {
23+
'username': 'Jäsøn Doe',
24+
'realm': 'api@example.org',
25+
'password': 'Secret, or not?',
26+
'method': 'GET',
27+
'uri': '/doe.json',
28+
'nonce': '5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK',
29+
'nc': '00000001',
30+
'cnonce': 'NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v',
31+
'qop': 'auth',
32+
}
33+
34+
1035
def test_digest_www_authentication(headers):
1136
www_auth = WWWAuthenticate('Digest', {
1237
'realm': 'testrealm@host.com',
@@ -112,6 +137,35 @@ def test_digest_authorization_auth_int(headers):
112137
assert auth.params == headers.element('Authorization').params
113138

114139

140+
@pytest.mark.parametrize(
141+
('algorithm', 'expected'),
142+
[
143+
('MD5', '02c9d6f0ab6dfc20fc9e2105c8fc728b'),
144+
('MD5-sess', '4c187ba5e8ff03c06627fc4e3940fc97'),
145+
('SHA-256', '22be276ffb2b1acc389119cac518f32fb2db8a419f31ec8ba3f395d711920c6e'),
146+
('SHA-256-sess', '28a76fe8f6141a8d4d868ed1c8ff383edf29c5f03b50e1d53e7251654befbe83'),
147+
('SHA-512-256', 'd54bd6b5b9fc948b8135e347402e314d14f62d9041cb6745c7f5331c6809d221'),
148+
('SHA-512-256-sess', '87bad8ef67556e3c82f765be811beeb7c5bc61d4d7c2e538f8561a7cc04027f3'),
149+
],
150+
)
151+
def test_digest_auth_int_all_algorithms(algorithm, expected):
152+
auth = {
153+
'username': b'Mufasa',
154+
'realm': b'testrealm@host.com',
155+
'password': b'Circle Of Life',
156+
'method': b'GET',
157+
'uri': b'/dir/index.html',
158+
'nonce': b'dcd98b7102dd2f0e8b11d0f600bfb0c093',
159+
'nc': b'00000001',
160+
'cnonce': b'0a4f113b',
161+
'qop': b'auth-int',
162+
'algorithm': algorithm.encode(),
163+
'entity_body': b'foo',
164+
}
165+
166+
assert DigestAuthRequestScheme.calculate_request_digest(auth) == expected.encode()
167+
168+
115169
def test_digest_authorization_md5_sess_a1(headers):
116170
auth = Authorization('Digest', {
117171
'username': 'Mufasa',
@@ -211,3 +265,20 @@ def test_required_parameter(params, headers):
211265
with pytest.raises(InvalidHeader) as excinfo:
212266
bytes(element)
213267
assert 'Missing parameter' in str(excinfo)
268+
269+
270+
def test_auth_int_defaults_to_md5_when_algorithm_omitted():
271+
authinfo = {
272+
'username': b'Mufasa',
273+
'realm': b'testrealm@host.com',
274+
'password': b'Circle Of Life',
275+
'method': b'GET',
276+
'uri': b'/dir/index.html',
277+
'nonce': b'dcd98b7102dd2f0e8b11d0f600bfb0c093',
278+
'nc': b'00000001',
279+
'cnonce': b'0a4f113b',
280+
'qop': b'auth-int',
281+
'entity_body': b'',
282+
}
283+
284+
DigestAuthRequestScheme.calculate_request_digest(authinfo)

0 commit comments

Comments
 (0)