commit 0dba06fd54d6e1ce844e3a1518f15e9feb375db1 Author: Miguel Jacq mig@mig5.net Date: Thu Jun 18 13:24:40 2020 +1000
Adds support for ONION_CLIENT_AUTH_ADD, ONION_CLIENT_AUTH_REMOVE and ONION_CLIENT_AUTH_VIEW --- stem/control.py | 82 ++++++++++++++++++++++++++++++++++++++ stem/interpreter/settings.cfg | 12 ++++++ stem/response/__init__.py | 41 ++++++++++++++----- stem/response/onion_client_auth.py | 58 +++++++++++++++++++++++++++ test/integ/control/controller.py | 48 ++++++++++++++++++++++ 5 files changed, 230 insertions(+), 11 deletions(-)
diff --git a/stem/control.py b/stem/control.py index 0b5721e8..5bdb4a5a 100644 --- a/stem/control.py +++ b/stem/control.py @@ -112,6 +112,10 @@ If you're fine with allowing your script to raise exceptions then this can be mo |- create_ephemeral_hidden_service - create a new ephemeral hidden service |- remove_ephemeral_hidden_service - removes an ephemeral hidden service | + |- add_onion_client_auth - add Client Authentication for a v3 onion service + |- remove_onion_client_auth - remove Client Authentication for a v3 onion service + |- view_onion_client_auth - view Client Authentication for a v3 onion service + | |- add_event_listener - attaches an event listener to be notified of tor events |- remove_event_listener - removes a listener so it isn't notified of further events | @@ -257,6 +261,7 @@ import stem.exit_policy import stem.response import stem.response.add_onion import stem.response.events +import stem.response.onion_client_auth import stem.response.protocolinfo import stem.socket import stem.util @@ -2900,6 +2905,12 @@ class Controller(BaseController): response. For instance, only bob can access using the given newly generated credentials...
+ Note that **basic_auth** only works for legacy (v2) onion services. + There is not yet any Control Port support for adding Client Auth to the + server side of a v3 onion service. + + To add Client Authentication on the client side of a v3 onion, you can use + :func`~stem.control.Controller.add_onion_client_auth`. ::
>>> response = controller.create_ephemeral_hidden_service(80, basic_auth = {'bob': None}) @@ -3074,6 +3085,77 @@ class Controller(BaseController): else: raise stem.ProtocolError('DEL_ONION returned unexpected response code: %s' % response.code)
+ async def add_onion_client_auth(self, service_id: str, private_key_blob: str, key_type: str = 'x25519', client_name: Optional[str] = None, permanent: Optional[bool] = False) -> stem.response.onion_client_auth.OnionClientAuthAddResponse: + """ + Adds Client Authentication for a v3 onion service. + + :param service_id: hidden service address without the '.onion' suffix + :param key_type: the type of private key in use. x25519 is the only one supported right now + :param private_key_blob: base64 encoding of x25519 private key + :param client_name: optional nickname for this client + :param permanent: optionally flag that this client's credentials should be stored in the filesystem. + If this is not set, the client's credentials are epheremal and stored in memory. + + :returns: **True* if the client authentication was added or replaced, **False** if it + was rejected by the Tor controller + + :raises: :class:`stem.ControllerError` if the call fails + """ + + request = 'ONION_CLIENT_AUTH_ADD %s %s:%s' % (service_id, key_type, private_key_blob) + + if client_name: + request += ' ClientName=%s' % client_name + + flags = [] + + if permanent: + flags.append('Permanent') + + if flags: + request += ' Flags=%s' % ','.join(flags) + + response = stem.response._convert_to_onion_client_auth_add(stem.response._convert_to_onion_client_auth_add(await self.msg(request))) + + return response + + async def remove_onion_client_auth(self, service_id: str) -> stem.response.onion_client_auth.OnionClientAuthRemoveResponse: + """ + Removes Client Authentication for a v3 onion service. + + :param service_id: hidden service address without the '.onion' suffix + + :returns: **True* if the client authentication was removed, (or if no such + service ID existed), **False** if it was rejected by the Tor controller + + :raises: :class:`stem.ControllerError` if the call fails + """ + + request = 'ONION_CLIENT_AUTH_REMOVE %s' % service_id + + response = stem.response._convert_to_onion_client_auth_remove(stem.response._convert_to_onion_client_auth_remove(await self.msg(request))) + + return response + + async def view_onion_client_auth(self, service_id: str) -> stem.response.onion_client_auth.OnionClientAuthViewResponse: + """ + View Client Authentication for a v3 onion service. + + :param service_id: hidden service address without the '.onion' suffix + + :returns: :class:`~stem.response.onion_client_auth.OnionClientAuthViewResponse` with the + client_auth_credential if there were credentials to view, **True** if the service ID + was valid but no credentials existed, **False** if the service ID was invalid + + :raises: :class:`stem.ControllerError` if the call fails + """ + + request = 'ONION_CLIENT_AUTH_VIEW %s' % service_id + + response = stem.response._convert_to_onion_client_auth_view(stem.response._convert_to_onion_client_auth_view(await self.msg(request))) + + return response + async def add_event_listener(self, listener: Callable[[stem.response.events.Event], Union[None, Awaitable[None]]], *events: 'stem.control.EventType') -> None: """ Directs further tor controller events to a given function. The function is diff --git a/stem/interpreter/settings.cfg b/stem/interpreter/settings.cfg index af96d599..a373e617 100644 --- a/stem/interpreter/settings.cfg +++ b/stem/interpreter/settings.cfg @@ -87,6 +87,9 @@ help.general | CLOSESTREAM - closes the given stream | ADD_ONION - create a new hidden service | DEL_ONION - delete a hidden service that was created with ADD_ONION +| ONION_CLIENT_AUTH_ADD - add Client Authentication for a v3 onion service +| ONION_CLIENT_AUTH_REMOVE - remove Client Authentication for a v3 onion service +| ONION_CLIENT_AUTH_VIEW - view Client Authentication for a v3 onion service | HSFETCH - retrieve a hidden service descriptor, providing it in a HS_DESC_CONTENT event | HSPOST - uploads a hidden service descriptor | RESOLVE - issues an asynchronous dns or rdns request over tor @@ -122,6 +125,9 @@ help.usage REDIRECTSTREAM => REDIRECTSTREAM StreamID Address [Port] help.usage CLOSESTREAM => CLOSESTREAM StreamID Reason [Flag] help.usage ADD_ONION => KeyType:KeyBlob [Flags=Flag] (Port=Port [,Target])... help.usage DEL_ONION => ServiceID +help.usage ONION_CLIENT_AUTH_ADD => ServiceID KeyType PrivateKeyBlob [ClientName] [Permanent] +help.usage ONION_CLIENT_AUTH_REMOVE => ServiceID +help.usage ONION_CLIENT_AUTH_VIEW => ServiceID help.usage HSFETCH => HSFETCH (HSAddress/v2-DescId) [SERVER=Server]... help.usage HSPOST => [SERVER=Server] DESCRIPTOR help.usage RESOLVE => RESOLVE [mode=reverse] address @@ -264,6 +270,9 @@ help.description.add_onion help.description.del_onion |Delete a hidden service that was created with ADD_ONION.
+help.description.onion_client_auth +|Add, remove or view Client Authentication for a v3 onion service. + help.description.hsfetch |Retrieves the descriptor for a hidden service. This is an asynchronous |request, with the descriptor provided by a HS_DESC_CONTENT event. @@ -326,6 +335,9 @@ autocomplete ADD_ONION NEW:RSA1024 autocomplete ADD_ONION NEW:ED25519-V3 autocomplete ADD_ONION RSA1024: autocomplete ADD_ONION ED25519-V3: +autocomplete ONION_CLIENT_AUTH_ADD +autocomplete ONION_CLIENT_AUTH_REMOVE +autocomplete ONION_CLIENT_AUTH_VIEW autocomplete DEL_ONION autocomplete HSFETCH autocomplete HSPOST diff --git a/stem/response/__init__.py b/stem/response/__init__.py index 2e251144..e77312c8 100644 --- a/stem/response/__init__.py +++ b/stem/response/__init__.py @@ -45,6 +45,7 @@ __all__ = [ 'events', 'getinfo', 'getconf', + 'onion_client_auth', 'protocolinfo', 'authchallenge', 'convert', @@ -66,14 +67,17 @@ def convert(response_type: str, message: 'stem.response.ControlMessage', **kwarg =================== ===== response_type Class =================== ===== - **ADD_ONION** :class:`stem.response.add_onion.AddOnionResponse` - **AUTHCHALLENGE** :class:`stem.response.authchallenge.AuthChallengeResponse` - **EVENT** :class:`stem.response.events.Event` subclass - **GETCONF** :class:`stem.response.getconf.GetConfResponse` - **GETINFO** :class:`stem.response.getinfo.GetInfoResponse` - **MAPADDRESS** :class:`stem.response.mapaddress.MapAddressResponse` - **PROTOCOLINFO** :class:`stem.response.protocolinfo.ProtocolInfoResponse` - **SINGLELINE** :class:`stem.response.SingleLineResponse` + **ADD_ONION** :class:`stem.response.add_onion.AddOnionResponse` + **AUTHCHALLENGE** :class:`stem.response.authchallenge.AuthChallengeResponse` + **EVENT** :class:`stem.response.events.Event` subclass + **GETCONF** :class:`stem.response.getconf.GetConfResponse` + **GETINFO** :class:`stem.response.getinfo.GetInfoResponse` + **MAPADDRESS** :class:`stem.response.mapaddress.MapAddressResponse` + **ONION_CLIENT_AUTH_ADD** :class:`stem.response.onion_client_auth.OnionClientAuthAddResponse` + **ONION_CLIENT_AUTH_REMOVE** :class:`stem.response.onion_client_auth.OnionClientAuthRemoveResponse` + **ONION_CLIENT_AUTH_VIEW** :class:`stem.response.onion_client_auth.OnionClientAuthViewResponse` + **PROTOCOLINFO** :class:`stem.response.protocolinfo.ProtocolInfoResponse` + **SINGLELINE** :class:`stem.response.SingleLineResponse` =================== =====
:param response_type: type of tor response to convert to @@ -101,6 +105,7 @@ def convert(response_type: str, message: 'stem.response.ControlMessage', **kwarg import stem.response.getinfo import stem.response.getconf import stem.response.mapaddress + import stem.response.onion_client_auth import stem.response.protocolinfo
if not isinstance(message, ControlMessage): @@ -113,6 +118,9 @@ def convert(response_type: str, message: 'stem.response.ControlMessage', **kwarg 'GETCONF': stem.response.getconf.GetConfResponse, 'GETINFO': stem.response.getinfo.GetInfoResponse, 'MAPADDRESS': stem.response.mapaddress.MapAddressResponse, + 'ONION_CLIENT_AUTH_ADD': stem.response.onion_client_auth.OnionClientAuthAddResponse, + 'ONION_CLIENT_AUTH_REMOVE': stem.response.onion_client_auth.OnionClientAuthRemoveResponse, + 'ONION_CLIENT_AUTH_VIEW': stem.response.onion_client_auth.OnionClientAuthViewResponse, 'PROTOCOLINFO': stem.response.protocolinfo.ProtocolInfoResponse, 'SINGLELINE': SingleLineResponse, } @@ -153,6 +161,17 @@ def _convert_to_add_onion(message: 'stem.response.ControlMessage', **kwargs: Any stem.response.convert('ADD_ONION', message) return message # type: ignore
+def _convert_to_onion_client_auth_add(message: 'stem.response.ControlMessage', **kwargs: Any) -> 'stem.response.onion_client_auth.OnionClientAuthAddResponse': + stem.response.convert('ONION_CLIENT_AUTH_ADD', message) + return message # type: ignore + +def _convert_to_onion_client_auth_remove(message: 'stem.response.ControlMessage', **kwargs: Any) -> 'stem.response.onion_client_auth.OnionClientAuthRemoveResponse': + stem.response.convert('ONION_CLIENT_AUTH_REMOVE', message) + return message # type: ignore + +def _convert_to_onion_client_auth_view(message: 'stem.response.ControlMessage', **kwargs: Any) -> 'stem.response.onion_client_auth.OnionClientAuthViewResponse': + stem.response.convert('ONION_CLIENT_AUTH_VIEW', message) + return message # type: ignore
def _convert_to_mapaddress(message: 'stem.response.ControlMessage', **kwargs: Any) -> 'stem.response.mapaddress.MapAddressResponse': stem.response.convert('MAPADDRESS', message) @@ -226,13 +245,13 @@ class ControlMessage(object):
def is_ok(self) -> bool: """ - Checks if any of our lines have a 250 response. + Checks if any of our lines have a 250, 251 or 252 response.
- :returns: **True** if any lines have a 250 response code, **False** otherwise + :returns: **True** if any lines have a 250, 251 or 252 response code, **False** otherwise """
for code, _, _ in self._parsed_content: - if code == '250': + if code in ['250', '251', '252']: return True
return False diff --git a/stem/response/onion_client_auth.py b/stem/response/onion_client_auth.py new file mode 100644 index 00000000..80800bf3 --- /dev/null +++ b/stem/response/onion_client_auth.py @@ -0,0 +1,58 @@ +# Copyright 2015-2020, Damian Johnson and The Tor Project +# See LICENSE for licensing information + +import stem.response +from stem.util import log + +class OnionClientAuthAddResponse(stem.response.ControlMessage): + """ + ONION_CLIENT_AUTH_ADD response. + """ + + def _parse_message(self) -> None: + # ONION_CLIENT_AUTH_ADD responds with: + # '250 OK', + # '251 Client for onion existed and replaced', + # '252 Registered client and decrypted desc', + # '512 Invalid v3 address [service id]', + # '553 Unable to store creds for [service id]' + + if not self.is_ok(): + raise stem.ProtocolError("ONION_CLIENT_AUTH_ADD response didn't have an OK status: %s" % self) + +class OnionClientAuthRemoveResponse(stem.response.ControlMessage): + """ + ONION_CLIENT_AUTH_REMOVE response. + """ + + def _parse_message(self) -> None: + # ONION_CLIENT_AUTH_REMOVE responds with: + # '250 OK', + # '251 No credentials for [service id]', + # '512 Invalid v3 address [service id]' + + if not self.is_ok(): + raise stem.ProtocolError("ONION_CLIENT_AUTH_REMOVE response didn't have an OK status: %s" % self) + +class OnionClientAuthViewResponse(stem.response.ControlMessage): + """ + ONION_CLIENT_AUTH_VIEW response. + """ + + def _parse_message(self) -> None: + # ONION_CLIENT_AUTH_VIEW responds with: + # '250 OK' if there was Client Auth for this service or if the service is a valid address, + # ''512 Invalid v3 address [service id]' + + self.client_auth_credential = None + + if not self.is_ok(): + raise stem.ProtocolError("ONION_CLIENT_AUTH_VIEW response didn't have an OK status: %s" % self) + else: + for line in list(self): + if line.startswith('CLIENT'): + key, value = line.split(' ', 1) + log.debug(key) + log.debug(value) + + self.client_auth_credential = value diff --git a/test/integ/control/controller.py b/test/integ/control/controller.py index 19d4ba85..47c51caf 100644 --- a/test/integ/control/controller.py +++ b/test/integ/control/controller.py @@ -1602,6 +1602,54 @@ class TestController(unittest.TestCase): finally: await controller.set_conf('OrPort', str(test.runner.ORPORT))
+ @test.require.controller + @async_test + async def test_client_auth_for_v3_onion(self): + """ + Exercises adding, viewing and removing Client Auth for a v3 ephemeral hidden service. + """ + + runner = test.runner.get_runner() + + async with await runner.get_tor_controller() as controller: + service_id = 'yvhz3ofkv7gwf5hpzqvhonpr3gbax2cc7dee3xcnt7dmtlx2gu7vyvid' + # This is an invalid key, it should throw an error + private_key = 'XXXXXXXXXFCV0c0ELDKKDpSFgVIB8Yow8Evj5iD+GoiTtK878NkQ=' + exc_msg = "ONION_CLIENT_AUTH_ADD response didn't have an OK status: Failed to decode x25519 private key" + + with self.assertRaisesWith(stem.ProtocolError, exc_msg): + await controller.add_onion_client_auth(service_id, private_key) + + # This is a valid key + private_key = 'FCV0c0ELDKKDpSFgVIB8Yow8Evj5iD+GoiTtK878NkQ=' + response = await controller.add_onion_client_auth(service_id, private_key) + + # View the credential + response = await controller.view_onion_client_auth(service_id) + self.assertEqual(response.client_auth_credential, '%s x25519:%s' % (service_id, private_key)) + + # Remove the credential + await controller.remove_onion_client_auth(service_id) + response = await controller.view_onion_client_auth(service_id) + self.assertTrue(response.client_auth_credential is None) + + # Test that an invalid service ID throws the appropriate error for adding, removing or viewing client auth + service_id = 'xxxxxxxxyvhz3ofkv7gwf5hpzqvhonpr3gbax2cc7dee3xcnt7dmtlx2gu7vyvid' + exc_msg = "ONION_CLIENT_AUTH_ADD response didn't have an OK status: Invalid v3 address "%s"" % service_id + + with self.assertRaisesWith(stem.ProtocolError, exc_msg): + await controller.add_onion_client_auth(service_id, private_key) + + exc_msg = "ONION_CLIENT_AUTH_REMOVE response didn't have an OK status: Invalid v3 address "%s"" % service_id + + with self.assertRaisesWith(stem.ProtocolError, exc_msg): + await controller.remove_onion_client_auth(service_id) + + exc_msg = "ONION_CLIENT_AUTH_VIEW response didn't have an OK status: Invalid v3 address "%s"" % service_id + + with self.assertRaisesWith(stem.ProtocolError, exc_msg): + await controller.view_onion_client_auth(service_id) + async def _get_router_status_entry(self, controller): """ Provides a router status entry for a relay with a nickname other than
tor-commits@lists.torproject.org