123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698 |
- # Copyright (c) Twisted Matrix Laboratories.
- # See LICENSE for details.
- """
- Tests for L{twisted.cred._digest} and the associated bits in
- L{twisted.cred.credentials}.
- """
- from __future__ import division, absolute_import
- import base64
- from binascii import hexlify
- from hashlib import md5, sha1
- from zope.interface.verify import verifyObject
- from twisted.trial.unittest import TestCase
- from twisted.internet.address import IPv4Address
- from twisted.cred.error import LoginFailed
- from twisted.cred.credentials import calcHA1, calcHA2, IUsernameDigestHash
- from twisted.cred.credentials import calcResponse, DigestCredentialFactory
- from twisted.python.compat import networkString
- def b64encode(s):
- return base64.b64encode(s).strip()
- class FakeDigestCredentialFactory(DigestCredentialFactory):
- """
- A Fake Digest Credential Factory that generates a predictable
- nonce and opaque
- """
- def __init__(self, *args, **kwargs):
- super(FakeDigestCredentialFactory, self).__init__(*args, **kwargs)
- self.privateKey = b"0"
- def _generateNonce(self):
- """
- Generate a static nonce
- """
- return b'178288758716122392881254770685'
- def _getTime(self):
- """
- Return a stable time
- """
- return 0
- class DigestAuthTests(TestCase):
- """
- L{TestCase} mixin class which defines a number of tests for
- L{DigestCredentialFactory}. Because this mixin defines C{setUp}, it
- must be inherited before L{TestCase}.
- """
- def setUp(self):
- """
- Create a DigestCredentialFactory for testing
- """
- self.username = b"foobar"
- self.password = b"bazquux"
- self.realm = b"test realm"
- self.algorithm = b"md5"
- self.cnonce = b"29fc54aa1641c6fa0e151419361c8f23"
- self.qop = b"auth"
- self.uri = b"/write/"
- self.clientAddress = IPv4Address('TCP', '10.2.3.4', 43125)
- self.method = b'GET'
- self.credentialFactory = DigestCredentialFactory(
- self.algorithm, self.realm)
- def test_MD5HashA1(self, _algorithm=b'md5', _hash=md5):
- """
- L{calcHA1} accepts the C{'md5'} algorithm and returns an MD5 hash of
- its parameters, excluding the nonce and cnonce.
- """
- nonce = b'abc123xyz'
- hashA1 = calcHA1(_algorithm, self.username, self.realm, self.password,
- nonce, self.cnonce)
- a1 = b":".join((self.username, self.realm, self.password))
- expected = hexlify(_hash(a1).digest())
- self.assertEqual(hashA1, expected)
- def test_MD5SessionHashA1(self):
- """
- L{calcHA1} accepts the C{'md5-sess'} algorithm and returns an MD5 hash
- of its parameters, including the nonce and cnonce.
- """
- nonce = b'xyz321abc'
- hashA1 = calcHA1(b'md5-sess', self.username, self.realm, self.password,
- nonce, self.cnonce)
- a1 = self.username + b':' + self.realm + b':' + self.password
- ha1 = hexlify(md5(a1).digest())
- a1 = ha1 + b':' + nonce + b':' + self.cnonce
- expected = hexlify(md5(a1).digest())
- self.assertEqual(hashA1, expected)
- def test_SHAHashA1(self):
- """
- L{calcHA1} accepts the C{'sha'} algorithm and returns a SHA hash of its
- parameters, excluding the nonce and cnonce.
- """
- self.test_MD5HashA1(b'sha', sha1)
- def test_MD5HashA2Auth(self, _algorithm=b'md5', _hash=md5):
- """
- L{calcHA2} accepts the C{'md5'} algorithm and returns an MD5 hash of
- its arguments, excluding the entity hash for QOP other than
- C{'auth-int'}.
- """
- method = b'GET'
- hashA2 = calcHA2(_algorithm, method, self.uri, b'auth', None)
- a2 = method + b':' + self.uri
- expected = hexlify(_hash(a2).digest())
- self.assertEqual(hashA2, expected)
- def test_MD5HashA2AuthInt(self, _algorithm=b'md5', _hash=md5):
- """
- L{calcHA2} accepts the C{'md5'} algorithm and returns an MD5 hash of
- its arguments, including the entity hash for QOP of C{'auth-int'}.
- """
- method = b'GET'
- hentity = b'foobarbaz'
- hashA2 = calcHA2(_algorithm, method, self.uri, b'auth-int', hentity)
- a2 = method + b':' + self.uri + b':' + hentity
- expected = hexlify(_hash(a2).digest())
- self.assertEqual(hashA2, expected)
- def test_MD5SessHashA2Auth(self):
- """
- L{calcHA2} accepts the C{'md5-sess'} algorithm and QOP of C{'auth'} and
- returns the same value as it does for the C{'md5'} algorithm.
- """
- self.test_MD5HashA2Auth(b'md5-sess')
- def test_MD5SessHashA2AuthInt(self):
- """
- L{calcHA2} accepts the C{'md5-sess'} algorithm and QOP of C{'auth-int'}
- and returns the same value as it does for the C{'md5'} algorithm.
- """
- self.test_MD5HashA2AuthInt(b'md5-sess')
- def test_SHAHashA2Auth(self):
- """
- L{calcHA2} accepts the C{'sha'} algorithm and returns a SHA hash of
- its arguments, excluding the entity hash for QOP other than
- C{'auth-int'}.
- """
- self.test_MD5HashA2Auth(b'sha', sha1)
- def test_SHAHashA2AuthInt(self):
- """
- L{calcHA2} accepts the C{'sha'} algorithm and returns a SHA hash of
- its arguments, including the entity hash for QOP of C{'auth-int'}.
- """
- self.test_MD5HashA2AuthInt(b'sha', sha1)
- def test_MD5HashResponse(self, _algorithm=b'md5', _hash=md5):
- """
- L{calcResponse} accepts the C{'md5'} algorithm and returns an MD5 hash
- of its parameters, excluding the nonce count, client nonce, and QoP
- value if the nonce count and client nonce are L{None}
- """
- hashA1 = b'abc123'
- hashA2 = b'789xyz'
- nonce = b'lmnopq'
- response = hashA1 + b':' + nonce + b':' + hashA2
- expected = hexlify(_hash(response).digest())
- digest = calcResponse(hashA1, hashA2, _algorithm, nonce, None, None,
- None)
- self.assertEqual(expected, digest)
- def test_MD5SessionHashResponse(self):
- """
- L{calcResponse} accepts the C{'md5-sess'} algorithm and returns an MD5
- hash of its parameters, excluding the nonce count, client nonce, and
- QoP value if the nonce count and client nonce are L{None}
- """
- self.test_MD5HashResponse(b'md5-sess')
- def test_SHAHashResponse(self):
- """
- L{calcResponse} accepts the C{'sha'} algorithm and returns a SHA hash
- of its parameters, excluding the nonce count, client nonce, and QoP
- value if the nonce count and client nonce are L{None}
- """
- self.test_MD5HashResponse(b'sha', sha1)
- def test_MD5HashResponseExtra(self, _algorithm=b'md5', _hash=md5):
- """
- L{calcResponse} accepts the C{'md5'} algorithm and returns an MD5 hash
- of its parameters, including the nonce count, client nonce, and QoP
- value if they are specified.
- """
- hashA1 = b'abc123'
- hashA2 = b'789xyz'
- nonce = b'lmnopq'
- nonceCount = b'00000004'
- clientNonce = b'abcxyz123'
- qop = b'auth'
- response = hashA1 + b':' + nonce + b':' + nonceCount + b':' +\
- clientNonce + b':' + qop + b':' + hashA2
- expected = hexlify(_hash(response).digest())
- digest = calcResponse(
- hashA1, hashA2, _algorithm, nonce, nonceCount, clientNonce, qop)
- self.assertEqual(expected, digest)
- def test_MD5SessionHashResponseExtra(self):
- """
- L{calcResponse} accepts the C{'md5-sess'} algorithm and returns an MD5
- hash of its parameters, including the nonce count, client nonce, and
- QoP value if they are specified.
- """
- self.test_MD5HashResponseExtra(b'md5-sess')
- def test_SHAHashResponseExtra(self):
- """
- L{calcResponse} accepts the C{'sha'} algorithm and returns a SHA hash
- of its parameters, including the nonce count, client nonce, and QoP
- value if they are specified.
- """
- self.test_MD5HashResponseExtra(b'sha', sha1)
- def formatResponse(self, quotes=True, **kw):
- """
- Format all given keyword arguments and their values suitably for use as
- the value of an HTTP header.
- @types quotes: C{bool}
- @param quotes: A flag indicating whether to quote the values of each
- field in the response.
- @param **kw: Keywords and C{bytes} values which will be treated as field
- name/value pairs to include in the result.
- @rtype: C{bytes}
- @return: The given fields formatted for use as an HTTP header value.
- """
- if 'username' not in kw:
- kw['username'] = self.username
- if 'realm' not in kw:
- kw['realm'] = self.realm
- if 'algorithm' not in kw:
- kw['algorithm'] = self.algorithm
- if 'qop' not in kw:
- kw['qop'] = self.qop
- if 'cnonce' not in kw:
- kw['cnonce'] = self.cnonce
- if 'uri' not in kw:
- kw['uri'] = self.uri
- if quotes:
- quote = b'"'
- else:
- quote = b''
- return b', '.join([
- b"".join((networkString(k), b"=", quote, v, quote))
- for (k, v)
- in kw.items()
- if v is not None])
- def getDigestResponse(self, challenge, ncount):
- """
- Calculate the response for the given challenge
- """
- nonce = challenge.get('nonce')
- algo = challenge.get('algorithm').lower()
- qop = challenge.get('qop')
- ha1 = calcHA1(
- algo, self.username, self.realm, self.password, nonce, self.cnonce)
- ha2 = calcHA2(algo, b"GET", self.uri, qop, None)
- expected = calcResponse(
- ha1, ha2, algo, nonce, ncount, self.cnonce, qop)
- return expected
- def test_response(self, quotes=True):
- """
- L{DigestCredentialFactory.decode} accepts a digest challenge response
- and parses it into an L{IUsernameHashedPassword} provider.
- """
- challenge = self.credentialFactory.getChallenge(
- self.clientAddress.host)
- nc = b"00000001"
- clientResponse = self.formatResponse(
- quotes=quotes,
- nonce=challenge['nonce'],
- response=self.getDigestResponse(challenge, nc),
- nc=nc,
- opaque=challenge['opaque'])
- creds = self.credentialFactory.decode(
- clientResponse, self.method, self.clientAddress.host)
- self.assertTrue(creds.checkPassword(self.password))
- self.assertFalse(creds.checkPassword(self.password + b'wrong'))
- def test_responseWithoutQuotes(self):
- """
- L{DigestCredentialFactory.decode} accepts a digest challenge response
- which does not quote the values of its fields and parses it into an
- L{IUsernameHashedPassword} provider in the same way it would a
- response which included quoted field values.
- """
- self.test_response(False)
- def test_responseWithCommaURI(self):
- """
- L{DigestCredentialFactory.decode} accepts a digest challenge response
- which quotes the values of its fields and includes a C{b","} in the URI
- field.
- """
- self.uri = b"/some,path/"
- self.test_response(True)
- def test_caseInsensitiveAlgorithm(self):
- """
- The case of the algorithm value in the response is ignored when
- checking the credentials.
- """
- self.algorithm = b'MD5'
- self.test_response()
- def test_md5DefaultAlgorithm(self):
- """
- The algorithm defaults to MD5 if it is not supplied in the response.
- """
- self.algorithm = None
- self.test_response()
- def test_responseWithoutClientIP(self):
- """
- L{DigestCredentialFactory.decode} accepts a digest challenge response
- even if the client address it is passed is L{None}.
- """
- challenge = self.credentialFactory.getChallenge(None)
- nc = b"00000001"
- clientResponse = self.formatResponse(
- nonce=challenge['nonce'],
- response=self.getDigestResponse(challenge, nc),
- nc=nc,
- opaque=challenge['opaque'])
- creds = self.credentialFactory.decode(clientResponse, self.method,
- None)
- self.assertTrue(creds.checkPassword(self.password))
- self.assertFalse(creds.checkPassword(self.password + b'wrong'))
- def test_multiResponse(self):
- """
- L{DigestCredentialFactory.decode} handles multiple responses to a
- single challenge.
- """
- challenge = self.credentialFactory.getChallenge(
- self.clientAddress.host)
- nc = b"00000001"
- clientResponse = self.formatResponse(
- nonce=challenge['nonce'],
- response=self.getDigestResponse(challenge, nc),
- nc=nc,
- opaque=challenge['opaque'])
- creds = self.credentialFactory.decode(clientResponse, self.method,
- self.clientAddress.host)
- self.assertTrue(creds.checkPassword(self.password))
- self.assertFalse(creds.checkPassword(self.password + b'wrong'))
- nc = b"00000002"
- clientResponse = self.formatResponse(
- nonce=challenge['nonce'],
- response=self.getDigestResponse(challenge, nc),
- nc=nc,
- opaque=challenge['opaque'])
- creds = self.credentialFactory.decode(clientResponse, self.method,
- self.clientAddress.host)
- self.assertTrue(creds.checkPassword(self.password))
- self.assertFalse(creds.checkPassword(self.password + b'wrong'))
- def test_failsWithDifferentMethod(self):
- """
- L{DigestCredentialFactory.decode} returns an L{IUsernameHashedPassword}
- provider which rejects a correct password for the given user if the
- challenge response request is made using a different HTTP method than
- was used to request the initial challenge.
- """
- challenge = self.credentialFactory.getChallenge(
- self.clientAddress.host)
- nc = b"00000001"
- clientResponse = self.formatResponse(
- nonce=challenge['nonce'],
- response=self.getDigestResponse(challenge, nc),
- nc=nc,
- opaque=challenge['opaque'])
- creds = self.credentialFactory.decode(clientResponse, b'POST',
- self.clientAddress.host)
- self.assertFalse(creds.checkPassword(self.password))
- self.assertFalse(creds.checkPassword(self.password + b'wrong'))
- def test_noUsername(self):
- """
- L{DigestCredentialFactory.decode} raises L{LoginFailed} if the response
- has no username field or if the username field is empty.
- """
- # Check for no username
- e = self.assertRaises(
- LoginFailed,
- self.credentialFactory.decode,
- self.formatResponse(username=None),
- self.method, self.clientAddress.host)
- self.assertEqual(str(e), "Invalid response, no username given.")
- # Check for an empty username
- e = self.assertRaises(
- LoginFailed,
- self.credentialFactory.decode,
- self.formatResponse(username=b""),
- self.method, self.clientAddress.host)
- self.assertEqual(str(e), "Invalid response, no username given.")
- def test_noNonce(self):
- """
- L{DigestCredentialFactory.decode} raises L{LoginFailed} if the response
- has no nonce.
- """
- e = self.assertRaises(
- LoginFailed,
- self.credentialFactory.decode,
- self.formatResponse(opaque=b"abc123"),
- self.method, self.clientAddress.host)
- self.assertEqual(str(e), "Invalid response, no nonce given.")
- def test_noOpaque(self):
- """
- L{DigestCredentialFactory.decode} raises L{LoginFailed} if the response
- has no opaque.
- """
- e = self.assertRaises(
- LoginFailed,
- self.credentialFactory.decode,
- self.formatResponse(),
- self.method, self.clientAddress.host)
- self.assertEqual(str(e), "Invalid response, no opaque given.")
- def test_checkHash(self):
- """
- L{DigestCredentialFactory.decode} returns an L{IUsernameDigestHash}
- provider which can verify a hash of the form 'username:realm:password'.
- """
- challenge = self.credentialFactory.getChallenge(
- self.clientAddress.host)
- nc = b"00000001"
- clientResponse = self.formatResponse(
- nonce=challenge['nonce'],
- response=self.getDigestResponse(challenge, nc),
- nc=nc,
- opaque=challenge['opaque'])
- creds = self.credentialFactory.decode(clientResponse, self.method,
- self.clientAddress.host)
- self.assertTrue(verifyObject(IUsernameDigestHash, creds))
- cleartext = self.username + b":" + self.realm + b":" + self.password
- hash = md5(cleartext)
- self.assertTrue(creds.checkHash(hexlify(hash.digest())))
- hash.update(b'wrong')
- self.assertFalse(creds.checkHash(hexlify(hash.digest())))
- def test_invalidOpaque(self):
- """
- L{DigestCredentialFactory.decode} raises L{LoginFailed} when the opaque
- value does not contain all the required parts.
- """
- credentialFactory = FakeDigestCredentialFactory(self.algorithm,
- self.realm)
- challenge = credentialFactory.getChallenge(self.clientAddress.host)
- exc = self.assertRaises(
- LoginFailed,
- credentialFactory._verifyOpaque,
- b'badOpaque',
- challenge['nonce'],
- self.clientAddress.host)
- self.assertEqual(str(exc), 'Invalid response, invalid opaque value')
- badOpaque = b'foo-' + b64encode(b'nonce,clientip')
- exc = self.assertRaises(
- LoginFailed,
- credentialFactory._verifyOpaque,
- badOpaque,
- challenge['nonce'],
- self.clientAddress.host)
- self.assertEqual(str(exc), 'Invalid response, invalid opaque value')
- exc = self.assertRaises(
- LoginFailed,
- credentialFactory._verifyOpaque,
- b'',
- challenge['nonce'],
- self.clientAddress.host)
- self.assertEqual(str(exc), 'Invalid response, invalid opaque value')
- badOpaque = b'foo-' + b64encode(
- b",".join((challenge['nonce'],
- networkString(self.clientAddress.host),
- b"foobar")))
- exc = self.assertRaises(
- LoginFailed,
- credentialFactory._verifyOpaque,
- badOpaque,
- challenge['nonce'],
- self.clientAddress.host)
- self.assertEqual(
- str(exc), 'Invalid response, invalid opaque/time values')
- def test_incompatibleNonce(self):
- """
- L{DigestCredentialFactory.decode} raises L{LoginFailed} when the given
- nonce from the response does not match the nonce encoded in the opaque.
- """
- credentialFactory = FakeDigestCredentialFactory(self.algorithm,
- self.realm)
- challenge = credentialFactory.getChallenge(self.clientAddress.host)
- badNonceOpaque = credentialFactory._generateOpaque(
- b'1234567890',
- self.clientAddress.host)
- exc = self.assertRaises(
- LoginFailed,
- credentialFactory._verifyOpaque,
- badNonceOpaque,
- challenge['nonce'],
- self.clientAddress.host)
- self.assertEqual(
- str(exc),
- 'Invalid response, incompatible opaque/nonce values')
- exc = self.assertRaises(
- LoginFailed,
- credentialFactory._verifyOpaque,
- badNonceOpaque,
- b'',
- self.clientAddress.host)
- self.assertEqual(
- str(exc),
- 'Invalid response, incompatible opaque/nonce values')
- def test_incompatibleClientIP(self):
- """
- L{DigestCredentialFactory.decode} raises L{LoginFailed} when the
- request comes from a client IP other than what is encoded in the
- opaque.
- """
- credentialFactory = FakeDigestCredentialFactory(self.algorithm,
- self.realm)
- challenge = credentialFactory.getChallenge(self.clientAddress.host)
- badAddress = '10.0.0.1'
- # Sanity check
- self.assertNotEqual(self.clientAddress.host, badAddress)
- badNonceOpaque = credentialFactory._generateOpaque(
- challenge['nonce'], badAddress)
- self.assertRaises(
- LoginFailed,
- credentialFactory._verifyOpaque,
- badNonceOpaque,
- challenge['nonce'],
- self.clientAddress.host)
- def test_oldNonce(self):
- """
- L{DigestCredentialFactory.decode} raises L{LoginFailed} when the given
- opaque is older than C{DigestCredentialFactory.CHALLENGE_LIFETIME_SECS}
- """
- credentialFactory = FakeDigestCredentialFactory(self.algorithm,
- self.realm)
- challenge = credentialFactory.getChallenge(self.clientAddress.host)
- key = b",".join((challenge['nonce'],
- networkString(self.clientAddress.host),
- b'-137876876'))
- digest = hexlify(md5(key + credentialFactory.privateKey).digest())
- ekey = b64encode(key)
- oldNonceOpaque = b"-".join((digest, ekey.strip(b'\n')))
- self.assertRaises(
- LoginFailed,
- credentialFactory._verifyOpaque,
- oldNonceOpaque,
- challenge['nonce'],
- self.clientAddress.host)
- def test_mismatchedOpaqueChecksum(self):
- """
- L{DigestCredentialFactory.decode} raises L{LoginFailed} when the opaque
- checksum fails verification.
- """
- credentialFactory = FakeDigestCredentialFactory(self.algorithm,
- self.realm)
- challenge = credentialFactory.getChallenge(self.clientAddress.host)
- key = b",".join((challenge['nonce'],
- networkString(self.clientAddress.host),
- b'0'))
- digest = hexlify(md5(key + b'this is not the right pkey').digest())
- badChecksum = b"-".join((digest, b64encode(key)))
- self.assertRaises(
- LoginFailed,
- credentialFactory._verifyOpaque,
- badChecksum,
- challenge['nonce'],
- self.clientAddress.host)
- def test_incompatibleCalcHA1Options(self):
- """
- L{calcHA1} raises L{TypeError} when any of the pszUsername, pszRealm,
- or pszPassword arguments are specified with the preHA1 keyword
- argument.
- """
- arguments = (
- (b"user", b"realm", b"password", b"preHA1"),
- (None, b"realm", None, b"preHA1"),
- (None, None, b"password", b"preHA1"),
- )
- for pszUsername, pszRealm, pszPassword, preHA1 in arguments:
- self.assertRaises(
- TypeError,
- calcHA1,
- b"md5",
- pszUsername,
- pszRealm,
- pszPassword,
- b"nonce",
- b"cnonce",
- preHA1=preHA1)
- def test_noNewlineOpaque(self):
- """
- L{DigestCredentialFactory._generateOpaque} returns a value without
- newlines, regardless of the length of the nonce.
- """
- opaque = self.credentialFactory._generateOpaque(
- b"long nonce " * 10, None)
- self.assertNotIn(b'\n', opaque)
|