[Zope-Checkins] CVS: ZODB3/ZEO/auth - base.py:1.1.2.2 auth_digest.py:1.1.2.5 auth_sha.py:NONE auth_plaintext.py:NONE

Jeremy Hylton jeremy@zope.com
Wed, 28 May 2003 14:38:02 -0400


Update of /cvs-repository/ZODB3/ZEO/auth
In directory cvs.zope.org:/tmp/cvs-serv5351/ZEO/auth

Modified Files:
      Tag: ZODB3-auth-branch
	base.py auth_digest.py 
Removed Files:
      Tag: ZODB3-auth-branch
	auth_sha.py auth_plaintext.py 
Log Message:
Big refactoring of authentication mechanism.

Add mac to the smac layer.
Add explicit realm for use by client and server.
Add authentication to the ZEO schema components.
Add session key generation to digest authentication.

Add a new zeopasswd.py script that isn't quite done.
Move plaintext authentication to the tests directory; it isn't
supposed to be used for real.


=== ZODB3/ZEO/auth/base.py 1.1.2.1 => 1.1.2.2 ===
--- ZODB3/ZEO/auth/base.py:1.1.2.1	Fri May 23 17:13:20 2003
+++ ZODB3/ZEO/auth/base.py	Wed May 28 14:37:31 2003
@@ -29,6 +29,11 @@
         self.stub = stub
         for m in self.extensions:
             setattr(self.stub, m, self.stub.extensionMethod(m))
+
+def sort(L):
+    """Sort a list in-place and return it."""
+    L.sort()
+    return L
     
 class Database:
     """Abstracts a password database.
@@ -42,16 +47,19 @@
     produced from the password string.
     """
     
-    def __init__(self, filename):
+    def __init__(self, filename, realm=None):
         """Creates a new Database
 
-           filename: a string containing the full pathname of
-               the password database file. Must be readable by the user
-               running ZEO. Must be writeable by any client script that
-               accesses the database."""
-        
+        filename: a string containing the full pathname of
+            the password database file. Must be readable by the user
+            running ZEO. Must be writeable by any client script that
+            accesses the database.
+
+        realm: the realm name (a string)
+        """
         self._users = {}
         self.filename = filename
+        self.realm = realm
         self.load()
         
     def save(self, fd=None):
@@ -59,9 +67,11 @@
 
         if not fd:
             fd = open(filename, 'w')
-        
-        for username, hash in self._users.items():
-            fd.write('%s:%s\n' % (username, hash))
+        if self.realm:
+            print >> fd, "realm", self.realm
+
+        for username in sort(self._users.keys()):
+            print >> fd, "%s: %s" % (username, self._users[username])
             
     def load(self):
         filename = self.filename
@@ -72,9 +82,14 @@
             return
         
         fd = open(filename)
-        for line in fd.readlines():
-            username, hash = line[:-1].split(':', 1)
-            self._users[username] = hash
+        L = fd.readlines()
+        if L[0].startswith("realm "):
+            line = L.pop(0).strip()
+            self.realm = line[len("realm "):]
+            
+        for line in L:
+            username, hash = line.strip().split(":", 1)
+            self._users[username] = hash.strip()
 
     def _store_password(self, username, password):
         self._users[username] = self.hash(password)


=== ZODB3/ZEO/auth/auth_digest.py 1.1.2.4 => 1.1.2.5 ===
--- ZODB3/ZEO/auth/auth_digest.py:1.1.2.4	Fri May 23 17:13:20 2003
+++ ZODB3/ZEO/auth/auth_digest.py	Wed May 28 14:37:31 2003
@@ -19,19 +19,19 @@
 security.  The RFC discusses many of the limitations of this kind of
 protocol.
 
-XXX The HTTP mechanism includes a "realm" that specifies in what
-context the username is valid.  ZEO does not have any notion of a
-realm at the moment, but it probably should.  The password database
-should store the hash of user, realm, and password.  Right now, it
-just stores the hash of user and password, which would allow an
-attacker to determine that the user-password combo was the same at two
-different sites.
-
 Guard the password database as if it contained plaintext passwords.
 It stores the hash of a username and password.  This does not expose
-the plaintext password, but it is sensitive nonetheless.  An attack
+the plaintext password, but it is sensitive nonetheless.  An attacker
 with the hash can impersonate the real user.  This is a limitation of
 the simple digest scheme.
+
+HTTP is a stateless protocol, and ZEO is a stateful protocol.  The
+security requirements are quite different as a result.  The HTTP
+protocol uses a nonce as a challenge.  The ZEO protocol requires a
+separate session key that is used for message authentication.  We
+generate a second nonce for this purpose; the hash of nonce and
+user/realm/password is used as the session key.  XXX I'm not sure if
+this is a sound approach; SRP would be preferred.
 """
 
 import base64
