1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 00:31:58 +00:00

[wizard] Use API encryption key instead of deprecated password (#13634)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Swoboda
2026-02-02 19:20:24 -05:00
committed by GitHub
parent 1119003eb5
commit da947d060f
2 changed files with 125 additions and 25 deletions

View File

@@ -1,5 +1,7 @@
import base64
from pathlib import Path from pathlib import Path
import random import random
import secrets
import string import string
from typing import Literal, NotRequired, TypedDict, Unpack from typing import Literal, NotRequired, TypedDict, Unpack
import unicodedata import unicodedata
@@ -116,7 +118,6 @@ class WizardFileKwargs(TypedDict):
board: str board: str
ssid: NotRequired[str] ssid: NotRequired[str]
psk: NotRequired[str] psk: NotRequired[str]
password: NotRequired[str]
ota_password: NotRequired[str] ota_password: NotRequired[str]
api_encryption_key: NotRequired[str] api_encryption_key: NotRequired[str]
friendly_name: NotRequired[str] friendly_name: NotRequired[str]
@@ -144,9 +145,7 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str:
config += API_CONFIG config += API_CONFIG
# Configure API # Configure API encryption
if "password" in kwargs:
config += f' password: "{kwargs["password"]}"\n'
if "api_encryption_key" in kwargs: if "api_encryption_key" in kwargs:
config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n' config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n'
@@ -155,8 +154,6 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str:
config += " - platform: esphome\n" config += " - platform: esphome\n"
if "ota_password" in kwargs: if "ota_password" in kwargs:
config += f' password: "{kwargs["ota_password"]}"' config += f' password: "{kwargs["ota_password"]}"'
elif "password" in kwargs:
config += f' password: "{kwargs["password"]}"'
# Configuring wifi # Configuring wifi
config += "\n\nwifi:\n" config += "\n\nwifi:\n"
@@ -205,7 +202,6 @@ class WizardWriteKwargs(TypedDict):
platform: NotRequired[str] platform: NotRequired[str]
ssid: NotRequired[str] ssid: NotRequired[str]
psk: NotRequired[str] psk: NotRequired[str]
password: NotRequired[str]
ota_password: NotRequired[str] ota_password: NotRequired[str]
api_encryption_key: NotRequired[str] api_encryption_key: NotRequired[str]
friendly_name: NotRequired[str] friendly_name: NotRequired[str]
@@ -232,7 +228,7 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool:
else: # "basic" else: # "basic"
board = kwargs["board"] board = kwargs["board"]
for key in ("ssid", "psk", "password", "ota_password"): for key in ("ssid", "psk", "ota_password"):
if key in kwargs: if key in kwargs:
kwargs[key] = sanitize_double_quotes(kwargs[key]) kwargs[key] = sanitize_double_quotes(kwargs[key])
if "platform" not in kwargs: if "platform" not in kwargs:
@@ -522,26 +518,54 @@ def wizard(path: Path) -> int:
"Almost there! ESPHome can automatically upload custom firmwares over WiFi " "Almost there! ESPHome can automatically upload custom firmwares over WiFi "
"(over the air) and integrates into Home Assistant with a native API." "(over the air) and integrates into Home Assistant with a native API."
) )
safe_print()
sleep(0.5)
# Generate encryption key (32 bytes, base64 encoded) for secure API communication
noise_psk = secrets.token_bytes(32)
api_encryption_key = base64.b64encode(noise_psk).decode()
safe_print( safe_print(
f"This can be insecure if you do not trust the WiFi network. Do you want to set a {color(AnsiFore.GREEN, 'password')} for connecting to this ESP?" "For secure API communication, I've generated a random encryption key."
)
safe_print()
safe_print(
f"Your {color(AnsiFore.GREEN, 'API encryption key')} is: "
f"{color(AnsiFore.BOLD_WHITE, api_encryption_key)}"
)
safe_print()
safe_print("You'll need this key when adding the device to Home Assistant.")
sleep(1)
safe_print()
safe_print(
f"Do you want to set a {color(AnsiFore.GREEN, 'password')} for OTA updates? "
"This can be insecure if you do not trust the WiFi network."
) )
safe_print() safe_print()
sleep(0.25) sleep(0.25)
safe_print("Press ENTER for no password") safe_print("Press ENTER for no password")
password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): ")) ota_password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): "))
else: else:
ssid, password, psk = "", "", "" ssid, psk = "", ""
api_encryption_key = None
ota_password = ""
if not wizard_write( kwargs = {
path=path, "path": path,
name=name, "name": name,
platform=platform, "platform": platform,
board=board, "board": board,
ssid=ssid, "ssid": ssid,
psk=psk, "psk": psk,
password=password, "type": "basic",
type="basic", }
): if api_encryption_key:
kwargs["api_encryption_key"] = api_encryption_key
if ota_password:
kwargs["ota_password"] = ota_password
if not wizard_write(**kwargs):
return 1 return 1
safe_print() safe_print()

