From 3a37a9166b6e6f8f2bad86961ed263572ee470b9 Mon Sep 17 00:00:00 2001
From: Helmut Grohne <helmut@subdivi.de>
Date: Sun, 21 Sep 2008 17:42:37 +0200
Subject: digest: create a generic nonce storage interface

---
 wsgitools/digest.py | 333 ++++++++++++++++++++++++++++++++++++++--------------
 1 file changed, 247 insertions(+), 86 deletions(-)

diff --git a/wsgitools/digest.py b/wsgitools/digest.py
index 65560f6..8c5bc0f 100755
--- a/wsgitools/digest.py
+++ b/wsgitools/digest.py
@@ -52,6 +52,7 @@ class AuthTokenGenerator:
     realm attribute being a string."""
     def __init__(self, realm, getpass):
         """
+        @type realm: str
         @param realm: is a string according to RFC2617.
         @param getpass: this function is called with a username and password is
                 expected as result. None may be used as an invalid password."""
@@ -69,34 +70,260 @@ class AuthTokenGenerator:
         a1 = "%s:%s:%s" % (username, self.realm, password)
         return md5(a1).hexdigest()
 
-__all__.append("AuthDigestMiddleware")
-class AuthDigestMiddleware:
-    """Middleware partly implementing RFC2617. (md5-sess was omited)"""
-    algorithms = {"md5": lambda data: md5(data).hexdigest()}
-    def __init__(self, app, gentoken, maxage=300, maxuses=5):
+__all__.append("NonceStoreBase")
+class NonceStoreBase:
+    """Nonce storage interface."""
+    def __init__(self):
+        pass
+    def newnonce(self):
         """
-        @param app: is the wsgi application to be served with authentification.
-        @param gentoken: has to have the same functionality and interface as the
-                 AuthTokenGenerator class.
+        This method is to be overriden and should return new nonces.
+        @rtype: str
+        """
+        raise NotImplementedError
+    def isnonce(self, nonce):
+        """
+        This method is to be overridden and should do a quick check for whether
+        the given nonce has a chance to be a valid one. This function must not
+        return false for a stale nonce.
+        @type nonce: str
+        @rtype: bool
+        """
+        raise NotImplementedError
+    def checknonce(self, nonce, qop, nc):
+        """
+        This method is to be overridden and should do a thorough check for
+        whether the given nonce is a valid one taking qop and nc into account.
+        @type nonce: str
+        @type qop: str or None
+        @type nc: str or None
+        @rtype: bool
+        """
+        raise NotImplementedError
+
+def format_time(seconds):
+    """
+    internal method formatting a unix time to a fixed-length string
+    @type seconds: float
+    @rtype: str
+    """
+    return "%13X" % long(seconds * 1000000)
+
+__all__.append("StatelessNonceStore")
+class StatelessNonceStore(NonceStoreBase):
+    """
+    This is a stateless nonce storage that cannot check the usage count for
+    a nonce and thus cannot protect against replay attacks. It however can make
+    it difficult by posing a timeout on nonces and making it difficult to forge
+    nonces.
+    
+    This nonce store is usable with scgi.forkpool.
+    """
+    def __init__(self, maxage=300, secret=None):
+        """
+        @type maxage: int
+        @param maxage: is the number of seconds a nonce may be valid. Choosing a
+                large value may result in more memory usage whereas a smaller
+                value results in more requests. Defaults to 5 minutes.
+        @type secret: str
+        @param secret: if not given, a secret is generated and is therefore
+                shared after forks. Knowing this secret permits creating nonces.
+        """
+        NonceStoreBase.__init__(self)
+        self.maxage = maxage
+        if secret:
+            self.server_secret = secret
+        else:
+            self.server_secret = ("%066X" % sysrand.getrandbits(33*8)
+                                  ).decode("hex").encode("base64").strip()
+
+    def newnonce(self):
+        """
+        Generates a new nonce string.
+        @rtype: str
+        """
+        nonce_time = format_time(time.time())
+        nonce_value = ("%066X" % sysrand.getrandbits(33*8)
+                      ).decode("hex").encode("base64").strip()
+        token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
+        token = md5(token).hexdigest()
+        return "%s:%s:%s" % (nonce_time, nonce_value, token)
+
+    def isnonce(self, nonce):
+        """
+        Do a quick a stateless check for whether the provides string might
+        be a nonce.
+        @type nonce: str
+        @rtype: bool
+        """
+        try:
+            nonce_time, nonce_value, nonce_hash = nonce.split(':')
+        except ValueError:
+            return False
+        token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
+        token = md5(token).hexdigest()
+        return nonce_hash == token
+
+    def checknonce(self, nonce, qop, nc):
+        """
+        Do a thorough check for whether the provided string is a nonce and
+        increase usage count on returning True.
+        @type nonce: str
+        @type qop: str or None
+        @type nc: str or None
+        @rtype: bool
+        """
+        try:
+            nonce_time, nonce_value, nonce_hash = nonce.split(':')
+        except ValueError:
+            return False
+        token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
+        token = md5(token).hexdigest()
+        if token != nonce_hash:
+            return False
+
+        if nonce_time < format_time(time.time() - self.maxage):
+            return False
+        return True
+
+__all__.append("MemoryNonceStore")
+class MemoryNonceStore(NonceStoreBase):
+    """Simple in-memory mechanism to store nonces."""
+    def __init__(self, maxage=300, maxuses=5):
+        """
+        @type maxage: int
         @param maxage: is the number of seconds a nonce may be valid. Choosing a
                large value may result in more memory usage whereas a smaller
                value results in more requests. Defaults to 5 minutes.
