From b3ebdcea8468215af132dafeb10cf6983f067883 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Mon, 10 Aug 2026 11:13:48 +0800 Subject: [PATCH] feat(pager): add bounded voice messages --- apps/linux_sim_shell/CMakeLists.txt | 186 ++ .../boards/tlora_pager/tlora_pager_board.h | 1 + boards/tlora_pager/src/tlora_pager_board.cpp | 2 + .../esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake | 16 + cmake/TrailMateLinuxSources.cmake | 12 + docs/design/lr1121_voice_message_protocol.md | 448 +++++ .../chat/infra/voice/vmp_contact_secrets.h | 76 + .../chat/infra/voice/vmp_control_auth.h | 48 + .../chat/infra/voice/vmp_control_ingress.h | 62 + .../chat/infra/voice/vmp_media_frames.h | 90 + .../chat/infra/voice/vmp_mqtt_transport.h | 150 ++ .../chat/infra/voice/vmp_private_crypto.h | 193 ++ .../chat/infra/voice/vmp_receive_block.h | 72 + .../include/chat/infra/voice/vmp_rs_fec.h | 46 + .../infra/voice/vmp_session_state_machine.h | 123 ++ .../chat/infra/voice/vmp_voice_inbox.h | 118 ++ .../include/chat/infra/voice/vmp_wire.h | 179 ++ .../src/infra/voice/vmp_contact_secrets.cpp | 152 ++ .../src/infra/voice/vmp_control_auth.cpp | 179 ++ .../src/infra/voice/vmp_control_ingress.cpp | 31 + .../src/infra/voice/vmp_media_frames.cpp | 265 +++ .../src/infra/voice/vmp_mqtt_transport.cpp | 390 ++++ .../src/infra/voice/vmp_private_crypto.cpp | 862 ++++++++ .../src/infra/voice/vmp_receive_block.cpp | 118 ++ .../core_chat/src/infra/voice/vmp_rs_fec.cpp | 272 +++ .../infra/voice/vmp_session_state_machine.cpp | 244 +++ .../src/infra/voice/vmp_voice_inbox.cpp | 316 +++ .../core_chat/src/infra/voice/vmp_wire.cpp | 416 ++++ ...st_vmp_attachment_persistence_contract.cpp | 108 + .../tests/test_vmp_contact_secrets.cpp | 71 + .../core_chat/tests/test_vmp_control_auth.cpp | 78 + .../tests/test_vmp_control_ingress.cpp | 77 + .../test_vmp_lxmf_isolation_contract.cpp | 82 + .../core_chat/tests/test_vmp_media_frames.cpp | 117 ++ .../test_vmp_mqtt_isolation_contract.cpp | 83 + .../tests/test_vmp_mqtt_transport.cpp | 241 +++ .../tests/test_vmp_private_crypto.cpp | 255 +++ .../tests/test_vmp_receive_block.cpp | 111 + modules/core_chat/tests/test_vmp_rs_fec.cpp | 118 ++ .../tests/test_vmp_session_state_machine.cpp | 111 + .../core_chat/tests/test_vmp_voice_inbox.cpp | 166 ++ modules/core_chat/tests/test_vmp_wire.cpp | 230 +++ .../ui_shared/include/ui/chat_voice_runtime.h | 69 + .../ui/screens/chat/chat_compose_components.h | 2 + .../chat/chat_conversation_components.h | 10 + .../ui/screens/chat/chat_ui_controller.h | 8 + .../ui_shared/src/ui/chat_voice_runtime.cpp | 49 + .../screens/chat/chat_compose_components.cpp | 40 +- .../chat/chat_conversation_components.cpp | 91 + .../ui/screens/chat/chat_ui_controller.cpp | 90 + .../tests/test_chat_voice_runtime.cpp | 105 + .../platform/esp/arduino_common/app_tasks.h | 28 + .../chat/infra/lxmf/lxmf_adapter.h | 2 + .../chat/infra/mesh_adapter_router.h | 6 + .../chat/infra/meshcore/meshcore_adapter.h | 2 + .../chat/infra/meshtastic/mt_adapter.h | 2 + .../chat/infra/reticulum/reticulum_adapter.h | 2 + .../infra/store/message_attachment_store.h | 62 + .../voice/vmp_control_runtime.h | 46 + .../arduino_common/voice/vmp_pager_audio.h | 102 + .../arduino_common/voice/vmp_pager_session.h | 176 ++ .../arduino_common/voice/vmp_radio_lease.h | 54 + .../esp/arduino_common/src/app_context.cpp | 205 ++ .../src/app_context_platform_bindings.cpp | 6 + platform/esp/arduino_common/src/app_tasks.cpp | 28 +- .../src/chat/infra/lxmf/lxmf_adapter.cpp | 51 + .../src/chat/infra/mesh_adapter_router.cpp | 44 + .../chat/infra/mesh_mqtt_client_runtime.cpp | 99 + .../chat/infra/meshcore/meshcore_adapter.cpp | 36 + .../src/chat/infra/meshtastic/mt_adapter.cpp | 41 + .../infra/reticulum/reticulum_adapter.cpp | 6 + .../infra/store/message_attachment_store.cpp | 386 ++++ .../src/voice/vmp_control_runtime.cpp | 188 ++ .../src/voice/vmp_pager_audio.cpp | 379 ++++ .../src/voice/vmp_pager_session.cpp | 1785 +++++++++++++++++ .../src/voice/vmp_radio_lease.cpp | 222 ++ scripts/platformio-pre.py | 5 +- 77 files changed, 11332 insertions(+), 10 deletions(-) create mode 100644 docs/design/lr1121_voice_message_protocol.md create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_contact_secrets.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_control_auth.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_control_ingress.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_media_frames.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_mqtt_transport.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_private_crypto.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_receive_block.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_rs_fec.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_session_state_machine.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h create mode 100644 modules/core_chat/include/chat/infra/voice/vmp_wire.h create mode 100644 modules/core_chat/src/infra/voice/vmp_contact_secrets.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_control_auth.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_control_ingress.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_media_frames.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_mqtt_transport.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_private_crypto.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_receive_block.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_rs_fec.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_session_state_machine.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp create mode 100644 modules/core_chat/src/infra/voice/vmp_wire.cpp create mode 100644 modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp create mode 100644 modules/core_chat/tests/test_vmp_contact_secrets.cpp create mode 100644 modules/core_chat/tests/test_vmp_control_auth.cpp create mode 100644 modules/core_chat/tests/test_vmp_control_ingress.cpp create mode 100644 modules/core_chat/tests/test_vmp_lxmf_isolation_contract.cpp create mode 100644 modules/core_chat/tests/test_vmp_media_frames.cpp create mode 100644 modules/core_chat/tests/test_vmp_mqtt_isolation_contract.cpp create mode 100644 modules/core_chat/tests/test_vmp_mqtt_transport.cpp create mode 100644 modules/core_chat/tests/test_vmp_private_crypto.cpp create mode 100644 modules/core_chat/tests/test_vmp_receive_block.cpp create mode 100644 modules/core_chat/tests/test_vmp_rs_fec.cpp create mode 100644 modules/core_chat/tests/test_vmp_session_state_machine.cpp create mode 100644 modules/core_chat/tests/test_vmp_voice_inbox.cpp create mode 100644 modules/core_chat/tests/test_vmp_wire.cpp create mode 100644 modules/ui_shared/include/ui/chat_voice_runtime.h create mode 100644 modules/ui_shared/src/ui/chat_voice_runtime.cpp create mode 100644 modules/ui_shared/tests/test_chat_voice_runtime.cpp create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/message_attachment_store.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_control_runtime.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_audio.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_radio_lease.h create mode 100644 platform/esp/arduino_common/src/chat/infra/store/message_attachment_store.cpp create mode 100644 platform/esp/arduino_common/src/voice/vmp_control_runtime.cpp create mode 100644 platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp create mode 100644 platform/esp/arduino_common/src/voice/vmp_pager_session.cpp create mode 100644 platform/esp/arduino_common/src/voice/vmp_radio_lease.cpp diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index b96c2b7b..727f0260 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -299,6 +299,192 @@ if(BUILD_TESTING) COMMAND trailmate_reticulum_call_product_contract_smoke "${TRAIL_MATE_REPO_ROOT}") + add_executable(trailmate_vmp_wire_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp") + target_include_directories(trailmate_vmp_wire_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_vmp_wire_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_wire_smoke + COMMAND trailmate_vmp_wire_smoke) + + add_executable(trailmate_vmp_contact_secrets_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_contact_secrets.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_contact_secrets.cpp") + target_include_directories(trailmate_vmp_contact_secrets_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_vmp_contact_secrets_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_contact_secrets_smoke + COMMAND trailmate_vmp_contact_secrets_smoke) + + add_executable(trailmate_vmp_control_ingress_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_control_ingress.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_control_ingress.cpp") + target_include_directories(trailmate_vmp_control_ingress_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_vmp_control_ingress_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_control_ingress_smoke + COMMAND trailmate_vmp_control_ingress_smoke) + + add_executable(trailmate_vmp_session_state_machine_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_session_state_machine.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_session_state_machine.cpp") + target_include_directories(trailmate_vmp_session_state_machine_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_vmp_session_state_machine_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_session_state_machine_smoke + COMMAND trailmate_vmp_session_state_machine_smoke) + + add_executable(trailmate_vmp_rs_fec_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_rs_fec.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_rs_fec.cpp") + target_include_directories(trailmate_vmp_rs_fec_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_vmp_rs_fec_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_rs_fec_smoke + COMMAND trailmate_vmp_rs_fec_smoke) + + add_executable(trailmate_vmp_receive_block_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_receive_block.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_rs_fec.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_receive_block.cpp") + target_include_directories(trailmate_vmp_receive_block_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_vmp_receive_block_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_receive_block_smoke + COMMAND trailmate_vmp_receive_block_smoke) + + add_executable(trailmate_vmp_media_frames_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_media_frames.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_rs_fec.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_media_frames.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_receive_block.cpp") + target_include_directories(trailmate_vmp_media_frames_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_vmp_media_frames_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_media_frames_smoke + COMMAND trailmate_vmp_media_frames_smoke) + + add_executable(trailmate_vmp_voice_inbox_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_voice_inbox.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp") + target_include_directories(trailmate_vmp_voice_inbox_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_vmp_voice_inbox_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_voice_inbox_smoke + COMMAND trailmate_vmp_voice_inbox_smoke) + + add_executable(trailmate_vmp_attachment_persistence_contract_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp") + target_compile_features(trailmate_vmp_attachment_persistence_contract_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_attachment_persistence_contract_smoke + COMMAND trailmate_vmp_attachment_persistence_contract_smoke + "${TRAIL_MATE_REPO_ROOT}") + + add_executable(trailmate_chat_voice_runtime_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_chat_voice_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/chat_voice_runtime.cpp") + target_include_directories(trailmate_chat_voice_runtime_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include") + target_compile_features(trailmate_chat_voice_runtime_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_chat_voice_runtime_smoke + COMMAND trailmate_chat_voice_runtime_smoke) + + add_executable(trailmate_vmp_mqtt_isolation_contract_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_mqtt_isolation_contract.cpp") + target_compile_features(trailmate_vmp_mqtt_isolation_contract_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_mqtt_isolation_contract_smoke + COMMAND trailmate_vmp_mqtt_isolation_contract_smoke + "${TRAIL_MATE_REPO_ROOT}") + + add_executable(trailmate_vmp_lxmf_isolation_contract_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_lxmf_isolation_contract.cpp") + target_compile_features(trailmate_vmp_lxmf_isolation_contract_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_lxmf_isolation_contract_smoke + COMMAND trailmate_vmp_lxmf_isolation_contract_smoke + "${TRAIL_MATE_REPO_ROOT}") + + add_executable(trailmate_vmp_control_auth_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_control_auth.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_control_auth.cpp") + target_include_directories(trailmate_vmp_control_auth_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_vmp_control_auth_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_control_auth_smoke + COMMAND trailmate_vmp_control_auth_smoke) + + if(OpenSSL_FOUND) + add_executable(trailmate_vmp_private_crypto_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_private_crypto.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp") + target_include_directories(trailmate_vmp_private_crypto_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_definitions(trailmate_vmp_private_crypto_smoke + PRIVATE TRAIL_MATE_HAS_OPENSSL=1) + target_link_libraries(trailmate_vmp_private_crypto_smoke + PRIVATE OpenSSL::Crypto) + target_compile_features(trailmate_vmp_private_crypto_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_private_crypto_smoke + COMMAND trailmate_vmp_private_crypto_smoke) + + add_executable(trailmate_vmp_mqtt_transport_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_vmp_mqtt_transport.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_contact_secrets.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_control_auth.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_rs_fec.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_media_frames.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_receive_block.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_mqtt_transport.cpp") + target_include_directories(trailmate_vmp_mqtt_transport_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_definitions(trailmate_vmp_mqtt_transport_smoke + PRIVATE TRAIL_MATE_HAS_OPENSSL=1) + target_link_libraries(trailmate_vmp_mqtt_transport_smoke + PRIVATE OpenSSL::Crypto) + target_compile_features(trailmate_vmp_mqtt_transport_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_vmp_mqtt_transport_smoke + COMMAND trailmate_vmp_mqtt_transport_smoke) + endif() + add_executable(trailmate_lxmf_runtime_budget_smoke "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/tests/test_lxmf_runtime_budget.cpp" "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_runtime_budget.cpp") diff --git a/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h b/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h index 122bb08a..d49ce3fc 100644 --- a/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h +++ b/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h @@ -76,6 +76,7 @@ enum class PagerAudioOwner : uint8_t MessageTone, IncomingCallTone, ReticulumCall, + VoiceMessage, Walkie, Sstv, }; diff --git a/boards/tlora_pager/src/tlora_pager_board.cpp b/boards/tlora_pager/src/tlora_pager_board.cpp index 4777f0be..e42fbad6 100644 --- a/boards/tlora_pager/src/tlora_pager_board.cpp +++ b/boards/tlora_pager/src/tlora_pager_board.cpp @@ -96,6 +96,8 @@ const char* audioOwnerLabel(PagerAudioOwner owner) return "incoming_call_tone"; case PagerAudioOwner::ReticulumCall: return "reticulum_call"; + case PagerAudioOwner::VoiceMessage: + return "voice_message"; case PagerAudioOwner::Walkie: return "walkie"; case PagerAudioOwner::Sstv: diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index f2c8ce9c..ee504f7d 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -91,6 +91,17 @@ set(TRAILMATE_ESP_IDF_CORE_CHAT_SOURCES "${TRAILMATE_ROOT}/modules/core_chat/src/infra/mesh_peer_directory_core.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/reticulum/audio_call_wire.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_contact_secrets.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_control_auth.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_control_ingress.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_rs_fec.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_media_frames.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_mqtt_transport.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_receive_block.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_session_state_machine.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/reticulum/lxst_call_state_machine.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/reticulum/lxst_telephony_wire.cpp" "${TRAILMATE_ROOT}/modules/core_chat/src/infra/reticulum/reticulum_wire.cpp" @@ -199,6 +210,7 @@ set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES "${TRAILMATE_ROOT}/modules/ui_map_runtime/src/map_tiles/map_tile_resolver.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/app_catalog_builder.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/app_runtime.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/chat_voice_runtime.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/air_status_footer.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/floating_search_box.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/components/info_card.cpp" @@ -393,6 +405,10 @@ set(TRAILMATE_ESP_IDF_UI_LVGL_UX_PACK_SOURCES set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/boards/src/board_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/app_tasks.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/voice/vmp_control_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/voice/vmp_radio_lease.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/sys/event_bus.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/app_runtime_support.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/ble_manager_stub.cpp" diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index 970ba288..b716076d 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -212,6 +212,17 @@ set(TRAIL_MATE_LINUX_COMMON_SOURCES "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_radio_config.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_region.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/reticulum/reticulum_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_contact_secrets.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_control_auth.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_control_ingress.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_rs_fec.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_media_frames.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_mqtt_transport.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_receive_block.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_session_state_machine.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/store/ram_store.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/chat_service.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/contact_service.cpp" @@ -371,6 +382,7 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/components/two_pane_styles.cpp" # modules/ui_shared �?shell / menu / page "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/app_runtime.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/chat_voice_runtime.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/app_catalog_builder.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/formatters.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/loop_shell.cpp" diff --git a/docs/design/lr1121_voice_message_protocol.md b/docs/design/lr1121_voice_message_protocol.md new file mode 100644 index 00000000..86bc3da9 --- /dev/null +++ b/docs/design/lr1121_voice_message_protocol.md @@ -0,0 +1,448 @@ +# Pager Voice Message Protocol (VMP) v1 + +**Status:** proposed implementation specification +**Scope:** Trail Mate Pager devices with microphone/audio hardware. LR1121 has the direct Sub-GHz/2.4 GHz VMP carrier; SX1262 supports the isolated MT MQTT carrier only when its MQTT uplink is enabled. +**Normative words:** the terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are to be interpreted as requirements for the VMP implementation. + +## 1. Purpose and boundaries + +VMP carries a short, recorded voice message between Trail Mate Pager devices. On an LR1121 it uses the currently selected Sub-GHz radio configuration only as a control plane, then sends compressed voice bytes over LR1121 2.4 GHz GFSK: private delivery is encrypted end to end; broadcast delivery is intentionally public cleartext. An SX1262 cannot enter that path: it may encode and publish the same bounded VMP object only through an enabled isolated MT MQTT uplink. A recording is at most **five seconds** long. + +The protocol deliberately has two delivery modes: + +| Mode | Who receives | Sub-GHz control exchange | 2.4 GHz data exchange | Receiver transmission | +| --- | --- | --- | --- | --- | +| private | one selected contact | `OFFER` then authenticated `ACCEPT` | sender -> selected receiver | one `ACCEPT` only; never data/retry/forward | +| broadcast | compatible receivers on one selected group/channel | public `ANNOUNCE` | sender -> all silent receivers | none | + +“Broadcast” in this document means **one-hop local broadcast**, not a mesh flood. A relay, a receiver, and an MQTT ingress device MUST NOT transmit, forward, retransmit, rebroadcast, or inject a received VMP audio packet onto a radio mesh. The only receive-side transmission VMP allows is the private recipient's small, authenticated `ACCEPT` control reply, which creates a temporary receive reservation; it is not a copy of the message. + +VMP is a Trail Mate application protocol. It is not a Meshtastic (MT) or MeshCore (MC) port number, payload, routing rule, or relay rule. MT/MC radio operation remains unchanged. When Reticulum (RT) is active, LXMF MAY carry a VMP manifest or an encrypted VMP object through an established RT path, but that is a VMP carrier adapter, not an alteration to LXMF text-message semantics. + +### 1.1 Pager carrier capability matrix + +The implementation treats the radio chip as a hard carrier-security and RF-capability boundary, not as a UI preference: + +| Pager hardware | Direct Sub-GHz control + LR1121 2.4 GHz train | MT MQTT publish/ingest | RT/LXMF VQ carrier | Record/send button | +| --- | --- | --- | --- | --- | +| LR1121 | allowed; private `OFFER`/`ACCEPT`/`READY` and public `ANNOUNCE` are available | allowed only when the existing MT MQTT uplink is enabled | allowed for private VMP when RT is active | available after local inbox hydration; direct RF does not require MQTT | +| SX1262 | **MUST NOT** enter any VMP RF control, `READY`, lease, or 2.4 GHz state | the **only** VMP carrier; allowed only while the existing MT MQTT uplink is enabled | **MUST NOT** send or accept VMP through LXMF | hidden/disabled unless the MQTT-only carrier is actually enabled | + +An SX1262 receiving an MQTT VMP object still terminates it locally in the attachment inbox and may play it. It MUST NOT announce, relay, downlink, retransmit, or transform that object into a LoRa/Sub-GHz/2.4 GHz transmission. The SX1262 restriction applies to both egress and ingress: no direct VMP RF receive path and no LXMF VMP carrier are installed on that hardware. + +## 2. Design principles + +1. **No protocol leakage.** `core_voice` owns VMP state, packet parsing, key derivation, local-inbox storage, and deduplication. MT, MC, RT/LXMF, MQTT, UI, and LR1121 are adapters behind ports. +2. **No forwarding by construction.** The inbound VMP service exposes only `store`, `display`, and `play`; it has no radio enqueue capability. The MT MQTT adapter must not call `MtAdapter::enqueueMqttDownlinkTx()` for a VMP envelope. +3. **Bounded work and memory.** The maximum encoded message is fixed, packet data are streamed into fixed-depth storage slots, and no Codec2 frame, protobuf object, or large byte buffer is an ESP task-stack local. +4. **Validate before expensive work.** A private device validates a control authentication tag before reserving radio time and authenticates each private 2.4 GHz data frame before writing it. A public broadcast validates only unkeyed corruption checks and is intentionally marked unverified. +5. **Best-effort media, deterministic completion.** VMP uses no data ACK and no radio data retransmission. It can use proactive FEC, but a message is either stored as complete, stored as recoverable-with-gaps, or discarded. It is never re-originated by the receiver. +6. **Fail closed.** Unknown VMP versions, invalid profile identifiers, bad authentication tags, oversized objects, expired reservations, and duplicate sessions are rejected locally. + +## 3. Architecture + +```mermaid +flowchart LR + UI["Chat compose / conversation UI"] --> APP["VoiceMessageService\ncore_voice application"] + APP --> MODEL["Voice message model & local store"] + APP --> CODEC["Codec2 record / decode port"] + APP --> CTRL["VMP control port"] + APP --> DATA["VMP 2.4 GHz data port"] + CTRL --> RADIO["LR1121 only: radio lease &\nSub-GHz / 2.4 GHz switch"] + DATA --> RADIO + RT["RT/LXMF carrier adapter"] --> APP + MQTT["MT MQTT voice bridge adapter"] --> APP + MT["Existing MT adapter"] -. "unchanged; no VMP port" .-> RADIO + MC["Existing MC adapter"] -. "unchanged; no VMP port" .-> RADIO +``` + +The implementation is split into the following units: + +| Unit | Responsibility | Must not do | +| --- | --- | --- | +| `core_voice/domain` | message metadata, limits, delivery mode, status, content identifier | call radio, UI, MQTT, or Codec2 | +| `core_voice/protocol` | VMP v1 binary codecs, FEC layout, replay/deduplication and session state machine | know MT/MC/RT wire formats | +| `core_voice/application` | start record/send, receive control/data, persist message, publish domain events | make board-specific SPI calls | +| `platform/.../voice_audio_runtime` | bounded Codec2 capture/decode/playback | own message routing or radio policy | +| `platform/.../lr1121_voice_radio` | serialized RF lease, PHY profile application and immediate restoration of Sub-GHz RX | expose generic arbitrary radio TX to an inbound message | +| `.../voice_mqtt_bridge` | VMP MQTT manifest/chunk publication and local-only ingest | invoke MT MQTT downlink-to-radio logic | +| `ui/.../chat` | record affordance, countdown, send intent, playable row | encode or decrypt audio | + +This keeps VMP optional: boards without a microphone or supported audio runtime report `Unsupported` and leave existing text chat behavior intact. A Pager with SX1262 can still expose the bounded VMP feature after inbox hydration, but its send affordance remains unavailable until the existing MT MQTT uplink is active; it has no fallback to direct RF or LXMF. + +## 4. Media representation and limits + +### 4.1 Codec and storage + +VMP v1 uses **Codec2 mode 1300** at 8 kHz mono for the default profile. In the bundled Codec2 implementation this is 320 samples / 52 bits (7 packed bytes) per 40 ms frame, so a full five-second recording is exactly 125 frames or **875 encoded bytes** before VMP padding. `Codec2 mode 1200` is an allowed fallback; its identifier is carried in the manifest. The protocol carries encoded Codec2 frames directly, not a WAV container and not the existing real-time-call protobuf wrapper. + +The sender records PCM into a board-owned/PSRAM buffer or a bounded spill file, then encodes before entering the radio handshake. It MUST stop capture at 5,000 ms even if the record button remains pressed. The UI may stop earlier. The receiver stores the authenticated encoded object and decodes it only when the user selects playback; this avoids holding the 2.4 GHz reservation while decoding and keeps playback independent of RF timing. + +V1 limits: + +| Item | Limit | Reason | +| --- | ---: | --- | +| recording duration | 5,000 ms | product requirement and predictable storage/airtime | +| encoded media length | 1,280 bytes | one fixed 8-source-shard V1 block; a full 5 s Codec2-1300 recording is 875 bytes | +| 2.4 GHz voice data frames | exactly 10 | eight padded source shards + two parity shards; excludes short readiness control | +| protected wire bytes | about 1.8 KiB | ten 160-byte shards plus VMP data headers and private authentication tags | +| unicast reservation | 5,000 ms after 2.4 GHz RX becomes ready | required receive fallback behavior | +| broadcast reservation | 5,000 ms after announcement | same user-visible behavior without ACK | +| control reservation | 1,500 ms total | prevents Sub-GHz ownership starvation | +| active sessions | one TX + one RX reservation, never simultaneous | LR1121 is half duplex and shares one RF front end | +| retained voice objects | 8 / at most 10,240 B encoded payload in PSRAM; 21,280 B for primary+backup on SD and 31,920 B at a snapshot commit peak | fixed inbox; oldest object is replaced; values include the V1 1,280-byte per-object maximum and local record headers | + +### 3.1 Pager memory and durable attachment policy + +The Pager implementation deliberately separates RF control state from bulk media state: + +* The static VMP session keeps only radio/control state, short wire buffers, cryptographic transient state, and FreeRTOS synchronization in internal RAM. +* The inbox, encoded Codec2 media, RS/FEC receive and transmit blocks, MQTT carrier plans, playback copy, and persistence metadata scratch live in one PSRAM-only `PagerMediaStorage` allocation. If that allocation fails, VMP is disabled; it never silently falls back to consuming tens of kilobytes of internal heap. +* PCM stereo/mono frame storage is the one deliberate internal-RAM exception because I2S needs DMA-capable memory. It is a 1,920-byte scratch allocation made only while recording or playing and is securely released immediately afterwards. It is not a permanent VMP allocation. +* The chat UI's eight-entry metadata projection is also PSRAM-only. Its runtime adapter keeps one internal-RAM pointer rather than a 328-byte permanent metadata array; if that small PSRAM allocation fails, the VMP UI is not registered and does not fall back to internal RAM. + +VMP is persistent whenever the active text chat uses the existing `SdStore`; it follows the **same deferred-storage hydration boundary**. Until that shared hydration completes, VMP cannot record, receive, display, or play an object, so a restore cannot overwrite a newly received voice. If text falls back to `RamStore` because SD persistence is unavailable, VMP explicitly follows the same volatile policy rather than pretending that received media is durable. + +The first implemented adapter stores voice under `/data/v2/attachments/voice/inbox.v1`. It is an atomic bounded snapshot with a temporary file, a retained previous snapshot, schema/version, per-media CRC-32, and whole-payload CRC-32. The committed primary and previous backup require at most 21,280 B for a full eight-object V1 inbox; writing a new temporary snapshot raises the bounded peak to 31,920 B. A completed media object is first validated by VMP, then placed in the PSRAM inbox, and is exposed to the UI only if the snapshot commit succeeds. A failed commit removes that tentative inbox entry. This mirrors text incoming-message behavior: the UI never presents an entry as received before its authoritative persistent record is durable. Restore validates the primary first and, on I/O or integrity failure, attempts the retained backup before declaring the inbox unavailable. It preserves each local playback ID and the `(sender_id, session_id)` deduplication key; it never feeds a restored item to any RF, MQTT, or LXMF transmit path. + +The attachment store shares the SD runtime's controlled file access, bounded transfer slices, and temporary/backup recovery protocol. It does not create another uncoordinated SPI client. The current product SD policy, like existing text-chat storage, does not provide a claim of at-rest encryption for a device whose removable storage is physically compromised. Private VMP provides end-to-end confidentiality over RF/LXMF/MQTT carriers; a storage-encryption product requirement must be implemented at the common storage layer for text and all attachments together, not as a voice-only cipher. + +### 3.2 Future message attachment contract + +Text remains in the protocol-partitioned chat journal. Binary and structured bodies use the local **message attachment store** with a stable attachment ID and typed record family: + +| Attachment kind | Storage role | Intended chat record linkage | Current status | +| --- | --- | --- | --- | +| Voice | Codec2 encoded object, codec/mode/identity metadata, play action | local voice ID projected into the owning conversation | implemented | +| Image | immutable compressed image/blob with MIME, dimensions, content hash, thumbnail policy | attachment ID in a normal chat message record | storage family reserved; transport/UI not yet implemented | +| Location | compact structured coordinates, timestamp, accuracy, and optional text preview | inline metadata where small, attachment ID only if extended history/track payload is required | storage family reserved; transport/UI not yet implemented | + +Images and location messages MUST use this attachment boundary rather than inventing protocol-specific caches or direct SD paths. The future chat message schema should carry an attachment descriptor (kind, local attachment ID, content hash, presentation metadata) rather than a full image/audio byte vector. Retention, eviction, export, delete, and at-rest encryption then remain common storage concerns. The attachment layer has no mesh/radio/MQTT/LXMF send function by design; bearer adapters may create a local attachment only after their own validation. + +If a message cannot be stored, the receiver reports a local storage failure and returns to Sub-GHz; it never asks another node to retransmit. + +### 4.2 Fragment layout and FEC + +Each V1 data frame contains a fixed 160-byte shard. The whole five-second recording is one group of eight source shards; the final source shard is zero-padded and unused source slots are all-zero. The group includes two parity shards generated by systematic Reed-Solomon `(10, 8)` over those 160-byte slots. Thus any eight of the ten valid voice-data frames reconstruct the object, allowing up to two packet losses without a reverse-channel ARQ. A message that cannot reconstruct this one block MAY be saved as `partial` only when the product setting **Keep damaged voice messages** is enabled; its conversation entry must say “voice message incomplete” and playback must fill missing frames with Codec2 silence. + +The sender transmits the eight padded source shards followed by two parity shards and then returns directly to Sub-GHz RX. There is no `DATA_END` frame in V1: the Sub-GHz control frame already supplies the media length and fixed `(10, 8)` layout. A receiver reconstructs and stores as soon as it has any eight distinct valid shards; if it cannot do so, it times out at five seconds and restores Sub-GHz RX. The fixed ten-frame train is intentionally the complete 2.4 GHz voice-transfer budget. + +Before a private sender emits any source or parity shard, it performs a bounded 2.4 GHz readiness exchange. The sender sends a short train of authenticated `READY_PROBE` frames, which have no voice payload. The receiver validates one probe, briefly sends an authenticated `READY` control frame on 2.4 GHz, immediately returns to RX, and starts its five-second media deadline. The sender MUST NOT send audio unless it receives the matching `READY`. `READY` and `READY_PROBE` are session-control exceptions only; they are neither voice data, delivery receipts, retry requests, nor packets a third party may relay. + +## 5. Security model + +### 5.1 Trust material + +VMP uses a separate, versioned **voice key domain**. It MUST NOT reinterpret MT channel keys or MC forwarding keys as VMP keys. + +* A private contact has a VMP-specific, verified 32-byte static contact secret `K_contact`. The Pager keeps only a bounded RAM cache of this derived VMP value. It is never an MT channel key, MC forwarding key, or a key copied from an unrelated protocol packet. +* A broadcast session is public: it has **no group key, no key exchange, no encryption, and no sender authentication**. Its `key_or_profile_id` is zero and the `public-broadcast` flag is mandatory. +* Key material is never placed in a chat event, a diagnostic log, or an MQTT topic name. + +For a **private** session, `OFFER` carries the sender's fresh X25519 ephemeral public key `E_s` and `ACCEPT` carries the receiver's fresh ephemeral public key `E_r`. The private portions never go on air and MUST be erased immediately after completion, failure, or timeout. Given a 64-bit `session_id` and 96-bit `session_nonce`, derive: + +``` +S_contact = X25519(local_verified_static_private, peer_verified_static_public) +K_contact = HKDF-Expand(HKDF-Extract("TMVM-V1-CTK", S_contact), + "TrailMate/VMP/v1/contact/{mt|mc|rt}" || + min(local_node_id, peer_node_id) || + max(local_node_id, peer_node_id), 32) +K_eph = X25519(local_ephemeral_private, peer_ephemeral_public) +K_ctrl = HKDF-Expand(HKDF-Extract(session_nonce, K_contact), + "TrailMate/VMP/v1/private-control" || session_id, 32) +PRK = HKDF-Extract(salt = session_nonce, IKM = K_eph) +K_ready = HKDF-Expand(PRK, "TrailMate/VMP/v1/ready" || session_id, 16) +K_data = HKDF-Expand(PRK, "TrailMate/VMP/v1/data" || session_id, 16) +K_mqtt = HKDF-Expand(PRK, "TrailMate/VMP/v1/mqtt" || session_id, 32) +``` + +For private delivery, `K_ctrl` authenticates the Sub-GHz `OFFER`/`ACCEPT` transcript with a full 128-bit ChaCha20-Poly1305 AEAD tag over the clear control bytes. `K_ready` authenticates the zero-payload 2.4 GHz `READY_PROBE`/`READY` exchange the same way. `K_data` encrypts and authenticates every 2.4 GHz voice-data frame with ChaCha20-Poly1305 and a full 128-bit tag. VMP derives a unique 96-bit nonce from `session_nonce[0..7]`, frame type, block, shard, direction, and a domain separator; all clear headers are AEAD additional authenticated data. `K_mqtt` authenticates the MQTT manifest and binds cloud delivery to the same object. + +For broadcast delivery, the control trailer is a CRC-64/ECMA of the preceding control bytes and each 2.4 GHz frame has the PHY CRC plus a CRC-32C over its clear header and shard. These checks reject accidental corruption only; they are not signatures or authentication. The broadcast `session_id`/nonce remain random so receivers can deduplicate a public transmission, but they are not keys. + +The verified static contact secret authenticates the peer and blocks a man-in-the-middle from substituting an ephemeral key. The per-message ephemeral X25519 exchange gives recorded private voice traffic forward secrecy after the ephemeral private keys are erased. The V1 wire frame reserves and carries the two existing control-frame public keys, so it adds no key-exchange round trip beyond `OFFER`/`ACCEPT`. + +**Implemented trust bridge and fail-closed policy:** before a private send, direct private receive, MQTT control, or LXMF VQ control is accepted, the Pager asks a VMP-only bridge for `K_contact`. The bridge derives it from the active backend's static identity ECDH and stores it in the bounded VMP cache only after the contact policy passes. MT and MC require a non-ignored contact with a public key marked `key_manually_verified`; RT requires a non-ignored saved contact with a stored LXMF encryption identity. Missing identity material, an unverified MT/MC key, an ignored peer, a failed ECDH operation, or an unsupported backend makes private VMP unavailable. It never falls back to cleartext, a channel key, an MC forwarding key, or a different protocol's packet key. + +The cache is invalidated whenever the active mesh protocol changes, so an equal numeric node ID on another backend can never reuse a contact secret derived in the former `{mt|mc|rt}` identity family. If a VMP transfer is already active, invalidation is deferred until that transfer has completed and erased its ephemeral/session keys. + +The RT saved-contact rule is explicit trust-on-first-use for the currently stored LXMF identity. It prevents a passive third party, MQTT broker, RF listener, or carrier from decrypting voice traffic, but a future UI should expose and persist an out-of-band LXMF fingerprint-verification mark to make active identity-substitution protection user-visible as it already is for MT/MC. Until then, users needing resistance to an active first-contact MITM MUST verify the LXMF identity fingerprint out of band before saving the contact. + +### 5.2 Replay, reflection, and reservation abuse + +The receiver maintains a fixed ring of recently seen `(sender_id, session_id, session_nonce)` values for 10 minutes. It MUST reject duplicate control packets and must not extend a timeout on a duplicate. It validates a private control tag or a broadcast corruption check and the intended target before switching RF. The private `ACCEPT` is bound to the `OFFER` digest and has its own `K_ctrl` tag; a sender accepts it only within 1,500 ms of its own offer. + +Public broadcast is intentionally susceptible to nearby spoofed `ANNOUNCE`/`READY_PROBE` traffic and short receive-window denial of service. The receiver MUST mark the resulting message as `source_unverified`, expose that state in the conversation UI, and bound public reservations exactly as specified. A future signed-public-broadcast profile may improve origin assurance, but must remain a separate opt-in protocol version so it does not reintroduce a key exchange into V1. + +No VMP ingress is permitted to create a session that is then announced over radio. MQTT and LXMF object reception terminate in the local VMP inbox only. + +## 6. Sub-GHz control plane + +### 6.1 Control envelope + +VMP control bytes use the binary envelope below. They are carried by the `VoiceControlPort` on the currently configured Sub-GHz air parameters, scheduled by a radio lease after ordinary packet RX has become idle. The control envelope is neither an MT nor MC application packet. + +| Offset | Size | Field | Notes | +| ---: | ---: | --- | --- | +| 0 | 2 | magic | ASCII `VM` | +| 2 | 1 | version | `1` | +| 3 | 1 | type | `1=OFFER`, `2=ACCEPT`, `3=ANNOUNCE`, `4=CANCEL` | +| 4 | 1 | flags | bit 0 private, bit 1 broadcast, bit 2 public-broadcast, bit 3 RT-carrier hint | +| 5 | 1 | key/profile ID | private key slot; public broadcast MUST use `0` | +| 6 | 4 | sender ID | Trail Mate node identity short ID | +| 10 | 4 | target ID | recipient short ID, or `0xFFFFFFFF` for broadcast | +| 14 | 8 | session ID | cryptographically random, nonzero | +| 22 | 12 | session nonce | cryptographically random | +| 34 | 1 | 2.4 GHz PHY profile | profile registry ID | +| 35 | 1 | 2.4 GHz channel index | selected from the region whitelist | +| 36 | 2 | source media length | encoded Codec2 bytes, big-endian | +| 38 | 1 | codec | `1=Codec2-1300`, `2=Codec2-1200` | +| 39 | 1 | FEC layout | V1 value `0xA8` = 10 total / 8 source shards | +| 40 | 1 | total blocks | V1 fixed value `1` | +| 41 | 2 | data-start delay / guard | semantics defined below, milliseconds | +| 43 | 4 | object fingerprint | non-secret session binding: low 32 bits of `session_id XOR (session_id >> 32)` | +| 47 | 32 | ephemeral public key | private: fresh X25519 public key; broadcast: all zeroes | +| 79 | 16 | integrity trailer | private: full 128-bit ChaCha20-Poly1305 tag with `K_ctrl`; broadcast: CRC-64/ECMA followed by eight zero bytes | + +The fixed frame is 95 bytes. `ACCEPT` uses the same layout: it carries the receiver's fresh X25519 key, copies the offered-object fingerprint at bytes 43–46, and sets bytes 41–42 to the receiver's selected guard. This 32-bit field is only a cheap session-matching aid; private integrity comes from the authenticated control tag and every media frame's ChaCha20-Poly1305 tag, never from the fingerprint. `CANCEL` uses zero length media and preserves the normal mode flags; V1 does not encode a reason, so it is optional diagnostic cleanup rather than a retry mechanism. + +Control packets are no larger than the normal application-independent control MTU. If the current Sub-GHz settings cannot carry the fixed 95 bytes, VMP is unavailable and the UI disables the record button with a precise reason. + +### 6.2 Private session sequence + +```mermaid +sequenceDiagram + participant S as Sender Pager + participant R as Recipient Pager + S->>S: record <= 5 s, Codec2 encode, derive K_ctrl and generate E_s + S->>R: Sub-GHz VMP OFFER + R->>R: authenticate, reserve RF, switch 2.4 GHz RX + R->>S: Sub-GHz VMP ACCEPT (reservation-only) + S->>S: authenticate ACCEPT, switch 2.4 GHz TX + S->>R: 2.4 GHz READY_PROBE train (no voice) + R->>S: 2.4 GHz READY (one control response) + S->>R: 2.4 GHz fixed 10-frame encrypted source + FEC train + S->>S: return Sub-GHz RX + R->>R: validate/reconstruct/store, return Sub-GHz RX +``` + +Detailed timing: + +1. Sender derives `K_ctrl` from the verified static-contact secret, generates `E_s`, obtains the radio lease, and performs a short RSSI/CAD preflight. It sends one authenticated `OFFER`. It waits at most 1,500 ms for `ACCEPT`. +2. The target derives the same `K_ctrl` from its verified static-contact secret and validates the packet **before** allocating `E_r` or reserving 2.4 GHz. It then generates `E_r`, derives `K_ready`/`K_data`/`K_mqtt`, reserves the radio, immediately configures 2.4 GHz receive, and sends exactly one authenticated `ACCEPT` using the old Sub-GHz profile. It may repeat that **control-only** `ACCEPT` once after 80 ms if it can do so before changing the PHY; it MUST NOT send after moving to 2.4 GHz and MUST NOT send any data acknowledgement. +3. Sender validates `ACCEPT` with `K_ctrl`, derives `K_ready`/`K_data`/`K_mqtt` from `E_r`, changes to the specified 2.4 GHz profile, waits the receiver-selected guard (default 120 ms), and sends `READY_PROBE` at 40 ms intervals. It sends at most three probes and stops as soon as it authenticates a matching 2.4 GHz `READY` frame. +4. Receiver remains in 2.4 GHz RX for five seconds from readiness. A valid `READY_PROBE` causes one 2.4 GHz `READY` control reply, then an immediate return to RX; a valid first voice shard converts state from `WAITING_FIRST_MEDIA` to `RECEIVING`. A `READY_PROBE` alone does not count as received voice media and cannot extend the five-second media deadline. It returns early as soon as any eight authenticated shards reconstruct the fixed FEC block. +5. Sender returns to the stored Sub-GHz configuration after its ten-frame train, a 2.4 GHz ready timeout, or its private acceptance deadline. It never retries the audio data. + +### 6.3 Broadcast sequence + +```mermaid +sequenceDiagram + participant S as Sender Pager + participant G as All compatible group receivers + S->>S: record <= 5 s, Codec2 encode, prepare public session metadata + S->>G: Sub-GHz VMP ANNOUNCE + G->>G: authenticate, schedule 2.4 GHz RX silently + S->>S: switch after announced delay + S->>G: 2.4 GHz READY_PROBE train (no reply expected) + S->>G: 2.4 GHz fixed 10-frame clear source + FEC train + G->>G: reconstruct/store locally and restore Sub-GHz RX +``` + +An `ANNOUNCE` carries a 700 ms data-start delay. A receiver switches after validating the public CRC, starts a five-second receive deadline, and is listening at least 250 ms before the advertised start. The sender starts with a short `READY_PROBE` train so the first 2.4 GHz frame is not voice data, then sends clear media without waiting for any response. Broadcast receivers MUST send no ACK, NACK, `READY`, receipt, or data packet. The sender begins no sooner than the announced delay, so it cannot be delayed by an ACK storm. Receivers that miss the announcement simply do not receive this one-hop message. + +## 7. 2.4 GHz data plane + +### 7.1 PHY profile registry + +VMP does not hard-code a worldwide channel map. Board capabilities and the selected regulatory region provide a whitelisted `VoicePhyProfile` table; unknown IDs are rejected. The first field-tested V1 profile is intentionally conservative: + +| Property | VMP profile `0x01` target | Rationale | +| --- | --- | --- | +| modulation | 2.4 GHz GFSK, packet mode | suitable for short bulk transfer and available on LR1121 | +| nominal bit rate | 500 kbit/s | transfers a 3 KiB protected object well within five seconds | +| frequency deviation | 250 kHz | modulation index near 1; validate against RadioLib/LR1121 settings | +| receiver bandwidth | 800 kHz | accommodates occupied bandwidth plus implementation margin | +| whitening | enabled | reduces long patterns and DC bias | +| sync word | VMP-specific 32-bit value | makes accidental Wi-Fi/BLE noise less likely to enter frame parsing | +| packet CRC | enabled | rejects corruption before AEAD work; cryptographic tag remains authoritative | +| TX power | board/region capped, never above the configured 2.4 GHz capability | LR1121's 2.4 GHz PA is a separate, lower-power path | +| channels | region-approved, 2 MHz-spaced 2.4 GHz centres selected by a hash of the public session ID and sent in the control frame | spreads sessions without requiring a broadcast key | + +The LR1121 supports the 2.4 GHz ISM band and (G)FSK. Its documented 2.4 GHz power path is lower than its Sub-GHz power path, so deployment must measure the actual board antenna/matching-network performance rather than assume Sub-GHz range. Semtech's product information also makes clear that the RF matching network must satisfy regional limits. See the [LR1121 product page](https://www.semtech.com/products/wireless-rf/lora-connect/lr1121) and [Semtech's LR1121 family table](https://www.semtech.com/products/wireless-rf/lora-connect). + +`0x01` is a starting profile, not an unvalidated regulatory declaration. Before enabling it by default, test conducted/radiated spectral mask, occupied bandwidth, receive sensitivity, Wi-Fi coexistence, packet error rate, and the exact T-LoRa Pager LR1121 front-end. The profile registry permits a lower-rate/longer-range GFSK profile if field data requires it, without changing the VMP envelope. + +### 7.2 Data frame + +The clear data header is private-session AEAD additional authenticated data and is covered by the public-broadcast CRC: + +| Offset | Size | Field | +| ---: | ---: | --- | +| 0 | 2 | magic `VD` | +| 2 | 1 | version `1` | +| 3 | 1 | type: `1=READY_PROBE`, `2=READY`, `3=SHARD` | +| 4 | 8 | session ID | +| 12 | 1 | block index | +| 13 | 1 | shard index (`0..7` source, `8..9` parity) | +| 14 | 1 | shard payload length (0–160) | +| 15 | 1 | flags (`last block`, `partial source`) | +| 16 | N | private ciphertext or public clear shard | +| 16+N | 16 / 4 | private ChaCha20-Poly1305 tag / public CRC-32C | + +`READY_PROBE` and `READY` use this same header but carry zero media bytes. They have `block_index=0`, `shard_index=0`, and no flags, so they cannot be confused with a Codec2 shard. Private receivers send exactly one `READY` after an authenticated probe; broadcast receivers never send it. The receiver first checks magic/version/session bounds, then validates/decrypts a private frame or validates a broadcast CRC. It accepts each shard index once, uses one fixed 10-shard block slot, and never makes a packet-sized automatic local. The control frame's validated media length determines how many reconstructed bytes are retained; all padding is discarded. Once any eight distinct shards are available, the receiver reconstructs, persists, and marks the message complete without waiting for an extra terminator packet. + +## 8. Session state machine + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Recording: UI record pressed + Recording --> Encoding: stop or 5 s cap + Encoding --> OfferPending: private OFFER + Encoding --> Announced: broadcast ANNOUNCE + OfferPending --> TxData: valid ACCEPT before 1.5 s + OfferPending --> Failed: timeout / reject / radio unavailable + Announced --> TxData: announced guard elapsed + TxData --> Finalizing: fixed ten-frame train sent or send failure + Finalizing --> Idle: restore Sub-GHz + Idle --> RxReservation: valid OFFER / ANNOUNCE + RxReservation --> ReadyProbed: valid READY_PROBE + ReadyProbed --> Receiving: first valid voice shard + RxReservation --> Idle: 5 s without voice data + ReadyProbed --> Idle: 5 s without voice data + Receiving --> Reconstructing: any eight V1 shards recovered + Reconstructing --> Stored: valid complete or allowed partial object + Reconstructing --> Idle: invalid / no recoverable data + Stored --> Playing: user selects voice entry + Playing --> Stored: playback ends / stops +``` + +Every transition that leaves a radio-reserved state uses RAII-style `RadioLease` cleanup to restore the captured Sub-GHz profile, clear DIO/IRQ state, and restart normal RX. A watchdog-safe deadline is owned by `VoiceSessionCoordinator`; neither a UI object nor an ISR owns it. + +## 9. RT/LXMF carrier adapter (LR1121 Pager only) + +When the active protocol is Reticulum, an **LR1121 Pager** selects an **LXMF private-unicast carrier** after microphone capture and Codec2 encoding. It does not alter LXMF text payloads, LXST calls, real-time Codec2 packets, or the Reticulum routing protocol. An SX1262 Pager MUST reject the VMP LXMF adapter and cannot use LXMF as a substitute for its absent 2.4 GHz carrier; its only supported VMP network carrier is the explicitly enabled MT MQTT uplink in Section 10. + +### 9.1 Exact LXMF carrier + +The carrier is existing signed/encrypted LXMF `AppData` on the reserved 32-bit port `0x564D5001` (`VMP` v1), not an MT/MC port and not an LXMF text string. The payload is the existing VQ envelope used by the MQTT carrier: + +| Order | Payload | Maximum | +| ---: | --- | ---: | +| 1 | one exact private VMP `OFFER` control frame inside VQ kind `1` | 99 B including VQ prefix | +| 2–11 | one exact private encrypted VMP shard inside VQ kind `2` | 196 B including VQ prefix | + +For a private VMP object, the sender emits the original 11 envelopes once to the selected LXMF destination: control first, then eight source and two RS parity shards. The outer LXMF session provides its normal authenticated/encrypted delivery semantics. VMP additionally keeps its own static-contact `K_data` AEAD for the object, so a third party cannot decrypt the audio merely by obtaining the LXMF application payload, broker copy, or a different bearer copy. This asynchronous-carrier schedule intentionally has no `ACCEPT` ephemeral exchange and therefore has no additional VMP forward secrecy beyond `K_contact`; the direct 2.4 GHz private flow retains its per-transfer ephemeral forward secrecy. + +The pager's VMP worker does **not** send VMP ACK/NACK/receipt/retry envelopes. It invokes the existing LXMF AppData sender once per VQ envelope with `want_ack=false`; any internal LXMF link scheduling is transport behavior and must not be treated as an application-level VMP relay. A failed local LXMF enqueue clears the unsent VMP object rather than falling back to radio or cleartext. + +On receive, the LXMF adapter first performs its existing signature/identity validation. It then checks the reserved port before the generic AppData queue. The `MeshIncomingData.from` peer ID MUST equal the `sender_id` in the VMP control and every subsequent shard. The payload is handed to the bounded VMP VQ receiver and, after complete cryptographic/FEC validation, to the local VMP inbox. The reserved-port branch always returns before generic AppData delivery: it cannot be consumed by team, BLE, MT, MC, or a future bridge that could resend it. + +LXMF has no single-frame public multicast equivalent. Therefore RT mode uses LXMF only for **private** VMP unicast. A user-selected broadcast remains the direct, public, one-hop 2.4 GHz `ANNOUNCE`/ten-shard VMP mode. The firmware MUST NOT emulate broadcast by fan-out to every known LXMF peer, because that would multiply traffic and violate the bounded-send design. + +## 10. MT MQTT bridge + +### 10.1 Publishing + +If the active protocol is MT and both the existing MQTT client and MQTT uplink are enabled, every locally completed LR1121 VMP direct-radio send is copied into one bounded VMP MQTT publish plan. On an SX1262 Pager, this exact bounded plan is the primary and only VMP send path: the object is encoded locally and placed directly into the MQTT publish plan without an `OFFER`, `ACCEPT`, `READY`, radio lease, or 2.4 GHz operation. The plan is independent of MT service envelopes and uses the existing broker connection only as a byte carrier. + +``` +/2/e/vmp +``` + +The application payload is one bounded **VQ envelope**, never a Meshtastic `ServiceEnvelope`: + +| bytes | meaning | +|---:|---| +| 0–1 | ASCII `VQ` magic | +| 2 | VQ version `1` | +| 3 | kind: `1=VMP control`, `2=VMP shard` | +| 4… | one exact existing VMP control frame (95 bytes) or one exact VMP shard frame (180 public / 192 private bytes) | + +One voice produces precisely eleven MQTT envelopes: first the control envelope and then its ten source/FEC shard envelopes. Every envelope is at most 196 bytes, fitting below the current direct MQTT runtime's bounded packet buffers and MT proxy payload ceiling without making VMP an MT payload. + +The direct MQTT client currently uses its existing QoS 0 raw publish primitive. The VMP plan is nevertheless two-phase: it exposes a copy of the current envelope, and advances only after the TCP MQTT write succeeds. A socket error retains the current envelope for a later reconnect; no radio retransmission is requested and a failed cloud copy never changes a successful local radio result. There is one in-memory plan only; a newer local VMP send replaces an undrained older plan. + +When the active protocol is not MT, MQTT is disabled, or MT MQTT uplink is disabled, the runtime clears that pending plan. This is intentional: enabling upload later must not silently publish a voice recorded while upload was disabled. + +Private MQTT delivery uses the VMP **asynchronous-carrier** static-contact schedule (also used by the RT/LXMF private-unicast carrier): + +```text +K_ctrl = HKDF(K_contact, session_nonce, "private-control", session_id) +K_ready = HKDF(K_ctrl, session_nonce, "mqtt-ready", session_id) +K_data = HKDF(K_ctrl, session_nonce, "mqtt-data", session_id) +K_mqtt = HKDF(K_ctrl, session_nonce, "mqtt-manifest", session_id) +``` + +`K_data` protects every private asynchronous-carrier shard with the same ChaCha20-Poly1305 VMP media framing used on 2.4 GHz. The schedule is deliberately separate from the radio ephemeral-X25519 schedule: an asynchronous carrier cannot obtain an `ACCEPT`-side ephemeral key without adding an unbounded handshake. Consequently, private MQTT/LXMF objects retain end-to-end confidentiality and authentication against a broker, router, or third party holding no verified contact secret, but do **not** add forward secrecy beyond that long-lived contact secret. Radio private transfers retain their per-transfer forward-secret schedule. + +### 10.2 Receiving + +The existing MT subscription (`/2/e/#`) already includes the VMP topic. Before the generic MT proxy decoder receives a PUBLISH, the runtime compares the topic against `/2/e/vmp`. A matching VQ envelope is handled by `MqttReceiveTransfer`, which has one bounded in-flight object and is limited to the VMP ten-shard layout. + +Private cloud control is accepted only if all of the following are true: + +1. the control is a valid private `OFFER` addressed to the local node; +2. the sender has an already verified VMP contact secret; +3. the static MQTT key schedule authenticates the control tag; +4. every shard decrypts and authenticates under `K_data`; and +5. any eight unique V1 shards reconstruct exactly the announced encoded length. + +Public cloud control must be a valid public broadcast `ANNOUNCE`; every shard still requires VMP's CRC-32C corruption check, but the stored object remains `source_unverified` by design. Retained PUBLISHes, empty/oversized VQ payloads, unrelated topics, unauthenticated private controls, duplicate shards, and invalid FEC are rejected. A completed object is copied only into the local VMP inbox. The chat UI's normal VMP inbox projection observes it and offers on-demand playback. + +The following are mandatory safety rules: + +* VMP MQTT input MUST NOT be passed to `MtAdapter::handleMqttProxyMessage()` or its downlink-to-radio queue. The VMP topic branch returns before that call. +* It MUST NOT call any method that transmits a received VMP object over LoRa/Sub-GHz/2.4 GHz. `acceptMqttEnvelope()` has only receive/FEC/inbox operations. +* The local inbox deduplicates `(sender_id, session_id)` and never exposes a publication or radio-send API for received media. +* A public MQTT broker topic is not a substitute for end-to-end encryption. Private VMP payloads remain VMP encrypted; broadcast VMP is intentionally public and unverified. + +This directly enforces the requirement that a voice message received from MQTT is displayed and playable, but never floods or relays onto the air. + +## 11. Chat UI and accessibility + +`chat_compose` displays a microphone action only when the isolated VMP runtime reports `recordAndSend` currently possible. On LR1121 this is available after local inbox hydration. On SX1262 it becomes available only while the MT MQTT uplink is enabled and is immediately hidden when that uplink is disabled. It is available for both a selected private conversation and the broadcast/channel conversation when their applicable carrier condition is met. + +1. Selecting the microphone enters a recording state with a visible five-second countdown and a stop/cancel action. +2. At 5.0 seconds it stops automatically and encodes/sends in the background. The destination is derived from the active conversation: peer means private; channel/group means one-hop broadcast. +3. If the active protocol/board cannot provide VMP, the action is hidden or disabled with a reason; it does not silently fall back to text, MT, or MC payloads. +4. The conversation renders a voice bubble with direction, duration, mode (private/broadcast), delivery state, integrity state (`complete` or `incomplete`), and a play button. Playback decodes on demand through the audio port. +5. A received message is added only after cryptographic validation and bounded local-inbox storage. The notification can be a normal incoming-message tone; it must never auto-play voice. + +## 12. Error behavior and observability + +The service records a small, non-sensitive diagnostic reason: `unsupported_board`, `radio_busy`, `control_auth_failed`, `accept_timeout`, `ready_timeout`, `no_first_voice_data`, `data_auth_failed`, `fec_unrecoverable`, `storage_full`, `mqtt_manifest_invalid`, or `mqtt_loop_suppressed`. Logs may include session ID in redacted form and byte counts, but never keys, full PCM, ciphertext, contact secret, or user voice content. + +UI delivery state is intentionally honest: + +| Sender result | Meaning | +| --- | --- | +| `sent-to-2.4GHz` | voice frames were handed to the radio; not a remote receipt | +| `receiver-ready` | private recipient authenticated and sent `ACCEPT`; not a content acknowledgement | +| `broadcast-sent` | announced and sent once; receiver population is unknown | +| `not-delivered` | no private `ACCEPT`, RF failure, or local encoding/store failure | +| `cloud-upload-pending/failed` | only VMP MQTT replication state | + +No delivery receipt is added to V1 because receiving one would require another radio transmission and would violate the no-data-TX/no-flood property. + +## 13. Implementation status and verification gates + +| Area | Current implementation status | Remaining release gate | +| --- | --- | --- | +| Core VMP v1 | Implemented: binary control/data codecs, private/public validation, RS(10,8), replay/session state, `K_contact` domain derivation, bounded inbox, and host test sources. | Run the OpenSSL-enabled private crypto/transport tests in CI; this workstation's CMake OpenSSL discovery is unavailable. | +| Pager audio/UI | Implemented: 5-second Codec2 capture, chat-compose microphone action, local inbox projection, and on-demand playback. | Hardware exercise capture/playback ownership alongside a real call and a text conversation. | +| LR1121 direct carrier | Implemented: Sub-GHz control, authenticated private `ACCEPT`, repeated `READY_PROBE`, 2.4 GHz ten-shard train, timeout cleanup, and Sub-GHz restoration. | Two-Pager over-the-air timing, packet-loss, coexistence, regional channel, EIRP, and current-consumption validation. | +| SX1262 MQTT-only carrier | Implemented: VMP service/audio/inbox and UI runtime initialize for the SX1262 Pager; it records only while MT MQTT uplink is enabled, queues the same bounded VQ publication, and rejects direct RF and LXMF VMP paths. | Build and hardware test with MQTT enabled/disabled, publish failure/reconnect, and proof that no VMP frame reaches SX1262 LoRa TX. | +| MT MQTT | Implemented: isolated VQ topic, optional QoS 0 upload plan, local-only inbound termination, and no-MT-downlink contract test. | Broker interoperability and retained/duplicate/partition test on hardware. | +| RT/LXMF | Implemented: reserved AppData VQ port, private carrier, nested VMP encryption, local-only inbound termination, and isolation contract test. | LXMF path/identity lifecycle and delayed-delivery validation on two devices. | +| Persistent storage | Implemented: VMP voice uses the common SD attachment-store boundary, atomic snapshot/backup recovery, CRC validation, stable playback IDs, and the same delayed hydration/durable-incoming behavior as text chat. Bulk live state is PSRAM; only active PCM scratch is internal DMA RAM. | Hardware power-loss/SD-removal recovery and common text+attachment at-rest encryption policy validation. | + +Completed automated gates in this workspace are the Pager release build, clang-format 14, ESP stack-hygiene validation, and the MT MQTT/LXMF local-only ingress contract tests. RF and audio behavior still require the two-device hardware gates above before a production-default rollout. + +## 14. Open engineering decisions retained for field validation + +* The exact region-specific 2.4 GHz channel whitelist and maximum EIRP must come from board RF compliance validation, not a universal firmware constant. +* Profile `0x01` bitrate/deviation/bandwidth must be confirmed against the exact RadioLib LR1121 driver version and the Pager's matching network. A lower-rate fallback may be added behind the same profile registry. +* The initial durable retention count remains eight whole voice objects. Image retention, thumbnail size, location-history compaction, cross-kind quota accounting, and user-visible delete/export behavior must be specified through the common attachment-store policy before those message kinds are enabled. +* The current VMP bridge derives a bounded RAM-cached secret from existing verified static identities. A future dedicated VMP pairing flow may add key rotation or explicit RT fingerprint-verification UX, but must retain domain separation and must not reuse MT/MC channel or forwarding keys. diff --git a/modules/core_chat/include/chat/infra/voice/vmp_contact_secrets.h b/modules/core_chat/include/chat/infra/voice/vmp_contact_secrets.h new file mode 100644 index 00000000..75f578b9 --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_contact_secrets.h @@ -0,0 +1,76 @@ +/** + * @file vmp_contact_secrets.h + * @brief Fixed, separately-provisioned private-contact secret directory. + * + * This is intentionally separate from MT channel keys, MC routing secrets, + * and Reticulum transport/link state. A private VMP session may proceed only + * when its peer has an explicitly verified secret in this directory. + */ + +#pragma once + +#include "chat/infra/voice/vmp_private_crypto.h" + +#include +#include + +namespace chat::voice::vmp +{ + +inline constexpr std::size_t kMaxVerifiedVoiceContacts = 16U; + +class IVerifiedContactSecretProvider +{ + public: + virtual ~IVerifiedContactSecretProvider() = default; + + /** + * @brief Obtains the pinned static-contact secret for exactly one peer. + * + * The returned bytes are `K_contact`, not an MT/MC key and not an + * ephemeral VMP session key. The caller owns and must clear `out_secret` + * after deriving VMP session keys. + */ + virtual bool lookupVerifiedContactSecret( + uint32_t peer_id, + uint8_t out_secret[kPrivateKeySize]) const = 0; +}; + +/** + * @brief Bounded in-memory implementation used behind a protected key store. + * + * Firmware must keep an instance in owned static/runtime storage. The + * provisioning adapter supplies secrets only after user-visible contact-key + * verification; this type intentionally has no unauthenticated "learn" API. + */ +class FixedVerifiedContactSecretDirectory final + : public IVerifiedContactSecretProvider +{ + public: + bool upsertVerifiedContactSecret( + uint32_t peer_id, + const uint8_t secret[kPrivateKeySize]); + bool removeVerifiedContactSecret(uint32_t peer_id); + void clear(); + + bool lookupVerifiedContactSecret( + uint32_t peer_id, + uint8_t out_secret[kPrivateKeySize]) const override; + + [[nodiscard]] bool hasVerifiedContactSecret(uint32_t peer_id) const; + + [[nodiscard]] std::size_t size() const { return size_; } + + private: + struct Entry + { + uint32_t peer_id = 0U; + uint8_t secret[kPrivateKeySize] = {}; + bool occupied = false; + }; + + Entry entries_[kMaxVerifiedVoiceContacts] = {}; + std::size_t size_ = 0U; +}; + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_control_auth.h b/modules/core_chat/include/chat/infra/voice/vmp_control_auth.h new file mode 100644 index 00000000..75c3e37e --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_control_auth.h @@ -0,0 +1,48 @@ +/** + * @file vmp_control_auth.h + * @brief Integrity encoding/verification for VMP Sub-GHz control envelopes. + */ + +#pragma once + +#include "chat/infra/voice/vmp_private_crypto.h" + +#include +#include + +namespace chat::voice::vmp +{ + +/** + * @brief Serializes a private OFFER/ACCEPT/CANCEL with its control AEAD tag. + */ +bool encodePrivateControlFrame(const ControlFrame& frame, + const PrivateSessionKeys& keys, + PrivateFrameDirection direction, + uint8_t* out, + std::size_t* inout_len); + +/** + * @brief Decodes and authenticates a private control envelope before RF work. + */ +bool decodePrivateControlFrame(const uint8_t* data, + std::size_t len, + const PrivateSessionKeys& keys, + PrivateFrameDirection direction, + ControlFrame* out_frame); + +/** + * @brief Serializes an intentionally public ANNOUNCE/CANCEL with CRC-64/ECMA. + */ +bool encodePublicControlFrame(const ControlFrame& frame, + uint8_t* out, + std::size_t* inout_len); + +/** + * @brief Decodes a public control envelope and checks corruption only. + */ +bool decodePublicControlFrame(const uint8_t* data, + std::size_t len, + ControlFrame* out_frame); + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_control_ingress.h b/modules/core_chat/include/chat/infra/voice/vmp_control_ingress.h new file mode 100644 index 00000000..3fb9596e --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_control_ingress.h @@ -0,0 +1,62 @@ +/** + * @file vmp_control_ingress.h + * @brief Bounded Sub-GHz VMP control-envelope classifier. + * + * It is deliberately not a mesh adapter: once it positively recognizes a VMP + * v1 envelope, that packet must not be handed to MT, MC, or RT. Authentication + * and RF switching happen later in a dedicated VMP task through the sink. + */ + +#pragma once + +#include "chat/infra/voice/vmp_wire.h" + +#include +#include + +namespace chat::voice::vmp +{ + +struct ControlRxMetadata +{ + float rssi = 0.0f; + float snr = 0.0f; +}; + +/** + * @brief A non-blocking hand-off into VMP-owned task storage. + * + * The borrowed bytes remain valid only until enqueueControl() returns. An + * implementation must copy them into a bounded slot/queue if it accepts them; + * it must never call a mesh adapter or a radio TX function from this method. + */ +class IControlEnvelopeSink +{ + public: + virtual ~IControlEnvelopeSink() = default; + virtual bool enqueueControl(const uint8_t* data, + std::size_t size, + const ControlRxMetadata& metadata) = 0; +}; + +/** + * @brief Recognizes VMP v1 control envelopes before generic mesh parsing. + */ +class ControlIngress +{ + public: + void setSink(IControlEnvelopeSink* sink) { sink_ = sink; } + + /** + * @return true if this is a VMP v1-shaped packet and must be removed from + * the generic mesh path, including when the bounded sink is full. + */ + bool tryConsume(const uint8_t* data, + std::size_t size, + const ControlRxMetadata& metadata) const; + + private: + IControlEnvelopeSink* sink_ = nullptr; +}; + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_media_frames.h b/modules/core_chat/include/chat/infra/voice/vmp_media_frames.h new file mode 100644 index 00000000..e16cc709 --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_media_frames.h @@ -0,0 +1,90 @@ +/** + * @file vmp_media_frames.h + * @brief Fixed VMP v1 ten-frame media preparation and wire protection. + * + * A sender owns this bounded object for the lifetime of one voice send. It + * converts an already Codec2-encoded object into eight padded source shards + * plus two RS parity shards. It has no retry or forwarding capability. + */ + +#pragma once + +#include "chat/infra/voice/vmp_private_crypto.h" +#include "chat/infra/voice/vmp_rs_fec.h" + +#include +#include + +namespace chat::voice::vmp +{ + +inline constexpr std::size_t kPublicShardFrameSize = + kDataHeaderSize + kMaxShardPayloadSize + 4U; +inline constexpr std::size_t kPublicReadyFrameSize = kDataHeaderSize + 4U; +inline constexpr std::size_t kPrivateShardFrameSize = + kDataHeaderSize + kMaxShardPayloadSize + kPrivateDataAuthTagSize; + +/** + * @brief Builds public broadcast READY_PROBE/READY with CRC-32C protection. + * + * VMP broadcast sends only READY_PROBE; parsers recognize READY as well so a + * received response can be explicitly ignored rather than mistaken for media. + */ +bool buildPublicReadyFrame(const DataHeader& header, + uint8_t* out, + std::size_t* inout_len); + +/** @brief Parses a public zero-payload readiness frame and validates CRC-32C. */ +bool parsePublicReadyFrame(const uint8_t* data, + std::size_t len, + DataHeader* out_header); + +/** @brief Validates public broadcast data without claiming sender identity. */ +bool parsePublicShardFrame(const uint8_t* data, + std::size_t len, + DataHeader* out_header, + const uint8_t** out_shard); + +/** + * @brief Owns one padded source/FEC block prepared for the fixed ten-frame TX. + */ +class TransmitBlock +{ + public: + bool prepare(const uint8_t* encoded_media, std::size_t encoded_media_len); + + bool prepared() const { return prepared_; } + const MediaLayout& layout() const { return layout_; } + void clear(); + + /** + * @brief Builds a public broadcast frame: header + clear shard + CRC-32C. + */ + bool buildPublicShardFrame(uint64_t session_id, + uint8_t shard_index, + uint8_t* out, + std::size_t* inout_len) const; + + /** + * @brief Builds a private frame: header + ciphertext + full AEAD tag. + */ + bool buildPrivateShardFrame(const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + uint64_t session_id, + uint8_t shard_index, + uint8_t* out, + std::size_t* inout_len) const; + + const uint8_t* shard(uint8_t shard_index) const; + + private: + bool buildHeader(uint64_t session_id, + uint8_t shard_index, + DataHeader* out_header) const; + + MediaLayout layout_ = {}; + uint8_t shards_[kTotalShardsPerBlock][kMaxShardPayloadSize] = {}; + bool prepared_ = false; +}; + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_mqtt_transport.h b/modules/core_chat/include/chat/infra/voice/vmp_mqtt_transport.h new file mode 100644 index 00000000..819f276c --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_mqtt_transport.h @@ -0,0 +1,150 @@ +/** + * @file vmp_mqtt_transport.h + * @brief Bounded VMP object carrier for MQTT, isolated from MT/MC packets. + * + * This layer turns already-valid VMP control and media frames into small MQTT + * application payloads. It deliberately has no radio API and never produces a + * radio frame from an inbound MQTT payload. A platform bridge may publish the + * envelopes when MQTT uplink is enabled, or pass subscribed payloads to the + * receive transfer for local-only inbox storage. + */ + +#pragma once + +#include "chat/infra/voice/vmp_contact_secrets.h" +#include "chat/infra/voice/vmp_control_auth.h" +#include "chat/infra/voice/vmp_media_frames.h" +#include "chat/infra/voice/vmp_receive_block.h" + +#include +#include + +namespace chat::voice::vmp +{ + +inline constexpr std::size_t kMqttEnvelopePrefixSize = 4U; +inline constexpr std::size_t kMaxMqttEnvelopeSize = + kMqttEnvelopePrefixSize + kPrivateShardFrameSize; + +enum class MqttEnvelopeKind : uint8_t +{ + Control = 1U, + Shard = 2U, +}; + +struct MqttEnvelopeView +{ + MqttEnvelopeKind kind = MqttEnvelopeKind::Control; + const uint8_t* payload = nullptr; + std::size_t payload_len = 0U; +}; + +/** @brief Wraps one exact VMP control or media frame for an MQTT topic. */ +bool buildMqttEnvelope(MqttEnvelopeKind kind, + const uint8_t* payload, + std::size_t payload_len, + uint8_t* out, + std::size_t* inout_len); + +/** @brief Parses only a bounded VMP MQTT envelope; no side effects. */ +bool parseMqttEnvelope(const uint8_t* data, + std::size_t len, + MqttEnvelopeView* out_view); + +enum class MqttTransferResult : uint8_t +{ + Rejected = 0U, + Accepted = 1U, + Duplicate = 2U, + Complete = 3U, +}; + +/** + * @brief Fixed one-object publish plan: control then exactly ten media shards. + * + * A platform MQTT client drains this plan at its ordinary traffic budget. It + * has no retry/rebroadcast policy: a disconnected client merely leaves the + * bounded local plan pending until replaced or explicitly cleared. + */ +class MqttTransmitTransfer final +{ + public: + bool preparePrivate(const ControlFrame& control, + const uint8_t verified_contact_secret[kPrivateKeySize], + const uint8_t* encoded_media, + std::size_t encoded_media_len); + + bool prepareBroadcast(const ControlFrame& control, + const uint8_t* encoded_media, + std::size_t encoded_media_len); + + /** @brief Copies the current envelope without consuming it. */ + bool copyNextEnvelope(uint8_t* out, std::size_t* inout_len); + /** @brief Commits the envelope previously copied by copyNextEnvelope(). */ + bool commitNextEnvelope(); + /** @brief Compatibility convenience: copy and immediately commit. */ + bool nextEnvelope(uint8_t* out, std::size_t* inout_len); + bool hasNext() const { return prepared_ && next_index_ <= kTotalShardsPerBlock; } + const ControlFrame& control() const { return control_; } + void clear(); + + private: + bool prepareCommon(const ControlFrame& control, + const uint8_t* encoded_media, + std::size_t encoded_media_len); + + ControlFrame control_{}; + PrivateSessionKeys keys_{}; + TransmitBlock transmit_block_{}; + uint8_t control_wire_[kControlFrameSize] = {}; + uint8_t frame_wire_[kPrivateShardFrameSize] = {}; + uint8_t next_index_ = 0U; + bool private_mode_ = false; + bool prepared_ = false; +}; + +/** + * @brief Single bounded inbound MQTT VMP object, terminating at local storage. + * + * One object is accepted at a time. Private control is authenticated using a + * verified static contact secret and derives the MQTT-only data key schedule; + * broadcast remains explicitly unverified. No method here sends, relays, or + * exposes an accepted object to a radio stack. + */ +class MqttReceiveTransfer final +{ + public: + MqttTransferResult acceptEnvelope( + const uint8_t* envelope, + std::size_t envelope_len, + uint32_t self_node_id, + const IVerifiedContactSecretProvider& contacts); + + bool recover(uint8_t* out_media, + std::size_t out_capacity, + std::size_t* out_media_len); + const ControlFrame& control() const { return control_; } + bool active() const { return active_; } + bool complete() const { return complete_; } + void clear(); + + private: + MqttTransferResult acceptControl(const uint8_t* control_wire, + std::size_t control_len, + uint32_t self_node_id, + const IVerifiedContactSecretProvider& contacts); + MqttTransferResult acceptShard(const uint8_t* frame, std::size_t frame_len); + + ControlFrame candidate_control_{}; + ControlFrame control_{}; + DataHeader data_header_{}; + PrivateSessionKeys keys_{}; + ReceiveBlock receive_block_{}; + uint8_t contact_secret_[kPrivateKeySize] = {}; + uint8_t plaintext_[kMaxShardPayloadSize] = {}; + bool private_mode_ = false; + bool active_ = false; + bool complete_ = false; +}; + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_private_crypto.h b/modules/core_chat/include/chat/infra/voice/vmp_private_crypto.h new file mode 100644 index 00000000..07ca7226 --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_private_crypto.h @@ -0,0 +1,193 @@ +/** + * @file vmp_private_crypto.h + * @brief Private-session cryptography for Trail Mate VMP v1. + * + * The contact secret supplied here is derived from a separately verified and + * pinned X25519 contact identity. VMP never falls back to a channel key or + * an unauthenticated peer ID. Every voice transfer creates a new ephemeral + * X25519 key pair and destroys its private half after deriving these keys. + */ + +#pragma once + +#include "chat/infra/voice/vmp_wire.h" + +#include +#include + +namespace chat::voice::vmp +{ + +inline constexpr std::size_t kPrivateKeySize = 32; +inline constexpr std::size_t kPrivateFrameNonceSize = 12; + +/** + * Static identity family used only to domain-separate a derived VMP contact + * secret. This is not an on-air VMP field and does not reuse any MT/MC + * channel, forwarding, or application key. + */ +enum class ContactSecretIdentityFamily : uint8_t +{ + Meshtastic = 1U, + MeshCore = 2U, + Reticulum = 3U, +}; + +enum class PrivateFrameDirection : uint8_t +{ + SenderToReceiver = 1, + ReceiverToSender = 2, +}; + +struct EphemeralKeyPair +{ + uint8_t public_key[kEphemeralPublicKeySize] = {}; + uint8_t private_key[kPrivateKeySize] = {}; +}; + +struct PrivateSessionKeys +{ + uint8_t control_key[kPrivateKeySize] = {}; + uint8_t ready_key[kPrivateKeySize] = {}; + uint8_t data_key[kPrivateKeySize] = {}; + uint8_t mqtt_key[kPrivateKeySize] = {}; +}; + +/** + * @brief Creates a fresh X25519 ephemeral pair using the platform CSPRNG. + */ +bool generateEphemeralKeyPair(EphemeralKeyPair* out_pair); + +/** + * Turns a shared secret from an already verified static contact identity into + * VMP-only `K_contact`. Both node IDs are sorted before binding, so each peer + * obtains identical bytes while different protocol families remain separated. + * The input is immediately suitable only after the caller has verified the + * peer identity through its own contact-verification policy. + */ +bool deriveVmpContactSecret( + const uint8_t verified_identity_shared_secret[kPrivateKeySize], + ContactSecretIdentityFamily family, + uint32_t local_node_id, + uint32_t peer_node_id, + uint8_t out_contact_secret[kPrivateKeySize]); + +/** + * @brief Derives the static-contact control key before an OFFER is accepted. + * + * The receiver invokes this with a verified contact secret to authenticate an + * incoming OFFER before it allocates an ephemeral key or reserves 2.4 GHz. + */ +bool derivePrivateControlKey( + const uint8_t verified_contact_secret[kPrivateKeySize], + const uint8_t session_nonce[kSessionNonceSize], + uint64_t session_id, + uint8_t out_control_key[kPrivateKeySize]); + +/** + * @brief Derives VMP keys and securely clears `local_ephemeral_private`. + * + * `verified_contact_secret` must be the 32-byte static-contact X25519 shared + * secret created only after contact verification. A caller must abort a + * private session if this function returns false; plaintext must never be + * transmitted as a fallback. + */ +bool derivePrivateSessionKeys( + const uint8_t verified_contact_secret[kPrivateKeySize], + uint8_t local_ephemeral_private[kPrivateKeySize], + const uint8_t peer_ephemeral_public[kEphemeralPublicKeySize], + const uint8_t session_nonce[kSessionNonceSize], + uint64_t session_id, + PrivateSessionKeys* out_keys); + +/** + * @brief Derives the private VMP MQTT key schedule without a radio handshake. + * + * MQTT is an asynchronous store-and-forward carrier and therefore cannot use + * the receiver's per-transfer `ACCEPT` ephemeral key. This schedule remains + * end-to-end encrypted using only the already verified static contact secret, + * with independent control, data, and MQTT keys. It MUST be used only by the + * VMP MQTT carrier; radio sessions always use derivePrivateSessionKeys(). + */ +bool derivePrivateMqttSessionKeys( + const uint8_t verified_contact_secret[kPrivateKeySize], + const uint8_t session_nonce[kSessionNonceSize], + uint64_t session_id, + PrivateSessionKeys* out_keys); + +/** @brief Explicitly erases session keys when a VMP reservation ends. */ +void clearPrivateSessionKeys(PrivateSessionKeys* keys); + +/** + * @brief Computes the 16-byte AEAD tag for serialized private OFFER/ACCEPT. + * + * `authenticated_control` is the first 79 bytes of a control frame (all + * fields before its integrity trailer). The returned tag authenticates all + * of those bytes; it does not encrypt control metadata. + */ +bool tagPrivateControl( + const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + ControlType type, + PrivateFrameDirection direction, + const uint8_t* authenticated_control, + std::size_t authenticated_control_len, + uint8_t out_tag[kControlIntegrityTagSize]); + +/** @brief Constant-time verification counterpart to tagPrivateControl(). */ +bool verifyPrivateControlTag( + const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + ControlType type, + PrivateFrameDirection direction, + const uint8_t* authenticated_control, + std::size_t authenticated_control_len, + const uint8_t tag[kControlIntegrityTagSize]); + +/** + * @brief Authenticates a private READY_PROBE or READY frame on 2.4 GHz. + */ +bool tagPrivateReady(const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + PrivateFrameDirection direction, + const DataHeader& header, + uint8_t out_tag[kPrivateDataAuthTagSize]); + +bool verifyPrivateReadyTag(const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + PrivateFrameDirection direction, + const DataHeader& header, + const uint8_t tag[kPrivateDataAuthTagSize]); + +/** + * @brief Encrypts one private VMP source/parity shard using ChaCha20-Poly1305. + * + * The data header is serialized as AEAD associated data. Ciphertext has the + * same length as plaintext and the full 16-byte authentication tag is emitted + * separately, so every private 2.4 GHz voice frame is 192 bytes at most. + */ +bool sealPrivateShard(const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + PrivateFrameDirection direction, + const DataHeader& header, + const uint8_t* plaintext, + std::size_t plaintext_len, + uint8_t* out_ciphertext, + uint8_t out_tag[kPrivateDataAuthTagSize]); + +/** + * @brief Authenticates and decrypts a private VMP shard. + * + * On a tag failure `out_plaintext` is cleared. Callers MUST reject the frame + * and MUST NOT place the shard into the FEC slot if this returns false. + */ +bool openPrivateShard(const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + PrivateFrameDirection direction, + const DataHeader& header, + const uint8_t* ciphertext, + std::size_t ciphertext_len, + const uint8_t tag[kPrivateDataAuthTagSize], + uint8_t* out_plaintext); + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_receive_block.h b/modules/core_chat/include/chat/infra/voice/vmp_receive_block.h new file mode 100644 index 00000000..ebeadca0 --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_receive_block.h @@ -0,0 +1,72 @@ +/** + * @file vmp_receive_block.h + * @brief Caller-owned bounded VMP v1 media receive block. + * + * This object is intentionally a member/static-storage candidate, not a task + * stack local. It retains one fixed `(10,8)` FEC block and exposes no radio + * transmission operation, which makes an inbound voice message terminate in + * local storage rather than becoming a relay candidate. + */ + +#pragma once + +#include "chat/infra/voice/vmp_rs_fec.h" + +#include +#include + +namespace chat::voice::vmp +{ + +enum class ReceiveBlockResult : uint8_t +{ + Accepted = 1, + Duplicate = 2, + Invalid = 3, + Complete = 4, +}; + +class ReceiveBlock +{ + public: + /** @brief Clears prior state and binds this slot to one validated layout. */ + bool begin(const MediaLayout& layout); + + /** + * @brief Stores one already-authenticated 160-byte source or parity shard. + * + * Authentication/decryption belongs to the radio/session layer and MUST + * happen before this method. The block rejects duplicate indices and + * foreign/variable-size shards without changing its existing contents. + */ + ReceiveBlockResult accept(const DataHeader& header, + const uint8_t* shard, + std::size_t shard_len); + + /** + * @brief Attempts RS recovery after at least eight unique shards arrive. + * + * The reconstructed encoded media is copied into caller-owned output. It + * is exactly the length announced by the validated control frame. + */ + bool recover(uint8_t* out_media, + std::size_t out_capacity, + std::size_t* out_media_len); + + void clear(); + + bool active() const { return active_; } + bool recovered() const { return recovered_; } + std::size_t receivedShardCount() const { return received_shard_count_; } + const MediaLayout& layout() const { return layout_; } + + private: + MediaLayout layout_ = {}; + uint8_t shards_[kTotalShardsPerBlock][kMaxShardPayloadSize] = {}; + bool present_[kTotalShardsPerBlock] = {}; + std::size_t received_shard_count_ = 0; + bool active_ = false; + bool recovered_ = false; +}; + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_rs_fec.h b/modules/core_chat/include/chat/infra/voice/vmp_rs_fec.h new file mode 100644 index 00000000..a1fd7dc1 --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_rs_fec.h @@ -0,0 +1,46 @@ +/** + * @file vmp_rs_fec.h + * @brief Fixed-size Reed-Solomon (10,8) erasure coding for VMP media. + * + * VMP has exactly eight 160-byte source shards and two parity shards. This + * narrow API deliberately does not expose a generic allocator-backed FEC + * codec: all storage belongs to the caller's bounded session slot and the + * decoder can recover any two erased shards without an ARQ exchange. + */ + +#pragma once + +#include "chat/infra/voice/vmp_wire.h" + +#include +#include + +namespace chat::voice::vmp +{ + +/** + * @brief Generates parity shards 8 and 9 from the eight source shards. + * + * Every input and output slot is exactly @p shard_size bytes. Slots must not + * overlap. The first eight slots must be present and contain the padded + * source object before this function is called. + */ +bool encodeRs10_8(const uint8_t* const source_shards[kSourceShardsPerBlock], + std::size_t shard_size, + uint8_t* out_parity0, + uint8_t* out_parity1); + +/** + * @brief Recovers missing shards in one VMP `(10,8)` media block. + * + * `shards` contains ten caller-owned, writable slots of `shard_size` bytes; + * `present` says which slots arrived with valid authentication/CRC. The + * decoder accepts at least eight present slots and reconstructs up to two + * missing slots in place. On success, all ten `present` entries become true. + * The function performs no dynamic allocation and has no radio side effects. + */ +bool recoverRs10_8(uint8_t* shards[kTotalShardsPerBlock], + bool present[kTotalShardsPerBlock], + std::size_t shard_size); + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_session_state_machine.h b/modules/core_chat/include/chat/infra/voice/vmp_session_state_machine.h new file mode 100644 index 00000000..98775e2c --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_session_state_machine.h @@ -0,0 +1,123 @@ +/** + * @file vmp_session_state_machine.h + * @brief Side-effect-free VMP v1 private/broadcast radio session state machine. + * + * Callers dispatch only already-authenticated control/data events. The + * returned actions are executed by the application/radio adapter; this core + * never obtains an RF handle and therefore cannot accidentally forward a + * received voice message. + */ + +#pragma once + +#include "chat/infra/voice/vmp_wire.h" + +#include +#include + +namespace chat::voice::vmp +{ + +enum class SessionRole : uint8_t +{ + Sender = 1, + Receiver = 2, +}; + +enum class SessionState : uint8_t +{ + Idle = 0, + AwaitingSubGhzAccept, + Awaiting2GhzReady, + AwaitingBroadcastDataWindow, + SendingVoiceMedia, + Awaiting2GhzProbe, + AwaitingVoiceMedia, + ReceivingVoiceMedia, + Completed, + Failed, +}; + +enum class SessionEvent : uint8_t +{ + SubGhzAcceptAuthenticated = 1, + BroadcastDataWindowReady = 2, + TwoGhzReadyProbeAuthenticated = 3, + TwoGhzReadyAuthenticated = 4, + TwoGhzVoiceShardAuthenticated = 5, + VoiceDataTrainComplete = 6, + FecBlockRecovered = 7, + ControlDeadlineExpired = 8, + ReadyDeadlineExpired = 9, + MediaDeadlineExpired = 10, + MediaDeadlineExpiredWithRecoverableData = 11, + RadioFailure = 12, + LocalStorageFailure = 13, +}; + +enum class SessionAction : uint8_t +{ + SendSubGhzOffer = 1, + SendSubGhzAnnounce = 2, + SendSubGhzAccept = 3, + SwitchTo2GhzTx = 4, + SwitchTo2GhzRx = 5, + Send2GhzReadyProbeTrain = 6, + Send2GhzReady = 7, + BeginVoiceMediaTx = 8, + CommitCompleteIncomingVoice = 9, + CommitPartialIncomingVoice = 10, + RestoreSubGhzRx = 11, +}; + +enum class SessionFailure : uint8_t +{ + None = 0, + UnexpectedEvent, + AcceptTimeout, + ReadyTimeout, + NoVoiceMedia, + Radio, + Storage, +}; + +struct SessionTransition +{ + static constexpr std::size_t kMaxActions = 3; + + bool accepted = false; + SessionState previous = SessionState::Idle; + SessionState current = SessionState::Idle; + SessionFailure failure = SessionFailure::None; + SessionAction actions[kMaxActions] = {}; + std::size_t action_count = 0; + + bool hasAction(SessionAction action) const; +}; + +/** + * @brief Models a single VMP session from one local device's point of view. + */ +class SessionStateMachine +{ + public: + SessionTransition startSender(DeliveryMode mode); + SessionTransition startReceiver(DeliveryMode mode); + SessionTransition dispatch(SessionEvent event); + + SessionState state() const { return state_; } + SessionRole role() const { return role_; } + DeliveryMode mode() const { return mode_; } + bool active() const; + + private: + SessionState state_ = SessionState::Idle; + SessionRole role_ = SessionRole::Sender; + DeliveryMode mode_ = DeliveryMode::Private; + + SessionTransition transition(SessionState next, + SessionFailure failure = SessionFailure::None); + static void addAction(SessionTransition& transition, SessionAction action); +}; + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h b/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h new file mode 100644 index 00000000..9be39415 --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h @@ -0,0 +1,118 @@ +/** + * @file vmp_voice_inbox.h + * @brief Fixed local-only VMP voice-object inbox. + * + * This deliberately stores already-validated encoded media and has no mesh, + * radio, MQTT, or forwarding API. A receive-side VMP implementation can only + * persist, present, or play an object through this type; it cannot re-originate + * it onto any air interface. + */ + +#pragma once + +#include "chat/infra/voice/vmp_wire.h" + +#include +#include + +namespace chat::voice::vmp +{ + +inline constexpr std::size_t kVoiceInboxCapacity = 8U; + +struct VoiceMessageMetadata +{ + uint64_t local_id = 0U; + uint32_t sender_id = 0U; + uint32_t target_id = 0U; + uint64_t session_id = 0U; + uint32_t object_fingerprint = 0U; + uint32_t received_at_seconds = 0U; + uint16_t encoded_media_len = 0U; + Codec codec = Codec::Codec2_1300; + DeliveryMode mode = DeliveryMode::Private; + bool source_unverified = false; + bool complete = false; +}; + +struct VoiceMessageView +{ + VoiceMessageMetadata metadata{}; + const uint8_t* encoded_media = nullptr; +}; + +enum class VoiceInboxStoreResult : uint8_t +{ + Stored = 1, + Duplicate = 2, + Invalid = 3, +}; + +/** + * @brief Bounded, replacement-on-oldest local VMP inbox. + * + * ESP callers must own this as static/runtime storage because each slot holds + * one encoded voice object. Replacing an old slot securely clears its encoded + * media first. Caller-owned durable storage may mirror accepted entries. + */ +class VoiceMessageInbox final +{ + public: + VoiceInboxStoreResult store(const ControlFrame& control, + const uint8_t* encoded_media, + std::size_t encoded_media_len, + bool complete, + uint32_t received_at_seconds, + uint64_t* out_local_id = nullptr); + + bool get(uint64_t local_id, VoiceMessageView* out_view) const; + + /** + * @brief Copies metadata newest first without exposing encoded media. + * + * This is intentionally a presentation-only operation. The returned + * metadata cannot be used to forward, serialize, or recreate a VMP radio + * frame. + */ + std::size_t listMetadata(VoiceMessageMetadata* out_metadata, + std::size_t capacity) const; + + /** + * Restore one authenticated, completed local object from durable local + * storage. This is intentionally not a wire ingress API: callers must + * have already validated the record checksum and the object must have + * originated from a previously accepted inbox entry. + * + * The original local ID is retained so a chat projection's playback + * target survives a reboot. Restored records participate in the ordinary + * duplicate check and bounded oldest-entry replacement policy. + */ + bool restore(const VoiceMessageMetadata& metadata, + const uint8_t* encoded_media, + std::size_t encoded_media_len); + + bool erase(uint64_t local_id); + void clear(); + + [[nodiscard]] std::size_t size() const { return size_; } + + private: + struct Slot + { + VoiceMessageMetadata metadata{}; + uint8_t encoded_media[kMaxEncodedMediaSize] = {}; + uint64_t insertion_sequence = 0U; + bool occupied = false; + }; + + bool isDuplicate(const ControlFrame& control) const; + Slot* selectDestination(); + static void clearSlot(Slot* slot); + + Slot slots_[kVoiceInboxCapacity] = {}; + std::size_t size_ = 0U; + uint64_t next_local_id_ = 1U; + uint64_t next_insertion_sequence_ = 1U; +}; + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/include/chat/infra/voice/vmp_wire.h b/modules/core_chat/include/chat/infra/voice/vmp_wire.h new file mode 100644 index 00000000..b85325dd --- /dev/null +++ b/modules/core_chat/include/chat/infra/voice/vmp_wire.h @@ -0,0 +1,179 @@ +/** + * @file vmp_wire.h + * @brief Trail Mate Voice Message Protocol (VMP) v1 binary framing. + * + * VMP is an application-owned, one-hop voice-message protocol. Its frames + * deliberately do not share Meshtastic, MeshCore, or Reticulum wire formats. + * Authentication and encryption are applied by the VMP session layer; this + * file only gives that layer strict, bounded binary framing. + */ + +#pragma once + +#include +#include + +namespace chat::voice::vmp +{ + +inline constexpr uint8_t kVersion = 1; +inline constexpr std::size_t kControlFrameSize = 95; +inline constexpr std::size_t kControlIntegrityTagSize = 16; +inline constexpr std::size_t kSessionNonceSize = 12; +inline constexpr std::size_t kEphemeralPublicKeySize = 32; +inline constexpr std::size_t kDataHeaderSize = 16; +inline constexpr std::size_t kPrivateDataAuthTagSize = 16; +inline constexpr std::size_t kMaxShardPayloadSize = 160; +inline constexpr std::size_t kSourceShardsPerBlock = 8; +inline constexpr std::size_t kTotalShardsPerBlock = 10; +inline constexpr std::size_t kMaxBlocks = 1; +inline constexpr std::size_t kMaxEncodedMediaSize = + kSourceShardsPerBlock * kMaxShardPayloadSize; +inline constexpr uint8_t kFecLayoutRs10_8 = 0xA8; +inline constexpr uint32_t kBroadcastTargetId = 0xFFFFFFFFU; + +enum class ControlType : uint8_t +{ + Offer = 1, + Accept = 2, + Announce = 3, + Cancel = 4, +}; + +enum class DeliveryMode : uint8_t +{ + Private = 1, + Broadcast = 2, +}; + +enum class Codec : uint8_t +{ + Codec2_1300 = 1, + Codec2_1200 = 2, +}; + +enum class DataType : uint8_t +{ + ReadyProbe = 1, + Ready = 2, + Shard = 3, +}; + +enum ControlFlag : uint8_t +{ + ControlFlagPrivate = 1U << 0U, + ControlFlagBroadcast = 1U << 1U, + ControlFlagPublicBroadcast = 1U << 2U, + ControlFlagReticulumCarrierHint = 1U << 3U, +}; + +enum DataFlag : uint8_t +{ + DataFlagFinalBlock = 1U << 0U, + DataFlagPartialSource = 1U << 1U, +}; + +struct ControlFrame +{ + ControlType type = ControlType::Offer; + uint8_t flags = 0; + uint8_t key_or_profile_id = 0; + uint32_t sender_id = 0; + uint32_t target_id = 0; + uint64_t session_id = 0; + uint8_t session_nonce[kSessionNonceSize] = {}; + uint8_t phy_profile_id = 0; + uint8_t channel_index = 0; + uint16_t encoded_media_len = 0; + Codec codec = Codec::Codec2_1300; + uint8_t fec_layout = kFecLayoutRs10_8; + uint8_t total_blocks = 0; + uint16_t data_start_delay_ms = 0; + uint32_t object_fingerprint = 0; + // Private OFFER/ACCEPT only: ephemeral X25519 public key. Public + // broadcast must carry all zeroes here and performs no key exchange. + uint8_t ephemeral_public_key[kEphemeralPublicKeySize] = {}; + // Private frames carry a truncated HMAC here; public broadcast frames use + // an unkeyed corruption-detection value and provide no origin proof. + uint8_t integrity_tag[kControlIntegrityTagSize] = {}; +}; + +struct DataHeader +{ + DataType type = DataType::Shard; + uint64_t session_id = 0; + uint8_t block_index = 0; + uint8_t shard_index = 0; + uint8_t payload_len = 0; + uint8_t flags = 0; +}; + +struct MediaLayout +{ + uint16_t encoded_media_len = 0; + uint8_t source_shard_count = 0; + uint8_t block_count = 0; + uint8_t data_frame_count = 0; +}; + +/** + * @brief Returns the delivery mode encoded in a valid VMP control frame. + */ +bool deliveryModeFor(const ControlFrame& frame, DeliveryMode* out_mode); + +/** + * @brief Validates VMP v1 semantic constraints before authentication work. + */ +bool isValidControlFrame(const ControlFrame& frame); + +/** + * @brief Encodes one fixed-size Sub-GHz control frame. + * + * Private OFFER/ACCEPT frames include an ephemeral X25519 public key so the + * caller can establish a forward-secret media key without extra packets. + */ +bool encodeControlFrame(const ControlFrame& frame, + uint8_t* out, + std::size_t* inout_len); + +/** + * @brief Decodes and validates one fixed-size Sub-GHz control frame. + */ +bool decodeControlFrame(const uint8_t* data, + std::size_t len, + ControlFrame* out_frame); + +/** + * @brief Validates one unauthenticated 2.4 GHz data-frame header. + */ +bool isValidDataHeader(const DataHeader& header); + +/** + * @brief Encodes the fixed authenticated-data header for a VMP data frame. + */ +bool encodeDataHeader(const DataHeader& header, + uint8_t* out, + std::size_t* inout_len); + +/** + * @brief Decodes and validates the fixed authenticated-data header. + */ +bool decodeDataHeader(const uint8_t* data, + std::size_t len, + DataHeader* out_header); + +/** + * @brief Plans fixed V1 source/FEC blocks for one encoded Codec2 object. + */ +bool planMediaLayout(uint16_t encoded_media_len, MediaLayout* out_layout); + +/** + * @brief Returns the source-shard payload bytes before zero padding for one slot. + * + * A zero return means the slot is padding or is not a source-shard slot. + */ +std::size_t sourceShardPayloadSize(const MediaLayout& layout, + uint8_t block_index, + uint8_t shard_index); + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_contact_secrets.cpp b/modules/core_chat/src/infra/voice/vmp_contact_secrets.cpp new file mode 100644 index 00000000..5a58ef57 --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_contact_secrets.cpp @@ -0,0 +1,152 @@ +/** + * @file vmp_contact_secrets.cpp + * @brief Fixed, separately-provisioned private-contact secret directory. + */ + +#include "chat/infra/voice/vmp_contact_secrets.h" + +#include + +namespace chat::voice::vmp +{ +namespace +{ + +bool validPeerId(uint32_t peer_id) +{ + return peer_id != 0U && peer_id != kBroadcastTargetId; +} + +bool allZero(const uint8_t* bytes, std::size_t size) +{ + if (!bytes) + { + return true; + } + uint8_t value = 0U; + for (std::size_t index = 0U; index < size; ++index) + { + value |= bytes[index]; + } + return value == 0U; +} + +void secureClear(uint8_t* bytes, std::size_t size) +{ + volatile uint8_t* cursor = bytes; + while (cursor && size-- != 0U) + { + *cursor++ = 0U; + } +} + +} // namespace + +bool FixedVerifiedContactSecretDirectory::upsertVerifiedContactSecret( + uint32_t peer_id, + const uint8_t secret[kPrivateKeySize]) +{ + if (!validPeerId(peer_id) || !secret || allZero(secret, kPrivateKeySize)) + { + return false; + } + + Entry* destination = nullptr; + for (Entry& entry : entries_) + { + if (entry.occupied && entry.peer_id == peer_id) + { + destination = &entry; + break; + } + if (!destination && !entry.occupied) + { + destination = &entry; + } + } + if (!destination) + { + return false; + } + + const bool was_occupied = destination->occupied; + destination->peer_id = peer_id; + std::memcpy(destination->secret, secret, sizeof(destination->secret)); + destination->occupied = true; + if (!was_occupied) + { + ++size_; + } + return true; +} + +bool FixedVerifiedContactSecretDirectory::removeVerifiedContactSecret( + uint32_t peer_id) +{ + if (!validPeerId(peer_id)) + { + return false; + } + for (Entry& entry : entries_) + { + if (entry.occupied && entry.peer_id == peer_id) + { + secureClear(entry.secret, sizeof(entry.secret)); + entry.peer_id = 0U; + entry.occupied = false; + --size_; + return true; + } + } + return false; +} + +void FixedVerifiedContactSecretDirectory::clear() +{ + for (Entry& entry : entries_) + { + secureClear(entry.secret, sizeof(entry.secret)); + entry.peer_id = 0U; + entry.occupied = false; + } + size_ = 0U; +} + +bool FixedVerifiedContactSecretDirectory::lookupVerifiedContactSecret( + uint32_t peer_id, + uint8_t out_secret[kPrivateKeySize]) const +{ + if (!validPeerId(peer_id) || !out_secret) + { + return false; + } + for (const Entry& entry : entries_) + { + if (entry.occupied && entry.peer_id == peer_id) + { + std::memcpy(out_secret, entry.secret, sizeof(entry.secret)); + return true; + } + } + secureClear(out_secret, kPrivateKeySize); + return false; +} + +bool FixedVerifiedContactSecretDirectory::hasVerifiedContactSecret( + uint32_t peer_id) const +{ + if (!validPeerId(peer_id)) + { + return false; + } + for (const Entry& entry : entries_) + { + if (entry.occupied && entry.peer_id == peer_id) + { + return true; + } + } + return false; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_control_auth.cpp b/modules/core_chat/src/infra/voice/vmp_control_auth.cpp new file mode 100644 index 00000000..d3742b16 --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_control_auth.cpp @@ -0,0 +1,179 @@ +/** + * @file vmp_control_auth.cpp + * @brief Integrity encoding/verification for VMP Sub-GHz control envelopes. + */ + +#include "chat/infra/voice/vmp_control_auth.h" + +#include + +namespace chat::voice::vmp +{ +namespace +{ + +constexpr std::size_t kAuthenticatedControlBytes = + kControlFrameSize - kControlIntegrityTagSize; + +uint64_t crc64Ecma(const uint8_t* data, std::size_t len) +{ + if (!data && len != 0U) + { + return 0U; + } + uint64_t crc = 0U; + for (std::size_t index = 0; index < len; ++index) + { + crc ^= static_cast(data[index]) << 56U; + for (uint8_t bit = 0; bit < 8U; ++bit) + { + crc = (crc & (1ULL << 63U)) != 0U + ? (crc << 1U) ^ 0x42F0E1EBA9EA3693ULL + : crc << 1U; + } + } + return crc; +} + +void writeU64(uint64_t value, uint8_t out[8]) +{ + for (std::size_t index = 0; index < sizeof(value); ++index) + { + out[index] = static_cast( + value >> ((sizeof(value) - 1U - index) * 8U)); + } +} + +uint64_t readU64(const uint8_t data[8]) +{ + uint64_t value = 0U; + for (std::size_t index = 0; index < sizeof(value); ++index) + { + value = (value << 8U) | data[index]; + } + return value; +} + +bool allZero(const uint8_t* data, std::size_t len) +{ + if (!data) + { + return false; + } + uint8_t aggregate = 0U; + for (std::size_t index = 0; index < len; ++index) + { + aggregate |= data[index]; + } + return aggregate == 0U; +} + +bool isPrivate(const ControlFrame& frame) +{ + DeliveryMode mode = DeliveryMode::Broadcast; + return deliveryModeFor(frame, &mode) && mode == DeliveryMode::Private; +} + +bool isPublicBroadcast(const ControlFrame& frame) +{ + DeliveryMode mode = DeliveryMode::Private; + return deliveryModeFor(frame, &mode) && mode == DeliveryMode::Broadcast; +} + +} // namespace + +bool encodePrivateControlFrame(const ControlFrame& frame, + const PrivateSessionKeys& keys, + PrivateFrameDirection direction, + uint8_t* out, + std::size_t* inout_len) +{ + if (!out || !inout_len || !isPrivate(frame)) + { + return false; + } + ControlFrame unsigned_frame = frame; + std::memset(unsigned_frame.integrity_tag, 0, sizeof(unsigned_frame.integrity_tag)); + if (!encodeControlFrame(unsigned_frame, out, inout_len)) + { + return false; + } + return tagPrivateControl(keys, + unsigned_frame.session_nonce, + unsigned_frame.type, + direction, + out, + kAuthenticatedControlBytes, + out + kAuthenticatedControlBytes); +} + +bool decodePrivateControlFrame(const uint8_t* data, + std::size_t len, + const PrivateSessionKeys& keys, + PrivateFrameDirection direction, + ControlFrame* out_frame) +{ + if (!data || !out_frame || len != kControlFrameSize) + { + return false; + } + ControlFrame frame{}; + if (!decodeControlFrame(data, len, &frame) || !isPrivate(frame) || + !verifyPrivateControlTag(keys, + frame.session_nonce, + frame.type, + direction, + data, + kAuthenticatedControlBytes, + data + kAuthenticatedControlBytes)) + { + return false; + } + *out_frame = frame; + return true; +} + +bool encodePublicControlFrame(const ControlFrame& frame, + uint8_t* out, + std::size_t* inout_len) +{ + if (!out || !inout_len || !isPublicBroadcast(frame)) + { + return false; + } + ControlFrame unchecked_frame = frame; + std::memset(unchecked_frame.integrity_tag, 0, sizeof(unchecked_frame.integrity_tag)); + if (!encodeControlFrame(unchecked_frame, out, inout_len)) + { + return false; + } + writeU64(crc64Ecma(out, kAuthenticatedControlBytes), + out + kAuthenticatedControlBytes); + std::memset(out + kAuthenticatedControlBytes + sizeof(uint64_t), + 0, + sizeof(uint64_t)); + return true; +} + +bool decodePublicControlFrame(const uint8_t* data, + std::size_t len, + ControlFrame* out_frame) +{ + if (!data || !out_frame || len != kControlFrameSize || + !allZero(data + kAuthenticatedControlBytes + sizeof(uint64_t), + sizeof(uint64_t)) || + crc64Ecma(data, kAuthenticatedControlBytes) != + readU64(data + kAuthenticatedControlBytes)) + { + return false; + } + ControlFrame frame{}; + if (!decodeControlFrame(data, len, &frame) || !isPublicBroadcast(frame)) + { + return false; + } + *out_frame = frame; + return true; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_control_ingress.cpp b/modules/core_chat/src/infra/voice/vmp_control_ingress.cpp new file mode 100644 index 00000000..85c62624 --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_control_ingress.cpp @@ -0,0 +1,31 @@ +/** + * @file vmp_control_ingress.cpp + * @brief Bounded Sub-GHz VMP control-envelope classifier. + */ + +#include "chat/infra/voice/vmp_control_ingress.h" + +namespace chat::voice::vmp +{ + +bool ControlIngress::tryConsume(const uint8_t* data, + std::size_t size, + const ControlRxMetadata& metadata) const +{ + // Classify only a complete VMP v1-sized envelope. This keeps normal mesh + // traffic byte-for-byte unchanged while malformed VMP candidates are + // dropped locally rather than being reinterpreted as MT/MC/RT payloads. + if (!data || size != kControlFrameSize || data[0] != static_cast('V') || + data[1] != static_cast('M') || data[2] != kVersion) + { + return false; + } + + if (sink_) + { + (void)sink_->enqueueControl(data, size, metadata); + } + return true; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_media_frames.cpp b/modules/core_chat/src/infra/voice/vmp_media_frames.cpp new file mode 100644 index 00000000..44844bb1 --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_media_frames.cpp @@ -0,0 +1,265 @@ +/** + * @file vmp_media_frames.cpp + * @brief Fixed VMP v1 ten-frame media preparation and wire protection. + */ + +#include "chat/infra/voice/vmp_media_frames.h" + +#include + +namespace chat::voice::vmp +{ +namespace +{ + +uint32_t crc32c(const uint8_t* data, std::size_t len) +{ + if (!data && len != 0U) + { + return 0U; + } + uint32_t value = 0xFFFFFFFFU; + for (std::size_t index = 0; index < len; ++index) + { + value ^= data[index]; + for (uint8_t bit = 0; bit < 8U; ++bit) + { + const uint32_t mask = static_cast( + -static_cast(value & 1U)); + value = (value >> 1U) ^ (0x82F63B78U & mask); + } + } + return ~value; +} + +void writeU32(uint32_t value, uint8_t out[4]) +{ + out[0] = static_cast(value >> 24U); + out[1] = static_cast(value >> 16U); + out[2] = static_cast(value >> 8U); + out[3] = static_cast(value); +} + +uint32_t readU32(const uint8_t data[4]) +{ + return (static_cast(data[0]) << 24U) | + (static_cast(data[1]) << 16U) | + (static_cast(data[2]) << 8U) | + static_cast(data[3]); +} + +} // namespace + +bool buildPublicReadyFrame(const DataHeader& header, + uint8_t* out, + std::size_t* inout_len) +{ + if (!out || !inout_len || + (header.type != DataType::ReadyProbe && header.type != DataType::Ready) || + !isValidDataHeader(header)) + { + return false; + } + if (*inout_len < kPublicReadyFrameSize) + { + *inout_len = kPublicReadyFrameSize; + return false; + } + std::size_t header_len = kDataHeaderSize; + if (!encodeDataHeader(header, out, &header_len)) + { + return false; + } + writeU32(crc32c(out, header_len), out + header_len); + *inout_len = kPublicReadyFrameSize; + return true; +} + +bool parsePublicReadyFrame(const uint8_t* data, + std::size_t len, + DataHeader* out_header) +{ + if (!data || !out_header || len != kPublicReadyFrameSize || + crc32c(data, kDataHeaderSize) != readU32(data + kDataHeaderSize)) + { + return false; + } + DataHeader header{}; + if (!decodeDataHeader(data, kDataHeaderSize, &header) || + (header.type != DataType::ReadyProbe && header.type != DataType::Ready)) + { + return false; + } + *out_header = header; + return true; +} + +bool parsePublicShardFrame(const uint8_t* data, + std::size_t len, + DataHeader* out_header, + const uint8_t** out_shard) +{ + if (!data || !out_header || !out_shard || len != kPublicShardFrameSize) + { + return false; + } + DataHeader header{}; + if (!decodeDataHeader(data, kDataHeaderSize, &header) || + header.type != DataType::Shard || + header.payload_len != kMaxShardPayloadSize) + { + return false; + } + const uint32_t expected_crc = crc32c(data, kDataHeaderSize + kMaxShardPayloadSize); + if (expected_crc != readU32(data + kDataHeaderSize + kMaxShardPayloadSize)) + { + return false; + } + *out_header = header; + *out_shard = data + kDataHeaderSize; + return true; +} + +bool TransmitBlock::prepare(const uint8_t* encoded_media, + std::size_t encoded_media_len) +{ + clear(); + if (!encoded_media || encoded_media_len == 0U || + encoded_media_len > kMaxEncodedMediaSize) + { + return false; + } + MediaLayout planned{}; + if (!planMediaLayout(static_cast(encoded_media_len), &planned)) + { + return false; + } + + std::memcpy(shards_, encoded_media, encoded_media_len); + const uint8_t* source_shards[kSourceShardsPerBlock] = {}; + for (std::size_t index = 0; index < kSourceShardsPerBlock; ++index) + { + source_shards[index] = shards_[index]; + } + if (!encodeRs10_8(source_shards, + kMaxShardPayloadSize, + shards_[kSourceShardsPerBlock], + shards_[kSourceShardsPerBlock + 1U])) + { + clear(); + return false; + } + + layout_ = planned; + prepared_ = true; + return true; +} + +void TransmitBlock::clear() +{ + layout_ = {}; + std::memset(shards_, 0, sizeof(shards_)); + prepared_ = false; +} + +const uint8_t* TransmitBlock::shard(uint8_t shard_index) const +{ + return prepared_ && shard_index < kTotalShardsPerBlock + ? shards_[shard_index] + : nullptr; +} + +bool TransmitBlock::buildHeader(uint64_t session_id, + uint8_t shard_index, + DataHeader* out_header) const +{ + if (!prepared_ || !out_header || session_id == 0U || + shard_index >= kTotalShardsPerBlock) + { + return false; + } + DataHeader header{}; + header.type = DataType::Shard; + header.session_id = session_id; + header.block_index = 0U; + header.shard_index = shard_index; + header.payload_len = kMaxShardPayloadSize; + header.flags = DataFlagFinalBlock; + if (shard_index < layout_.source_shard_count && + sourceShardPayloadSize(layout_, 0U, shard_index) < kMaxShardPayloadSize) + { + header.flags |= DataFlagPartialSource; + } + if (!isValidDataHeader(header)) + { + return false; + } + *out_header = header; + return true; +} + +bool TransmitBlock::buildPublicShardFrame(uint64_t session_id, + uint8_t shard_index, + uint8_t* out, + std::size_t* inout_len) const +{ + if (!out || !inout_len) + { + return false; + } + if (*inout_len < kPublicShardFrameSize) + { + *inout_len = kPublicShardFrameSize; + return false; + } + DataHeader header{}; + std::size_t header_len = kDataHeaderSize; + if (!buildHeader(session_id, shard_index, &header) || + !encodeDataHeader(header, out, &header_len)) + { + return false; + } + std::memcpy(out + header_len, shards_[shard_index], kMaxShardPayloadSize); + writeU32(crc32c(out, header_len + kMaxShardPayloadSize), + out + header_len + kMaxShardPayloadSize); + *inout_len = kPublicShardFrameSize; + return true; +} + +bool TransmitBlock::buildPrivateShardFrame( + const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + uint64_t session_id, + uint8_t shard_index, + uint8_t* out, + std::size_t* inout_len) const +{ + if (!session_nonce || !out || !inout_len) + { + return false; + } + if (*inout_len < kPrivateShardFrameSize) + { + *inout_len = kPrivateShardFrameSize; + return false; + } + DataHeader header{}; + std::size_t header_len = kDataHeaderSize; + if (!buildHeader(session_id, shard_index, &header) || + !encodeDataHeader(header, out, &header_len) || + !sealPrivateShard(keys, + session_nonce, + PrivateFrameDirection::SenderToReceiver, + header, + shards_[shard_index], + kMaxShardPayloadSize, + out + header_len, + out + header_len + kMaxShardPayloadSize)) + { + return false; + } + *inout_len = kPrivateShardFrameSize; + return true; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_mqtt_transport.cpp b/modules/core_chat/src/infra/voice/vmp_mqtt_transport.cpp new file mode 100644 index 00000000..2e9e5e7d --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_mqtt_transport.cpp @@ -0,0 +1,390 @@ +/** + * @file vmp_mqtt_transport.cpp + * @brief Bounded VMP object carrier for MQTT. + */ + +#include "chat/infra/voice/vmp_mqtt_transport.h" + +#include + +namespace chat::voice::vmp +{ +namespace +{ + +constexpr uint8_t kEnvelopeMagic0 = 'V'; +constexpr uint8_t kEnvelopeMagic1 = 'Q'; +constexpr uint8_t kEnvelopeVersion = 1U; + +void secureClear(uint8_t* bytes, std::size_t size) +{ + volatile uint8_t* cursor = bytes; + while (cursor && size-- != 0U) + { + *cursor++ = 0U; + } +} + +bool isKnownEnvelopeKind(MqttEnvelopeKind kind) +{ + return kind == MqttEnvelopeKind::Control || kind == MqttEnvelopeKind::Shard; +} + +bool isPrivateOffer(const ControlFrame& control) +{ + DeliveryMode mode = DeliveryMode::Private; + return control.type == ControlType::Offer && deliveryModeFor(control, &mode) && + mode == DeliveryMode::Private; +} + +bool isPublicAnnounce(const ControlFrame& control) +{ + DeliveryMode mode = DeliveryMode::Private; + return control.type == ControlType::Announce && deliveryModeFor(control, &mode) && + mode == DeliveryMode::Broadcast; +} + +} // namespace + +bool buildMqttEnvelope(MqttEnvelopeKind kind, + const uint8_t* payload, + std::size_t payload_len, + uint8_t* out, + std::size_t* inout_len) +{ + if (!payload || !out || !inout_len || !isKnownEnvelopeKind(kind) || + payload_len == 0U || payload_len > kPrivateShardFrameSize) + { + return false; + } + const std::size_t needed = kMqttEnvelopePrefixSize + payload_len; + if (*inout_len < needed) + { + *inout_len = needed; + return false; + } + out[0] = kEnvelopeMagic0; + out[1] = kEnvelopeMagic1; + out[2] = kEnvelopeVersion; + out[3] = static_cast(kind); + std::memcpy(out + kMqttEnvelopePrefixSize, payload, payload_len); + *inout_len = needed; + return true; +} + +bool parseMqttEnvelope(const uint8_t* data, + std::size_t len, + MqttEnvelopeView* out_view) +{ + if (!data || !out_view || len <= kMqttEnvelopePrefixSize || + data[0] != kEnvelopeMagic0 || data[1] != kEnvelopeMagic1 || + data[2] != kEnvelopeVersion) + { + return false; + } + const MqttEnvelopeKind kind = static_cast(data[3]); + if (!isKnownEnvelopeKind(kind)) + { + return false; + } + const std::size_t payload_len = len - kMqttEnvelopePrefixSize; + if (payload_len > kPrivateShardFrameSize) + { + return false; + } + out_view->kind = kind; + out_view->payload = data + kMqttEnvelopePrefixSize; + out_view->payload_len = payload_len; + return true; +} + +bool MqttTransmitTransfer::prepareCommon(const ControlFrame& control, + const uint8_t* encoded_media, + std::size_t encoded_media_len) +{ + if (!isValidControlFrame(control) || !encoded_media || + encoded_media_len != control.encoded_media_len || + !transmit_block_.prepare(encoded_media, encoded_media_len)) + { + return false; + } + control_ = control; + return true; +} + +bool MqttTransmitTransfer::preparePrivate( + const ControlFrame& control, + const uint8_t verified_contact_secret[kPrivateKeySize], + const uint8_t* encoded_media, + std::size_t encoded_media_len) +{ + clear(); + if (!verified_contact_secret || !isPrivateOffer(control) || + !prepareCommon(control, encoded_media, encoded_media_len) || + !derivePrivateMqttSessionKeys(verified_contact_secret, + control.session_nonce, + control.session_id, + &keys_)) + { + clear(); + return false; + } + std::size_t control_len = sizeof(control_wire_); + if (!encodePrivateControlFrame(control_, + keys_, + PrivateFrameDirection::SenderToReceiver, + control_wire_, + &control_len) || + control_len != sizeof(control_wire_)) + { + clear(); + return false; + } + private_mode_ = true; + prepared_ = true; + return true; +} + +bool MqttTransmitTransfer::prepareBroadcast(const ControlFrame& control, + const uint8_t* encoded_media, + std::size_t encoded_media_len) +{ + clear(); + if (!isPublicAnnounce(control) || !prepareCommon(control, encoded_media, encoded_media_len)) + { + clear(); + return false; + } + std::size_t control_len = sizeof(control_wire_); + if (!encodePublicControlFrame(control_, control_wire_, &control_len) || + control_len != sizeof(control_wire_)) + { + clear(); + return false; + } + private_mode_ = false; + prepared_ = true; + return true; +} + +bool MqttTransmitTransfer::copyNextEnvelope(uint8_t* out, std::size_t* inout_len) +{ + if (!out || !inout_len || !hasNext()) + { + return false; + } + if (next_index_ == 0U) + { + const bool ok = buildMqttEnvelope(MqttEnvelopeKind::Control, + control_wire_, + sizeof(control_wire_), + out, + inout_len); + return ok; + } + + const uint8_t shard_index = static_cast(next_index_ - 1U); + std::size_t frame_len = sizeof(frame_wire_); + const bool frame_ok = private_mode_ + ? transmit_block_.buildPrivateShardFrame( + keys_, + control_.session_nonce, + control_.session_id, + shard_index, + frame_wire_, + &frame_len) + : transmit_block_.buildPublicShardFrame( + control_.session_id, + shard_index, + frame_wire_, + &frame_len); + if (!frame_ok) + { + return false; + } + const bool envelope_ok = buildMqttEnvelope( + MqttEnvelopeKind::Shard, frame_wire_, frame_len, out, inout_len); + return envelope_ok; +} + +bool MqttTransmitTransfer::commitNextEnvelope() +{ + if (!hasNext()) + { + return false; + } + ++next_index_; + return true; +} + +bool MqttTransmitTransfer::nextEnvelope(uint8_t* out, std::size_t* inout_len) +{ + return copyNextEnvelope(out, inout_len) && commitNextEnvelope(); +} + +void MqttTransmitTransfer::clear() +{ + control_ = {}; + clearPrivateSessionKeys(&keys_); + transmit_block_.clear(); + secureClear(control_wire_, sizeof(control_wire_)); + secureClear(frame_wire_, sizeof(frame_wire_)); + next_index_ = 0U; + private_mode_ = false; + prepared_ = false; +} + +MqttTransferResult MqttReceiveTransfer::acceptEnvelope( + const uint8_t* envelope, + std::size_t envelope_len, + uint32_t self_node_id, + const IVerifiedContactSecretProvider& contacts) +{ + MqttEnvelopeView view{}; + if (!parseMqttEnvelope(envelope, envelope_len, &view)) + { + return MqttTransferResult::Rejected; + } + return view.kind == MqttEnvelopeKind::Control + ? acceptControl(view.payload, view.payload_len, self_node_id, contacts) + : acceptShard(view.payload, view.payload_len); +} + +MqttTransferResult MqttReceiveTransfer::acceptControl( + const uint8_t* control_wire, + std::size_t control_len, + uint32_t self_node_id, + const IVerifiedContactSecretProvider& contacts) +{ + clear(); + if (!control_wire || control_len != kControlFrameSize || self_node_id == 0U || + !decodeControlFrame(control_wire, control_len, &candidate_control_)) + { + return MqttTransferResult::Rejected; + } + + DeliveryMode mode = DeliveryMode::Private; + if (!deliveryModeFor(candidate_control_, &mode)) + { + return MqttTransferResult::Rejected; + } + if (mode == DeliveryMode::Private) + { + if (!isPrivateOffer(candidate_control_) || + candidate_control_.target_id != self_node_id || + !contacts.lookupVerifiedContactSecret(candidate_control_.sender_id, + contact_secret_) || + !derivePrivateMqttSessionKeys(contact_secret_, + candidate_control_.session_nonce, + candidate_control_.session_id, + &keys_) || + !decodePrivateControlFrame(control_wire, + control_len, + keys_, + PrivateFrameDirection::SenderToReceiver, + &control_)) + { + clear(); + return MqttTransferResult::Rejected; + } + private_mode_ = true; + } + else + { + if (!isPublicAnnounce(candidate_control_) || + !decodePublicControlFrame(control_wire, control_len, &control_)) + { + clear(); + return MqttTransferResult::Rejected; + } + private_mode_ = false; + } + + MediaLayout layout{}; + if (!planMediaLayout(control_.encoded_media_len, &layout) || + !receive_block_.begin(layout)) + { + clear(); + return MqttTransferResult::Rejected; + } + active_ = true; + return MqttTransferResult::Accepted; +} + +MqttTransferResult MqttReceiveTransfer::acceptShard(const uint8_t* frame, + std::size_t frame_len) +{ + if (!active_ || !frame || complete_) + { + return MqttTransferResult::Rejected; + } + + const uint8_t* shard = nullptr; + if (private_mode_) + { + if (frame_len != kPrivateShardFrameSize || + !decodeDataHeader(frame, kDataHeaderSize, &data_header_) || + data_header_.session_id != control_.session_id || + !openPrivateShard(keys_, + control_.session_nonce, + PrivateFrameDirection::SenderToReceiver, + data_header_, + frame + kDataHeaderSize, + kMaxShardPayloadSize, + frame + kDataHeaderSize + kMaxShardPayloadSize, + plaintext_)) + { + return MqttTransferResult::Rejected; + } + shard = plaintext_; + } + else + { + if (frame_len != kPublicShardFrameSize || + !parsePublicShardFrame(frame, frame_len, &data_header_, &shard) || + data_header_.session_id != control_.session_id) + { + return MqttTransferResult::Rejected; + } + } + + const ReceiveBlockResult accepted = receive_block_.accept( + data_header_, shard, kMaxShardPayloadSize); + if (accepted == ReceiveBlockResult::Duplicate) + { + return MqttTransferResult::Duplicate; + } + if (accepted == ReceiveBlockResult::Invalid) + { + return MqttTransferResult::Rejected; + } + if (accepted == ReceiveBlockResult::Complete) + { + complete_ = true; + return MqttTransferResult::Complete; + } + return MqttTransferResult::Accepted; +} + +bool MqttReceiveTransfer::recover(uint8_t* out_media, + std::size_t out_capacity, + std::size_t* out_media_len) +{ + return complete_ && receive_block_.recover(out_media, out_capacity, out_media_len); +} + +void MqttReceiveTransfer::clear() +{ + candidate_control_ = {}; + control_ = {}; + data_header_ = {}; + clearPrivateSessionKeys(&keys_); + receive_block_.clear(); + secureClear(contact_secret_, sizeof(contact_secret_)); + secureClear(plaintext_, sizeof(plaintext_)); + private_mode_ = false; + active_ = false; + complete_ = false; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp b/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp new file mode 100644 index 00000000..d19f9bbf --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_private_crypto.cpp @@ -0,0 +1,862 @@ +/** + * @file vmp_private_crypto.cpp + * @brief Private-session cryptography for Trail Mate VMP v1. + */ + +#include "chat/infra/voice/vmp_private_crypto.h" + +#if defined(ESP_PLATFORM) || defined(ARDUINO) +#include "platform/esp/common/reticulum_crypto_compat.h" +#endif + +#if defined(ESP_PLATFORM) && !defined(ARDUINO) +#include "mbedtls/chachapoly.h" +#include "mbedtls/md.h" +#elif defined(ARDUINO) +#include +#include +#include +#elif defined(TRAIL_MATE_HAS_OPENSSL) +#include +#include +#endif + +#include + +namespace chat::voice::vmp +{ +namespace +{ + +constexpr std::size_t kPrivateControlAuthenticatedBytes = + kControlFrameSize - kControlIntegrityTagSize; +constexpr uint8_t kControlNonceDomain = 0xC1U; +constexpr uint8_t kReadyNonceDomain = 0x52U; +constexpr uint8_t kContactDerivationSalt[kSessionNonceSize] = { + 'T', 'M', 'V', 'M', 'P', '-', 'V', '1', '-', 'C', 'T', 'K'}; + +bool isAllZero(const uint8_t* data, std::size_t len) +{ + if (!data) + { + return true; + } + uint8_t accumulated = 0; + for (std::size_t index = 0; index < len; ++index) + { + accumulated |= data[index]; + } + return accumulated == 0U; +} + +void secureZero(void* data, std::size_t len) +{ + volatile uint8_t* cursor = static_cast(data); + while (cursor && len != 0U) + { + *cursor++ = 0; + --len; + } +} + +bool constantTimeEqual(const uint8_t* left, const uint8_t* right, std::size_t len) +{ + if (!left || !right) + { + return false; + } + uint8_t difference = 0; + for (std::size_t index = 0; index < len; ++index) + { + difference |= static_cast(left[index] ^ right[index]); + } + return difference == 0U; +} + +void writeSessionId(uint64_t session_id, uint8_t out[sizeof(session_id)]) +{ + for (std::size_t index = 0; index < sizeof(session_id); ++index) + { + const std::size_t shift = (sizeof(session_id) - 1U - index) * 8U; + out[index] = static_cast(session_id >> shift); + } +} + +bool buildKdfInfo(const char* label, + uint64_t session_id, + uint8_t out_info[64], + std::size_t* out_len) +{ + if (!label || !out_info || !out_len) + { + return false; + } + const std::size_t label_len = std::strlen(label); + if (label_len + sizeof(session_id) + 1U > 64U) + { + return false; + } + std::memcpy(out_info, label, label_len); + writeSessionId(session_id, out_info + label_len); + out_info[label_len + sizeof(session_id)] = 1U; + *out_len = label_len + sizeof(session_id) + 1U; + return true; +} + +bool deriveKey(const uint8_t input_key[kPrivateKeySize], + const uint8_t session_nonce[kSessionNonceSize], + const char* label, + uint64_t session_id, + uint8_t out_key[kPrivateKeySize]) +{ + if (!input_key || !session_nonce || !label || !out_key || + isAllZero(input_key, kPrivateKeySize)) + { + return false; + } + + uint8_t info[64] = {}; + std::size_t info_len = 0; + if (!buildKdfInfo(label, session_id, info, &info_len)) + { + return false; + } + +#if defined(ESP_PLATFORM) && !defined(ARDUINO) + uint8_t prk[kPrivateKeySize] = {}; + const mbedtls_md_info_t* const md = + mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + const bool ok = md != nullptr && + mbedtls_md_hmac(md, + session_nonce, + kSessionNonceSize, + input_key, + kPrivateKeySize, + prk) == 0 && + mbedtls_md_hmac(md, + prk, + sizeof(prk), + info, + info_len, + out_key) == 0; + secureZero(prk, sizeof(prk)); + secureZero(info, sizeof(info)); + return ok; +#elif defined(ARDUINO) + hkdf(out_key, + kPrivateKeySize, + input_key, + kPrivateKeySize, + session_nonce, + kSessionNonceSize, + info, + info_len - 1U); + secureZero(info, sizeof(info)); + return !isAllZero(out_key, kPrivateKeySize); +#elif defined(TRAIL_MATE_HAS_OPENSSL) + uint8_t prk[kPrivateKeySize] = {}; + unsigned int output_len = 0; + const bool ok = + HMAC(EVP_sha256(), + session_nonce, + static_cast(kSessionNonceSize), + input_key, + kPrivateKeySize, + prk, + &output_len) != nullptr && + output_len == kPrivateKeySize && + HMAC(EVP_sha256(), + prk, + static_cast(sizeof(prk)), + info, + info_len, + out_key, + &output_len) != nullptr && + output_len == kPrivateKeySize; + secureZero(prk, sizeof(prk)); + secureZero(info, sizeof(info)); + return ok; +#else + secureZero(info, sizeof(info)); + (void)session_id; + return false; +#endif +} + +const char* contactFamilyLabel(ContactSecretIdentityFamily family) +{ + switch (family) + { + case ContactSecretIdentityFamily::Meshtastic: + return "TrailMate/VMP/v1/contact/mt"; + case ContactSecretIdentityFamily::MeshCore: + return "TrailMate/VMP/v1/contact/mc"; + case ContactSecretIdentityFamily::Reticulum: + return "TrailMate/VMP/v1/contact/rt"; + default: + return nullptr; + } +} + +void makeControlNonce(const uint8_t session_nonce[kSessionNonceSize], + ControlType type, + PrivateFrameDirection direction, + uint8_t out_nonce[kPrivateFrameNonceSize]) +{ + std::memcpy(out_nonce, session_nonce, 8U); + out_nonce[8] = static_cast(type); + out_nonce[9] = static_cast(direction); + out_nonce[10] = kControlNonceDomain; + out_nonce[11] = 0U; +} + +bool makeDataNonce(const uint8_t session_nonce[kSessionNonceSize], + const DataHeader& header, + PrivateFrameDirection direction, + uint8_t domain, + uint8_t out_nonce[kPrivateFrameNonceSize]) +{ + if (!session_nonce || !out_nonce || !isValidDataHeader(header)) + { + return false; + } + std::memcpy(out_nonce, session_nonce, 8U); + out_nonce[8] = static_cast(header.type); + out_nonce[9] = header.block_index; + out_nonce[10] = header.shard_index; + out_nonce[11] = static_cast( + static_cast(direction) ^ domain); + return true; +} + +bool isKnownDirection(PrivateFrameDirection direction) +{ + return direction == PrivateFrameDirection::SenderToReceiver || + direction == PrivateFrameDirection::ReceiverToSender; +} + +bool sealAead(const uint8_t key[kPrivateKeySize], + const uint8_t nonce[kPrivateFrameNonceSize], + const uint8_t* aad, + std::size_t aad_len, + const uint8_t* plaintext, + std::size_t plaintext_len, + uint8_t* out_ciphertext, + uint8_t out_tag[kPrivateDataAuthTagSize]) +{ + if (!key || !nonce || !aad || aad_len == 0U || + (plaintext_len != 0U && (!plaintext || !out_ciphertext)) || !out_tag) + { + return false; + } + +#if defined(ESP_PLATFORM) && !defined(ARDUINO) + uint8_t empty = 0; + mbedtls_chachapoly_context context; + mbedtls_chachapoly_init(&context); + const bool ok = + mbedtls_chachapoly_setkey(&context, key) == 0 && + mbedtls_chachapoly_encrypt_and_tag( + &context, + plaintext_len, + nonce, + aad, + aad_len, + plaintext_len == 0U ? &empty : plaintext, + plaintext_len == 0U ? &empty : out_ciphertext, + out_tag) == 0; + mbedtls_chachapoly_free(&context); + return ok; +#elif defined(ARDUINO) + uint8_t empty = 0; + ChaChaPoly cipher; + const bool ok = cipher.setKey(key, kPrivateKeySize) && + cipher.setIV(nonce, kPrivateFrameNonceSize); + if (ok) + { + cipher.addAuthData(aad, aad_len); + cipher.encrypt(plaintext_len == 0U ? &empty : out_ciphertext, + plaintext_len == 0U ? &empty : plaintext, + plaintext_len); + cipher.computeTag(out_tag, kPrivateDataAuthTagSize); + } + cipher.clear(); + return ok; +#elif defined(TRAIL_MATE_HAS_OPENSSL) + EVP_CIPHER_CTX* context = EVP_CIPHER_CTX_new(); + int output_len = 0; + int final_len = 0; + const bool ok = + context != nullptr && + EVP_EncryptInit_ex(context, EVP_chacha20_poly1305(), nullptr, nullptr, nullptr) == + 1 && + EVP_CIPHER_CTX_ctrl(context, + EVP_CTRL_AEAD_SET_IVLEN, + static_cast(kPrivateFrameNonceSize), + nullptr) == 1 && + EVP_EncryptInit_ex(context, nullptr, nullptr, key, nonce) == 1 && + EVP_EncryptUpdate(context, + nullptr, + &output_len, + aad, + static_cast(aad_len)) == 1 && + (plaintext_len == 0U || + EVP_EncryptUpdate(context, + out_ciphertext, + &output_len, + plaintext, + static_cast(plaintext_len)) == 1) && + EVP_EncryptFinal_ex(context, nullptr, &final_len) == 1 && + EVP_CIPHER_CTX_ctrl(context, + EVP_CTRL_AEAD_GET_TAG, + static_cast(kPrivateDataAuthTagSize), + out_tag) == 1; + if (context) + { + EVP_CIPHER_CTX_free(context); + } + return ok; +#else + (void)plaintext; + (void)plaintext_len; + (void)out_ciphertext; + (void)out_tag; + return false; +#endif +} + +bool openAead(const uint8_t key[kPrivateKeySize], + const uint8_t nonce[kPrivateFrameNonceSize], + const uint8_t* aad, + std::size_t aad_len, + const uint8_t* ciphertext, + std::size_t ciphertext_len, + const uint8_t tag[kPrivateDataAuthTagSize], + uint8_t* out_plaintext) +{ + if (!key || !nonce || !aad || aad_len == 0U || + (ciphertext_len != 0U && (!ciphertext || !out_plaintext)) || !tag) + { + return false; + } + +#if defined(ESP_PLATFORM) && !defined(ARDUINO) + uint8_t empty = 0; + mbedtls_chachapoly_context context; + mbedtls_chachapoly_init(&context); + const bool ok = + mbedtls_chachapoly_setkey(&context, key) == 0 && + mbedtls_chachapoly_auth_decrypt( + &context, + ciphertext_len, + nonce, + aad, + aad_len, + tag, + ciphertext_len == 0U ? &empty : ciphertext, + ciphertext_len == 0U ? &empty : out_plaintext) == 0; + mbedtls_chachapoly_free(&context); + return ok; +#elif defined(ARDUINO) + uint8_t empty = 0; + ChaChaPoly cipher; + const bool initialized = cipher.setKey(key, kPrivateKeySize) && + cipher.setIV(nonce, kPrivateFrameNonceSize); + bool ok = false; + if (initialized) + { + cipher.addAuthData(aad, aad_len); + cipher.decrypt(ciphertext_len == 0U ? &empty : out_plaintext, + ciphertext_len == 0U ? &empty : ciphertext, + ciphertext_len); + ok = cipher.checkTag(tag, kPrivateDataAuthTagSize); + } + cipher.clear(); + return ok; +#elif defined(TRAIL_MATE_HAS_OPENSSL) + EVP_CIPHER_CTX* context = EVP_CIPHER_CTX_new(); + int output_len = 0; + int final_len = 0; + const bool ok = + context != nullptr && + EVP_DecryptInit_ex(context, EVP_chacha20_poly1305(), nullptr, nullptr, nullptr) == + 1 && + EVP_CIPHER_CTX_ctrl(context, + EVP_CTRL_AEAD_SET_IVLEN, + static_cast(kPrivateFrameNonceSize), + nullptr) == 1 && + EVP_DecryptInit_ex(context, nullptr, nullptr, key, nonce) == 1 && + EVP_DecryptUpdate(context, + nullptr, + &output_len, + aad, + static_cast(aad_len)) == 1 && + (ciphertext_len == 0U || + EVP_DecryptUpdate(context, + out_plaintext, + &output_len, + ciphertext, + static_cast(ciphertext_len)) == 1) && + EVP_CIPHER_CTX_ctrl(context, + EVP_CTRL_AEAD_SET_TAG, + static_cast(kPrivateDataAuthTagSize), + const_cast(tag)) == 1 && + EVP_DecryptFinal_ex(context, nullptr, &final_len) == 1; + if (context) + { + EVP_CIPHER_CTX_free(context); + } + return ok; +#else + (void)ciphertext; + (void)ciphertext_len; + (void)tag; + (void)out_plaintext; + return false; +#endif +} + +bool deriveEphemeralSecret(uint8_t local_ephemeral_private[kPrivateKeySize], + const uint8_t peer_ephemeral_public[kEphemeralPublicKeySize], + uint8_t out_secret[kPrivateKeySize]) +{ + if (!local_ephemeral_private || !peer_ephemeral_public || !out_secret || + isAllZero(local_ephemeral_private, kPrivateKeySize) || + isAllZero(peer_ephemeral_public, kEphemeralPublicKeySize)) + { + return false; + } + +#if defined(ESP_PLATFORM) || defined(ARDUINO) + std::memcpy(out_secret, peer_ephemeral_public, kPrivateKeySize); + return Curve25519::dh2(out_secret, local_ephemeral_private) && + !isAllZero(out_secret, kPrivateKeySize); +#elif defined(TRAIL_MATE_HAS_OPENSSL) + EVP_PKEY* const local = EVP_PKEY_new_raw_private_key( + EVP_PKEY_X25519, nullptr, local_ephemeral_private, kPrivateKeySize); + EVP_PKEY* const peer = EVP_PKEY_new_raw_public_key( + EVP_PKEY_X25519, nullptr, peer_ephemeral_public, kEphemeralPublicKeySize); + EVP_PKEY_CTX* const context = local ? EVP_PKEY_CTX_new(local, nullptr) : nullptr; + std::size_t secret_len = kPrivateKeySize; + const bool ok = local != nullptr && peer != nullptr && context != nullptr && + EVP_PKEY_derive_init(context) == 1 && + EVP_PKEY_derive_set_peer(context, peer) == 1 && + EVP_PKEY_derive(context, out_secret, &secret_len) == 1 && + secret_len == kPrivateKeySize && + !isAllZero(out_secret, kPrivateKeySize); + if (context) + { + EVP_PKEY_CTX_free(context); + } + if (peer) + { + EVP_PKEY_free(peer); + } + if (local) + { + EVP_PKEY_free(local); + } + secureZero(local_ephemeral_private, kPrivateKeySize); + return ok; +#else + (void)out_secret; + return false; +#endif +} + +} // namespace + +bool generateEphemeralKeyPair(EphemeralKeyPair* out_pair) +{ + if (!out_pair) + { + return false; + } + *out_pair = {}; + +#if defined(ESP_PLATFORM) || defined(ARDUINO) + RNG.begin("trail-mate-vmp"); + Curve25519::dh1(out_pair->public_key, out_pair->private_key); + const bool ok = !isAllZero(out_pair->public_key, sizeof(out_pair->public_key)) && + !isAllZero(out_pair->private_key, sizeof(out_pair->private_key)); + if (!ok) + { + *out_pair = {}; + } + return ok; +#elif defined(TRAIL_MATE_HAS_OPENSSL) + EVP_PKEY_CTX* context = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, nullptr); + EVP_PKEY* key = nullptr; + std::size_t public_len = sizeof(out_pair->public_key); + std::size_t private_len = sizeof(out_pair->private_key); + const bool ok = context != nullptr && EVP_PKEY_keygen_init(context) == 1 && + EVP_PKEY_keygen(context, &key) == 1 && key != nullptr && + EVP_PKEY_get_raw_public_key( + key, out_pair->public_key, &public_len) == 1 && + EVP_PKEY_get_raw_private_key( + key, out_pair->private_key, &private_len) == 1 && + public_len == sizeof(out_pair->public_key) && + private_len == sizeof(out_pair->private_key); + if (key) + { + EVP_PKEY_free(key); + } + if (context) + { + EVP_PKEY_CTX_free(context); + } + if (!ok) + { + *out_pair = {}; + } + return ok; +#else + return false; +#endif +} + +bool deriveVmpContactSecret( + const uint8_t verified_identity_shared_secret[kPrivateKeySize], + ContactSecretIdentityFamily family, + uint32_t local_node_id, + uint32_t peer_node_id, + uint8_t out_contact_secret[kPrivateKeySize]) +{ + if (!verified_identity_shared_secret || !out_contact_secret || + isAllZero(verified_identity_shared_secret, kPrivateKeySize) || + local_node_id == 0U || peer_node_id == 0U || local_node_id == peer_node_id) + { + if (out_contact_secret) + { + secureZero(out_contact_secret, kPrivateKeySize); + } + return false; + } + const char* const label = contactFamilyLabel(family); + if (!label) + { + secureZero(out_contact_secret, kPrivateKeySize); + return false; + } + const uint32_t lower_node_id = local_node_id < peer_node_id ? local_node_id : peer_node_id; + const uint32_t higher_node_id = local_node_id < peer_node_id ? peer_node_id : local_node_id; + const uint64_t pair_binding = + (static_cast(lower_node_id) << 32U) | higher_node_id; + const bool ok = deriveKey(verified_identity_shared_secret, + kContactDerivationSalt, + label, + pair_binding, + out_contact_secret); + if (!ok) + { + secureZero(out_contact_secret, kPrivateKeySize); + } + return ok; +} + +bool derivePrivateControlKey( + const uint8_t verified_contact_secret[kPrivateKeySize], + const uint8_t session_nonce[kSessionNonceSize], + uint64_t session_id, + uint8_t out_control_key[kPrivateKeySize]) +{ + if (!verified_contact_secret || !session_nonce || !out_control_key || + session_id == 0U || isAllZero(verified_contact_secret, kPrivateKeySize) || + isAllZero(session_nonce, kSessionNonceSize)) + { + return false; + } + return deriveKey(verified_contact_secret, + session_nonce, + "TrailMate/VMP/v1/private-control", + session_id, + out_control_key); +} + +bool derivePrivateSessionKeys( + const uint8_t verified_contact_secret[kPrivateKeySize], + uint8_t local_ephemeral_private[kPrivateKeySize], + const uint8_t peer_ephemeral_public[kEphemeralPublicKeySize], + const uint8_t session_nonce[kSessionNonceSize], + uint64_t session_id, + PrivateSessionKeys* out_keys) +{ + if (!verified_contact_secret || !local_ephemeral_private || + !peer_ephemeral_public || !session_nonce || !out_keys || session_id == 0U || + isAllZero(verified_contact_secret, kPrivateKeySize) || + isAllZero(session_nonce, kSessionNonceSize)) + { + if (local_ephemeral_private) + { + secureZero(local_ephemeral_private, kPrivateKeySize); + } + clearPrivateSessionKeys(out_keys); + return false; + } + + clearPrivateSessionKeys(out_keys); + uint8_t ephemeral_secret[kPrivateKeySize] = {}; + const bool shared_secret_ok = deriveEphemeralSecret( + local_ephemeral_private, peer_ephemeral_public, ephemeral_secret); + const bool keys_ok = + shared_secret_ok && + derivePrivateControlKey(verified_contact_secret, + session_nonce, + session_id, + out_keys->control_key) && + deriveKey(ephemeral_secret, + session_nonce, + "TrailMate/VMP/v1/ready", + session_id, + out_keys->ready_key) && + deriveKey(ephemeral_secret, + session_nonce, + "TrailMate/VMP/v1/data", + session_id, + out_keys->data_key) && + deriveKey(ephemeral_secret, + session_nonce, + "TrailMate/VMP/v1/mqtt", + session_id, + out_keys->mqtt_key); + secureZero(ephemeral_secret, sizeof(ephemeral_secret)); + secureZero(local_ephemeral_private, kPrivateKeySize); + if (!keys_ok) + { + clearPrivateSessionKeys(out_keys); + } + return keys_ok; +} + +bool derivePrivateMqttSessionKeys( + const uint8_t verified_contact_secret[kPrivateKeySize], + const uint8_t session_nonce[kSessionNonceSize], + uint64_t session_id, + PrivateSessionKeys* out_keys) +{ + if (!verified_contact_secret || !session_nonce || !out_keys || session_id == 0U || + isAllZero(verified_contact_secret, kPrivateKeySize) || + isAllZero(session_nonce, kSessionNonceSize)) + { + clearPrivateSessionKeys(out_keys); + return false; + } + + clearPrivateSessionKeys(out_keys); + const bool keys_ok = + derivePrivateControlKey(verified_contact_secret, + session_nonce, + session_id, + out_keys->control_key) && + deriveKey(out_keys->control_key, + session_nonce, + "TrailMate/VMP/v1/mqtt-ready", + session_id, + out_keys->ready_key) && + deriveKey(out_keys->control_key, + session_nonce, + "TrailMate/VMP/v1/mqtt-data", + session_id, + out_keys->data_key) && + deriveKey(out_keys->control_key, + session_nonce, + "TrailMate/VMP/v1/mqtt-manifest", + session_id, + out_keys->mqtt_key); + if (!keys_ok) + { + clearPrivateSessionKeys(out_keys); + } + return keys_ok; +} + +void clearPrivateSessionKeys(PrivateSessionKeys* keys) +{ + if (keys) + { + secureZero(keys, sizeof(*keys)); + } +} + +bool tagPrivateControl( + const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + ControlType type, + PrivateFrameDirection direction, + const uint8_t* authenticated_control, + std::size_t authenticated_control_len, + uint8_t out_tag[kControlIntegrityTagSize]) +{ + if (!session_nonce || !authenticated_control || !out_tag || + authenticated_control_len != kPrivateControlAuthenticatedBytes || + !isKnownDirection(direction)) + { + return false; + } + uint8_t nonce[kPrivateFrameNonceSize] = {}; + makeControlNonce(session_nonce, type, direction, nonce); + const bool ok = sealAead(keys.control_key, + nonce, + authenticated_control, + authenticated_control_len, + nullptr, + 0, + nullptr, + out_tag); + secureZero(nonce, sizeof(nonce)); + return ok; +} + +bool verifyPrivateControlTag( + const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + ControlType type, + PrivateFrameDirection direction, + const uint8_t* authenticated_control, + std::size_t authenticated_control_len, + const uint8_t tag[kControlIntegrityTagSize]) +{ + if (!tag) + { + return false; + } + uint8_t expected_tag[kControlIntegrityTagSize] = {}; + const bool ok = tagPrivateControl(keys, + session_nonce, + type, + direction, + authenticated_control, + authenticated_control_len, + expected_tag) && + constantTimeEqual(expected_tag, tag, sizeof(expected_tag)); + secureZero(expected_tag, sizeof(expected_tag)); + return ok; +} + +bool tagPrivateReady(const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + PrivateFrameDirection direction, + const DataHeader& header, + uint8_t out_tag[kPrivateDataAuthTagSize]) +{ + if (!session_nonce || !out_tag || !isKnownDirection(direction) || + (header.type != DataType::ReadyProbe && header.type != DataType::Ready) || + !isValidDataHeader(header)) + { + return false; + } + uint8_t encoded_header[kDataHeaderSize] = {}; + std::size_t encoded_len = sizeof(encoded_header); + uint8_t nonce[kPrivateFrameNonceSize] = {}; + const bool ok = encodeDataHeader(header, encoded_header, &encoded_len) && + makeDataNonce(session_nonce, + header, + direction, + kReadyNonceDomain, + nonce) && + sealAead(keys.ready_key, + nonce, + encoded_header, + encoded_len, + nullptr, + 0, + nullptr, + out_tag); + secureZero(encoded_header, sizeof(encoded_header)); + secureZero(nonce, sizeof(nonce)); + return ok; +} + +bool verifyPrivateReadyTag(const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + PrivateFrameDirection direction, + const DataHeader& header, + const uint8_t tag[kPrivateDataAuthTagSize]) +{ + if (!tag) + { + return false; + } + uint8_t expected_tag[kPrivateDataAuthTagSize] = {}; + const bool ok = + tagPrivateReady(keys, session_nonce, direction, header, expected_tag) && + constantTimeEqual(expected_tag, tag, sizeof(expected_tag)); + secureZero(expected_tag, sizeof(expected_tag)); + return ok; +} + +bool sealPrivateShard(const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + PrivateFrameDirection direction, + const DataHeader& header, + const uint8_t* plaintext, + std::size_t plaintext_len, + uint8_t* out_ciphertext, + uint8_t out_tag[kPrivateDataAuthTagSize]) +{ + if (!session_nonce || !plaintext || !out_ciphertext || !out_tag || + !isKnownDirection(direction) || header.type != DataType::Shard || + plaintext_len == 0U || plaintext_len != header.payload_len || + !isValidDataHeader(header)) + { + return false; + } + uint8_t encoded_header[kDataHeaderSize] = {}; + std::size_t encoded_len = sizeof(encoded_header); + uint8_t nonce[kPrivateFrameNonceSize] = {}; + const bool ok = encodeDataHeader(header, encoded_header, &encoded_len) && + makeDataNonce( + session_nonce, header, direction, 0U, nonce) && + sealAead(keys.data_key, + nonce, + encoded_header, + encoded_len, + plaintext, + plaintext_len, + out_ciphertext, + out_tag); + secureZero(encoded_header, sizeof(encoded_header)); + secureZero(nonce, sizeof(nonce)); + return ok; +} + +bool openPrivateShard(const PrivateSessionKeys& keys, + const uint8_t session_nonce[kSessionNonceSize], + PrivateFrameDirection direction, + const DataHeader& header, + const uint8_t* ciphertext, + std::size_t ciphertext_len, + const uint8_t tag[kPrivateDataAuthTagSize], + uint8_t* out_plaintext) +{ + if (!session_nonce || !ciphertext || !tag || !out_plaintext || + !isKnownDirection(direction) || header.type != DataType::Shard || + ciphertext_len == 0U || ciphertext_len != header.payload_len || + !isValidDataHeader(header)) + { + return false; + } + uint8_t encoded_header[kDataHeaderSize] = {}; + std::size_t encoded_len = sizeof(encoded_header); + uint8_t nonce[kPrivateFrameNonceSize] = {}; + const bool ok = encodeDataHeader(header, encoded_header, &encoded_len) && + makeDataNonce( + session_nonce, header, direction, 0U, nonce) && + openAead(keys.data_key, + nonce, + encoded_header, + encoded_len, + ciphertext, + ciphertext_len, + tag, + out_plaintext); + secureZero(encoded_header, sizeof(encoded_header)); + secureZero(nonce, sizeof(nonce)); + if (!ok) + { + secureZero(out_plaintext, ciphertext_len); + } + return ok; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_receive_block.cpp b/modules/core_chat/src/infra/voice/vmp_receive_block.cpp new file mode 100644 index 00000000..59b6e436 --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_receive_block.cpp @@ -0,0 +1,118 @@ +/** + * @file vmp_receive_block.cpp + * @brief Caller-owned bounded VMP v1 media receive block. + */ + +#include "chat/infra/voice/vmp_receive_block.h" + +#include + +namespace chat::voice::vmp +{ + +bool ReceiveBlock::begin(const MediaLayout& layout) +{ + if (layout.encoded_media_len == 0U || + layout.encoded_media_len > kMaxEncodedMediaSize || + layout.block_count != kMaxBlocks || + layout.data_frame_count != kTotalShardsPerBlock || + layout.source_shard_count == 0U || + layout.source_shard_count > kSourceShardsPerBlock) + { + return false; + } + MediaLayout verified = {}; + if (!planMediaLayout(layout.encoded_media_len, &verified) || + verified.source_shard_count != layout.source_shard_count || + verified.block_count != layout.block_count || + verified.data_frame_count != layout.data_frame_count) + { + return false; + } + + clear(); + layout_ = layout; + active_ = true; + return true; +} + +ReceiveBlockResult ReceiveBlock::accept(const DataHeader& header, + const uint8_t* shard, + std::size_t shard_len) +{ + if (!active_ || recovered_ || !shard || header.type != DataType::Shard || + !isValidDataHeader(header) || header.block_index != 0U || + header.shard_index >= kTotalShardsPerBlock || + shard_len != kMaxShardPayloadSize || + header.payload_len != kMaxShardPayloadSize) + { + return ReceiveBlockResult::Invalid; + } + if (present_[header.shard_index]) + { + return ReceiveBlockResult::Duplicate; + } + + std::memcpy(shards_[header.shard_index], shard, kMaxShardPayloadSize); + present_[header.shard_index] = true; + ++received_shard_count_; + return received_shard_count_ == kSourceShardsPerBlock + ? ReceiveBlockResult::Complete + : ReceiveBlockResult::Accepted; +} + +bool ReceiveBlock::recover(uint8_t* out_media, + std::size_t out_capacity, + std::size_t* out_media_len) +{ + if (!active_ || !out_media || !out_media_len || + out_capacity < layout_.encoded_media_len || + received_shard_count_ < kSourceShardsPerBlock) + { + return false; + } + + if (!recovered_) + { + uint8_t* slots[kTotalShardsPerBlock] = {}; + for (std::size_t index = 0; index < kTotalShardsPerBlock; ++index) + { + slots[index] = shards_[index]; + } + if (!recoverRs10_8(slots, present_, kMaxShardPayloadSize)) + { + return false; + } + recovered_ = true; + } + + std::size_t copied = 0; + for (uint8_t source = 0; source < layout_.source_shard_count; ++source) + { + const std::size_t source_len = sourceShardPayloadSize(layout_, 0U, source); + if (source_len == 0U || copied + source_len > layout_.encoded_media_len) + { + return false; + } + std::memcpy(out_media + copied, shards_[source], source_len); + copied += source_len; + } + if (copied != layout_.encoded_media_len) + { + return false; + } + *out_media_len = copied; + return true; +} + +void ReceiveBlock::clear() +{ + layout_ = {}; + std::memset(shards_, 0, sizeof(shards_)); + std::memset(present_, 0, sizeof(present_)); + received_shard_count_ = 0; + active_ = false; + recovered_ = false; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_rs_fec.cpp b/modules/core_chat/src/infra/voice/vmp_rs_fec.cpp new file mode 100644 index 00000000..27e9ecad --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_rs_fec.cpp @@ -0,0 +1,272 @@ +/** + * @file vmp_rs_fec.cpp + * @brief Fixed-size Reed-Solomon (10,8) erasure coding for VMP media. + */ + +#include "chat/infra/voice/vmp_rs_fec.h" + +#include + +namespace chat::voice::vmp +{ +namespace +{ + +constexpr uint8_t kFieldPolynomialLow = 0x1DU; + +uint8_t gfMultiply(uint8_t left, uint8_t right) +{ + uint8_t product = 0; + for (uint8_t bit = 0; bit < 8U; ++bit) + { + if ((right & 1U) != 0U) + { + product ^= left; + } + const bool high_bit = (left & 0x80U) != 0U; + left = static_cast(left << 1U); + if (high_bit) + { + left ^= kFieldPolynomialLow; + } + right = static_cast(right >> 1U); + } + return product; +} + +uint8_t gfPower(uint8_t base, uint16_t exponent) +{ + uint8_t result = 1; + while (exponent != 0U) + { + if ((exponent & 1U) != 0U) + { + result = gfMultiply(result, base); + } + base = gfMultiply(base, base); + exponent = static_cast(exponent >> 1U); + } + return result; +} + +uint8_t gfInverse(uint8_t value) +{ + return value == 0U ? 0U : gfPower(value, 254U); +} + +uint8_t parityCoefficient(uint8_t source_index) +{ + // The second parity row is [1, 2, 4, ..., 2^7]. Together with the + // all-one first row, any pair of data columns forms an invertible matrix. + return gfPower(2U, source_index); +} + +bool validShardPointers(uint8_t* const shards[kTotalShardsPerBlock], + std::size_t shard_size) +{ + if (!shards || shard_size == 0U || shard_size > kMaxShardPayloadSize) + { + return false; + } + for (std::size_t index = 0; index < kTotalShardsPerBlock; ++index) + { + if (!shards[index]) + { + return false; + } + } + return true; +} + +} // namespace + +bool encodeRs10_8(const uint8_t* const source_shards[kSourceShardsPerBlock], + std::size_t shard_size, + uint8_t* out_parity0, + uint8_t* out_parity1) +{ + if (!source_shards || !out_parity0 || !out_parity1 || shard_size == 0U || + shard_size > kMaxShardPayloadSize || out_parity0 == out_parity1) + { + return false; + } + + for (std::size_t source = 0; source < kSourceShardsPerBlock; ++source) + { + if (!source_shards[source]) + { + return false; + } + } + + std::memset(out_parity0, 0, shard_size); + std::memset(out_parity1, 0, shard_size); + for (uint8_t source = 0; source < kSourceShardsPerBlock; ++source) + { + const uint8_t coefficient = parityCoefficient(source); + const uint8_t* const input = source_shards[source]; + for (std::size_t byte = 0; byte < shard_size; ++byte) + { + out_parity0[byte] ^= input[byte]; + out_parity1[byte] ^= gfMultiply(coefficient, input[byte]); + } + } + return true; +} + +bool recoverRs10_8(uint8_t* shards[kTotalShardsPerBlock], + bool present[kTotalShardsPerBlock], + std::size_t shard_size) +{ + if (!validShardPointers(shards, shard_size) || !present) + { + return false; + } + + uint8_t missing[kTotalShardsPerBlock] = {}; + std::size_t missing_count = 0; + for (uint8_t index = 0; index < kTotalShardsPerBlock; ++index) + { + if (!present[index]) + { + if (missing_count == 2U) + { + return false; + } + missing[missing_count++] = index; + } + } + if (missing_count == 0U) + { + return true; + } + + uint8_t missing_data[kSourceShardsPerBlock] = {}; + std::size_t missing_data_count = 0; + for (std::size_t index = 0; index < missing_count; ++index) + { + if (missing[index] < kSourceShardsPerBlock) + { + missing_data[missing_data_count++] = missing[index]; + } + } + + if (missing_data_count == 0U) + { + const uint8_t* sources[kSourceShardsPerBlock] = {}; + for (std::size_t index = 0; index < kSourceShardsPerBlock; ++index) + { + sources[index] = shards[index]; + } + if (!encodeRs10_8(sources, shard_size, shards[8], shards[9])) + { + return false; + } + } + else if (missing_data_count == 1U) + { + const uint8_t missing_source = missing_data[0]; + if (present[8]) + { + std::memcpy(shards[missing_source], shards[8], shard_size); + for (uint8_t source = 0; source < kSourceShardsPerBlock; ++source) + { + if (source != missing_source) + { + for (std::size_t byte = 0; byte < shard_size; ++byte) + { + shards[missing_source][byte] ^= shards[source][byte]; + } + } + } + } + else if (present[9]) + { + const uint8_t inverse = gfInverse(parityCoefficient(missing_source)); + if (inverse == 0U) + { + return false; + } + std::memcpy(shards[missing_source], shards[9], shard_size); + for (uint8_t source = 0; source < kSourceShardsPerBlock; ++source) + { + if (source != missing_source) + { + const uint8_t coefficient = parityCoefficient(source); + for (std::size_t byte = 0; byte < shard_size; ++byte) + { + shards[missing_source][byte] ^= + gfMultiply(coefficient, shards[source][byte]); + } + } + } + for (std::size_t byte = 0; byte < shard_size; ++byte) + { + shards[missing_source][byte] = + gfMultiply(inverse, shards[missing_source][byte]); + } + } + else + { + return false; + } + + const uint8_t* sources[kSourceShardsPerBlock] = {}; + for (std::size_t index = 0; index < kSourceShardsPerBlock; ++index) + { + sources[index] = shards[index]; + } + if (!encodeRs10_8(sources, shard_size, shards[8], shards[9])) + { + return false; + } + } + else + { + // Two data erasures require both parity rows. Let S0 = Da ^ Db and + // S1 = Ca*Da ^ Cb*Db. Solving the 2x2 GF(256) system recovers both. + if (!present[8] || !present[9]) + { + return false; + } + + const uint8_t first = missing_data[0]; + const uint8_t second = missing_data[1]; + const uint8_t first_coefficient = parityCoefficient(first); + const uint8_t second_coefficient = parityCoefficient(second); + const uint8_t denominator = first_coefficient ^ second_coefficient; + const uint8_t inverse = gfInverse(denominator); + if (inverse == 0U) + { + return false; + } + + for (std::size_t byte = 0; byte < shard_size; ++byte) + { + uint8_t sum0 = shards[8][byte]; + uint8_t sum1 = shards[9][byte]; + for (uint8_t source = 0; source < kSourceShardsPerBlock; ++source) + { + if (source == first || source == second) + { + continue; + } + sum0 ^= shards[source][byte]; + sum1 ^= gfMultiply(parityCoefficient(source), shards[source][byte]); + } + const uint8_t first_value = gfMultiply( + inverse, + static_cast(sum1 ^ gfMultiply(second_coefficient, sum0))); + shards[first][byte] = first_value; + shards[second][byte] = static_cast(sum0 ^ first_value); + } + } + + for (std::size_t index = 0; index < kTotalShardsPerBlock; ++index) + { + present[index] = true; + } + return true; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_session_state_machine.cpp b/modules/core_chat/src/infra/voice/vmp_session_state_machine.cpp new file mode 100644 index 00000000..9e677e31 --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_session_state_machine.cpp @@ -0,0 +1,244 @@ +/** + * @file vmp_session_state_machine.cpp + * @brief Side-effect-free VMP v1 private/broadcast radio session state machine. + */ + +#include "chat/infra/voice/vmp_session_state_machine.h" + +namespace chat::voice::vmp +{ + +bool SessionTransition::hasAction(SessionAction action) const +{ + for (std::size_t index = 0; index < action_count; ++index) + { + if (actions[index] == action) + { + return true; + } + } + return false; +} + +void SessionStateMachine::addAction(SessionTransition& transition, + SessionAction action) +{ + if (transition.action_count < SessionTransition::kMaxActions) + { + transition.actions[transition.action_count++] = action; + } +} + +SessionTransition SessionStateMachine::transition(SessionState next, + SessionFailure failure) +{ + SessionTransition result{}; + result.accepted = true; + result.previous = state_; + state_ = next; + result.current = state_; + result.failure = failure; + return result; +} + +SessionTransition SessionStateMachine::startSender(DeliveryMode mode) +{ + if (state_ != SessionState::Idle) + { + return SessionTransition{false, state_, state_, SessionFailure::UnexpectedEvent}; + } + + role_ = SessionRole::Sender; + mode_ = mode; + if (mode == DeliveryMode::Private) + { + SessionTransition result = transition(SessionState::AwaitingSubGhzAccept); + addAction(result, SessionAction::SendSubGhzOffer); + return result; + } + + SessionTransition result = transition(SessionState::AwaitingBroadcastDataWindow); + addAction(result, SessionAction::SendSubGhzAnnounce); + return result; +} + +SessionTransition SessionStateMachine::startReceiver(DeliveryMode mode) +{ + if (state_ != SessionState::Idle) + { + return SessionTransition{false, state_, state_, SessionFailure::UnexpectedEvent}; + } + + role_ = SessionRole::Receiver; + mode_ = mode; + SessionTransition result = transition(SessionState::Awaiting2GhzProbe); + if (mode == DeliveryMode::Private) + { + addAction(result, SessionAction::SendSubGhzAccept); + } + addAction(result, SessionAction::SwitchTo2GhzRx); + return result; +} + +SessionTransition SessionStateMachine::dispatch(SessionEvent event) +{ + if (!active()) + { + return SessionTransition{false, state_, state_, SessionFailure::UnexpectedEvent}; + } + + if (event == SessionEvent::RadioFailure) + { + SessionTransition result = transition(SessionState::Failed, SessionFailure::Radio); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + if (event == SessionEvent::LocalStorageFailure) + { + SessionTransition result = transition(SessionState::Failed, SessionFailure::Storage); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + + if (role_ == SessionRole::Sender) + { + switch (state_) + { + case SessionState::AwaitingSubGhzAccept: + if (event == SessionEvent::SubGhzAcceptAuthenticated) + { + SessionTransition result = transition(SessionState::Awaiting2GhzReady); + addAction(result, SessionAction::SwitchTo2GhzTx); + addAction(result, SessionAction::Send2GhzReadyProbeTrain); + return result; + } + if (event == SessionEvent::ControlDeadlineExpired) + { + SessionTransition result = transition(SessionState::Failed, + SessionFailure::AcceptTimeout); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + break; + + case SessionState::Awaiting2GhzReady: + if (event == SessionEvent::TwoGhzReadyAuthenticated) + { + SessionTransition result = transition(SessionState::SendingVoiceMedia); + addAction(result, SessionAction::BeginVoiceMediaTx); + return result; + } + if (event == SessionEvent::ReadyDeadlineExpired) + { + SessionTransition result = transition(SessionState::Failed, + SessionFailure::ReadyTimeout); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + break; + + case SessionState::AwaitingBroadcastDataWindow: + if (event == SessionEvent::BroadcastDataWindowReady) + { + SessionTransition result = transition(SessionState::SendingVoiceMedia); + addAction(result, SessionAction::SwitchTo2GhzTx); + addAction(result, SessionAction::Send2GhzReadyProbeTrain); + addAction(result, SessionAction::BeginVoiceMediaTx); + return result; + } + break; + + case SessionState::SendingVoiceMedia: + if (event == SessionEvent::VoiceDataTrainComplete) + { + SessionTransition result = transition(SessionState::Completed); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + break; + + default: + break; + } + } + else + { + switch (state_) + { + case SessionState::Awaiting2GhzProbe: + if (event == SessionEvent::TwoGhzReadyProbeAuthenticated) + { + SessionTransition result = transition(SessionState::AwaitingVoiceMedia); + if (mode_ == DeliveryMode::Private) + { + addAction(result, SessionAction::Send2GhzReady); + } + return result; + } + if (event == SessionEvent::TwoGhzVoiceShardAuthenticated && + mode_ == DeliveryMode::Broadcast) + { + return transition(SessionState::ReceivingVoiceMedia); + } + if (event == SessionEvent::MediaDeadlineExpired) + { + SessionTransition result = transition(SessionState::Failed, + SessionFailure::NoVoiceMedia); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + break; + + case SessionState::AwaitingVoiceMedia: + if (event == SessionEvent::TwoGhzVoiceShardAuthenticated) + { + return transition(SessionState::ReceivingVoiceMedia); + } + if (event == SessionEvent::MediaDeadlineExpired) + { + SessionTransition result = transition(SessionState::Failed, + SessionFailure::NoVoiceMedia); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + break; + + case SessionState::ReceivingVoiceMedia: + if (event == SessionEvent::FecBlockRecovered) + { + SessionTransition result = transition(SessionState::Completed); + addAction(result, SessionAction::CommitCompleteIncomingVoice); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + if (event == SessionEvent::MediaDeadlineExpiredWithRecoverableData) + { + SessionTransition result = transition(SessionState::Completed); + addAction(result, SessionAction::CommitPartialIncomingVoice); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + if (event == SessionEvent::MediaDeadlineExpired) + { + SessionTransition result = transition(SessionState::Failed, + SessionFailure::NoVoiceMedia); + addAction(result, SessionAction::RestoreSubGhzRx); + return result; + } + break; + + default: + break; + } + } + + return SessionTransition{false, state_, state_, SessionFailure::UnexpectedEvent}; +} + +bool SessionStateMachine::active() const +{ + return state_ != SessionState::Idle && state_ != SessionState::Completed && + state_ != SessionState::Failed; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp b/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp new file mode 100644 index 00000000..7a261f56 --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp @@ -0,0 +1,316 @@ +/** + * @file vmp_voice_inbox.cpp + * @brief Fixed local-only VMP voice-object inbox. + */ + +#include "chat/infra/voice/vmp_voice_inbox.h" + +#include + +namespace chat::voice::vmp +{ +namespace +{ + +void secureClear(uint8_t* bytes, std::size_t size) +{ + volatile uint8_t* cursor = bytes; + while (cursor && size-- != 0U) + { + *cursor++ = 0U; + } +} + +bool validInboundControl(const ControlFrame& control, DeliveryMode* out_mode) +{ + return out_mode && isValidControlFrame(control) && + control.type != ControlType::Cancel && + deliveryModeFor(control, out_mode); +} + +bool validRestoredMetadata(const VoiceMessageMetadata& metadata, + std::size_t encoded_media_len) +{ + if (metadata.local_id == 0U || !metadata.complete || + encoded_media_len == 0U || encoded_media_len > kMaxEncodedMediaSize || + encoded_media_len != metadata.encoded_media_len) + { + return false; + } + + if (metadata.codec != Codec::Codec2_1300) + { + return false; + } + + if (metadata.mode == DeliveryMode::Broadcast) + { + return metadata.target_id == kBroadcastTargetId && + metadata.source_unverified; + } + if (metadata.mode == DeliveryMode::Private) + { + return metadata.sender_id != 0U && metadata.target_id != 0U && + metadata.target_id != kBroadcastTargetId && + !metadata.source_unverified; + } + return false; +} + +uint64_t nextNonZero(uint64_t value) +{ + ++value; + return value == 0U ? 1U : value; +} + +} // namespace + +VoiceInboxStoreResult VoiceMessageInbox::store(const ControlFrame& control, + const uint8_t* encoded_media, + std::size_t encoded_media_len, + bool complete, + uint32_t received_at_seconds, + uint64_t* out_local_id) +{ + if (out_local_id) + { + *out_local_id = 0U; + } + + DeliveryMode mode = DeliveryMode::Private; + if (!encoded_media || encoded_media_len == 0U || + encoded_media_len > kMaxEncodedMediaSize || + encoded_media_len != control.encoded_media_len || + !validInboundControl(control, &mode)) + { + return VoiceInboxStoreResult::Invalid; + } + if (isDuplicate(control)) + { + return VoiceInboxStoreResult::Duplicate; + } + + Slot* const destination = selectDestination(); + if (!destination) + { + return VoiceInboxStoreResult::Invalid; + } + const bool replacing = destination->occupied; + if (replacing) + { + clearSlot(destination); + } + + destination->metadata.local_id = next_local_id_++; + if (next_local_id_ == 0U) + { + next_local_id_ = 1U; + } + destination->metadata.sender_id = control.sender_id; + destination->metadata.target_id = control.target_id; + destination->metadata.session_id = control.session_id; + destination->metadata.object_fingerprint = control.object_fingerprint; + destination->metadata.received_at_seconds = received_at_seconds; + destination->metadata.encoded_media_len = + static_cast(encoded_media_len); + destination->metadata.codec = control.codec; + destination->metadata.mode = mode; + destination->metadata.source_unverified = mode == DeliveryMode::Broadcast; + destination->metadata.complete = complete; + std::memcpy(destination->encoded_media, encoded_media, encoded_media_len); + destination->insertion_sequence = next_insertion_sequence_++; + if (next_insertion_sequence_ == 0U) + { + next_insertion_sequence_ = 1U; + } + destination->occupied = true; + if (!replacing) + { + ++size_; + } + if (out_local_id) + { + *out_local_id = destination->metadata.local_id; + } + return VoiceInboxStoreResult::Stored; +} + +bool VoiceMessageInbox::get(uint64_t local_id, VoiceMessageView* out_view) const +{ + if (!out_view || local_id == 0U) + { + return false; + } + for (const Slot& slot : slots_) + { + if (slot.occupied && slot.metadata.local_id == local_id) + { + out_view->metadata = slot.metadata; + out_view->encoded_media = slot.encoded_media; + return true; + } + } + return false; +} + +std::size_t VoiceMessageInbox::listMetadata(VoiceMessageMetadata* out_metadata, + std::size_t capacity) const +{ + if (!out_metadata || capacity == 0U) + { + return 0U; + } + + std::size_t written = 0U; + uint64_t preceding_sequence = ~uint64_t{0}; + while (written < capacity) + { + const Slot* next = nullptr; + for (const Slot& slot : slots_) + { + if (!slot.occupied || slot.insertion_sequence >= preceding_sequence) + { + continue; + } + if (!next || slot.insertion_sequence > next->insertion_sequence) + { + next = &slot; + } + } + if (!next) + { + break; + } + out_metadata[written++] = next->metadata; + preceding_sequence = next->insertion_sequence; + } + return written; +} + +bool VoiceMessageInbox::restore(const VoiceMessageMetadata& metadata, + const uint8_t* encoded_media, + std::size_t encoded_media_len) +{ + if (!encoded_media || !validRestoredMetadata(metadata, encoded_media_len)) + { + return false; + } + + for (const Slot& slot : slots_) + { + if (!slot.occupied) + { + continue; + } + if (slot.metadata.local_id == metadata.local_id) + { + return slot.metadata.sender_id == metadata.sender_id && + slot.metadata.session_id == metadata.session_id && + slot.metadata.object_fingerprint == + metadata.object_fingerprint && + slot.metadata.encoded_media_len == encoded_media_len; + } + if (slot.metadata.sender_id == metadata.sender_id && + slot.metadata.session_id == metadata.session_id) + { + return true; + } + } + + Slot* const destination = selectDestination(); + if (!destination) + { + return false; + } + const bool replacing = destination->occupied; + if (replacing) + { + clearSlot(destination); + } + + destination->metadata = metadata; + std::memcpy(destination->encoded_media, encoded_media, encoded_media_len); + destination->insertion_sequence = next_insertion_sequence_; + next_insertion_sequence_ = nextNonZero(next_insertion_sequence_); + destination->occupied = true; + if (!replacing) + { + ++size_; + } + if (metadata.local_id >= next_local_id_) + { + next_local_id_ = nextNonZero(metadata.local_id); + } + return true; +} + +bool VoiceMessageInbox::erase(uint64_t local_id) +{ + if (local_id == 0U) + { + return false; + } + for (Slot& slot : slots_) + { + if (slot.occupied && slot.metadata.local_id == local_id) + { + clearSlot(&slot); + --size_; + return true; + } + } + return false; +} + +void VoiceMessageInbox::clear() +{ + for (Slot& slot : slots_) + { + clearSlot(&slot); + } + size_ = 0U; +} + +bool VoiceMessageInbox::isDuplicate(const ControlFrame& control) const +{ + for (const Slot& slot : slots_) + { + if (slot.occupied && slot.metadata.sender_id == control.sender_id && + slot.metadata.session_id == control.session_id) + { + return true; + } + } + return false; +} + +VoiceMessageInbox::Slot* VoiceMessageInbox::selectDestination() +{ + Slot* oldest = nullptr; + for (Slot& slot : slots_) + { + if (!slot.occupied) + { + return &slot; + } + if (!oldest || slot.insertion_sequence < oldest->insertion_sequence) + { + oldest = &slot; + } + } + return oldest; +} + +void VoiceMessageInbox::clearSlot(Slot* slot) +{ + if (!slot) + { + return; + } + secureClear(slot->encoded_media, sizeof(slot->encoded_media)); + slot->metadata = {}; + slot->insertion_sequence = 0U; + slot->occupied = false; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/src/infra/voice/vmp_wire.cpp b/modules/core_chat/src/infra/voice/vmp_wire.cpp new file mode 100644 index 00000000..7488c540 --- /dev/null +++ b/modules/core_chat/src/infra/voice/vmp_wire.cpp @@ -0,0 +1,416 @@ +/** + * @file vmp_wire.cpp + * @brief Trail Mate Voice Message Protocol (VMP) v1 binary framing. + */ + +#include "chat/infra/voice/vmp_wire.h" + +#include + +namespace chat::voice::vmp +{ +namespace +{ + +constexpr uint8_t kControlMagic0 = 'V'; +constexpr uint8_t kControlMagic1 = 'M'; +constexpr uint8_t kDataMagic0 = 'V'; +constexpr uint8_t kDataMagic1 = 'D'; +constexpr std::size_t kControlEphemeralKeyOffset = 47; +constexpr std::size_t kControlIntegrityTagOffset = + kControlEphemeralKeyOffset + kEphemeralPublicKeySize; + +void writeU16(uint16_t value, uint8_t* out) +{ + out[0] = static_cast(value >> 8U); + out[1] = static_cast(value); +} + +void writeU32(uint32_t value, uint8_t* out) +{ + out[0] = static_cast(value >> 24U); + out[1] = static_cast(value >> 16U); + out[2] = static_cast(value >> 8U); + out[3] = static_cast(value); +} + +void writeU64(uint64_t value, uint8_t* out) +{ + for (std::size_t index = 0; index < sizeof(value); ++index) + { + const std::size_t shift = (sizeof(value) - 1U - index) * 8U; + out[index] = static_cast(value >> shift); + } +} + +uint16_t readU16(const uint8_t* data) +{ + return (static_cast(data[0]) << 8U) | + static_cast(data[1]); +} + +uint32_t readU32(const uint8_t* data) +{ + return (static_cast(data[0]) << 24U) | + (static_cast(data[1]) << 16U) | + (static_cast(data[2]) << 8U) | + static_cast(data[3]); +} + +uint64_t readU64(const uint8_t* data) +{ + uint64_t value = 0; + for (std::size_t index = 0; index < sizeof(value); ++index) + { + value = (value << 8U) | data[index]; + } + return value; +} + +bool isKnownControlType(ControlType type) +{ + return type == ControlType::Offer || type == ControlType::Accept || + type == ControlType::Announce || type == ControlType::Cancel; +} + +bool isKnownCodec(Codec codec) +{ + return codec == Codec::Codec2_1300 || codec == Codec::Codec2_1200; +} + +bool isAllZero(const uint8_t* data, std::size_t len) +{ + if (!data) + { + return true; + } + for (std::size_t index = 0; index < len; ++index) + { + if (data[index] != 0) + { + return false; + } + } + return true; +} + +bool hasOnlyKnownControlFlags(uint8_t flags) +{ + constexpr uint8_t kKnownFlags = ControlFlagPrivate | + ControlFlagBroadcast | + ControlFlagPublicBroadcast | + ControlFlagReticulumCarrierHint; + return (flags & ~kKnownFlags) == 0; +} + +bool hasOnlyKnownDataFlags(uint8_t flags) +{ + constexpr uint8_t kKnownFlags = DataFlagFinalBlock | + DataFlagPartialSource; + return (flags & ~kKnownFlags) == 0; +} + +} // namespace + +bool deliveryModeFor(const ControlFrame& frame, DeliveryMode* out_mode) +{ + if (!out_mode) + { + return false; + } + + const bool is_private = (frame.flags & ControlFlagPrivate) != 0; + const bool is_broadcast = (frame.flags & ControlFlagBroadcast) != 0; + if (is_private == is_broadcast) + { + return false; + } + + *out_mode = is_private ? DeliveryMode::Private : DeliveryMode::Broadcast; + return true; +} + +bool planMediaLayout(uint16_t encoded_media_len, MediaLayout* out_layout) +{ + if (!out_layout || encoded_media_len == 0 || + encoded_media_len > kMaxEncodedMediaSize) + { + return false; + } + + const std::size_t source_shard_count = + (static_cast(encoded_media_len) + kMaxShardPayloadSize - 1U) / + kMaxShardPayloadSize; + const std::size_t block_count = + (source_shard_count + kSourceShardsPerBlock - 1U) / + kSourceShardsPerBlock; + if (source_shard_count == 0 || block_count == 0 || block_count > kMaxBlocks) + { + return false; + } + + *out_layout = MediaLayout{}; + out_layout->encoded_media_len = encoded_media_len; + out_layout->source_shard_count = static_cast(source_shard_count); + out_layout->block_count = static_cast(block_count); + out_layout->data_frame_count = static_cast( + block_count * kTotalShardsPerBlock); + return true; +} + +std::size_t sourceShardPayloadSize(const MediaLayout& layout, + uint8_t block_index, + uint8_t shard_index) +{ + if (layout.encoded_media_len == 0 || + layout.source_shard_count == 0 || + layout.block_count == 0 || + block_index >= layout.block_count || + shard_index >= kSourceShardsPerBlock) + { + return 0; + } + + const std::size_t source_index = + static_cast(block_index) * kSourceShardsPerBlock + shard_index; + if (source_index >= layout.source_shard_count) + { + return 0; + } + + const std::size_t byte_offset = source_index * kMaxShardPayloadSize; + const std::size_t bytes_remaining = + static_cast(layout.encoded_media_len) - byte_offset; + return bytes_remaining < kMaxShardPayloadSize ? bytes_remaining + : kMaxShardPayloadSize; +} + +bool isValidControlFrame(const ControlFrame& frame) +{ + if (!isKnownControlType(frame.type) || !hasOnlyKnownControlFlags(frame.flags) || + frame.sender_id == 0 || frame.session_id == 0 || + isAllZero(frame.session_nonce, sizeof(frame.session_nonce))) + { + return false; + } + + DeliveryMode mode = DeliveryMode::Private; + if (!deliveryModeFor(frame, &mode)) + { + return false; + } + + if (mode == DeliveryMode::Private) + { + if (frame.target_id == 0 || frame.target_id == kBroadcastTargetId || + (frame.flags & ControlFlagPublicBroadcast) != 0) + { + return false; + } + if (frame.type != ControlType::Offer && frame.type != ControlType::Accept && + frame.type != ControlType::Cancel) + { + return false; + } + } + else + { + if (frame.target_id != kBroadcastTargetId || frame.type == ControlType::Accept || + frame.key_or_profile_id != 0 || + (frame.flags & ControlFlagPublicBroadcast) == 0) + { + return false; + } + } + + if (frame.type == ControlType::Cancel) + { + return frame.encoded_media_len == 0 && frame.total_blocks == 0 && + (mode == DeliveryMode::Private || + isAllZero(frame.ephemeral_public_key, + sizeof(frame.ephemeral_public_key))); + } + + if (!isKnownCodec(frame.codec) || frame.fec_layout != kFecLayoutRs10_8 || + frame.data_start_delay_ms == 0) + { + return false; + } + + MediaLayout layout{}; + if (!planMediaLayout(frame.encoded_media_len, &layout) || + layout.block_count != frame.total_blocks) + { + return false; + } + + return mode == DeliveryMode::Private + ? !isAllZero(frame.ephemeral_public_key, + sizeof(frame.ephemeral_public_key)) + : isAllZero(frame.ephemeral_public_key, + sizeof(frame.ephemeral_public_key)); +} + +bool encodeControlFrame(const ControlFrame& frame, + uint8_t* out, + std::size_t* inout_len) +{ + if (!out || !inout_len || !isValidControlFrame(frame)) + { + return false; + } + if (*inout_len < kControlFrameSize) + { + *inout_len = kControlFrameSize; + return false; + } + + out[0] = kControlMagic0; + out[1] = kControlMagic1; + out[2] = kVersion; + out[3] = static_cast(frame.type); + out[4] = frame.flags; + out[5] = frame.key_or_profile_id; + writeU32(frame.sender_id, out + 6); + writeU32(frame.target_id, out + 10); + writeU64(frame.session_id, out + 14); + std::memcpy(out + 22, frame.session_nonce, sizeof(frame.session_nonce)); + out[34] = frame.phy_profile_id; + out[35] = frame.channel_index; + writeU16(frame.encoded_media_len, out + 36); + out[38] = static_cast(frame.codec); + out[39] = frame.fec_layout; + out[40] = frame.total_blocks; + writeU16(frame.data_start_delay_ms, out + 41); + writeU32(frame.object_fingerprint, out + 43); + std::memcpy(out + kControlEphemeralKeyOffset, + frame.ephemeral_public_key, + sizeof(frame.ephemeral_public_key)); + std::memcpy(out + kControlIntegrityTagOffset, + frame.integrity_tag, + sizeof(frame.integrity_tag)); + *inout_len = kControlFrameSize; + return true; +} + +bool decodeControlFrame(const uint8_t* data, + std::size_t len, + ControlFrame* out_frame) +{ + if (!data || !out_frame || len != kControlFrameSize || + data[0] != kControlMagic0 || data[1] != kControlMagic1 || + data[2] != kVersion) + { + return false; + } + + ControlFrame frame{}; + frame.type = static_cast(data[3]); + frame.flags = data[4]; + frame.key_or_profile_id = data[5]; + frame.sender_id = readU32(data + 6); + frame.target_id = readU32(data + 10); + frame.session_id = readU64(data + 14); + std::memcpy(frame.session_nonce, data + 22, sizeof(frame.session_nonce)); + frame.phy_profile_id = data[34]; + frame.channel_index = data[35]; + frame.encoded_media_len = readU16(data + 36); + frame.codec = static_cast(data[38]); + frame.fec_layout = data[39]; + frame.total_blocks = data[40]; + frame.data_start_delay_ms = readU16(data + 41); + frame.object_fingerprint = readU32(data + 43); + std::memcpy(frame.ephemeral_public_key, + data + kControlEphemeralKeyOffset, + sizeof(frame.ephemeral_public_key)); + std::memcpy(frame.integrity_tag, + data + kControlIntegrityTagOffset, + sizeof(frame.integrity_tag)); + if (!isValidControlFrame(frame)) + { + return false; + } + + *out_frame = frame; + return true; +} + +bool isValidDataHeader(const DataHeader& header) +{ + if (header.session_id == 0 || !hasOnlyKnownDataFlags(header.flags)) + { + return false; + } + + if (header.type == DataType::ReadyProbe || header.type == DataType::Ready) + { + return header.payload_len == 0 && header.block_index == 0 && + header.shard_index == 0 && header.flags == 0; + } + + if (header.type != DataType::Shard || header.block_index >= kMaxBlocks || + header.shard_index >= kTotalShardsPerBlock || header.payload_len == 0 || + header.payload_len > kMaxShardPayloadSize) + { + return false; + } + + const bool is_source = header.shard_index < kSourceShardsPerBlock; + return is_source || (header.flags & DataFlagPartialSource) == 0; +} + +bool encodeDataHeader(const DataHeader& header, + uint8_t* out, + std::size_t* inout_len) +{ + if (!out || !inout_len || !isValidDataHeader(header)) + { + return false; + } + if (*inout_len < kDataHeaderSize) + { + *inout_len = kDataHeaderSize; + return false; + } + + out[0] = kDataMagic0; + out[1] = kDataMagic1; + out[2] = kVersion; + out[3] = static_cast(header.type); + writeU64(header.session_id, out + 4); + out[12] = header.block_index; + out[13] = header.shard_index; + out[14] = header.payload_len; + out[15] = header.flags; + *inout_len = kDataHeaderSize; + return true; +} + +bool decodeDataHeader(const uint8_t* data, + std::size_t len, + DataHeader* out_header) +{ + if (!data || !out_header || len != kDataHeaderSize || + data[0] != kDataMagic0 || data[1] != kDataMagic1 || + data[2] != kVersion) + { + return false; + } + + DataHeader header{}; + header.type = static_cast(data[3]); + header.session_id = readU64(data + 4); + header.block_index = data[12]; + header.shard_index = data[13]; + header.payload_len = data[14]; + header.flags = data[15]; + if (!isValidDataHeader(header)) + { + return false; + } + + *out_header = header; + return true; +} + +} // namespace chat::voice::vmp diff --git a/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp new file mode 100644 index 00000000..a274ec53 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include + +namespace +{ + +std::string readFile(const std::filesystem::path& path) +{ + std::ifstream input(path, std::ios::binary); + assert(input.good()); + std::ostringstream output; + output << input.rdbuf(); + return output.str(); +} + +std::size_t positionOf(const std::string& source, const std::string& needle) +{ + const std::size_t position = source.find(needle); + assert(position != std::string::npos); + return position; +} + +} // namespace + +// This is deliberately a source-level boundary test. The ESP attachment +// adapter depends on SdFat/Arduino, while its most important regressions are +// architectural: bypassing text-storage hydration, exposing an object before +// a durable commit, or adding a bearer-side escape to local attachment data. +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path root = argv[1]; + const std::string session = readFile( + root / "platform/esp/arduino_common/src/voice/vmp_pager_session.cpp"); + const std::string app = readFile( + root / "platform/esp/arduino_common/src/app_context.cpp"); + const std::string bindings = readFile( + root / "platform/esp/arduino_common/src/app_context_platform_bindings.cpp"); + const std::string attachment = readFile( + root / "platform/esp/arduino_common/src/chat/infra/store/" + "message_attachment_store.cpp"); + const std::string attachment_header = readFile( + root / "platform/esp/arduino_common/include/platform/esp/arduino_common/" + "chat/infra/store/message_attachment_store.h"); + const std::string pager_header = readFile( + root / "platform/esp/arduino_common/include/platform/esp/arduino_common/" + "voice/vmp_pager_session.h"); + const std::string pager_audio = readFile( + root / "platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp"); + + const std::size_t store_completed = positionOf(session, "bool storeCompletedVoice("); + const std::size_t durable_commit = positionOf(session, "persistVoiceInbox("); + const std::size_t rollback = positionOf(session, "media_->inbox.erase(local_id)"); + assert(store_completed < durable_commit); + assert(durable_commit < rollback); + assert(session.find("requires_durable_attachment_store_") != + std::string::npos); + assert(session.find("inbox_ready_") != std::string::npos); + assert(session.find("servicePersistentInbox") != std::string::npos); + + assert(app.find("getSelfNodeId(), deferred_storage_store_context_ != nullptr") != + std::string::npos); + assert(app.find("vmp_session::servicePersistentInbox()") != + std::string::npos); + assert(app.find("MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT") != + std::string::npos); + assert(app.find("UI metadata scratch unavailable in PSRAM") != + std::string::npos); + assert(bindings.find("vmp_session::onPersistentStorageReady()") != + std::string::npos); + + assert(attachment.find("/data/v2/attachments/voice/inbox.v1") != + std::string::npos); + assert(attachment.find("inbox.v1.tmp") != std::string::npos); + assert(attachment.find("inbox.v1.bak") != std::string::npos); + assert(attachment.find("payload_crc32") != std::string::npos); + assert(attachment.find("restoreVoiceInboxSnapshot") != std::string::npos); + assert(attachment.find("const VoiceInboxLoadResult backup_result") != + std::string::npos); + assert(attachment.find("return backup_result;") != std::string::npos); + // The prior snapshot must survive a successful temporary-to-primary + // rename, otherwise a detected primary corruption has nothing to restore. + assert(attachment.find("if (moved_current)\n {\n (void)storage::sd_remove(kVoiceSnapshotBackupPath);") == + std::string::npos); + assert(attachment.find("AttachmentKind::Voice") != std::string::npos); + assert(attachment_header.find("Image = 2U") != std::string::npos); + assert(attachment_header.find("Location = 3U") != std::string::npos); + assert(attachment.find("radio::") == std::string::npos); + assert(attachment.find("mqtt_") == std::string::npos); + assert(attachment.find("lxmf_") == std::string::npos); + + // The Pager's SX1262 variant can only create the isolated MQTT plan. It + // must never become a hidden direct-RF/LXMF fallback merely because the + // shared VMP session is compiled for both Pager radio variants. + assert(session.find("#if defined(ARDUINO_T_LORA_PAGER)") != + std::string::npos); + assert(session.find("direct_rf_voice_supported_") != std::string::npos); + assert(session.find("sent = queueMqttPublication();") != std::string::npos); + assert(session.find("return direct_rf_voice_supported_ && source_id != 0U") != + std::string::npos); + assert(pager_header.find("SX1262 never has a") != std::string::npos); + assert(pager_audio.find("#if defined(ARDUINO_T_LORA_PAGER)") != + std::string::npos); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_contact_secrets.cpp b/modules/core_chat/tests/test_vmp_contact_secrets.cpp new file mode 100644 index 00000000..a180036c --- /dev/null +++ b/modules/core_chat/tests/test_vmp_contact_secrets.cpp @@ -0,0 +1,71 @@ +#include "chat/infra/voice/vmp_contact_secrets.h" + +#include +#include +#include +#include + +namespace +{ + +using chat::voice::vmp::FixedVerifiedContactSecretDirectory; +using chat::voice::vmp::kPrivateKeySize; + +std::array secret(uint8_t seed) +{ + std::array result{}; + for (std::size_t index = 0U; index < result.size(); ++index) + { + result[index] = static_cast(seed + index + 1U); + } + return result; +} + +void test_directory_rejects_unverified_or_broadcast_entries() +{ + FixedVerifiedContactSecretDirectory directory; + const std::array zero{}; + assert(!directory.upsertVerifiedContactSecret(0U, zero.data())); + assert(!directory.upsertVerifiedContactSecret(0xFFFFFFFFU, zero.data())); + assert(!directory.upsertVerifiedContactSecret(42U, zero.data())); + assert(directory.size() == 0U); +} + +void test_directory_replaces_and_clears_contact_secrets() +{ + FixedVerifiedContactSecretDirectory directory; + const auto first = secret(0x10U); + const auto replacement = secret(0x40U); + std::array copied{}; + + assert(directory.upsertVerifiedContactSecret(0x01020304U, first.data())); + assert(directory.size() == 1U); + assert(directory.hasVerifiedContactSecret(0x01020304U)); + assert(directory.lookupVerifiedContactSecret(0x01020304U, copied.data())); + assert(copied == first); + + assert(directory.upsertVerifiedContactSecret(0x01020304U, replacement.data())); + assert(directory.size() == 1U); + assert(directory.lookupVerifiedContactSecret(0x01020304U, copied.data())); + assert(copied == replacement); + + copied.fill(0xA5U); + assert(!directory.lookupVerifiedContactSecret(0x55667788U, copied.data())); + for (uint8_t byte : copied) + { + assert(byte == 0U); + } + + assert(directory.removeVerifiedContactSecret(0x01020304U)); + assert(directory.size() == 0U); + assert(!directory.hasVerifiedContactSecret(0x01020304U)); +} + +} // namespace + +int main() +{ + test_directory_rejects_unverified_or_broadcast_entries(); + test_directory_replaces_and_clears_contact_secrets(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_control_auth.cpp b/modules/core_chat/tests/test_vmp_control_auth.cpp new file mode 100644 index 00000000..0bf8d8c4 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_control_auth.cpp @@ -0,0 +1,78 @@ +#include "chat/infra/voice/vmp_control_auth.h" + +#include +#include +#include +#include +#include + +namespace +{ + +using namespace chat::voice::vmp; + +void fill(uint8_t* out, std::size_t len, uint8_t seed) +{ + for (std::size_t index = 0; index < len; ++index) + { + out[index] = static_cast(seed + index * 23U); + } +} + +ControlFrame makeBroadcastAnnounce() +{ + ControlFrame frame{}; + frame.type = ControlType::Announce; + frame.flags = ControlFlagBroadcast | ControlFlagPublicBroadcast; + frame.sender_id = 42U; + frame.target_id = kBroadcastTargetId; + frame.session_id = 0x9988776655443322ULL; + fill(frame.session_nonce, sizeof(frame.session_nonce), 0x33U); + frame.phy_profile_id = 1U; + frame.channel_index = 6U; + frame.encoded_media_len = 875U; + frame.codec = Codec::Codec2_1300; + frame.fec_layout = kFecLayoutRs10_8; + frame.total_blocks = 1U; + frame.data_start_delay_ms = 700U; + frame.object_fingerprint = 0x12345678U; + return frame; +} + +void test_public_control_corruption_is_rejected() +{ + const ControlFrame frame = makeBroadcastAnnounce(); + std::array encoded{}; + std::size_t encoded_len = encoded.size(); + assert(encodePublicControlFrame(frame, encoded.data(), &encoded_len)); + assert(encoded_len == encoded.size()); + + ControlFrame decoded{}; + assert(decodePublicControlFrame(encoded.data(), encoded.size(), &decoded)); + assert(decoded.sender_id == frame.sender_id); + assert(decoded.session_id == frame.session_id); + encoded[36] ^= 0x01U; + assert(!decodePublicControlFrame(encoded.data(), encoded.size(), &decoded)); +} + +void test_broadcast_cannot_use_private_encoder() +{ + const ControlFrame frame = makeBroadcastAnnounce(); + PrivateSessionKeys keys{}; + std::array encoded{}; + std::size_t encoded_len = encoded.size(); + assert(!encodePrivateControlFrame(frame, + keys, + PrivateFrameDirection::SenderToReceiver, + encoded.data(), + &encoded_len)); +} + +} // namespace + +int main() +{ + test_public_control_corruption_is_rejected(); + test_broadcast_cannot_use_private_encoder(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_control_ingress.cpp b/modules/core_chat/tests/test_vmp_control_ingress.cpp new file mode 100644 index 00000000..4b2ec56e --- /dev/null +++ b/modules/core_chat/tests/test_vmp_control_ingress.cpp @@ -0,0 +1,77 @@ +#include "chat/infra/voice/vmp_control_ingress.h" + +#include +#include +#include +#include +#include + +namespace +{ + +using namespace chat::voice::vmp; + +class RecordingSink final : public IControlEnvelopeSink +{ + public: + bool enqueueControl(const uint8_t* data, + std::size_t size, + const ControlRxMetadata& metadata) override + { + ++calls; + last_size = size; + last_metadata = metadata; + std::memcpy(last_bytes.data(), data, size); + return accepts; + } + + bool accepts = true; + std::size_t calls = 0; + std::size_t last_size = 0; + ControlRxMetadata last_metadata = {}; + std::array last_bytes = {}; +}; + +void test_non_vmp_leaves_mesh_path_unchanged() +{ + ControlIngress ingress{}; + std::array packet{}; + packet[0] = static_cast('M'); + packet[1] = static_cast('T'); + packet[2] = kVersion; + assert(!ingress.tryConsume(packet.data(), packet.size(), {})); + assert(!ingress.tryConsume(packet.data(), packet.size() - 1U, {})); +} + +void test_vmp_is_consumed_even_when_bounded_sink_is_full() +{ + ControlIngress ingress{}; + RecordingSink sink{}; + ingress.setSink(&sink); + + std::array packet{}; + packet[0] = static_cast('V'); + packet[1] = static_cast('M'); + packet[2] = kVersion; + packet[3] = static_cast(ControlType::Offer); + const ControlRxMetadata metadata{-73.25f, 5.5f}; + assert(ingress.tryConsume(packet.data(), packet.size(), metadata)); + assert(sink.calls == 1U); + assert(sink.last_size == packet.size()); + assert(std::memcmp(sink.last_bytes.data(), packet.data(), packet.size()) == 0); + assert(sink.last_metadata.rssi == metadata.rssi); + assert(sink.last_metadata.snr == metadata.snr); + + sink.accepts = false; + assert(ingress.tryConsume(packet.data(), packet.size(), metadata)); + assert(sink.calls == 2U); +} + +} // namespace + +int main() +{ + test_non_vmp_leaves_mesh_path_unchanged(); + test_vmp_is_consumed_even_when_bounded_sink_is_full(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_lxmf_isolation_contract.cpp b/modules/core_chat/tests/test_vmp_lxmf_isolation_contract.cpp new file mode 100644 index 00000000..d12679d5 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_lxmf_isolation_contract.cpp @@ -0,0 +1,82 @@ +#include +#include +#include +#include +#include + +namespace +{ + +std::string readFile(const std::filesystem::path& path) +{ + std::ifstream stream(path, std::ios::binary); + assert(stream.is_open()); + std::ostringstream out; + out << stream.rdbuf(); + return out.str(); +} + +std::size_t positionOf(const std::string& text, const char* needle) +{ + const std::size_t position = text.find(needle); + assert(position != std::string::npos); + return position; +} + +std::size_t positionOfAfter(const std::string& text, + const char* needle, + std::size_t offset) +{ + const std::size_t position = text.find(needle, offset); + assert(position != std::string::npos); + return position; +} + +} // namespace + +// LXMF is permitted to carry VMP, but a received VMP envelope must end at the +// local voice inbox. It may not leak into the generic AppData queue, where a +// future service could bridge it into a different protocol or radio bearer. +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path root = argv[1]; + const std::string session_header = readFile( + root / "platform/esp/arduino_common/include/platform/esp/arduino_common/voice/" + "vmp_pager_session.h"); + const std::string session_source = readFile( + root / "platform/esp/arduino_common/src/voice/vmp_pager_session.cpp"); + const std::string lxmf_source = readFile( + root / "platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp"); + const std::string app_source = readFile( + root / "platform/esp/arduino_common/src/app_context.cpp"); + + assert(session_header.find("kLxmfAppDataPort = 0x564D5001UL") != std::string::npos); + assert(session_header.find("bool acceptLxmfEnvelope(") != std::string::npos); + + const std::size_t lxmf_port_branch = positionOf( + lxmf_source, + "delivery.app_data.incoming.portnum ==\n" + " ::platform::esp::arduino_common::voice::vmp_session::kLxmfAppDataPort"); + const std::size_t generic_queue_push = positionOfAfter( + lxmf_source, "data_receive_queue_.push(", lxmf_port_branch); + const std::string port_branch = + lxmf_source.substr(lxmf_port_branch, generic_queue_push - lxmf_port_branch); + assert(port_branch.find("acceptLxmfEnvelope(") != std::string::npos); + assert(port_branch.find("local_only=%u") != std::string::npos); + assert(port_branch.find("return true;") != std::string::npos); + assert(port_branch.find("data_receive_queue_") == std::string::npos); + + const std::size_t send_begin = positionOf(session_source, "bool sendLxmfVoice()"); + const std::size_t control_begin = positionOfAfter( + session_source, "bool prepareOutboundControl()", send_begin); + const std::string send_body = session_source.substr(send_begin, control_begin - send_begin); + assert(send_body.find("preparePrivate(") != std::string::npos); + assert(send_body.find("radio::") == std::string::npos); + assert(send_body.find("acknowledgement, a relay, or a VMP resend") != std::string::npos); + + assert(app_source.find("sendVmpLxmfEnvelope") != std::string::npos); + assert(app_source.find("kLxmfAppDataPort") != std::string::npos); + assert(app_source.find("isReticulumMeshProtocol") != std::string::npos); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_media_frames.cpp b/modules/core_chat/tests/test_vmp_media_frames.cpp new file mode 100644 index 00000000..705233e5 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_media_frames.cpp @@ -0,0 +1,117 @@ +#include "chat/infra/voice/vmp_media_frames.h" +#include "chat/infra/voice/vmp_receive_block.h" + +#include +#include +#include +#include +#include + +namespace +{ + +using namespace chat::voice::vmp; + +void fillMedia(uint8_t* media, std::size_t len) +{ + for (std::size_t index = 0; index < len; ++index) + { + media[index] = static_cast((index * 17U) ^ (index >> 3U)); + } +} + +void test_exactly_ten_public_frames_recover_encoded_object() +{ + std::array media{}; + fillMedia(media.data(), media.size()); + TransmitBlock sender{}; + assert(sender.prepare(media.data(), media.size())); + assert(sender.layout().data_frame_count == kTotalShardsPerBlock); + + ReceiveBlock receiver{}; + assert(receiver.begin(sender.layout())); + std::array frame{}; + for (uint8_t index = 0; index < kTotalShardsPerBlock; ++index) + { + std::size_t frame_len = frame.size(); + assert(sender.buildPublicShardFrame(0xAABBCCDDEEFF0011ULL, + index, + frame.data(), + &frame_len)); + assert(frame_len == frame.size()); + DataHeader header{}; + const uint8_t* shard = nullptr; + assert(parsePublicShardFrame(frame.data(), frame_len, &header, &shard)); + const ReceiveBlockResult result = receiver.accept( + header, shard, kMaxShardPayloadSize); + assert(result == ReceiveBlockResult::Accepted || + result == ReceiveBlockResult::Complete); + } + + std::array recovered{}; + std::size_t recovered_len = 0; + assert(receiver.recover(recovered.data(), recovered.size(), &recovered_len)); + assert(recovered_len == media.size()); + assert(std::memcmp(recovered.data(), media.data(), media.size()) == 0); +} + +void test_broadcast_crc_rejects_tampering() +{ + std::array media{}; + fillMedia(media.data(), media.size()); + TransmitBlock sender{}; + assert(sender.prepare(media.data(), media.size())); + std::array frame{}; + std::size_t frame_len = frame.size(); + assert(sender.buildPublicShardFrame(8U, 0U, frame.data(), &frame_len)); + frame[kDataHeaderSize + 10U] ^= 0x80U; + DataHeader header{}; + const uint8_t* shard = nullptr; + assert(!parsePublicShardFrame(frame.data(), frame_len, &header, &shard)); +} + +void test_partial_source_flag_is_metadata_not_short_air_frame() +{ + std::array media{}; + fillMedia(media.data(), media.size()); + TransmitBlock sender{}; + assert(sender.prepare(media.data(), media.size())); + std::array frame{}; + std::size_t frame_len = frame.size(); + assert(sender.buildPublicShardFrame(9U, 1U, frame.data(), &frame_len)); + DataHeader header{}; + const uint8_t* shard = nullptr; + assert(parsePublicShardFrame(frame.data(), frame_len, &header, &shard)); + assert(header.payload_len == kMaxShardPayloadSize); + assert((header.flags & DataFlagPartialSource) != 0U); + assert(shard[1] == 0U); +} + +void test_public_ready_probe_has_no_media_and_detects_corruption() +{ + DataHeader header{}; + header.type = DataType::ReadyProbe; + header.session_id = 0x0102030405060708ULL; + std::array frame{}; + std::size_t frame_len = frame.size(); + assert(buildPublicReadyFrame(header, frame.data(), &frame_len)); + assert(frame_len == frame.size()); + + DataHeader decoded{}; + assert(parsePublicReadyFrame(frame.data(), frame.size(), &decoded)); + assert(decoded.type == DataType::ReadyProbe); + assert(decoded.payload_len == 0U); + frame[4] ^= 0x01U; + assert(!parsePublicReadyFrame(frame.data(), frame.size(), &decoded)); +} + +} // namespace + +int main() +{ + test_exactly_ten_public_frames_recover_encoded_object(); + test_broadcast_crc_rejects_tampering(); + test_partial_source_flag_is_metadata_not_short_air_frame(); + test_public_ready_probe_has_no_media_and_detects_corruption(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_mqtt_isolation_contract.cpp b/modules/core_chat/tests/test_vmp_mqtt_isolation_contract.cpp new file mode 100644 index 00000000..92d23dde --- /dev/null +++ b/modules/core_chat/tests/test_vmp_mqtt_isolation_contract.cpp @@ -0,0 +1,83 @@ +#include +#include +#include +#include +#include + +namespace +{ + +std::string readFile(const std::filesystem::path& path) +{ + std::ifstream stream(path, std::ios::binary); + assert(stream.is_open()); + std::ostringstream out; + out << stream.rdbuf(); + return out.str(); +} + +std::size_t positionOf(const std::string& text, const char* needle) +{ + const std::size_t position = text.find(needle); + assert(position != std::string::npos); + return position; +} + +std::size_t positionOfAfter(const std::string& text, + const char* needle, + std::size_t offset) +{ + const std::size_t position = text.find(needle, offset); + assert(position != std::string::npos); + return position; +} + +} // namespace + +// VMP MQTT is deliberately a store-and-play carrier. It must never enter the +// generic Meshtastic MQTT bridge because that path may inject a packet onto +// Sub-GHz. Keep this source-level contract near the VMP unit tests so a future +// bridge refactor cannot silently turn a received voice message into a relay. +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::string source = readFile( + std::filesystem::path(argv[1]) / + "platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp"); + + const std::size_t vmp_publish_begin = positionOf(source, "bool flushVmpPublish()"); + const std::size_t publish_queue_begin = + positionOf(source, "void flushPublishQueue(chat::meshtastic::MtAdapter* mt,"); + const std::string vmp_publish = + source.substr(vmp_publish_begin, publish_queue_begin - vmp_publish_begin); + const std::size_t peek = positionOf(vmp_publish, "peekMqttEnvelope("); + const std::size_t socket_write = positionOf(vmp_publish, "sendPublishRaw("); + const std::size_t commit = positionOf(vmp_publish, "acknowledgeMqttEnvelope()"); + assert(peek < socket_write); + assert(socket_write < commit); + assert(vmp_publish.find("retained_for_retry=1") != std::string::npos); + + const std::size_t vmp_inbound_begin = positionOf( + source, + "if (config_.protocol == RuntimeProtocol::Meshtastic &&\n" + " isVmpTopic(topic, topic_len))"); + const std::size_t meshcore_begin = positionOfAfter( + source, + "if (config_.protocol == RuntimeProtocol::MeshCore)", + vmp_inbound_begin); + const std::size_t generic_mt_begin = positionOfAfter( + source, + "const bool ok = mt->handleMqttProxyMessage(mt_proxy_);", + vmp_inbound_begin); + const std::string vmp_inbound = + source.substr(vmp_inbound_begin, meshcore_begin - vmp_inbound_begin); + + assert(vmp_inbound_begin < generic_mt_begin); + assert(vmp_inbound.find("acceptMqttEnvelope(") != std::string::npos); + assert(vmp_inbound.find("local_only=%u") != std::string::npos); + assert(vmp_inbound.find("return;") != std::string::npos); + assert(vmp_inbound.find("handleMqttProxyMessage") == std::string::npos); + assert(vmp_inbound.find("sendPublishRaw") == std::string::npos); + + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_mqtt_transport.cpp b/modules/core_chat/tests/test_vmp_mqtt_transport.cpp new file mode 100644 index 00000000..7cb05e0f --- /dev/null +++ b/modules/core_chat/tests/test_vmp_mqtt_transport.cpp @@ -0,0 +1,241 @@ +#include "chat/infra/voice/vmp_mqtt_transport.h" + +#include +#include +#include +#include +#include + +namespace +{ + +using namespace chat::voice::vmp; + +ControlFrame publicControl(uint16_t encoded_media_len) +{ + ControlFrame control{}; + control.type = ControlType::Announce; + control.flags = ControlFlagBroadcast | ControlFlagPublicBroadcast; + control.sender_id = 0x1010U; + control.target_id = kBroadcastTargetId; + control.session_id = 0x1020304050607080ULL; + for (std::size_t index = 0U; index < kSessionNonceSize; ++index) + { + control.session_nonce[index] = static_cast(index + 1U); + } + control.phy_profile_id = 1U; + control.channel_index = 3U; + control.encoded_media_len = encoded_media_len; + control.codec = Codec::Codec2_1300; + control.fec_layout = kFecLayoutRs10_8; + control.total_blocks = 1U; + control.data_start_delay_ms = 700U; + control.object_fingerprint = 0xCC33AA55U; + return control; +} + +ControlFrame privateControl(uint16_t encoded_media_len) +{ + ControlFrame control{}; + control.type = ControlType::Offer; + control.flags = ControlFlagPrivate; + control.sender_id = 0x1010U; + control.target_id = 0x2020U; + control.session_id = 0x0A0B0C0D0E0F1011ULL; + for (std::size_t index = 0U; index < kSessionNonceSize; ++index) + { + control.session_nonce[index] = static_cast(0x80U + index); + } + for (std::size_t index = 0U; index < kEphemeralPublicKeySize; ++index) + { + control.ephemeral_public_key[index] = static_cast(0x31U + index); + } + control.phy_profile_id = 1U; + control.channel_index = 7U; + control.encoded_media_len = encoded_media_len; + control.codec = Codec::Codec2_1300; + control.fec_layout = kFecLayoutRs10_8; + control.total_blocks = 1U; + control.data_start_delay_ms = 120U; + control.object_fingerprint = 0xF0E1D2C3U; + return control; +} + +class NoContacts final : public IVerifiedContactSecretProvider +{ + public: + bool lookupVerifiedContactSecret(uint32_t, + uint8_t[kPrivateKeySize]) const override + { + return false; + } +}; + +class OneContact final : public IVerifiedContactSecretProvider +{ + public: + OneContact(uint32_t peer_id, const uint8_t secret[kPrivateKeySize]) + : peer_id_(peer_id) + { + std::memcpy(secret_.data(), secret, secret_.size()); + } + + bool lookupVerifiedContactSecret(uint32_t peer_id, + uint8_t out_secret[kPrivateKeySize]) const override + { + if (peer_id != peer_id_ || !out_secret) + { + return false; + } + std::memcpy(out_secret, secret_.data(), secret_.size()); + return true; + } + + private: + uint32_t peer_id_ = 0U; + std::array secret_{}; +}; + +void test_envelope_is_bounded_and_strict() +{ + const std::array payload = {1U, 2U, 3U}; + std::array bytes{}; + std::size_t len = bytes.size(); + assert(buildMqttEnvelope( + MqttEnvelopeKind::Control, payload.data(), payload.size(), bytes.data(), &len)); + assert(len == kMqttEnvelopePrefixSize + payload.size()); + + MqttEnvelopeView view{}; + assert(parseMqttEnvelope(bytes.data(), len, &view)); + assert(view.kind == MqttEnvelopeKind::Control); + assert(view.payload_len == payload.size()); + assert(std::memcmp(view.payload, payload.data(), payload.size()) == 0); + + bytes[0] ^= 0x01U; + assert(!parseMqttEnvelope(bytes.data(), len, &view)); +} + +void test_public_transfer_reassembles_without_radio_api() +{ + constexpr std::size_t kMediaSize = 875U; + std::array media{}; + for (std::size_t index = 0U; index < media.size(); ++index) + { + media[index] = static_cast((index * 29U) ^ 0x5AU); + } + + MqttTransmitTransfer transmitter{}; + const ControlFrame control = publicControl(static_cast(media.size())); + assert(transmitter.prepareBroadcast(control, media.data(), media.size())); + + MqttReceiveTransfer receiver{}; + NoContacts contacts{}; + std::array envelope{}; + unsigned emitted = 0U; + MqttTransferResult result = MqttTransferResult::Rejected; + while (transmitter.hasNext()) + { + std::size_t envelope_len = envelope.size(); + assert(transmitter.nextEnvelope(envelope.data(), &envelope_len)); + result = receiver.acceptEnvelope( + envelope.data(), envelope_len, 0x2020U, contacts); + assert(result == MqttTransferResult::Accepted || + result == MqttTransferResult::Complete); + ++emitted; + if (result == MqttTransferResult::Complete) + { + break; + } + } + // The RS(10,8) plan still publishes ten shards, but a receiver may end + // local processing as soon as any eight valid shards complete recovery. + assert(emitted == kSourceShardsPerBlock + 1U); + assert(result == MqttTransferResult::Complete); + assert(receiver.complete()); + assert(receiver.control().sender_id == control.sender_id); + assert(receiver.control().target_id == kBroadcastTargetId); + + std::array restored{}; + std::size_t restored_len = 0U; + assert(receiver.recover(restored.data(), restored.size(), &restored_len)); + assert(restored_len == media.size()); + assert(std::memcmp(restored.data(), media.data(), media.size()) == 0); + + transmitter.clear(); + receiver.clear(); +} + +void test_private_transfer_is_end_to_end_encrypted_and_authenticated() +{ + constexpr std::size_t kMediaSize = 875U; + std::array media{}; + std::array contact_secret{}; + for (std::size_t index = 0U; index < media.size(); ++index) + { + media[index] = static_cast((index * 11U) ^ 0xC7U); + } + for (std::size_t index = 0U; index < contact_secret.size(); ++index) + { + contact_secret[index] = static_cast(0x44U + index); + } + + const ControlFrame control = privateControl(static_cast(media.size())); + MqttTransmitTransfer transmitter{}; + assert(transmitter.preparePrivate(control, + contact_secret.data(), + media.data(), + media.size())); + + OneContact contacts{control.sender_id, contact_secret.data()}; + MqttReceiveTransfer receiver{}; + std::array envelope{}; + + std::size_t envelope_len = envelope.size(); + assert(transmitter.copyNextEnvelope(envelope.data(), &envelope_len)); + assert(receiver.acceptEnvelope(envelope.data(), envelope_len, control.target_id, contacts) == + MqttTransferResult::Accepted); + assert(transmitter.commitNextEnvelope()); + + // A third party that changes an encrypted data envelope cannot create a + // usable voice shard. The valid original remains accepted afterwards. + envelope_len = envelope.size(); + assert(transmitter.copyNextEnvelope(envelope.data(), &envelope_len)); + std::array altered = envelope; + altered[envelope_len - 1U] ^= 0x80U; + assert(receiver.acceptEnvelope(altered.data(), envelope_len, control.target_id, contacts) == + MqttTransferResult::Rejected); + assert(receiver.acceptEnvelope(envelope.data(), envelope_len, control.target_id, contacts) == + MqttTransferResult::Accepted); + assert(transmitter.commitNextEnvelope()); + + MqttTransferResult result = MqttTransferResult::Accepted; + while (transmitter.hasNext() && result != MqttTransferResult::Complete) + { + envelope_len = envelope.size(); + assert(transmitter.nextEnvelope(envelope.data(), &envelope_len)); + result = receiver.acceptEnvelope( + envelope.data(), envelope_len, control.target_id, contacts); + assert(result == MqttTransferResult::Accepted || + result == MqttTransferResult::Complete); + } + assert(result == MqttTransferResult::Complete); + + std::array restored{}; + std::size_t restored_len = 0U; + assert(receiver.recover(restored.data(), restored.size(), &restored_len)); + assert(restored_len == media.size()); + assert(std::memcmp(restored.data(), media.data(), media.size()) == 0); + + transmitter.clear(); + receiver.clear(); +} + +} // namespace + +int main() +{ + test_envelope_is_bounded_and_strict(); + test_public_transfer_reassembles_without_radio_api(); + test_private_transfer_is_end_to_end_encrypted_and_authenticated(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_private_crypto.cpp b/modules/core_chat/tests/test_vmp_private_crypto.cpp new file mode 100644 index 00000000..03e06c30 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_private_crypto.cpp @@ -0,0 +1,255 @@ +#include "chat/infra/voice/vmp_private_crypto.h" + +#include +#include +#include +#include +#include + +namespace +{ + +using namespace chat::voice::vmp; + +void fill(uint8_t* out, std::size_t len, uint8_t seed) +{ + for (std::size_t index = 0; index < len; ++index) + { + out[index] = static_cast(seed + index * 19U); + } +} + +bool allZero(const uint8_t* data, std::size_t len) +{ + uint8_t aggregate = 0; + for (std::size_t index = 0; index < len; ++index) + { + aggregate |= data[index]; + } + return aggregate == 0U; +} + +void deriveTwoPeerKeys(PrivateSessionKeys* out_sender, + PrivateSessionKeys* out_receiver, + uint8_t out_nonce[kSessionNonceSize]) +{ + EphemeralKeyPair sender{}; + EphemeralKeyPair receiver{}; + assert(generateEphemeralKeyPair(&sender)); + assert(generateEphemeralKeyPair(&receiver)); + + uint8_t contact_secret[kPrivateKeySize] = {}; + fill(contact_secret, sizeof(contact_secret), 0xA4U); + fill(out_nonce, kSessionNonceSize, 0x41U); + constexpr uint64_t kSessionId = 0x1020304050607080ULL; + assert(derivePrivateSessionKeys(contact_secret, + sender.private_key, + receiver.public_key, + out_nonce, + kSessionId, + out_sender)); + assert(derivePrivateSessionKeys(contact_secret, + receiver.private_key, + sender.public_key, + out_nonce, + kSessionId, + out_receiver)); + assert(allZero(sender.private_key, sizeof(sender.private_key))); + assert(allZero(receiver.private_key, sizeof(receiver.private_key))); + assert(std::memcmp(out_sender, out_receiver, sizeof(*out_sender)) == 0); + std::memset(contact_secret, 0, sizeof(contact_secret)); +} + +void test_private_control_tags() +{ + PrivateSessionKeys sender_control{}; + PrivateSessionKeys receiver_control{}; + uint8_t nonce[kSessionNonceSize] = {}; + uint8_t contact_secret[kPrivateKeySize] = {}; + fill(contact_secret, sizeof(contact_secret), 0xA4U); + fill(nonce, sizeof(nonce), 0x41U); + constexpr uint64_t kSessionId = 0x1020304050607080ULL; + assert(derivePrivateControlKey(contact_secret, + nonce, + kSessionId, + sender_control.control_key)); + assert(derivePrivateControlKey(contact_secret, + nonce, + kSessionId, + receiver_control.control_key)); + + std::array bytes{}; + fill(bytes.data(), bytes.size(), 0x01U); + uint8_t tag[kControlIntegrityTagSize] = {}; + assert(tagPrivateControl(sender_control, + nonce, + ControlType::Offer, + PrivateFrameDirection::SenderToReceiver, + bytes.data(), + bytes.size(), + tag)); + assert(verifyPrivateControlTag(receiver_control, + nonce, + ControlType::Offer, + PrivateFrameDirection::SenderToReceiver, + bytes.data(), + bytes.size(), + tag)); + bytes[2] ^= 0x01U; + assert(!verifyPrivateControlTag(receiver_control, + nonce, + ControlType::Offer, + PrivateFrameDirection::SenderToReceiver, + bytes.data(), + bytes.size(), + tag)); + clearPrivateSessionKeys(&sender_control); + clearPrivateSessionKeys(&receiver_control); + std::memset(contact_secret, 0, sizeof(contact_secret)); +} + +void test_ready_and_shard_protection() +{ + PrivateSessionKeys sender{}; + PrivateSessionKeys receiver{}; + uint8_t nonce[kSessionNonceSize] = {}; + deriveTwoPeerKeys(&sender, &receiver, nonce); + + DataHeader ready{}; + ready.type = DataType::ReadyProbe; + ready.session_id = 99U; + uint8_t ready_tag[kPrivateDataAuthTagSize] = {}; + assert(tagPrivateReady(sender, + nonce, + PrivateFrameDirection::SenderToReceiver, + ready, + ready_tag)); + assert(verifyPrivateReadyTag(receiver, + nonce, + PrivateFrameDirection::SenderToReceiver, + ready, + ready_tag)); + + DataHeader shard{}; + shard.type = DataType::Shard; + shard.session_id = ready.session_id; + shard.block_index = 0; + shard.shard_index = 4; + shard.payload_len = 160; + shard.flags = DataFlagFinalBlock; + std::array plaintext{}; + std::array ciphertext{}; + std::array opened{}; + uint8_t tag[kPrivateDataAuthTagSize] = {}; + fill(plaintext.data(), plaintext.size(), 0xE1U); + assert(sealPrivateShard(sender, + nonce, + PrivateFrameDirection::SenderToReceiver, + shard, + plaintext.data(), + plaintext.size(), + ciphertext.data(), + tag)); + assert(ciphertext != plaintext); + assert(openPrivateShard(receiver, + nonce, + PrivateFrameDirection::SenderToReceiver, + shard, + ciphertext.data(), + ciphertext.size(), + tag, + opened.data())); + assert(opened == plaintext); + + tag[0] ^= 0x80U; + opened.fill(0xA5U); + assert(!openPrivateShard(receiver, + nonce, + PrivateFrameDirection::SenderToReceiver, + shard, + ciphertext.data(), + ciphertext.size(), + tag, + opened.data())); + assert(allZero(opened.data(), opened.size())); + clearPrivateSessionKeys(&sender); + clearPrivateSessionKeys(&receiver); +} + +void test_private_mqtt_keys_are_static_contact_bound_and_distinct() +{ + uint8_t contact_secret[kPrivateKeySize] = {}; + uint8_t nonce[kSessionNonceSize] = {}; + fill(contact_secret, sizeof(contact_secret), 0x7AU); + fill(nonce, sizeof(nonce), 0x13U); + + PrivateSessionKeys sender{}; + PrivateSessionKeys receiver{}; + constexpr uint64_t kSessionId = 0x9988776655443322ULL; + assert(derivePrivateMqttSessionKeys(contact_secret, nonce, kSessionId, &sender)); + assert(derivePrivateMqttSessionKeys(contact_secret, nonce, kSessionId, &receiver)); + assert(std::memcmp(&sender, &receiver, sizeof(sender)) == 0); + assert(std::memcmp(sender.control_key, sender.data_key, kPrivateKeySize) != 0); + assert(std::memcmp(sender.data_key, sender.mqtt_key, kPrivateKeySize) != 0); + + nonce[0] ^= 0x80U; + PrivateSessionKeys different_nonce{}; + assert(derivePrivateMqttSessionKeys( + contact_secret, nonce, kSessionId, &different_nonce)); + assert(std::memcmp(sender.data_key, + different_nonce.data_key, + kPrivateKeySize) != 0); + + clearPrivateSessionKeys(&sender); + clearPrivateSessionKeys(&receiver); + clearPrivateSessionKeys(&different_nonce); + std::memset(contact_secret, 0, sizeof(contact_secret)); +} + +void test_contact_secret_is_identity_family_and_pair_bound() +{ + uint8_t identity_shared_secret[kPrivateKeySize] = {}; + fill(identity_shared_secret, sizeof(identity_shared_secret), 0x55U); + + uint8_t forward[kPrivateKeySize] = {}; + uint8_t reverse[kPrivateKeySize] = {}; + uint8_t other_family[kPrivateKeySize] = {}; + assert(deriveVmpContactSecret(identity_shared_secret, + ContactSecretIdentityFamily::Meshtastic, + 0x1001U, + 0x2002U, + forward)); + assert(deriveVmpContactSecret(identity_shared_secret, + ContactSecretIdentityFamily::Meshtastic, + 0x2002U, + 0x1001U, + reverse)); + assert(deriveVmpContactSecret(identity_shared_secret, + ContactSecretIdentityFamily::MeshCore, + 0x1001U, + 0x2002U, + other_family)); + assert(std::memcmp(forward, reverse, sizeof(forward)) == 0); + assert(std::memcmp(forward, other_family, sizeof(forward)) != 0); + assert(!deriveVmpContactSecret(identity_shared_secret, + ContactSecretIdentityFamily::Meshtastic, + 0x1001U, + 0x1001U, + other_family)); + assert(allZero(other_family, sizeof(other_family))); + + std::memset(identity_shared_secret, 0, sizeof(identity_shared_secret)); + std::memset(forward, 0, sizeof(forward)); + std::memset(reverse, 0, sizeof(reverse)); +} + +} // namespace + +int main() +{ + test_private_control_tags(); + test_ready_and_shard_protection(); + test_private_mqtt_keys_are_static_contact_bound_and_distinct(); + test_contact_secret_is_identity_family_and_pair_bound(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_receive_block.cpp b/modules/core_chat/tests/test_vmp_receive_block.cpp new file mode 100644 index 00000000..f743f9c3 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_receive_block.cpp @@ -0,0 +1,111 @@ +#include "chat/infra/voice/vmp_receive_block.h" + +#include +#include +#include +#include +#include + +namespace +{ + +using namespace chat::voice::vmp; + +using Shard = std::array; +using Block = std::array; + +Block makeBlock() +{ + Block block{}; + for (uint8_t source = 0; source < kSourceShardsPerBlock; ++source) + { + for (std::size_t byte = 0; byte < kMaxShardPayloadSize; ++byte) + { + block[source][byte] = static_cast(source * 31U + byte); + } + } + const uint8_t* sources[kSourceShardsPerBlock] = {}; + for (std::size_t source = 0; source < kSourceShardsPerBlock; ++source) + { + sources[source] = block[source].data(); + } + assert(encodeRs10_8(sources, + kMaxShardPayloadSize, + block[8].data(), + block[9].data())); + return block; +} + +DataHeader shardHeader(uint8_t index) +{ + DataHeader header{}; + header.type = DataType::Shard; + header.session_id = 1234U; + header.block_index = 0; + header.shard_index = index; + header.payload_len = kMaxShardPayloadSize; + header.flags = DataFlagFinalBlock; + return header; +} + +void test_recovers_two_lost_shards() +{ + MediaLayout layout{}; + assert(planMediaLayout(813U, &layout)); + ReceiveBlock receiver{}; + assert(receiver.begin(layout)); + const Block block = makeBlock(); + + for (uint8_t index = 0; index < kTotalShardsPerBlock; ++index) + { + if (index == 2U || index == 8U) + { + continue; + } + const ReceiveBlockResult result = receiver.accept( + shardHeader(index), block[index].data(), block[index].size()); + assert(result == ReceiveBlockResult::Accepted || + result == ReceiveBlockResult::Complete); + } + assert(receiver.receivedShardCount() == kSourceShardsPerBlock); + + std::array decoded{}; + std::size_t decoded_len = 0; + assert(receiver.recover(decoded.data(), decoded.size(), &decoded_len)); + assert(decoded_len == layout.encoded_media_len); + + std::array expected{}; + for (uint8_t source = 0; source < layout.source_shard_count; ++source) + { + std::memcpy(expected.data() + source * kMaxShardPayloadSize, + block[source].data(), + kMaxShardPayloadSize); + } + assert(std::memcmp(decoded.data(), expected.data(), decoded_len) == 0); +} + +void test_duplicates_and_variable_shards_are_rejected() +{ + MediaLayout layout{}; + assert(planMediaLayout(160U, &layout)); + ReceiveBlock receiver{}; + assert(receiver.begin(layout)); + const Block block = makeBlock(); + const DataHeader header = shardHeader(0U); + assert(receiver.accept(header, block[0].data(), block[0].size()) == + ReceiveBlockResult::Accepted); + assert(receiver.accept(header, block[0].data(), block[0].size()) == + ReceiveBlockResult::Duplicate); + assert(receiver.accept(header, block[0].data(), block[0].size() - 1U) == + ReceiveBlockResult::Invalid); + assert(receiver.receivedShardCount() == 1U); +} + +} // namespace + +int main() +{ + test_recovers_two_lost_shards(); + test_duplicates_and_variable_shards_are_rejected(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_rs_fec.cpp b/modules/core_chat/tests/test_vmp_rs_fec.cpp new file mode 100644 index 00000000..f24eae44 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_rs_fec.cpp @@ -0,0 +1,118 @@ +#include "chat/infra/voice/vmp_rs_fec.h" + +#include +#include +#include +#include +#include + +namespace +{ + +using namespace chat::voice::vmp; + +constexpr std::size_t kShardSize = kMaxShardPayloadSize; +using Shard = std::array; +using Block = std::array; + +Block makeEncodedBlock() +{ + Block block{}; + for (std::size_t source = 0; source < kSourceShardsPerBlock; ++source) + { + for (std::size_t byte = 0; byte < kShardSize; ++byte) + { + block[source][byte] = static_cast( + (source * 67U + byte * 29U + (byte >> 2U)) & 0xFFU); + } + } + + const uint8_t* sources[kSourceShardsPerBlock] = {}; + for (std::size_t source = 0; source < kSourceShardsPerBlock; ++source) + { + sources[source] = block[source].data(); + } + assert(encodeRs10_8(sources, + kShardSize, + block[8].data(), + block[9].data())); + return block; +} + +void recoverAndVerify(uint8_t missing_first, uint8_t missing_second) +{ + const Block expected = makeEncodedBlock(); + Block actual = expected; + uint8_t* shards[kTotalShardsPerBlock] = {}; + bool present[kTotalShardsPerBlock] = {}; + for (std::size_t index = 0; index < kTotalShardsPerBlock; ++index) + { + shards[index] = actual[index].data(); + present[index] = true; + } + present[missing_first] = false; + present[missing_second] = false; + std::memset(actual[missing_first].data(), 0, kShardSize); + std::memset(actual[missing_second].data(), 0, kShardSize); + + assert(recoverRs10_8(shards, present, kShardSize)); + for (std::size_t index = 0; index < kTotalShardsPerBlock; ++index) + { + assert(present[index]); + assert(actual[index] == expected[index]); + } +} + +void test_all_single_and_double_erasures() +{ + for (uint8_t first = 0; first < kTotalShardsPerBlock; ++first) + { + for (uint8_t second = static_cast(first + 1U); + second < kTotalShardsPerBlock; + ++second) + { + recoverAndVerify(first, second); + } + } +} + +void test_three_erasures_fail() +{ + Block block = makeEncodedBlock(); + uint8_t* shards[kTotalShardsPerBlock] = {}; + bool present[kTotalShardsPerBlock] = {}; + for (std::size_t index = 0; index < kTotalShardsPerBlock; ++index) + { + shards[index] = block[index].data(); + present[index] = true; + } + present[0] = false; + present[1] = false; + present[8] = false; + assert(!recoverRs10_8(shards, present, kShardSize)); +} + +void test_invalid_arguments_fail() +{ + assert(!encodeRs10_8(nullptr, kShardSize, nullptr, nullptr)); + + Block block = makeEncodedBlock(); + uint8_t* shards[kTotalShardsPerBlock] = {}; + bool present[kTotalShardsPerBlock] = {}; + for (std::size_t index = 0; index < kTotalShardsPerBlock; ++index) + { + shards[index] = block[index].data(); + present[index] = true; + } + assert(!recoverRs10_8(shards, present, 0)); +} + +} // namespace + +int main() +{ + test_all_single_and_double_erasures(); + test_three_erasures_fail(); + test_invalid_arguments_fail(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_session_state_machine.cpp b/modules/core_chat/tests/test_vmp_session_state_machine.cpp new file mode 100644 index 00000000..0e836547 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_session_state_machine.cpp @@ -0,0 +1,111 @@ +#include "chat/infra/voice/vmp_session_state_machine.h" + +#include + +namespace +{ + +using namespace chat::voice::vmp; + +void testPrivateSenderRequiresTwoGhzReadyBeforeVoice() +{ + SessionStateMachine session; + SessionTransition start = session.startSender(DeliveryMode::Private); + assert(start.accepted); + assert(start.current == SessionState::AwaitingSubGhzAccept); + assert(start.hasAction(SessionAction::SendSubGhzOffer)); + + SessionTransition accepted = session.dispatch(SessionEvent::SubGhzAcceptAuthenticated); + assert(accepted.accepted); + assert(accepted.current == SessionState::Awaiting2GhzReady); + assert(accepted.hasAction(SessionAction::SwitchTo2GhzTx)); + assert(accepted.hasAction(SessionAction::Send2GhzReadyProbeTrain)); + assert(!accepted.hasAction(SessionAction::BeginVoiceMediaTx)); + + SessionTransition media = session.dispatch(SessionEvent::TwoGhzReadyAuthenticated); + assert(media.accepted); + assert(media.current == SessionState::SendingVoiceMedia); + assert(media.hasAction(SessionAction::BeginVoiceMediaTx)); + + SessionTransition completed = session.dispatch(SessionEvent::VoiceDataTrainComplete); + assert(completed.accepted); + assert(completed.current == SessionState::Completed); + assert(completed.hasAction(SessionAction::RestoreSubGhzRx)); +} + +void testPrivateReceiverSendsOnlyReadinessControl() +{ + SessionStateMachine session; + SessionTransition start = session.startReceiver(DeliveryMode::Private); + assert(start.accepted); + assert(start.hasAction(SessionAction::SendSubGhzAccept)); + assert(start.hasAction(SessionAction::SwitchTo2GhzRx)); + + SessionTransition probe = session.dispatch(SessionEvent::TwoGhzReadyProbeAuthenticated); + assert(probe.accepted); + assert(probe.current == SessionState::AwaitingVoiceMedia); + assert(probe.hasAction(SessionAction::Send2GhzReady)); + assert(!probe.hasAction(SessionAction::BeginVoiceMediaTx)); + + SessionTransition voice = session.dispatch(SessionEvent::TwoGhzVoiceShardAuthenticated); + assert(voice.accepted); + assert(voice.current == SessionState::ReceivingVoiceMedia); + + SessionTransition end = session.dispatch(SessionEvent::FecBlockRecovered); + assert(end.accepted); + assert(end.hasAction(SessionAction::CommitCompleteIncomingVoice)); + assert(end.hasAction(SessionAction::RestoreSubGhzRx)); +} + +void testBroadcastNeverSendsReadinessResponse() +{ + SessionStateMachine receiver; + SessionTransition start = receiver.startReceiver(DeliveryMode::Broadcast); + assert(start.accepted); + assert(!start.hasAction(SessionAction::SendSubGhzAccept)); + assert(start.hasAction(SessionAction::SwitchTo2GhzRx)); + + SessionTransition probe = receiver.dispatch(SessionEvent::TwoGhzReadyProbeAuthenticated); + assert(probe.accepted); + assert(probe.current == SessionState::AwaitingVoiceMedia); + assert(!probe.hasAction(SessionAction::Send2GhzReady)); + + SessionStateMachine sender; + SessionTransition announce = sender.startSender(DeliveryMode::Broadcast); + assert(announce.hasAction(SessionAction::SendSubGhzAnnounce)); + SessionTransition media = sender.dispatch(SessionEvent::BroadcastDataWindowReady); + assert(media.accepted); + assert(media.hasAction(SessionAction::SwitchTo2GhzTx)); + assert(media.hasAction(SessionAction::Send2GhzReadyProbeTrain)); + assert(media.hasAction(SessionAction::BeginVoiceMediaTx)); +} + +void testTimeoutRestoresSubGhzWithoutAnyVoiceSend() +{ + SessionStateMachine sender; + (void)sender.startSender(DeliveryMode::Private); + SessionTransition timeout = sender.dispatch(SessionEvent::ControlDeadlineExpired); + assert(timeout.accepted); + assert(timeout.current == SessionState::Failed); + assert(timeout.failure == SessionFailure::AcceptTimeout); + assert(timeout.hasAction(SessionAction::RestoreSubGhzRx)); + assert(!timeout.hasAction(SessionAction::BeginVoiceMediaTx)); + + SessionStateMachine receiver; + (void)receiver.startReceiver(DeliveryMode::Private); + SessionTransition no_voice = receiver.dispatch(SessionEvent::MediaDeadlineExpired); + assert(no_voice.accepted); + assert(no_voice.failure == SessionFailure::NoVoiceMedia); + assert(no_voice.hasAction(SessionAction::RestoreSubGhzRx)); +} + +} // namespace + +int main() +{ + testPrivateSenderRequiresTwoGhzReadyBeforeVoice(); + testPrivateReceiverSendsOnlyReadinessControl(); + testBroadcastNeverSendsReadinessResponse(); + testTimeoutRestoresSubGhzWithoutAnyVoiceSend(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_voice_inbox.cpp b/modules/core_chat/tests/test_vmp_voice_inbox.cpp new file mode 100644 index 00000000..4896dde6 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_voice_inbox.cpp @@ -0,0 +1,166 @@ +#include "chat/infra/voice/vmp_voice_inbox.h" + +#include +#include +#include +#include + +namespace +{ + +using namespace chat::voice::vmp; + +ControlFrame privateOffer(uint32_t sender, uint64_t session_id, uint16_t media_len) +{ + ControlFrame control{}; + control.type = ControlType::Offer; + control.flags = ControlFlagPrivate; + control.sender_id = sender; + control.target_id = 7U; + control.session_id = session_id; + control.session_nonce[0] = 1U; + control.phy_profile_id = 1U; + control.encoded_media_len = media_len; + control.total_blocks = 1U; + control.data_start_delay_ms = 120U; + control.object_fingerprint = static_cast(session_id); + control.ephemeral_public_key[0] = 2U; + return control; +} + +ControlFrame broadcastAnnounce(uint32_t sender, uint64_t session_id, uint16_t media_len) +{ + ControlFrame control{}; + control.type = ControlType::Announce; + control.flags = ControlFlagBroadcast | ControlFlagPublicBroadcast; + control.sender_id = sender; + control.target_id = kBroadcastTargetId; + control.session_id = session_id; + control.session_nonce[0] = 3U; + control.phy_profile_id = 1U; + control.encoded_media_len = media_len; + control.total_blocks = 1U; + control.data_start_delay_ms = 700U; + control.object_fingerprint = static_cast(session_id); + return control; +} + +void test_store_local_only_voice_and_dedupe() +{ + VoiceMessageInbox inbox; + const auto control = privateOffer(42U, 0x0102030405060708ULL, 7U); + const std::array media = {1U, 2U, 3U, 4U, 5U, 6U, 7U}; + uint64_t local_id = 0U; + + assert(inbox.store(control, media.data(), media.size(), true, 123U, &local_id) == + VoiceInboxStoreResult::Stored); + assert(local_id != 0U); + assert(inbox.size() == 1U); + + VoiceMessageView view{}; + assert(inbox.get(local_id, &view)); + assert(view.metadata.sender_id == 42U); + assert(view.metadata.complete); + assert(!view.metadata.source_unverified); + assert(view.metadata.encoded_media_len == media.size()); + for (std::size_t index = 0U; index < media.size(); ++index) + { + assert(view.encoded_media[index] == media[index]); + } + assert(inbox.store(control, media.data(), media.size(), true, 124U, nullptr) == + VoiceInboxStoreResult::Duplicate); +} + +void test_broadcast_is_explicitly_unverified() +{ + VoiceMessageInbox inbox; + const auto control = broadcastAnnounce(9U, 0xAA55ULL, 7U); + const std::array media = {7U, 6U, 5U, 4U, 3U, 2U, 1U}; + uint64_t local_id = 0U; + assert(inbox.store(control, media.data(), media.size(), true, 0U, &local_id) == + VoiceInboxStoreResult::Stored); + + VoiceMessageView view{}; + assert(inbox.get(local_id, &view)); + assert(view.metadata.mode == DeliveryMode::Broadcast); + assert(view.metadata.source_unverified); +} + +void test_bad_media_is_never_persisted() +{ + VoiceMessageInbox inbox; + const auto control = privateOffer(42U, 100U, 7U); + const std::array too_short = {}; + assert(inbox.store(control, too_short.data(), too_short.size(), true, 0U, nullptr) == + VoiceInboxStoreResult::Invalid); + assert(inbox.size() == 0U); +} + +void test_metadata_lists_newest_first_without_media() +{ + VoiceMessageInbox inbox; + const auto first = privateOffer(0x1001U, 0xA1U, 7U); + const auto second = privateOffer(0x1002U, 0xA2U, 7U); + const std::array first_media = {1U, 1U, 1U, 1U, 1U, 1U, 1U}; + const std::array second_media = {2U, 2U, 2U, 2U, 2U, 2U, 2U}; + assert(inbox.store(first, first_media.data(), first_media.size(), true, 10U, nullptr) == + VoiceInboxStoreResult::Stored); + assert(inbox.store(second, second_media.data(), second_media.size(), true, 20U, nullptr) == + VoiceInboxStoreResult::Stored); + + VoiceMessageMetadata metadata[2] = {}; + assert(inbox.listMetadata(metadata, 2U) == 2U); + assert(metadata[0].sender_id == second.sender_id); + assert(metadata[0].received_at_seconds == 20U); + assert(metadata[1].sender_id == first.sender_id); + assert(inbox.listMetadata(nullptr, 2U) == 0U); +} + +void test_restore_retains_playback_identity_and_deduplication() +{ + VoiceMessageInbox original; + const auto control = privateOffer(0x11223344U, 0x1234567890ABCDEFULL, 7U); + const std::array media = {9U, 8U, 7U, 6U, 5U, 4U, 3U}; + uint64_t local_id = 0U; + assert(original.store(control, + media.data(), + media.size(), + true, + 321U, + &local_id) == VoiceInboxStoreResult::Stored); + + VoiceMessageView original_view{}; + assert(original.get(local_id, &original_view)); + + VoiceMessageInbox restored; + assert(restored.restore(original_view.metadata, + original_view.encoded_media, + original_view.metadata.encoded_media_len)); + VoiceMessageView restored_view{}; + assert(restored.get(local_id, &restored_view)); + assert(restored_view.metadata.local_id == local_id); + assert(restored_view.metadata.received_at_seconds == 321U); + assert(restored_view.metadata.object_fingerprint == control.object_fingerprint); + for (std::size_t index = 0U; index < media.size(); ++index) + { + assert(restored_view.encoded_media[index] == media[index]); + } + assert(restored.store(control, media.data(), media.size(), true, 322U, nullptr) == + VoiceInboxStoreResult::Duplicate); + + VoiceMessageMetadata invalid = original_view.metadata; + invalid.complete = false; + assert(!VoiceMessageInbox{}.restore(invalid, media.data(), media.size())); +} + +} // namespace + +int main() +{ + test_store_local_only_voice_and_dedupe(); + test_broadcast_is_explicitly_unverified(); + test_bad_media_is_never_persisted(); + test_metadata_lists_newest_first_without_media(); + test_restore_retains_playback_identity_and_deduplication(); + return 0; +} diff --git a/modules/core_chat/tests/test_vmp_wire.cpp b/modules/core_chat/tests/test_vmp_wire.cpp new file mode 100644 index 00000000..4698b7c9 --- /dev/null +++ b/modules/core_chat/tests/test_vmp_wire.cpp @@ -0,0 +1,230 @@ +#include "chat/infra/voice/vmp_wire.h" + +#include +#include +#include + +namespace +{ + +using namespace chat::voice::vmp; + +ControlFrame makePrivateOffer() +{ + ControlFrame frame{}; + frame.type = ControlType::Offer; + frame.flags = ControlFlagPrivate; + frame.key_or_profile_id = 7; + frame.sender_id = 0x12345678U; + frame.target_id = 0x90ABCDEFU; + frame.session_id = 0x0123456789ABCDEFULL; + for (std::size_t index = 0; index < sizeof(frame.session_nonce); ++index) + { + frame.session_nonce[index] = static_cast(index + 1U); + } + for (std::size_t index = 0; index < sizeof(frame.ephemeral_public_key); ++index) + { + frame.ephemeral_public_key[index] = static_cast(0x40U + index); + } + frame.phy_profile_id = 1; + frame.channel_index = 6; + frame.encoded_media_len = 813; + frame.codec = Codec::Codec2_1300; + frame.total_blocks = 1; + frame.data_start_delay_ms = 120; + frame.object_fingerprint = 0x7AA55A77U; + for (std::size_t index = 0; index < sizeof(frame.integrity_tag); ++index) + { + frame.integrity_tag[index] = static_cast(0xA0U + index); + } + return frame; +} + +void testPrivateControlRoundTrip() +{ + const ControlFrame expected = makePrivateOffer(); + std::array bytes{}; + std::size_t len = bytes.size(); + assert(encodeControlFrame(expected, bytes.data(), &len)); + assert(len == bytes.size()); + + ControlFrame actual{}; + assert(decodeControlFrame(bytes.data(), bytes.size(), &actual)); + assert(actual.type == expected.type); + assert(actual.flags == expected.flags); + assert(actual.sender_id == expected.sender_id); + assert(actual.target_id == expected.target_id); + assert(actual.session_id == expected.session_id); + assert(actual.encoded_media_len == expected.encoded_media_len); + assert(actual.total_blocks == expected.total_blocks); + assert(actual.object_fingerprint == expected.object_fingerprint); + assert(std::memcmp(actual.session_nonce, + expected.session_nonce, + sizeof(expected.session_nonce)) == 0); + assert(std::memcmp(actual.ephemeral_public_key, + expected.ephemeral_public_key, + sizeof(expected.ephemeral_public_key)) == 0); + assert(std::memcmp(actual.integrity_tag, + expected.integrity_tag, + sizeof(expected.integrity_tag)) == 0); + + DeliveryMode mode = DeliveryMode::Broadcast; + assert(deliveryModeFor(actual, &mode)); + assert(mode == DeliveryMode::Private); +} + +void testBroadcastAndInvalidControlConstraints() +{ + ControlFrame broadcast = makePrivateOffer(); + broadcast.type = ControlType::Announce; + broadcast.flags = ControlFlagBroadcast | ControlFlagPublicBroadcast; + broadcast.key_or_profile_id = 0; + std::memset(broadcast.ephemeral_public_key, + 0, + sizeof(broadcast.ephemeral_public_key)); + broadcast.target_id = kBroadcastTargetId; + broadcast.data_start_delay_ms = 700; + assert(isValidControlFrame(broadcast)); + + ControlFrame invalid_mode = broadcast; + invalid_mode.flags = ControlFlagPrivate | ControlFlagBroadcast; + assert(!isValidControlFrame(invalid_mode)); + + ControlFrame invalid_broadcast_target = broadcast; + invalid_broadcast_target.target_id = 42; + assert(!isValidControlFrame(invalid_broadcast_target)); + + ControlFrame invalid_broadcast_key = broadcast; + invalid_broadcast_key.key_or_profile_id = 1; + assert(!isValidControlFrame(invalid_broadcast_key)); + + ControlFrame invalid_broadcast_ephemeral = broadcast; + invalid_broadcast_ephemeral.ephemeral_public_key[0] = 1; + assert(!isValidControlFrame(invalid_broadcast_ephemeral)); + + ControlFrame invalid_accept = broadcast; + invalid_accept.type = ControlType::Accept; + assert(!isValidControlFrame(invalid_accept)); + + ControlFrame invalid_layout = makePrivateOffer(); + invalid_layout.total_blocks = 2; + assert(!isValidControlFrame(invalid_layout)); +} + +void testControlDecodeRejectsCorruption() +{ + const ControlFrame expected = makePrivateOffer(); + std::array bytes{}; + std::size_t len = bytes.size(); + assert(encodeControlFrame(expected, bytes.data(), &len)); + + bytes[0] = 'X'; + ControlFrame decoded{}; + assert(!decodeControlFrame(bytes.data(), bytes.size(), &decoded)); + + bytes[0] = 'V'; + bytes[4] |= 0x80U; + assert(!decodeControlFrame(bytes.data(), bytes.size(), &decoded)); +} + +void testMediaLayoutBoundaries() +{ + MediaLayout layout{}; + assert(!planMediaLayout(0, &layout)); + assert(planMediaLayout(1, &layout)); + assert(layout.source_shard_count == 1); + assert(layout.block_count == 1); + assert(layout.data_frame_count == 10); + assert(sourceShardPayloadSize(layout, 0, 0) == 1); + assert(sourceShardPayloadSize(layout, 0, 1) == 0); + + assert(planMediaLayout(160, &layout)); + assert(layout.source_shard_count == 1); + assert(sourceShardPayloadSize(layout, 0, 0) == 160); + + assert(planMediaLayout(161, &layout)); + assert(layout.source_shard_count == 2); + assert(sourceShardPayloadSize(layout, 0, 0) == 160); + assert(sourceShardPayloadSize(layout, 0, 1) == 1); + + assert(planMediaLayout(1280, &layout)); + assert(layout.source_shard_count == 8); + assert(layout.block_count == 1); + assert(layout.data_frame_count == 10); + assert(sourceShardPayloadSize(layout, 0, 7) == 160); + assert(!planMediaLayout(1281, &layout)); +} + +void testDataHeaderRoundTripAndConstraints() +{ + DataHeader expected{}; + expected.type = DataType::Shard; + expected.session_id = 0x0102030405060708ULL; + expected.block_index = 0; + expected.shard_index = 7; + expected.payload_len = 53; + expected.flags = DataFlagFinalBlock | DataFlagPartialSource; + + std::array bytes{}; + std::size_t len = bytes.size(); + assert(encodeDataHeader(expected, bytes.data(), &len)); + assert(len == bytes.size()); + + DataHeader actual{}; + assert(decodeDataHeader(bytes.data(), bytes.size(), &actual)); + assert(actual.type == expected.type); + assert(actual.session_id == expected.session_id); + assert(actual.block_index == expected.block_index); + assert(actual.shard_index == expected.shard_index); + assert(actual.payload_len == expected.payload_len); + assert(actual.flags == expected.flags); + + DataHeader invalid_parity = expected; + invalid_parity.shard_index = 8; + assert(!isValidDataHeader(invalid_parity)); + + DataHeader unknown{}; + unknown.type = static_cast(4); + unknown.session_id = expected.session_id; + assert(!isValidDataHeader(unknown)); +} + +void testReadyHeadersNeverContainVoiceBytes() +{ + DataHeader probe{}; + probe.type = DataType::ReadyProbe; + probe.session_id = 0x0A0B0C0D0E0F1011ULL; + assert(isValidDataHeader(probe)); + + std::array bytes{}; + std::size_t len = bytes.size(); + assert(encodeDataHeader(probe, bytes.data(), &len)); + + DataHeader decoded{}; + assert(decodeDataHeader(bytes.data(), bytes.size(), &decoded)); + assert(decoded.type == DataType::ReadyProbe); + assert(decoded.payload_len == 0); + + DataHeader ready = probe; + ready.type = DataType::Ready; + assert(isValidDataHeader(ready)); + + ready.payload_len = 1; + assert(!isValidDataHeader(ready)); + ready.payload_len = 0; + ready.block_index = 1; + assert(!isValidDataHeader(ready)); +} + +} // namespace + +int main() +{ + testPrivateControlRoundTrip(); + testBroadcastAndInvalidControlConstraints(); + testControlDecodeRejectsCorruption(); + testMediaLayoutBoundaries(); + testDataHeaderRoundTripAndConstraints(); + testReadyHeadersNeverContainVoiceBytes(); + return 0; +} diff --git a/modules/ui_shared/include/ui/chat_voice_runtime.h b/modules/ui_shared/include/ui/chat_voice_runtime.h new file mode 100644 index 00000000..e2ff0646 --- /dev/null +++ b/modules/ui_shared/include/ui/chat_voice_runtime.h @@ -0,0 +1,69 @@ +/** + * @file chat_voice_runtime.h + * @brief Narrow UI port for the isolated VMP voice-message feature. + * + * The shared chat UI depends on this port instead of a radio, MQTT, or chat + * transport implementation. Registering an implementation is optional; on + * devices without VMP support the compose screen simply has no voice action. + */ + +#pragma once + +#include +#include + +namespace ui::chat_voice +{ + +enum class StartResult : uint8_t +{ + Queued = 1, + Unsupported = 2, + Busy = 3, + PrivateContactUnverified = 4, +}; + +/** @brief A local-only summary of a received VMP object for chat projection. */ +struct MessageSummary +{ + uint64_t local_id = 0U; + uint32_t sender_id = 0U; + uint32_t target_id = 0U; + uint32_t received_at_seconds = 0U; + bool private_message = false; + bool source_unverified = false; +}; + +class IVoiceMessageRuntime +{ + public: + virtual ~IVoiceMessageRuntime() = default; + + virtual bool isAvailable() const = 0; + virtual bool canRecordAndSend() const = 0; + virtual StartResult requestRecordAndSend(uint32_t target_id) = 0; + virtual std::size_t listReceivedMessages(MessageSummary* out_messages, + std::size_t capacity) const = 0; + virtual bool requestPlayback(uint64_t local_id) = 0; +}; + +/** @brief Binds the device-specific VMP service during platform startup. */ +void setRuntime(IVoiceMessageRuntime* runtime); + +/** @brief True only when this device has initialized an isolated VMP service. */ +bool isAvailable(); + +/** @brief True when the active VMP carrier currently permits recording/sending. */ +bool canRecordAndSend(); + +/** @brief Requests an asynchronous record-and-send operation through VMP only. */ +StartResult requestRecordAndSend(uint32_t target_id); + +/** @brief Retrieves newest-first local VMP summaries; never exposes audio bytes. */ +std::size_t listReceivedMessages(MessageSummary* out_messages, + std::size_t capacity); + +/** @brief Requests asynchronous playback of a local VMP object. */ +bool requestPlayback(uint64_t local_id); + +} // namespace ui::chat_voice diff --git a/modules/ui_shared/include/ui/screens/chat/chat_compose_components.h b/modules/ui_shared/include/ui/screens/chat/chat_compose_components.h index ca9634b1..4c486b85 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_compose_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_compose_components.h @@ -27,6 +27,7 @@ class ChatComposeScreen { Send, Position, + Voice, Cancel }; @@ -36,6 +37,7 @@ class ChatComposeScreen void setHeaderText(const char* title, const char* status = nullptr); void setActionLabels(const char* send_label, const char* cancel_label); void setPositionButton(const char* label, bool visible); + void setVoiceButton(const char* label, bool visible); std::string getText() const; void clearText(); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h index d4995acc..cf01d4b7 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h @@ -11,6 +11,7 @@ #include "chat/domain/chat_types.h" #include "chat_conversation_input.h" #include "lvgl.h" +#include "ui/chat_voice_runtime.h" #include "ui/components/shortcut_help_modal.h" #include "ui/widgets/map/map_viewport.h" #include "ui/widgets/top_bar.h" @@ -51,6 +52,8 @@ class ChatConversationScreen ~ChatConversationScreen(); void addMessage(const ::ui::chat::MessageRow& row); + /** @brief Adds one local-only, click-to-play VMP voice bubble. */ + void addVoiceMessage(const ::ui::chat_voice::MessageSummary& summary); void clearMessages(); void scrollToTop(); void scrollToBottom(); @@ -149,6 +152,11 @@ class ChatConversationScreen ::ui::chat::MessageRef ref; }; + struct VoicePlaybackContext + { + uint64_t local_id = 0U; + }; + lv_obj_t* container_ = nullptr; ::ui::widgets::TopBar top_bar_{}; lv_obj_t* body_row_ = nullptr; @@ -188,6 +196,7 @@ class ChatConversationScreen lv_obj_t* time_label = nullptr; // inside meta row lv_obj_t* status_label = nullptr; // inside meta row std::unique_ptr retry_ctx; + std::unique_ptr voice_playback_ctx; bool retry_enabled = false; }; @@ -224,6 +233,7 @@ class ChatConversationScreen static void action_event_cb(lv_event_t* e); static void message_action_event_cb(lv_event_t* e); + static void voice_message_event_cb(lv_event_t* e); static void scroll_event_cb(lv_event_t* e); static void async_action_cb(void* user_data); static void async_message_action_cb(void* user_data); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h index c936c5a5..4f568eb8 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h @@ -10,6 +10,7 @@ #include "lvgl.h" #include "sys/event_bus.h" #include "ui/chat_ui_runtime.h" +#include "ui/chat_voice_runtime.h" #include "ui/screens/chat/chat_compose_components.h" #include "ui/screens/chat/chat_conversation_components.h" #include "ui/screens/chat/chat_message_list_components.h" @@ -18,6 +19,7 @@ #include "ui_lvgl_ux_packs/common/key_verification_modal_renderer.h" #include "ui_lvgl_ux_packs/common/team_position_picker_renderer.h" #include "ui_presentation/chat/chat_workspace_model.h" +#include #include #include #include @@ -132,6 +134,8 @@ class UiController : public IChatUiRefreshSink void switchToChannelList(); void switchToConversation(chat::ConversationId conv); void switchToCompose(chat::ConversationId conv); + void appendVoiceMessagesToConversation(); + uint64_t currentVoiceProjectionSignature(); void handleChannelSelected(const chat::ConversationId& conv); void handlePingDestination(const chat::ConversationId& conv); void handleDeleteConversation(const chat::ConversationId& conv); @@ -180,6 +184,10 @@ class UiController : public IChatUiRefreshSink // the stack during page entry. Reuse controller-owned buffers instead. ::ui::chat::ChatWorkspaceSnapshot chat_snapshot_buffer_{}; ::ui::chat::ChatWorkspaceSnapshot team_chat_snapshot_buffer_{}; + static constexpr std::size_t kVoiceProjectionCapacity = 8U; + ::ui::chat_voice::MessageSummary voice_projection_buffer_[kVoiceProjectionCapacity] = {}; + uint64_t rendered_voice_projection_signature_ = 0U; + uint32_t voice_projection_last_poll_ms_ = 0U; bool conversation_list_dirty_ = true; bool conversation_list_loaded_ = false; bool conversation_view_loaded_ = false; diff --git a/modules/ui_shared/src/ui/chat_voice_runtime.cpp b/modules/ui_shared/src/ui/chat_voice_runtime.cpp new file mode 100644 index 00000000..ad0409f3 --- /dev/null +++ b/modules/ui_shared/src/ui/chat_voice_runtime.cpp @@ -0,0 +1,49 @@ +/** + * @file chat_voice_runtime.cpp + * @brief Global binding for the optional isolated VMP voice UI port. + */ + +#include "ui/chat_voice_runtime.h" + +namespace ui::chat_voice +{ +namespace +{ + +IVoiceMessageRuntime* s_runtime = nullptr; + +} // namespace + +void setRuntime(IVoiceMessageRuntime* runtime) +{ + s_runtime = runtime; +} + +bool isAvailable() +{ + return s_runtime && s_runtime->isAvailable(); +} + +bool canRecordAndSend() +{ + return s_runtime && s_runtime->canRecordAndSend(); +} + +StartResult requestRecordAndSend(uint32_t target_id) +{ + return s_runtime ? s_runtime->requestRecordAndSend(target_id) + : StartResult::Unsupported; +} + +std::size_t listReceivedMessages(MessageSummary* out_messages, + std::size_t capacity) +{ + return s_runtime ? s_runtime->listReceivedMessages(out_messages, capacity) : 0U; +} + +bool requestPlayback(uint64_t local_id) +{ + return s_runtime && s_runtime->requestPlayback(local_id); +} + +} // namespace ui::chat_voice diff --git a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp index 1bdfa9d8..0b77be44 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp @@ -48,7 +48,7 @@ struct ChatComposeScreen::Impl ActionIntent intent = ActionIntent::Send; }; ActionContext send_ctx; - ActionContext position_ctx; + ActionContext auxiliary_ctx; ActionContext cancel_ctx; lv_obj_t* sym_btn = nullptr; lv_obj_t* emoji_btn = nullptr; @@ -161,12 +161,12 @@ ChatComposeScreen::ChatComposeScreen(lv_obj_t* parent, chat::ConversationId conv impl_->send_ctx.screen = this; impl_->send_ctx.intent = ActionIntent::Send; - impl_->position_ctx.screen = this; - impl_->position_ctx.intent = ActionIntent::Position; + impl_->auxiliary_ctx.screen = this; + impl_->auxiliary_ctx.intent = ActionIntent::Position; impl_->cancel_ctx.screen = this; impl_->cancel_ctx.intent = ActionIntent::Cancel; lv_obj_add_event_cb(impl_->w.send_btn, on_action_click, LV_EVENT_CLICKED, &impl_->send_ctx); - lv_obj_add_event_cb(impl_->w.position_btn, on_action_click, LV_EVENT_CLICKED, &impl_->position_ctx); + lv_obj_add_event_cb(impl_->w.position_btn, on_action_click, LV_EVENT_CLICKED, &impl_->auxiliary_ctx); lv_obj_add_event_cb(impl_->w.cancel_btn, on_action_click, LV_EVENT_CLICKED, &impl_->cancel_ctx); lv_obj_add_event_cb(impl_->w.send_btn, on_key, LV_EVENT_KEY, this); lv_obj_add_event_cb(impl_->w.position_btn, on_key, LV_EVENT_KEY, this); @@ -266,6 +266,38 @@ void ChatComposeScreen::setActionLabels(const char* send_label, const char* canc void ChatComposeScreen::setPositionButton(const char* label, bool visible) { if (!impl_ || !impl_->w.position_btn) return; + impl_->auxiliary_ctx.intent = ActionIntent::Position; + if (label) + { + set_btn_label_text(impl_->w.position_btn, label); + fit_btn_to_label(impl_->w.position_btn, 8); + } + if (visible) + { + lv_obj_clear_flag(impl_->w.position_btn, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_add_flag(impl_->w.position_btn, LV_OBJ_FLAG_HIDDEN); + } + + if (lv_group_t* g = lv_group_get_default()) + { + if (visible) + { + lv_group_add_obj(g, impl_->w.position_btn); + } + else + { + lv_group_remove_obj(impl_->w.position_btn); + } + } +} + +void ChatComposeScreen::setVoiceButton(const char* label, bool visible) +{ + if (!impl_ || !impl_->w.position_btn) return; + impl_->auxiliary_ctx.intent = ActionIntent::Voice; if (label) { set_btn_label_text(impl_->w.position_btn, label); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp index 1e7deeb2..7fe9e06d 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp @@ -9,6 +9,7 @@ #include "app/app_facade_access.h" #include "chat/usecase/contact_service.h" #include "ui/assets/fonts/font_utils.h" +#include "ui/chat_voice_runtime.h" #include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/runtime/ui_feedback.h" @@ -782,6 +783,79 @@ void ChatConversationScreen::addMessage(const ::ui::chat::MessageRow& row) static_cast(lv_tick_elaps(started_ms))); } +void ChatConversationScreen::addVoiceMessage( + const ::ui::chat_voice::MessageSummary& summary) +{ + if (!guard_ || !guard_->alive || !msg_list_ || !lv_obj_is_valid(msg_list_) || + summary.local_id == 0U) + { + return; + } + if (messages_.size() >= MAX_DISPLAY_MESSAGES) + { + MessageItem& oldest = messages_[0]; + if (oldest.container) + { + lv_obj_del(oldest.container); + } + messages_.erase(messages_.begin()); + } + + MessageItem item{}; + item.container = chat::ui::layout::create_message_row(msg_list_); + chat::ui::conversation::styles::apply_message_row(item.container); + + lv_obj_t* const bubble = chat::ui::layout::create_bubble(item.container); + item.bubble = bubble; + chat::ui::conversation::styles::apply_bubble( + bubble, false, summary.source_unverified); + chat::ui::layout::set_bubble_max_width(bubble, kBubbleMaxWidth); + lv_obj_add_flag(bubble, LV_OBJ_FLAG_CLICKABLE); + + char sender[16] = {}; + std::string sender_name = + app::messagingFacade().getContactService().getContactName(summary.sender_id); + if (sender_name.empty()) + { + std::snprintf(sender, + sizeof(sender), + "%04lX", + static_cast(summary.sender_id & 0xFFFFU)); + sender_name = sender; + } + const lv_coord_t max_meta_w = + std::max(kBubbleMaxWidth - 2 * bubble_pad_x(), 24); + item.meta_row = create_meta_row(bubble, max_meta_w, false); + item.sender_label = create_meta_chip( + item.meta_row, sender_name.c_str(), lv_color_hex(0xF1B75A), max_meta_w); + item.source_label = create_meta_chip( + item.meta_row, + summary.source_unverified ? "VMP broadcast (unverified)" : "VMP private", + summary.source_unverified ? lv_color_hex(0xFFB4A2) : lv_color_hex(0xCFE4FF), + max_meta_w); + char time_buf[24] = {}; + format_message_time(time_buf, + sizeof(time_buf), + summary.received_at_seconds); + item.time_label = create_meta_chip( + item.meta_row, time_buf, lv_color_hex(0xD4F0D2), max_meta_w); + + item.text_label = chat::ui::layout::create_bubble_text(bubble); + chat::ui::conversation::styles::apply_bubble_text(item.text_label); + lv_label_set_text(item.text_label, "Voice message - tap to play"); + ::ui::fonts::apply_chat_content_font( + item.text_label, lv_label_get_text(item.text_label)); + lv_obj_set_width(item.text_label, + std::max(kBubbleMaxWidth - 2 * bubble_pad_x(), 24)); + item.voice_playback_ctx.reset(new VoicePlaybackContext{summary.local_id}); + lv_obj_add_event_cb(bubble, + voice_message_event_cb, + LV_EVENT_CLICKED, + item.voice_playback_ctx.get()); + chat::ui::layout::align_message_row(item.container, false); + messages_.push_back(std::move(item)); +} + void ChatConversationScreen::clearMessages() { const uint32_t started_ms = lv_tick_get(); @@ -811,6 +885,23 @@ void ChatConversationScreen::clearMessages() static_cast(lv_tick_elaps(started_ms))); } +void ChatConversationScreen::voice_message_event_cb(lv_event_t* e) +{ + if (!e || lv_event_get_code(e) != LV_EVENT_CLICKED) + { + return; + } + const auto* context = + static_cast(lv_event_get_user_data(e)); + if (!context || context->local_id == 0U) + { + return; + } + const bool started = ::ui::chat_voice::requestPlayback(context->local_id); + ::ui::feedback::show_notice(started ? "Playing voice" : "Voice playback unavailable", + started ? 1400 : 1800); +} + void ChatConversationScreen::scrollToTop() { if (guard_ && guard_->alive && msg_list_) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index 708a562d..38a5c959 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -16,6 +16,7 @@ #include "sys/event_bus.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" +#include "ui/chat_voice_runtime.h" #include "ui/components/two_pane_styles.h" #include "ui/localization.h" #include "ui/page/page_profile.h" @@ -800,6 +801,16 @@ void UiController::update() // empty message list just because the conversation list has loaded. reloadConversationView(); } + if (state_ == State::Conversation && conversation_ && !team_conv_active_ && + ::ui::chat_voice::isAvailable() && + lv_tick_elaps(voice_projection_last_poll_ms_) >= 1000U) + { + voice_projection_last_poll_ms_ = lv_tick_get(); + if (currentVoiceProjectionSignature() != rendered_voice_projection_signature_) + { + reloadConversationView(); + } + } const auto receive = ::platform::ui::reticulum_receive::snapshot(); if (state_ == State::Conversation && conversation_ && (receive.active || receive_status_visible_)) @@ -1179,6 +1190,7 @@ void UiController::switchToConversation(chat::ConversationId conv) if (snapshot_loaded) { applySnapshotMessagesToConversation(chat_snapshot_buffer_, *conversation_); + appendVoiceMessagesToConversation(); } conversation_view_loaded_ = snapshot_loaded; CHAT_UI_LOG("[ChatUiTrace] stage=switch_conversation mark_read begin elapsed_ms=%lu\n", @@ -1320,7 +1332,11 @@ void UiController::switchToCompose(chat::ConversationId conv) } std::string header = "[" + std::string(protocol_short_label(conv.protocol)) + "] " + title; compose_->setHeaderText(header.c_str(), nullptr); +#if !defined(ARDUINO_T_WATCH_S3) + compose_->setVoiceButton("Voice", ::ui::chat_voice::canRecordAndSend()); +#else compose_->setPositionButton(nullptr, false); +#endif } void UiController::handleChannelSelected(const chat::ConversationId& conv) @@ -1818,6 +1834,56 @@ void UiController::reloadConversationView() return; } applySnapshotMessagesToConversation(chat_snapshot_buffer_, *conversation_); + appendVoiceMessagesToConversation(); +} + +void UiController::appendVoiceMessagesToConversation() +{ + if (!conversation_ || team_conv_active_ || !::ui::chat_voice::isAvailable()) + { + return; + } + const std::size_t count = ::ui::chat_voice::listReceivedMessages( + voice_projection_buffer_, kVoiceProjectionCapacity); + for (std::size_t index = count; index > 0U; --index) + { + const auto& summary = voice_projection_buffer_[index - 1U]; + const bool matches_current = summary.private_message + ? current_conv_.peer != 0U && + summary.sender_id == current_conv_.peer + : current_conv_.peer == 0U; + if (matches_current) + { + conversation_->addVoiceMessage(summary); + } + } + rendered_voice_projection_signature_ = currentVoiceProjectionSignature(); + conversation_->scrollToBottom(); +} + +uint64_t UiController::currentVoiceProjectionSignature() +{ + if (!::ui::chat_voice::isAvailable()) + { + return 0U; + } + const std::size_t count = ::ui::chat_voice::listReceivedMessages( + voice_projection_buffer_, kVoiceProjectionCapacity); + uint64_t signature = static_cast(count); + for (std::size_t index = 0U; index < count; ++index) + { + const auto& summary = voice_projection_buffer_[index]; + const bool matches_current = summary.private_message + ? current_conv_.peer != 0U && + summary.sender_id == current_conv_.peer + : current_conv_.peer == 0U; + if (matches_current) + { + signature ^= summary.local_id + 0x9E3779B97F4A7C15ULL + + (signature << 6U) + (signature >> 2U); + } + } + return signature; } bool UiController::isTeamConversation(const chat::ConversationId& conv) const @@ -2173,6 +2239,7 @@ void UiController::handleConversationAction(ChatConversationScreen::ActionIntent return; } applySnapshotMessagesToConversation(chat_snapshot_buffer_, *conversation_); + appendVoiceMessagesToConversation(); return; } @@ -2240,6 +2307,7 @@ void UiController::handleConversationAction(ChatConversationScreen::ActionIntent applySnapshotMessagesToConversation(chat_snapshot_buffer_, *conversation_, ConversationScrollAnchor::Top); + appendVoiceMessagesToConversation(); return; } #endif @@ -2348,6 +2416,28 @@ void UiController::handleComposeAction(ChatComposeScreen::ActionIntent intent) return; } +#if !defined(ARDUINO_T_WATCH_S3) + if (intent == ChatComposeScreen::ActionIntent::Voice) + { + switch (::ui::chat_voice::requestRecordAndSend(current_conv_.peer)) + { + case ::ui::chat_voice::StartResult::Queued: + ::ui::feedback::show_notice("Recording voice (max 5s)", 2200); + return; + case ::ui::chat_voice::StartResult::PrivateContactUnverified: + ::ui::feedback::show_notice("Verify contact before private voice", 2400); + return; + case ::ui::chat_voice::StartResult::Busy: + ::ui::feedback::show_notice("Voice session already active", 1800); + return; + case ::ui::chat_voice::StartResult::Unsupported: + default: + ::ui::feedback::show_notice("Voice unavailable on this device", 2000); + return; + } + } +#endif + if (intent == ChatComposeScreen::ActionIntent::Send) { CHAT_UI_LOG("[ChatUiTrace] stage=compose_action read_text begin elapsed_ms=%lu\n", diff --git a/modules/ui_shared/tests/test_chat_voice_runtime.cpp b/modules/ui_shared/tests/test_chat_voice_runtime.cpp new file mode 100644 index 00000000..1bbc3f82 --- /dev/null +++ b/modules/ui_shared/tests/test_chat_voice_runtime.cpp @@ -0,0 +1,105 @@ +#include "ui/chat_voice_runtime.h" + +#include +#include +#include + +namespace +{ + +class FakeVoiceRuntime final : public ui::chat_voice::IVoiceMessageRuntime +{ + public: + bool isAvailable() const override + { + return available; + } + + bool canRecordAndSend() const override + { + return send_available; + } + + ui::chat_voice::StartResult requestRecordAndSend(uint32_t target_id) override + { + last_target = target_id; + ++request_count; + return result; + } + + std::size_t listReceivedMessages(ui::chat_voice::MessageSummary* out_messages, + std::size_t capacity) const override + { + if (!out_messages || capacity == 0U) + { + return 0U; + } + out_messages[0] = summary; + return 1U; + } + + bool requestPlayback(uint64_t local_id) override + { + played_id = local_id; + return playback_result; + } + + bool available = true; + bool send_available = true; + uint32_t last_target = 0U; + uint32_t request_count = 0U; + mutable ui::chat_voice::MessageSummary summary{0xF00DU, 9U, 0U, 123U, false, true}; + uint64_t played_id = 0U; + bool playback_result = true; + ui::chat_voice::StartResult result = ui::chat_voice::StartResult::Queued; +}; + +void test_unbound_runtime_is_safe() +{ + ui::chat_voice::setRuntime(nullptr); + assert(!ui::chat_voice::isAvailable()); + assert(!ui::chat_voice::canRecordAndSend()); + assert(ui::chat_voice::requestRecordAndSend(1U) == + ui::chat_voice::StartResult::Unsupported); +} + +void test_runtime_forwards_without_transport_coupling() +{ + FakeVoiceRuntime runtime{}; + ui::chat_voice::setRuntime(&runtime); + + assert(ui::chat_voice::isAvailable()); + assert(ui::chat_voice::canRecordAndSend()); + assert(ui::chat_voice::requestRecordAndSend(0x11223344U) == + ui::chat_voice::StartResult::Queued); + assert(runtime.request_count == 1U); + assert(runtime.last_target == 0x11223344U); + + runtime.available = false; + runtime.send_available = false; + runtime.result = ui::chat_voice::StartResult::PrivateContactUnverified; + assert(!ui::chat_voice::isAvailable()); + assert(!ui::chat_voice::canRecordAndSend()); + assert(ui::chat_voice::requestRecordAndSend(0x55667788U) == + ui::chat_voice::StartResult::PrivateContactUnverified); + assert(runtime.request_count == 2U); + assert(runtime.last_target == 0x55667788U); + + ui::chat_voice::MessageSummary summaries[1] = {}; + assert(ui::chat_voice::listReceivedMessages(summaries, 1U) == 1U); + assert(summaries[0].local_id == runtime.summary.local_id); + assert(summaries[0].source_unverified); + assert(ui::chat_voice::requestPlayback(summaries[0].local_id)); + assert(runtime.played_id == summaries[0].local_id); + + ui::chat_voice::setRuntime(nullptr); +} + +} // namespace + +int main() +{ + test_unbound_runtime_is_safe(); + test_runtime_forwards_without_transport_coupling(); + return 0; +} diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/app_tasks.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_tasks.h index b1312c1b..9c60c3a2 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/app_tasks.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_tasks.h @@ -15,6 +15,25 @@ namespace app { +/** + * @brief Optional bounded sideband parser for application-owned raw RF frames. + * + * The interceptor runs in the mesh task before the configured mesh adapter. + * It must return false for every frame it does not own, and it must not block, + * pause radio tasks, or perform radio I/O from that task. Returning true + * consumes the frame permanently; this is how an application protocol such as + * VMP stays outside the MT/MC/RT decoder and forwarding paths. + */ +class IRawRadioPacketInterceptor +{ + public: + virtual ~IRawRadioPacketInterceptor() = default; + virtual bool tryConsume(const uint8_t* data, + size_t size, + float rssi, + float snr) = 0; +}; + /** * @brief Task management */ @@ -116,6 +135,14 @@ class AppTasks static bool isRadioTransmitActive(); static bool enqueueRadioTransmit(const uint8_t* data, size_t size); + /** + * @brief Installs/removes the one application-owned raw packet interceptor. + * + * The caller retains ownership and must clear the pointer before destroying + * the interceptor. Null leaves existing mesh-adapter behavior unchanged. + */ + static void setRawRadioPacketInterceptor(IRawRadioPacketInterceptor* interceptor); + class ScopedRadioTransmitActivity { public: @@ -133,6 +160,7 @@ class AppTasks static TaskHandle_t mesh_task_handle_; static LoraBoard* board_; static chat::IMeshAdapter* adapter_; + static IRawRadioPacketInterceptor* raw_radio_packet_interceptor_; static uint8_t* radio_rx_scratch_; static volatile bool radio_tasks_paused_; static volatile bool radio_receive_active_; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h index 4b407118..e6bd874c 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h @@ -74,6 +74,8 @@ class LxmfAdapter : public IMeshAdapter, private runtime::IPeerProjectionSink bool requestNodeInfo(NodeId dest, bool want_response) override; bool broadcastSelfIdentity() override; NodeId getNodeId() const override; + /** Derives a domain-separated VMP contact secret from the peer LXMF identity. */ + bool deriveVmpContactSecret(NodeId peer_id, uint8_t out_secret[32]); bool getReticulumLocalIdentityInfo(ReticulumLocalIdentityInfo* out) const override; MeshActionResult startReticulumAudioCall( const ReticulumPeerIdentity& destination) override; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h index 0c2e6e55..7ea30fb1 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h @@ -28,6 +28,12 @@ class MeshAdapterRouter : public IMeshAdapter, IMeshAdapter* backendForProtocol(MeshProtocol protocol) override; const IMeshAdapter* backendForProtocol(MeshProtocol protocol) const override; + /** + * Pager-only bridge to a VMP-domain-separated secret of the active, + * verified contact identity. This deliberately is not part of IMeshAdapter. + */ + bool deriveVmpContactSecret(NodeId peer_id, uint8_t out_secret[32]); + MeshCapabilities getCapabilities() const override; bool sendText(ChannelId channel, const std::string& text, MessageId* out_msg_id, NodeId peer = 0) override; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshcore/meshcore_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshcore/meshcore_adapter.h index dbfd8e81..04e2a142 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshcore/meshcore_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshcore/meshcore_adapter.h @@ -82,6 +82,8 @@ class MeshCoreAdapter : public IMeshAdapter, bool submitKeyVerificationNumber(NodeId dest, uint64_t nonce, uint32_t number) override; bool isPkiReady() const override; bool hasPkiKey(NodeId dest) const override; + /** Derives a domain-separated VMP contact secret from a verified MC peer. */ + bool deriveVmpContactSecret(NodeId peer_id, uint8_t out_secret[32]); void applyConfig(const MeshConfig& config) override; void setUserInfo(const char* long_name, const char* short_name) override; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h index fc1694dd..f8a5ccc5 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h @@ -70,6 +70,8 @@ class MtAdapter : public chat::IMeshAdapter bool hasPkiKey(NodeId dest) const override; bool getNodePublicKey(NodeId node_id, uint8_t out_key[32]) const; bool getOwnPublicKey(uint8_t out_key[32]) const; + /** Derives a domain-separated VMP contact secret from verified MT PKI. */ + bool deriveVmpContactSecret(NodeId peer_id, uint8_t out_secret[32]); void rememberNodePublicKey(NodeId node_id, const uint8_t* key, size_t key_len); void forgetNodePublicKey(NodeId node_id); meshtastic_Routing_Error getLastRoutingError() const; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h index b09cfd0a..630f8d1b 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h @@ -63,6 +63,8 @@ class ReticulumAdapter final : public IMeshAdapter, bool requestNodeInfo(NodeId dest, bool want_response) override; bool broadcastSelfIdentity() override; NodeId getNodeId() const override; + /** Delegates VMP secret derivation to the active LXMF identity service. */ + bool deriveVmpContactSecret(NodeId peer_id, uint8_t out_secret[32]); bool getReticulumLocalIdentityInfo(ReticulumLocalIdentityInfo* out) const override; MeshActionResult startReticulumAudioCall( const ReticulumPeerIdentity& destination) override; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/message_attachment_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/message_attachment_store.h new file mode 100644 index 00000000..8de86930 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/message_attachment_store.h @@ -0,0 +1,62 @@ +/** + * @file message_attachment_store.h + * @brief Durable local sidecar storage for non-text chat message payloads. + * + * Text stays in SdStore's protocol-partitioned message journal. Large or + * structured message bodies live here and are linked by a stable local + * attachment identifier instead of being duplicated into the text journal. + * The storage layer owns no radio, MQTT, LXMF, or relay operation. + */ + +#pragma once + +#include "chat/infra/voice/vmp_voice_inbox.h" + +#include +#include + +namespace platform::esp::arduino_common::chat_attachment +{ + +/** + * Stable attachment family identifiers. Voice is the first implemented + * adapter; Image and Location reserve the common storage contract so they do + * not grow a protocol-specific persistence path later. + */ +enum class AttachmentKind : uint8_t +{ + Voice = 1U, + Image = 2U, + Location = 3U, +}; + +enum class VoiceInboxLoadResult : uint8_t +{ + Restored = 1U, + Empty = 2U, + Unavailable = 3U, + Corrupt = 4U, + IoError = 5U, +}; + +/** + * Writes an atomic snapshot of the local voice attachment index and payloads. + * The caller supplies PSRAM-backed metadata and byte scratch storage so the + * persistence path cannot reserve a second internal-RAM inbox. + */ +bool persistVoiceInbox( + const ::chat::voice::vmp::VoiceMessageInbox& inbox, + ::chat::voice::vmp::VoiceMessageMetadata* metadata_scratch, + std::size_t metadata_capacity); + +/** + * Restores the voice attachment adapter from its committed snapshot. The + * caller supplies a PSRAM-backed media scratch buffer of at least + * kMaxEncodedMediaSize bytes. No received record is put back on any bearer. + */ +VoiceInboxLoadResult restoreVoiceInbox( + ::chat::voice::vmp::VoiceMessageInbox* inbox, + uint8_t* media_scratch, + std::size_t media_scratch_size); + +} // namespace platform::esp::arduino_common::chat_attachment diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_control_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_control_runtime.h new file mode 100644 index 00000000..cd0c4632 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_control_runtime.h @@ -0,0 +1,46 @@ +/** + * @file vmp_control_runtime.h + * @brief Pager-owned control-plane handoff for VMP v1. + * + * VMP envelopes are removed from AppTasks before any MT, MC, or RT adapter is + * reached. This runtime has no mesh send API and no forwarding API: a + * consumed voice control packet is delivered only to its local VMP handler. + */ + +#pragma once + +#include +#include + +namespace platform::esp::arduino_common::voice::vmp_control +{ + +inline constexpr std::size_t kControlEnvelopeSize = 95U; + +struct Envelope +{ + uint8_t bytes[kControlEnvelopeSize] = {}; + float rssi = 0.0F; + float snr = 0.0F; +}; + +using EnvelopeHandler = void (*)(const Envelope& envelope, void* context); + +/** + * @brief Starts the fixed-depth control dispatcher and raw-RF interceptor. + * + * On boards other than the LR1121 Pager the function is a harmless false + * return. Calling it more than once is safe. + */ +bool initialize(); + +/** + * @brief Installs the only local VMP control consumer. + * + * The callback executes on the VMP-owned task, never in AppTasks::meshTask. + * Replacing a handler is atomic with respect to a future dispatch; callers + * must ensure an old handler/context remains alive until it is replaced. + */ +void setEnvelopeHandler(EnvelopeHandler handler, void* context); + +} // namespace platform::esp::arduino_common::voice::vmp_control diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_audio.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_audio.h new file mode 100644 index 00000000..30bd8fcc --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_audio.h @@ -0,0 +1,102 @@ +/** + * @file vmp_pager_audio.h + * @brief Bounded Codec2 recording/playback adapter for Pager VMP. + * + * This adapter uses PSRAM-backed encoded media supplied by its enclosing VMP + * media store, while it acquires its only DMA-capable PCM frame scratch for + * the duration of recording or playback. Its five-second capture ceiling is + * enforced by Codec2 frame count, not by a UI timer, so a blocked or slow UI + * cannot cause over-recording. + */ + +#pragma once + +#include "chat/infra/voice/vmp_wire.h" + +#include +#include + +namespace platform::esp::arduino_common::voice::vmp_audio +{ + +inline constexpr uint32_t kSampleRateHz = 8000U; +inline constexpr uint8_t kBitsPerSample = 16U; +inline constexpr uint8_t kHardwareChannels = 2U; +inline constexpr std::size_t kCodec2FramesPerMessage = 125U; +inline constexpr std::size_t kCodec2SamplesPerFrame = 320U; +inline constexpr std::size_t kCodec2BytesPerFrame = 7U; +inline constexpr std::size_t kMaximumEncodedBytes = + kCodec2FramesPerMessage * kCodec2BytesPerFrame; + +enum class CaptureResult : uint8_t +{ + Complete, + Cancelled, + Unsupported, + AudioBusy, + AudioFailure, + CodecFailure, +}; + +enum class PlaybackResult : uint8_t +{ + Complete, + Unsupported, + AudioBusy, + InvalidMedia, + AudioFailure, + CodecFailure, +}; + +/** + * @brief Pager microphone/speaker adapter for VMP's fixed Codec2-1300 format. + * + * Only Codec2-1300 is accepted. That restriction keeps a five second clip + * at 875 bytes so VMP v1 always fits in its one RS(10,8) radio block. + */ +class PagerCodec2Audio final +{ + public: + PagerCodec2Audio() = default; + PagerCodec2Audio(const PagerCodec2Audio&) = delete; + PagerCodec2Audio& operator=(const PagerCodec2Audio&) = delete; + + [[nodiscard]] bool isSupported() const; + + /** + * @brief Records at most 125 Codec2 frames (exactly five seconds). + * + * A caller may set `stop_requested` to terminate early; an early clip + * remains a valid message if it contains at least one full Codec2 frame. + */ + CaptureResult capture(const volatile bool* stop_requested = nullptr); + + [[nodiscard]] const uint8_t* encodedMedia() const; + [[nodiscard]] std::size_t encodedMediaSize() const; + [[nodiscard]] bool hasEncodedMedia() const; + void clearEncodedMedia(); + + /** @brief Decodes and plays one local VMP voice object. */ + PlaybackResult play(const uint8_t* encoded_media, + std::size_t encoded_media_len, + chat::voice::vmp::Codec codec, + uint8_t volume_percent = 70U); + + private: + struct FrameScratch; + + bool acquireFrameScratch(); + void releaseFrameScratch(); + bool readCaptureFrame(); + bool writePlaybackFrame(); + void mixCaptureToMono(); + void duplicatePlaybackToStereo(); + + // PCM needs internal DMA-capable RAM but only while the codec owns audio. + // Encoded media can safely live in the Pager's PSRAM VMP media store. + FrameScratch* frame_scratch_ = nullptr; + uint8_t encoded_media_[kMaximumEncodedBytes] = {}; + std::size_t encoded_media_size_ = 0U; +}; + +} // namespace platform::esp::arduino_common::voice::vmp_audio diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h new file mode 100644 index 00000000..a83a2f8c --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h @@ -0,0 +1,176 @@ +/** + * @file vmp_pager_session.h + * @brief Pager VMP session with LR1121 direct RF and SX1262 MQTT-only modes. + * + * On LR1121 the service receives direct VMP control through the dedicated + * control runtime and switches the Pager radio through the VMP-exclusive + * lease. On SX1262 it has no direct-radio or LXMF carrier and uses only an + * enabled MT MQTT bridge. Every accepted object terminates in the local-only + * VMP inbox; the service has no API that forwards or republishes a received + * object. + */ + +#pragma once + +#include "chat/infra/voice/vmp_contact_secrets.h" +#include "chat/infra/voice/vmp_voice_inbox.h" + +#include +#include + +namespace platform::esp::arduino_common::voice::vmp_session +{ + +enum class StartSendResult : uint8_t +{ + Queued = 1, + Unsupported = 2, + Busy = 3, + PrivateContactUnverified = 4, +}; + +/** LXMF AppData port reserved exclusively for the VMP VQ envelope carrier. */ +inline constexpr uint32_t kLxmfAppDataPort = 0x564D5001UL; + +/** + * Sends one already-framed VMP VQ envelope through an authenticated LXMF + * unicast. The callback owns no VMP state and must copy the supplied bytes + * before returning. + */ +using LxmfEnvelopeSender = bool (*)(void* context, + uint32_t target_id, + const uint8_t* envelope, + std::size_t envelope_len); + +/** + * Derives a VMP-only contact secret from an already verified peer identity. + * + * This callback is deliberately scoped to VMP: it neither changes nor exposes + * MT/MC protocol keys. It must fail unless the enclosing contact service has + * independently verified the peer identity, keeping private VMP fail-closed. + */ +using VerifiedContactSecretDeriver = bool (*)( + void* context, + uint32_t peer_id, + uint8_t out_secret[chat::voice::vmp::kPrivateKeySize]); + +/** + * Installs the VMP receiver on an already initialized Pager runtime. + * + * `durable_attachment_store` must mirror the active text-chat store policy: + * when text is backed by SdStore, VMP remains unavailable until the same + * deferred-storage lifecycle has restored its local attachment inbox. + */ +bool initialize(uint32_t self_node_id, bool durable_attachment_store); + +/** + * @brief True when this Pager can currently record and send a VMP object. + * + * LR1121 has the direct Sub-GHz/2.4 GHz carrier. SX1262 returns true only + * while the isolated Meshtastic MQTT uplink is enabled; SX1262 never has a + * direct-RF or LXMF VMP carrier. + */ +bool canRecordAndSend(); + +/** Marks the shared chat-storage hydration complete and starts VMP restore. */ +void onPersistentStorageReady(); + +/** Retries a failed bounded VMP attachment restore without blocking UI work. */ +void servicePersistentInbox(); + +/** + * @brief Adds a key obtained by an already-completed verified contact pairing. + * + * This does not perform pairing or discover a peer automatically. Supplying a + * contact secret is an explicit trusted provisioning action, and private VMP + * remains unavailable for a contact absent from this directory. + */ +bool provisionVerifiedContactSecret( + uint32_t peer_id, + const uint8_t secret[chat::voice::vmp::kPrivateKeySize]); + +/** Installs the narrow verified-identity-to-VMP contact-secret bridge. */ +void setVerifiedContactSecretDeriver(VerifiedContactSecretDeriver deriver, + void* context); + +/** + * Invalidates RAM-cached VMP contact secrets after an active mesh identity + * family changes. An in-progress VMP session retains its own session keys. + */ +void invalidateContactSecretCache(); + +/** @brief Local-only inbox for chat projection and on-demand playback. */ +const chat::voice::vmp::VoiceMessageInbox* inbox(); + +/** @brief Copies newest-first local VMP metadata under the session lock. */ +std::size_t listInboxMetadata(chat::voice::vmp::VoiceMessageMetadata* out_metadata, + std::size_t capacity); + +/** @brief Decodes one already-local VMP inbox object to the Pager speaker. */ +bool playInboxMessage(uint64_t local_id, uint8_t volume_percent = 70U); + +/** + * @brief Starts local playback on a VMP worker so the UI task never blocks. + * + * Playback reads exclusively from the local-only inbox and cannot transmit or + * relay the object. It is rejected while voice capture/transmit is active. + */ +bool requestPlayback(uint64_t local_id); + +/** + * @brief Dequeues one bounded VMP MQTT envelope for the MT MQTT bridge. + * + * This exposes no MT packet and no radio operation. The envelope is produced + * only from a completed local VMP send. The caller must acknowledge it only + * after the MQTT client has written it, so a socket failure retains the same + * bounded envelope for retry. + */ +bool peekMqttEnvelope(uint8_t* out, std::size_t* inout_len); + +/** @brief Commits the envelope only after the MQTT client wrote it to socket. */ +bool acknowledgeMqttEnvelope(); + +/** @brief Mirrors the active MT MQTT uplink policy into the isolated VMP queue. */ +void setMqttUplinkEnabled(bool enabled); + +/** Installs the narrow RT/LXMF egress bridge; it is never a radio callback. */ +void setLxmfEnvelopeSender(LxmfEnvelopeSender sender, void* context); + +/** + * Selects LXMF VQ unicast for a private recording while RT is active. + * Broadcast remains the direct public 2.4 GHz VMP mode because LXMF has no + * equivalent one-packet broadcast primitive. + */ +void setLxmfCarrierEnabled(bool enabled); + +/** + * @brief Accepts one subscribed VMP MQTT envelope into local VMP storage. + * + * This function never invokes radio TX, MT/MC receive adapters, or a publish + * path. A completed valid object is stored in the local-only VMP inbox. + */ +bool acceptMqttEnvelope(const uint8_t* envelope, std::size_t envelope_len); + +/** + * Terminates an authenticated LXMF AppData VQ envelope in local storage. + * `source_id` must agree with the VMP envelope sender. It never enters the + * generic AppData queue and never invokes any transmit or relay path. + */ +bool acceptLxmfEnvelope(uint32_t source_id, + const uint8_t* envelope, + std::size_t envelope_len); + +/** @brief Explicitly discards a not-yet-uploaded in-memory VMP cloud object. */ +void discardMqttPublication(); + +/** + * @brief Queues microphone capture and a one-hop VMP transmit session. + * + * `target_id == kBroadcastTargetId` starts a public broadcast. Any other + * nonzero target starts a private session and requires an already provisioned + * verified-contact secret. Recording itself is performed by a VMP worker, so + * this call never blocks a UI task for five seconds. + */ +StartSendResult requestRecordAndSend(uint32_t target_id); + +} // namespace platform::esp::arduino_common::voice::vmp_session diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_radio_lease.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_radio_lease.h new file mode 100644 index 00000000..d84d396e --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_radio_lease.h @@ -0,0 +1,54 @@ +/** + * @file vmp_radio_lease.h + * @brief Exclusive LR1121 2.4 GHz lease for VMP control/data transfers. + * + * This is deliberately a narrow Pager-only adapter. It borrows the current + * Sub-GHz configuration for one VMP control exchange, switches only the same + * LR1121 to VMP GFSK, and restores the captured LoRa configuration before + * normal AppTasks RX resumes. It exposes no method for received media to + * re-enter the generic mesh TX queue. + */ + +#pragma once + +#include +#include + +namespace platform::esp::arduino_common::voice::vmp_radio +{ + +struct PhyProfile +{ + float frequency_mhz = 0.0f; + float bit_rate_kbps = 500.0f; + float frequency_deviation_khz = 250.0f; + float receive_bandwidth_khz = 800.0f; + int8_t tx_power_dbm = 10; + uint16_t preamble_length = 16; +}; + +struct Lease +{ + void* implementation = nullptr; + bool owns_radio_tasks = false; + bool switched_to_2ghz = false; +}; + +bool isSupported(); +bool tryAcquire(Lease* out_lease); + +/** @brief Applies a bounded regional 2.4 GHz VMP GFSK profile. */ +bool switchTo2Ghz(Lease* lease, const PhyProfile& profile); + +/** @brief Sends one VMP control or media frame on the currently active PHY. */ +bool transmit(Lease* lease, const uint8_t* data, std::size_t size); + +bool startReceive(Lease* lease); +int packetLength(Lease* lease); +bool readPacket(Lease* lease, uint8_t* out, std::size_t size); +void clearIrq(Lease* lease); + +/** @brief Always restores cached Sub-GHz LoRa and resumes normal task RX. */ +void release(Lease* lease); + +} // namespace platform::esp::arduino_common::voice::vmp_radio diff --git a/platform/esp/arduino_common/src/app_context.cpp b/platform/esp/arduino_common/src/app_context.cpp index 173b54b5..b8f940a4 100644 --- a/platform/esp/arduino_common/src/app_context.cpp +++ b/platform/esp/arduino_common/src/app_context.cpp @@ -13,15 +13,20 @@ #include "board/LoraBoard.h" #include "board/MotionBoard.h" #include "chat/infra/mesh_protocol_utils.h" +#include "chat/ports/i_mesh_adapter.h" #include "chat/runtime/self_identity_policy.h" +#include "chat/usecase/contact_service.h" #include "platform/esp/arduino_common/app_tasks.h" +#include "platform/esp/arduino_common/chat/infra/mesh_adapter_router.h" #include "platform/esp/arduino_common/memory_diag.h" #include "platform/esp/arduino_common/storage/storage_runtime.h" +#include "platform/esp/arduino_common/voice/vmp_pager_session.h" #include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/reticulum_directory_runtime.h" #include "platform/ui/reticulum_group_config_runtime.h" #include "sys/event_bus.h" #include "ui/chat_ui_runtime_proxy.h" +#include "ui/chat_voice_runtime.h" #include "ui/ui_boot.h" #include @@ -35,6 +40,154 @@ namespace { constexpr TickType_t kConfigSaveMutexWait = pdMS_TO_TICKS(20); +class PagerVoiceMessageRuntime final : public ::ui::chat_voice::IVoiceMessageRuntime +{ + public: + bool initialize() + { + if (metadata_scratch_) + { + return true; + } + metadata_scratch_ = static_cast( + heap_caps_malloc(sizeof(chat::voice::vmp::VoiceMessageMetadata) * + chat::voice::vmp::kVoiceInboxCapacity, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + return metadata_scratch_ != nullptr; + } + + bool isAvailable() const override + { + return metadata_scratch_ != nullptr && + ::platform::esp::arduino_common::voice::vmp_session::inbox() != nullptr; + } + + bool canRecordAndSend() const override + { + return ::platform::esp::arduino_common::voice::vmp_session::canRecordAndSend(); + } + + ::ui::chat_voice::StartResult requestRecordAndSend(uint32_t target_id) override + { + switch (::platform::esp::arduino_common::voice::vmp_session::requestRecordAndSend( + target_id)) + { + case ::platform::esp::arduino_common::voice::vmp_session::StartSendResult::Queued: + return ::ui::chat_voice::StartResult::Queued; + case ::platform::esp::arduino_common::voice::vmp_session::StartSendResult::Busy: + return ::ui::chat_voice::StartResult::Busy; + case ::platform::esp::arduino_common::voice::vmp_session::StartSendResult::PrivateContactUnverified: + return ::ui::chat_voice::StartResult::PrivateContactUnverified; + case ::platform::esp::arduino_common::voice::vmp_session::StartSendResult::Unsupported: + default: + return ::ui::chat_voice::StartResult::Unsupported; + } + } + + std::size_t listReceivedMessages( + ::ui::chat_voice::MessageSummary* out_messages, + std::size_t capacity) const override + { + if (!out_messages || capacity == 0U || !metadata_scratch_) + { + return 0U; + } + const std::size_t limit = + capacity < chat::voice::vmp::kVoiceInboxCapacity + ? capacity + : chat::voice::vmp::kVoiceInboxCapacity; + const std::size_t count = + ::platform::esp::arduino_common::voice::vmp_session::listInboxMetadata( + metadata_scratch_, limit); + for (std::size_t index = 0U; index < count; ++index) + { + const auto& metadata = metadata_scratch_[index]; + out_messages[index].local_id = metadata.local_id; + out_messages[index].sender_id = metadata.sender_id; + out_messages[index].target_id = metadata.target_id; + out_messages[index].received_at_seconds = metadata.received_at_seconds; + out_messages[index].private_message = + metadata.mode == chat::voice::vmp::DeliveryMode::Private; + out_messages[index].source_unverified = metadata.source_unverified; + } + return count; + } + + bool requestPlayback(uint64_t local_id) override + { + return ::platform::esp::arduino_common::voice::vmp_session::requestPlayback( + local_id); + } + + private: + mutable chat::voice::vmp::VoiceMessageMetadata* metadata_scratch_ = nullptr; +}; + +PagerVoiceMessageRuntime s_pager_voice_message_runtime{}; + +bool deriveVmpVerifiedContactSecret(void* context, + uint32_t peer_id, + uint8_t out_secret[chat::voice::vmp::kPrivateKeySize]) +{ + auto* const app_context = static_cast(context); + if (!app_context || !out_secret || peer_id == 0U || + peer_id == chat::voice::vmp::kBroadcastTargetId) + { + return false; + } + + const chat::MeshProtocol protocol = chat::infra::normalizeMeshProtocol( + app_context->getMeshProtocol()); + if (protocol != chat::MeshProtocol::Meshtastic && + protocol != chat::MeshProtocol::MeshCore && + protocol != chat::MeshProtocol::Reticulum) + { + return false; + } + + const chat::contacts::PeerDirectoryItem* const peer = + app_context->getContactService().getPeerByNodeId(peer_id); + if (!peer || peer->is_ignored || !peer->has_public_key || + ((protocol == chat::MeshProtocol::Meshtastic || + protocol == chat::MeshProtocol::MeshCore) && + !peer->key_manually_verified) || + (protocol == chat::MeshProtocol::Reticulum && !peer->is_contact)) + { + return false; + } + + // AppContext owns MeshAdapterRouter for the ESP production runtime. Its + // VMP-only method intentionally does not widen the IMeshAdapter contract. + auto* const router = static_cast( + app_context->getMeshAdapter()); + return router && router->deriveVmpContactSecret(peer_id, out_secret); +} + +bool sendVmpLxmfEnvelope(void* context, + uint32_t target_id, + const uint8_t* envelope, + std::size_t envelope_len) +{ + auto* const app_context = static_cast(context); + if (!app_context || !envelope || envelope_len == 0U || + target_id == chat::voice::vmp::kBroadcastTargetId || + !chat::infra::isReticulumMeshProtocol( + chat::infra::normalizeMeshProtocol(app_context->getMeshProtocol()))) + { + return false; + } + chat::IMeshAdapter* const adapter = app_context->getMeshAdapter(); + return adapter && adapter->sendAppData( + chat::ChannelId::PRIMARY, + ::platform::esp::arduino_common::voice::vmp_session::kLxmfAppDataPort, + envelope, + envelope_len, + target_id, + false, + 0U, + false); +} + void normalize_reticulum_interface_strategy(AppConfig& config) { chat::MeshConfig& reticulum = config.reticulumConfig(); @@ -179,6 +332,40 @@ void AppContext::initChatRuntime(bool use_mock_adapter) applyNetworkLimits(); applyPrivacyConfig(); applyChatDefaults(); +#if defined(ARDUINO_T_LORA_PAGER) + if (!::platform::esp::arduino_common::voice::vmp_session::initialize( + getSelfNodeId(), deferred_storage_store_context_ != nullptr)) + { + Serial.printf("[VMP] Pager voice service unavailable\n"); + ::ui::chat_voice::setRuntime(nullptr); + } + else + { + ::platform::esp::arduino_common::voice::vmp_session::setVerifiedContactSecretDeriver( + &deriveVmpVerifiedContactSecret, this); +#if defined(ARDUINO_LILYGO_LORA_LR1121) + ::platform::esp::arduino_common::voice::vmp_session::setLxmfEnvelopeSender( + &sendVmpLxmfEnvelope, this); + ::platform::esp::arduino_common::voice::vmp_session::setLxmfCarrierEnabled( + chat::infra::isReticulumMeshProtocol( + chat::infra::normalizeMeshProtocol(config_.mesh_protocol))); +#endif + if (!s_pager_voice_message_runtime.initialize()) + { + // This metadata projection is deliberately PSRAM-only. A Pager + // without its bounded external-media pool must not silently add + // another permanent internal-RAM VMP buffer. + Serial.printf("[VMP] UI metadata scratch unavailable in PSRAM\n"); + ::ui::chat_voice::setRuntime(nullptr); + } + else + { + ::ui::chat_voice::setRuntime(&s_pager_voice_message_runtime); + } + } +#else + ::ui::chat_voice::setRuntime(nullptr); +#endif } void AppContext::initTeamServices() @@ -477,6 +664,11 @@ void AppContext::applyMeshConfig() { chat_service_->setActiveProtocol(config_.mesh_protocol); } +#if defined(ARDUINO_T_LORA_PAGER) && defined(ARDUINO_LILYGO_LORA_LR1121) + ::platform::esp::arduino_common::voice::vmp_session::setLxmfCarrierEnabled( + chat::infra::isReticulumMeshProtocol( + chat::infra::normalizeMeshProtocol(config_.mesh_protocol))); +#endif } void AppContext::applyUserInfo() @@ -677,6 +869,13 @@ bool AppContext::switchMeshProtocol(chat::MeshProtocol protocol, bool persist) { contact_service_->setActiveProtocol(normalized); } +#if defined(ARDUINO_T_LORA_PAGER) + ::platform::esp::arduino_common::voice::vmp_session::invalidateContactSecretCache(); +#if defined(ARDUINO_LILYGO_LORA_LR1121) + ::platform::esp::arduino_common::voice::vmp_session::setLxmfCarrierEnabled( + chat::infra::isReticulumMeshProtocol(normalized)); +#endif +#endif if (persist) { @@ -722,6 +921,12 @@ void AppContext::getEffectiveUserInfo(char* out_long, size_t long_len, void AppContext::updateCoreServices() { flushConfigPersistence(millis()); +#if defined(ARDUINO_T_LORA_PAGER) + // Text and VMP attachments share the deferred-storage readiness boundary. + // After that boundary, this is a rate-limited retry only when an SD I/O + // failure prevented the local VMP attachment snapshot from restoring. + ::platform::esp::arduino_common::voice::vmp_session::servicePersistentInbox(); +#endif if (::platform::ui::reticulum_groups::hasPending()) { (void)::platform::ui::reticulum_groups::flushPending(); diff --git a/platform/esp/arduino_common/src/app_context_platform_bindings.cpp b/platform/esp/arduino_common/src/app_context_platform_bindings.cpp index 176e9c35..f4d5fae5 100644 --- a/platform/esp/arduino_common/src/app_context_platform_bindings.cpp +++ b/platform/esp/arduino_common/src/app_context_platform_bindings.cpp @@ -24,6 +24,7 @@ #include "platform/esp/arduino_common/team/event/team_event_bus_sink.h" #include "platform/esp/arduino_common/team/event/team_pairing_event_bus_sink.h" #include "platform/esp/arduino_common/team_platform_bundle.h" +#include "platform/esp/arduino_common/voice/vmp_pager_session.h" #include "platform/ui/reticulum_group_config_runtime.h" #include "platform/ui/team_ui_store_runtime.h" #include "team/usecase/team_controller.h" @@ -108,6 +109,11 @@ void init_track_recorder(const app::AppConfig& config) void deferred_storage_ready(app::IAppFacade& app_facade) { + // VMP's local attachment inbox follows the same deferred hydration gate + // as the authoritative text store. This is a local restore only; it never + // republishes an attachment to a radio, MQTT, or LXMF carrier. + ::platform::esp::arduino_common::voice::vmp_session::onPersistentStorageReady(); + const app::AppConfig& config = app_facade.readConfig(); if (chat::infra::isReticulumMeshProtocol( chat::infra::normalizeMeshProtocol(config.mesh_protocol))) diff --git a/platform/esp/arduino_common/src/app_tasks.cpp b/platform/esp/arduino_common/src/app_tasks.cpp index 306ab7f9..8479aaf8 100644 --- a/platform/esp/arduino_common/src/app_tasks.cpp +++ b/platform/esp/arduino_common/src/app_tasks.cpp @@ -253,6 +253,7 @@ TaskHandle_t AppTasks::radio_task_handle_ = nullptr; TaskHandle_t AppTasks::mesh_task_handle_ = nullptr; LoraBoard* AppTasks::board_ = nullptr; chat::IMeshAdapter* AppTasks::adapter_ = nullptr; +IRawRadioPacketInterceptor* AppTasks::raw_radio_packet_interceptor_ = nullptr; uint8_t* AppTasks::radio_rx_scratch_ = nullptr; volatile bool AppTasks::radio_tasks_paused_ = false; volatile bool AppTasks::radio_receive_active_ = false; @@ -467,6 +468,12 @@ bool AppTasks::enqueueRadioTransmit(const uint8_t* data, size_t size) return true; } +void AppTasks::setRawRadioPacketInterceptor( + IRawRadioPacketInterceptor* interceptor) +{ + raw_radio_packet_interceptor_ = interceptor; +} + AppTasks::ScopedRadioTransmitActivity::ScopedRadioTransmitActivity() { AppTasks::setRadioTransmitActive(true); @@ -780,13 +787,24 @@ void AppTasks::meshTask(void* pvParameters) } if (xQueueReceive(mesh_queue_, &rx_packet, 0) == pdPASS) { - if (!rx_packet.is_tx && rx_packet.data && adapter_) + if (!rx_packet.is_tx && rx_packet.data) { - // Decode and process through configured mesh adapter - adapter_->setLastRxStats(rx_packet.rssi, rx_packet.snr); - adapter_->handleRawPacket(rx_packet.data, rx_packet.size); + const bool intercepted = raw_radio_packet_interceptor_ && + raw_radio_packet_interceptor_->tryConsume( + rx_packet.data, + rx_packet.size, + rx_packet.rssi, + rx_packet.snr); + if (!intercepted && adapter_) + { + // The default behavior is intentionally unchanged for any + // frame that an application-owned sideband parser declines. + adapter_->setLastRxStats(rx_packet.rssi, rx_packet.snr); + adapter_->handleRawPacket(rx_packet.data, rx_packet.size); + } - // Free buffer + // Both the mesh adapter and a sideband parser receive borrowed + // bytes only; AppTasks retains and releases this allocation. heap_caps_free(rx_packet.data); } } diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp index f15aad0e..c5ff58c7 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp @@ -5,11 +5,14 @@ #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h" +#include "platform/esp/arduino_common/voice/vmp_pager_session.h" + #include "chat/domain/contact_types.h" #include "chat/domain/reticulum_identity.h" #include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" #include "chat/infra/reticulum/audio_call_wire.h" #include "chat/infra/reticulum/lxst_telephony_wire.h" +#include "chat/infra/voice/vmp_private_crypto.h" #include "chat/time_utils.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_call_profile.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h" @@ -2641,6 +2644,36 @@ NodeId LxmfAdapter::getNodeId() const return identity_.nodeId(); } +bool LxmfAdapter::deriveVmpContactSecret(NodeId peer_id, uint8_t out_secret[32]) +{ + if (!out_secret || peer_id == 0U || + peer_id == ::chat::voice::vmp::kBroadcastTargetId || + !identity_.isReady()) + { + return false; + } + const PeerInfo* const peer = findOrLoadPeerByNodeId(peer_id); + if (!peer || isZeroBytes(peer->enc_pub, sizeof(peer->enc_pub))) + { + return false; + } + + uint8_t identity_shared_secret[LxmfIdentity::kEncPubKeySize] = {}; + const bool derived = identity_.deriveSharedSecret(peer->enc_pub, identity_shared_secret) && + ::chat::voice::vmp::deriveVmpContactSecret( + identity_shared_secret, + ::chat::voice::vmp::ContactSecretIdentityFamily::Reticulum, + identity_.nodeId(), + peer_id, + out_secret); + volatile uint8_t* const cursor = identity_shared_secret; + for (std::size_t index = 0U; index < sizeof(identity_shared_secret); ++index) + { + cursor[index] = 0U; + } + return derived; +} + bool LxmfAdapter::getReticulumLocalIdentityInfo(ReticulumLocalIdentityInfo* out) const { if (!out) @@ -9571,6 +9604,24 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( if (delivery.kind == runtime::LxmfDeliveryKind::AppData) { + if (delivery.app_data.incoming.portnum == + ::platform::esp::arduino_common::voice::vmp_session::kLxmfAppDataPort) + { + const bool accepted = + ::platform::esp::arduino_common::voice::vmp_session::acceptLxmfEnvelope( + delivery.app_data.incoming.from, + delivery.app_data.payload.empty() ? nullptr + : delivery.app_data.payload.data(), + delivery.app_data.payload.size()); + // VMP is terminal at the local voice inbox. Even malformed VMP + // traffic is consumed here so it can never become generic app data + // that a later service might resend or bridge to another bearer. + Serial.printf("[VMP][LXMF] inbound from=%08lX bytes=%u local_only=%u\n", + static_cast(delivery.app_data.incoming.from), + static_cast(delivery.app_data.payload.size()), + accepted ? 1U : 0U); + return true; + } ::chat::infra::IncomingQueuePushReport report{}; if (data_receive_queue_.push(delivery.app_data.incoming, delivery.app_data.payload.empty() ? nullptr diff --git a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp index 69c9be47..22093ceb 100644 --- a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp +++ b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp @@ -5,6 +5,10 @@ #include "platform/esp/arduino_common/chat/infra/mesh_adapter_router.h" +#include "platform/esp/arduino_common/chat/infra/meshcore/meshcore_adapter.h" +#include "platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h" +#include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h" + namespace chat { @@ -70,6 +74,46 @@ const IMeshAdapter* MeshAdapterRouter::backendForProtocol(MeshProtocol protocol) return lock.locked() ? core_.backendForProtocol(protocol) : nullptr; } +bool MeshAdapterRouter::deriveVmpContactSecret(NodeId peer_id, + uint8_t out_secret[32]) +{ + if (!out_secret) + { + return false; + } + LockGuard lock(mutex_); + if (!lock.locked()) + { + return false; + } + + const MeshProtocol protocol = core_.backendProtocol(); + IMeshAdapter* const backend = core_.backendForProtocol(protocol); + if (!backend) + { + return false; + } + + // AppContext installs these concrete production backends. Keeping this + // Pager-only bridge here avoids adding a VMP key API to IMeshAdapter. + if (protocol == MeshProtocol::Meshtastic) + { + return static_cast(backend)->deriveVmpContactSecret( + peer_id, out_secret); + } + if (protocol == MeshProtocol::MeshCore) + { + return static_cast(backend)->deriveVmpContactSecret( + peer_id, out_secret); + } + if (protocol == MeshProtocol::Reticulum) + { + return static_cast(backend) + ->deriveVmpContactSecret(peer_id, out_secret); + } + return false; +} + MeshCapabilities MeshAdapterRouter::getCapabilities() const { LockGuard lock(mutex_); diff --git a/platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp b/platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp index 5d4f5283..3d7a1b39 100644 --- a/platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp +++ b/platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp @@ -3,10 +3,12 @@ #include "app/app_config.h" #include "app/app_facades.h" #include "chat/infra/meshtastic/mt_radio_config.h" +#include "chat/infra/voice/vmp_mqtt_transport.h" #include "meshtastic/mesh.pb.h" #include "meshtastic/mqtt.pb.h" #include "platform/esp/arduino_common/chat/infra/meshcore/meshcore_adapter.h" #include "platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h" +#include "platform/esp/arduino_common/voice/vmp_pager_session.h" #include "platform/ui/wifi_access_runtime.h" #include "platform/ui/wifi_runtime.h" #include "sys/event_bus.h" @@ -231,6 +233,7 @@ class PlainMqttRuntime if (protocol != chat::MeshProtocol::Meshtastic && protocol != chat::MeshProtocol::MeshCore) { + ::platform::esp::arduino_common::voice::vmp_session::setMqttUplinkEnabled(false); stop("protocol"); have_config_ = false; return; @@ -241,6 +244,10 @@ class PlainMqttRuntime refreshConfig(app_context, now_ms); } + ::platform::esp::arduino_common::voice::vmp_session::setMqttUplinkEnabled( + config_.configured && config_.protocol == RuntimeProtocol::Meshtastic && + config_.uplink_enabled); + if (!config_.configured) { stop("disabled"); @@ -368,6 +375,8 @@ class PlainMqttRuntime std::array dns_secondary_scratch_{}; std::array tx_{}; std::array rx_{}; + std::array + vmp_publish_envelope_{}; std::array discard_{}; meshtastic_MqttClientProxyMessage mt_proxy_ = meshtastic_MqttClientProxyMessage_init_zero; meshtastic_MqttClientProxyMessage mt_publish_proxy_ = @@ -1320,6 +1329,66 @@ class PlainMqttRuntime writePacket(pos, "puback"); } + bool buildVmpTopic(char* out, std::size_t out_len) const + { + if (!out || out_len == 0U || config_.protocol != RuntimeProtocol::Meshtastic) + { + return false; + } + const int written = std::snprintf( + out, + out_len, + "%s/2/e/vmp", + config_.root[0] ? config_.root : kDefaultMeshtasticMqttRoot); + return written > 0 && static_cast(written) < out_len; + } + + bool isVmpTopic(const uint8_t* topic, std::size_t topic_len) + { + if (!topic || topic_len == 0U || + !buildVmpTopic(publish_topic_, sizeof(publish_topic_))) + { + return false; + } + const std::size_t expected_len = std::strlen(publish_topic_); + return topic_len == expected_len && + std::memcmp(topic, publish_topic_, expected_len) == 0; + } + + bool flushVmpPublish() + { + if (config_.protocol != RuntimeProtocol::Meshtastic || + !config_.uplink_enabled || + !buildVmpTopic(publish_topic_, sizeof(publish_topic_))) + { + return false; + } + std::size_t envelope_len = vmp_publish_envelope_.size(); + if (!::platform::esp::arduino_common::voice::vmp_session::peekMqttEnvelope( + vmp_publish_envelope_.data(), &envelope_len)) + { + return false; + } + if (!sendPublishRaw(publish_topic_, + vmp_publish_envelope_.data(), + envelope_len)) + { + std::printf("[VMP][MQTT] publish failed retained_for_retry=1\n"); + stop("vmp_publish"); + return false; + } + if (!::platform::esp::arduino_common::voice::vmp_session::acknowledgeMqttEnvelope()) + { + std::printf("[VMP][MQTT] publish acknowledgement lost\n"); + stop("vmp_publish_ack"); + return false; + } + std::printf("[VMP][MQTT] publish topic=%s bytes=%u\n", + publish_topic_, + static_cast(envelope_len)); + return true; + } + void flushPublishQueue(chat::meshtastic::MtAdapter* mt, chat::meshcore::MeshCoreAdapter* mc) { @@ -1348,6 +1417,15 @@ class PlainMqttRuntime control_plane_uplink ? std::max(budget.tx_packet_budget, 1U) : budget.tx_packet_budget; + if (flushVmpPublish()) + { + if (control_plane_uplink) + { + control_plane_uplink_sent_ = true; + } + return; + } + if (config_.protocol == RuntimeProtocol::MeshCore) { if (flushMeshCorePublishQueue(mc, tx_packet_budget) && @@ -1855,6 +1933,27 @@ class PlainMqttRuntime } } const std::size_t payload_len = rx_remaining_len_ - payload_offset; + const uint8_t* const topic = rx_.data() + 2U; + if (config_.protocol == RuntimeProtocol::Meshtastic && + isVmpTopic(topic, topic_len)) + { + if (!config_.downlink_enabled || (rx_header_ & 0x01U) != 0U || + payload_len == 0U || + payload_len > ::chat::voice::vmp::kMaxMqttEnvelopeSize) + { + std::printf("[VMP][MQTT] inbound drop retained=%u bytes=%u\n", + (rx_header_ & 0x01U) != 0U ? 1U : 0U, + static_cast(payload_len)); + return; + } + const bool accepted = + ::platform::esp::arduino_common::voice::vmp_session::acceptMqttEnvelope( + rx_.data() + payload_offset, payload_len); + std::printf("[VMP][MQTT] inbound bytes=%u local_only=%u\n", + static_cast(payload_len), + accepted ? 1U : 0U); + return; + } if (config_.protocol == RuntimeProtocol::MeshCore) { handleMeshCorePublish(mc, payload_offset, payload_len, topic_len); diff --git a/platform/esp/arduino_common/src/chat/infra/meshcore/meshcore_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/meshcore/meshcore_adapter.cpp index b8ebbcee..f2054be8 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshcore/meshcore_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshcore/meshcore_adapter.cpp @@ -7,6 +7,7 @@ #include "chat/domain/contact_types.h" #include "chat/infra/meshcore/meshcore_payload_helpers.h" #include "chat/infra/meshcore/meshcore_protocol_helpers.h" +#include "chat/infra/voice/vmp_private_crypto.h" #include "chat/runtime/meshcore_direct_route_policy.h" #include "chat/runtime/meshcore_direct_secret_core.h" #include "chat/time_utils.h" @@ -3198,6 +3199,41 @@ bool MeshCoreAdapter::hasPkiKey(NodeId dest) const return route && route->has_pubkey; } +bool MeshCoreAdapter::deriveVmpContactSecret(NodeId peer_id, + uint8_t out_secret[32]) +{ + if (!out_secret || !isPkiReady() || peer_id == 0U || peer_id == 0xFFFFFFFFUL) + { + return false; + } + for (const PeerRouteEntry& route : peer_routes_) + { + if (route.node_id_guess != peer_id || !route.has_pubkey || + !route.pubkey_verified || + isZeroKey(route.pubkey, sizeof(route.pubkey))) + { + continue; + } + + uint8_t identity_shared_secret[MeshCoreIdentity::kPubKeySize] = {}; + const bool derived = identity_.deriveSharedSecret(route.pubkey, + identity_shared_secret) && + ::chat::voice::vmp::deriveVmpContactSecret( + identity_shared_secret, + ::chat::voice::vmp::ContactSecretIdentityFamily::MeshCore, + node_id_, + peer_id, + out_secret); + volatile uint8_t* const cursor = identity_shared_secret; + for (std::size_t index = 0U; index < sizeof(identity_shared_secret); ++index) + { + cursor[index] = 0U; + } + return derived; + } + return false; +} + bool MeshCoreAdapter::handleControlAppData(const MeshIncomingData& incoming, const uint8_t* payload, size_t payload_len) diff --git a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp index c256cd22..0c460e4f 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp @@ -7,6 +7,7 @@ #include "app/app_config.h" #include "app/app_facade_access.h" #include "chat/domain/contact_types.h" +#include "chat/infra/voice/vmp_private_crypto.h" #include "chat/time_utils.h" #include "platform/esp/arduino_common/app_tasks.h" #include "platform/esp/arduino_common/gps/gps_service_api.h" @@ -967,6 +968,46 @@ bool MtAdapter::getOwnPublicKey(uint8_t out_key[32]) const return true; } +bool MtAdapter::deriveVmpContactSecret(NodeId peer_id, uint8_t out_secret[32]) +{ + if (!out_secret || peer_id == 0U || peer_id == kBroadcastNodeId || !pki_ready_) + { + return false; + } + const PkiNodeKeyEntry* const peer_key = findPkiNodeKey(peer_id); + if (!peer_key) + { + return false; + } + + uint8_t identity_shared_secret[32] = {}; + uint8_t local_private_key[32] = {}; + std::memcpy(identity_shared_secret, + peer_key->key.data(), + sizeof(identity_shared_secret)); + std::memcpy(local_private_key, + pki_private_key_.data(), + sizeof(local_private_key)); + const bool derived = Curve25519::dh2(identity_shared_secret, local_private_key) && + ::chat::voice::vmp::deriveVmpContactSecret( + identity_shared_secret, + ::chat::voice::vmp::ContactSecretIdentityFamily::Meshtastic, + node_id_, + peer_id, + out_secret); + volatile uint8_t* cursor = identity_shared_secret; + for (std::size_t index = 0U; index < sizeof(identity_shared_secret); ++index) + { + cursor[index] = 0U; + } + cursor = local_private_key; + for (std::size_t index = 0U; index < sizeof(local_private_key); ++index) + { + cursor[index] = 0U; + } + return derived; +} + void MtAdapter::rememberNodePublicKey(NodeId node_id, const uint8_t* key, size_t key_len) { if (node_id == 0 || !key || key_len != pki_public_key_.size()) diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp index 8c5b422d..b64645aa 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp @@ -193,6 +193,12 @@ NodeId ReticulumAdapter::getNodeId() const return service_->getNodeId(); } +bool ReticulumAdapter::deriveVmpContactSecret(NodeId peer_id, + uint8_t out_secret[32]) +{ + return service_ && service_->deriveVmpContactSecret(peer_id, out_secret); +} + bool ReticulumAdapter::getReticulumLocalIdentityInfo(ReticulumLocalIdentityInfo* out) const { return service_->getReticulumLocalIdentityInfo(out); diff --git a/platform/esp/arduino_common/src/chat/infra/store/message_attachment_store.cpp b/platform/esp/arduino_common/src/chat/infra/store/message_attachment_store.cpp new file mode 100644 index 00000000..6eee0b89 --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/store/message_attachment_store.cpp @@ -0,0 +1,386 @@ +/** + * @file message_attachment_store.cpp + * @brief Atomic local attachment snapshots; VMP voice is the first adapter. + */ + +#include "platform/esp/arduino_common/chat/infra/store/message_attachment_store.h" + +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" + +#include + +namespace platform::esp::arduino_common::chat_attachment +{ +namespace +{ + +namespace vmp = ::chat::voice::vmp; +namespace storage = ::platform::esp::arduino_common::storage; + +constexpr const char* kAttachmentRoot = "/data/v2/attachments"; +constexpr const char* kVoiceRoot = "/data/v2/attachments/voice"; +constexpr const char* kVoiceSnapshotPath = + "/data/v2/attachments/voice/inbox.v1"; +constexpr const char* kVoiceSnapshotTempPath = + "/data/v2/attachments/voice/inbox.v1.tmp"; +constexpr const char* kVoiceSnapshotBackupPath = + "/data/v2/attachments/voice/inbox.v1.bak"; +constexpr uint32_t kSnapshotMagic = 0x54414D56UL; // "VMAT", little-endian. +constexpr uint32_t kSnapshotFooterMagic = 0x454E4456UL; // "VDNE". +constexpr uint16_t kSnapshotSchemaVersion = 1U; +constexpr uint8_t kVoiceCompleteFlag = 0x01U; +constexpr uint8_t kVoiceUnverifiedFlag = 0x02U; + +struct SnapshotHeader +{ + uint32_t magic = kSnapshotMagic; + uint16_t schema = kSnapshotSchemaVersion; + uint8_t kind = static_cast(AttachmentKind::Voice); + uint8_t record_count = 0U; +}; + +struct VoiceRecordHeader +{ + uint64_t local_id = 0U; + uint64_t session_id = 0U; + uint32_t sender_id = 0U; + uint32_t target_id = 0U; + uint32_t object_fingerprint = 0U; + uint32_t received_at_seconds = 0U; + uint16_t encoded_media_len = 0U; + uint8_t codec = 0U; + uint8_t mode = 0U; + uint8_t flags = 0U; + uint8_t reserved[3] = {}; + uint32_t media_crc32 = 0U; +}; + +struct SnapshotFooter +{ + uint32_t magic = kSnapshotFooterMagic; + uint32_t payload_crc32 = 0U; +}; + +static_assert(sizeof(SnapshotHeader) == 8U, + "VMP attachment snapshot header must stay compact"); +static_assert(sizeof(VoiceRecordHeader) == 48U, + "VMP attachment record ABI is part of the local schema"); +static_assert(sizeof(SnapshotFooter) == 8U, + "VMP attachment snapshot footer must stay compact"); + +uint32_t crc32Update(uint32_t crc, const uint8_t* data, std::size_t size) +{ + while (data && size-- != 0U) + { + crc ^= *data++; + for (uint8_t bit = 0U; bit < 8U; ++bit) + { + const uint32_t mask = 0U - (crc & 1U); + crc = (crc >> 1U) ^ (0xEDB88320UL & mask); + } + } + return crc; +} + +uint32_t crc32(const uint8_t* data, std::size_t size) +{ + return crc32Update(0xFFFFFFFFUL, data, size) ^ 0xFFFFFFFFUL; +} + +bool writeExact(storage::SdRuntimeFile* file, + const void* data, + std::size_t size) +{ + return file && data && size != 0U && file->write(data, size) == size; +} + +bool readExact(storage::SdRuntimeFile* file, void* data, std::size_t size) +{ + return file && data && size != 0U && + file->read(data, size) == static_cast(size); +} + +bool ensureVoiceLayout() +{ + return storage::sd_card_ready() && + (storage::sd_exists(kAttachmentRoot) || + storage::sd_mkdir(kAttachmentRoot)) && + (storage::sd_exists(kVoiceRoot) || storage::sd_mkdir(kVoiceRoot)); +} + +VoiceRecordHeader makeRecordHeader(const vmp::VoiceMessageMetadata& metadata, + const uint8_t* media) +{ + VoiceRecordHeader record{}; + record.local_id = metadata.local_id; + record.session_id = metadata.session_id; + record.sender_id = metadata.sender_id; + record.target_id = metadata.target_id; + record.object_fingerprint = metadata.object_fingerprint; + record.received_at_seconds = metadata.received_at_seconds; + record.encoded_media_len = metadata.encoded_media_len; + record.codec = static_cast(metadata.codec); + record.mode = static_cast(metadata.mode); + record.flags = (metadata.complete ? kVoiceCompleteFlag : 0U) | + (metadata.source_unverified ? kVoiceUnverifiedFlag : 0U); + record.media_crc32 = crc32(media, metadata.encoded_media_len); + return record; +} + +bool decodeRecordMetadata(const VoiceRecordHeader& record, + vmp::VoiceMessageMetadata* metadata) +{ + if (!metadata || record.local_id == 0U || + record.encoded_media_len == 0U || + record.encoded_media_len > vmp::kMaxEncodedMediaSize || + (record.flags & kVoiceCompleteFlag) == 0U || + record.codec != static_cast(vmp::Codec::Codec2_1300) || + (record.mode != static_cast(vmp::DeliveryMode::Private) && + record.mode != static_cast(vmp::DeliveryMode::Broadcast))) + { + return false; + } + + metadata->local_id = record.local_id; + metadata->session_id = record.session_id; + metadata->sender_id = record.sender_id; + metadata->target_id = record.target_id; + metadata->object_fingerprint = record.object_fingerprint; + metadata->received_at_seconds = record.received_at_seconds; + metadata->encoded_media_len = record.encoded_media_len; + metadata->codec = static_cast(record.codec); + metadata->mode = static_cast(record.mode); + metadata->source_unverified = (record.flags & kVoiceUnverifiedFlag) != 0U; + metadata->complete = true; + return true; +} + +bool replaceCommittedSnapshot() +{ + if (storage::sd_exists(kVoiceSnapshotBackupPath) && + !storage::sd_remove(kVoiceSnapshotBackupPath)) + { + return false; + } + + bool moved_current = false; + if (storage::sd_exists(kVoiceSnapshotPath)) + { + if (!storage::sd_rename(kVoiceSnapshotPath, kVoiceSnapshotBackupPath)) + { + return false; + } + moved_current = true; + } + + if (storage::sd_rename(kVoiceSnapshotTempPath, kVoiceSnapshotPath)) + { + // Keep the last known-good generation after a successful commit. + // Besides covering power loss between the two renames, this lets boot + // recovery reject a corrupt primary snapshot without discarding all + // locally retained voice objects. + return true; + } + + if (moved_current && !storage::sd_exists(kVoiceSnapshotPath)) + { + (void)storage::sd_rename(kVoiceSnapshotBackupPath, kVoiceSnapshotPath); + } + (void)storage::sd_remove(kVoiceSnapshotTempPath); + return false; +} + +VoiceInboxLoadResult restoreVoiceInboxSnapshot( + const char* path, + vmp::VoiceMessageInbox* inbox, + uint8_t* media_scratch, + std::size_t media_scratch_size) +{ + if (!path || !inbox || !media_scratch || + media_scratch_size < vmp::kMaxEncodedMediaSize) + { + return VoiceInboxLoadResult::IoError; + } + + storage::SdRuntimeFile file; + if (!file.open(path, "r")) + { + return VoiceInboxLoadResult::IoError; + } + + SnapshotHeader header{}; + if (!readExact(&file, &header, sizeof(header)) || + header.magic != kSnapshotMagic || + header.schema != kSnapshotSchemaVersion || + header.kind != static_cast(AttachmentKind::Voice) || + header.record_count > vmp::kVoiceInboxCapacity) + { + file.close(); + return VoiceInboxLoadResult::Corrupt; + } + + inbox->clear(); + uint32_t payload_crc = 0xFFFFFFFFUL; + for (uint8_t index = 0U; index < header.record_count; ++index) + { + VoiceRecordHeader record{}; + vmp::VoiceMessageMetadata metadata{}; + if (!readExact(&file, &record, sizeof(record)) || + !decodeRecordMetadata(record, &metadata) || + !readExact(&file, media_scratch, metadata.encoded_media_len) || + crc32(media_scratch, metadata.encoded_media_len) != record.media_crc32 || + !inbox->restore(metadata, media_scratch, metadata.encoded_media_len)) + { + file.close(); + inbox->clear(); + return VoiceInboxLoadResult::Corrupt; + } + payload_crc = crc32Update(payload_crc, + reinterpret_cast(&record), + sizeof(record)); + payload_crc = crc32Update(payload_crc, + media_scratch, + metadata.encoded_media_len); + } + + SnapshotFooter footer{}; + const bool valid_footer = readExact(&file, &footer, sizeof(footer)) && + footer.magic == kSnapshotFooterMagic && + footer.payload_crc32 == + (payload_crc ^ 0xFFFFFFFFUL) && + file.available() == 0; + file.close(); + if (!valid_footer) + { + inbox->clear(); + return VoiceInboxLoadResult::Corrupt; + } + return header.record_count == 0U ? VoiceInboxLoadResult::Empty + : VoiceInboxLoadResult::Restored; +} + +} // namespace + +bool persistVoiceInbox(const vmp::VoiceMessageInbox& inbox, + vmp::VoiceMessageMetadata* metadata_scratch, + std::size_t metadata_capacity) +{ + if (!metadata_scratch || metadata_capacity < vmp::kVoiceInboxCapacity || + !ensureVoiceLayout()) + { + return false; + } + + const std::size_t count = inbox.listMetadata(metadata_scratch, + vmp::kVoiceInboxCapacity); + if (count > vmp::kVoiceInboxCapacity) + { + return false; + } + + if (storage::sd_exists(kVoiceSnapshotTempPath)) + { + (void)storage::sd_remove(kVoiceSnapshotTempPath); + } + + storage::SdRuntimeFile file; + if (!file.open(kVoiceSnapshotTempPath, "w")) + { + return false; + } + + SnapshotHeader header{}; + header.record_count = static_cast(count); + bool wrote = writeExact(&file, &header, sizeof(header)); + uint32_t payload_crc = 0xFFFFFFFFUL; + // `listMetadata` is newest-first; serializing oldest-first lets inbox + // restore rebuild the original presentation order without a second RAM + // array or storing its private insertion sequence on disk. + for (std::size_t remaining = count; wrote && remaining > 0U; --remaining) + { + const vmp::VoiceMessageMetadata& metadata = + metadata_scratch[remaining - 1U]; + vmp::VoiceMessageView view{}; + if (!inbox.get(metadata.local_id, &view) || + !view.encoded_media || + view.metadata.encoded_media_len != metadata.encoded_media_len) + { + wrote = false; + break; + } + + const VoiceRecordHeader record = makeRecordHeader(view.metadata, + view.encoded_media); + wrote = writeExact(&file, &record, sizeof(record)) && + writeExact(&file, + view.encoded_media, + view.metadata.encoded_media_len); + payload_crc = crc32Update(payload_crc, + reinterpret_cast(&record), + sizeof(record)); + payload_crc = crc32Update(payload_crc, + view.encoded_media, + view.metadata.encoded_media_len); + } + + SnapshotFooter footer{}; + footer.payload_crc32 = payload_crc ^ 0xFFFFFFFFUL; + wrote = wrote && writeExact(&file, &footer, sizeof(footer)) && file.flush(); + file.close(); + if (!wrote) + { + (void)storage::sd_remove(kVoiceSnapshotTempPath); + return false; + } + return replaceCommittedSnapshot(); +} + +VoiceInboxLoadResult restoreVoiceInbox(vmp::VoiceMessageInbox* inbox, + uint8_t* media_scratch, + std::size_t media_scratch_size) +{ + if (!inbox || !media_scratch || + media_scratch_size < vmp::kMaxEncodedMediaSize) + { + return VoiceInboxLoadResult::IoError; + } + if (!storage::sd_card_ready()) + { + return VoiceInboxLoadResult::Unavailable; + } + const bool has_primary = storage::sd_exists(kVoiceSnapshotPath); + const bool has_backup = storage::sd_exists(kVoiceSnapshotBackupPath); + if (!has_primary && !has_backup) + { + return VoiceInboxLoadResult::Empty; + } + + const VoiceInboxLoadResult primary_result = + has_primary + ? restoreVoiceInboxSnapshot(kVoiceSnapshotPath, + inbox, + media_scratch, + media_scratch_size) + : VoiceInboxLoadResult::Corrupt; + if (primary_result == VoiceInboxLoadResult::Restored || + primary_result == VoiceInboxLoadResult::Empty) + { + return primary_result; + } + + if (!has_backup) + { + return primary_result; + } + + const VoiceInboxLoadResult backup_result = restoreVoiceInboxSnapshot( + kVoiceSnapshotBackupPath, inbox, media_scratch, media_scratch_size); + if (backup_result == VoiceInboxLoadResult::Restored || + backup_result == VoiceInboxLoadResult::Empty) + { + return backup_result; + } + return has_primary ? primary_result : backup_result; +} + +} // namespace platform::esp::arduino_common::chat_attachment diff --git a/platform/esp/arduino_common/src/voice/vmp_control_runtime.cpp b/platform/esp/arduino_common/src/voice/vmp_control_runtime.cpp new file mode 100644 index 00000000..eb2b949e --- /dev/null +++ b/platform/esp/arduino_common/src/voice/vmp_control_runtime.cpp @@ -0,0 +1,188 @@ +/** + * @file vmp_control_runtime.cpp + * @brief Pager-owned control-plane handoff for VMP v1. + */ + +#include "platform/esp/arduino_common/voice/vmp_control_runtime.h" + +#if defined(ARDUINO_T_LORA_PAGER) && defined(ARDUINO_LILYGO_LORA_LR1121) + +#include "chat/infra/voice/vmp_control_ingress.h" +#include "platform/esp/arduino_common/app_tasks.h" + +#include + +namespace platform::esp::arduino_common::voice::vmp_control +{ +namespace +{ + +constexpr uint8_t kQueueDepth = 4U; +constexpr uint32_t kWorkerStackWords = 3072U; +constexpr UBaseType_t kWorkerPriority = 4U; + +class Runtime final : public app::IRawRadioPacketInterceptor, + public chat::voice::vmp::IControlEnvelopeSink +{ + public: + bool initialize() + { + if (initialized_) + { + return true; + } + ingress_.setSink(this); + if (xTaskCreatePinnedToCore(&Runtime::workerEntry, + "vmp_control", + kWorkerStackWords, + this, + kWorkerPriority, + &worker_task_, + tskNO_AFFINITY) != pdPASS) + { + return false; + } + app::AppTasks::setRawRadioPacketInterceptor(this); + initialized_ = true; + return true; + } + + void setHandler(EnvelopeHandler handler, void* context) + { + portENTER_CRITICAL(&lock_); + handler_ = handler; + handler_context_ = context; + portEXIT_CRITICAL(&lock_); + } + + bool tryConsume(const uint8_t* data, + std::size_t size, + float rssi, + float snr) override + { + chat::voice::vmp::ControlRxMetadata metadata{}; + metadata.rssi = rssi; + metadata.snr = snr; + return ingress_.tryConsume(data, size, metadata); + } + + bool enqueueControl(const uint8_t* data, + std::size_t size, + const chat::voice::vmp::ControlRxMetadata& metadata) override + { + if (!data || size != kControlEnvelopeSize) + { + return false; + } + + bool accepted = false; + portENTER_CRITICAL(&lock_); + if (queued_count_ < kQueueDepth) + { + Envelope& slot = slots_[write_index_]; + std::memcpy(slot.bytes, data, sizeof(slot.bytes)); + slot.rssi = metadata.rssi; + slot.snr = metadata.snr; + write_index_ = static_cast((write_index_ + 1U) % kQueueDepth); + ++queued_count_; + accepted = true; + } + portEXIT_CRITICAL(&lock_); + + if (accepted && worker_task_) + { + xTaskNotifyGive(worker_task_); + } + return accepted; + } + + private: + static void workerEntry(void* context) + { + static_cast(context)->runWorker(); + } + + void runWorker() + { + for (;;) + { + (void)ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + for (;;) + { + const Envelope* envelope = nullptr; + EnvelopeHandler handler = nullptr; + void* handler_context = nullptr; + portENTER_CRITICAL(&lock_); + if (queued_count_ != 0U) + { + envelope = &slots_[read_index_]; + handler = handler_; + handler_context = handler_context_; + } + portEXIT_CRITICAL(&lock_); + if (!envelope) + { + break; + } + + if (handler) + { + handler(*envelope, handler_context); + } + + portENTER_CRITICAL(&lock_); + if (queued_count_ != 0U) + { + read_index_ = static_cast((read_index_ + 1U) % kQueueDepth); + --queued_count_; + } + portEXIT_CRITICAL(&lock_); + } + } + } + + chat::voice::vmp::ControlIngress ingress_{}; + Envelope slots_[kQueueDepth] = {}; + portMUX_TYPE lock_ = portMUX_INITIALIZER_UNLOCKED; + uint8_t read_index_ = 0U; + uint8_t write_index_ = 0U; + uint8_t queued_count_ = 0U; + EnvelopeHandler handler_ = nullptr; + void* handler_context_ = nullptr; + TaskHandle_t worker_task_ = nullptr; + bool initialized_ = false; +}; + +Runtime s_runtime{}; + +} // namespace + +bool initialize() +{ + return s_runtime.initialize(); +} + +void setEnvelopeHandler(EnvelopeHandler handler, void* context) +{ + s_runtime.setHandler(handler, context); +} + +} // namespace platform::esp::arduino_common::voice::vmp_control + +#else + +namespace platform::esp::arduino_common::voice::vmp_control +{ + +bool initialize() +{ + return false; +} + +void setEnvelopeHandler(EnvelopeHandler, void*) +{ +} + +} // namespace platform::esp::arduino_common::voice::vmp_control + +#endif diff --git a/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp b/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp new file mode 100644 index 00000000..86db516f --- /dev/null +++ b/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp @@ -0,0 +1,379 @@ +/** + * @file vmp_pager_audio.cpp + * @brief Bounded Codec2 recording/playback adapter for Pager VMP. + */ + +#include "platform/esp/arduino_common/voice/vmp_pager_audio.h" + +#include +#include + +#if defined(ARDUINO_T_LORA_PAGER) + +#include +#include + +#include "boards/tlora_pager/tlora_pager_board.h" +#include "platform/esp/boards/board_runtime.h" + +namespace platform::esp::arduino_common::voice::vmp_audio +{ +namespace +{ + +using ::boards::tlora_pager::PagerAudioOwner; +using ::boards::tlora_pager::TLoRaPagerBoard; + +constexpr PagerAudioOwner kOwner = PagerAudioOwner::VoiceMessage; +constexpr float kCaptureGainDb = 24.0F; + +TLoRaPagerBoard* pagerBoard() +{ + ::platform::esp::boards::AppContextInitHandles handles; + if (!::platform::esp::boards::tryResolveAppContextInitHandles(&handles) || + !handles.board) + { + return nullptr; + } + return static_cast(handles.board); +} + +void secureClear(uint8_t* data, std::size_t size) +{ + volatile uint8_t* cursor = data; + while (cursor && size-- != 0U) + { + *cursor++ = 0U; + } +} + +int16_t clampToInt16(int32_t value) +{ + if (value > 32767) + { + return 32767; + } + if (value < -32768) + { + return -32768; + } + return static_cast(value); +} + +bool beginAudio(TLoRaPagerBoard* board, bool speaker_enabled) +{ + if (!board || + board->openAudioSession(kOwner, + kBitsPerSample, + kHardwareChannels, + kSampleRateHz, + speaker_enabled) != 0) + { + return false; + } + if (!board->audioSetGain(kOwner, kCaptureGainDb) || + !board->audioSetMute(kOwner, false)) + { + board->closeAudioSession(kOwner); + return false; + } + if (speaker_enabled && !board->audioSetOutMute(kOwner, false)) + { + board->closeAudioSession(kOwner); + return false; + } + return true; +} + +} // namespace + +struct PagerCodec2Audio::FrameScratch +{ + int16_t stereo[kCodec2SamplesPerFrame * kHardwareChannels] = {}; + int16_t mono[kCodec2SamplesPerFrame] = {}; +}; + +bool PagerCodec2Audio::isSupported() const +{ + return pagerBoard() != nullptr; +} + +CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) +{ + clearEncodedMedia(); + TLoRaPagerBoard* const board = pagerBoard(); + if (!board || !acquireFrameScratch()) + { + return CaptureResult::Unsupported; + } + if (!beginAudio(board, false)) + { + releaseFrameScratch(); + return CaptureResult::AudioBusy; + } + + CODEC2* const encoder = codec2_create(CODEC2_MODE_1300); + const int sample_count = encoder ? codec2_samples_per_frame(encoder) : 0; + const int byte_count = encoder ? codec2_bytes_per_frame(encoder) : 0; + if (!encoder || sample_count != static_cast(kCodec2SamplesPerFrame) || + byte_count != static_cast(kCodec2BytesPerFrame)) + { + if (encoder) + { + codec2_destroy(encoder); + } + board->closeAudioSession(kOwner); + releaseFrameScratch(); + return CaptureResult::CodecFailure; + } + + CaptureResult result = CaptureResult::Complete; + for (std::size_t frame = 0; frame < kCodec2FramesPerMessage; ++frame) + { + if (stop_requested && *stop_requested) + { + result = encoded_media_size_ == 0U ? CaptureResult::Cancelled + : CaptureResult::Complete; + break; + } + if (!readCaptureFrame()) + { + result = CaptureResult::AudioFailure; + break; + } + mixCaptureToMono(); + codec2_encode(encoder, + encoded_media_ + encoded_media_size_, + frame_scratch_->mono); + encoded_media_size_ += kCodec2BytesPerFrame; + } + + codec2_destroy(encoder); + board->closeAudioSession(kOwner); + releaseFrameScratch(); + if (result != CaptureResult::Complete) + { + clearEncodedMedia(); + } + return result; +} + +const uint8_t* PagerCodec2Audio::encodedMedia() const +{ + return encoded_media_size_ != 0U ? encoded_media_ : nullptr; +} + +std::size_t PagerCodec2Audio::encodedMediaSize() const +{ + return encoded_media_size_; +} + +bool PagerCodec2Audio::hasEncodedMedia() const +{ + return encoded_media_size_ != 0U; +} + +void PagerCodec2Audio::clearEncodedMedia() +{ + secureClear(encoded_media_, sizeof(encoded_media_)); + encoded_media_size_ = 0U; +} + +PlaybackResult PagerCodec2Audio::play(const uint8_t* encoded_media, + std::size_t encoded_media_len, + chat::voice::vmp::Codec codec, + uint8_t volume_percent) +{ + if (!encoded_media || encoded_media_len == 0U || + encoded_media_len > kMaximumEncodedBytes || + encoded_media_len % kCodec2BytesPerFrame != 0U || + codec != chat::voice::vmp::Codec::Codec2_1300) + { + return PlaybackResult::InvalidMedia; + } + + TLoRaPagerBoard* const board = pagerBoard(); + if (!board || !acquireFrameScratch()) + { + return PlaybackResult::Unsupported; + } + if (!beginAudio(board, true)) + { + releaseFrameScratch(); + return PlaybackResult::AudioBusy; + } + (void)board->audioSetVolume(kOwner, volume_percent > 100U ? 100U + : volume_percent); + + CODEC2* const decoder = codec2_create(CODEC2_MODE_1300); + const int sample_count = decoder ? codec2_samples_per_frame(decoder) : 0; + const int byte_count = decoder ? codec2_bytes_per_frame(decoder) : 0; + if (!decoder || sample_count != static_cast(kCodec2SamplesPerFrame) || + byte_count != static_cast(kCodec2BytesPerFrame)) + { + if (decoder) + { + codec2_destroy(decoder); + } + board->closeAudioSession(kOwner); + releaseFrameScratch(); + return PlaybackResult::CodecFailure; + } + codec2_set_lpc_post_filter(decoder, 1, 0, 0.8F, 0.2F); + + PlaybackResult result = PlaybackResult::Complete; + for (std::size_t offset = 0U; offset < encoded_media_len; + offset += kCodec2BytesPerFrame) + { + codec2_decode(decoder, + frame_scratch_->mono, + const_cast(encoded_media + offset)); + duplicatePlaybackToStereo(); + if (!writePlaybackFrame()) + { + result = PlaybackResult::AudioFailure; + break; + } + } + + codec2_destroy(decoder); + board->closeAudioSession(kOwner); + releaseFrameScratch(); + return result; +} + +bool PagerCodec2Audio::acquireFrameScratch() +{ + if (frame_scratch_) + { + return true; + } + void* const storage = heap_caps_malloc_prefer( + sizeof(FrameScratch), + 2, + MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT, + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (!storage) + { + return false; + } + frame_scratch_ = new (storage) FrameScratch{}; + return true; +} + +void PagerCodec2Audio::releaseFrameScratch() +{ + if (!frame_scratch_) + { + return; + } + secureClear(reinterpret_cast(frame_scratch_), sizeof(FrameScratch)); + heap_caps_free(frame_scratch_); + frame_scratch_ = nullptr; +} + +bool PagerCodec2Audio::readCaptureFrame() +{ + TLoRaPagerBoard* const board = pagerBoard(); + return board && frame_scratch_ && + board->audioRead(kOwner, + reinterpret_cast(frame_scratch_->stereo), + sizeof(frame_scratch_->stereo)) == 0; +} + +bool PagerCodec2Audio::writePlaybackFrame() +{ + TLoRaPagerBoard* const board = pagerBoard(); + return board && frame_scratch_ && + board->audioWrite(kOwner, + reinterpret_cast(frame_scratch_->stereo), + sizeof(frame_scratch_->stereo)) == 0; +} + +void PagerCodec2Audio::mixCaptureToMono() +{ + for (std::size_t index = 0U; index < kCodec2SamplesPerFrame; ++index) + { + const int32_t left = frame_scratch_->stereo[index * kHardwareChannels]; + const int32_t right = frame_scratch_->stereo[index * kHardwareChannels + 1U]; + frame_scratch_->mono[index] = clampToInt16((left + right) / 2); + } +} + +void PagerCodec2Audio::duplicatePlaybackToStereo() +{ + for (std::size_t index = 0U; index < kCodec2SamplesPerFrame; ++index) + { + frame_scratch_->stereo[index * kHardwareChannels] = frame_scratch_->mono[index]; + frame_scratch_->stereo[index * kHardwareChannels + 1U] = frame_scratch_->mono[index]; + } +} + +} // namespace platform::esp::arduino_common::voice::vmp_audio + +#else + +namespace platform::esp::arduino_common::voice::vmp_audio +{ + +bool PagerCodec2Audio::isSupported() const +{ + return false; +} + +CaptureResult PagerCodec2Audio::capture(const volatile bool*) +{ + clearEncodedMedia(); + return CaptureResult::Unsupported; +} + +const uint8_t* PagerCodec2Audio::encodedMedia() const +{ + return nullptr; +} + +std::size_t PagerCodec2Audio::encodedMediaSize() const +{ + return 0U; +} + +bool PagerCodec2Audio::hasEncodedMedia() const +{ + return false; +} + +void PagerCodec2Audio::clearEncodedMedia() +{ + std::memset(encoded_media_, 0, sizeof(encoded_media_)); + encoded_media_size_ = 0U; +} + +PlaybackResult PagerCodec2Audio::play(const uint8_t*, + std::size_t, + chat::voice::vmp::Codec, + uint8_t) +{ + return PlaybackResult::Unsupported; +} + +bool PagerCodec2Audio::readCaptureFrame() +{ + return false; +} + +bool PagerCodec2Audio::writePlaybackFrame() +{ + return false; +} + +void PagerCodec2Audio::mixCaptureToMono() +{ +} + +void PagerCodec2Audio::duplicatePlaybackToStereo() +{ +} + +} // namespace platform::esp::arduino_common::voice::vmp_audio + +#endif diff --git a/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp b/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp new file mode 100644 index 00000000..56d227d9 --- /dev/null +++ b/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp @@ -0,0 +1,1785 @@ +/** + * @file vmp_pager_session.cpp + * @brief Pager VMP session: LR1121 direct RF or SX1262 MQTT-only carriage. + */ + +#include "platform/esp/arduino_common/voice/vmp_pager_session.h" + +#if defined(ARDUINO_T_LORA_PAGER) + +#include "platform/esp/arduino_common/voice/vmp_control_runtime.h" +#include "platform/esp/arduino_common/voice/vmp_pager_audio.h" +#include "platform/esp/arduino_common/voice/vmp_radio_lease.h" + +#include "chat/infra/voice/vmp_control_auth.h" +#include "chat/infra/voice/vmp_media_frames.h" +#include "chat/infra/voice/vmp_mqtt_transport.h" +#include "chat/infra/voice/vmp_receive_block.h" +#include "platform/esp/arduino_common/chat/infra/store/message_attachment_store.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace platform::esp::arduino_common::voice::vmp_session +{ +namespace +{ + +namespace vmp = ::chat::voice::vmp; +namespace control = ::platform::esp::arduino_common::voice::vmp_control; +namespace radio = ::platform::esp::arduino_common::voice::vmp_radio; +namespace audio = ::platform::esp::arduino_common::voice::vmp_audio; + +constexpr uint32_t kReceiveWindowMs = 5000U; +constexpr uint8_t kDefaultPhyProfile = 1U; +constexpr uint8_t kMaxChannelIndex = 39U; +constexpr std::size_t kMaxRadioFrameSize = 255U; +constexpr uint32_t kPrivateAcceptWindowMs = 1500U; +constexpr uint32_t kReadyProbeSpacingMs = 40U; +constexpr uint8_t kReadyProbeCount = 3U; +constexpr uint32_t kOutboundTaskStackWords = 4096U; +constexpr UBaseType_t kOutboundTaskPriority = 4U; +constexpr uint32_t kPlaybackTaskStackWords = 3072U; +constexpr UBaseType_t kPlaybackTaskPriority = 3U; +constexpr uint32_t kPersistentInboxRetryMs = 5000U; + +bool deadlineExpired(uint32_t deadline) +{ + return static_cast(millis() - deadline) >= 0; +} + +bool profileFor(const vmp::ControlFrame& control, radio::PhyProfile* out_profile) +{ + if (!out_profile || control.phy_profile_id != kDefaultPhyProfile || + control.channel_index > kMaxChannelIndex) + { + return false; + } + + radio::PhyProfile profile{}; + profile.frequency_mhz = 2402.0F + + static_cast(control.channel_index) * 2.0F; + if (profile.frequency_mhz > 2480.0F) + { + return false; + } + *out_profile = profile; + return true; +} + +void secureClear(uint8_t* bytes, std::size_t size) +{ + volatile uint8_t* cursor = bytes; + while (cursor && size-- != 0U) + { + *cursor++ = 0U; + } +} + +/** + * Bulk VMP state is deliberately external: it holds user media, FEC blocks, + * and asynchronous carrier plans but no radio DMA buffer, ISR state, or + * FreeRTOS synchronization primitive. Pager hardware requires PSRAM, so VMP + * refuses to enable rather than silently consuming the scarce internal heap. + */ +struct PagerMediaStorage final +{ + vmp::VoiceMessageInbox inbox{}; + audio::PagerCodec2Audio audio{}; + vmp::TransmitBlock transmit_block{}; + vmp::MqttTransmitTransfer mqtt_transmit{}; + vmp::MqttReceiveTransfer mqtt_receive{}; + vmp::ReceiveBlock receive_block{}; + uint8_t received_media[vmp::kMaxEncodedMediaSize] = {}; + uint8_t playback_media[vmp::kMaxEncodedMediaSize] = {}; + uint8_t mqtt_received_media[vmp::kMaxEncodedMediaSize] = {}; + vmp::VoiceMessageMetadata persistence_metadata[vmp::kVoiceInboxCapacity] = {}; +}; + +PagerMediaStorage* allocatePagerMediaStorage() +{ + static PagerMediaStorage* storage = []() -> PagerMediaStorage* + { + void* const raw = heap_caps_malloc(sizeof(PagerMediaStorage), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + return raw ? new (raw) PagerMediaStorage{} : nullptr; + }(); + return storage; +} + +class PagerReceiveSession final +{ + public: + bool initialize(uint32_t self_node_id, bool durable_attachment_store) + { + if (!media_) + { + media_ = allocatePagerMediaStorage(); + } + if (!state_mutex_) + { + state_mutex_ = xSemaphoreCreateMutexStatic(&state_mutex_storage_); + } + direct_rf_voice_supported_ = radio::isSupported(); + if (self_node_id == 0U || !media_ || !media_->audio.isSupported() || + (direct_rf_voice_supported_ && !control::initialize()) || !state_mutex_) + { + return false; + } + self_node_id_ = self_node_id; + requires_durable_attachment_store_ = durable_attachment_store; + inbox_ready_ = !requires_durable_attachment_store_; + if (direct_rf_voice_supported_) + { + control::setEnvelopeHandler(&PagerReceiveSession::controlEnvelopeReceived, this); + } + initialized_ = true; + return true; + } + + bool canRecordAndSend() const + { + if (!initialized_ || !media_ || !inbox_ready_ || + !media_->audio.isSupported() || !lockState()) + { + return false; + } + // SX1262 does not have the LR1121 2.4 GHz path. Its Pager voice + // compose action becomes available only while MT MQTT uplink is + // genuinely enabled; it cannot fall through to RF or LXMF. + const bool available = direct_rf_voice_supported_ || mqtt_uplink_enabled_; + unlockState(); + return available; + } + + void onPersistentStorageReady() + { + if (!initialized_ || !requires_durable_attachment_store_) + { + return; + } + if (lockState()) + { + attachment_store_ready_ = true; + last_persistent_inbox_attempt_ms_ = 0U; + unlockState(); + } + servicePersistentInbox(); + } + + void servicePersistentInbox() + { + if (!initialized_ || !requires_durable_attachment_store_ || !media_ || + !lockState()) + { + return; + } + const uint32_t now_ms = millis(); + const bool retry_due = attachment_store_ready_ && !inbox_ready_ && + (last_persistent_inbox_attempt_ms_ == 0U || + now_ms - last_persistent_inbox_attempt_ms_ >= + kPersistentInboxRetryMs); + if (!retry_due) + { + unlockState(); + return; + } + last_persistent_inbox_attempt_ms_ = now_ms; + const auto result = ::platform::esp::arduino_common::chat_attachment:: + restoreVoiceInbox(&media_->inbox, + media_->received_media, + sizeof(media_->received_media)); + secureClear(media_->received_media, sizeof(media_->received_media)); + if (result == ::platform::esp::arduino_common::chat_attachment:: + VoiceInboxLoadResult::Restored || + result == ::platform::esp::arduino_common::chat_attachment:: + VoiceInboxLoadResult::Empty) + { + inbox_ready_ = true; + Serial.printf("[VMP] attachment inbox restore=%s\n", + result == ::platform::esp::arduino_common::chat_attachment:: + VoiceInboxLoadResult::Restored + ? "restored" + : "empty"); + } + else + { + Serial.printf("[VMP] attachment inbox restore deferred=%u\n", + static_cast(result)); + } + unlockState(); + } + + bool provisionVerifiedContactSecret( + uint32_t peer_id, + const uint8_t secret[vmp::kPrivateKeySize]) + { + return contacts_.upsertVerifiedContactSecret(peer_id, secret); + } + + void setVerifiedContactSecretDeriver(VerifiedContactSecretDeriver deriver, + void* context) + { + if (lockState()) + { + contact_secret_deriver_ = deriver; + contact_secret_deriver_context_ = context; + unlockState(); + } + } + + void invalidateContactSecretCache() + { + if (lockState()) + { + contact_secret_cache_stale_ = true; + if (!active_) + { + contacts_.clear(); + contact_secret_cache_stale_ = false; + } + unlockState(); + } + } + + const vmp::VoiceMessageInbox* inbox() const + { + return initialized_ && media_ && inbox_ready_ ? &media_->inbox : nullptr; + } + + std::size_t listInboxMetadata(vmp::VoiceMessageMetadata* out_metadata, + std::size_t capacity) const + { + if (!initialized_ || !inbox_ready_ || !out_metadata || capacity == 0U || + !lockState()) + { + return 0U; + } + const std::size_t listed = media_->inbox.listMetadata(out_metadata, capacity); + unlockState(); + return listed; + } + + bool playInboxMessage(uint64_t local_id, uint8_t volume_percent) + { + vmp::VoiceMessageView view{}; + return initialized_ && media_ && inbox_ready_ && + media_->inbox.get(local_id, &view) && + view.encoded_media && + media_->audio.play(view.encoded_media, + view.metadata.encoded_media_len, + view.metadata.codec, + volume_percent) == audio::PlaybackResult::Complete; + } + + bool requestPlayback(uint64_t local_id) + { + if (!initialized_ || !inbox_ready_ || local_id == 0U || !lockState()) + { + return false; + } + const bool unavailable = active_ || playback_task_ != nullptr; + unlockState(); + if (unavailable) + { + return false; + } + + if (!lockState()) + { + return false; + } + if (active_ || playback_task_) + { + unlockState(); + return false; + } + vmp::VoiceMessageView view{}; + if (!media_->inbox.get(local_id, &view) || !view.encoded_media || + view.metadata.encoded_media_len > sizeof(media_->playback_media)) + { + unlockState(); + return false; + } + std::memcpy(media_->playback_media, + view.encoded_media, + view.metadata.encoded_media_len); + playback_media_len_ = view.metadata.encoded_media_len; + playback_codec_ = view.metadata.codec; + playback_local_id_ = local_id; + if (xTaskCreatePinnedToCore(&PagerReceiveSession::playbackTaskEntry, + "vmp_play", + kPlaybackTaskStackWords, + this, + kPlaybackTaskPriority, + &playback_task_, + tskNO_AFFINITY) != pdPASS) + { + playback_local_id_ = 0U; + playback_task_ = nullptr; + unlockState(); + return false; + } + unlockState(); + return true; + } + + bool peekMqttEnvelope(uint8_t* out, std::size_t* inout_len) + { + if (!initialized_ || !out || !inout_len || !lockState()) + { + return false; + } + const bool emitted = mqtt_uplink_enabled_ && + media_->mqtt_transmit.copyNextEnvelope(out, inout_len); + unlockState(); + return emitted; + } + + bool acknowledgeMqttEnvelope() + { + if (!initialized_ || !lockState()) + { + return false; + } + if (!media_->mqtt_transmit.commitNextEnvelope()) + { + unlockState(); + return false; + } + if (!media_->mqtt_transmit.hasNext()) + { + media_->mqtt_transmit.clear(); + } + unlockState(); + return true; + } + + void setMqttUplinkEnabled(bool enabled) + { + if (lockState()) + { + mqtt_uplink_enabled_ = enabled; + if (!enabled && !lxmf_carrier_enabled_) + { + media_->mqtt_transmit.clear(); + } + unlockState(); + } + } + + void setLxmfEnvelopeSender(LxmfEnvelopeSender sender, void* context) + { + if (lockState()) + { + lxmf_sender_ = sender; + lxmf_sender_context_ = context; + if (!lxmf_sender_) + { + lxmf_carrier_enabled_ = false; + if (!mqtt_uplink_enabled_) + { + media_->mqtt_transmit.clear(); + } + } + unlockState(); + } + } + + void setLxmfCarrierEnabled(bool enabled) + { + if (lockState()) + { + lxmf_carrier_enabled_ = enabled && lxmf_sender_ != nullptr; + if (!lxmf_carrier_enabled_ && !mqtt_uplink_enabled_) + { + media_->mqtt_transmit.clear(); + } + unlockState(); + } + } + + bool acceptMqttEnvelope(const uint8_t* envelope, std::size_t envelope_len) + { + return acceptStoreForwardEnvelope(0U, envelope, envelope_len); + } + + bool acceptLxmfEnvelope(uint32_t source_id, + const uint8_t* envelope, + std::size_t envelope_len) + { + return direct_rf_voice_supported_ && source_id != 0U && + acceptStoreForwardEnvelope(source_id, envelope, envelope_len); + } + + bool acceptStoreForwardEnvelope(uint32_t source_id, + const uint8_t* envelope, + std::size_t envelope_len) + { + if (!initialized_ || !inbox_ready_ || !envelope || envelope_len == 0U || + !lockState()) + { + return false; + } + + vmp::MqttEnvelopeView envelope_view{}; + vmp::DeliveryMode transport_delivery_mode = vmp::DeliveryMode::Private; + if (!vmp::parseMqttEnvelope(envelope, envelope_len, &envelope_view) || + (envelope_view.kind == vmp::MqttEnvelopeKind::Control && + !vmp::decodeControlFrame(envelope_view.payload, + envelope_view.payload_len, + &transport_candidate_control_)) || + (envelope_view.kind == vmp::MqttEnvelopeKind::Control && + !vmp::deliveryModeFor(transport_candidate_control_, + &transport_delivery_mode)) || + (source_id != 0U && + ((envelope_view.kind == vmp::MqttEnvelopeKind::Control && + transport_candidate_control_.sender_id != source_id) || + (envelope_view.kind == vmp::MqttEnvelopeKind::Shard && + (!media_->mqtt_receive.active() || + media_->mqtt_receive.control().sender_id != source_id))))) + { + unlockState(); + return false; + } + const bool require_contact_secret = + envelope_view.kind == vmp::MqttEnvelopeKind::Control && + transport_delivery_mode == vmp::DeliveryMode::Private; + const uint32_t sender_id = transport_candidate_control_.sender_id; + unlockState(); + if (require_contact_secret && !ensureVerifiedContactSecret(sender_id)) + { + return false; + } + if (!lockState()) + { + return false; + } + const vmp::MqttTransferResult accepted = media_->mqtt_receive.acceptEnvelope( + envelope, envelope_len, self_node_id_, contacts_); + bool stored = accepted == vmp::MqttTransferResult::Accepted || + accepted == vmp::MqttTransferResult::Duplicate; + if (accepted == vmp::MqttTransferResult::Complete) + { + std::size_t media_len = 0U; + const bool recovered = media_->mqtt_receive.recover(media_->mqtt_received_media, + sizeof(media_->mqtt_received_media), + &media_len); + const bool stored_voice = + recovered && storeCompletedVoice(media_->mqtt_receive.control(), + media_->mqtt_received_media, + media_len, + millis() / 1000U); + secureClear(media_->mqtt_received_media, sizeof(media_->mqtt_received_media)); + media_->mqtt_receive.clear(); + stored = stored_voice; + } + unlockState(); + return stored; + } + + void discardMqttPublication() + { + if (lockState()) + { + media_->mqtt_transmit.clear(); + unlockState(); + } + } + + StartSendResult requestRecordAndSend(uint32_t target_id) + { + const bool broadcast = target_id == vmp::kBroadcastTargetId; + if (!initialized_ || !media_ || !inbox_ready_ || + !media_->audio.isSupported()) + { + return StartSendResult::Unsupported; + } + if ((!broadcast && target_id == 0U) || !lockState()) + { + return StartSendResult::Busy; + } + const bool unavailable = active_ || outbound_task_ || playback_task_ || + (!direct_rf_voice_supported_ && !mqtt_uplink_enabled_); + unlockState(); + if (unavailable) + { + return StartSendResult::Busy; + } + if (!broadcast && !ensureVerifiedContactSecret(target_id)) + { + return StartSendResult::PrivateContactUnverified; + } + + if (!lockState()) + { + return StartSendResult::Busy; + } + if (active_ || outbound_task_ || playback_task_) + { + unlockState(); + return StartSendResult::Busy; + } + outbound_target_id_ = target_id; + outbound_is_broadcast_ = broadcast; + active_ = true; + unlockState(); + if (xTaskCreatePinnedToCore(&PagerReceiveSession::outboundTaskEntry, + "vmp_tx", + kOutboundTaskStackWords, + this, + kOutboundTaskPriority, + nullptr, + tskNO_AFFINITY) != pdPASS) + { + setActive(false); + return StartSendResult::Unsupported; + } + return StartSendResult::Queued; + } + + private: + static void controlEnvelopeReceived(const control::Envelope& envelope, + void* context) + { + auto* const self = static_cast(context); + if (self) + { + self->handleControl(envelope); + } + } + + void handleControl(const control::Envelope& envelope) + { + if (!initialized_ || !direct_rf_voice_supported_ || !vmp::decodeControlFrame(envelope.bytes, sizeof(envelope.bytes), &candidate_control_)) + { + return; + } + + if (candidate_control_.type == vmp::ControlType::Accept && + outboundAcceptMatchesCandidate()) + { + handlePrivateAccept(envelope.bytes); + return; + } + if (isActive()) + { + return; + } + + vmp::DeliveryMode mode = vmp::DeliveryMode::Private; + if (!vmp::deliveryModeFor(candidate_control_, &mode)) + { + return; + } + if (mode == vmp::DeliveryMode::Private) + { + if (candidate_control_.type != vmp::ControlType::Offer || + candidate_control_.target_id != self_node_id_ || !tryBeginInbound()) + { + return; + } + handlePrivateOffer(envelope.bytes); + return; + } + + if (candidate_control_.type == vmp::ControlType::Announce && + candidate_control_.target_id == vmp::kBroadcastTargetId && + vmp::decodePublicControlFrame( + envelope.bytes, sizeof(envelope.bytes), &incoming_control_) && + tryBeginInbound()) + { + receiveBroadcast(); + } + } + + static void outboundTaskEntry(void* context) + { + auto* const self = static_cast(context); + if (self) + { + self->setOutboundTask(xTaskGetCurrentTaskHandle()); + self->runOutbound(); + } + vTaskDelete(nullptr); + } + + static void playbackTaskEntry(void* context) + { + auto* const self = static_cast(context); + if (self) + { + self->runPlayback(); + } + vTaskDelete(nullptr); + } + + void runOutbound() + { + bool sent = false; + bool used_lxmf = false; + if (media_->audio.capture(nullptr) == audio::CaptureResult::Complete && + media_->audio.hasEncodedMedia() && + media_->transmit_block.prepare(media_->audio.encodedMedia(), media_->audio.encodedMediaSize()) && + prepareOutboundControl()) + { + if (!direct_rf_voice_supported_) + { + // SX1262 can encode and publish the VMP object through an + // explicitly enabled MT MQTT uplink, but has no legal RF or + // LXMF voice carrier. No READY/control/2.4 GHz operation is + // reachable from this branch. + sent = queueMqttPublication(); + } + else + { + used_lxmf = shouldUseLxmfCarrier(); + sent = used_lxmf ? sendLxmfVoice() + : (outbound_is_broadcast_ ? sendBroadcastVoice() + : sendPrivateVoice()); + if (sent && !used_lxmf) + { + (void)queueMqttPublication(); + } + } + } + (void)sent; + clearOutboundAcceptWait(); + media_->audio.clearEncodedMedia(); + media_->transmit_block.clear(); + releaseRadio(); + resetEphemeralState(); + finishOutbound(); + } + + void runPlayback() + { + if (playback_local_id_ != 0U && playback_media_len_ != 0U) + { + (void)media_->audio.play(media_->playback_media, + playback_media_len_, + playback_codec_, + 70U); + } + clearPlaybackTask(); + } + + bool queueMqttPublication() + { + if (!media_->audio.hasEncodedMedia() || !lockState()) + { + return false; + } + if (!mqtt_uplink_enabled_) + { + unlockState(); + return false; + } + const bool prepared = outbound_is_broadcast_ + ? media_->mqtt_transmit.prepareBroadcast(outgoing_control_, + media_->audio.encodedMedia(), + media_->audio.encodedMediaSize()) + : media_->mqtt_transmit.preparePrivate(outgoing_control_, + contact_secret_, + media_->audio.encodedMedia(), + media_->audio.encodedMediaSize()); + if (!prepared) + { + media_->mqtt_transmit.clear(); + } + unlockState(); + return prepared; + } + + bool shouldUseLxmfCarrier() const + { + if (!lockState()) + { + return false; + } + const bool use_lxmf = direct_rf_voice_supported_ && !outbound_is_broadcast_ && + lxmf_carrier_enabled_ && + lxmf_sender_ != nullptr; + unlockState(); + return use_lxmf; + } + + bool ensureVerifiedContactSecret(uint32_t peer_id) + { + if (peer_id == 0U || !lockState()) + { + return false; + } + if (contact_secret_cache_stale_) + { + contacts_.clear(); + contact_secret_cache_stale_ = false; + } + if (contacts_.hasVerifiedContactSecret(peer_id)) + { + unlockState(); + return true; + } + const VerifiedContactSecretDeriver deriver = contact_secret_deriver_; + void* const context = contact_secret_deriver_context_; + const bool derived = deriver && + deriver(context, peer_id, contact_derivation_scratch_); + if (!derived) + { + secureClear(contact_derivation_scratch_, sizeof(contact_derivation_scratch_)); + unlockState(); + return false; + } + const bool stored = contacts_.upsertVerifiedContactSecret( + peer_id, contact_derivation_scratch_); + secureClear(contact_derivation_scratch_, sizeof(contact_derivation_scratch_)); + unlockState(); + return stored; + } + + bool sendLxmfVoice() + { + LxmfEnvelopeSender sender = nullptr; + void* sender_context = nullptr; + uint32_t target_id = 0U; + if (!lockState()) + { + return false; + } + if (outbound_is_broadcast_ || !lxmf_carrier_enabled_ || !lxmf_sender_ || + !media_->mqtt_transmit.preparePrivate(outgoing_control_, + contact_secret_, + media_->audio.encodedMedia(), + media_->audio.encodedMediaSize())) + { + media_->mqtt_transmit.clear(); + unlockState(); + return false; + } + sender = lxmf_sender_; + sender_context = lxmf_sender_context_; + target_id = outbound_target_id_; + unlockState(); + + while (true) + { + std::size_t envelope_len = sizeof(data_wire_); + if (!lockState()) + { + return false; + } + if (!media_->mqtt_transmit.copyNextEnvelope(data_wire_, &envelope_len)) + { + media_->mqtt_transmit.clear(); + unlockState(); + return false; + } + unlockState(); + + // LXMF owns its link/session delivery policy. VMP only emits the + // original bounded object once and never creates an application + // acknowledgement, a relay, or a VMP resend from a received item. + if (!sender(sender_context, target_id, data_wire_, envelope_len)) + { + if (lockState()) + { + media_->mqtt_transmit.clear(); + unlockState(); + } + return false; + } + + if (!lockState()) + { + return false; + } + if (!media_->mqtt_transmit.commitNextEnvelope()) + { + media_->mqtt_transmit.clear(); + unlockState(); + return false; + } + if (!media_->mqtt_transmit.hasNext()) + { + media_->mqtt_transmit.clear(); + unlockState(); + return true; + } + unlockState(); + } + } + + bool prepareOutboundControl() + { + outgoing_control_ = {}; + outgoing_control_.type = outbound_is_broadcast_ ? vmp::ControlType::Announce + : vmp::ControlType::Offer; + outgoing_control_.flags = outbound_is_broadcast_ + ? static_cast(vmp::ControlFlagBroadcast | + vmp::ControlFlagPublicBroadcast) + : static_cast(vmp::ControlFlagPrivate); + outgoing_control_.sender_id = self_node_id_; + outgoing_control_.target_id = outbound_target_id_; + esp_fill_random(&outgoing_control_.session_id, + sizeof(outgoing_control_.session_id)); + if (outgoing_control_.session_id == 0U) + { + outgoing_control_.session_id = 1U; + } + esp_fill_random(outgoing_control_.session_nonce, + sizeof(outgoing_control_.session_nonce)); + outgoing_control_.phy_profile_id = kDefaultPhyProfile; + outgoing_control_.channel_index = static_cast( + outgoing_control_.session_id % (static_cast(kMaxChannelIndex) + 1U)); + outgoing_control_.encoded_media_len = static_cast(media_->audio.encodedMediaSize()); + outgoing_control_.codec = vmp::Codec::Codec2_1300; + outgoing_control_.fec_layout = vmp::kFecLayoutRs10_8; + outgoing_control_.total_blocks = 1U; + outgoing_control_.data_start_delay_ms = + outbound_is_broadcast_ ? 700U : 120U; + outgoing_control_.object_fingerprint = static_cast( + outgoing_control_.session_id ^ (outgoing_control_.session_id >> 32U)); + + if (outbound_is_broadcast_) + { + return true; + } + return contacts_.lookupVerifiedContactSecret(outbound_target_id_, contact_secret_) && + vmp::generateEphemeralKeyPair(&local_ephemeral_) && + vmp::derivePrivateControlKey(contact_secret_, + outgoing_control_.session_nonce, + outgoing_control_.session_id, + session_keys_.control_key) && + copyOutboundEphemeralPublicKey(); + } + + bool copyOutboundEphemeralPublicKey() + { + std::memcpy(outgoing_control_.ephemeral_public_key, + local_ephemeral_.public_key, + sizeof(outgoing_control_.ephemeral_public_key)); + return true; + } + + bool sendPrivateVoice() + { + if (!direct_rf_voice_supported_) + { + return false; + } + std::size_t control_len = sizeof(control_wire_); + if (!vmp::encodePrivateControlFrame( + outgoing_control_, + session_keys_, + vmp::PrivateFrameDirection::SenderToReceiver, + control_wire_, + &control_len) || + !radio::tryAcquire(&radio_lease_)) + { + return false; + } + + beginOutboundAcceptWait(); + if (!radio::transmit(&radio_lease_, control_wire_, control_len)) + { + clearOutboundAcceptWait(); + releaseRadio(); + return false; + } + releaseRadio(); + + if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kPrivateAcceptWindowMs)) == 0U || + !takeOutboundAccept() || + !vmp::derivePrivateSessionKeys(contact_secret_, + local_ephemeral_.private_key, + peer_accept_.ephemeral_public_key, + outgoing_control_.session_nonce, + outgoing_control_.session_id, + &session_keys_)) + { + clearOutboundAcceptWait(); + return false; + } + return transmitPrivateDataTrain(); + } + + bool sendBroadcastVoice() + { + if (!direct_rf_voice_supported_) + { + return false; + } + std::size_t control_len = sizeof(control_wire_); + if (!vmp::encodePublicControlFrame(outgoing_control_, control_wire_, &control_len) || + !radio::tryAcquire(&radio_lease_) || + !radio::transmit(&radio_lease_, control_wire_, control_len)) + { + releaseRadio(); + return false; + } + releaseRadio(); + + vTaskDelay(pdMS_TO_TICKS(outgoing_control_.data_start_delay_ms)); + radio::PhyProfile profile{}; + if (!profileFor(outgoing_control_, &profile) || + !radio::tryAcquire(&radio_lease_) || + !radio::switchTo2Ghz(&radio_lease_, profile)) + { + releaseRadio(); + return false; + } + for (uint8_t probe = 0U; probe < kReadyProbeCount; ++probe) + { + data_header_ = {}; + data_header_.type = vmp::DataType::ReadyProbe; + data_header_.session_id = outgoing_control_.session_id; + std::size_t probe_len = sizeof(data_wire_); + if (!vmp::buildPublicReadyFrame(data_header_, data_wire_, &probe_len) || + !radio::transmit(&radio_lease_, data_wire_, probe_len)) + { + releaseRadio(); + return false; + } + vTaskDelay(pdMS_TO_TICKS(kReadyProbeSpacingMs)); + } + return transmitDataShards(false); + } + + bool transmitPrivateDataTrain() + { + radio::PhyProfile profile{}; + if (!profileFor(outgoing_control_, &profile) || + !radio::tryAcquire(&radio_lease_) || + !radio::switchTo2Ghz(&radio_lease_, profile)) + { + releaseRadio(); + return false; + } + vTaskDelay(pdMS_TO_TICKS(outgoing_control_.data_start_delay_ms)); + for (uint8_t probe = 0U; probe < kReadyProbeCount; ++probe) + { + if (!sendPrivateReadyProbe() || !waitForPrivateReady()) + { + if (probe + 1U == kReadyProbeCount) + { + releaseRadio(); + return false; + } + vTaskDelay(pdMS_TO_TICKS(kReadyProbeSpacingMs)); + continue; + } + return transmitDataShards(true); + } + releaseRadio(); + return false; + } + + bool sendPrivateReadyProbe() + { + data_header_ = {}; + data_header_.type = vmp::DataType::ReadyProbe; + data_header_.session_id = outgoing_control_.session_id; + std::size_t probe_len = vmp::kDataHeaderSize; + return vmp::encodeDataHeader(data_header_, data_wire_, &probe_len) && + vmp::tagPrivateReady(session_keys_, + outgoing_control_.session_nonce, + vmp::PrivateFrameDirection::SenderToReceiver, + data_header_, + data_wire_ + probe_len) && + radio::transmit(&radio_lease_, + data_wire_, + probe_len + vmp::kPrivateDataAuthTagSize) && + radio::startReceive(&radio_lease_); + } + + bool waitForPrivateReady() + { + const uint32_t deadline = millis() + kReadyProbeSpacingMs; + while (!deadlineExpired(deadline)) + { + const int packet_len = radio::packetLength(&radio_lease_); + if (packet_len == static_cast(vmp::kDataHeaderSize + + vmp::kPrivateDataAuthTagSize) && + radio::readPacket(&radio_lease_, data_wire_, + static_cast(packet_len)) && + vmp::decodeDataHeader(data_wire_, vmp::kDataHeaderSize, &data_header_) && + data_header_.type == vmp::DataType::Ready && + data_header_.session_id == outgoing_control_.session_id && + vmp::verifyPrivateReadyTag( + session_keys_, + outgoing_control_.session_nonce, + vmp::PrivateFrameDirection::ReceiverToSender, + data_header_, + data_wire_ + vmp::kDataHeaderSize)) + { + return true; + } + (void)radio::startReceive(&radio_lease_); + vTaskDelay(pdMS_TO_TICKS(2)); + } + return false; + } + + bool transmitDataShards(bool private_mode) + { + for (uint8_t shard_index = 0U; shard_index < vmp::kTotalShardsPerBlock; + ++shard_index) + { + std::size_t frame_len = sizeof(data_wire_); + const bool built = private_mode + ? media_->transmit_block.buildPrivateShardFrame( + session_keys_, + outgoing_control_.session_nonce, + outgoing_control_.session_id, + shard_index, + data_wire_, + &frame_len) + : media_->transmit_block.buildPublicShardFrame( + outgoing_control_.session_id, + shard_index, + data_wire_, + &frame_len); + if (!built || !radio::transmit(&radio_lease_, data_wire_, frame_len)) + { + releaseRadio(); + return false; + } + } + releaseRadio(); + return true; + } + + void handlePrivateAccept(const uint8_t* raw_control) + { + if (!raw_control || !lockState()) + { + return; + } + TaskHandle_t outbound_task = nullptr; + if (!outbound_waiting_accept_ || + !vmp::decodePrivateControlFrame(raw_control, + vmp::kControlFrameSize, + session_keys_, + vmp::PrivateFrameDirection::ReceiverToSender, + &peer_accept_) || + peer_accept_.object_fingerprint != outgoing_control_.object_fingerprint || + std::memcmp(peer_accept_.session_nonce, + outgoing_control_.session_nonce, + sizeof(peer_accept_.session_nonce)) != 0) + { + unlockState(); + return; + } + outbound_accept_received_ = true; + outbound_waiting_accept_ = false; + outbound_task = outbound_task_; + unlockState(); + if (outbound_task) + { + xTaskNotifyGive(outbound_task); + } + } + + void handlePrivateOffer(const uint8_t* raw_control) + { + resetEphemeralState(); + if (!ensureVerifiedContactSecret(candidate_control_.sender_id) || + !contacts_.lookupVerifiedContactSecret(candidate_control_.sender_id, + contact_secret_) || + !vmp::derivePrivateControlKey(contact_secret_, + candidate_control_.session_nonce, + candidate_control_.session_id, + session_keys_.control_key) || + !vmp::decodePrivateControlFrame(raw_control, + vmp::kControlFrameSize, + session_keys_, + vmp::PrivateFrameDirection::SenderToReceiver, + &incoming_control_) || + !vmp::generateEphemeralKeyPair(&local_ephemeral_) || + !vmp::derivePrivateSessionKeys(contact_secret_, + local_ephemeral_.private_key, + incoming_control_.ephemeral_public_key, + incoming_control_.session_nonce, + incoming_control_.session_id, + &session_keys_) || + !prepareReceiveBlock()) + { + resetEphemeralState(); + setActive(false); + return; + } + + outgoing_control_ = incoming_control_; + outgoing_control_.type = vmp::ControlType::Accept; + outgoing_control_.sender_id = self_node_id_; + outgoing_control_.target_id = incoming_control_.sender_id; + std::memcpy(outgoing_control_.ephemeral_public_key, + local_ephemeral_.public_key, + sizeof(outgoing_control_.ephemeral_public_key)); + + std::size_t control_len = sizeof(control_wire_); + if (!vmp::encodePrivateControlFrame( + outgoing_control_, + session_keys_, + vmp::PrivateFrameDirection::ReceiverToSender, + control_wire_, + &control_len) || + !radio::tryAcquire(&radio_lease_) || + !radio::transmit(&radio_lease_, control_wire_, control_len)) + { + releaseRadio(); + resetEphemeralState(); + setActive(false); + return; + } + + radio::PhyProfile profile{}; + if (!profileFor(incoming_control_, &profile) || + !radio::switchTo2Ghz(&radio_lease_, profile) || + !radio::startReceive(&radio_lease_)) + { + releaseRadio(); + resetEphemeralState(); + setActive(false); + return; + } + receivePrivateMedia(); + setActive(false); + releaseRadio(); + resetEphemeralState(); + } + + void receiveBroadcast() + { + if (!prepareReceiveBlock()) + { + setActive(false); + return; + } + radio::PhyProfile profile{}; + if (!profileFor(incoming_control_, &profile) || + !radio::tryAcquire(&radio_lease_) || + !radio::switchTo2Ghz(&radio_lease_, profile) || + !radio::startReceive(&radio_lease_)) + { + releaseRadio(); + setActive(false); + return; + } + receivePublicMedia(); + setActive(false); + releaseRadio(); + } + + bool prepareReceiveBlock() + { + vmp::MediaLayout layout{}; + return vmp::planMediaLayout(incoming_control_.encoded_media_len, &layout) && + media_->receive_block.begin(layout); + } + + void receivePrivateMedia() + { + bool ready_sent = false; + const uint32_t deadline = millis() + kReceiveWindowMs; + while (!deadlineExpired(deadline)) + { + const int packet_len = radio::packetLength(&radio_lease_); + if (packet_len <= 0 || + static_cast(packet_len) > sizeof(data_wire_) || + !radio::readPacket(&radio_lease_, data_wire_, + static_cast(packet_len))) + { + vTaskDelay(pdMS_TO_TICKS(2)); + continue; + } + + if (static_cast(packet_len) == + vmp::kDataHeaderSize + vmp::kPrivateDataAuthTagSize) + { + handlePrivateReadyProbe(&ready_sent); + } + else if (ready_sent && static_cast(packet_len) == vmp::kPrivateShardFrameSize && + handlePrivateShard()) + { + return; + } + (void)radio::startReceive(&radio_lease_); + } + } + + void receivePublicMedia() + { + const uint32_t deadline = millis() + kReceiveWindowMs; + while (!deadlineExpired(deadline)) + { + const int packet_len = radio::packetLength(&radio_lease_); + if (packet_len <= 0 || + static_cast(packet_len) > sizeof(data_wire_) || + !radio::readPacket(&radio_lease_, data_wire_, + static_cast(packet_len))) + { + vTaskDelay(pdMS_TO_TICKS(2)); + continue; + } + + if (static_cast(packet_len) == vmp::kPublicShardFrameSize && + handlePublicShard()) + { + return; + } + (void)radio::startReceive(&radio_lease_); + } + } + + void handlePrivateReadyProbe(bool* ready_sent) + { + if (!ready_sent || *ready_sent || + !vmp::decodeDataHeader(data_wire_, vmp::kDataHeaderSize, &data_header_) || + data_header_.type != vmp::DataType::ReadyProbe || + data_header_.session_id != incoming_control_.session_id || + !vmp::verifyPrivateReadyTag( + session_keys_, + incoming_control_.session_nonce, + vmp::PrivateFrameDirection::SenderToReceiver, + data_header_, + data_wire_ + vmp::kDataHeaderSize)) + { + return; + } + + data_header_.type = vmp::DataType::Ready; + std::size_t ready_len = vmp::kDataHeaderSize; + if (!vmp::encodeDataHeader(data_header_, data_wire_, &ready_len) || + !vmp::tagPrivateReady(session_keys_, + incoming_control_.session_nonce, + vmp::PrivateFrameDirection::ReceiverToSender, + data_header_, + data_wire_ + ready_len) || + !radio::transmit(&radio_lease_, + data_wire_, + ready_len + vmp::kPrivateDataAuthTagSize)) + { + return; + } + *ready_sent = true; + } + + bool handlePrivateShard() + { + if (!vmp::decodeDataHeader(data_wire_, vmp::kDataHeaderSize, &data_header_) || + data_header_.session_id != incoming_control_.session_id || + !vmp::openPrivateShard( + session_keys_, + incoming_control_.session_nonce, + vmp::PrivateFrameDirection::SenderToReceiver, + data_header_, + data_wire_ + vmp::kDataHeaderSize, + vmp::kMaxShardPayloadSize, + data_wire_ + vmp::kDataHeaderSize + vmp::kMaxShardPayloadSize, + shard_plaintext_)) + { + return false; + } + return acceptShardAndStore(data_header_, shard_plaintext_); + } + + bool handlePublicShard() + { + const uint8_t* shard = nullptr; + if (!vmp::parsePublicShardFrame(data_wire_, + vmp::kPublicShardFrameSize, + &data_header_, + &shard) || + data_header_.session_id != incoming_control_.session_id) + { + return false; + } + return acceptShardAndStore(data_header_, shard); + } + + bool acceptShardAndStore(const vmp::DataHeader& header, const uint8_t* shard) + { + const vmp::ReceiveBlockResult result = media_->receive_block.accept( + header, shard, vmp::kMaxShardPayloadSize); + if (result != vmp::ReceiveBlockResult::Complete) + { + return false; + } + std::size_t media_len = 0U; + if (!media_->receive_block.recover(media_->received_media, + sizeof(media_->received_media), + &media_len)) + { + return false; + } + if (!lockState()) + { + return false; + } + const bool stored = storeCompletedVoice(incoming_control_, + media_->received_media, + media_len, + millis() / 1000U); + secureClear(media_->received_media, sizeof(media_->received_media)); + unlockState(); + return stored; + } + + void releaseRadio() + { + if (radio_lease_.implementation) + { + radio::release(&radio_lease_); + } + } + + void resetEphemeralState() + { + vmp::clearPrivateSessionKeys(&session_keys_); + secureClear(contact_secret_, sizeof(contact_secret_)); + secureClear(local_ephemeral_.private_key, sizeof(local_ephemeral_.private_key)); + secureClear(local_ephemeral_.public_key, sizeof(local_ephemeral_.public_key)); + media_->receive_block.clear(); + } + + bool lockState() const + { + return state_mutex_ && xSemaphoreTake(state_mutex_, portMAX_DELAY) == pdTRUE; + } + + void unlockState() const + { + if (state_mutex_) + { + (void)xSemaphoreGive(state_mutex_); + } + } + + bool isActive() const + { + if (!lockState()) + { + return true; + } + const bool active = active_; + unlockState(); + return active; + } + + void setActive(bool active) + { + if (lockState()) + { + active_ = active; + unlockState(); + } + } + + bool tryBeginInbound() + { + if (!lockState()) + { + return false; + } + if (active_ || !inbox_ready_) + { + unlockState(); + return false; + } + active_ = true; + unlockState(); + return true; + } + + bool outboundAcceptMatchesCandidate() const + { + if (!lockState()) + { + return false; + } + const bool matches = outbound_waiting_accept_ && + candidate_control_.target_id == self_node_id_ && + candidate_control_.sender_id == outbound_target_id_ && + candidate_control_.session_id == outgoing_control_.session_id; + unlockState(); + return matches; + } + + void setOutboundTask(TaskHandle_t task) + { + if (lockState()) + { + outbound_task_ = task; + unlockState(); + } + } + + void beginOutboundAcceptWait() + { + if (lockState()) + { + outbound_accept_received_ = false; + outbound_waiting_accept_ = true; + unlockState(); + } + } + + void clearOutboundAcceptWait() + { + if (lockState()) + { + outbound_waiting_accept_ = false; + outbound_accept_received_ = false; + unlockState(); + } + } + + bool takeOutboundAccept() + { + if (!lockState()) + { + return false; + } + const bool accepted = outbound_accept_received_; + outbound_waiting_accept_ = false; + outbound_accept_received_ = false; + unlockState(); + return accepted; + } + + void finishOutbound() + { + if (lockState()) + { + outbound_waiting_accept_ = false; + outbound_accept_received_ = false; + outbound_task_ = nullptr; + active_ = false; + unlockState(); + } + } + + void clearPlaybackTask() + { + if (lockState()) + { + secureClear(media_->playback_media, sizeof(media_->playback_media)); + playback_media_len_ = 0U; + playback_local_id_ = 0U; + playback_task_ = nullptr; + unlockState(); + } + } + + bool storeCompletedVoice(const vmp::ControlFrame& control, + const uint8_t* encoded_media, + std::size_t encoded_media_len, + uint32_t received_at_seconds) + { + if (!media_ || !inbox_ready_) + { + return false; + } + uint64_t local_id = 0U; + const vmp::VoiceInboxStoreResult result = media_->inbox.store( + control, + encoded_media, + encoded_media_len, + true, + received_at_seconds, + &local_id); + if (result == vmp::VoiceInboxStoreResult::Duplicate) + { + return true; + } + if (result != vmp::VoiceInboxStoreResult::Stored) + { + return false; + } + + if (!requires_durable_attachment_store_) + { + return true; + } + const bool persisted = + ::platform::esp::arduino_common::chat_attachment::persistVoiceInbox( + media_->inbox, + media_->persistence_metadata, + vmp::kVoiceInboxCapacity); + if (!persisted) + { + // Match the text ledger's durable-incoming boundary: a completed + // object is not exposed locally until both its payload and index + // have been committed. There is no VMP ACK or retransmit here. + (void)media_->inbox.erase(local_id); + } + return persisted; + } + + uint32_t self_node_id_ = 0U; + bool initialized_ = false; + bool direct_rf_voice_supported_ = false; + bool active_ = false; + bool requires_durable_attachment_store_ = false; + bool attachment_store_ready_ = false; + bool inbox_ready_ = false; + uint32_t last_persistent_inbox_attempt_ms_ = 0U; + StaticSemaphore_t state_mutex_storage_{}; + SemaphoreHandle_t state_mutex_ = nullptr; + vmp::FixedVerifiedContactSecretDirectory contacts_{}; + PagerMediaStorage* media_ = nullptr; + radio::Lease radio_lease_{}; + TaskHandle_t outbound_task_ = nullptr; + TaskHandle_t playback_task_ = nullptr; + uint64_t playback_local_id_ = 0U; + uint16_t playback_media_len_ = 0U; + vmp::Codec playback_codec_ = vmp::Codec::Codec2_1300; + uint32_t outbound_target_id_ = 0U; + bool outbound_is_broadcast_ = false; + bool mqtt_uplink_enabled_ = false; + bool lxmf_carrier_enabled_ = false; + bool contact_secret_cache_stale_ = false; + bool outbound_waiting_accept_ = false; + bool outbound_accept_received_ = false; + vmp::ControlFrame candidate_control_{}; + vmp::ControlFrame incoming_control_{}; + vmp::ControlFrame outgoing_control_{}; + vmp::ControlFrame peer_accept_{}; + vmp::ControlFrame transport_candidate_control_{}; + vmp::DataHeader data_header_{}; + vmp::EphemeralKeyPair local_ephemeral_{}; + vmp::PrivateSessionKeys session_keys_{}; + uint8_t contact_secret_[vmp::kPrivateKeySize] = {}; + uint8_t contact_derivation_scratch_[vmp::kPrivateKeySize] = {}; + uint8_t control_wire_[vmp::kControlFrameSize] = {}; + uint8_t data_wire_[kMaxRadioFrameSize] = {}; + uint8_t shard_plaintext_[vmp::kMaxShardPayloadSize] = {}; + LxmfEnvelopeSender lxmf_sender_ = nullptr; + void* lxmf_sender_context_ = nullptr; + VerifiedContactSecretDeriver contact_secret_deriver_ = nullptr; + void* contact_secret_deriver_context_ = nullptr; +}; + +PagerReceiveSession s_session{}; + +} // namespace + +bool initialize(uint32_t self_node_id, bool durable_attachment_store) +{ + return s_session.initialize(self_node_id, durable_attachment_store); +} + +bool canRecordAndSend() +{ + return s_session.canRecordAndSend(); +} + +void onPersistentStorageReady() +{ + s_session.onPersistentStorageReady(); +} + +void servicePersistentInbox() +{ + s_session.servicePersistentInbox(); +} + +bool provisionVerifiedContactSecret( + uint32_t peer_id, + const uint8_t secret[vmp::kPrivateKeySize]) +{ + return s_session.provisionVerifiedContactSecret(peer_id, secret); +} + +void setVerifiedContactSecretDeriver(VerifiedContactSecretDeriver deriver, + void* context) +{ + s_session.setVerifiedContactSecretDeriver(deriver, context); +} + +void invalidateContactSecretCache() +{ + s_session.invalidateContactSecretCache(); +} + +const vmp::VoiceMessageInbox* inbox() +{ + return s_session.inbox(); +} + +std::size_t listInboxMetadata(vmp::VoiceMessageMetadata* out_metadata, + std::size_t capacity) +{ + return s_session.listInboxMetadata(out_metadata, capacity); +} + +bool playInboxMessage(uint64_t local_id, uint8_t volume_percent) +{ + return s_session.playInboxMessage(local_id, volume_percent); +} + +bool requestPlayback(uint64_t local_id) +{ + return s_session.requestPlayback(local_id); +} + +bool peekMqttEnvelope(uint8_t* out, std::size_t* inout_len) +{ + return s_session.peekMqttEnvelope(out, inout_len); +} + +bool acknowledgeMqttEnvelope() +{ + return s_session.acknowledgeMqttEnvelope(); +} + +void setMqttUplinkEnabled(bool enabled) +{ + s_session.setMqttUplinkEnabled(enabled); +} + +void setLxmfEnvelopeSender(LxmfEnvelopeSender sender, void* context) +{ + s_session.setLxmfEnvelopeSender(sender, context); +} + +void setLxmfCarrierEnabled(bool enabled) +{ + s_session.setLxmfCarrierEnabled(enabled); +} + +bool acceptMqttEnvelope(const uint8_t* envelope, std::size_t envelope_len) +{ + return s_session.acceptMqttEnvelope(envelope, envelope_len); +} + +bool acceptLxmfEnvelope(uint32_t source_id, + const uint8_t* envelope, + std::size_t envelope_len) +{ + return s_session.acceptLxmfEnvelope(source_id, envelope, envelope_len); +} + +void discardMqttPublication() +{ + s_session.discardMqttPublication(); +} + +StartSendResult requestRecordAndSend(uint32_t target_id) +{ + return s_session.requestRecordAndSend(target_id); +} + +} // namespace platform::esp::arduino_common::voice::vmp_session + +#else + +namespace platform::esp::arduino_common::voice::vmp_session +{ + +bool initialize(uint32_t, bool) +{ + return false; +} + +bool canRecordAndSend() +{ + return false; +} + +void onPersistentStorageReady() +{ +} + +void servicePersistentInbox() +{ +} + +bool provisionVerifiedContactSecret( + uint32_t, + const uint8_t[chat::voice::vmp::kPrivateKeySize]) +{ + return false; +} + +void setVerifiedContactSecretDeriver(VerifiedContactSecretDeriver, void*) +{ +} + +void invalidateContactSecretCache() +{ +} + +const chat::voice::vmp::VoiceMessageInbox* inbox() +{ + return nullptr; +} + +std::size_t listInboxMetadata(chat::voice::vmp::VoiceMessageMetadata*, std::size_t) +{ + return 0U; +} + +bool playInboxMessage(uint64_t, uint8_t) +{ + return false; +} + +bool requestPlayback(uint64_t) +{ + return false; +} + +bool peekMqttEnvelope(uint8_t*, std::size_t*) +{ + return false; +} + +bool acknowledgeMqttEnvelope() +{ + return false; +} + +void setMqttUplinkEnabled(bool) +{ +} + +void setLxmfEnvelopeSender(LxmfEnvelopeSender, void*) +{ +} + +void setLxmfCarrierEnabled(bool) +{ +} + +bool acceptMqttEnvelope(const uint8_t*, std::size_t) +{ + return false; +} + +bool acceptLxmfEnvelope(uint32_t, const uint8_t*, std::size_t) +{ + return false; +} + +void discardMqttPublication() +{ +} + +StartSendResult requestRecordAndSend(uint32_t) +{ + return StartSendResult::Unsupported; +} + +} // namespace platform::esp::arduino_common::voice::vmp_session + +#endif diff --git a/platform/esp/arduino_common/src/voice/vmp_radio_lease.cpp b/platform/esp/arduino_common/src/voice/vmp_radio_lease.cpp new file mode 100644 index 00000000..740f788d --- /dev/null +++ b/platform/esp/arduino_common/src/voice/vmp_radio_lease.cpp @@ -0,0 +1,222 @@ +/** + * @file vmp_radio_lease.cpp + * @brief Exclusive LR1121 2.4 GHz lease for VMP control/data transfers. + */ + +#include "platform/esp/arduino_common/voice/vmp_radio_lease.h" + +#if defined(ARDUINO_T_LORA_PAGER) && defined(ARDUINO_LILYGO_LORA_LR1121) + +#include + +#include "boards/tlora_pager/tlora_pager_board.h" +#include "platform/esp/arduino_common/exclusive_lora_runtime.h" + +namespace platform::esp::arduino_common::voice::vmp_radio +{ +namespace +{ + +constexpr float kMinVmpFrequencyMhz = 2400.0f; +constexpr float kMaxVmpFrequencyMhz = 2483.5f; +constexpr std::size_t kMaxVmpFrameSize = 255U; +constexpr uint8_t kVmpSyncWord[] = {'V', 'M', 'P', 1U}; + +struct LeaseImplementation +{ + exclusive_lora_runtime::Session exclusive = {}; + ::boards::tlora_pager::TLoRaPagerBoard* board = nullptr; +}; + +bool validProfile(const PhyProfile& profile) +{ + return profile.frequency_mhz >= kMinVmpFrequencyMhz && + profile.frequency_mhz <= kMaxVmpFrequencyMhz && + profile.bit_rate_kbps > 0.0f && profile.bit_rate_kbps <= 2000.0f && + profile.frequency_deviation_khz > 0.0f && + profile.receive_bandwidth_khz > 0.0f && + profile.preamble_length >= 8U; +} + +LeaseImplementation* implementation(Lease* lease) +{ + return lease ? static_cast(lease->implementation) : nullptr; +} + +const LeaseImplementation* implementation(const Lease* lease) +{ + return lease ? static_cast(lease->implementation) : nullptr; +} + +} // namespace + +bool isSupported() +{ + return true; +} + +bool tryAcquire(Lease* out_lease) +{ + if (!out_lease) + { + return false; + } + *out_lease = {}; + + static LeaseImplementation storage{}; + if (storage.exclusive.lora != nullptr) + { + return false; + } + if (!exclusive_lora_runtime::tryAcquire(&storage.exclusive)) + { + return false; + } + + storage.board = static_cast<::boards::tlora_pager::TLoRaPagerBoard*>( + storage.exclusive.lora); + if (!storage.board || !storage.board->isRadioOnline()) + { + exclusive_lora_runtime::release(&storage.exclusive); + storage.board = nullptr; + return false; + } + + out_lease->implementation = &storage; + out_lease->owns_radio_tasks = storage.exclusive.paused_radio_tasks; + return true; +} + +bool switchTo2Ghz(Lease* lease, const PhyProfile& profile) +{ + LeaseImplementation* const state = implementation(lease); + if (!state || !state->board || !validProfile(profile)) + { + return false; + } + // beginGFSK() may have changed the radio even when a later configuration + // step reports an error. Mark the lease before the call so release() is + // guaranteed to restore the cached Sub-GHz profile on every error path. + lease->switched_to_2ghz = true; + const int result = state->board->configureFskRadio(profile.frequency_mhz, + profile.bit_rate_kbps, + profile.frequency_deviation_khz, + profile.receive_bandwidth_khz, + profile.tx_power_dbm, + profile.preamble_length, + 3.0f, + kVmpSyncWord, + sizeof(kVmpSyncWord), + 2U); + if (result != RADIOLIB_ERR_NONE) + { + return false; + } + return true; +} + +bool transmit(Lease* lease, const uint8_t* data, std::size_t size) +{ + LeaseImplementation* const state = implementation(lease); + return state && state->board && data && size != 0U && size <= kMaxVmpFrameSize && + state->board->transmitRadio(data, size) == RADIOLIB_ERR_NONE; +} + +bool startReceive(Lease* lease) +{ + LeaseImplementation* const state = implementation(lease); + return state && state->board && + state->board->startRadioReceive() == RADIOLIB_ERR_NONE; +} + +int packetLength(Lease* lease) +{ + LeaseImplementation* const state = implementation(lease); + return state && state->board ? state->board->getRadioPacketLength(true) : -1; +} + +bool readPacket(Lease* lease, uint8_t* out, std::size_t size) +{ + LeaseImplementation* const state = implementation(lease); + return state && state->board && out && size != 0U && size <= kMaxVmpFrameSize && + state->board->readRadioData(out, size) == RADIOLIB_ERR_NONE; +} + +void clearIrq(Lease* lease) +{ + LeaseImplementation* const state = implementation(lease); + if (state && state->board) + { + state->board->clearRadioIrqFlags(0xFFFFFFFFU); + } +} + +void release(Lease* lease) +{ + LeaseImplementation* const state = implementation(lease); + if (!lease || !state) + { + return; + } + if (state->board && lease->switched_to_2ghz) + { + (void)state->board->restoreLoRaRadio(); + } + exclusive_lora_runtime::release(&state->exclusive); + state->board = nullptr; + *lease = {}; +} + +} // namespace platform::esp::arduino_common::voice::vmp_radio + +#else + +namespace platform::esp::arduino_common::voice::vmp_radio +{ + +bool isSupported() +{ + return false; +} + +bool tryAcquire(Lease*) +{ + return false; +} + +bool switchTo2Ghz(Lease*, const PhyProfile&) +{ + return false; +} + +bool transmit(Lease*, const uint8_t*, std::size_t) +{ + return false; +} + +bool startReceive(Lease*) +{ + return false; +} + +int packetLength(Lease*) +{ + return -1; +} + +bool readPacket(Lease*, uint8_t*, std::size_t) +{ + return false; +} + +void clearIrq(Lease*) +{ +} + +void release(Lease*) +{ +} + +} // namespace platform::esp::arduino_common::voice::vmp_radio + +#endif diff --git a/scripts/platformio-pre.py b/scripts/platformio-pre.py index c3e3c121..1d976c54 100644 --- a/scripts/platformio-pre.py +++ b/scripts/platformio-pre.py @@ -401,7 +401,9 @@ def configure_crypto_for_sx1262_esp32(): crypto_dir = os.path.join(project_dir, ".pio", "libdeps", pio_env, "Crypto") library_json_path = os.path.join(crypto_dir, "library.json") # This is the linked object closure for AES-CTR, ChaCha-Poly1305, - # Curve25519, RNG, and SHA-256 used by the four supported protocols. + # Curve25519, HKDF, RNG, and SHA-256 used by the supported protocols. + # VMP private MQTT uses HKDF even on the SX1262 Pager, whose VMP carrier + # is MQTT-only and therefore never takes the LR1121 RF handshake path. desired_src_filter = [ "-<*>", "+", @@ -414,6 +416,7 @@ def configure_crypto_for_sx1262_esp32(): "+", "+", "+", + "+", "+", "+", "+",