From 785fd273e31078fa279a68aa8ef22424ae8d4549 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 11 Mar 2019 05:34:58 -0700 Subject: [PATCH] If registration supports encryption then return encrypted payloads (#21853) --- .../components/mobile_app/helpers.py | 45 ++++++++++++++++--- .../components/mobile_app/http_api.py | 4 +- .../components/mobile_app/webhook.py | 16 ++++--- tests/components/mobile_app/__init__.py | 6 +++ tests/components/mobile_app/const.py | 12 +++++ tests/components/mobile_app/test_http_api.py | 8 +++- tests/components/mobile_app/test_webhook.py | 24 ++++++---- 7 files changed, 91 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 82e6c1b6afa..28d8a797a32 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -9,15 +9,15 @@ from homeassistant.core import Context from homeassistant.helpers.typing import HomeAssistantType from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, - ATTR_APP_VERSION, DATA_DELETED_IDS, ATTR_DEVICE_NAME, - ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, - DATA_REGISTRATIONS, ATTR_SUPPORTS_ENCRYPTION, - CONF_USER_ID, DOMAIN) + ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, + CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS, + DATA_REGISTRATIONS, DOMAIN) _LOGGER = logging.getLogger(__name__) -def get_cipher() -> Tuple[int, Callable]: +def setup_decrypt() -> Tuple[int, Callable]: """Return decryption function and length of key. Async friendly. @@ -31,10 +31,24 @@ def get_cipher() -> Tuple[int, Callable]: return (SecretBox.KEY_SIZE, decrypt) +def setup_encrypt() -> Tuple[int, Callable]: + """Return encryption function and length of key. + + Async friendly. + """ + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder + + def encrypt(ciphertext, key): + """Encrypt ciphertext using key.""" + return SecretBox(key).encrypt(ciphertext, encoder=Base64Encoder) + return (SecretBox.KEY_SIZE, encrypt) + + def _decrypt_payload(key: str, ciphertext: str) -> Dict[str, str]: """Decrypt encrypted payload.""" try: - keylen, decrypt = get_cipher() + keylen, decrypt = setup_decrypt() except OSError: _LOGGER.warning( "Ignoring encrypted payload because libsodium not installed") @@ -101,3 +115,22 @@ def savable_state(hass: HomeAssistantType) -> Dict: DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], DATA_REGISTRATIONS: hass.data[DOMAIN][DATA_REGISTRATIONS] } + + +def webhook_response(data, *, registration: Dict, status: int = 200, + headers: Dict = None) -> Response: + """Return a encrypted response if registration supports it.""" + data = json.dumps(data) + + if CONF_SECRET in registration: + keylen, encrypt = setup_encrypt() + + key = registration[CONF_SECRET].encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8") + data = json.dumps({'encrypted': True, 'encrypted_data': enc_data}) + + return Response(text=data, status=status, content_type='application/json', + headers=headers) diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 8de1d954605..15e1385359e 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -53,9 +53,9 @@ class RegistrationsView(HomeAssistantView): data[CONF_WEBHOOK_ID] = webhook_id if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption(): - secret = generate_secret(16) + from nacl.secret import SecretBox - data[CONF_SECRET] = secret + data[CONF_SECRET] = generate_secret(SecretBox.KEY_SIZE) data[CONF_USER_ID] = request['hass_user'].id diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index a5496c4395d..a14354e4ae3 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -3,7 +3,7 @@ from functools import partial import logging from typing import Dict -from aiohttp.web import HTTPBadRequest, json_response, Response, Request +from aiohttp.web import HTTPBadRequest, Response, Request import voluptuous as vol from homeassistant.components.device_tracker import (DOMAIN as DT_DOMAIN, @@ -32,7 +32,8 @@ from .const import (ATTR_APP_COMPONENT, DATA_DELETED_IDS, WEBHOOK_TYPE_UPDATE_REGISTRATION) from .helpers import (_decrypt_payload, empty_okay_response, - registration_context, safe_registration, savable_state) + registration_context, safe_registration, savable_state, + webhook_response) _LOGGER = logging.getLogger(__name__) @@ -127,14 +128,16 @@ async def handle_webhook(store: Store, hass: HomeAssistantType, try: tpl = template.Template(data[ATTR_TEMPLATE], hass) rendered = tpl.async_render(data.get(ATTR_TEMPLATE_VARIABLES)) - return json_response({"rendered": rendered}, headers=headers) + return webhook_response({"rendered": rendered}, + registration=registration, headers=headers) # noqa: E722 pylint: disable=broad-except except (ValueError, TemplateError, Exception) as ex: _LOGGER.error("Error when rendering template during mobile_app " "webhook (device name: %s): %s", registration[ATTR_DEVICE_NAME], ex) - return json_response(({"error": str(ex)}), status=HTTP_BAD_REQUEST, - headers=headers) + return webhook_response(({"error": str(ex)}), + status=HTTP_BAD_REQUEST, + registration=registration, headers=headers) if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: try: @@ -159,4 +162,5 @@ async def handle_webhook(store: Store, hass: HomeAssistantType, _LOGGER.error("Error updating mobile_app registration: %s", ex) return empty_okay_response() - return json_response(safe_registration(new_registration)) + return webhook_response(safe_registration(new_registration), + registration=registration, headers=headers) diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py index 02107eafb81..1f91eb7e442 100644 --- a/tests/components/mobile_app/__init__.py +++ b/tests/components/mobile_app/__init__.py @@ -26,6 +26,12 @@ def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): CONF_WEBHOOK_ID: 'mobile_app_test', 'device_name': 'Test Device', CONF_USER_ID: hass_admin_user.id, + }, + 'mobile_app_test_cleartext': { + 'supports_encryption': False, + CONF_WEBHOOK_ID: 'mobile_app_test_cleartext', + 'device_name': 'Test Device (Cleartext)', + CONF_USER_ID: hass_admin_user.id, } }, DATA_DELETED_IDS: [], diff --git a/tests/components/mobile_app/const.py b/tests/components/mobile_app/const.py index 423af7929a4..63b37932104 100644 --- a/tests/components/mobile_app/const.py +++ b/tests/components/mobile_app/const.py @@ -32,6 +32,18 @@ REGISTER = { 'supports_encryption': True } +REGISTER_CLEARTEXT = { + 'app_data': {'foo': 'bar'}, + 'app_id': 'io.homeassistant.mobile_app_test', + 'app_name': 'Mobile App Tests', + 'app_version': '1.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_version': '1.0', + 'supports_encryption': False +} + RENDER_TEMPLATE = { 'type': 'render_template', 'data': { diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index 96b1a9d8cf4..3ff93bdfa75 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -56,4 +56,10 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811 assert resp.status == 200 webhook_json = await resp.json() - assert webhook_json == {'rendered': 'Hello world'} + assert 'encrypted_data' in webhook_json + + decrypted_data = SecretBox(key).decrypt(webhook_json['encrypted_data'], + encoder=Base64Encoder) + decrypted_data = decrypted_data.decode("utf-8") + + assert json.loads(decrypted_data) == {'rendered': 'Hello world'} diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index f2e838fb3cb..a935110754c 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -10,14 +10,14 @@ from tests.common import async_mock_service from . import authed_api_client, webhook_client # noqa: F401 -from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER, RENDER_TEMPLATE, - UPDATE) +from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, + RENDER_TEMPLATE, UPDATE) async def test_webhook_handle_render_template(webhook_client): # noqa: F811 """Test that we render templates properly.""" resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/mobile_app_test_cleartext', json=RENDER_TEMPLATE ) @@ -32,7 +32,7 @@ async def test_webhook_handle_call_services(hass, webhook_client): # noqa: E501 calls = async_mock_service(hass, 'test', 'mobile_app') resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/mobile_app_test_cleartext', json=CALL_SERVICE ) @@ -53,7 +53,7 @@ async def test_webhook_handle_fire_event(hass, webhook_client): # noqa: F811 hass.bus.async_listen('test_event', store_event) resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/mobile_app_test_cleartext', json=FIRE_EVENT ) @@ -69,7 +69,7 @@ async def test_webhook_update_registration(webhook_client, hass_client): # noqa """Test that a we can update an existing registration via webhook.""" authed_api_client = await hass_client() # noqa: F811 register_resp = await authed_api_client.post( - '/api/mobile_app/registrations', json=REGISTER + '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT ) assert register_resp.status == 201 @@ -96,7 +96,7 @@ async def test_webhook_update_registration(webhook_client, hass_client): # noqa async def test_webhook_returns_error_incorrect_json(webhook_client, caplog): # noqa: E501 F811 """Test that an error is returned when JSON is invalid.""" resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/mobile_app_test_cleartext', data='not json' ) @@ -141,5 +141,11 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811 assert resp.status == 200 - json = await resp.json() - assert json == {'rendered': 'Hello world'} + webhook_json = await resp.json() + assert 'encrypted_data' in webhook_json + + decrypted_data = SecretBox(key).decrypt(webhook_json['encrypted_data'], + encoder=Base64Encoder) + decrypted_data = decrypted_data.decode("utf-8") + + assert json.loads(decrypted_data) == {'rendered': 'Hello world'}