Say what a provisioned key next to a password means, log a loaded key only when there is one, drop the wizard's dead password

This commit is contained in:
J. Nick Koston
2026-09-05 17:04:37 +02:00
parent 7668e37ab8
commit 3b43f50b9c
5 changed files with 20 additions and 10 deletions
+3 -1
View File
@@ -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
+2 -1
View File
@@ -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<SavedNoisePsk>(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
+5 -2
View File
@@ -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,
@@ -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)
+1 -6
View File
@@ -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