[tor-commits] [stem/master] Implement Safecookie support in Stem

atagar at torproject.org atagar at torproject.org
Sun Jun 10 01:27:16 UTC 2012


commit 682cbb4a2b36b0d226ea2a7e8d9bb527fdb2e28e
Author: Ravi Chandra Padmala <neenaoffline at gmail.com>
Date:   Tue May 8 01:05:20 2012 +0530

    Implement Safecookie support in Stem
---
 run_tests.py                            |    2 +
 stem/connection.py                      |  283 ++++++++++++++++++++++++++----
 stem/response/__init__.py               |   21 +++-
 stem/response/authchallenge.py          |   67 ++++++++
 stem/response/protocolinfo.py           |    6 +-
 stem/util/connection.py                 |   45 +++++
 test/integ/connection/authentication.py |  151 ++++++++++++-----
 test/integ/response/protocolinfo.py     |    1 +
 test/unit/connection/authentication.py  |   85 ++++++----
 test/unit/response/__init__.py          |    2 +-
 test/unit/response/authchallenge.py     |   57 ++++++
 11 files changed, 601 insertions(+), 119 deletions(-)

diff --git a/run_tests.py b/run_tests.py
index 22adce3..6a6aa09 100755
--- a/run_tests.py
+++ b/run_tests.py
@@ -23,6 +23,7 @@ import test.unit.response.control_line
 import test.unit.response.control_message
 import test.unit.response.getinfo
 import test.unit.response.protocolinfo
+import test.unit.response.authchallenge
 import test.unit.util.conf
 import test.unit.util.connection
 import test.unit.util.enum
@@ -104,6 +105,7 @@ UNIT_TESTS = (
   test.unit.response.control_line.TestControlLine,
   test.unit.response.getinfo.TestGetInfoResponse,
   test.unit.response.protocolinfo.TestProtocolInfoResponse,
+  test.unit.response.authchallenge.TestAuthChallengeResponse,
   test.unit.connection.authentication.TestAuthenticate,
 )
 
diff --git a/stem/connection.py b/stem/connection.py
index ce83068..8234441 100644
--- a/stem/connection.py
+++ b/stem/connection.py
@@ -71,7 +71,11 @@ the authentication process. For instance...
     |  |- CookieAuthRejected - Tor rejected this method of authentication.
     |  |- IncorrectCookieValue - Authentication cookie was rejected.
     |  |- IncorrectCookieSize - Size of the cookie file is incorrect.
-    |  +- UnreadableCookieFile - Unable to read the contents of the auth cookie.
+    |  |- UnreadableCookieFile - Unable to read the contents of the auth cookie.
+    |  +- AuthChallengeFailed - Failure completing the authchallenge request
+    |     |- AuthSecurityFailure - The computer/network may be compromised.
+    |     |- InvalidClientNonce - The client nonce is invalid.
+    |     +- UnrecognizedAuthChallengeMethod - AUTHCHALLENGE does not support the given methods.
     |
     +- MissingAuthInfo - Unexpected PROTOCOLINFO response, missing auth info.
        |- NoAuthMethods - Missing any methods for authenticating.
@@ -79,6 +83,7 @@ the authentication process. For instance...
 """
 
 import os
+import re
 import getpass
 import binascii
 
@@ -88,10 +93,11 @@ import stem.control
 import stem.version
 import stem.util.enum
 import stem.util.system
+import stem.util.connection
 import stem.util.log as log
 from stem.response.protocolinfo import AuthMethod
 
-def connect_port(control_addr = "127.0.0.1", control_port = 9051, password = None, chroot_path = None, controller = stem.control.Controller):
+def connect_port(control_addr = "127.0.0.1", control_port = 9051, password = None, chroot_path = None, controller = None):
   """
   Convenience function for quickly getting a control connection. This is very
   handy for debugging or CLI setup, handling setup and prompting for a password
