mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 03:07:37 +00:00
If registration supports encryption then return encrypted payloads (#21853)
This commit is contained in:
parent
c401f35a43
commit
785fd273e3
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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: [],
|
||||
|
@ -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': {
|
||||
|
@ -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'}
|
||||
|
@ -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'}
|
||||
|
Loading…
x
Reference in New Issue
Block a user