mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
128 lines
5.4 KiB
Python
128 lines
5.4 KiB
Python
"""Integration tests for provisioning the encryption key over a zero-PSK connection.
|
|
|
|
A device with `api: encryption:` but no key accepts Noise handshakes using the
|
|
well-known all-zeros PSK. The ephemeral X25519 exchange protects the key from
|
|
passive sniffing while it is provisioned; plaintext provisioning still works
|
|
but is deprecated.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
|
|
from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError
|
|
import pytest
|
|
|
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
|
|
|
# The well-known provisioning PSK: base64 of 32 zero bytes
|
|
ZERO_PSK = base64.b64encode(bytes(32)).decode()
|
|
# A real key to provision
|
|
NEW_KEY = base64.b64encode(b"n" * 32)
|
|
# Time for the device to activate a newly saved key (100ms timer plus margin)
|
|
KEY_ACTIVATION_DELAY = 0.5
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
|
"""Keep host preferences per-test so every run starts unprovisioned."""
|
|
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_zero_psk_provisioning(
|
|
yaml_config: str,
|
|
run_compiled: RunCompiledFunction,
|
|
api_client_connected: APIClientConnectedFactory,
|
|
) -> None:
|
|
"""Exercise the reject paths, then provision a key over the zero-PSK channel."""
|
|
async with run_compiled(yaml_config):
|
|
# --- Pre-provisioning reject paths (device state is unchanged) ---
|
|
|
|
# A wrong (non-zero) PSK fails against the zero provisioning PSK
|
|
with pytest.raises(InvalidEncryptionKeyAPIError):
|
|
async with api_client_connected(
|
|
noise_psk=base64.b64encode(b"w" * 32).decode(), timeout=5
|
|
) as client:
|
|
await client.device_info()
|
|
|
|
# A plaintext client and a zero-PSK client can be connected at the
|
|
# same time while the device is unprovisioned
|
|
async with (
|
|
api_client_connected() as plaintext_client,
|
|
api_client_connected(noise_psk=ZERO_PSK) as noise_client,
|
|
):
|
|
plaintext_info = await plaintext_client.device_info()
|
|
noise_info = await noise_client.device_info()
|
|
# Both transports advertise provisioning support so old and new
|
|
# clients can decide how to provision
|
|
assert plaintext_info.api_encryption_provisionable is True
|
|
assert noise_info.api_encryption_provisionable is True
|
|
|
|
# The all-zeros key is reserved as the provisioning PSK and is
|
|
# rejected on both transports
|
|
zero_key = base64.b64encode(bytes(32))
|
|
assert await noise_client.noise_encryption_set_key(zero_key) is False
|
|
assert await plaintext_client.noise_encryption_set_key(zero_key) is False
|
|
|
|
# --- Provision over the zero-PSK channel ---
|
|
|
|
# The unprovisioned device accepts the all-zeros PSK; the handshake's
|
|
# ephemeral-ephemeral DH encrypts everything that follows
|
|
async with api_client_connected(noise_psk=ZERO_PSK) as client:
|
|
device_info = await client.device_info()
|
|
assert device_info.name == "zero-psk-provision-test"
|
|
assert device_info.api_encryption_supported is True
|
|
assert device_info.api_encryption_provisionable is True
|
|
|
|
assert await client.noise_encryption_set_key(NEW_KEY) is True
|
|
|
|
# The device activates the new key shortly after responding
|
|
await asyncio.sleep(KEY_ACTIVATION_DELAY)
|
|
|
|
# The new key now works, and the device is no longer provisionable
|
|
async with api_client_connected(noise_psk=NEW_KEY.decode()) as client:
|
|
device_info = await client.device_info()
|
|
assert device_info.name == "zero-psk-provision-test"
|
|
assert device_info.api_encryption_provisionable is False
|
|
|
|
# The zero PSK no longer works
|
|
with pytest.raises(InvalidEncryptionKeyAPIError):
|
|
async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client:
|
|
await client.device_info()
|
|
|
|
# Plaintext no longer works
|
|
with pytest.raises(RequiresEncryptionAPIError):
|
|
async with api_client_connected(timeout=5) as client:
|
|
await client.device_info()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_zero_psk_provisioning_plaintext(
|
|
yaml_config: str,
|
|
run_compiled: RunCompiledFunction,
|
|
api_client_connected: APIClientConnectedFactory,
|
|
) -> None:
|
|
"""The legacy plaintext provisioning path still works and warns."""
|
|
log_lines: list[str] = []
|
|
async with run_compiled(yaml_config, line_callback=log_lines.append):
|
|
async with api_client_connected() as client:
|
|
device_info = await client.device_info()
|
|
assert device_info.name == "zero-psk-plaintext-test"
|
|
|
|
assert await client.noise_encryption_set_key(NEW_KEY) is True
|
|
|
|
await asyncio.sleep(KEY_ACTIVATION_DELAY)
|
|
|
|
# The deprecation warning was logged
|
|
assert any("deprecated" in line for line in log_lines)
|
|
|
|
# The new key works; the zero PSK does not
|
|
async with api_client_connected(noise_psk=NEW_KEY.decode()) as client:
|
|
assert (await client.device_info()).name == "zero-psk-plaintext-test"
|
|
|
|
with pytest.raises(InvalidEncryptionKeyAPIError):
|
|
async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client:
|
|
await client.device_info()
|