Skip to content

Commit 46958f1

Browse files
committed
Add HA encryption-key handoff endpoint and adoption key reuse
1 parent 3276b0a commit 46958f1

22 files changed

Lines changed: 1012 additions & 41 deletions

docs/API.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,5 +528,6 @@ For Home Assistant ESPHome integration backward compat only.
528528
| `GET /devices` | List devices. `configured` entries keep the flat legacy shape — `runtime_state` is flattened to top-level keys (`deployed_version` etc.), which HA's `esphome-dashboard-api` `ConfiguredDevice` reads |
529529
| `GET /ping` | Online-status map `{<config>.yaml: true\|false\|null}` (third-party widgets, e.g. homepage) |
530530
| `GET /json-config?configuration=...` | Fully-resolved config (substitutions/packages/includes/secrets) as JSON, via `esphome config`; 404 missing, 422 invalid, 503 unavailable, 403 traversal |
531+
| `POST /encryption-key` | HA-provisioned Noise API key handoff, body `{device_name, key, mac?}`. Splices the key into a configured device's YAML (`{"result": "updated"\|"unchanged"\|"not_writable", configurations, reason?}`) or stashes it for adoption (`{"result": "stored"}`); 400 malformed body or non-32-byte-base64 key. Only accepted on the trusted peer-guarded ingress site — the public site answers 403 |
531532
| `GET /compile` (WebSocket) | Compile via spawn protocol |
532533
| `GET /upload` (WebSocket) | Upload via spawn protocol |

esphome_device_builder/api/legacy.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
HA uses:
88
- GET /devices (list configured + importable devices)
99
- GET /json-config?configuration=... (parsed YAML as JSON)
10+
- POST /encryption-key (HA-provisioned API key handoff; ingress site only)
1011
- /compile (WebSocket, spawn protocol)
1112
- /upload (WebSocket, spawn protocol)
1213
@@ -47,6 +48,7 @@
4748
TERMINAL_JOB_STATUSES,
4849
Device,
4950
DeviceState,
51+
ErrorCode,
5052
EventType,
5153
FirmwareJob,
5254
JobType,
@@ -337,6 +339,52 @@ async def _json_config_response(db: DeviceBuilder, configuration: str) -> web.Re
337339
return json_response(config)
338340

339341

342+
def _parse_encryption_key_payload(raw: bytes) -> tuple[str, str, str] | str:
343+
"""Parse the POST /encryption-key body; ``(device_name, key, mac)`` or an error message."""
344+
try:
345+
body = loads(raw)
346+
except JSONDecodeError:
347+
return "invalid JSON body"
348+
if not isinstance(body, dict):
349+
return "invalid JSON body"
350+
device_name = body.get("device_name")
351+
key = body.get("key")
352+
mac = body.get("mac", "")
353+
if not isinstance(device_name, str) or not device_name:
354+
return "device_name is required"
355+
if not isinstance(key, str) or not key:
356+
return "key is required"
357+
if not isinstance(mac, str):
358+
return "mac must be a string"
359+
return device_name, key, mac
360+
361+
362+
async def _encryption_key_response(request: web.Request) -> web.Response:
363+
"""
364+
Land an HA-provisioned key via the devices controller.
365+
366+
Only accepted on the trusted, peer-guarded ingress site: the key is
367+
secret material and the supervisor-authenticated ingress path is the
368+
one channel where the caller is known to be Home Assistant.
369+
"""
370+
if not (request.app.get("trusted_site") and request.app.get("peer_guarded")):
371+
return json_response(
372+
{"error": "encryption-key is only accepted over the Home Assistant ingress"},
373+
status=403,
374+
)
375+
parsed = _parse_encryption_key_payload(await request.read())
376+
if isinstance(parsed, str):
377+
return json_response({"error": parsed}, status=400)
378+
device_name, key, mac = parsed
379+
db = request.app["device_builder"]
380+
try:
381+
result = await db.devices.set_encryption_key(name=device_name, key=key, mac=mac)
382+
except CommandError as err:
383+
status = 400 if err.code is ErrorCode.INVALID_ARGS else 500
384+
return json_response({"error": err.message}, status=status)
385+
return json_response(result)
386+
387+
340388
def create_legacy_routes() -> web.RouteTableDef:
341389
"""Create backward-compatible REST + WS routes for HA."""
342390
routes = web.RouteTableDef()
@@ -396,6 +444,11 @@ async def legacy_json_config(request: web.Request) -> web.Response:
396444
db = request.app["device_builder"]
397445
return await _json_config_response(db, request.query.get("configuration", ""))
398446