@@ -224,8 +230,28 @@ def authenticate(controller, password = None, chroot_path = None, protocolinfo_r
       Tor allows for authentication by reading it a cookie file, but rejected
       the contents of that file.
     
-    * **\***:class:`stem.connection.OpenAuthRejected`
+    * **\***:class:`stem.connection.UnrecognizedAuthChallengeMethod`
+
+      Tor couldn't recognize the AUTHCHALLENGE method Stem sent to it. This
+      shouldn't happen at all.
+
+    * **\***:class:`stem.connection.InvalidClientNonce`
+
+      Tor says that the client nonce provided by Stem during the AUTHCHALLENGE
+      process is invalid.
+
+    * **\***:class:`stem.connection.AuthSecurityFailure`
+
+      Raised when self is a possibility self security having been compromised.
+    
+    * **\***:class:`stem.connection.AuthChallengeFailed`
+
+      The AUTHCHALLENGE command has failed (probably because Stem is connecting
+      to an old version of Tor which doesn't support Safe cookie authentication,
+      could also be because of other reasons).
     
+    * **\***:class:`stem.connection.OpenAuthRejected`
+
       Tor says that it allows for authentication without any credentials, but
       then rejected our authentication attempt.
     
@@ -279,16 +305,22 @@ def authenticate(controller, password = None, chroot_path = None, protocolinfo_r
     else:
       log.debug("Authenticating to a socket with unrecognized auth method%s, ignoring them: %s" % (plural_label, methods_label))
   
-  if AuthMethod.COOKIE in auth_methods and protocolinfo_response.cookie_path is None:
-    auth_methods.remove(AuthMethod.COOKIE)
-    auth_exceptions.append(NoAuthCookie("our PROTOCOLINFO response did not have the location of our authentication cookie"))
+  if protocolinfo_response.cookie_path is None:
+    for cookie_auth_method in (AuthMethod.COOKIE, AuthMethod.SAFECOOKIE):
+      if cookie_auth_method in auth_methods:
+        try:
+          auth_methods.remove(AuthMethod.COOKIE)
+        except ValueError:
+          pass
+        auth_exceptions.append(NoAuthCookie("our PROTOCOLINFO response did not have the location of our authentication cookie"))
   
   if AuthMethod.PASSWORD in auth_methods and password is None:
     auth_methods.remove(AuthMethod.PASSWORD)
     auth_exceptions.append(MissingPassword("no passphrase provided"))
   
   # iterating over AuthMethods so we can try them in this order
-  for auth_type in (AuthMethod.NONE, AuthMethod.PASSWORD, AuthMethod.COOKIE):
+  for auth_type in (AuthMethod.NONE, AuthMethod.PASSWORD, AuthMethod.SAFECOOKIE,
+      AuthMethod.COOKIE):
     if not auth_type in auth_methods: continue
     
     try:
@@ -296,13 +328,16 @@ def authenticate(controller, password = None, chroot_path = None, protocolinfo_r
         authenticate_none(controller, False)
       elif auth_type == AuthMethod.PASSWORD:
         authenticate_password(controller, password, False)
-      elif auth_type == AuthMethod.COOKIE:
+      elif auth_type == AuthMethod.COOKIE or auth_type == AuthMethod.SAFECOOKIE:
         cookie_path = protocolinfo_response.cookie_path
         
         if chroot_path:
           cookie_path = os.path.join(chroot_path, cookie_path.lstrip(os.path.sep))
         
-        authenticate_cookie(controller, cookie_path, False)
+        if auth_type == AuthMethod.SAFECOOKIE:
+          authenticate_safecookie(controller, cookie_path, False)
+        else:
+          authenticate_cookie(controller, cookie_path, False)
       
       return # success!
     except OpenAuthRejected, exc:
@@ -314,10 +349,19 @@ def authenticate(controller, password = None, chroot_path = None, protocolinfo_r
       # that if PasswordAuthRejected is raised it's being raised in error.
       log.debug("The authenticate_password method raised a PasswordAuthRejected when password auth should be available. Stem may need to be corrected to recognize this response: %s" % exc)
       auth_exceptions.append(IncorrectPassword(str(exc)))
+    except AuthSecurityFailure, exc:
+      log.info("The authenticate_safecookie method raised an AuthSecurityFailure. Security might have been compromised - attack? (%s)" % exc)
+      auth_exceptions.append(exc)
+    except (InvalidClientNonce, UnrecognizedAuthChallengeMethod,
+        AuthChallengeFailed), exc:
+      auth_exceptions.append(exc)
     except (IncorrectCookieSize, UnreadableCookieFile, IncorrectCookieValue), exc:
       auth_exceptions.append(exc)
     except CookieAuthRejected, exc:
-      log.debug("The authenticate_cookie method raised a CookieAuthRejected when cookie auth should be available. Stem may need to be corrected to recognize this response: %s" % exc)
+      auth_func = "authenticate_cookie"
+      if exc.auth_type == AuthMethod.SAFECOOKIE:
+        auth_func = "authenticate_safecookie"
+      log.debug("The %s method raised a CookieAuthRejected when cookie auth should be available. Stem may need to be corrected to recognize this response: %s" % (auth_func, exc))
       auth_exceptions.append(IncorrectCookieValue(str(exc), exc.cookie_path))
     except stem.socket.ControllerError, exc:
       auth_exceptions.append(AuthenticationFailure(str(exc)))
@@ -429,6 +473,47 @@ def authenticate_password(controller, password, suppress_ctl_errors = True):
     if not suppress_ctl_errors: raise exc
     else: raise PasswordAuthRejected("Socket failed (%s)" % exc)
 
+def _read_cookie(cookie_path, auth_type):
+  """
+  Provides the contents of a given cookie file. If unable to do so this raises
+  an exception of a given type.
+  
+  :param str cookie_path: absolute path of the cookie file
+  :param str auth_type: cookie authentication type (from the AuthMethod Enum)
+  
+  :raises:
+    * :class:`stem.connection.UnreadableCookieFile` if the cookie file is unreadable
+    * :class:`stem.connection.IncorrectCookieSize` if the cookie size is incorrect (not 32 bytes)
+  """
+
+  if not os.path.exists(cookie_path):
+    raise UnreadableCookieFile("Authentication failed: '%s' doesn't exist" % 
+        cookie_path, cookie_path, auth_type = auth_type)
+  
+  # Abort if the file isn't 32 bytes long. This is to avoid exposing arbitrary
+  # file content to the port.
+  #
+  # Without this a malicious socket could, for instance, claim that
+  # '~/.bash_history' or '~/.ssh/id_rsa' was its authentication cookie to trick
+  # us into reading it for them with our current permissions.
+  #
+  # https://trac.torproject.org/projects/tor/ticket/4303
+  
+  auth_cookie_size = os.path.getsize(cookie_path)
+  
+  if auth_cookie_size != 32:
+    exc_msg = "Authentication failed: authentication cookie '%s' is the wrong \
+size (%i bytes instead of 32)" % (cookie_path, auth_cookie_size)
+    raise IncorrectCookieSize(exc_msg, cookie_path, auth_type = auth_type)
+  
+  try:
+    with file(cookie_path, 'rb', 0) as f:
+      cookie_data = f.read()
+      return cookie_data
+  except IOError, exc:
+    raise UnreadableCookieFile("Authentication failed: unable to read '%s' (%s)"
+        % (cookie_path, exc), cookie_path, auth_type = auth_type)
+
 def authenticate_cookie(controller, cookie_path, suppress_ctl_errors = True):
   """
   Authenticates to a control socket that uses the contents of an authentication
@@ -464,33 +549,10 @@ def authenticate_cookie(controller, cookie_path, suppress_ctl_errors = True):
     * :class:`stem.connection.IncorrectCookieValue` if the cookie file's value is rejected
   """
   
-  if not os.path.exists(cookie_path):
-    raise UnreadableCookieFile("Authentication failed: '%s' doesn't exist" % cookie_path, cookie_path)
-  
-  # Abort if the file isn't 32 bytes long. This is to avoid exposing arbitrary
-  # file content to the port.
-  #
-  # Without this a malicious socket could, for instance, claim that
-  # '~/.bash_history' or '~/.ssh/id_rsa' was its authentication cookie to trick
-  # us into reading it for them with our current permissions.
-  #
-  # https://trac.torproject.org/projects/tor/ticket/4303
-  
-  auth_cookie_size = os.path.getsize(cookie_path)
-  
-  if auth_cookie_size != 32:
-    exc_msg = "Authentication failed: authentication cookie '%s' is the wrong size (%i bytes instead of 32)" % (cookie_path, auth_cookie_size)
-    raise IncorrectCookieSize(exc_msg, cookie_path)
-  
-  try:
-    auth_cookie_file = open(cookie_path, "r")
-    auth_cookie_contents = auth_cookie_file.read()
-    auth_cookie_file.close()
-  except IOError, exc:
-    raise UnreadableCookieFile("Authentication failed: unable to read '%s' (%s)" % (cookie_path, exc), cookie_path) 
+  cookie_data = _read_cookie(cookie_path, AuthMethod.COOKIE)
   
   try:
-    msg = "AUTHENTICATE %s" % binascii.b2a_hex(auth_cookie_contents)
+    msg = "AUTHENTICATE %s" % binascii.b2a_hex(cookie_data)
     auth_response = _msg(controller, msg)
     
     # if we got anything but an OK response then error
@@ -514,6 +576,111 @@ def authenticate_cookie(controller, cookie_path, suppress_ctl_errors = True):
     if not suppress_ctl_errors: raise exc
     else: raise CookieAuthRejected("Socket failed (%s)" % exc, cookie_path)
 
+def authenticate_safecookie(controller, cookie_path, suppress_ctl_errors = True):
+  """
+  Authenticates to a control socket using the safe cookie method, which is
+  enabled by setting the CookieAuthentication torrc option on Tor client's which
+  support it. This uses a two-step process - first, it sends a nonce to the
+  server and receives a challenge from the server of the cookie's contents.
+  Next, it generates a hash digest using the challenge received in the first
+  step and uses it to authenticate to 'controller'.
+  
+  The IncorrectCookieSize and UnreadableCookieFile exceptions take 
+  precedence over the other exception types.
+
+  The UnrecognizedAuthChallengeMethod, AuthChallengeFailed, InvalidClientNonce
+  and CookieAuthRejected exceptions are next in the order of precedence.
+  Depending on the reason, one of these is raised if the first (AUTHCHALLENGE) step
+  fails.
+
+  In the second (AUTHENTICATE) step, IncorrectCookieValue or
+  CookieAuthRejected maybe raised.
+  
+  If authentication fails tor will disconnect and we'll make a best effort
+  attempt to re-establish the connection. This may not succeed, so check
+  is_alive() before using the socket further.
+  
+  For general usage use the authenticate() function instead.
+  
+  :param controller: tor controller or socket to be authenticated
+  :param str cookie_path: path of the authentication cookie to send to tor
+  :param bool suppress_ctl_errors: reports raised :class:`stem.socket.ControllerError` as authentication rejection if True, otherwise they're re-raised
+  
+  :raises:
+    * :class:`stem.connection.IncorrectCookieSize` if the cookie file's size is wrong
+    * :class:`stem.connection.UnreadableCookieFile` if the cookie file doesn't exist or we're unable to read it
+    * :class:`stem.connection.CookieAuthRejected` if cookie authentication is attempted but the socket doesn't accept it
+    * :class:`stem.connection.IncorrectCookieValue` if the cookie file's value is rejected
+    * :class:`stem.connection.UnrecognizedAuthChallengeMethod` if the Tor client fails to recognize the AuthChallenge method
+    * :class:`stem.connection.AuthChallengeFailed` if AUTHCHALLENGE is unimplemented, or if unable to parse AUTHCHALLENGE response
+    * :class:`stem.connection.AuthSecurityFailure` if AUTHCHALLENGE's response looks like a security attack
+    * :class:`stem.connection.InvalidClientNonce` if stem's AUTHCHALLENGE client nonce is rejected for being invalid
+  """
+  
+  cookie_data = _read_cookie(cookie_path, AuthMethod.SAFECOOKIE)
+  client_nonce =  stem.util.connection.random_bytes(32)
+  client_hash_const = "Tor safe cookie authentication controller-to-server hash"
+  server_hash_const = "Tor safe cookie authentication server-to-controller hash"
+
+  try:
+    challenge_response = _msg(controller, "AUTHCHALLENGE SAFECOOKIE %s" %
+        binascii.b2a_hex(client_nonce))
+  
+    if not challenge_response.is_ok():
+      try: controller.connect()
+      except: pass
+      
+      challenge_response_str = str(challenge_response)
+      if "AUTHCHALLENGE only supports" in challenge_response_str:
+        raise UnrecognizedAuthChallengeMethod(challenge_response_str, cookie_path, AuthMethod.SAFECOOKIE)
+      elif "Invalid base16 client nonce" in challenge_response_str:
+        raise InvalidClientNonce(challenge_response_str, cookie_path)
+      elif "Authentication required." in challenge_response_str:
+        raise AuthChallengeFailed("SAFECOOKIE Authentication unimplemented", cookie_path)
+      elif "Cookie authentication is disabled." in challenge_response_str:
+        raise CookieAuthRejected(challenge_response_str, cookie_path, auth_type = AuthMethod.SAFECOOKIE)
+      else:
+        raise AuthChallengeFailed(challenge_response, cookie_path)
+  except stem.socket.ControllerError, exc:
+    try: controller.connect()
+    except: pass
+    
+    if not suppress_ctl_errors: raise exc
+    else: raise AuthChallengeFailed("Socket failed (%s)" % exc, cookie_path)
+
+  try:
+    stem.response.convert("AUTHCHALLENGE", challenge_response)
+  except stem.socket.ProtocolError, exc:
+    if not suppress_ctl_errors: raise exc
+    else: raise AuthChallengeFailed("Unable to parse AUTHCHALLENGE response: %s" % exc, cookie_path)
+
+  expected_server_hash = stem.util.connection.hmac_sha256(server_hash_const,
+      cookie_data + client_nonce + challenge_response.server_nonce)
+  if not stem.util.connection.cryptovariables_equal(challenge_response.server_hash, expected_server_hash):
+    raise AuthSecurityFailure("Server hash is wrong -- attack?", cookie_path)
+
+  try:
+    client_hash = stem.util.connection.hmac_sha256(client_hash_const,
+        cookie_data + client_nonce + challenge_response.server_nonce)
+    controller.send("AUTHENTICATE %s" % (binascii.b2a_hex(client_hash)))
+    auth_response = controller.recv()
+  except stem.socket.ControllerError, exc:
+    try: controller.connect()
+    except: pass
+    
+    if not suppress_ctl_errors: raise exc
+    else: raise CookieAuthRejected("Socket failed (%s)" % exc, cookie_path, auth_response, AuthMethod.SAFECOOKIE)
+
+  # if we got anything but an OK response then err
+  if not auth_response.is_ok():
+    try: controller.connect()
+    except: pass
+    
+    if 'Safe cookie response did not match expected value' in auth_response[0]: #Cookie doesn't match
+      raise IncorrectCookieValue(str(auth_response), cookie_path, auth_response, AuthMethod.SAFECOOKIE)
+    else:
+      raise CookieAuthRejected(str(auth_response), cookie_path, auth_response, AuthMethod.SAFECOOKIE)
+
 def get_protocolinfo(controller):
   """
   Issues a PROTOCOLINFO query to a control socket, getting information about
@@ -665,11 +832,15 @@ class CookieAuthFailed(AuthenticationFailure):
   Failure to authenticate with an authentication cookie.
   
   :param str cookie_path: location of the authentication cookie we attempted
+  :param str auth_type:  cookie authentication type (from the AuthMethod Enum)
   """
   
-  def __init__(self, message, cookie_path, auth_response = None):
+  def __init__(self, message, cookie_path, auth_response = None, auth_type = AuthMethod.COOKIE):
+    """
+    """
     AuthenticationFailure.__init__(self, message, auth_response)
     self.cookie_path = cookie_path
+    self.auth_type = auth_type
 
 class CookieAuthRejected(CookieAuthFailed):
   "Socket does not support password authentication."
@@ -683,6 +854,39 @@ class IncorrectCookieSize(CookieAuthFailed):
 class UnreadableCookieFile(CookieAuthFailed):
   "Error arose in reading the authentication cookie."
 
+class AuthChallengeFailed(CookieAuthFailed):
+  """
+  AUTHCHALLENGE command has failed.
+
+  :param str cookie_path: path to the cookie file
+  :param str auth_type: cookie authentication type (from the AuthMethod Enum)
+  """
+  
+  def __init__(self, message, cookie_path, auth_type = AuthMethod.SAFECOOKIE):
+    CookieAuthFailed.__init__(self, message, cookie_path)
+    self.auth_type = auth_type
+
+
+class UnrecognizedAuthChallengeMethod(AuthChallengeFailed):
+  """
+  Tor couldn't recognize our AUTHCHALLENGE method.
+  
+  :var str authchallenge_method: AUTHCHALLENGE method that Tor couldn't recognize
+  :var str cookie_path: path to the cookie file
+  :var str auth_type: cookie authentication type (from the AuthMethod Enum)
+  """
+  
+  def __init__(self, message, cookie_path, authchallenge_method, auth_type = AuthMethod.SAFECOOKIE):
+    CookieAuthFailed.__init__(self, message, cookie_path)
+    self.authchallenge_method = authchallenge_method
+    self.auth_type = auth_type
+
+class AuthSecurityFailure(AuthChallengeFailed):
+  "AUTHCHALLENGE response is invalid."
+
+class InvalidClientNonce(AuthChallengeFailed):
+  "AUTHCHALLENGE request contains an invalid client nonce."
+
 class MissingAuthInfo(AuthenticationFailure):
   """
   The PROTOCOLINFO response didn't have enough information to authenticate.
@@ -704,8 +908,11 @@ AUTHENTICATE_EXCEPTIONS = (
   IncorrectCookieSize,
   UnreadableCookieFile,
   IncorrectCookieValue,
+  UnrecognizedAuthChallengeMethod,
+  InvalidClientNonce,
+  AuthSecurityFailure,
+  AuthChallengeFailed,
   OpenAuthRejected,
   MissingAuthInfo,
-  AuthenticationFailure,
+  AuthenticationFailure
 )
-
diff --git a/stem/response/__init__.py b/stem/response/__init__.py
index 823cefa..788658b 100644
--- a/stem/response/__init__.py
+++ b/stem/response/__init__.py
@@ -23,7 +23,7 @@ Parses replies from the control socket.
     +- pop_mapping - removes and returns the next entry as a KEY=VALUE mapping
 """
 
-__all__ = ["getinfo", "protocolinfo", "convert", "ControlMessage", "ControlLine"]
+__all__ = ["getinfo", "protocolinfo", "authchallenge", "convert", "ControlMessage", "ControlLine"]
 
 import re
 import threading
@@ -48,6 +48,7 @@ def convert(response_type, message):
   
     * GETINFO
     * PROTOCOLINFO
+    * AUTHCHALLENGE
   
   If the response_type isn't recognized then this is leaves it alone.
   
@@ -61,6 +62,7 @@ def convert(response_type, message):
   
   import stem.response.getinfo
   import stem.response.protocolinfo
+  import stem.response.authchallenge
   
   if not isinstance(message, ControlMessage):
     raise TypeError("Only able to convert stem.response.ControlMessage instances")
@@ -69,6 +71,8 @@ def convert(response_type, message):
     response_class = stem.response.getinfo.GetInfoResponse
   elif response_type == "PROTOCOLINFO":
     response_class = stem.response.protocolinfo.ProtocolInfoResponse
+  elif response_type == "AUTHCHALLENGE":
+    response_class = stem.response.authchallenge.AuthChallengeResponse
   else: raise TypeError("Unsupported response type: %s" % response_type)
   
   message.__class__ = response_class
@@ -165,6 +169,20 @@ class ControlMessage:
     
     for _, _, content in self._parsed_content:
       yield ControlLine(content)
+  
+  def __len__(self):
+    """
+    :returns: Number of ControlLines
+    """
+
+    return len(self._parsed_content)
+  
+  def __getitem__(self, index):
+    """
+    :returns: ControlLine at index
+    """
+
+    return ControlLine(self._parsed_content[index][2])
 
 class ControlLine(str):
   """
@@ -302,6 +320,7 @@ class ControlLine(str):
     :returns: tuple of the form (key, value)
     
     :raises: ValueError if this isn't a KEY=VALUE mapping or if quoted is True without the value being quoted
+    :raises: IndexError if there's nothing to parse from the line
     """
     
     with self._remainder_lock:
diff --git a/stem/response/authchallenge.py b/stem/response/authchallenge.py
new file mode 100644
index 0000000..61c9ae8
--- /dev/null
+++ b/stem/response/authchallenge.py
@@ -0,0 +1,67 @@
+
+import re
+import binascii
+
+import stem.socket
+import stem.response
+
+class AuthChallengeResponse(stem.response.ControlMessage):
+  """
+  AUTHCHALLENGE query response.
+  
+  :var str server_hash: server hash returned by Tor
+  :var str server_nonce: server nonce returned by Tor
+  """
+  
+  def _parse_message(self):
+    # Example:
+    #   250 AUTHCHALLENGE SERVERHASH=680A73C9836C4F557314EA1C4EDE54C285DB9DC89C83627401AEF9D7D27A95D5 SERVERNONCE=F8EA4B1F2C8B40EF1AF68860171605B910E3BBCABADF6FC3DB1FA064F4690E85
+    
+    _ProtocolError = stem.socket.ProtocolError
+
+    try:
+      line = self[0]
+    except IndexError:
+      raise _ProtocolError("Received empty AUTHCHALLENGE response")
+
+    # sanity check that we're a AUTHCHALLENGE response
+    if not line.pop() == "AUTHCHALLENGE":
+      raise _ProtocolError("Message is not an AUTHCHALLENGE response (%s)" % self)
+
+    if len(self) > 1:
+      raise _ProtocolError("Received multiline AUTHCHALLENGE response (%s)" % line)
+
+    self.server_hash, self.server_nonce = None, None
+
+    try:
+      key, value = line.pop_mapping()
+    except (IndexError, ValueError), exc:
+      raise _ProtocolError(exc.message)
+    if key == "SERVERHASH":
+      if not re.match("^[A-Fa-f0-9]{64}$", value):
+        raise _ProtocolError("SERVERHASH has an invalid value: %s" % value)
+          
+      self.server_hash = binascii.a2b_hex(value)
+
+    try:
+      key, value = line.pop_mapping()
+    except (IndexError, ValueError), exc:
+      raise _ProtocolError(exc.message)
+    if key == "SERVERNONCE":
+      if not re.match("^[A-Fa-f0-9]{64}$", value):
+        raise _ProtocolError("SERVERNONCE has an invalid value: %s" % value)
+      
+      self.server_nonce = binascii.a2b_hex(value)
+      
+    msg = ""
+    if not self.server_hash:
+      msg.append("SERVERHASH")
+      if not self.server_nonce:
+        msg.append("and SERVERNONCE")
+    else:
+      if not self.server_nonce:
+        msg.append("SERVERNONCE")
+
+    if msg:
+      raise _ProtocolError("AUTHCHALLENGE response is missing %s." % msg)
+
diff --git a/stem/response/protocolinfo.py b/stem/response/protocolinfo.py
index 0a9d94d..23bef69 100644
--- a/stem/response/protocolinfo.py
+++ b/stem/response/protocolinfo.py
@@ -16,6 +16,10 @@ methods it will accept in response to PROTOCOLINFO queries.
   See tor's CookieAuthentication option. Controllers need to supply the
   contents of the cookie file.
 
+**AuthMethod.SAFECOOKIE**
+  See tor's CookieAuthentication option. Controllers need to reply to a
+  hmac challenge using the contents of the cookie file.
+
 **AuthMethod.UNKNOWN**
   Tor provided one or more authentication methods that we don't recognize. This
   is probably from a new addition to the control protocol.
@@ -27,7 +31,7 @@ import stem.version
 import stem.util.enum
 import stem.util.log as log
 
-AuthMethod = stem.util.enum.Enum("NONE", "PASSWORD", "COOKIE", "UNKNOWN")
+AuthMethod = stem.util.enum.Enum("NONE", "PASSWORD", "COOKIE", "SAFECOOKIE", "UNKNOWN")
 
 class ProtocolInfoResponse(stem.response.ControlMessage):
   """
diff --git a/stem/util/connection.py b/stem/util/connection.py
index cbf9856..ed339c9 100644
--- a/stem/util/connection.py
+++ b/stem/util/connection.py
@@ -5,7 +5,11 @@ later to have all of `arm's functions
 but for now just moving the parts we need.
 """
 
+import os
 import re
+import hmac
+import random
+import hashlib
 
 def is_valid_ip_address(address):
   """
@@ -78,3 +82,44 @@ def is_valid_port(entry, allow_zero = False):
   
   return entry > 0 and entry < 65536
 
+
+def hmac_sha256(key, msg):
+  """
+  Generates a sha256 digest using the given key and message.
+
+  :param str key: starting key for the hash
+  :param str msg: message to be hashed
+
+  :returns; A sha256 digest of msg, hashed using the given key.
+  """
+
+  return hmac.new(key, msg, hashlib.sha256).digest()
+
+def random_bytes(length):
+  """
+  Generates and returns a 'length' byte random string.
+
+  :param int length: length of random string to be returned in bytes.
+
+  :returns: A string of length 'length' bytes.
+  """
+  
+  return os.urandom(length)
+
+CRYPTOVARIABLE_EQUALITY_COMPARISON_NONCE = random_bytes(32)
+def cryptovariables_equal(x, y):
+  """
+  Compares two strings for equality securely.
+
+  :param str x: string to be compared.
+  :param str y: the other string to be compared.
+
+  :returns: True if both strings are equal, False otherwise.
+  """
+
+  ## Like all too-high-level languages, Python sucks for secure coding.
+  ## I'm not even going to try to compare strings in constant time.
+  ## Fortunately, I have HMAC and a random number generator. -- rransom
+  return (hmac_sha256(CRYPTOVARIABLE_EQUALITY_COMPARISON_NONCE, x) ==
+      hmac_sha256(CRYPTOVARIABLE_EQUALITY_COMPARISON_NONCE, y))
+
diff --git a/test/integ/connection/authentication.py b/test/integ/connection/authentication.py
index 5875a80..aa7d6c1 100644
--- a/test/integ/connection/authentication.py
+++ b/test/integ/connection/authentication.py
@@ -9,17 +9,22 @@ import unittest
 import test.runner
 import stem.connection
 import stem.socket
+from stem.version import Version
+from stem.response.protocolinfo import AuthMethod
 
 # Responses given by tor for various authentication failures. These may change
 # in the future and if they do then this test should be updated.
 
 COOKIE_AUTH_FAIL = "Authentication failed: Wrong length on authentication cookie."
+SAFECOOKIE_AUTH_FAIL = "Authentication failed: Wrong length for safe cookie response."
 PASSWORD_AUTH_FAIL = "Authentication failed: Password did not match HashedControlPassword value from configuration. Maybe you tried a plain text password? If so, the standard requires that you put it in double quotes."
 MULTIPLE_AUTH_FAIL = "Authentication failed: Password did not match HashedControlPassword *or* authentication cookie."
+SAFECOOKIE_AUTHCHALLENGE_FAIL = "Cookie authentication is disabled"
 
 # this only arises in cookie-only or password-only auth when we authenticate
 # with the wrong value
 INCORRECT_COOKIE_FAIL = "Authentication failed: Authentication cookie did not match expected value."
+INCORRECT_SAFECOOKIE_FAIL = "Authentication failed: Safe cookie response did not match expected value."
 INCORRECT_PASSWORD_FAIL = "Authentication failed: Password did not match HashedControlPassword value from configuration"
 
 def _can_authenticate(auth_type):
@@ -36,11 +41,12 @@ def _can_authenticate(auth_type):
   
   tor_options = test.runner.get_runner().get_options()
   password_auth = test.runner.Torrc.PASSWORD in tor_options
-  cookie_auth = test.runner.Torrc.COOKIE in tor_options
+  safecookie_auth = cookie_auth = test.runner.Torrc.COOKIE in tor_options
   
   if not password_auth and not cookie_auth: return True # open socket
   elif auth_type == stem.connection.AuthMethod.PASSWORD: return password_auth
   elif auth_type == stem.connection.AuthMethod.COOKIE: return cookie_auth
+  elif auth_type == stem.connection.AuthMethod.SAFECOOKIE: return safecookie_auth
   else: return False
 
 def _get_auth_failure_message(auth_type):
@@ -58,27 +64,37 @@ def _get_auth_failure_message(auth_type):
   
   tor_options = test.runner.get_runner().get_options()
   password_auth = test.runner.Torrc.PASSWORD in tor_options
-  cookie_auth = test.runner.Torrc.COOKIE in tor_options
+  safecookie_auth = cookie_auth = test.runner.Torrc.COOKIE in tor_options
   
   if cookie_auth and password_auth:
     return MULTIPLE_AUTH_FAIL
   elif cookie_auth:
     if auth_type == stem.connection.AuthMethod.COOKIE:
-      return INCORRECT_COOKIE_FAIL
+        return INCORRECT_COOKIE_FAIL
+    elif auth_type == stem.connection.AuthMethod.SAFECOOKIE:
+        return INCORRECT_SAFECOOKIE_FAIL
     else:
-      return COOKIE_AUTH_FAIL
+        return COOKIE_AUTH_FAIL
   elif password_auth:
     if auth_type == stem.connection.AuthMethod.PASSWORD:
       return INCORRECT_PASSWORD_FAIL
     else:
       return PASSWORD_AUTH_FAIL
   else:
-    # shouldn't happen, if so then the test has a bug
-    raise ValueError("No methods of authentication. If this is an open socket then auth shoulnd't fail.")
+    # shouldn't happen unless safecookie, if so then the test has a bug
+    if auth_type == stem.connection.AuthMethod.SAFECOOKIE:
+      return SAFECOOKIE_AUTHCHALLENGE_FAIL
+    raise ValueError("No methods of authentication. If this is an open socket then auth shouldn't fail.")
 
 class TestAuthenticate(unittest.TestCase):
   def setUp(self):
     test.runner.require_control(self)
+    self.cookie_auth_methods = [AuthMethod.COOKIE]
+    
+    tor_version = test.runner.get_runner().get_tor_version()
+    if tor_version >= Version("0.2.2.36") and tor_version < Version("0.2.3.0") \
+        or tor_version >= Version("0.2.3.13-alpha"):
+      self.cookie_auth_methods.append(AuthMethod.SAFECOOKIE)
   
   def test_authenticate_general_socket(self):
     """
@@ -169,6 +185,23 @@ class TestAuthenticate(unittest.TestCase):
       stem.connection.authenticate(control_socket, test.runner.CONTROL_PASSWORD, runner.get_chroot())
       test.runner.exercise_controller(self, control_socket)
   
+  def test_authenticate_general_cookie(self):
+    """
+    Tests the authenticate function's password argument.
+    """
+    
+    runner = test.runner.get_runner()
+    tor_options = runner.get_options()
+    is_cookie_only = test.runner.Torrc.COOKIE in tor_options and not test.runner.Torrc.PASSWORD in tor_options
+    
+    # test both cookie authentication mechanisms
+    with runner.get_tor_socket(False) as control_socket:
+      if is_cookie_only:
+        for method in self.cookie_auth_methods:
+          protocolinfo_response = stem.connection.get_protocolinfo(control_socket)
+          protocolinfo_response.auth_methods.remove(method)
+          stem.connection.authenticate(control_socket, chroot_path = runner.get_chroot(), protocolinfo_response = protocolinfo_response)
+  
   def test_authenticate_none(self):
     """
     Tests the authenticate_none function.
@@ -213,21 +246,21 @@ class TestAuthenticate(unittest.TestCase):
     Tests the authenticate_cookie function.
     """
     
-    auth_type = stem.connection.AuthMethod.COOKIE
-    auth_value = test.runner.get_runner().get_auth_cookie_path()
-    
-    if not os.path.exists(auth_value):
-      # If the authentication cookie doesn't exist then we'll be getting an
-      # error for that rather than rejection. This will even fail if
-      # _can_authenticate is true because we *can* authenticate with cookie
-      # auth but the function will short circuit with failure due to the
-      # missing file.
+    for auth_type in self.cookie_auth_methods:
+      auth_value = test.runner.get_runner().get_auth_cookie_path()
       
-      self.assertRaises(stem.connection.UnreadableCookieFile, self._check_auth, auth_type, auth_value, False)
-    elif _can_authenticate(auth_type):
-      self._check_auth(auth_type, auth_value)
-    else:
-      self.assertRaises(stem.connection.CookieAuthRejected, self._check_auth, auth_type, auth_value)
+      if not os.path.exists(auth_value):
+        # If the authentication cookie doesn't exist then we'll be getting an
+        # error for that rather than rejection. This will even fail if
+        # _can_authenticate is true because we *can* authenticate with cookie
+        # auth but the function will short circuit with failure due to the
+        # missing file.
+        
+        self.assertRaises(stem.connection.UnreadableCookieFile, self._check_auth, auth_type, auth_value, False)
+      elif _can_authenticate(auth_type):
+        self._check_auth(auth_type, auth_value)
+      else:
+        self.assertRaises(stem.connection.CookieAuthRejected, self._check_auth, auth_type, auth_value)
   
   def test_authenticate_cookie_invalid(self):
     """
@@ -235,26 +268,34 @@ class TestAuthenticate(unittest.TestCase):
     value.
     """
     
-    auth_type = stem.connection.AuthMethod.COOKIE
-    auth_value = test.runner.get_runner().get_test_dir("fake_cookie")
-    
-    # we need to create a 32 byte cookie file to load from
-    fake_cookie = open(auth_value, "w")
-    fake_cookie.write("0" * 32)
-    fake_cookie.close()
-    
-    if _can_authenticate(stem.connection.AuthMethod.NONE):
-      # authentication will work anyway
-      self._check_auth(auth_type, auth_value)
-    else:
-      if _can_authenticate(auth_type):
-        exc_type = stem.connection.IncorrectCookieValue
+    for auth_type in self.cookie_auth_methods:
+      auth_value = test.runner.get_runner().get_test_dir("fake_cookie")
+      
+      # we need to create a 32 byte cookie file to load from
+      fake_cookie = open(auth_value, "w")
+      fake_cookie.write("0" * 32)
+      fake_cookie.close()
+      
+      if _can_authenticate(stem.connection.AuthMethod.NONE):
+        # authentication will work anyway
+        if auth_type == AuthMethod.COOKIE:
+          self._check_auth(auth_type, auth_value)
+        #unless you're trying the safe cookie method
+        elif auth_type == AuthMethod.SAFECOOKIE:
+          exc_type = stem.connection.AuthChallengeFailed
+          self.assertRaises(exc_type, self._check_auth, auth_type, auth_value)
+      
       else:
-        exc_type = stem.connection.CookieAuthRejected
+        if _can_authenticate(auth_type):
+          exc_type = stem.connection.IncorrectCookieValue
+        else:
+          exc_type = stem.connection.CookieAuthRejected
+          if auth_type == AuthMethod.SAFECOOKIE:
+            exc_type = stem.connection.AuthChallengeFailed
+        
+        self.assertRaises(exc_type, self._check_auth, auth_type, auth_value)
       
-      self.assertRaises(exc_type, self._check_auth, auth_type, auth_value)
-    
-    os.remove(auth_value)
+      os.remove(auth_value)
   
   def test_authenticate_cookie_missing(self):
     """
@@ -262,9 +303,9 @@ class TestAuthenticate(unittest.TestCase):
     shouldn't exist.
     """
     
-    auth_type = stem.connection.AuthMethod.COOKIE
-    auth_value = "/if/this/exists/then/they're/asking/for/a/failure"
-    self.assertRaises(stem.connection.UnreadableCookieFile, self._check_auth, auth_type, auth_value, False)
+    for auth_type in self.cookie_auth_methods:
+      auth_value = "/if/this/exists/then/they're/asking/for/a/failure"
+      self.assertRaises(stem.connection.UnreadableCookieFile, self._check_auth, auth_type, auth_value, False)
   
   def test_authenticate_cookie_wrong_size(self):
     """
@@ -273,7 +314,7 @@ class TestAuthenticate(unittest.TestCase):
     socket.
     """
     
-    auth_type = stem.connection.AuthMethod.COOKIE
+    auth_type = AuthMethod.COOKIE
     auth_value = test.runner.get_runner().get_torrc_path(True)
     
     if os.path.getsize(auth_value) == 32:
@@ -282,6 +323,25 @@ class TestAuthenticate(unittest.TestCase):
     else:
       self.assertRaises(stem.connection.IncorrectCookieSize, self._check_auth, auth_type, auth_value, False)
   
+  def test_authenticate_safecookie_wrong_size(self):
+    """
+    Tests the authenticate_safecookie function with our torrc as an auth cookie.
+    This is to confirm that we won't read arbitrary files to the control
+    socket.
+    """
+    
+    auth_type = AuthMethod.SAFECOOKIE
+    auth_value = test.runner.get_runner().get_torrc_path(True)
+    
+    auth_value = test.runner.get_runner().get_test_dir("fake_cookie")
+    
+    # we need to create a 32 byte cookie file to load from
+    fake_cookie = open(auth_value, "w")
+    fake_cookie.write("0" * 48)
+    fake_cookie.close()
+    self.assertRaises(stem.connection.IncorrectCookieSize,
+        stem.connection.authenticate_safecookie, auth_type, auth_value, False)
+  
   def _check_auth(self, auth_type, auth_arg = None, check_message = True):
     """
     Attempts to use the given type of authentication against tor's control
@@ -308,6 +368,8 @@ class TestAuthenticate(unittest.TestCase):
           stem.connection.authenticate_password(control_socket, auth_arg)
         elif auth_type == stem.connection.AuthMethod.COOKIE:
           stem.connection.authenticate_cookie(control_socket, auth_arg)
+        elif auth_type == stem.connection.AuthMethod.SAFECOOKIE:
+          stem.connection.authenticate_safecookie(control_socket, auth_arg)
         
         test.runner.exercise_controller(self, control_socket)
       except stem.connection.AuthenticationFailure, exc:
@@ -316,7 +378,10 @@ class TestAuthenticate(unittest.TestCase):
         
         # check that we got the failure message that we'd expect
         if check_message:
-          failure_msg = _get_auth_failure_message(auth_type)
+          if auth_type != AuthMethod.SAFECOOKIE:
+            failure_msg = _get_auth_failure_message(auth_type)
+          else:
+            failure_msg = _get_auth_failure_message(auth_type)
           self.assertEqual(failure_msg, str(exc))
         
         raise exc
diff --git a/test/integ/response/protocolinfo.py b/test/integ/response/protocolinfo.py
index e8e8c12..f9c96b0 100644
--- a/test/integ/response/protocolinfo.py
+++ b/test/integ/response/protocolinfo.py
@@ -120,6 +120,7 @@ class TestProtocolInfo(unittest.TestCase):
     
     if test.runner.Torrc.COOKIE in tor_options:
       auth_methods.append(stem.response.protocolinfo.AuthMethod.COOKIE)
+      auth_methods.append(stem.response.protocolinfo.AuthMethod.SAFECOOKIE)
       chroot_path = runner.get_chroot()
       auth_cookie_path = runner.get_auth_cookie_path()
       
diff --git a/test/unit/connection/authentication.py b/test/unit/connection/authentication.py
index 831ca62..bd56727 100644
--- a/test/unit/connection/authentication.py
+++ b/test/unit/connection/authentication.py
@@ -12,6 +12,8 @@ various error conditions, and make sure that the right exception is raised.
 import unittest
 
 import stem.connection
+import stem.response
+import stem.response.authchallenge
 import stem.util.log as log
 import test.mocking as mocking
 
@@ -24,15 +26,17 @@ def _get_all_auth_method_combinations():
   for is_none in (False, True):
     for is_password in (False, True):
       for is_cookie in (False, True):
-        for is_unknown in (False, True):
-          auth_methods = []
-          
-          if is_none: auth_methods.append(stem.connection.AuthMethod.NONE)
-          if is_password: auth_methods.append(stem.connection.AuthMethod.PASSWORD)
-          if is_cookie: auth_methods.append(stem.connection.AuthMethod.COOKIE)
-          if is_unknown: auth_methods.append(stem.connection.AuthMethod.UNKNOWN)
-          
-          yield tuple(auth_methods)
+        for is_safecookie in (False, True):
+          for is_unknown in (False, True):
+            auth_methods = []
+            
+            if is_none: auth_methods.append(stem.connection.AuthMethod.NONE)
+            if is_password: auth_methods.append(stem.connection.AuthMethod.PASSWORD)
+            if is_cookie: auth_methods.append(stem.connection.AuthMethod.COOKIE)
+            if is_safecookie: auth_methods.append(stem.connection.AuthMethod.SAFECOOKIE)
+            if is_unknown: auth_methods.append(stem.connection.AuthMethod.UNKNOWN)
+            
+            yield tuple(auth_methods)
 
 class TestAuthenticate(unittest.TestCase):
   def setUp(self):
@@ -40,6 +44,7 @@ class TestAuthenticate(unittest.TestCase):
     mocking.mock(stem.connection.authenticate_none, mocking.no_op())
     mocking.mock(stem.connection.authenticate_password, mocking.no_op())
     mocking.mock(stem.connection.authenticate_cookie, mocking.no_op())
+    mocking.mock(stem.connection.authenticate_safecookie, mocking.no_op())
   
   def tearDown(self):
     mocking.revert_mocking()
@@ -92,6 +97,12 @@ class TestAuthenticate(unittest.TestCase):
       stem.connection.CookieAuthRejected(None, None),
       stem.connection.IncorrectCookieValue(None, None))
     
+    all_auth_safecookie_exc = all_auth_cookie_exc + (
+      stem.connection.UnrecognizedAuthChallengeMethod(None, None, None),
+      stem.connection.AuthChallengeFailed(None, None),
+      stem.connection.AuthSecurityFailure(None, None),
+      stem.connection.InvalidClientNonce(None, None))
+    
     # authentication functions might raise a controller error when
     # 'suppress_ctl_errors' is False, so including those
     
@@ -103,6 +114,7 @@ class TestAuthenticate(unittest.TestCase):
     all_auth_none_exc += control_exc
     all_auth_password_exc += control_exc
     all_auth_cookie_exc += control_exc
+    all_auth_safecookie_exc += control_exc
     
     for protocolinfo_auth_methods in _get_all_auth_method_combinations():
       # protocolinfo input for the authenticate() call we'll be making
@@ -114,35 +126,38 @@ class TestAuthenticate(unittest.TestCase):
       for auth_none_exc in all_auth_none_exc:
         for auth_password_exc in all_auth_password_exc:
           for auth_cookie_exc in all_auth_cookie_exc:
-            # determine if the authenticate() call will succeed and mock each
-            # of the authenticate_* function to raise its given exception
-            
-            expect_success = False
-            auth_mocks = {
-              stem.connection.AuthMethod.NONE:
-                (stem.connection.authenticate_none, auth_none_exc),
-              stem.connection.AuthMethod.PASSWORD:
-                (stem.connection.authenticate_password, auth_password_exc),
-              stem.connection.AuthMethod.COOKIE:
-                (stem.connection.authenticate_cookie, auth_cookie_exc),
-            }
-            
-            for auth_method in auth_mocks:
-              auth_function, raised_exc = auth_mocks[auth_method]
+            for auth_safecookie_exc in all_auth_cookie_exc:
+              # determine if the authenticate() call will succeed and mock each
+              # of the authenticate_* function to raise its given exception
+              
+              expect_success = False
+              auth_mocks = {
+                stem.connection.AuthMethod.NONE:
+                  (stem.connection.authenticate_none, auth_none_exc),
+                stem.connection.AuthMethod.PASSWORD:
+                  (stem.connection.authenticate_password, auth_password_exc),
+                stem.connection.AuthMethod.COOKIE:
+                  (stem.connection.authenticate_cookie, auth_cookie_exc),
+                stem.connection.AuthMethod.SAFECOOKIE:
+                  (stem.connection.authenticate_safecookie, auth_safecookie_exc),
+              }
               
-              if not raised_exc:
-                # Mocking this authentication method so it will succeed. If
-                # it's among the protocolinfo methods then expect success.
+              for auth_method in auth_mocks:
+                auth_function, raised_exc = auth_mocks[auth_method]
                 
-                mocking.mock(auth_function, mocking.no_op())
-                expect_success |= auth_method in protocolinfo_auth_methods
+                if not raised_exc:
+                  # Mocking this authentication method so it will succeed. If
+                  # it's among the protocolinfo methods then expect success.
+                  
+                  mocking.mock(auth_function, mocking.no_op())
+                  expect_success |= auth_method in protocolinfo_auth_methods
+                else:
+                  mocking.mock(auth_function, mocking.raise_exception(raised_exc))
+              
+              if expect_success:
+                stem.connection.authenticate(None, "blah", None, protocolinfo_arg)
               else:
-                mocking.mock(auth_function, mocking.raise_exception(raised_exc))
-            
-            if expect_success:
-              stem.connection.authenticate(None, "blah", None, protocolinfo_arg)
-            else:
-              self.assertRaises(stem.connection.AuthenticationFailure, stem.connection.authenticate, None, "blah", None, protocolinfo_arg)
+                self.assertRaises(stem.connection.AuthenticationFailure, stem.connection.authenticate, None, "blah", None, protocolinfo_arg)
     
     # revert logging back to normal
     stem_logger.setLevel(log.logging_level(log.TRACE))
diff --git a/test/unit/response/__init__.py b/test/unit/response/__init__.py
index c53d9ef..0e4889c 100644
--- a/test/unit/response/__init__.py
+++ b/test/unit/response/__init__.py
@@ -2,5 +2,5 @@
 Unit tests for stem.response.
 """
 
-__all__ = ["control_message", "control_line", "getinfo", "protocolinfo"]
+__all__ = ["control_message", "control_line", "getinfo", "protocolinfo", "authchallenge"]
 
diff --git a/test/unit/response/authchallenge.py b/test/unit/response/authchallenge.py
new file mode 100644
index 0000000..593d3ac
--- /dev/null
+++ b/test/unit/response/authchallenge.py
@@ -0,0 +1,57 @@
+"""
+Unit tests for the stem.response.authchallenge.AuthChallengeResponse class.
+"""
+
+import unittest
+
+import stem.socket
+import stem.response
+import stem.response.authchallenge
+import test.mocking as mocking
+
+class TestAuthChallengeResponse(unittest.TestCase):
+  VALID_RESPONSE = "250 AUTHCHALLENGE SERVERHASH=B16F72DACD4B5ED1531F3FCC04B593D46A1E30267E636EA7C7F8DD7A2B7BAA05 SERVERNONCE=653574272ABBB49395BD1060D642D653CFB7A2FCE6A4955BCFED819703A9998C"
+  VALID_HASH = "\xb1or\xda\xcdK^\xd1S\x1f?\xcc\x04\xb5\x93\xd4j\x1e0&~cn\xa7\xc7\xf8\xddz+{\xaa\x05"
+  VALID_NONCE = "e5t'*\xbb\xb4\x93\x95\xbd\x10`\xd6B\xd6S\xcf\xb7\xa2\xfc\xe6\xa4\x95[\xcf\xed\x81\x97\x03\xa9\x99\x8c"
+  INVALID_RESPONSE = "250 AUTHCHALLENGE SERVERHASH=FOOBARB16F72DACD4B5ED1531F3FCC04B593D46A1E30267E636EA7C7F8DD7A2B7BAA05 SERVERNONCE=FOOBAR653574272ABBB49395BD1060D642D653CFB7A2FCE6A4955BCFED819703A9998C"
+  
+  def test_valid_response(self):
+    """
+    Parses valid AUTHCHALLENGE responses.
+    """
+    
+    control_message = mocking.get_message(self.VALID_RESPONSE)
+    stem.response.convert("AUTHCHALLENGE", control_message)
+    
+    # now this should be a AuthChallengeResponse (ControlMessage subclass)
+    self.assertTrue(isinstance(control_message, stem.response.ControlMessage))
+    self.assertTrue(isinstance(control_message, stem.response.authchallenge.AuthChallengeResponse))
+    
+    self.assertEqual(self.VALID_HASH, control_message.server_hash)
+    self.assertEqual(self.VALID_NONCE, control_message.server_nonce)
+  
+  def test_invalid_responses(self):
+    """
+    Tries to parse various malformed responses and checks it they raise
+    appropriate exceptions.
+    """
+    
+    valid_resp = self.VALID_RESPONSE.split()
+    
+    control_message = mocking.get_message(' '.join(valid_resp[0:1] + [valid_resp[3]]))
+    self.assertRaises(stem.socket.ProtocolError, stem.response.convert, "AUTHCHALLENGE", control_message)
+    
+    control_message = mocking.get_message(' '.join(valid_resp[0:1] + [valid_resp[3], valid_resp[2]]))
+    self.assertRaises(stem.socket.ProtocolError, stem.response.convert, "AUTHCHALLENGE", control_message)
+    
+    control_message = mocking.get_message(' '.join(valid_resp[0:2]))
+    self.assertRaises(stem.socket.ProtocolError, stem.response.convert, "AUTHCHALLENGE", control_message)
+    
+    for begin in range(4):
+      for end in range(4):
+        try:
+          control_message = mocking.get_message(' '.join(self.VALID_RESPONSE.split()[begin:end]))
+        except stem.socket.ProtocolError:
+          continue
+        self.assertRaises(stem.socket.ProtocolError, stem.response.convert, "AUTHCHALLENGE", control_message)
+





More information about the tor-commits mailing list