mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
[api] Provision encryption keys over an encrypted zero-PSK noise connection (#17482)
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
<<: !include common-base.yaml
|
||||
packages:
|
||||
common: !include common-base.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# Encryption enabled without a key: compiles both frame helpers so the key
|
||||
# can be provisioned at runtime (zero-PSK noise or deprecated plaintext)
|
||||
api:
|
||||
encryption:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
esphome:
|
||||
name: zero-psk-provision-test
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
logger:
|
||||
@@ -0,0 +1,6 @@
|
||||
esphome:
|
||||
name: zero-psk-plaintext-test
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
logger:
|
||||
@@ -0,0 +1,127 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user