[cli] Add rotate-key to change the OTA encryption key over the air

Locates the current key on its yaml or secrets.yaml line, writes the new
one with the editor from #20602, keeps the replaced key as old_key, checks
the device with the probe from #20601, builds, uploads with the current key
and confirms with the new one; anything short of a reached device puts the
files back.
This commit is contained in:
J. Nick Koston
2026-10-13 07:28:38 -10:00
committed by GitHub
parent 3e6324445b
commit 645cb490fc
7 changed files with 2285 additions and 3 deletions
+38
View File
@@ -2232,6 +2232,12 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
return 0
def command_rotate_key(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome.cli.rotate_key import command_rotate_key as run
return run(args, config)
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome.cli.rename import command_rename as run
@@ -2258,6 +2264,7 @@ POST_CONFIG_ACTIONS = {
"clean-mqtt": command_clean_mqtt,
"idedata": command_idedata,
"rename": command_rename,
"rotate-key": command_rotate_key,
"discover": command_discover,
"analyze-memory": command_analyze_memory,
"bundle": command_bundle,
@@ -2655,6 +2662,37 @@ def parse_args(argv):
)
parser_rename.add_argument("name", help="The new name for the device.", type=str)
parser_rotate_key = subparsers.add_parser(
"rotate-key",
help=(
"Change the OTA encryption key over the air: write a new key to the "
"yaml, build with it, upload with the current key, confirm."
),
parents=[mqtt_options],
)
parser_rotate_key.add_argument(
"configuration", help="Your YAML configuration file.", nargs=1
)
parser_rotate_key.add_argument(
"--device",
action="append",
help=ARGUMENT_HELP_DEVICE,
)
parser_rotate_key.add_argument(
"--prompt-new-key",
action="store_true",
help=(
"Ask for the new key instead of generating one; read without echo, "
"or as one line from stdin when that is not a terminal."
),
)
parser_rotate_key.add_argument(
"-y",
"--yes",
action="store_true",
help="Do not ask for confirmation when the api encryption key changes too.",
)
parser_analyze_memory = subparsers.add_parser(
"analyze-memory",
help="Analyze memory usage by component.",
+266
View File
@@ -0,0 +1,266 @@
"""``esphome rotate-key``."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from esphome import espota2
from esphome.components.noise import (
generate_encryption_key,
static_encryption_key,
validate_encryption_key,
)
from esphome.config_validation import Invalid
from esphome.const import CONF_API, CONF_PASSWORD, CONF_PORT
from esphome.core import CORE, EsphomeError
from esphome.log import AnsiFore, color
from esphome.ota_key_edit import locate_key_edits, old_key_edit, with_sharers
from esphome.types import ConfigType
from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
ESPHOME_COMMAND,
read_secret_line,
run_external_process,
safe_input,
safe_print,
)
from esphome.yaml_edit import RestoreError, Snapshot, apply_line_edits, restore_files
PRECHECK_TIMEOUT = 15.0
def command_rotate_key(args: argparse.Namespace, config: ConfigType) -> int | None:
"""Write a new key, build with it, upload with the current one, confirm."""
from esphome.__main__ import (
Purpose,
_esphome_ota_conf,
_resolve_network_devices,
choose_upload_log_host,
)
def fail(message: str) -> int:
safe_print(color(AnsiFore.BOLD_RED, message))
return 1
ota_conf = _esphome_ota_conf(config)
if ota_conf is None:
return fail("rotate-key needs the esphome OTA platform in the configuration")
old_key = static_encryption_key(ota_conf)
if old_key is None:
return fail(
"rotate-key needs 'encryption:' under the esphome OTA platform with a "
"key in the yaml; a key provisioned at runtime is rotated from Home "
"Assistant"
)
if CORE.is_host:
return fail("rotate-key is for devices updated over the air")
old_key = str(old_key)
remote_port = int(ota_conf[CONF_PORT])
devices = choose_upload_log_host(
default=args.device, check_default=None, purpose=Purpose.UPLOADING
)
if get_port_type(devices[0]) in (PortType.SERIAL, PortType.BOOTSEL):
return fail(
"rotate-key works over the air; on a serial connection change the "
"key in the yaml and run 'esphome run' instead"
)
network_devices = _resolve_network_devices(devices, config, args)
if args.prompt_new_key:
new_key = read_secret_line("New OTA encryption key: ")
else:
new_key = generate_encryption_key()
try:
validate_encryption_key(new_key)
except Invalid as err:
return fail(f"Invalid new key: {err}")
if new_key == old_key:
return fail("The new key is the same as the current one")
try:
edits = locate_key_edits(old_key, new_key)
# The previous key stays in the config as old_key so an install still
# reaches the device if it ends up running either key
secret = next((edit.secret for edit in edits if edit.secret), None)
edits += old_key_edit(old_key, secret)
unchecked = with_sharers(edits)
except EsphomeError as err:
return fail(str(err))
warnings = []
# With the recommended shape the ota inherits the api key, so rotating
# it changes what Home Assistant connects with
if str(static_encryption_key(config.get(CONF_API) or {})) == old_key:
warnings.append(
"The api encryption key changes too; Home Assistant will ask for "
"the new key after the install."
)
shared: dict[str, set[str]] = {}
for edit in edits:
what = f"secret '{edit.secret}'" if edit.secret else edit.path.name
for path in edit.shared_with:
shared.setdefault(
str(path.relative_to(CORE.config_dir.resolve())), set()
).add(what)
if unchecked:
warnings.append(
"Could not check every configuration for shared use: "
+ "; ".join(unchecked)
)
if shared:
warnings.append(
"Other configurations share what is rewritten: "
+ "; ".join(
f"{p} ({', '.join(sorted(w))})" for p, w in sorted(shared.items())
)
+ ". Their devices keep the previous key until their next install; "
"make sure those configurations carry it as old_key."
)
if warnings:
for warning in warnings:
safe_print(color(AnsiFore.BOLD_YELLOW, warning))
if not args.yes:
if not sys.stdin.isatty():
return fail("Confirm with --yes when there is no terminal")
if safe_input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
return 1
safe_print("Checking that the device accepts the current key...")
if not espota2.probe_ota_key(
network_devices,
remote_port,
old_key,
timeout=PRECHECK_TIMEOUT,
retry_rejected=False,
):
return fail(
"The device did not complete an encrypted handshake with the current "
"key (see the warning above for the reason). If it is reachable, it "
"has to run the key in the yaml on ESPHome 2026.9.0 or newer; install "
"the current configuration first, then rotate."
)
# Both keys are shown only when a file could not be put back
keys = f"Previous key: {old_key}\nNew key: {new_key}"
try:
originals = apply_line_edits(edits)
except RestoreError as err:
safe_print(keys)
return fail(str(err))
except EsphomeError as err:
return fail(str(err))
except KeyboardInterrupt as err:
if notes := getattr(err, "__notes__", None): # a rollback that failed too
safe_print(keys)
return fail("Interrupted; " + "; ".join(notes))
return fail("Interrupted before the upload")
# From here every exit restores or reports, so nothing sits outside the try
uploaded = False
try:
safe_print(
"Wrote the new key to "
+ ", ".join(color(AnsiFore.CYAN, str(p)) for p in originals)
)
# Global options go before the subcommand; the compile parser is strict
cli_args = ["--dashboard"] if CORE.dashboard else []
if args.toolchain:
cli_args += ["--toolchain", str(args.toolchain)]
for key, value in args.substitution or []:
cli_args += ["-s", key, value]
cli_args += ["compile", str(CORE.config_path)]
if run_external_process(*ESPHOME_COMMAND, *cli_args) != 0:
return fail("Compiling with the new key failed")
def device_reached() -> None:
# The upload may commit from here on; the edited config reaches
# the device on either key
nonlocal uploaded
uploaded = True
rc, _ = espota2.run_ota(
network_devices,
remote_port,
ota_conf.get(CONF_PASSWORD),
CORE.firmware_bin,
noise_psk=old_key,
plaintext_fallback=False,
on_connect=device_reached,
)
if rc != 0:
return fail(
"Uploading with the current key failed. The configuration keeps "
"the new key with the previous one as old_key, so the next "
"install reaches the device whichever key it runs."
if uploaded
else "The upload did not reach the device."
)
safe_print("Waiting for the device to come back with the new key...")
if not espota2.probe_ota_key(network_devices, remote_port, new_key):
return fail(
"The device did not answer with the new key. The configuration "
"keeps it, with the previous key as old_key, so the next install "
"reaches the device either way."
)
except KeyboardInterrupt:
return fail(
"Interrupted; the device's key was not confirmed"
if uploaded
else "Interrupted before the upload"
)
finally:
# Before the upload nothing changed on the device; after it the device
# most likely runs the new key, and old_key covers the other case
if uploaded:
safe_print(f"New OTA encryption key: {color(AnsiFore.CYAN, new_key)}")
else:
_restore(originals, keys)
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
return 0
def _restore(originals: dict[Path, Snapshot], keys: str) -> None:
"""Put the previous key back, and drop the images a build made with the
new key; both keys are shown when a step fails."""
files = ", ".join(str(p) for p in originals)
try:
restore_files(originals)
except (EsphomeError, KeyboardInterrupt) as err:
safe_print(
color(AnsiFore.BOLD_RED, str(err) or f"Interrupted restoring {files}")
)
safe_print(keys)
else:
safe_print(color(AnsiFore.BOLD_YELLOW, f"Restored the previous key in {files}"))
build_dir = CORE.firmware_bin.parent
failed: list[str] = []
images: list[Path] = []
try:
images = [p for s in (".bin", ".uf2") for p in build_dir.glob(f"*{s}")]
for image in images: # every one is tried, whatever the others did
try:
image.unlink()
except OSError as err:
failed.append(f"{image}: {err}")
except (OSError, KeyboardInterrupt) as err:
failed.append(f"{build_dir}: {str(err) or 'interrupted'}")
if failed:
safe_print(
color(
AnsiFore.BOLD_RED,
"Could not remove images that may hold the new key: "
+ "; ".join(failed),
)
)
safe_print(keys)
elif images:
safe_print(
color(
AnsiFore.BOLD_YELLOW,
f"Removed the images in {build_dir}, which may hold the new key; "
"compile again before the next upload.",
)
)
+352
View File
@@ -0,0 +1,352 @@
"""Rewrite the OTA encryption key in place: the `key:` line the loader read
it from, or the secrets.yaml line a `!secret` on it points to."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import os
from pathlib import Path
import re
from esphome.components.noise import static_encryption_key
from esphome.const import CONF_API, CONF_ENCRYPTION, CONF_KEY
from esphome.core import CORE, EsphomeError
from esphome.espota2 import CONF_OLD_KEY, esphome_ota_items
from esphome.types import ConfigType
from esphome.yaml_edit import (
LineEdit,
editable_file,
field_line_re,
own_documents,
read_text,
rewrite,
source_line,
source_of,
)
from esphome.yaml_util import secrets_path_for
_LOGGER = logging.getLogger(__name__)
_DOMAIN = "ota_key_edit"
@dataclass
class KeyEdit(LineEdit):
"""A line edit with the secret it holds and the other configs using it."""
secret: str | None = None
shared_with: list[Path] = field(default_factory=list)
def _key_blocks(raw: ConfigType, key: str) -> list[ConfigType]:
"""The api and esphome ota ``encryption:`` mappings whose key is ``key``."""
blocks = [raw.get(CONF_API) or {}, *esphome_ota_items(raw)]
# A bare ota block gets the api key at validation; it has no key line
return [
block[CONF_ENCRYPTION]
for block in blocks
if (node := static_encryption_key(block)) is not None
and str(node) == key
and source_of(block[CONF_ENCRYPTION], CONF_KEY) is not None
]
_INDENT_RE = re.compile(r"\s*")
_KEY_FIELD_RE = re.compile(rf"^\s*{CONF_KEY}\s*:")
_BARE_BLOCK_RE = re.compile(rf"^\s*{CONF_ENCRYPTION}\s*:\s*(#.*)?$")
_YAML_SUFFIXES = (".yaml", ".yml")
_COMMENT_RE = re.compile(r"(?:^|\s)#.*$")
_INCLUDE_DIR_RE = re.compile(r"!include_dir_\w+\s+(?P<dir>[^\s#]+)")
# An include or package file whose path holds a substitution
_SUBSTITUTED_INCLUDE_RE = re.compile(
r"(?:!include\w*\s+|\bfile:\s*)[\"']?(?P<target>[^\s#\"']*\$[^\s#\"']*)"
)
def _secret_ref(name: str = r"[^\s#\"']+") -> str:
"""`!secret name` as the whole scalar; `!secret ota key` is not `ota`."""
return rf"!secret\s+(?P<q>[\"']?)(?P<name>{name})(?P=q)(?=\s*(?:#|$))"
def _secret_field_re(name: str) -> re.Pattern[str]:
"""``name: !secret <any>``."""
return re.compile(rf"^\s*{name}\s*:\s*{_secret_ref()}")
def _secret_use_re(name: str) -> re.Pattern[str]:
"""A use of the secret ``name`` anywhere on a line."""
return re.compile(_secret_ref(re.escape(name)))
_KEY_SECRET_RE = _secret_field_re(CONF_KEY)
_OLD_KEY_SECRET_RE = _secret_field_re(CONF_OLD_KEY)
def _indent(text: str) -> str:
return _INDENT_RE.match(text).group()
def _without_comment(text: str) -> str:
"""The line up to its comment, which starts at a `#` after whitespace."""
return _COMMENT_RE.sub("", text)
def _file_use_re(file_name: str) -> re.Pattern[str]:
"""The file name at a path boundary; `wifi_api.yaml` is not `api.yaml`."""
return re.compile(rf"(?<![\w.-]){re.escape(file_name)}(?![\w.])")
def _root_indent(lines: list[str]) -> str:
"""The indent of the first content line; a root mapping may be indented."""
content = (
t for t in lines if t.strip() and not t.strip().startswith(("#", "---", "%"))
)
return next((_indent(t) for t in content), "")
def _config_texts() -> tuple[list[tuple[Path, str]], list[str]]:
"""Every yaml file under the config dir with its text, and what could
not be read (a symlinked directory is not walked); walked once per run."""
if (found := CORE.data.get(_DOMAIN)) is None:
found = CORE.data[_DOMAIN] = _walk_config_texts()
return found
def _walk_config_texts() -> tuple[list[tuple[Path, str]], list[str]]:
data_dir = CORE.data_dir.resolve()
found = []
skipped: list[str] = []
def skip(what: object) -> None:
skipped.append(str(what))
_LOGGER.debug("Skipping %s", what)
for dirpath, dirnames, filenames in os.walk(
CORE.config_dir.resolve(), onerror=skip
):
for name in list(dirnames):
path = Path(dirpath, name)
if path == data_dir:
dirnames.remove(name)
elif path.is_symlink():
dirnames.remove(name)
skip(f"{path} is a link")
for filename in filenames:
path = Path(dirpath, filename)
if path.suffix not in _YAML_SUFFIXES:
continue
try:
text = read_text(path)
except EsphomeError as err:
skip(err)
continue
# Searched, never edited: comments do not count
found.append((path, "\n".join(map(_without_comment, text.splitlines()))))
return sorted(found), skipped
def with_sharers(edits: list[KeyEdit]) -> list[str]:
"""Fill in ``shared_with``: the other configs that use each rewritten
line's secret or include, directly or through further includes. Returns
what the scan could not follow (unreadable files, directory and
substituted includes)."""
main = CORE.config_path.resolve()
own = own_documents()
# This config's own includes stay in: another device may reach a line
# through one of them
texts, skipped = _config_texts()
texts = [(path, text) for path, text in texts if path.resolve() != main]
unchecked = [*skipped]
for path, text in texts:
if match := _INCLUDE_DIR_RE.search(text):
unchecked.append(f"{path} includes the directory {match['dir']}")
if match := _SUBSTITUTED_INCLUDE_RE.search(text):
unchecked.append(
f"{path} includes {match['target']} through a substitution"
)
for edit in edits:
if edit.insert_after:
continue
ref = (
_secret_use_re(edit.secret) if edit.secret else _file_use_re(edit.path.name)
)
users = {path for path, text in texts if ref.search(text)}
frontier = users
while frontier:
frontier = {
path
for name in {p.name for p in frontier}
for path, text in texts
if path not in users and _file_use_re(name).search(text)
}
users |= frontier
edit.shared_with = sorted(p for p in users if p.resolve() not in own)
return unchecked
def _secret_file(doc: Path) -> Path:
return editable_file(secrets_path_for(doc))
def _secret_line(
secrets_path: Path, name: str, value: str | None = None
) -> tuple[int, re.Match[str]] | None:
"""The root level ``name:`` line, None when absent."""
lines = read_text(secrets_path).splitlines()
line_re = field_line_re(name, value, re.escape(_root_indent(lines)))
return next(
((i, m) for i, text in enumerate(lines) if (m := line_re.match(text))), None
)
def _secret_rewrite(
secrets_path: Path, name: str, value: str, expect: str | None = None
) -> KeyEdit | None:
"""Set the root level ``name:`` line to ``value``; None when it is not a
plain scalar holding ``expect``."""
if (hit := _secret_line(secrets_path, name, expect)) is None:
return None
i, m = hit
return KeyEdit(secrets_path, i, m.string, rewrite(m, value), secret=name)
def _secret_insert(secrets_path: Path, after: str, name: str, value: str) -> KeyEdit:
"""Add ``name: value`` under the root level ``after`` line."""
if (hit := _secret_line(secrets_path, after)) is None:
raise EsphomeError(
f"No plain '{after}:' line in {secrets_path}; edit it by hand"
)
i, m = hit
return KeyEdit(
secrets_path,
i,
m.string,
f'{_indent(m.string)}{name}: "{value}"',
insert_after=True,
)
def locate_key_edits(old_key: str, new_key: str) -> list[KeyEdit]:
"""The edits that replace the current key; refuses a substitution, flow
mapping or remote package."""
literal_re = field_line_re(CONF_KEY, old_key)
blocks = _key_blocks(CORE.raw_config or {}, old_key)
if not blocks:
raise EsphomeError("The current key was not found on a line of the yaml")
edits: list[KeyEdit] = []
key_lines: set[tuple[Path, int]] = set()
for block in blocks:
doc, line_no, text = source_line(block, CONF_KEY)
key_lines.add((doc.resolve(), line_no))
if match := _KEY_SECRET_RE.match(text):
name, secrets_path = match["name"], _secret_file(doc)
edit = _secret_rewrite(secrets_path, name, new_key, expect=old_key)
if edit is None:
raise EsphomeError(
f"No '{name}:' line with the current key in {secrets_path}"
)
elif match := literal_re.match(text):
edit = KeyEdit(doc, line_no, text, rewrite(match, new_key))
else:
raise EsphomeError(
f"{doc}:{line_no + 1} does not hold the key as a plain value or "
"a !secret; edit the key by hand"
)
# The api and ota blocks may share one !secret line
if edit not in edits:
edits.append(edit)
for edit in edits:
if edit.secret:
_refuse_other_own_uses(edit.secret, key_lines)
return edits
def _refuse_other_own_uses(name: str, allowed: set[tuple[Path, int]]) -> None:
"""The secret may only feed the ``allowed`` lines; a wifi password on it
would change too."""
ref = _secret_use_re(name)
for doc in own_documents():
for i, text in enumerate(read_text(doc).splitlines()):
if ref.search(_without_comment(text)) and (doc, i) not in allowed:
raise EsphomeError(
f"'{name}' is also used at {doc}:{i + 1}; a rotation would "
"change that value too, edit the key by hand"
)
def old_key_edit(old_key: str, secret: str | None = None) -> list[KeyEdit]:
"""Set ``old_key:`` on the esphome ota block, rewriting or adding it. A
key from secrets.yaml (``secret``) stays there as ``<secret>_old``. Of
several entries the one with its own old_key or key line gets it."""
items = [
(item, item[CONF_ENCRYPTION] or {})
for item in esphome_ota_items(CORE.raw_config or {})
if CONF_ENCRYPTION in item
]
if not items:
raise EsphomeError("The esphome OTA platform has no 'encryption:' block")
item, encryption = next(
(
pair
for name in (CONF_OLD_KEY, CONF_KEY)
for pair in items
if source_of(pair[1], name) is not None
),
items[0],
)
if (existing := encryption.get(CONF_OLD_KEY)) is not None:
doc, line_no, text = source_line(encryption, CONF_OLD_KEY)
if match := _OLD_KEY_SECRET_RE.match(text):
name, secrets_path = match["name"], _secret_file(doc)
if (edit := _secret_rewrite(secrets_path, name, old_key)) is None:
raise EsphomeError(
f"No plain '{name}:' line in {secrets_path}; edit it by hand"
)
_refuse_other_own_uses(name, {(doc.resolve(), line_no)})
return [edit]
match = field_line_re(CONF_OLD_KEY, str(existing)).match(text)
if match is None:
raise EsphomeError(
f"{doc}:{line_no + 1} does not hold '{CONF_OLD_KEY}' as a plain "
"value; edit it by hand"
)
return [KeyEdit(doc, line_no, text, rewrite(match, old_key))]
# Indent from the block's own key line, else one level under `encryption:`
if source_of(encryption, CONF_KEY) is not None:
mapping, anchor, anchor_re, step = encryption, CONF_KEY, _KEY_FIELD_RE, ""
else:
mapping, anchor, anchor_re, step = item, CONF_ENCRYPTION, _BARE_BLOCK_RE, " "
doc, line_no, text = source_line(mapping, anchor)
if not anchor_re.match(text):
raise EsphomeError(
f"{doc}:{line_no + 1} does not hold '{anchor}:' as a block line; "
"edit it by hand"
)
if secret is None:
rendered = f'{CONF_OLD_KEY}: "{old_key}"'
extra = []
else:
rendered = f"{CONF_OLD_KEY}: !secret {secret}_old"
secrets_path = _secret_file(doc) # the file that gets old_key
# An existing `<secret>_old` line is reused only when nothing uses it
if (
kept := _secret_rewrite(secrets_path, f"{secret}_old", old_key)
) is not None:
ref = _secret_use_re(f"{secret}_old")
texts, skipped = _config_texts()
if skipped: # an unread file might use the line
raise EsphomeError(
"Could not read every configuration to check the secret: "
+ skipped[0]
)
if users := [p for p, t in texts if ref.search(t)]:
raise EsphomeError(
f"'{secret}_old:' in {secrets_path} is used by "
+ ", ".join(str(p) for p in users)
+ f"; rename it or add '{CONF_OLD_KEY}' by hand"
)
extra = [kept or _secret_insert(secrets_path, secret, f"{secret}_old", old_key)]
edit = KeyEdit(
doc, line_no, text, f"{_indent(text)}{step}{rendered}", insert_after=True
)
return [edit, *extra]
+15 -3
View File
@@ -926,9 +926,7 @@ class ESPHomeLoaderMixin:
if self.name == CORE.config_path:
raise e
try:
main_config_dir = CORE.config_path.parent
main_secret_yml = main_config_dir / SECRET_YAML
secrets = self.yaml_loader(main_secret_yml)
secrets = self.yaml_loader(CORE.config_path.parent / SECRET_YAML)
except EsphomeError as er:
raise EsphomeError(f"{e}\n{er}") from er
@@ -1301,6 +1299,20 @@ def find_files(directory: Path, pattern: str) -> Iterator[Path]:
yield filename
def secrets_path_for(document: Path) -> Path:
"""The secrets.yaml a ``!secret`` in ``document`` resolves against: the
one beside the document when it loads, else the main config's, the
way construct_secret falls back."""
beside = document.parent / SECRET_YAML
if document == CORE.config_path:
return beside
try:
load_yaml(beside, clear_secrets=False, track_document_range=False)
except EsphomeError:
return CORE.config_path.parent / SECRET_YAML
return beside
def is_secret(value):
try:
return _SECRET_VALUES[str(value)]
+471
View File
@@ -0,0 +1,471 @@
"""Tests for ``esphome rotate-key``."""
from __future__ import annotations
from collections.abc import Generator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from unittest.mock import ANY, Mock, patch
import pytest
from esphome import yaml_util
from esphome.cli.rotate_key import command_rotate_key
from esphome.const import (
CONF_API,
CONF_ENCRYPTION,
CONF_ESPHOME,
CONF_KEY,
CONF_OTA,
CONF_PLATFORM,
CONF_PORT,
CONF_WEB_SERVER,
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
)
from esphome.core import CORE, EsphomeError, Toolchain
from esphome.ota_key_edit import KeyEdit
from esphome.upload_targets import PortType
from esphome.util import ESPHOME_COMMAND
from esphome.yaml_edit import RestoreError
@dataclass
class MockArgs:
device: list[str] | None = None
prompt_new_key: bool = False
yes: bool = False
substitution: list[list[str]] | None = None
toolchain: Toolchain | None = None
def _interrupt_after(writes: int) -> Any:
"""A write_keeping_mode that lets ``writes`` calls through, then Ctrl-C."""
from esphome.yaml_edit import write_keeping_mode
calls = 0
def write(*args: Any, **kwargs: Any) -> None:
nonlocal calls
calls += 1
if calls > writes:
raise KeyboardInterrupt
write_keeping_mode(*args, **kwargs)
return write
def setup_core(tmp_path: Path) -> None:
CORE.reset()
CORE.config_path = tmp_path / "test.yaml"
CORE.name = "test"
CORE.build_path = tmp_path / ".esphome" / "build" / "test"
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
OLD_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
NEW_KEY = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
API_YAML = f"""esphome:
name: test
api:
encryption:
key: "{OLD_KEY}"
ota:
- platform: esphome
encryption:
"""
OTA_CONF = {
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
CONF_ENCRYPTION: {CONF_KEY: OLD_KEY},
}
@pytest.fixture
def env(tmp_path: Path) -> Generator[dict[str, Mock]]:
"""A config with an inline api key, loaded the way read_config does so
the key node carries its source range, and every network step mocked."""
setup_core(tmp_path)
CORE.config_path.write_text(API_YAML, encoding="utf-8")
CORE.raw_config = yaml_util.load_yaml(CORE.config_path)
CORE.config = {
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: OLD_KEY}},
CONF_OTA: [OTA_CONF],
}
with (
patch("esphome.__main__.choose_upload_log_host", return_value=["dev.local"]),
patch("esphome.__main__._resolve_network_devices", return_value=["dev.local"]),
patch("esphome.cli.rotate_key.get_port_type", return_value=PortType.NETWORK),
patch("esphome.espota2.probe_ota_key", return_value=True) as probe,
patch(
"esphome.cli.rotate_key.run_external_process", return_value=0
) as compile_,
patch("esphome.espota2.run_ota") as upload,
patch("esphome.cli.rotate_key.safe_input", return_value="y") as confirm,
patch("esphome.cli.rotate_key.sys.stdin") as stdin,
patch(
"esphome.components.noise.secrets.token_bytes",
return_value=bytes(range(1, 33)),
),
):
stdin.isatty.return_value = True
# The real upload reports the connection before its result
upload.return_value = (0, "dev.local")
def upload_after_connecting(
*_args: Any, **kwargs: Any
) -> tuple[int, str | None]:
kwargs["on_connect"]()
return upload.return_value
upload.side_effect = upload_after_connecting
yield {
"probe": probe,
"compile": compile_,
"upload": upload,
"confirm": confirm,
"stdin": stdin,
}
def test_success(env: dict[str, Mock], capfd: pytest.CaptureFixture[str]) -> None:
"""Generate, write, compile in a child, upload with the old key, confirm
with the new one, print it."""
args = MockArgs()
assert command_rotate_key(args, CORE.config) == 0
assert (
CORE.config_path.read_text()
== API_YAML.replace(OLD_KEY, NEW_KEY) + f' old_key: "{OLD_KEY}"\n'
)
env["confirm"].assert_called_once()
precheck, confirm = env["probe"].call_args_list
assert precheck.args[2] == OLD_KEY
assert precheck.kwargs["retry_rejected"] is False
assert confirm.args[2] == NEW_KEY
compile_args = env["compile"].call_args.args
assert compile_args[-2:] == ("compile", str(CORE.config_path))
env["upload"].assert_called_once_with(
["dev.local"],
3232,
None,
CORE.firmware_bin,
noise_psk=OLD_KEY,
plaintext_fallback=False,
on_connect=ANY,
)
out = capfd.readouterr().out
assert NEW_KEY in out
assert "SUCCESS" in out
def test_yes_skips_confirmation(
env: dict[str, Mock],
) -> None:
assert command_rotate_key(MockArgs(yes=True), CORE.config) == 0
env["confirm"].assert_not_called()
def test_declined(env: dict[str, Mock]) -> None:
env["confirm"].return_value = "n"
assert command_rotate_key(MockArgs(), CORE.config) == 1
assert CORE.config_path.read_text() == API_YAML
env["probe"].assert_not_called()
def test_warns_about_a_shared_secret(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
"""Other configurations using the rewritten secret are named before the
confirmation."""
def scan(edits: list[KeyEdit]) -> list[str]:
edits[0].secret = "device_key"
edits[0].shared_with = [CORE.config_dir / "b.yaml"]
return ["c.yaml is a link"]
with patch("esphome.cli.rotate_key.with_sharers", side_effect=scan):
env["confirm"].return_value = "n"
assert command_rotate_key(MockArgs(), CORE.config) == 1
out = capfd.readouterr().out
assert "b.yaml (secret 'device_key')" in out
assert "Could not check every configuration for shared use: c.yaml is a link" in out
def test_no_terminal_needs_yes(
env: dict[str, Mock],
) -> None:
env["stdin"].isatty.return_value = False
assert command_rotate_key(MockArgs(), CORE.config) == 1
assert CORE.config_path.read_text() == API_YAML
def test_own_ota_key_asks_nothing(
env: dict[str, Mock],
) -> None:
"""Without an api key nothing changes for Home Assistant."""
config = {CONF_OTA: [OTA_CONF]}
assert command_rotate_key(MockArgs(), config) == 0
env["confirm"].assert_not_called()
assert NEW_KEY in CORE.config_path.read_text()
def test_prompted_new_key(env: dict[str, Mock]) -> None:
key = "AgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICE="
with patch("esphome.cli.rotate_key.read_secret_line", return_value=key):
assert command_rotate_key(MockArgs(prompt_new_key=True), CORE.config) == 0
assert key in CORE.config_path.read_text()
assert env["probe"].call_args_list[1].args[2] == key
@pytest.mark.parametrize("key", ["not-base64", OLD_KEY], ids=["invalid", "same"])
def test_rejects_bad_new_key(env: dict[str, Mock], key: str) -> None:
with patch("esphome.cli.rotate_key.read_secret_line", return_value=key):
assert command_rotate_key(MockArgs(prompt_new_key=True), CORE.config) == 1
assert CORE.config_path.read_text() == API_YAML
env["probe"].assert_not_called()
def test_precheck_fails_writes_nothing(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
env["probe"].return_value = False
assert command_rotate_key(MockArgs(), CORE.config) == 1
assert CORE.config_path.read_text() == API_YAML
env["compile"].assert_not_called()
assert "did not complete an encrypted handshake" in capfd.readouterr().out
@pytest.mark.parametrize("step", ["compile", "interrupt"])
def test_restores_before_the_upload(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str], step: str
) -> None:
if step == "compile":
env["compile"].return_value = 1
else:
env["compile"].side_effect = KeyboardInterrupt
image = CORE.firmware_bin
image.parent.mkdir(parents=True)
factory = image.with_name("firmware.factory.bin")
for built in (image, factory):
built.write_bytes(b"built with the new key")
assert command_rotate_key(MockArgs(), CORE.config) == 1
assert CORE.config_path.read_text() == API_YAML
assert env["probe"].call_count == 1
out = capfd.readouterr().out
assert ("Interrupted before the upload" in out) is (step == "interrupt")
# The images may hold the new key, so no upload can install them
assert not image.exists() and not factory.exists()
assert "compile again before the next upload" in out
assert NEW_KEY not in out
def test_a_build_that_cannot_be_removed_shows_the_new_key(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
"""Every image is tried; the ones that stay are all named."""
env["compile"].return_value = 1
image = CORE.firmware_bin
image.mkdir(parents=True) # unlink fails on a directory
stuck = image.with_name("firmware.factory.bin")
stuck.mkdir()
removable = image.with_name("firmware.ota.bin")
removable.write_bytes(b"")
assert command_rotate_key(MockArgs(), CORE.config) == 1
out = capfd.readouterr().out
assert str(image) in out and str(stuck) in out
assert not removable.exists()
assert OLD_KEY in out and NEW_KEY in out
def test_an_interrupt_during_the_image_cleanup_shows_the_keys(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
env["compile"].return_value = 1
CORE.firmware_bin.parent.mkdir(parents=True)
CORE.firmware_bin.write_bytes(b"")
with patch("pathlib.Path.glob", side_effect=KeyboardInterrupt):
assert command_rotate_key(MockArgs(), CORE.config) == 1
out = capfd.readouterr().out
assert f"{CORE.firmware_bin.parent}: interrupted" in out
assert OLD_KEY in out and NEW_KEY in out
def test_a_build_from_before_the_rotation_is_kept(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
env["probe"].return_value = False
image = CORE.firmware_bin
image.parent.mkdir(parents=True)
image.write_bytes(b"built with the current key")
assert command_rotate_key(MockArgs(), CORE.config) == 1
assert image.exists()
assert "compile again" not in capfd.readouterr().out
@pytest.mark.parametrize("rollback", ["succeeded", "failed"])
def test_an_interrupt_while_writing_reports_a_failed_rollback(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str], rollback: str
) -> None:
"""apply_line_edits keeps an interrupt an interrupt and notes a rollback
that failed too; then a file still holds the new key, so both are shown."""
interrupt = KeyboardInterrupt()
if rollback == "failed":
interrupt.add_note("Could not restore test.yaml: disk")
with patch("esphome.cli.rotate_key.apply_line_edits", side_effect=interrupt):
assert command_rotate_key(MockArgs(), CORE.config) == 1
out = capfd.readouterr().out
assert ("Could not restore test.yaml: disk" in out) is (rollback == "failed")
assert (NEW_KEY in out) is (rollback == "failed")
assert ("Interrupted before the upload" in out) is (rollback == "succeeded")
def test_a_second_interrupt_during_the_restore_shows_the_keys(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
env["compile"].return_value = 1
with patch("esphome.yaml_edit.write_keeping_mode", side_effect=_interrupt_after(1)):
assert command_rotate_key(MockArgs(), CORE.config) == 1
out = capfd.readouterr().out
assert f"Could not restore {CORE.config_path}: interrupted" in out
assert OLD_KEY in out and NEW_KEY in out
def test_restores_when_the_device_was_never_reached(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
"""An upload that fails before a connection opens cannot have committed."""
env["upload"].side_effect = None
env["upload"].return_value = (1, None)
assert command_rotate_key(MockArgs(), CORE.config) == 1
assert CORE.config_path.read_text() == API_YAML
out = capfd.readouterr().out
assert "did not reach the device" in out
assert "put back" not in out
assert "Restored the previous key" in out
def test_keeps_the_edit_after_a_failed_upload(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
"""An attempted upload may have committed, so the config keeps both keys
and the new one is printed."""
env["upload"].return_value = (1, None)
assert command_rotate_key(MockArgs(), CORE.config) == 1
text = CORE.config_path.read_text()
assert NEW_KEY in text and f'old_key: "{OLD_KEY}"' in text
assert NEW_KEY in capfd.readouterr().out
assert env["probe"].call_count == 1
def test_shows_both_keys_only_when_a_file_stayed_rewritten(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
with patch(
"esphome.cli.rotate_key.apply_line_edits", side_effect=RestoreError("stuck")
):
assert command_rotate_key(MockArgs(), CORE.config) == 1
out = capfd.readouterr().out
assert "stuck" in out and OLD_KEY in out and NEW_KEY in out
def test_reports_a_failed_restore(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str]
) -> None:
env["compile"].return_value = 1
with patch(
"esphome.cli.rotate_key.restore_files",
side_effect=RestoreError("Could not restore x"),
):
assert command_rotate_key(MockArgs(), CORE.config) == 1
out = capfd.readouterr().out
assert "Could not restore x" in out
assert OLD_KEY in out and NEW_KEY in out
@pytest.mark.parametrize("outcome", ["unconfirmed", "interrupted"])
def test_keeps_the_new_key_after_the_upload(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str], outcome: str
) -> None:
"""Once the upload went through the device most likely runs the new key
and old_key covers the other case, so the files stay and the key is
printed."""
env["probe"].side_effect = [
True,
KeyboardInterrupt if outcome == "interrupted" else False,
]
assert command_rotate_key(MockArgs(), CORE.config) == 1
assert NEW_KEY in CORE.config_path.read_text()
assert f'old_key: "{OLD_KEY}"' in CORE.config_path.read_text()
out = capfd.readouterr().out
assert NEW_KEY in out
assert ("Interrupted" in out) is (outcome == "interrupted")
def test_child_compile_gets_global_options_first(
env: dict[str, Mock],
) -> None:
"""The compile parser is strict, so -s and --toolchain precede it."""
args = MockArgs(substitution=[["name", "kitchen"]])
CORE.dashboard = True
args.toolchain = Toolchain.PLATFORMIO
assert command_rotate_key(args, CORE.config) == 0
assert env["compile"].call_args.args[len(ESPHOME_COMMAND) :] == (
"--dashboard",
"--toolchain",
"platformio",
"-s",
"name",
"kitchen",
"compile",
str(CORE.config_path),
)
@pytest.mark.parametrize(
("config", "port_type", "match"),
[
(
{CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}]},
"NETWORK",
"needs 'encryption:'",
),
({CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}, "NETWORK", "esphome OTA"),
({CONF_OTA: [OTA_CONF]}, PortType.SERIAL, "over the air"),
],
ids=["no_encryption", "web_server_only", "serial"],
)
def test_refuses(
env: dict[str, Mock],
capfd: pytest.CaptureFixture[str],
config: dict[str, Any],
port_type: str,
match: str,
) -> None:
with patch("esphome.cli.rotate_key.get_port_type", return_value=port_type):
assert command_rotate_key(MockArgs(), config) == 1
assert match in capfd.readouterr().out
env["probe"].assert_not_called()
def test_refuses_host(env: dict[str, Mock]) -> None:
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = "host"
assert command_rotate_key(MockArgs(), CORE.config) == 1
env["probe"].assert_not_called()
@pytest.mark.parametrize("helper", ["locate_key_edits", "apply_line_edits"])
def test_reports_edit_errors(
env: dict[str, Mock], capfd: pytest.CaptureFixture[str], helper: str
) -> None:
with patch(f"esphome.cli.rotate_key.{helper}", side_effect=EsphomeError("nope")):
assert command_rotate_key(MockArgs(), CORE.config) == 1
out = capfd.readouterr().out
assert "nope" in out
# A refusal, or an edit rolled back cleanly, shows no key
assert NEW_KEY not in out
assert CORE.config_path.read_text() == API_YAML
+29
View File
@@ -8087,3 +8087,32 @@ def test_command_rename_is_dispatched_to_the_cli_module() -> None:
with patch("esphome.cli.rename.command_rename", return_value=7) as run:
assert main.command_rename(args, {}) == 7
run.assert_called_once_with(args, {})
def test_command_rotate_key_is_dispatched_to_the_cli_module() -> None:
args = MockArgs()
with patch("esphome.cli.rotate_key.command_rotate_key", return_value=7) as run:
assert main.command_rotate_key(args, {}) == 7
run.assert_called_once_with(args, {})
def test_parse_args_rotate_key() -> None:
args = parse_args(
[
"esphome",
"rotate-key",
"--device",
"dev.local",
"--prompt-new-key",
"-y",
"--username",
"mqtt-user",
"device.yaml",
]
)
assert args.command == "rotate-key"
assert args.device == ["dev.local"]
assert args.prompt_new_key is True
assert args.yes is True
# MQTT device resolution reads these like upload does
assert args.username == "mqtt-user"
File diff suppressed because it is too large Load Diff