+        @type maxuses: int
         @param maxuses: is the number of times a nonce may be used (with
                 different nc values). A value of 1 makes nonces usable exactly
                 once resulting in more requests. Defaults to 5.
         """
-        self.app = app
-        self.gentoken = gentoken
+        NonceStoreBase.__init__(self)
         self.maxage = maxage
         self.maxuses = maxuses
-        self.server_secret = ("%066X" % sysrand.getrandbits(33*8)
-                              ).decode("hex").encode("base64").strip()
         self.nonces = [] # [(creation_time, nonce_value, useage_count)]
                          # as [(float, str, int)]
+        self.server_secret = ("%066X" % sysrand.getrandbits(33*8)
+                              ).decode("hex").encode("base64").strip()
+
+    def _cleanup(self):
+        """internal methods cleaning list of valid nonces"""
+        old = format_time(time.time() - self.maxage)
+        while self.nonces and self.nonces[0][0] < old:
+            self.nonces.pop(0)
+
+    def newnonce(self):
+        """
+        Generates a new nonce string.
+        @rtype: str
+        """
+        self._cleanup() # avoid growing self.nonces
+        nonce_time = format_time(time.time())
+        nonce_value = ("%066X" % sysrand.getrandbits(33*8)
+                      ).decode("hex").encode("base64").strip()
+        self.nonces.append((nonce_time, nonce_value, 1))
+        token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
+        token = md5(token).hexdigest()
+        return "%s:%s:%s" % (nonce_time, nonce_value, token)
+
+    def isnonce(self, nonce):
+        """
+        Do a quick a stateless check for whether the provides string might
+        be a nonce.
+        @type nonce: str
+        @rtype: bool
+        """
+        try:
+            nonce_time, nonce_value, nonce_hash = nonce.split(':')
+        except ValueError:
+            return False
+        token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
+        token = md5(token).hexdigest()
+        return nonce_hash == token
+
+    def checknonce(self, nonce, qop, nc):
+        """
+        Do a thorough check for whether the provided string is a nonce and
+        increase usage count on returning True.
+        @type nonce: str
+        @type qop: str or None
+        @type nc: str or None
+        @rtype: bool
+        """
+        try:
+            nonce_time, nonce_value, nonce_hash = nonce.split(':')
+        except ValueError:
+            return False
+        token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
+        token = md5(token).hexdigest()
+        if token != nonce_hash:
+            return False
+        if qop is None:
+            nc = 1
+        else:
+            try:
+                nc = long(nc, 16)
+            except (KeyError, ValueError):
+                return False
+
+        self._cleanup() # avoid stale nonces
+
+        # searching nonce_time
+        lower, upper = 0, len(self.nonces) - 1
+        while lower < upper:
+            mid = (lower + upper) // 2
+            if nonce_time <= self.nonces[mid][0]:
+                upper = mid
+            else:
+                lower = mid + 1
+
+        (nt, nv, uses) = self.nonces[lower]
+        if nt != nonce_time or nv != nonce_value:
+            return False
+        if nc != uses:
+            del self.nonces[lower]
+            return False
+        if uses >= self.maxuses:
+            del self.nonces[lower]
+        else:
+            self.nonces[lower] = (nt, nv, uses+1)
+        return True
+
+__all__.append("AuthDigestMiddleware")
+class AuthDigestMiddleware:
+    """Middleware partly implementing RFC2617. (md5-sess was omited)
+    This middleware does not work with cgi servers or fork servers like
+    scgi.forkpool.
+    """
+    algorithms = {"md5": lambda data: md5(data).hexdigest()}
+    def __init__(self, app, gentoken, maxage=300, maxuses=5, store=None):
+        """
+        @param app: is the wsgi application to be served with authentification.
+        @type gentoken: str -> str
+        @param gentoken: has to have the same functionality and interface as the
+                 AuthTokenGenerator class.
+        @type maxage: int
+        @param maxage: deprecated, see MemoryNonceStore or StatelessNonceStore
+        @type maxuses: int
+        @param maxuses: deprecated, see MemoryNonceStore
+        @type store: NonceStoreBase
+        @param store: a nonce storage implementation object. Usage of this
+                parameter will override maxage and maxuses.
+        """
+        self.app = app
+        self.gentoken = gentoken
+        if store is None:
+            self.noncestore = MemoryNonceStore(maxage, maxuses)
+        else:
+            assert hasattr(store, "newnonce")
+            assert hasattr(store, "isnonce")
+            assert hasattr(store, "checknonce")
+            self.noncestore = store
 
     def __call__(self, environ, start_response):
         """wsgi interface"""
-        self.cleanup_nonces()
 
         try:
             auth = environ["HTTP_AUTHORIZATION"] # raises KeyError
@@ -143,16 +370,19 @@ class AuthDigestMiddleware:
                         "cnonce" not in credentials)):
                 raise AuthenticationRequired
 
-            if not self.is_nonce(credentials): # riases KeyError, ValueError
+            if not self.noncestore.isnonce(credentials["nonce"]):
                 raise AuthenticationRequired
 
             # raises KeyError, ValueError
             response = self.auth_response(credentials,
                                           environ["REQUEST_METHOD"])
+
             if response is None or response != credentials["response"]:
                 raise AuthenticationRequired
 
-            if not self.check_nonce(credentials): # raises KeyError, ValueError
+            if not self.noncestore.checknonce(credentials["nonce"],
+                                              credentials.get("qop"),
+                                              credentials.get("nc")):
                 return self.authorization_required(environ, start_response,
                                                    stale=True) # stale nonce!
 
@@ -161,7 +391,7 @@ class AuthDigestMiddleware:
         else:
             environ["REMOTE_USER"] = credentials["username"]
             def modified_start_response(status, headers, exc_info=None):
-                digest = dict(nextnonce=self.new_nonce())
+                digest = dict(nextnonce=self.noncestore.newnonce())
                 if "qop" in credentials:
                     digest["qop"] = "auth"
                     digest["cnonce"] = credentials["cnonce"] # no KeyError
@@ -197,78 +427,9 @@ class AuthDigestMiddleware:
         digh = self.algorithms[algo](dig)
         return digh
 
-    def cleanup_nonces(self):
-        """internal methods cleaning list of valid nonces"""
-        # see new_nonce
-        old = "%13X" % long((time.time() - self.maxage) * 1000000)
-        while self.nonces and self.nonces[0][0] < old:
-            self.nonces.pop(0)
-
-    def is_nonce(self, credentials):
-        """internal method checking whether a nonce might be from this server
-        @raise KeyError:
-        @raise ValueError:
-        """
-        nonce = credentials["nonce"] # raises KeyError
-        # raises ValueError
-        nonce_time, nonce_value, nonce_hash = nonce.split(':')
-        token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
-        token = md5(token).hexdigest()
-        return nonce_hash == token
-
-    def check_nonce(self, credentials):
-        """internal method checking nonce validity
-        @raise KeyError:
-        @raise ValueError:
-        """
-        nonce = credentials["nonce"]
-        # raises ValueError
-        nonce_time, nonce_value, nonce_hash = nonce.split(':')
-        token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
-        token = md5(token).hexdigest()
-        if token != nonce_hash:
-            return False
-        qop = credentials.get("qop", None)
-        if qop is None:
-            nc = 1
-        else:
-            nc = long(credentials["nc"], 16) # raises KeyError, ValueError
-        # searching nonce_time
-        lower, upper = 0, len(self.nonces) - 1
-        while lower < upper:
-            mid = (lower + upper) // 2
-            if nonce_time <= self.nonces[mid][0]:
-                upper = mid
-            else:
-                lower = mid + 1
-
-        (nt, nv, uses) = self.nonces[lower]
-        if nt != nonce_time or nv != nonce_value:
-            return False
-        if nc != uses:
-            del self.nonces[lower]
-            return False
-        if uses >= self.maxuses:
-            del self.nonces[lower]
-        else:
-            self.nonces[lower] = (nt, nv, uses+1)
-        return True
-
-    def new_nonce(self):
-        """internal method generating a new nonce"""
-        # 13 = (32 bit + 20 bit) / 4
-        nonce_time = "%13X" % long(time.time() * 1000000)
-        randval = sysrand.getrandbits(33*8)
-        nonce_value = ("%066X" % randval).decode("hex").encode("base64").strip()
-        self.nonces.append((nonce_time, nonce_value, 1))
-        token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
-        token = md5(token).hexdigest()
-        return "%s:%s:%s" % (nonce_time, nonce_value, token)
-
     def authorization_required(self, environ, start_response, stale=False):
         """internal method implementing wsgi interface, serving 401 page"""
-        nonce = self.new_nonce()
-        digest = dict(nonce=nonce,
+        digest = dict(nonce=self.noncestore.newnonce(),
                       realm=self.gentoken.realm,
                       algorithm="md5",
                       qop="auth")
-- 
cgit v1.2.3