diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index eabe777510..963613966e 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -150,7 +150,9 @@ The following are **not** vulnerabilities, by design: - Plaintext OTA on a device with no `ota: encryption:` block, including one that offers encryption because it has an api key. That is the documented default, authenticated (if at all) by the OTA password. An uploader that - takes the offer skips the password; the key authenticates it. + takes the offer skips the password; the key authenticates it. With a + runtime provisioned key and no `provisioning:` window, whoever provisions + the key gains that upload path too; validation warns about the pair. - The CLI plaintext fallback until 2027.3.0: without `ota: encryption:` an active attacker who strips the offer or breaks the handshake can make a keyed CLI upload plaintext, with the pre-existing plaintext exposure. A diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 4855b6fef4..4f456cc9c7 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -43,7 +43,8 @@ void APIServer::setup() { #if defined(USE_API_NOISE) && !defined(USE_API_NOISE_PSK_FROM_YAML) uint32_t hash = 88491486UL; this->noise_pref_ = global_preferences->make_preference(hash, true); - if (this->load_and_apply_noise_psk_()) { + // A cleared record loads fine but holds no key + if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) { ESP_LOGD(TAG, "Loaded saved Noise PSK"); } #endif diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index edec0a9cc2..8a3b79a5a6 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -155,11 +155,14 @@ def ota_esphome_final_validate(config: ConfigType) -> None: and not api_conf[CONF_ENCRYPTION].get(CONF_KEY) ): # A runtime provisioned key: the CLI still needs the password, but - # whoever holds the key Home Assistant provisions skips it + # whoever holds the key skips it, and without a provisioning window + # anyone on the network can be the one to provision it _LOGGER.warning( "The '%s' %s %s provisioned at runtime also authenticates OTA " "uploads once provisioned; '%s' %s then only guards plaintext " - "uploads", + "uploads. Whoever provisions the key can upload firmware " + "without the password, so add a 'provisioning:' block to limit " + "when that is possible", CONF_API, CONF_ENCRYPTION, CONF_KEY, diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 3fd7af4c82..6da0808bad 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -41,6 +41,15 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { * "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags */ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // The key can be cleared between the offer and the client taking it; a + // missing key must not turn into a handshake on the all-zeros key + if (!this->noise_context_().has_psk()) { + ESP_LOGW(TAG, "Key cleared"); + this->cleanup_connection_(); + return false; + } +#endif // Default-init: the frame buffer is always written before it is read, so // skip zeroing its 132 bytes // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) diff --git a/esphome/wizard.py b/esphome/wizard.py index cebd36d260..8e0a350a92 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -532,13 +532,9 @@ def wizard(path: Path) -> int: safe_print() safe_print("You'll need this key when adding the device to Home Assistant.") sleep(1) - - # The api key also secures OTA updates; no password prompt - ota_password = "" else: ssid, psk = "", "" api_encryption_key = None - ota_password = "" kwargs = { "path": path, @@ -549,10 +545,9 @@ def wizard(path: Path) -> int: "psk": psk, "type": "basic", } + # The api key also secures OTA updates, so the wizard sets no OTA password 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