@@ -42,6 +42,7 @@
 
 from ZEO.auth.base import Database, Client
 from ZEO.StorageServer import ZEOStorage
+from ZEO.Exceptions import AuthError
 
 def get_random_bytes(n=8):
     if os.path.exists("/dev/urandom"):
@@ -57,17 +58,25 @@
     return sha.new(s).hexdigest()
 
 class DigestDatabase(Database):
-    def __init__(self, filename):
-        Database.__init__(self, filename)
+    def __init__(self, filename, realm=None):
+        Database.__init__(self, filename, realm)
         
         # Initialize a key used to build the nonce for a challenge.
-        # We need one key for the lifetime of the server.
+        # We need one key for the lifetime of the server, so it
+        # is convenient to store in on the database.
         self.noncekey = get_random_bytes(8)
 
     def _store_password(self, username, password):
-        dig = hexdigest("%s:%s" % (username, password))
+        dig = hexdigest("%s:%s:%s" % (username, self.realm, password))
         self._users[username] = dig
 
+def session_key(h_up, nonce):
+    # The hash itself is a bit too short to be a session key.
+    # HMAC wants a 64-byte key.  We don't want to use h_up
+    # directly because it would never change over time.  Instead
+    # use the hash plus part of h_up.
+    return sha.new("%s:%s" % (h_up, nonce)).digest() + h_up[:44]
+
 class StorageClass(ZEOStorage):
     def set_database(self, database):
         assert isinstance(database, DigestDatabase)
@@ -75,15 +84,8 @@
         self.noncekey = database.noncekey
 
     def _get_time(self):
-        # Return an approximate time value suitable for a nonce.
-        # We check that the nonce is valid by generating a new
-        # one and comparing it with a previously generated one.
-        # So the nonce time should change infrequently.  If a client
-        # receives a nonce on the cusp of a time-change, it will
-        # need to request a new authentication challenge.
-
-        # Start with once an hour
-        t = int(time.time()) / 3600
+        # Return a string representing the current time.
+        t = int(time.time())
         return struct.pack("i", t)
 
     def _get_nonce(self):
@@ -93,30 +95,31 @@
         dig.update(str(self.connection.addr))
         dig.update(self._get_time())
         dig.update(self.noncekey)
-        self._nonce = dig.hexdigest()
-        return self._nonce
+        return dig.hexdigest()
 
     def auth_get_challenge(self):
-        """Return a nonce as the challenge."""
-        # XXX perhaps the server should identify itself in
-        # the challenge
-        return self._get_nonce()
+        """Return realm, challenge, and nonce."""
+        self._challenge = self._get_nonce()
+        self._key_nonce = self._get_nonce()
+        return self.database.realm, self._challenge, self._key_nonce
 
     def auth_response(self, resp):
         # verify client response
-        user, nonce, response = resp
+        user, challenge, response = resp
 
         # Since zrpc is a stateful protocol, we just store the nonce
         # we sent to the client.  It will need to generate a new
         # nonce for a new connection anyway.
-        if self._nonce != nonce:
-            raise ValueError, "invalid nonce"
+        if self._challenge != challenge:
+            raise ValueError, "invalid challenge"
 
         # lookup user in database
         h_up = self.database.get_password(user)
 
         # regeneration resp from user, password, and nonce
-        check = hexdigest("%s:%s" % (h_up, nonce))
+        check = hexdigest("%s:%s" % (h_up, challenge))
+        if check == response:
+            self.connection.setSessionKey(session_key(h_up, self._key_nonce))
         return self.finish_auth(check == response)
 
     extensions = [auth_get_challenge, auth_response]
@@ -124,9 +127,16 @@
 class DigestClient(Client):
     extensions = ["auth_get_challenge", "auth_response"]
 
-    def start(self, username, password):
-        h_up = hexdigest("%s:%s" % (username, password))
-        nonce = self.stub.auth_get_challenge()
+    def start(self, username, realm, password):
+        _realm, challenge, nonce = self.stub.auth_get_challenge()
+        if _realm != realm:
+            raise AuthError("expected realm %r, got realm %r"
+                            % (_realm, realm))
+        h_up = hexdigest("%s:%s:%s" % (username, realm, password))
         
-        resp_dig = hexdigest("%s:%s" % (h_up, nonce))
-        return self.stub.auth_response((username, nonce, resp_dig))
+        resp_dig = hexdigest("%s:%s" % (h_up, challenge))
+        result = self.stub.auth_response((username, challenge, resp_dig))
+        if result:
+            return session_key(h_up, nonce)
+        else:
+            return None

=== Removed File ZODB3/ZEO/auth/auth_sha.py ===

=== Removed File ZODB3/ZEO/auth/auth_plaintext.py ===