[CalendarServer-changes] [1512]
CalendarServer/branches/users/cdaboo/digest-1510
source_changes at macosforge.org
source_changes at macosforge.org
Fri May 4 11:09:20 PDT 2007
Revision: 1512
http://trac.macosforge.org/projects/calendarserver/changeset/1512
Author: cdaboo at apple.com
Date: 2007-05-04 11:09:20 -0700 (Fri, 04 May 2007)
Log Message:
-----------
Improved digest support so that the server keeps track of nonce re-use, including nonce-count etc. A database is used
to track this. The default implementation uses a dict. However, that does not work for multiple servers sharing the
same set of nonce (i.e. load-balancing), so we have another database implementation that uses sqlite and can thus
be shared by all server processes on the same machine, or with a shared file system.
Modified Paths:
--------------
CalendarServer/branches/users/cdaboo/digest-1510/conf/caldavd-test.plist
CalendarServer/branches/users/cdaboo/digest-1510/conf/caldavd.plist
CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/cluster.py
CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/config.py
CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/digest.py
CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/tap.py
Added Paths:
-----------
CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.auth.digest.patch
CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.test.test_httpauth.patch
CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/test/test_digest.py
Modified: CalendarServer/branches/users/cdaboo/digest-1510/conf/caldavd-test.plist
===================================================================
--- CalendarServer/branches/users/cdaboo/digest-1510/conf/caldavd-test.plist 2007-05-04 17:59:50 UTC (rev 1511)
+++ CalendarServer/branches/users/cdaboo/digest-1510/conf/caldavd-test.plist 2007-05-04 18:09:20 UTC (rev 1512)
@@ -68,6 +68,10 @@
Data Store
-->
+ <!-- Data root -->
+ <key>DataRoot</key>
+ <string>data/</string>
+
<!-- Document root -->
<key>DocumentRoot</key>
<string>twistedcaldav/test/data/</string>
@@ -217,8 +221,6 @@
<string>md5</string>
<key>Qop</key>
<string></string>
- <key>Secret</key>
- <string></string>
</dict>
<!-- Kerberos/SPNEGO -->
Modified: CalendarServer/branches/users/cdaboo/digest-1510/conf/caldavd.plist
===================================================================
--- CalendarServer/branches/users/cdaboo/digest-1510/conf/caldavd.plist 2007-05-04 17:59:50 UTC (rev 1511)
+++ CalendarServer/branches/users/cdaboo/digest-1510/conf/caldavd.plist 2007-05-04 18:09:20 UTC (rev 1512)
@@ -66,6 +66,10 @@
Data Store
-->
+ <!-- Data root -->
+ <key>DataRoot</key>
+ <string>/Library/CalendarServer/Data</string>
+
<!-- Document root -->
<key>DocumentRoot</key>
<string>/Library/CalendarServer/Documents</string>
@@ -164,8 +168,6 @@
<string>md5</string>
<key>Qop</key>
<string></string>
- <key>Secret</key>
- <string></string>
</dict>
<!-- Kerberos/SPNEGO -->
Added: CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.auth.digest.patch
===================================================================
--- CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.auth.digest.patch (rev 0)
+++ CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.auth.digest.patch 2007-05-04 18:09:20 UTC (rev 1512)
@@ -0,0 +1,340 @@
+Index: twisted/web2/auth/digest.py
+===================================================================
+--- twisted/web2/auth/digest.py (revision 19773)
++++ twisted/web2/auth/digest.py (working copy)
+@@ -173,14 +173,110 @@
+ return expected == response
+
+
++class IDigestCredentialsDatabase(Interface):
++ """
++ An interface to a digest credentials database that is used to hold per-client digest credentials so that fast
++ re-authentication can be done with replay attacks etc prevented.
++ """
++
++ def has_key(self, key):
++ """
++ See whether the matching key exists.
++
++ @param key: the key to check.
++ @type key: C{str}.
++
++ @return: C{True} if the key exists, C{False} otherwise.
++ """
++ pass
++
++ def set(self, key, value):
++ """
++ Store per-client credential information the first time a nonce is generated and used.
++
++ @param key: the key for the data to store.
++ @type key: C{str}
++ @param value: the data to store.
++ @type value: any.
++ """
++ pass
++
++ def get(self, key):
++ """
++ Validate client supplied credentials by comparing with the cached values. If valid, store the new
++ cnonce value in the database so that it can be used on the next validate.
++
++ @param key: the key to check.
++ @type key: C{str}.
++
++ @return: the value for the corresponding key, or C{None} if the key is not found.
++ """
++ pass
++
++ def delete(self, key):
++ """
++ Remove the record associated with the supplied key.
++
++ @param key: the key to remove.
++ @type key: C{str}
++ """
++ pass
++
++ def keys(self):
++ """
++ Return all the keys currently available.
++
++ @return: a C{list} of C{str} for each key currently in the database.
++ """
++ pass
++
++class DigestCredentialsMap:
++
++ implements(IDigestCredentialsDatabase)
++
++ def __init__(self, *args):
++ self.db = {}
++
++ def has_key(self, key):
++ """
++ See IDigestCredentialsDatabase.
++ """
++ return self.db.has_key(key)
++
++ def set(self, key, value):
++ """
++ See IDigestCredentialsDatabase.
++ """
++ self.db[key] = value
++
++ def get(self, key):
++ """
++ See IDigestCredentialsDatabase.
++ """
++ if self.db.has_key(key):
++ return self.db[key]
++ else:
++ return None
++
++ def delete(self, key):
++ """
++ See IDigestCredentialsDatabase.
++ """
++ if self.db.has_key(key):
++ del self.db[key]
++
++ def keys(self):
++ """
++ See IDigestCredentialsDatabase.
++ """
++ return self.db.keys()
++
+ class DigestCredentialFactory(object):
+ """
+ Support for RFC2617 HTTP Digest Authentication
+
+ @cvar CHALLENGE_LIFETIME_SECS: The number of seconds for which an
+ opaque should be valid.
+-
+- @ivar privateKey: A random string used for generating the secure opaque.
+ """
+
+ implements(ICredentialFactory)
+@@ -189,7 +285,7 @@
+
+ scheme = "digest"
+
+- def __init__(self, algorithm, realm):
++ def __init__(self, algorithm, realm, mapper=DigestCredentialsMap, mapperargs=[]):
+ """
+ @type algorithm: C{str}
+ @param algorithm: case insensitive string that specifies
+@@ -199,87 +295,19 @@
+ @type realm: C{str}
+ @param realm: case sensitive string that specifies the realm
+ portion of the challenge
++
++ @type mapper: C{IDigestCredentialsDatabase}
++ @param realm: the class of mapper to use with this factory
+ """
+ self.algorithm = algorithm
+ self.realm = realm
++ self.db = mapper(*mapperargs)
+
+- c = tuple([random.randrange(sys.maxint) for _ in range(3)])
+-
+- self.privateKey = '%d%d%d' % c
+-
+ def generateNonce(self):
+ c = tuple([random.randrange(sys.maxint) for _ in range(3)])
+ c = '%d%d%d' % c
+ return c
+
+- def _getTime(self):
+- """
+- Parameterize the time based seed used in generateOpaque
+- so we can deterministically unittest it's behavior.
+- """
+- return time.time()
+-
+- def generateOpaque(self, nonce, clientip):
+- """
+- Generate an opaque to be returned to the client.
+- This should be a unique string that can be returned to us and verified.
+- """
+-
+- # Now, what we do is encode the nonce, client ip and a timestamp
+- # in the opaque value with a suitable digest
+- key = "%s,%s,%s" % (nonce, clientip, str(int(self._getTime())))
+- digest = md5.new(key + self.privateKey).hexdigest()
+- ekey = key.encode('base64')
+- return "%s-%s" % (digest, ekey.strip('\n'))
+-
+- def verifyOpaque(self, opaque, nonce, clientip):
+- """
+- Given the opaque and nonce from the request, as well as the clientip
+- that made the request, verify that the opaque was generated by us.
+- And that it's not too old."
+-
+- @param opaque: The opaque value from the Digest response
+- @param nonce: The nonce value from the Digest response
+- @param clientip: The remote IP address of the client making the request
+-
+- @return: Return True if the opaque was successfully verified
+-
+- @raise L{twisted.cred.error.LoginFailed}
+- """
+-
+- # First split the digest from the key
+- opaqueParts = opaque.split('-')
+- if len(opaqueParts) != 2:
+- raise error.LoginFailed('Invalid response, invalid opaque value')
+-
+- # Verify the key
+- key = opaqueParts[1].decode('base64')
+- keyParts = key.split(',')
+-
+- if len(keyParts) != 3:
+- raise error.LoginFailed('Invalid response, invalid opaque value')
+-
+- if keyParts[0] != nonce:
+- raise error.LoginFailed(
+- 'Invalid response, incompatible opaque/nonce values')
+-
+- if keyParts[1] != clientip:
+- raise error.LoginFailed(
+- 'Invalid response, incompatible opaque/client values')
+-
+- if (int(self._getTime()) - int(keyParts[2]) >
+- DigestCredentialFactory.CHALLENGE_LIFETIME_SECS):
+-
+- raise error.LoginFailed(
+- 'Invalid response, incompatible opaque/nonce too old')
+-
+- # Verify the digest
+- digest = md5.new(key + self.privateKey).hexdigest()
+- if digest != opaqueParts[0]:
+- raise error.LoginFailed('Invalid response, invalid opaque value')
+-
+- return True
+-
+ def getChallenge(self, peer):
+ """
+ Generate the challenge for use in the WWW-Authenticate header
+@@ -291,10 +319,15 @@
+ """
+
+ c = self.generateNonce()
+- o = self.generateOpaque(c, peer.host)
++
++ # Make sure it is not a duplicate
++ if self.db.has_key(c):
++ raise AssertionError("nonce value already cached in credentials database: %s" % (c,))
+
++ # The database record is a tuple of (client ip, nonce-count, timestamp)
++ self.db.set(c, (peer.host, 0, time.time()))
++
+ return {'nonce': c,
+- 'opaque': o,
+ 'qop': 'auth',
+ 'algorithm': self.algorithm,
+ 'realm': self.realm}
+@@ -331,18 +364,96 @@
+ if not username:
+ raise error.LoginFailed('Invalid response, no username given.')
+
+- if 'opaque' not in auth:
+- raise error.LoginFailed('Invalid response, no opaque given.')
+-
+ if 'nonce' not in auth:
+ raise error.LoginFailed('Invalid response, no nonce given.')
+
+- # Now verify the nonce/opaque values for this client
+- if self.verifyOpaque(auth.get('opaque'),
+- auth.get('nonce'),
+- request.remoteAddr.host):
++ # Now verify the nonce/cnonce values for this client
++ if self.validate(auth, request):
+
+ return DigestedCredentials(username,
+ request.method,
+ self.realm,
+ auth)
++ else:
++ raise error.LoginFailed('Invalid nonce/cnonce values')
++
++ def validate(self, auth, request):
++ """
++ Check that the parameters in the response represent a valid set of credentials that
++ may be being re-used.
++
++ @param auth: the response parameters.
++ @type auth: C{dict}
++ @param request: the request being processed.
++ @type request: L{twisted.web2.server.Request}
++
++ @return: C{True} if validated.
++ @raise LoginFailed: if validation fails.
++ """
++
++ nonce = auth.get('nonce')
++ clientip = request.remoteAddr.host
++ nonce_count = auth.get('nc')
++
++ # First check we have this nonce
++ if not self.db.has_key(nonce):
++ raise error.LoginFailed('Invalid nonce value: %s' % (nonce,))
++ db_clientip, db_nonce_count, db_timestamp = self.db.get(nonce)
++
++ # Next check client ip
++ if db_clientip != clientip:
++ self.invalidate(nonce)
++ raise error.LoginFailed('Client IPs do not match: %s and %s' % (clientip, db_clientip,))
++
++ # cnonce and nonce-count MUST be present if qop is present
++ if auth.get('qop') is not None:
++ if auth.get('cnonce') is None:
++ self.invalidate(nonce)
++ raise error.LoginFailed('cnonce is required when qop is specified')
++ if nonce_count is None:
++ self.invalidate(nonce)
++ raise error.LoginFailed('nonce-count is required when qop is specified')
++
++ # Next check the nonce-count is one greater than the previous one and update it in the DB
++ try:
++ nonce_count = int(nonce_count, 16)
++ except ValueError:
++ self.invalidate(nonce)
++ raise error.LoginFailed('nonce-count is not a valid hex string: %s' % (auth.get('nonce-count'),))
++ if nonce_count != db_nonce_count + 1:
++ self.invalidate(nonce)
++ raise error.LoginFailed('nonce-count value out of sequence: %s should be one more than %s' % (nonce_count, db_nonce_count,))
++ self.db.set(nonce, (db_clientip, nonce_count, db_timestamp))
++ else:
++ # When not using qop the stored nonce-count must always be zero.
++ # i.e. we can't allow a qop auth then a non-qop auth with the same nonce
++ if db_nonce_count != 0:
++ self.invalidate(nonce)
++ raise error.LoginFailed('nonce-count was sent with this nonce: %s' % (nonce,))
++
++ # Now check timestamp
++ if db_timestamp + DigestCredentialFactory.CHALLENGE_LIFETIME_SECS <= time.time():
++ self.invalidate(nonce)
++ raise error.LoginFailed('Digest credentials expired')
++
++ return True
++
++ def invalidate(self, nonce):
++ """
++ Invalidate cached credentials for the specified nonce value.
++
++ @param nonce: the nonce for the record to invalidate.
++ @type nonce: C{str}
++ """
++ self.db.delete(nonce)
++
++ def cleanup(self):
++ """
++ This should be called at regular intervals to remove expired credentials from the cache.
++ """
++ keys = self.db.keys()
++ oldest_allowed = time.time() - DigestCredentialFactory.CHALLENGE_LIFETIME_SECS
++ for key in keys:
++ ignore_clientip, ignore_cnonce, db_timestamp = self.db.get(key)
++ if db_timestamp <= oldest_allowed:
++ self.invalidate(key)
Added: CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.test.test_httpauth.patch
===================================================================
--- CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.test.test_httpauth.patch (rev 0)
+++ CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.test.test_httpauth.patch 2007-05-04 18:09:20 UTC (rev 1512)
@@ -0,0 +1,294 @@
+Index: twisted/web2/test/test_httpauth.py
+===================================================================
+--- twisted/web2/test/test_httpauth.py (revision 19773)
++++ twisted/web2/test/test_httpauth.py (working copy)
+@@ -20,21 +20,13 @@
+ def __init__(self, *args, **kwargs):
+ super(FakeDigestCredentialFactory, self).__init__(*args, **kwargs)
+
+- self.privateKey = "0"
+-
+ def generateNonce(self):
+ """
+ Generate a static nonce
+ """
+ return '178288758716122392881254770685'
+
+- def _getTime(self):
+- """
+- Return a stable time
+- """
+- return 0
+
+-
+ class BasicAuthTestCase(unittest.TestCase):
+ def setUp(self):
+ self.credentialFactory = basic.BasicCredentialFactory('foo')
+@@ -86,18 +78,17 @@
+ challengeResponse = ('digest',
+ {'nonce': challengeNonce,
+ 'qop': 'auth', 'realm': 'test realm',
+- 'algorithm': 'md5',
+- 'opaque': challengeOpaque})
++ 'algorithm': 'md5',})
+
+ cnonce = "29fc54aa1641c6fa0e151419361c8f23"
+
+ authRequest1 = ('username="username", realm="test realm", nonce="%s", '
+- 'uri="/write/", response="%s", opaque="%s", algorithm="md5", '
++ 'uri="/write/", response="%s", algorithm="md5", '
+ 'cnonce="29fc54aa1641c6fa0e151419361c8f23", nc=00000001, '
+ 'qop="auth"')
+
+ authRequest2 = ('username="username", realm="test realm", nonce="%s", '
+- 'uri="/write/", response="%s", opaque="%s", algorithm="md5", '
++ 'uri="/write/", response="%s", algorithm="md5", '
+ 'cnonce="29fc54aa1641c6fa0e151419361c8f23", nc=00000002, '
+ 'qop="auth"')
+
+@@ -147,7 +138,6 @@
+ self.assertEquals(challenge['realm'], 'test realm')
+ self.assertEquals(challenge['algorithm'], 'md5')
+ self.assertTrue(challenge.has_key("nonce"))
+- self.assertTrue(challenge.has_key("opaque"))
+
+ def test_response(self):
+ """
+@@ -159,7 +149,7 @@
+ clientResponse = authRequest1 % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+- challenge['opaque'])
++ )
+
+ creds = self.credentialFactory.decode(clientResponse, _trivial_GET)
+ self.failUnless(creds.checkPassword('password'))
+@@ -175,7 +165,7 @@
+ clientResponse = authRequest1 % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+- challenge['opaque'])
++ )
+
+ creds = self.credentialFactory.decode(clientResponse, _trivial_GET)
+ self.failUnless(creds.checkPassword('password'))
+@@ -183,7 +173,7 @@
+ clientResponse = authRequest2 % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000002"),
+- challenge['opaque'])
++ )
+
+ creds = self.credentialFactory.decode(clientResponse, _trivial_GET)
+ self.failUnless(creds.checkPassword('password'))
+@@ -199,7 +189,7 @@
+ clientResponse = authRequest1 % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+- challenge['opaque'])
++ )
+
+ creds = self.credentialFactory.decode(clientResponse,
+ SimpleRequest(None, 'POST', '/'))
+@@ -236,17 +226,6 @@
+ _trivial_GET)
+ self.assertEquals(str(e), "Invalid response, no nonce given.")
+
+- def test_noOpaque(self):
+- """
+- Test that login fails when our response does not contain a nonce
+- """
+-
+- e = self.assertRaises(error.LoginFailed,
+- self.credentialFactory.decode,
+- 'realm="Test",username="Foo"',
+- _trivial_GET)
+- self.assertEquals(str(e), "Invalid response, no opaque given.")
+-
+ def test_checkHash(self):
+ """
+ Check that given a hash of the form 'username:realm:password'
+@@ -258,7 +237,7 @@
+ clientResponse = authRequest1 % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+- challenge['opaque'])
++ )
+
+ creds = self.credentialFactory.decode(clientResponse, _trivial_GET)
+
+@@ -268,41 +247,36 @@
+ self.failIf(creds.checkHash(
+ md5.md5('username:test realm:bogus').hexdigest()))
+
+- def test_invalidOpaque(self):
++ def test_invalidNonceCount(self):
+ """
+- Test that login fails when the opaque does not contain all the required
+- parts.
++ Test that login fails when the nonce-count is repeated.
+ """
+
+ credentialFactory = FakeDigestCredentialFactory('md5', 'test realm')
+
+ challenge = credentialFactory.getChallenge(clientAddress)
+
+- self.assertRaises(
+- error.LoginFailed,
+- credentialFactory.verifyOpaque,
+- 'badOpaque',
++ clientResponse1 = authRequest1 % (
+ challenge['nonce'],
+- clientAddress.host)
++ self.getDigestResponse(challenge, "00000001"),
++ )
+
+- badOpaque = ('foo-%s' % (
+- 'nonce,clientip'.encode('base64').strip('\n'),))
+-
+- self.assertRaises(
+- error.LoginFailed,
+- credentialFactory.verifyOpaque,
+- badOpaque,
++ clientResponse2 = authRequest2 % (
+ challenge['nonce'],
+- clientAddress.host)
++ self.getDigestResponse(challenge, "00000002"),
++ )
+
++ credentialFactory.decode(clientResponse1, _trivial_GET)
++ credentialFactory.decode(clientResponse2, _trivial_GET)
++
+ self.assertRaises(
+ error.LoginFailed,
+- credentialFactory.verifyOpaque,
+- '',
+- challenge['nonce'],
+- clientAddress.host)
++ self.credentialFactory.decode,
++ clientResponse2,
++ _trivial_GET
++ )
+
+- def test_incompatibleNonce(self):
++ def test_invalidNonce(self):
+ """
+ Test that login fails when the given nonce from the response, does not
+ match the nonce encoded in the opaque.
+@@ -311,24 +285,19 @@
+ credentialFactory = FakeDigestCredentialFactory('md5', 'test realm')
+
+ challenge = credentialFactory.getChallenge(clientAddress)
++ challenge['nonce'] = "noNoncense"
+
+- badNonceOpaque = credentialFactory.generateOpaque(
+- '1234567890',
+- clientAddress.host)
+-
+- self.assertRaises(
+- error.LoginFailed,
+- credentialFactory.verifyOpaque,
+- badNonceOpaque,
++ clientResponse = authRequest1 % (
+ challenge['nonce'],
+- clientAddress.host)
++ self.getDigestResponse(challenge, "00000001"),
++ )
+
+ self.assertRaises(
+ error.LoginFailed,
+- credentialFactory.verifyOpaque,
+- badNonceOpaque,
+- '',
+- clientAddress.host)
++ credentialFactory.decode,
++ clientResponse,
++ _trivial_GET
++ )
+
+ def test_incompatibleClientIp(self):
+ """
+@@ -338,18 +307,19 @@
+
+ credentialFactory = FakeDigestCredentialFactory('md5', 'test realm')
+
+- challenge = credentialFactory.getChallenge(clientAddress)
++ challenge = credentialFactory.getChallenge(address.IPv4Address('TCP', '127.0.0.2', 80))
+
+- badNonceOpaque = credentialFactory.generateOpaque(
++ clientResponse = authRequest1 % (
+ challenge['nonce'],
+- '10.0.0.1')
++ self.getDigestResponse(challenge, "00000001"),
++ )
+
+ self.assertRaises(
+ error.LoginFailed,
+- credentialFactory.verifyOpaque,
+- badNonceOpaque,
+- challenge['nonce'],
+- clientAddress.host)
++ credentialFactory.decode,
++ clientResponse,
++ _trivial_GET
++ )
+
+ def test_oldNonce(self):
+ """
+@@ -360,47 +330,20 @@
+ credentialFactory = FakeDigestCredentialFactory('md5', 'test realm')
+
+ challenge = credentialFactory.getChallenge(clientAddress)
++ clientip, nonce_count, timestamp = credentialFactory.db.get(challenge['nonce'])
++ credentialFactory.db.set(challenge['nonce'], (clientip, nonce_count, timestamp - 2 * digest.DigestCredentialFactory.CHALLENGE_LIFETIME_SECS))
+
+- key = '%s,%s,%s' % (challenge['nonce'],
+- clientAddress.host,
+- '-137876876')
+- digest = md5.new(key + credentialFactory.privateKey).hexdigest()
+- ekey = key.encode('base64')
+-
+- oldNonceOpaque = '%s-%s' % (digest, ekey.strip('\n'))
+-
+- self.assertRaises(
+- error.LoginFailed,
+- credentialFactory.verifyOpaque,
+- oldNonceOpaque,
++ clientResponse = authRequest1 % (
+ challenge['nonce'],
+- clientAddress.host)
++ self.getDigestResponse(challenge, "00000001"),
++ )
+
+- def test_mismatchedOpaqueChecksum(self):
+- """
+- Test that login fails when the opaque checksum fails verification
+- """
+-
+- credentialFactory = FakeDigestCredentialFactory('md5', 'test realm')
+-
+- challenge = credentialFactory.getChallenge(clientAddress)
+-
+-
+- key = '%s,%s,%s' % (challenge['nonce'],
+- clientAddress.host,
+- '0')
+-
+- digest = md5.new(key + 'this is not the right pkey').hexdigest()
+-
+- badChecksum = '%s-%s' % (digest,
+- key.encode('base64').strip('\n'))
+-
+ self.assertRaises(
+ error.LoginFailed,
+- credentialFactory.verifyOpaque,
+- badChecksum,
+- challenge['nonce'],
+- clientAddress.host)
++ credentialFactory.decode,
++ clientResponse,
++ _trivial_GET
++ )
+
+ def test_incompatibleCalcHA1Options(self):
+ """
Modified: CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/cluster.py
===================================================================
--- CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/cluster.py 2007-05-04 17:59:50 UTC (rev 1511)
+++ CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/cluster.py 2007-05-04 18:09:20 UTC (rev 1512)
@@ -95,7 +95,6 @@
'-o', 'BindSSLPorts=%s' % (','.join(map(str, self.sslPorts)),),
'-o', 'PIDFile=None',
'-o', 'ErrorLogFile=None',
- '-o', 'SharedSecret=%s' % (config.SharedSecret,),
'-o', 'MultiProcess/ProcessCount=%d' % (
config.MultiProcess['ProcessCount'],)])
Modified: CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/config.py
===================================================================
--- CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/config.py 2007-05-04 17:59:50 UTC (rev 1511)
+++ CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/config.py 2007-05-04 18:09:20 UTC (rev 1512)
@@ -63,7 +63,8 @@
#
# Data store
#
- "DocumentRoot": "/Library/CalendarServer/Documents",
+ "DataRoot" : "/Library/CalendarServer/Data",
+ "DocumentRoot" : "/Library/CalendarServer/Documents",
"UserQuota" : 104857600, # User quota (in bytes)
"MaximumAttachmentSize": 1048576, # Attachment size limit (in bytes)
@@ -94,7 +95,6 @@
"Enabled": True,
"Algorithm": "md5",
"Qop": "",
- "Secret": "",
},
"Kerberos": { # Kerberos/SPNEGO
"Enabled": False,
@@ -173,10 +173,6 @@
# processes.
"ControlSocket": "/var/run/caldavd.sock",
- # A secret key (SHA-1 hash of random string) that is used for internal
- # crypto operations and shared by multiple server processes
- "SharedSecret": "",
-
# Support for Content-Encoding compression options as specified in
# RFC2616 Section 3.5
"ResponseCompression": True,
Modified: CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/digest.py
===================================================================
--- CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/digest.py 2007-05-04 17:59:50 UTC (rev 1511)
+++ CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/digest.py 2007-05-04 18:09:20 UTC (rev 1512)
@@ -16,20 +16,149 @@
# DRI: Cyrus Daboo, cdaboo at apple.com
##
+from twistedcaldav.sql import AbstractSQLDatabase
-from twisted.web2.auth.digest import DigestCredentialFactory
+from twisted.web2.auth.digest import DigestCredentialFactory, IDigestCredentialsDatabase
+from zope.interface.declarations import implements
+
+import cPickle as pickle
+import os
+
"""
Overrides twisted.web2.auth.digest to allow specifying a qop value as a configuration parameter.
+Also adds an sqlite-based credentials cache that is multi-process safe.
"""
+class DigestCredentialsDB(AbstractSQLDatabase):
+
+ implements(IDigestCredentialsDatabase)
+
+ """
+ A database to maintain cached digest credentials.
+
+ SCHEMA:
+
+ Database: DIGESTCREDENTIALS
+
+ ROW: KEY, VALUE
+
+ """
+
+ dbType = "DIGESTCREDENTIALSCACHE"
+ dbFilename = ".db.digestcredentialscache"
+ dbFormatVersion = "1"
+
+ def __init__(self, path):
+ db_path = os.path.join(path, DigestCredentialsDB.dbFilename)
+ if os.path.exists(db_path):
+ os.remove(db_path)
+ super(DigestCredentialsDB, self).__init__(db_path, DigestCredentialsDB.dbFormatVersion)
+ self.db = {}
+
+ def has_key(self, key):
+ """
+ See IDigestCredentialsDatabase.
+ """
+ for ignore_key in self._db_execute(
+ "select KEY from DIGESTCREDENTIALS where KEY = :1",
+ key
+ ):
+ return True
+ else:
+ return False
+
+ def set(self, key, value):
+ """
+ See IDigestCredentialsDatabase.
+ """
+ self._delete_from_db(key)
+ pvalue = pickle.dumps(value)
+ self._add_to_db(key, pvalue)
+ self._db_commit()
+
+ def get(self, key):
+ """
+ See IDigestCredentialsDatabase.
+ """
+ for pvalue in self._db_execute(
+ "select VALUE from DIGESTCREDENTIALS where KEY = :1",
+ key
+ ):
+ return pickle.loads(str(pvalue[0]))
+ else:
+ return None
+
+ def delete(self, key):
+ """
+ See IDigestCredentialsDatabase.
+ """
+ self._delete_from_db(key)
+ self._db_commit()
+
+ def keys(self):
+ """
+ See IDigestCredentialsDatabase.
+ """
+ result = []
+ for key in self._db_execute("select KEY from DIGESTCREDENTIALS"):
+ result.append(str(key[0]))
+
+ return result
+
+ def _add_to_db(self, key, value):
+ """
+ Insert the specified entry into the database.
+
+ @param key: the key to add.
+ @param value: the value to add.
+ """
+ self._db_execute(
+ """
+ insert into DIGESTCREDENTIALS (KEY, VALUE)
+ values (:1, :2)
+ """, key, value
+ )
+
+ def _delete_from_db(self, key):
+ """
+ Deletes the specified entry from the database.
+
+ @param key: the key to remove.
+ """
+ self._db_execute("delete from DIGESTCREDENTIALS where KEY = :1", key)
+
+ def _db_type(self):
+ """
+ @return: the collection type assigned to this index.
+ """
+ return DigestCredentialsDB.dbType
+
+ def _db_init_data_tables(self, q):
+ """
+ Initialise the underlying database tables.
+ @param q: a database cursor to use.
+ """
+
+ #
+ # DIGESTCREDENTIALS table
+ #
+ q.execute(
+ """
+ create table DIGESTCREDENTIALS (
+ KEY text,
+ VALUE text
+ )
+ """
+ )
+
class QopDigestCredentialFactory(DigestCredentialFactory):
"""
See twisted.web2.auth.digest.DigestCredentialFactory
"""
- def __init__(self, algorithm, qop, secret, realm):
+ def __init__(self, algorithm, qop, realm, db_path):
"""
@type algorithm: C{str}
@param algorithm: case insensitive string that specifies
@@ -41,17 +170,15 @@
the qop to use
- @type secret: C{str}
- @param secret: specifies a secret key to be used for opaque value hashing
-
@type realm: C{str}
@param realm: case sensitive string that specifies the realm
portion of the challenge
+
+ @type db_path: C{str}
+ @param db_path: path where the credentials cache is to be stored
"""
- super(QopDigestCredentialFactory, self).__init__(algorithm, realm)
+ super(QopDigestCredentialFactory, self).__init__(algorithm, realm, mapper=DigestCredentialsDB, mapperargs=[db_path])
self.qop = qop
- if secret:
- self.privateKey = secret
def getChallenge(self, peer):
"""
Added: CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/test/test_digest.py
===================================================================
--- CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/test/test_digest.py (rev 0)
+++ CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/test/test_digest.py 2007-05-04 18:09:20 UTC (rev 1512)
@@ -0,0 +1,420 @@
+
+
+from twisted.cred import error
+from twisted.internet import address
+from twisted.trial import unittest
+from twisted.web2.auth import digest
+from twisted.web2.test.test_server import SimpleRequest
+from twisted.web2.dav.fileop import rmdir
+from twistedcaldav.directory.digest import QopDigestCredentialFactory
+import os
+import md5
+
+class FakeDigestCredentialFactory(QopDigestCredentialFactory):
+ """
+ A Fake Digest Credential Factory that generates a predictable
+ nonce and opaque
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(FakeDigestCredentialFactory, self).__init__(*args, **kwargs)
+
+ def generateNonce(self):
+ """
+ Generate a static nonce
+ """
+ return '178288758716122392881254770685'
+
+
+clientAddress = address.IPv4Address('TCP', '127.0.0.1', 80)
+
+challengeOpaque = ('75c4bd95b96b7b7341c646c6502f0833-MTc4Mjg4NzU'
+ '4NzE2MTIyMzkyODgxMjU0NzcwNjg1LHJlbW90ZWhvc3Q'
+ 'sMA==')
+
+challengeNonce = '178288758716122392881254770685'
+
+challengeResponse = ('digest',
+ {'nonce': challengeNonce,
+ 'qop': 'auth', 'realm': 'test realm',
+ 'algorithm': 'md5',})
+
+cnonce = "29fc54aa1641c6fa0e151419361c8f23"
+
+authRequest1 = (('username="username", realm="test realm", nonce="%s", '
+ 'uri="/write/", response="%s", algorithm="md5", '
+ 'cnonce="29fc54aa1641c6fa0e151419361c8f23", nc=00000001, '
+ 'qop="auth"'),
+ ('username="username", realm="test realm", nonce="%s", '
+ 'uri="/write/", response="%s", algorithm="md5"'))
+
+authRequest2 = (('username="username", realm="test realm", nonce="%s", '
+ 'uri="/write/", response="%s", algorithm="md5", '
+ 'cnonce="29fc54aa1641c6fa0e151419361c8f23", nc=00000002, '
+ 'qop="auth"'),
+ ('username="username", realm="test realm", nonce="%s", '
+ 'uri="/write/", response="%s", algorithm="md5"'))
+
+authRequest3 = ('username="username", realm="test realm", nonce="%s", '
+ 'uri="/write/", response="%s", algorithm="md5"')
+
+namelessAuthRequest = 'realm="test realm",nonce="doesn\'t matter"'
+
+
+class DigestAuthTestCase(unittest.TestCase):
+ """
+ Test the behavior of DigestCredentialFactory
+ """
+
+ def setUp(self):
+ """
+ Create a DigestCredentialFactory for testing
+ """
+ self.path1 = self.mktemp()
+ self.path2 = self.mktemp()
+ os.mkdir(self.path1)
+ os.mkdir(self.path2)
+
+ self.credentialFactories = (QopDigestCredentialFactory(
+ 'md5',
+ 'auth',
+ 'test realm',
+ self.path1
+ ),
+ QopDigestCredentialFactory(
+ 'md5',
+ '',
+ 'test realm',
+ self.path2
+ ))
+
+ def tearDown(self):
+ rmdir(self.path1)
+ rmdir(self.path2)
+
+ 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')
+
+ if qop:
+ expected = digest.calcResponse(
+ digest.calcHA1(algo,
+ "username",
+ "test realm",
+ "password",
+ nonce,
+ cnonce),
+ algo, nonce, ncount, cnonce, qop, "GET", "/write/", None
+ )
+ else:
+ expected = digest.calcResponse(
+ digest.calcHA1(algo,
+ "username",
+ "test realm",
+ "password",
+ nonce,
+ cnonce),
+ algo, nonce, None, None, None, "GET", "/write/", None
+ )
+ return expected
+
+ def test_getChallenge(self):
+ """
+ Test that all the required fields exist in the challenge,
+ and that the information matches what we put into our
+ DigestCredentialFactory
+ """
+
+ challenge = self.credentialFactories[0].getChallenge(clientAddress)
+ self.assertEquals(challenge['qop'], 'auth')
+ self.assertEquals(challenge['realm'], 'test realm')
+ self.assertEquals(challenge['algorithm'], 'md5')
+ self.assertTrue(challenge.has_key("nonce"))
+
+ challenge = self.credentialFactories[1].getChallenge(clientAddress)
+ self.assertFalse(challenge.has_key('qop'))
+ self.assertEquals(challenge['realm'], 'test realm')
+ self.assertEquals(challenge['algorithm'], 'md5')
+ self.assertTrue(challenge.has_key("nonce"))
+
+ def test_response(self):
+ """
+ Test that we can decode a valid response to our challenge
+ """
+
+ for ctr, factory in enumerate(self.credentialFactories):
+ challenge = factory.getChallenge(clientAddress)
+
+ clientResponse = authRequest1[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+ )
+
+ creds = factory.decode(clientResponse, _trivial_GET)
+ self.failUnless(creds.checkPassword('password'))
+
+ def test_multiResponse(self):
+ """
+ Test that multiple responses to to a single challenge are handled
+ successfully.
+ """
+
+ for ctr, factory in enumerate(self.credentialFactories):
+ challenge = factory.getChallenge(clientAddress)
+
+ clientResponse = authRequest1[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+ )
+
+ creds = factory.decode(clientResponse, _trivial_GET)
+ self.failUnless(creds.checkPassword('password'))
+
+ clientResponse = authRequest2[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000002"),
+ )
+
+ creds = factory.decode(clientResponse, _trivial_GET)
+ self.failUnless(creds.checkPassword('password'))
+
+ def test_failsWithDifferentMethod(self):
+ """
+ Test that the response fails if made for a different request method
+ than it is being issued for.
+ """
+
+ for ctr, factory in enumerate(self.credentialFactories):
+ challenge = factory.getChallenge(clientAddress)
+
+ clientResponse = authRequest1[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+ )
+
+ creds = factory.decode(clientResponse,
+ SimpleRequest(None, 'POST', '/'))
+ self.failIf(creds.checkPassword('password'))
+
+ def test_noUsername(self):
+ """
+ Test that login fails when our response does not contain a username,
+ or the username field is empty.
+ """
+
+ # Check for no username
+ for factory in self.credentialFactories:
+ e = self.assertRaises(error.LoginFailed,
+ factory.decode,
+ namelessAuthRequest,
+ _trivial_GET)
+ self.assertEquals(str(e), "Invalid response, no username given.")
+
+ # Check for an empty username
+ e = self.assertRaises(error.LoginFailed,
+ factory.decode,
+ namelessAuthRequest + ',username=""',
+ _trivial_GET)
+ self.assertEquals(str(e), "Invalid response, no username given.")
+
+ def test_noNonce(self):
+ """
+ Test that login fails when our response does not contain a nonce
+ """
+
+ for factory in self.credentialFactories:
+ e = self.assertRaises(error.LoginFailed,
+ factory.decode,
+ 'realm="Test",username="Foo",opaque="bar"',
+ _trivial_GET)
+ self.assertEquals(str(e), "Invalid response, no nonce given.")
+
+ def test_checkHash(self):
+ """
+ Check that given a hash of the form 'username:realm:password'
+ we can verify the digest challenge
+ """
+
+ for ctr, factory in enumerate(self.credentialFactories):
+ challenge = factory.getChallenge(clientAddress)
+
+ clientResponse = authRequest1[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+ )
+
+ creds = factory.decode(clientResponse, _trivial_GET)
+
+ self.failUnless(creds.checkHash(
+ md5.md5('username:test realm:password').hexdigest()))
+
+ self.failIf(creds.checkHash(
+ md5.md5('username:test realm:bogus').hexdigest()))
+
+ def test_invalidNonceCount(self):
+ """
+ Test that login fails when the nonce-count is repeated.
+ """
+
+ credentialFactories = (
+ FakeDigestCredentialFactory('md5', 'auth', 'test realm', self.path1),
+ FakeDigestCredentialFactory('md5', '', 'test realm', self.path2)
+ )
+
+ for ctr, factory in enumerate(credentialFactories):
+ challenge = factory.getChallenge(clientAddress)
+
+ clientResponse1 = authRequest1[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+ )
+
+ clientResponse2 = authRequest2[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000002"),
+ )
+
+ factory.decode(clientResponse1, _trivial_GET)
+ factory.decode(clientResponse2, _trivial_GET)
+
+ if challenge.get('qop') is not None:
+ self.assertRaises(
+ error.LoginFailed,
+ factory.decode,
+ clientResponse2,
+ _trivial_GET
+ )
+
+ challenge = factory.getChallenge(clientAddress)
+
+ clientResponse1 = authRequest1[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+ )
+ del challenge['qop']
+ clientResponse3 = authRequest3 % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000002"),
+ )
+ factory.decode(clientResponse1, _trivial_GET)
+ self.assertRaises(
+ error.LoginFailed,
+ factory.decode,
+ clientResponse3,
+ _trivial_GET
+ )
+
+ def test_invalidNonce(self):
+ """
+ Test that login fails when the given nonce from the response, does not
+ match the nonce encoded in the opaque.
+ """
+
+ credentialFactories = (
+ FakeDigestCredentialFactory('md5', 'auth', 'test realm', self.path1),
+ FakeDigestCredentialFactory('md5', '', 'test realm', self.path2)
+ )
+
+ for ctr, factory in enumerate(credentialFactories):
+ challenge = factory.getChallenge(clientAddress)
+ challenge['nonce'] = "noNoncense"
+
+ clientResponse = authRequest1[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+ )
+
+ self.assertRaises(
+ error.LoginFailed,
+ factory.decode,
+ clientResponse,
+ _trivial_GET
+ )
+
+ def test_incompatibleClientIp(self):
+ """
+ Test that the login fails when the request comes from a client ip
+ other than what is encoded in the opaque.
+ """
+
+ credentialFactories = (
+ FakeDigestCredentialFactory('md5', 'auth', 'test realm', self.path1),
+ FakeDigestCredentialFactory('md5', '', 'test realm', self.path2)
+ )
+
+ for ctr, factory in enumerate(credentialFactories):
+ challenge = factory.getChallenge(address.IPv4Address('TCP', '127.0.0.2', 80))
+
+ clientResponse = authRequest1[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+ )
+
+ self.assertRaises(
+ error.LoginFailed,
+ factory.decode,
+ clientResponse,
+ _trivial_GET
+ )
+
+ def test_oldNonce(self):
+ """
+ Test that the login fails when the given opaque is older than
+ DigestCredentialFactory.CHALLENGE_LIFETIME_SECS
+ """
+
+ credentialFactories = (
+ FakeDigestCredentialFactory('md5', 'auth', 'test realm', self.path1),
+ FakeDigestCredentialFactory('md5', '', 'test realm', self.path2)
+ )
+
+ for ctr, factory in enumerate(credentialFactories):
+ challenge = factory.getChallenge(clientAddress)
+ clientip, nonce_count, timestamp = factory.db.get(challenge['nonce'])
+ factory.db.set(challenge['nonce'], (clientip, nonce_count, timestamp - 2 * digest.DigestCredentialFactory.CHALLENGE_LIFETIME_SECS))
+
+ clientResponse = authRequest1[ctr] % (
+ challenge['nonce'],
+ self.getDigestResponse(challenge, "00000001"),
+ )
+
+ self.assertRaises(
+ error.LoginFailed,
+ factory.decode,
+ clientResponse,
+ _trivial_GET
+ )
+
+ def test_incompatibleCalcHA1Options(self):
+ """
+ Test that the appropriate error is raised when any of the
+ pszUsername, pszRealm, or pszPassword arguments are specified with
+ the preHA1 keyword argument.
+ """
+
+ arguments = (
+ ("user", "realm", "password", "preHA1"),
+ (None, "realm", None, "preHA1"),
+ (None, None, "password", "preHA1"),
+ )
+
+ for pszUsername, pszRealm, pszPassword, preHA1 in arguments:
+ self.assertRaises(
+ TypeError,
+ digest.calcHA1,
+ "md5",
+ pszUsername,
+ pszRealm,
+ pszPassword,
+ "nonce",
+ "cnonce",
+ preHA1=preHA1
+ )
+
+
+
+_trivial_GET = SimpleRequest(None, 'GET', '/')
Modified: CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/tap.py
===================================================================
--- CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/tap.py 2007-05-04 17:59:50 UTC (rev 1511)
+++ CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/tap.py 2007-05-04 18:09:20 UTC (rev 1512)
@@ -16,16 +16,10 @@
# DRI: David Reid, dreid at apple.com
##
-try:
- from hashlib import sha1
-except ImportError:
- import sha
- sha1 = sha.new
import random
import os
import stat
import sys
-import copy
from zope.interface import implements
@@ -233,11 +227,6 @@
log.msg("WARNING: changing umask from: 0%03o to 0%03o" % (
oldmask, config.umask,))
- # Generate a shared secret that will be passed to any slave processes
- if not config.SharedSecret:
- c = tuple([random.randrange(sys.maxint) for _ in range(3)])
- config.SharedSecret = sha1('%d%d%d' % c).hexdigest()
-
def checkDirectory(self, dirpath, description, access=None, fail=False, permissions=None, uname=None, gname=None):
if not os.path.exists(dirpath):
raise ConfigurationError("%s does not exist: %s" % (description, dirpath,))
@@ -445,17 +434,11 @@
)
elif scheme == 'digest':
- secret = schemeConfig['Secret']
- if not secret and config.SharedSecret:
- log.msg("Using master process shared secret for Digest authentication")
- secret = config.SharedSecret
- else:
- log.msg("No shared secret for Digest authentication")
credFactory = QopDigestCredentialFactory(
schemeConfig['Algorithm'],
schemeConfig['Qop'],
- secret,
- realm
+ realm,
+ config.DataRoot,
)
elif scheme == 'basic':
-------------- next part --------------
An HTML attachment was scrubbed...
URL: http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20070504/20b051c1/attachment.html
More information about the calendarserver-changes
mailing list