From 9aed1d2700681390cfe0df5301cb82f4744faaff Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 6 Jul 2026 23:04:38 -0500 Subject: [PATCH] [esp32] Add NVS encryption (HMAC scheme) (#17004) --- esphome/components/esp32/__init__.py | 62 +++++++++++++++++++ .../esp32/config/nvs_encryption_s3.yaml | 10 +++ tests/component_tests/esp32/test_esp32.py | 38 ++++++++++++ .../test-nvs_encryption.esp32-s3-idf.yaml | 9 +++ 4 files changed, 119 insertions(+) create mode 100644 tests/component_tests/esp32/config/nvs_encryption_s3.yaml create mode 100644 tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5a7ddb6c76..e8d1fe73c7 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -109,7 +109,9 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" +CONF_NVS_ENCRYPTION = "nvs_encryption" CONF_RELEASE = "release" CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification" CONF_SIGNING_KEY = "signing_key" @@ -167,6 +169,20 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = { VARIANT_ESP32, } +# NVS encryption (HMAC peripheral scheme) is only available on variants that +# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original +# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral +# should be added here. +NVS_ENCRYPTION_HMAC_VARIANTS = { + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32P4, +} + COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", "NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE", @@ -1349,6 +1365,29 @@ def final_validate(config): "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + variant = config[CONF_VARIANT] + if variant in NVS_ENCRYPTION_HMAC_VARIANTS: + _LOGGER.warning( + "NVS encryption will burn an HMAC key into eFuse key block %d on the " + "first boot of each device. This is PERMANENT and IRREVERSIBLE: " + "the block cannot be erased or reused afterwards. Enabling (or " + "later disabling) encryption also wipes any previously saved " + "preferences once, because the older data can no longer be read.", + nvs_enc[CONF_KEY_ID], + ) + else: + supported = ", ".join( + sorted(VARIANT_FRIENDLY[v] for v in NVS_ENCRYPTION_HMAC_VARIANTS) + ) + errs.append( + cv.Invalid( + f"NVS encryption (HMAC scheme) is not supported on " + f"{VARIANT_FRIENDLY[variant]} (it has no HMAC peripheral). " + f"Supported variants: {supported}.", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_ENCRYPTION], + ) + ) if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: project = full_config[CONF_ESPHOME].get(CONF_PROJECT) errs.extend( @@ -1609,6 +1648,15 @@ FRAMEWORK_SCHEMA = cv.Schema( ), cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), ), + cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema( + { + # eFuse key block (0-5) that stores the HMAC key from + # which the NVS encryption keys are derived. The block is + # written on first boot if empty -- an irreversible + # operation -- so it must be chosen explicitly. + cv.Required(CONF_KEY_ID): cv.int_range(min=0, max=5), + } + ), cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False ): cv.boolean, @@ -2451,6 +2499,20 @@ async def to_code(config): cg.add_define("USE_OTA_SIGNED_VERIFICATION") + # Encrypt NVS using the HMAC peripheral scheme. The NVS encryption keys are + # derived at runtime from an HMAC key stored in the configured eFuse block + # (no flash encryption required). The HMAC key is generated and burned into + # the eFuse block on first boot if it is empty. With the scheme selected, + # nvs_sec_provider registers it at startup and the default nvs_flash_init() + # (used in esp32/preferences.cpp) transparently performs the secure init, so + # no C++ changes are needed. + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True) + add_idf_sdkconfig_option("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC", True) + add_idf_sdkconfig_option( + "CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID", nvs_enc[CONF_KEY_ID] + ) + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( diff --git a/tests/component_tests/esp32/config/nvs_encryption_s3.yaml b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml new file mode 100644 index 0000000000..371f2e28ca --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 1b189c6331..d53e119e9f 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -175,6 +175,29 @@ def test_esp32_default_toolchain_is_esp_idf( r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]", id="ignore_efuse_mac_crc_only_on_esp32", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 0}}, + }, + }, + r"NVS encryption \(HMAC scheme\) is not supported on ESP32 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]", + id="nvs_encryption_unsupported_on_esp32", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 6}}, + }, + }, + r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", + id="nvs_encryption_key_id_out_of_range", + ), ], ) def test_esp32_configuration_errors( @@ -214,6 +237,21 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_nvs_encryption_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that nvs_encryption sets the HMAC scheme sdkconfig options.""" + generate_main(component_config_path("nvs_encryption_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ENCRYPTION") is True + assert sdkconfig.get("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC") is True + assert sdkconfig.get("CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID") == 0 + # The permanent/irreversible eFuse burn is warned about at config time. + assert "PERMANENT and IRREVERSIBLE" in caplog.text + + @pytest.mark.parametrize( ("fixture", "expect_warning"), [ diff --git a/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml new file mode 100644 index 0000000000..ab9001efec --- /dev/null +++ b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml @@ -0,0 +1,9 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 + +<<: !include common.yaml