447+
@routes.post("/encryption-key")
448+
async def legacy_encryption_key(request: web.Request) -> web.Response:
449+
"""HA-provisioned Noise API key — splice into a configured YAML or stash for adoption."""
450+
return await _encryption_key_response(request)
451+
399452
@routes.get("/compile")
400453
async def legacy_compile(request: web.Request) -> web.WebSocketResponse:
401454
return await _handle_legacy_ws_command(request, JobType.COMPILE)

esphome_device_builder/controllers/_device_state_monitor/api_reviver.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from typing import TYPE_CHECKING, Any
4646

4747
from ...helpers.cooldown import CooldownLedger
48+
from ...helpers.mac_addresses import normalize_mac
4849
from ...models import Device, DeviceState
4950
from . import shared
5051
from ._api_probe import (
@@ -53,7 +54,6 @@
5354
api_worker_available,
5455
apply_worker_info,
5556
)
56-
from .helpers import _normalize_mac
5757

5858
if TYPE_CHECKING:
5959
from .controller import DeviceStateMonitor
@@ -234,8 +234,8 @@ async def _verify_and_revive(self, device: Device, ip: str, rtt: float) -> None:
234234
self._record_dial_failure(key)
235235
monitor.invalidate_persisted_ip(device.name, ip)
236236
return
237-
mac = _normalize_mac(info.get("mac_address", ""))
238-
persisted_mac = _normalize_mac(device.mac_address)
237+
mac = normalize_mac(info.get("mac_address", ""))
238+
persisted_mac = normalize_mac(device.mac_address)
239239
if mac and persisted_mac and mac != persisted_mac:
240240
_LOGGER.warning(
241241
"Device at %s reports name %s but MAC %s != persisted %s; not claiming ONLINE",

esphome_device_builder/controllers/_device_state_monitor/controller.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
from ...helpers.async_ import create_eager_task, drain_tasks, log_task_exit
3232
from ...helpers.ip import drop_unusable_addresses, is_unusable_address
33+
from ...helpers.mac_addresses import normalize_mac
3334
from ...helpers.subscriber_presence import SubscriberPresence
3435
from ...models import (
3536
RUNTIME_STATE_FIELD_NAMES,
@@ -44,7 +45,6 @@
4445
from .api_info import ApiInfoSource
4546
from .api_reviver import ApiReviverSource
4647
from .helpers import (
47-
_normalize_mac,
4848
_pick_ipv4,
4949
)
5050
from .importable import ImportableDiscovery
@@ -96,7 +96,7 @@
9696
ApiEncryptionChangeCallback = Callable[[str, str], None]
9797

9898
# mDNS ``mac`` TXT change. The value has already been normalised by
99-
# :func:`_normalize_mac` to ``XX:XX:XX:XX:XX:XX`` so the frontend
99+
# :func:`normalize_mac` to ``XX:XX:XX:XX:XX:XX`` so the frontend
100100
# renders it directly. Empty / non-hex skips the callback so older
101101
# firmware without the broadcast doesn't blank a known MAC.
102102
MacAddressChangeCallback = Callable[[str, str], None]
@@ -528,15 +528,15 @@ def apply_mac_address(self, name: str, mac: str) -> bool:
528528
"""
529529
Record a MAC-address observation from the device's mDNS TXT.
530530
531-
Normalised via :func:`_normalize_mac` so the dedupe /
531+
Normalised via :func:`normalize_mac` so the dedupe /
532532
sidecar / wire all stay canonical regardless of which case
533533
or separator style the firmware emits. Empty / non-hex
534534
inputs are dropped so a broadcast that omits the ``mac``
535535
TXT (older firmware) doesn't blank an already-known value.
536536
"""
537537
if (forward := self._on_mac_address_change) is None:
538538
return False
539-
normalized = _normalize_mac(mac)
539+
normalized = normalize_mac(mac)
540540
if not normalized:
541541
return False
542542
return self._apply_observation(name, "mac_address", normalized, forward, name, normalized)

esphome_device_builder/controllers/_device_state_monitor/helpers.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,6 @@
2121
# get ``web_port`` from the YAML (``web_server:``).
2222
_HTTP_SERVICE_TYPE = "_http._tcp.local."
2323

24-
# Strip ``-`` (Windows) and ``.`` (Cisco) too so a vendored tool or
25-
# future firmware can't slip a non-canonical form into the dedupe
26-
# path or the sidecar.
27-
_MAC_SEPARATORS = str.maketrans("", "", ":-.")
28-
2924

3025
def device_name_from_service(service_name: str) -> str:
3126
"""
@@ -38,24 +33,6 @@ def device_name_from_service(service_name: str) -> str:
3833
return service_name.split(".", maxsplit=1)[0]
3934

4035

41-
def _normalize_mac(value: str) -> str:
42-
"""
43-
Canonicalise a broadcast MAC to ``XX:XX:XX:XX:XX:XX`` form.
44-
45-
Returns ``""`` when the input doesn't shape into a 48-bit hex
46-
MAC — callers treat that the same as "TXT absent" and skip
47-
the apply path.
48-
"""
49-
stripped = value.translate(_MAC_SEPARATORS).upper()
50-
if len(stripped) != 12:
51-
return ""
52-
try:
53-
int(stripped, 16)
54-
except ValueError:
55-
return ""
56-
return ":".join(stripped[i : i + 2] for i in range(0, 12, 2))
57-
58-
5936
def _http_url_from_service_info(device_name: str, info: AsyncServiceInfo) -> str:
6037
"""
6138
Build ``http://<host>[:port]`` from a populated HTTP service info.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Name-keyed store of HA-provisioned API keys awaiting adoption."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import TYPE_CHECKING
7+
8+
from ...helpers.json import JSONDecodeError, dumps_indent, loads
9+
from ...helpers.storage import Store
10+
11+
if TYPE_CHECKING:
12+
from pathlib import Path
13+
14+
from ...helpers.storage import ShutdownRegister
15+
16+
_LOGGER = logging.getLogger(__name__)
17+
18+
_STORE_FILENAME = ".device-builder-pending-keys.json"
19+
_SAVE_DELAY = 1.0
20+
21+
22+
def _encode(data: dict[str, dict[str, str]]) -> bytes:
23+
return dumps_indent(data)
24+
25+
26+
def _decode(raw: bytes) -> dict[str, dict[str, str]]:
27+
try:
28+
obj = loads(raw)
29+
except JSONDecodeError:
30+
_LOGGER.warning("pending keys store: corrupt JSON, starting empty")
31+
return {}
32+
if not isinstance(obj, dict):
33+
return {}
34+
return {k: v for k, v in obj.items() if isinstance(k, str) and isinstance(v, dict)}
35+
36+
37+
class PendingKeysStore:
38+
"""RAM-canonical pending keys; writes go through a debounced ``Store``."""
39+
40+
def __init__(self, data_dir: Path, shutdown_register: ShutdownRegister) -> None:
41+
self._state: dict[str, dict[str, str]] = {}
42+
self._store: Store[dict[str, dict[str, str]]] = Store(
43+
data_dir / _STORE_FILENAME,
44+
encoder=_encode,
45+
decoder=_decode,
46+
shutdown_register=shutdown_register,
47+
name="pending_keys",
48+
)
49+
50+
async def async_load(self) -> None:
51+
"""Seed RAM from disk."""
52+
loaded = await self._store.async_load()
53+
if loaded is not None:
54+
self._state = loaded
55+
56+
def get(self, name: str) -> dict[str, str] | None:
57+
"""Return a copy of *name*'s pending entry, or ``None``."""
58+
entry = self._state.get(name)
59+
return dict(entry) if entry is not None else None
60+
61+
def set(self, name: str, key: str, mac: str = "") -> None:
62+
"""Store or overwrite the pending key for *name*."""
63+
entry = {"key": key}
64+
if mac:
65+
entry["mac"] = mac
66+
if self._state.get(name) == entry:
67+
return
68+
self._state[name] = entry
69+
self._store.async_delay_save(self._snapshot, delay=_SAVE_DELAY)
70+
71+
def pop(self, name: str) -> dict[str, str] | None:
72+
"""Drop and return *name*'s pending entry, or ``None``."""
73+
entry = self._state.pop(name, None)
74+
if entry is not None:
75+
self._store.async_delay_save(self._snapshot, delay=_SAVE_DELAY)
76+
return entry
77+
78+
def _snapshot(self) -> dict[str, dict[str, str]]:
79+
return dict(self._state)

esphome_device_builder/controllers/devices/controller.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
api_key,
6363
archive,
6464
backtrace,
65+
encryption_key,
6566
firmware_sync,
6667
importable,
6768
logs,
@@ -79,6 +80,7 @@
7980
validate,
8081
)
8182
from ._metadata_store import DeviceMetadataStore
83+
from ._pending_keys_store import PendingKeysStore
8284
from ._shared_sidecar import SharedSidecarClient
8385
from ._state import DevicesState
8486
from ._yaml_search_cache import YamlSearchCache
@@ -136,6 +138,12 @@ def __init__(self, device_builder: DeviceBuilder) -> None:
136138
data_dir=Path(CORE.data_dir),
137139
shutdown_register=self._shutdown_callbacks.append,
138140
)
141+
# HA-provisioned API keys for devices not yet adopted; consumed
142+
# by ``import_device`` so adoption doesn't mint a competing key.
143+
self._pending_keys = PendingKeysStore(
144+
data_dir=Path(CORE.data_dir),
145+
shutdown_register=self._shutdown_callbacks.append,
146+
)
139147
# Resolved here because ``CORE.data_dir`` stats the config dir;
140148
# the validate path reads it from the loop thread.
141149
self._packages_root = Path(CORE.data_dir) / "packages"
@@ -265,6 +273,7 @@ async def start(self) -> None:
265273
# Seed the store (and migrate on first post-upgrade boot)
266274
# before the scanner runs — resolver reads off it.
267275
await self._metadata_store.async_load()
276+
await self._pending_keys.async_load()
268277
await self.migrate_board_id_user_set()
269278
await run_in_executor(self._load_ignored_devices)
270279
await self._scanner.scan()
@@ -912,6 +921,10 @@ async def toggle_ignore(self, *, name: str, ignore: bool = True, **kwargs: Any)
912921
"""Mark a discovered device as ignored / visible in the import list."""
913922
await importable.toggle_ignore(self, name=name, ignore=ignore)
914923

924+
async def set_encryption_key(self, *, name: str, key: str, mac: str = "") -> dict[str, Any]:
925+
"""Apply an HA-provisioned API encryption key, or stash it for adoption."""
926+
return await encryption_key.set_encryption_key(self, name=name, key=key, mac=mac)
927+
915928
# ------------------------------------------------------------------
916929
# API commands — per-connection streams (validate, logs)
917930
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)