Revision: 1512 http://trac.macosforge.org/projects/calendarserver/changeset/1512 Author: cdaboo@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@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@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':