1
# Copyright (c) 2008 Twisted Matrix Laboratories.
2
# See LICENSE for details.
5
Tests for L{twisted.cred._digest} and the associated bits in
6
L{twisted.cred.credentials}.
9
from zope.interface.verify import verifyObject
10
from twisted.trial.unittest import TestCase
11
from twisted.python.hashlib import md5, sha1
12
from twisted.internet.address import IPv4Address
13
from twisted.cred.error import LoginFailed
14
from twisted.cred.credentials import calcHA1, calcHA2, IUsernameDigestHash
15
from twisted.cred.credentials import calcResponse, DigestCredentialFactory
18
return s.encode('base64').strip()
21
class FakeDigestCredentialFactory(DigestCredentialFactory):
23
A Fake Digest Credential Factory that generates a predictable
26
def __init__(self, *args, **kwargs):
27
super(FakeDigestCredentialFactory, self).__init__(*args, **kwargs)
31
def _generateNonce(self):
33
Generate a static nonce
35
return '178288758716122392881254770685'
46
class DigestAuthTests(TestCase):
48
L{TestCase} mixin class which defines a number of tests for
49
L{DigestCredentialFactory}. Because this mixin defines C{setUp}, it
50
must be inherited before L{TestCase}.
54
Create a DigestCredentialFactory for testing
56
self.username = "foobar"
57
self.password = "bazquux"
58
self.realm = "test realm"
59
self.algorithm = "md5"
60
self.cnonce = "29fc54aa1641c6fa0e151419361c8f23"
63
self.clientAddress = IPv4Address('TCP', '10.2.3.4', 43125)
65
self.credentialFactory = DigestCredentialFactory(
66
self.algorithm, self.realm)
69
def test_MD5HashA1(self, _algorithm='md5', _hash=md5):
71
L{calcHA1} accepts the C{'md5'} algorithm and returns an MD5 hash of
72
its parameters, excluding the nonce and cnonce.
75
hashA1 = calcHA1(_algorithm, self.username, self.realm, self.password,
77
a1 = '%s:%s:%s' % (self.username, self.realm, self.password)
78
expected = _hash(a1).hexdigest()
79
self.assertEqual(hashA1, expected)
82
def test_MD5SessionHashA1(self):
84
L{calcHA1} accepts the C{'md5-sess'} algorithm and returns an MD5 hash
85
of its parameters, including the nonce and cnonce.
88
hashA1 = calcHA1('md5-sess', self.username, self.realm, self.password,
90
a1 = '%s:%s:%s' % (self.username, self.realm, self.password)
91
ha1 = md5(a1).digest()
92
a1 = '%s:%s:%s' % (ha1, nonce, self.cnonce)
93
expected = md5(a1).hexdigest()
94
self.assertEqual(hashA1, expected)
97
def test_SHAHashA1(self):
99
L{calcHA1} accepts the C{'sha'} algorithm and returns a SHA hash of its
100
parameters, excluding the nonce and cnonce.
102
self.test_MD5HashA1('sha', sha1)
105
def test_MD5HashA2Auth(self, _algorithm='md5', _hash=md5):
107
L{calcHA2} accepts the C{'md5'} algorithm and returns an MD5 hash of
108
its arguments, excluding the entity hash for QOP other than
112
hashA2 = calcHA2(_algorithm, method, self.uri, 'auth', None)
113
a2 = '%s:%s' % (method, self.uri)
114
expected = _hash(a2).hexdigest()
115
self.assertEqual(hashA2, expected)
118
def test_MD5HashA2AuthInt(self, _algorithm='md5', _hash=md5):
120
L{calcHA2} accepts the C{'md5'} algorithm and returns an MD5 hash of
121
its arguments, including the entity hash for QOP of C{'auth-int'}.
124
hentity = 'foobarbaz'
125
hashA2 = calcHA2(_algorithm, method, self.uri, 'auth-int', hentity)
126
a2 = '%s:%s:%s' % (method, self.uri, hentity)
127
expected = _hash(a2).hexdigest()
128
self.assertEqual(hashA2, expected)
131
def test_MD5SessHashA2Auth(self):
133
L{calcHA2} accepts the C{'md5-sess'} algorithm and QOP of C{'auth'} and
134
returns the same value as it does for the C{'md5'} algorithm.
136
self.test_MD5HashA2Auth('md5-sess')
139
def test_MD5SessHashA2AuthInt(self):
141
L{calcHA2} accepts the C{'md5-sess'} algorithm and QOP of C{'auth-int'}
142
and returns the same value as it does for the C{'md5'} algorithm.
144
self.test_MD5HashA2AuthInt('md5-sess')
147
def test_SHAHashA2Auth(self):
149
L{calcHA2} accepts the C{'sha'} algorithm and returns a SHA hash of
150
its arguments, excluding the entity hash for QOP other than
153
self.test_MD5HashA2Auth('sha', sha1)
156
def test_SHAHashA2AuthInt(self):
158
L{calcHA2} accepts the C{'sha'} algorithm and returns a SHA hash of
159
its arguments, including the entity hash for QOP of C{'auth-int'}.
161
self.test_MD5HashA2AuthInt('sha', sha1)
164
def test_MD5HashResponse(self, _algorithm='md5', _hash=md5):
166
L{calcResponse} accepts the C{'md5'} algorithm and returns an MD5 hash
167
of its parameters, excluding the nonce count, client nonce, and QoP
168
value if the nonce count and client nonce are C{None}
174
response = '%s:%s:%s' % (hashA1, nonce, hashA2)
175
expected = _hash(response).hexdigest()
177
digest = calcResponse(hashA1, hashA2, _algorithm, nonce, None, None,
179
self.assertEqual(expected, digest)
182
def test_MD5SessionHashResponse(self):
184
L{calcResponse} accepts the C{'md5-sess'} algorithm and returns an MD5
185
hash of its parameters, excluding the nonce count, client nonce, and
186
QoP value if the nonce count and client nonce are C{None}
188
self.test_MD5HashResponse('md5-sess')
191
def test_SHAHashResponse(self):
193
L{calcResponse} accepts the C{'sha'} algorithm and returns a SHA hash
194
of its parameters, excluding the nonce count, client nonce, and QoP
195
value if the nonce count and client nonce are C{None}
197
self.test_MD5HashResponse('sha', sha1)
200
def test_MD5HashResponseExtra(self, _algorithm='md5', _hash=md5):
202
L{calcResponse} accepts the C{'md5'} algorithm and returns an MD5 hash
203
of its parameters, including the nonce count, client nonce, and QoP
204
value if they are specified.
209
nonceCount = '00000004'
210
clientNonce = 'abcxyz123'
213
response = '%s:%s:%s:%s:%s:%s' % (
214
hashA1, nonce, nonceCount, clientNonce, qop, hashA2)
215
expected = _hash(response).hexdigest()
217
digest = calcResponse(
218
hashA1, hashA2, _algorithm, nonce, nonceCount, clientNonce, qop)
219
self.assertEqual(expected, digest)
222
def test_MD5SessionHashResponseExtra(self):
224
L{calcResponse} accepts the C{'md5-sess'} algorithm and returns an MD5
225
hash of its parameters, including the nonce count, client nonce, and
226
QoP value if they are specified.
228
self.test_MD5HashResponseExtra('md5-sess')
231
def test_SHAHashResponseExtra(self):
233
L{calcResponse} accepts the C{'sha'} algorithm and returns a SHA hash
234
of its parameters, including the nonce count, client nonce, and QoP
235
value if they are specified.
237
self.test_MD5HashResponseExtra('sha', sha1)
240
def formatResponse(self, quotes=True, **kw):
242
Format all given keyword arguments and their values suitably for use as
243
the value of an HTTP header.
245
@types quotes: C{bool}
246
@param quotes: A flag indicating whether to quote the values of each
247
field in the response.
249
@param **kw: Keywords and C{str} values which will be treated as field
250
name/value pairs to include in the result.
253
@return: The given fields formatted for use as an HTTP header value.
255
if 'username' not in kw:
256
kw['username'] = self.username
257
if 'realm' not in kw:
258
kw['realm'] = self.realm
259
if 'algorithm' not in kw:
260
kw['algorithm'] = self.algorithm
263
if 'cnonce' not in kw:
264
kw['cnonce'] = self.cnonce
272
'%s=%s%s%s' % (k, quote, v, quote)
278
def getDigestResponse(self, challenge, ncount):
280
Calculate the response for the given challenge
282
nonce = challenge.get('nonce')
283
algo = challenge.get('algorithm').lower()
284
qop = challenge.get('qop')
287
algo, self.username, self.realm, self.password, nonce, self.cnonce)
288
ha2 = calcHA2(algo, "GET", self.uri, qop, None)
289
expected = calcResponse(ha1, ha2, algo, nonce, ncount, self.cnonce, qop)
293
def test_response(self, quotes=True):
295
L{DigestCredentialFactory.decode} accepts a digest challenge response
296
and parses it into an L{IUsernameHashedPassword} provider.
298
challenge = self.credentialFactory.getChallenge(self.clientAddress.host)
301
clientResponse = self.formatResponse(
303
nonce=challenge['nonce'],
304
response=self.getDigestResponse(challenge, nc),
306
opaque=challenge['opaque'])
307
creds = self.credentialFactory.decode(
308
clientResponse, self.method, self.clientAddress.host)
309
self.assertTrue(creds.checkPassword(self.password))
310
self.assertFalse(creds.checkPassword(self.password + 'wrong'))
313
def test_responseWithoutQuotes(self):
315
L{DigestCredentialFactory.decode} accepts a digest challenge response
316
which does not quote the values of its fields and parses it into an
317
L{IUsernameHashedPassword} provider in the same way it would a
318
response which included quoted field values.
320
self.test_response(False)
323
def test_caseInsensitiveAlgorithm(self):
325
The case of the algorithm value in the response is ignored when
326
checking the credentials.
328
self.algorithm = 'MD5'
332
def test_md5DefaultAlgorithm(self):
334
The algorithm defaults to MD5 if it is not supplied in the response.
336
self.algorithm = None
340
def test_responseWithoutClientIP(self):
342
L{DigestCredentialFactory.decode} accepts a digest challenge response
343
even if the client address it is passed is C{None}.
345
challenge = self.credentialFactory.getChallenge(None)
348
clientResponse = self.formatResponse(
349
nonce=challenge['nonce'],
350
response=self.getDigestResponse(challenge, nc),
352
opaque=challenge['opaque'])
353
creds = self.credentialFactory.decode(clientResponse, self.method, None)
354
self.assertTrue(creds.checkPassword(self.password))
355
self.assertFalse(creds.checkPassword(self.password + 'wrong'))
358
def test_multiResponse(self):
360
L{DigestCredentialFactory.decode} handles multiple responses to a
363
challenge = self.credentialFactory.getChallenge(self.clientAddress.host)
366
clientResponse = self.formatResponse(
367
nonce=challenge['nonce'],
368
response=self.getDigestResponse(challenge, nc),
370
opaque=challenge['opaque'])
372
creds = self.credentialFactory.decode(clientResponse, self.method,
373
self.clientAddress.host)
374
self.assertTrue(creds.checkPassword(self.password))
375
self.assertFalse(creds.checkPassword(self.password + 'wrong'))
378
clientResponse = self.formatResponse(
379
nonce=challenge['nonce'],
380
response=self.getDigestResponse(challenge, nc),
382
opaque=challenge['opaque'])
384
creds = self.credentialFactory.decode(clientResponse, self.method,
385
self.clientAddress.host)
386
self.assertTrue(creds.checkPassword(self.password))
387
self.assertFalse(creds.checkPassword(self.password + 'wrong'))
390
def test_failsWithDifferentMethod(self):
392
L{DigestCredentialFactory.decode} returns an L{IUsernameHashedPassword}
393
provider which rejects a correct password for the given user if the
394
challenge response request is made using a different HTTP method than
395
was used to request the initial challenge.
397
challenge = self.credentialFactory.getChallenge(self.clientAddress.host)
400
clientResponse = self.formatResponse(
401
nonce=challenge['nonce'],
402
response=self.getDigestResponse(challenge, nc),
404
opaque=challenge['opaque'])
405
creds = self.credentialFactory.decode(clientResponse, 'POST',
406
self.clientAddress.host)
407
self.assertFalse(creds.checkPassword(self.password))
408
self.assertFalse(creds.checkPassword(self.password + 'wrong'))
411
def test_noUsername(self):
413
L{DigestCredentialFactory.decode} raises L{LoginFailed} if the response
414
has no username field or if the username field is empty.
416
# Check for no username
417
e = self.assertRaises(
419
self.credentialFactory.decode,
420
self.formatResponse(username=None),
421
self.method, self.clientAddress.host)
422
self.assertEqual(str(e), "Invalid response, no username given.")
424
# Check for an empty username
425
e = self.assertRaises(
427
self.credentialFactory.decode,
428
self.formatResponse(username=""),
429
self.method, self.clientAddress.host)
430
self.assertEqual(str(e), "Invalid response, no username given.")
433
def test_noNonce(self):
435
L{DigestCredentialFactory.decode} raises L{LoginFailed} if the response
438
e = self.assertRaises(
440
self.credentialFactory.decode,
441
self.formatResponse(opaque="abc123"),
442
self.method, self.clientAddress.host)
443
self.assertEqual(str(e), "Invalid response, no nonce given.")
446
def test_noOpaque(self):
448
L{DigestCredentialFactory.decode} raises L{LoginFailed} if the response
451
e = self.assertRaises(
453
self.credentialFactory.decode,
454
self.formatResponse(),
455
self.method, self.clientAddress.host)
456
self.assertEqual(str(e), "Invalid response, no opaque given.")
459
def test_checkHash(self):
461
L{DigestCredentialFactory.decode} returns an L{IUsernameDigestHash}
462
provider which can verify a hash of the form 'username:realm:password'.
464
challenge = self.credentialFactory.getChallenge(self.clientAddress.host)
467
clientResponse = self.formatResponse(
468
nonce=challenge['nonce'],
469
response=self.getDigestResponse(challenge, nc),
471
opaque=challenge['opaque'])
473
creds = self.credentialFactory.decode(clientResponse, self.method,
474
self.clientAddress.host)
475
self.assertTrue(verifyObject(IUsernameDigestHash, creds))
477
cleartext = '%s:%s:%s' % (self.username, self.realm, self.password)
478
hash = md5(cleartext)
479
self.assertTrue(creds.checkHash(hash.hexdigest()))
481
self.assertFalse(creds.checkHash(hash.hexdigest()))
484
def test_invalidOpaque(self):
486
L{DigestCredentialFactory.decode} raises L{LoginFailed} when the opaque
487
value does not contain all the required parts.
489
credentialFactory = FakeDigestCredentialFactory(self.algorithm,
491
challenge = credentialFactory.getChallenge(self.clientAddress.host)
493
exc = self.assertRaises(
495
credentialFactory._verifyOpaque,
498
self.clientAddress.host)
499
self.assertEqual(str(exc), 'Invalid response, invalid opaque value')
501
badOpaque = 'foo-' + b64encode('nonce,clientip')
503
exc = self.assertRaises(
505
credentialFactory._verifyOpaque,
508
self.clientAddress.host)
509
self.assertEqual(str(exc), 'Invalid response, invalid opaque value')
511
exc = self.assertRaises(
513
credentialFactory._verifyOpaque,
516
self.clientAddress.host)
517
self.assertEqual(str(exc), 'Invalid response, invalid opaque value')
520
'foo-' + b64encode('%s,%s,foobar' % (
522
self.clientAddress.host)))
523
exc = self.assertRaises(
525
credentialFactory._verifyOpaque,
528
self.clientAddress.host)
530
str(exc), 'Invalid response, invalid opaque/time values')
533
def test_incompatibleNonce(self):
535
L{DigestCredentialFactory.decode} raises L{LoginFailed} when the given
536
nonce from the response does not match the nonce encoded in the opaque.
538
credentialFactory = FakeDigestCredentialFactory(self.algorithm, self.realm)
539
challenge = credentialFactory.getChallenge(self.clientAddress.host)
541
badNonceOpaque = credentialFactory._generateOpaque(
543
self.clientAddress.host)
545
exc = self.assertRaises(
547
credentialFactory._verifyOpaque,
550
self.clientAddress.host)
553
'Invalid response, incompatible opaque/nonce values')
555
exc = self.assertRaises(
557
credentialFactory._verifyOpaque,
560
self.clientAddress.host)
563
'Invalid response, incompatible opaque/nonce values')
566
def test_incompatibleClientIP(self):
568
L{DigestCredentialFactory.decode} raises L{LoginFailed} when the
569
request comes from a client IP other than what is encoded in the
572
credentialFactory = FakeDigestCredentialFactory(self.algorithm, self.realm)
573
challenge = credentialFactory.getChallenge(self.clientAddress.host)
575
badAddress = '10.0.0.1'
577
self.assertNotEqual(self.clientAddress.host, badAddress)
579
badNonceOpaque = credentialFactory._generateOpaque(
580
challenge['nonce'], badAddress)
584
credentialFactory._verifyOpaque,
587
self.clientAddress.host)
590
def test_oldNonce(self):
592
L{DigestCredentialFactory.decode} raises L{LoginFailed} when the given
593
opaque is older than C{DigestCredentialFactory.CHALLENGE_LIFETIME_SECS}
595
credentialFactory = FakeDigestCredentialFactory(self.algorithm,
597
challenge = credentialFactory.getChallenge(self.clientAddress.host)
599
key = '%s,%s,%s' % (challenge['nonce'],
600
self.clientAddress.host,
602
digest = md5(key + credentialFactory.privateKey).hexdigest()
603
ekey = b64encode(key)
605
oldNonceOpaque = '%s-%s' % (digest, ekey.strip('\n'))
609
credentialFactory._verifyOpaque,
612
self.clientAddress.host)
615
def test_mismatchedOpaqueChecksum(self):
617
L{DigestCredentialFactory.decode} raises L{LoginFailed} when the opaque
618
checksum fails verification.
620
credentialFactory = FakeDigestCredentialFactory(self.algorithm,
622
challenge = credentialFactory.getChallenge(self.clientAddress.host)
624
key = '%s,%s,%s' % (challenge['nonce'],
625
self.clientAddress.host,
628
digest = md5(key + 'this is not the right pkey').hexdigest()
629
badChecksum = '%s-%s' % (digest, b64encode(key))
633
credentialFactory._verifyOpaque,
636
self.clientAddress.host)
639
def test_incompatibleCalcHA1Options(self):
641
L{calcHA1} raises L{TypeError} when any of the pszUsername, pszRealm,
642
or pszPassword arguments are specified with the preHA1 keyword
646
("user", "realm", "password", "preHA1"),
647
(None, "realm", None, "preHA1"),
648
(None, None, "password", "preHA1"),
651
for pszUsername, pszRealm, pszPassword, preHA1 in arguments: