[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