mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +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 homeassistant.helpers.typing import HomeAssistantType
|
||||||
|
|
||||||
from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME,
|
from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME,
|
||||||
ATTR_APP_VERSION, DATA_DELETED_IDS, ATTR_DEVICE_NAME,
|
ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER,
|
||||||
ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION,
|
ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION,
|
||||||
DATA_REGISTRATIONS, ATTR_SUPPORTS_ENCRYPTION,
|
CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS,
|
||||||
CONF_USER_ID, DOMAIN)
|
DATA_REGISTRATIONS, DOMAIN)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_cipher() -> Tuple[int, Callable]:
|
def setup_decrypt() -> Tuple[int, Callable]:
|
||||||
"""Return decryption function and length of key.
|
"""Return decryption function and length of key.
|
||||||
|
|
||||||
Async friendly.
|
Async friendly.
|
||||||
@ -31,10 +31,24 @@ def get_cipher() -> Tuple[int, Callable]:
|
|||||||
return (SecretBox.KEY_SIZE, decrypt)
|
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]:
|
def _decrypt_payload(key: str, ciphertext: str) -> Dict[str, str]:
|
||||||
"""Decrypt encrypted payload."""
|
"""Decrypt encrypted payload."""
|
||||||
try:
|
try:
|
||||||
keylen, decrypt = get_cipher()
|
keylen, decrypt = setup_decrypt()
|
||||||
except OSError:
|
except OSError:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Ignoring encrypted payload because libsodium not installed")
|
"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_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS],
|
||||||
DATA_REGISTRATIONS: hass.data[DOMAIN][DATA_REGISTRATIONS]
|
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
|
data[CONF_WEBHOOK_ID] = webhook_id
|
||||||
|
|
||||||
if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption():
|
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
|
data[CONF_USER_ID] = request['hass_user'].id
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ from functools import partial
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from aiohttp.web import HTTPBadRequest, json_response, Response, Request
|
from aiohttp.web import HTTPBadRequest, Response, Request
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.device_tracker import (DOMAIN as DT_DOMAIN,
|
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)
|
WEBHOOK_TYPE_UPDATE_REGISTRATION)
|
||||||
|
|
||||||
from .helpers import (_decrypt_payload, empty_okay_response,
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -127,14 +128,16 @@ async def handle_webhook(store: Store, hass: HomeAssistantType,
|
|||||||
try:
|
try:
|
||||||
tpl = template.Template(data[ATTR_TEMPLATE], hass)
|
tpl = template.Template(data[ATTR_TEMPLATE], hass)
|
||||||
rendered = tpl.async_render(data.get(ATTR_TEMPLATE_VARIABLES))
|
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
|
# noqa: E722 pylint: disable=broad-except
|
||||||
except (ValueError, TemplateError, Exception) as ex:
|
except (ValueError, TemplateError, Exception) as ex:
|
||||||
_LOGGER.error("Error when rendering template during mobile_app "
|
_LOGGER.error("Error when rendering template during mobile_app "
|
||||||
"webhook (device name: %s): %s",
|
"webhook (device name: %s): %s",
|
||||||
registration[ATTR_DEVICE_NAME], ex)
|
registration[ATTR_DEVICE_NAME], ex)
|
||||||
return json_response(({"error": str(ex)}), status=HTTP_BAD_REQUEST,
|
return webhook_response(({"error": str(ex)}),
|
||||||
headers=headers)
|
status=HTTP_BAD_REQUEST,
|
||||||
|
registration=registration, headers=headers)
|
||||||
|
|
||||||
if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION:
|
if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION:
|
||||||
try:
|
try:
|
||||||
@ -159,4 +162,5 @@ async def handle_webhook(store: Store, hass: HomeAssistantType,
|
|||||||
_LOGGER.error("Error updating mobile_app registration: %s", ex)
|
_LOGGER.error("Error updating mobile_app registration: %s", ex)
|
||||||
return empty_okay_response()
|
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',
|
CONF_WEBHOOK_ID: 'mobile_app_test',
|
||||||
'device_name': 'Test Device',
|
'device_name': 'Test Device',
|
||||||
CONF_USER_ID: hass_admin_user.id,
|
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: [],
|
DATA_DELETED_IDS: [],
|
||||||
|
@ -32,6 +32,18 @@ REGISTER = {
|
|||||||
'supports_encryption': True
|
'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 = {
|
RENDER_TEMPLATE = {
|
||||||
'type': 'render_template',
|
'type': 'render_template',
|
||||||
'data': {
|
'data': {
|
||||||
|
@ -56,4 +56,10 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811
|
|||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
|
|
||||||
webhook_json = await resp.json()
|
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 . import authed_api_client, webhook_client # noqa: F401
|
||||||
|
|
||||||
from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER, RENDER_TEMPLATE,
|
from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT,
|
||||||
UPDATE)
|
RENDER_TEMPLATE, UPDATE)
|
||||||
|
|
||||||
|
|
||||||
async def test_webhook_handle_render_template(webhook_client): # noqa: F811
|
async def test_webhook_handle_render_template(webhook_client): # noqa: F811
|
||||||
"""Test that we render templates properly."""
|
"""Test that we render templates properly."""
|
||||||
resp = await webhook_client.post(
|
resp = await webhook_client.post(
|
||||||
'/api/webhook/mobile_app_test',
|
'/api/webhook/mobile_app_test_cleartext',
|
||||||
json=RENDER_TEMPLATE
|
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')
|
calls = async_mock_service(hass, 'test', 'mobile_app')
|
||||||
|
|
||||||
resp = await webhook_client.post(
|
resp = await webhook_client.post(
|
||||||
'/api/webhook/mobile_app_test',
|
'/api/webhook/mobile_app_test_cleartext',
|
||||||
json=CALL_SERVICE
|
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)
|
hass.bus.async_listen('test_event', store_event)
|
||||||
|
|
||||||
resp = await webhook_client.post(
|
resp = await webhook_client.post(
|
||||||
'/api/webhook/mobile_app_test',
|
'/api/webhook/mobile_app_test_cleartext',
|
||||||
json=FIRE_EVENT
|
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."""
|
"""Test that a we can update an existing registration via webhook."""
|
||||||
authed_api_client = await hass_client() # noqa: F811
|
authed_api_client = await hass_client() # noqa: F811
|
||||||
register_resp = await authed_api_client.post(
|
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
|
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
|
async def test_webhook_returns_error_incorrect_json(webhook_client, caplog): # noqa: E501 F811
|
||||||
"""Test that an error is returned when JSON is invalid."""
|
"""Test that an error is returned when JSON is invalid."""
|
||||||
resp = await webhook_client.post(
|
resp = await webhook_client.post(
|
||||||
'/api/webhook/mobile_app_test',
|
'/api/webhook/mobile_app_test_cleartext',
|
||||||
data='not json'
|
data='not json'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -141,5 +141,11 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811
|
|||||||
|
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
|
|
||||||
json = await resp.json()
|
webhook_json = await resp.json()
|
||||||
assert 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'}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user