commit 682cbb4a2b36b0d226ea2a7e8d9bb527fdb2e28e Author: Ravi Chandra Padmala neenaoffline@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) +
tor-commits@lists.torproject.org