If registration supports encryption then return encrypted payloads (#21853)

This commit is contained in:
Robbie Trencheny 2019-03-11 05:34:58 -07:00 committed by Charles Garwood
parent c401f35a43
commit 785fd273e3
7 changed files with 91 additions and 24 deletions

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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: [],

View File

@ -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': {

View File

@ -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'}

View File

@ -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'}