mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 07:03:55 +00:00 
			
		
		
		
	Merge branch 'api_size_limits' into integration
This commit is contained in:
		| @@ -226,6 +226,7 @@ async def to_code(config): | |||||||
|         if key := encryption_config.get(CONF_KEY): |         if key := encryption_config.get(CONF_KEY): | ||||||
|             decoded = base64.b64decode(key) |             decoded = base64.b64decode(key) | ||||||
|             cg.add(var.set_noise_psk(list(decoded))) |             cg.add(var.set_noise_psk(list(decoded))) | ||||||
|  |             cg.add_define("USE_API_NOISE_PSK_FROM_YAML") | ||||||
|         else: |         else: | ||||||
|             # No key provided, but encryption desired |             # No key provided, but encryption desired | ||||||
|             # This will allow a plaintext client to provide a noise key, |             # This will allow a plaintext client to provide a noise key, | ||||||
|   | |||||||
| @@ -17,6 +17,16 @@ namespace esphome::api { | |||||||
| // uncomment to log raw packets | // uncomment to log raw packets | ||||||
| //#define HELPER_LOG_PACKETS | //#define HELPER_LOG_PACKETS | ||||||
|  |  | ||||||
|  | // Maximum message size limits to prevent OOM on constrained devices | ||||||
|  | // Voice Assistant is our largest user at 1024 bytes per audio chunk | ||||||
|  | // Using 2048 + 256 bytes overhead = 2304 bytes total to support voice and future needs | ||||||
|  | // ESP8266 has very limited RAM and cannot support voice assistant | ||||||
|  | #ifdef USE_ESP8266 | ||||||
|  | static constexpr uint16_t MAX_MESSAGE_SIZE = 512;  // Keep small for memory constrained ESP8266 | ||||||
|  | #else | ||||||
|  | static constexpr uint16_t MAX_MESSAGE_SIZE = 2304;  // Support voice (1024) + headroom for larger messages | ||||||
|  | #endif | ||||||
|  |  | ||||||
| // Forward declaration | // Forward declaration | ||||||
| struct ClientInfo; | struct ClientInfo; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -184,6 +184,13 @@ APIError APINoiseFrameHelper::try_read_frame_(std::vector<uint8_t> *frame) { | |||||||
|     return APIError::BAD_HANDSHAKE_PACKET_LEN; |     return APIError::BAD_HANDSHAKE_PACKET_LEN; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   // Check against maximum message size to prevent OOM | ||||||
|  |   if (msg_size > MAX_MESSAGE_SIZE) { | ||||||
|  |     state_ = State::FAILED; | ||||||
|  |     HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, MAX_MESSAGE_SIZE); | ||||||
|  |     return APIError::BAD_DATA_PACKET; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   // reserve space for body |   // reserve space for body | ||||||
|   if (rx_buf_.size() != msg_size) { |   if (rx_buf_.size() != msg_size) { | ||||||
|     rx_buf_.resize(msg_size); |     rx_buf_.resize(msg_size); | ||||||
|   | |||||||
| @@ -122,10 +122,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_(std::vector<uint8_t> *frame) { | |||||||
|       continue; |       continue; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (msg_size_varint->as_uint32() > std::numeric_limits<uint16_t>::max()) { |     if (msg_size_varint->as_uint32() > MAX_MESSAGE_SIZE) { | ||||||
|       state_ = State::FAILED; |       state_ = State::FAILED; | ||||||
|       HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(), |       HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(), | ||||||
|                  std::numeric_limits<uint16_t>::max()); |                  MAX_MESSAGE_SIZE); | ||||||
|       return APIError::BAD_DATA_PACKET; |       return APIError::BAD_DATA_PACKET; | ||||||
|     } |     } | ||||||
|     rx_header_parsed_len_ = msg_size_varint->as_uint16(); |     rx_header_parsed_len_ = msg_size_varint->as_uint16(); | ||||||
|   | |||||||
| @@ -37,12 +37,14 @@ void APIServer::setup() { | |||||||
|  |  | ||||||
|   this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true); |   this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true); | ||||||
|  |  | ||||||
|  | #ifndef USE_API_NOISE_PSK_FROM_YAML | ||||||
|  |   // Only load saved PSK if not set from YAML | ||||||
|   SavedNoisePsk noise_pref_saved{}; |   SavedNoisePsk noise_pref_saved{}; | ||||||
|   if (this->noise_pref_.load(&noise_pref_saved)) { |   if (this->noise_pref_.load(&noise_pref_saved)) { | ||||||
|     ESP_LOGD(TAG, "Loaded saved Noise PSK"); |     ESP_LOGD(TAG, "Loaded saved Noise PSK"); | ||||||
|  |  | ||||||
|     this->set_noise_psk(noise_pref_saved.psk); |     this->set_noise_psk(noise_pref_saved.psk); | ||||||
|   } |   } | ||||||
|  | #endif | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
|   // Schedule reboot if no clients connect within timeout |   // Schedule reboot if no clients connect within timeout | ||||||
| @@ -419,6 +421,12 @@ void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeo | |||||||
|  |  | ||||||
| #ifdef USE_API_NOISE | #ifdef USE_API_NOISE | ||||||
| bool APIServer::save_noise_psk(psk_t psk, bool make_active) { | bool APIServer::save_noise_psk(psk_t psk, bool make_active) { | ||||||
|  | #ifdef USE_API_NOISE_PSK_FROM_YAML | ||||||
|  |   // When PSK is set from YAML, this function should never be called | ||||||
|  |   // but if it is, reject the change | ||||||
|  |   ESP_LOGW(TAG, "Key set in YAML"); | ||||||
|  |   return false; | ||||||
|  | #else | ||||||
|   auto &old_psk = this->noise_ctx_->get_psk(); |   auto &old_psk = this->noise_ctx_->get_psk(); | ||||||
|   if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { |   if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { | ||||||
|     ESP_LOGW(TAG, "New PSK matches old"); |     ESP_LOGW(TAG, "New PSK matches old"); | ||||||
| @@ -447,6 +455,7 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|   return true; |   return true; | ||||||
|  | #endif | ||||||
| } | } | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
|   | |||||||
| @@ -514,7 +514,7 @@ async def to_code_characteristic(service_var, char_conf): | |||||||
|         ) |         ) | ||||||
|     if CONF_VALUE in char_conf: |     if CONF_VALUE in char_conf: | ||||||
|         # Check if the value is templated (Lambda) |         # Check if the value is templated (Lambda) | ||||||
|         value_data = char_conf[CONF_VALUE].get(CONF_DATA) |         value_data = char_conf[CONF_VALUE][CONF_DATA] | ||||||
|         if isinstance(value_data, cv.Lambda): |         if isinstance(value_data, cv.Lambda): | ||||||
|             # Templated value - need the full action infrastructure |             # Templated value - need the full action infrastructure | ||||||
|             action_conf = { |             action_conf = { | ||||||
|   | |||||||
| @@ -401,6 +401,12 @@ class DriverChip: | |||||||
|         sequence.append((MADCTL, madctl)) |         sequence.append((MADCTL, madctl)) | ||||||
|         return madctl |         return madctl | ||||||
|  |  | ||||||
|  |     def skip_command(self, command: str): | ||||||
|  |         """ | ||||||
|  |         Allow suppressing a standard command in the init sequence. | ||||||
|  |         """ | ||||||
|  |         return self.get_default(f"no_{command.lower()}", False) | ||||||
|  |  | ||||||
|     def get_sequence(self, config) -> tuple[tuple[int, ...], int]: |     def get_sequence(self, config) -> tuple[tuple[int, ...], int]: | ||||||
|         """ |         """ | ||||||
|         Create the init sequence for the display. |         Create the init sequence for the display. | ||||||
| @@ -432,6 +438,8 @@ class DriverChip: | |||||||
|             sequence.append((INVOFF,)) |             sequence.append((INVOFF,)) | ||||||
|         if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): |         if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): | ||||||
|             sequence.append((BRIGHTNESS, brightness)) |             sequence.append((BRIGHTNESS, brightness)) | ||||||
|  |         # Add a SLPOUT command if required. | ||||||
|  |         if not self.skip_command("SLPOUT"): | ||||||
|             sequence.append((SLPOUT,)) |             sequence.append((SLPOUT,)) | ||||||
|         sequence.append((DISPON,)) |         sequence.append((DISPON,)) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -27,7 +27,8 @@ DriverChip( | |||||||
|     bus_mode=TYPE_QUAD, |     bus_mode=TYPE_QUAD, | ||||||
|     brightness=0xD0, |     brightness=0xD0, | ||||||
|     color_order=MODE_RGB, |     color_order=MODE_RGB, | ||||||
|     initsequence=(SLPOUT,),  # Requires early SLPOUT |     no_slpout=True,  # SLPOUT is in the init sequence, early | ||||||
|  |     initsequence=(SLPOUT,), | ||||||
| ) | ) | ||||||
|  |  | ||||||
| DriverChip( | DriverChip( | ||||||
| @@ -95,6 +96,7 @@ CO5300 = DriverChip( | |||||||
|     brightness=0xD0, |     brightness=0xD0, | ||||||
|     color_order=MODE_RGB, |     color_order=MODE_RGB, | ||||||
|     bus_mode=TYPE_QUAD, |     bus_mode=TYPE_QUAD, | ||||||
|  |     no_slpout=True, | ||||||
|     initsequence=( |     initsequence=( | ||||||
|         (SLPOUT,),  # Requires early SLPOUT |         (SLPOUT,),  # Requires early SLPOUT | ||||||
|         (PAGESEL, 0x00), |         (PAGESEL, 0x00), | ||||||
|   | |||||||
| @@ -288,11 +288,15 @@ void Sim800LComponent::parse_cmd_(std::string message) { | |||||||
|           if (item == 3) {  // stat |           if (item == 3) {  // stat | ||||||
|             uint8_t current_call_state = parse_number<uint8_t>(message.substr(start, end - start)).value_or(6); |             uint8_t current_call_state = parse_number<uint8_t>(message.substr(start, end - start)).value_or(6); | ||||||
|             if (current_call_state != this->call_state_) { |             if (current_call_state != this->call_state_) { | ||||||
|  |               if (current_call_state == 4) { | ||||||
|  |                 ESP_LOGV(TAG, "Premature call state '4'. Ignoring, waiting for RING"); | ||||||
|  |               } else { | ||||||
|  |                 this->call_state_ = current_call_state; | ||||||
|                 ESP_LOGD(TAG, "Call state is now: %d", current_call_state); |                 ESP_LOGD(TAG, "Call state is now: %d", current_call_state); | ||||||
|                 if (current_call_state == 0) |                 if (current_call_state == 0) | ||||||
|                   this->call_connected_callback_.call(); |                   this->call_connected_callback_.call(); | ||||||
|               } |               } | ||||||
|             this->call_state_ = current_call_state; |             } | ||||||
|             break; |             break; | ||||||
|           } |           } | ||||||
|           // item 4 = "" |           // item 4 = "" | ||||||
|   | |||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | esphome: | ||||||
|  |   name: noise-key-test | ||||||
|  |  | ||||||
|  | host: | ||||||
|  |  | ||||||
|  | api: | ||||||
|  |   encryption: | ||||||
|  |     key: "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs=" | ||||||
|  |  | ||||||
|  | logger: | ||||||
							
								
								
									
										51
									
								
								tests/integration/test_noise_encryption_key_protection.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								tests/integration/test_noise_encryption_key_protection.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | """Integration test for noise encryption key protection from YAML.""" | ||||||
|  |  | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import base64 | ||||||
|  |  | ||||||
|  | from aioesphomeapi import InvalidEncryptionKeyAPIError | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from .types import APIClientConnectedFactory, RunCompiledFunction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_noise_encryption_key_protection( | ||||||
|  |     yaml_config: str, | ||||||
|  |     run_compiled: RunCompiledFunction, | ||||||
|  |     api_client_connected: APIClientConnectedFactory, | ||||||
|  | ) -> None: | ||||||
|  |     """Test that noise encryption key set in YAML cannot be changed via API.""" | ||||||
|  |     # The key that's set in the YAML fixture | ||||||
|  |     noise_psk = "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs=" | ||||||
|  |  | ||||||
|  |     # Keep ESPHome process running throughout all tests | ||||||
|  |     async with run_compiled(yaml_config): | ||||||
|  |         # First connection - test key change attempt | ||||||
|  |         async with api_client_connected(noise_psk=noise_psk) as client: | ||||||
|  |             # Verify connection is established | ||||||
|  |             device_info = await client.device_info() | ||||||
|  |             assert device_info is not None | ||||||
|  |  | ||||||
|  |             # Try to set a new encryption key via API | ||||||
|  |             new_key = base64.b64encode( | ||||||
|  |                 b"x" * 32 | ||||||
|  |             )  # Valid 32-byte key in base64 as bytes | ||||||
|  |  | ||||||
|  |             # This should fail since key was set in YAML | ||||||
|  |             success = await client.noise_encryption_set_key(new_key) | ||||||
|  |             assert success is False | ||||||
|  |  | ||||||
|  |         # Reconnect with the original key to verify it still works | ||||||
|  |         async with api_client_connected(noise_psk=noise_psk) as client: | ||||||
|  |             # Verify connection is still successful with original key | ||||||
|  |             device_info = await client.device_info() | ||||||
|  |             assert device_info is not None | ||||||
|  |             assert device_info.name == "noise-key-test" | ||||||
|  |  | ||||||
|  |         # Verify that connecting with a wrong key fails | ||||||
|  |         wrong_key = base64.b64encode(b"y" * 32).decode()  # Different key | ||||||
|  |         with pytest.raises(InvalidEncryptionKeyAPIError): | ||||||
|  |             async with api_client_connected(noise_psk=wrong_key) as client: | ||||||
|  |                 await client.device_info() | ||||||
| @@ -15,7 +15,7 @@ async def test_oversized_payload_plaintext( | |||||||
|     run_compiled: RunCompiledFunction, |     run_compiled: RunCompiledFunction, | ||||||
|     api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, |     api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test that oversized payloads (>100KiB) from client cause disconnection without crashing.""" |     """Test that oversized payloads (>2304 bytes) from client cause disconnection without crashing.""" | ||||||
|     process_exited = False |     process_exited = False | ||||||
|     helper_log_found = False |     helper_log_found = False | ||||||
|  |  | ||||||
| @@ -39,8 +39,8 @@ async def test_oversized_payload_plaintext( | |||||||
|             assert device_info is not None |             assert device_info is not None | ||||||
|             assert device_info.name == "oversized-plaintext" |             assert device_info.name == "oversized-plaintext" | ||||||
|  |  | ||||||
|             # Create an oversized payload (>100KiB) |             # Create an oversized payload (>2304 bytes which is our new limit) | ||||||
|             oversized_data = b"X" * (100 * 1024 + 1)  # 100KiB + 1 byte |             oversized_data = b"X" * 3000  # ~3KiB, exceeds the 2304 byte limit | ||||||
|  |  | ||||||
|             # Access the internal connection to send raw data |             # Access the internal connection to send raw data | ||||||
|             frame_helper = client._connection._frame_helper |             frame_helper = client._connection._frame_helper | ||||||
| @@ -132,22 +132,24 @@ async def test_oversized_payload_noise( | |||||||
|     run_compiled: RunCompiledFunction, |     run_compiled: RunCompiledFunction, | ||||||
|     api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, |     api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test that oversized payloads (>100KiB) from client cause disconnection without crashing with noise encryption.""" |     """Test that oversized payloads from client cause disconnection without crashing with noise encryption.""" | ||||||
|     noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" |     noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" | ||||||
|     process_exited = False |     process_exited = False | ||||||
|     cipherstate_failed = False |     helper_log_found = False | ||||||
|  |  | ||||||
|     def check_logs(line: str) -> None: |     def check_logs(line: str) -> None: | ||||||
|         nonlocal process_exited, cipherstate_failed |         nonlocal process_exited, helper_log_found | ||||||
|         # Check for signs that the process exited/crashed |         # Check for signs that the process exited/crashed | ||||||
|         if "Segmentation fault" in line or "core dumped" in line: |         if "Segmentation fault" in line or "core dumped" in line: | ||||||
|             process_exited = True |             process_exited = True | ||||||
|         # Check for the expected warning about decryption failure |         # Check for HELPER_LOG message about message size exceeding maximum | ||||||
|  |         # With our new protection, oversized messages are rejected at frame level | ||||||
|         if ( |         if ( | ||||||
|             "[W][api.connection" in line |             "[VV]" in line | ||||||
|             and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line |             and "Bad packet: message size" in line | ||||||
|  |             and "exceeds maximum" in line | ||||||
|         ): |         ): | ||||||
|             cipherstate_failed = True |             helper_log_found = True | ||||||
|  |  | ||||||
|     async with run_compiled(yaml_config, line_callback=check_logs): |     async with run_compiled(yaml_config, line_callback=check_logs): | ||||||
|         async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( |         async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( | ||||||
| @@ -159,8 +161,8 @@ async def test_oversized_payload_noise( | |||||||
|             assert device_info is not None |             assert device_info is not None | ||||||
|             assert device_info.name == "oversized-noise" |             assert device_info.name == "oversized-noise" | ||||||
|  |  | ||||||
|             # Create an oversized payload (>100KiB) |             # Create an oversized payload (>2304 bytes which is our new limit) | ||||||
|             oversized_data = b"Y" * (100 * 1024 + 1)  # 100KiB + 1 byte |             oversized_data = b"Y" * 3000  # ~3KiB, exceeds the 2304 byte limit | ||||||
|  |  | ||||||
|             # Access the internal connection to send raw data |             # Access the internal connection to send raw data | ||||||
|             frame_helper = client._connection._frame_helper |             frame_helper = client._connection._frame_helper | ||||||
| @@ -175,9 +177,9 @@ async def test_oversized_payload_noise( | |||||||
|  |  | ||||||
|         # After disconnection, verify process didn't crash |         # After disconnection, verify process didn't crash | ||||||
|         assert not process_exited, "ESPHome process should not crash" |         assert not process_exited, "ESPHome process should not crash" | ||||||
|         # Verify we saw the expected warning message |         # Verify we saw the expected HELPER_LOG message | ||||||
|         assert cipherstate_failed, ( |         assert helper_log_found, ( | ||||||
|             "Expected to see warning about CIPHERSTATE_DECRYPT_FAILED" |             "Expected to see HELPER_LOG about message size exceeding maximum" | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Try to reconnect to verify the process is still running |         # Try to reconnect to verify the process is still running | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user