View File

@@ -25,7 +25,6 @@ def default_config() -> dict[str, Any]:
"board": "esp01_1m", "board": "esp01_1m",
"ssid": "test_ssid", "ssid": "test_ssid",
"psk": "test_psk", "psk": "test_psk",
"password": "",
} }
@@ -37,7 +36,7 @@ def wizard_answers() -> list[str]:
"nodemcuv2", # board "nodemcuv2", # board
"SSID", # ssid "SSID", # ssid
"psk", # wifi password "psk", # wifi password
"ota_pass", # ota password "", # ota password (empty for no password)
] ]
@@ -105,16 +104,35 @@ def test_config_file_should_include_ota_when_password_set(
default_config: dict[str, Any], default_config: dict[str, Any],
): ):
""" """
The Over-The-Air update should be enabled when a password is set The Over-The-Air update should be enabled when an OTA password is set
""" """
# Given # Given
default_config["password"] = "foo" default_config["ota_password"] = "foo"
# When # When
config = wz.wizard_file(**default_config) config = wz.wizard_file(**default_config)
# Then # Then
assert "ota:" in config assert "ota:" in config
assert 'password: "foo"' in config
def test_config_file_should_include_api_encryption_key(
default_config: dict[str, Any],
):
"""
The API encryption key should be included when set
"""
# Given
default_config["api_encryption_key"] = "test_encryption_key_base64=="
# When
config = wz.wizard_file(**default_config)
# Then
assert "api:" in config
assert "encryption:" in config
assert 'key: "test_encryption_key_base64=="' in config
def test_wizard_write_sets_platform( def test_wizard_write_sets_platform(
@@ -556,3 +574,61 @@ def test_wizard_write_protects_existing_config(
# Then # Then
assert result is False # Should return False when file exists assert result is False # Should return False when file exists
assert config_file.read_text() == original_content assert config_file.read_text() == original_content
def test_wizard_accepts_ota_password(
tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str]
):
"""
The wizard should pass ota_password to wizard_write when the user provides one
"""
# Given
wizard_answers[5] = "my_ota_password" # Set OTA password
config_file = tmp_path / "test.yaml"
input_mock = MagicMock(side_effect=wizard_answers)
monkeypatch.setattr("builtins.input", input_mock)
monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0)
monkeypatch.setattr(wz, "sleep", lambda _: 0)
wizard_write_mock = MagicMock(return_value=True)
monkeypatch.setattr(wz, "wizard_write", wizard_write_mock)
# When
retval = wz.wizard(config_file)
# Then
assert retval == 0
call_kwargs = wizard_write_mock.call_args.kwargs
assert "ota_password" in call_kwargs
assert call_kwargs["ota_password"] == "my_ota_password"
def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch):
"""
The wizard should handle rpipico board which doesn't support WiFi.
This tests the branch where api_encryption_key is None.
"""
# Given
wizard_answers_rp2040 = [
"test-node", # Name of the node
"RP2040", # platform
"rpipico", # board (no WiFi support)
]
config_file = tmp_path / "test.yaml"
input_mock = MagicMock(side_effect=wizard_answers_rp2040)
monkeypatch.setattr("builtins.input", input_mock)
monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0)
monkeypatch.setattr(wz, "sleep", lambda _: 0)
wizard_write_mock = MagicMock(return_value=True)
monkeypatch.setattr(wz, "wizard_write", wizard_write_mock)
# When
retval = wz.wizard(config_file)
# Then
assert retval == 0
call_kwargs = wizard_write_mock.call_args.kwargs
# rpipico doesn't support WiFi, so no api_encryption_key or ota_password
assert "api_encryption_key" not in call_kwargs
assert "ota_password" not in call_kwargs