diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml index 39514b11..8c1deb73 100644 --- a/.github/workflows/style.yaml +++ b/.github/workflows/style.yaml @@ -19,6 +19,6 @@ jobs: steps: - uses: actions/checkout@v4 - name: uncrustify - run: /ros_entrypoint.sh ament_uncrustify rmw_zenoh_cpp/ + run: /ros_entrypoint.sh ament_uncrustify rmw_zenoh_cpp/ zenoh_security_tools/ - name: cpplint - run: /ros_entrypoint.sh ament_cpplint rmw_zenoh_cpp/ + run: /ros_entrypoint.sh ament_cpplint rmw_zenoh_cpp/ zenoh_security_tools/ diff --git a/README.md b/README.md index d9300d86..24b097a3 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,11 @@ For example, if another `Zenoh router` is listening on IP address `192.168.1.1` Then, start the `Zenoh router` after setting the `ZENOH_ROUTER_CONFIG_URI` environment variable to the absolute path of the modified config file. +### Security + +Security is available in `rmw_zenoh` by means of access control, authentication and encryption. +The [zenoh_security_tools](./zenoh_security_tools/) package contains a script to generate Zenoh configs with security configured along with documentation on its usage. + ### Logging The core of Zenoh is implemented in Rust and uses a logging library that can be configured via a `RUST_LOG` environment variable. @@ -221,7 +226,7 @@ Note that composable nodes should *never* call `rclcpp::shutdown()`, as the comp For more details, see https://github.com/ros2/rmw_zenoh/issues/170. -### rmw_zenoh is incompatible between Humble and newer distributions. +### rmw_zenoh is incompatible between Humble and newer distributions. Since Iron, ROS 2 introduced type hashes for messages and `rmw_zenoh` includes these type hashes in the Zenoh keyexpressions it constructs for data exchange. While participants will be discoverable, communication between Humble and newer distributions will fail, resulting in messages being silently dropped. diff --git a/zenoh_security_tools/CMakeLists.txt b/zenoh_security_tools/CMakeLists.txt new file mode 100644 index 00000000..ed3d57a2 --- /dev/null +++ b/zenoh_security_tools/CMakeLists.txt @@ -0,0 +1,51 @@ +cmake_minimum_required(VERSION 3.8) +project(zenoh_security_tools) + +# Default to C++17 +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) + set(CMAKE_CXX_STANDARD_REQUIRED ON) +endif() + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +# find dependencies +find_package(ament_cmake REQUIRED) +find_package(nlohmann_json REQUIRED) +find_package(rcpputils REQUIRED) +find_package(rcutils REQUIRED) +# todo(Yadunund): Remove rmw dependency after https://github.com/ros2/rmw/pull/400 is merged. +find_package(rmw REQUIRED) +find_package(rmw_security_common REQUIRED) +find_package(tinyxml2_vendor REQUIRED) +find_package(TinyXML2 REQUIRED) +find_package(zenoh_cpp_vendor REQUIRED) + +add_executable(generate_configs + src/main.cpp + src/config_generator.cpp +) +target_link_libraries(generate_configs PRIVATE + nlohmann_json::nlohmann_json + rcpputils::rcpputils + rcutils::rcutils + rmw::rmw + rmw_security_common::rmw_security_common_library + tinyxml2::tinyxml2 + zenohcxx::zenohc +) + +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + ament_lint_auto_find_test_dependencies() +endif() + + +install( + TARGETS generate_configs + DESTINATION lib/${PROJECT_NAME} +) + +ament_package() diff --git a/zenoh_security_tools/README.md b/zenoh_security_tools/README.md new file mode 100644 index 00000000..b21cf2c5 --- /dev/null +++ b/zenoh_security_tools/README.md @@ -0,0 +1,178 @@ +# zenoh_security_tools + +The `zenoh_security_tools` package contains the `generate_configs` executable which generates Zenoh session config files with access control, authentication and encryption parameters based on policies and keystores generated using [sros2](https://github.com/ros2/sros2). + +## Usage +```bash +ros2 run zenoh_security_tools generate_configs -h + +Generate Zenoh session configs with security artifacts. + +Options: + -h,--help Print this help message and exit + -p,--policy TEXT REQUIRED The path to the Access Control Policy file. + -e,--enclaves TEXT The directory with the security enclaves for the various nodes in the policy file. + -d,--ros-domain-id UINT REQUIRED The ROS Domain ID. + -c,--session-config TEXT REQUIRED The path to the Zenoh session config file. + -r,--router-config TEXT REQUIRED The path to the Zenoh router config file. + +``` + +## Example of configuring security rmw_zenoh + +The process of setting up security is very similar to [this tutorial](https://docs.ros.org/en/rolling/Tutorials/Advanced/Security/Introducing-ros2-security.html) but instead of relying on security environment variables and passing enclaves to nodes, we'll +pass Zenoh session configs with the desired security parameters configured to `rmw_zenoh`. +These modified session configs are generated using the tool above. + +### Setup + +The steps below will walk us through running rmw_zenoh with security enabled for a simple talker-lister system. + +#### First create a directory for security artifacts and configs that will be generated. + +```bash +mkdir ~/sros2_demo +``` + +#### Generate a keystore + +```bash +cd ~/sros2_demo +ros2 security create_keystore demo_keystore +``` + +#### Generate the certificates for authentication and encryption + +Generate security files for the `talker` and `listener` nodes, and the `zenohd` router respectively. + +```bash +ros2 security create_enclave demo_keystore /talker_listener/talker +ros2 security create_enclave demo_keystore /talker_listener/listener +ros2 security create_enclave demo_keystore /talker_listener/zenohd +``` + +#### Generate the policy.xml for access control + +Launch zenohd +```bash +ros2 run rmw_zenoh_cpp rmw_zenohd +``` + +Launch the listener +```bash +export RMW_IMPLEMENTATION=rmw_zenoh_cpp +ros2 run demo_nodes_cpp listener +``` + +Launch the talker +```bash +export RMW_IMPLEMENTATION=rmw_zenoh_cpp +ros2 run demo_nodes_cpp talker +``` + +Now run the policy generator from sros2 + +```bash +ros2 security generate_policy policy_listener_talker.xml +``` + +Finally, terminate all processes. + +## Try access control + +Generate security configs without enclaves (only access control). + +```bash +ros2 run zenoh_security_tools generate_configs \ + --policy policy_listener_talker.xml \ + --router-config /DEFAULT_RMW_ZENOH_ROUTER_CONFIG.json5 \ + --session-config /DEFAULT_RMW_ZENOH_SESSION_CONFIG.json5 \ + --ros-domain-id 0 +``` +This will generate Zenoh session config files for each node in the `policy_listener_talker.xml` file. + +#### Run the talker with the new config file + +```bash +export ZENOH_SESSION_CONFIG_URI=talker.json5 +ros2 run demo_nodes_cpp talker +[INFO] [1740601932.350808475] [talker]: Publishing: 'Hello World: 1' +[INFO] [1740601933.350487483] [talker]: Publishing: 'Hello World: 2' +``` + +#### Run the listener with the new config file +```bash +export ZENOH_SESSION_CONFIG_URI=listener.json5 +ros2 run demo_nodes_cpp listener +... +[INFO] [1740602312.492840958] [listener]: I heard: [Hello World: 1] +[INFO] [1740602313.492200366] [listener]: I heard: [Hello World: 2] +``` + +You can validate access control by remapping the `/chatter` topic which should result in no messages being published. + + +```bash +export ZENOH_SESSION_CONFIG_URI=talker.json5 +ros2 rmw_zenoh_cpp rmw_zenohd +``` + +```bash +export ZENOH_SESSION_CONFIG_URI=talker.json5 +ros2 run demo_nodes_cpp talker --ros-args -r chatter:=new_topic +``` + +```bash +export ZENOH_SESSION_CONFIG_URI=listener.json5 +ros2 run demo_nodes_cpp listener --ros-args -r chatter:=new_topic +... +# listener should not receive anything +``` + +## Try access control, authentication and encryption + +This time we generate the configs with authentication and encryption configured using the enclaves generated by sros2. + +```bash +ros2 run zenoh_security_tools generate_configs \ + --policy policy_listener_talker.xml \ + --router-config /DEFAULT_RMW_ZENOH_ROUTER_CONFIG.json5 \ + --session-config /DEFAULT_RMW_ZENOH_SESSION_CONFIG.json5 \ + --ros-domain-id 0 + --enclaves ~/sros2_demo/demo_keystore/enclaves/talker_listener +``` + +> [!NOTE] +The executable assumes that the `~/sros2_demo/demo_keystore/enclaves/talker_listener` directory contains folders with names matching node names defined in the `policy_listener_talker.xml` with the security files present. + +Start the zenoh router with the `zenohd.json` config file. +```bash +export ZENOH_ROUTER_CONFIG_URI=zenohd.json5 +ros2 rmw_zenoh_cpp rmw_zenohd +``` + +Start the talker + +```bash +export ZENOH_SESSION_CONFIG_URI=talker.json5 +ros2 rmw_zenoh_cpp rmw_zenohd +``` + +Start the listener without setting the session config. +```bash +ros2 run demo_nodes_cpp listener +``` + +The listener will not receive any messages. + +Restart the listener with the session config. + +```bash +export ZENOH_SESSION_CONFIG_URI=listener.json5 +ros2 run demo_nodes_cpp listener +... +[INFO] [1740602312.492840958] [listener]: I heard: [Hello World: 10] +[INFO] [1740602313.492200366] [listener]: I heard: [Hello World: 11] +``` + +The messages are received by the listener. diff --git a/zenoh_security_tools/package.xml b/zenoh_security_tools/package.xml new file mode 100644 index 00000000..3ce35a54 --- /dev/null +++ b/zenoh_security_tools/package.xml @@ -0,0 +1,26 @@ + + + + zenoh_security_tools + 0.5.0 + This package generates config files to enforce security with Zenoh + Alejandro Hernanadez + Apache License 2.0 + + + nlohmann-json-dev + + rcpputils + rcutils + rmw + rmw_security_common + tinyxml2_vendor + zenoh_cpp_vendor + + ament_lint_auto + ament_lint_common + + + ament_cmake + + diff --git a/zenoh_security_tools/src/config_generator.cpp b/zenoh_security_tools/src/config_generator.cpp new file mode 100644 index 00000000..af3efd36 --- /dev/null +++ b/zenoh_security_tools/src/config_generator.cpp @@ -0,0 +1,617 @@ +// Copyright (c) 2025, Open Source Robotics Foundation, Inc. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// * Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +#include "config_generator.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "rcpputils/scope_exit.hpp" +#include "rcutils/allocator.h" +#include "rcutils/types/string_map.h" +#include "rmw_security_common/security.hpp" + +#include + +static const char * root_str = "policy"; +static const char * enclaves_str = "enclaves"; +static const char * enclave_str = "enclave"; +static const char * profiles_str = "profiles"; +static const char * profile_str = "profile"; +static const char * router_str = "zenohd"; +static const char * services_str = "services"; +static const char * service_str = "service"; +static const char * topics_str = "topics"; +static const char * topic_str = "topic"; + +using json = nlohmann::json; + +namespace zenoh_security_tools +{ +//============================================================================== +ConfigGenerator::ConfigGenerator( + const std::string & policy_filepath, + const std::string & enclaves_dir, + const std::string & zenoh_router_config_filepath, + const std::string & zenoh_session_config_filepath, + uint8_t domain_id) +: enclaves_dir_(std::nullopt), + zenoh_router_config_filepath_(std::move(zenoh_router_config_filepath)), + zenoh_session_config_filepath_(std::move(zenoh_session_config_filepath)), + domain_id_(std::move(domain_id)) +{ + const tinyxml2::XMLError error = doc_.LoadFile(policy_filepath.c_str()); + if (error != tinyxml2::XML_SUCCESS) { + throw std::runtime_error("Invalid argument: wrong policy file."); + } + + if (!enclaves_dir.empty()) { + std::filesystem::path maybe_dir{enclaves_dir}; + if (std::filesystem::is_directory(enclaves_dir)) { + enclaves_dir_ = std::move(maybe_dir); + } + } +} + +namespace +{ +//============================================================================== +bool replace( + std::string & str, + const std::string & from, + const std::string & to) +{ + size_t start_pos = str.find(from); + if(start_pos == std::string::npos) { + return false; + } + str.replace(start_pos, from.length(), to); + return true; +} + +//============================================================================== +json to_key_exprs( + const std::set & key_exprs, + uint8_t domain_id) +{ + json key_exprs_ret = json::array(); + + for (const auto & name : key_exprs) { + key_exprs_ret.push_back(std::to_string(domain_id) + "/" + name + "/**"); + } + + return key_exprs_ret; +} + +//============================================================================== +std::string check_name( + const std::string & name, + const std::string & node_name) +{ + std::string result = name; + replace(result, "~", node_name); + if (result[0] == '/') { + result = result.substr(1); + } + return result; +} +} // anonymous namespace + +//============================================================================== +void ConfigGenerator::parse_services( + const tinyxml2::XMLElement * root, + const std::string & node_name) +{ + const tinyxml2::XMLElement * services_node = root->FirstChildElement(); + do{ + if (services_node != nullptr) { + if (strcmp(services_node->Name(), services_str) == 0) { + std::string service_type; + const char * permission_s = services_node->Attribute("reply"); + if (permission_s != nullptr) { + service_type = "reply"; + } else { + permission_s = services_node->Attribute("request"); + if (permission_s != nullptr) { + service_type = "request"; + } + } + + if (permission_s == nullptr) { + throw std::runtime_error("Not able to get permission from service " + + std::to_string(services_node->GetLineNum())); + } + std::string permission = permission_s; + + const tinyxml2::XMLElement * service_node = services_node->FirstChildElement(); + do { + if (service_node != nullptr) { + if (strcmp(service_node->Name(), service_str) == 0) { + if (service_type == "reply") { + if (permission == "ALLOW") { + services_reply_allow_.insert(check_name(service_node->GetText(), node_name)); + } else if (permission == "DENY") { + services_reply_deny_.insert(check_name(service_node->GetText(), node_name)); + } + } else if (service_type == "request") { + if (permission == "ALLOW") { + services_request_allow_.insert(check_name(service_node->GetText(), node_name)); + } else if (permission == "DENY") { + services_request_deny_.insert(check_name(service_node->GetText(), node_name)); + } + } + } else { + throw std::runtime_error("Invalid file"); + } + } + } while ((service_node = service_node->NextSiblingElement()) != nullptr); + } + } else { + throw std::runtime_error("Invalid file"); + } + } while ((services_node = services_node->NextSiblingElement()) != nullptr); +} + +//============================================================================== +void ConfigGenerator::clear() +{ + services_reply_allow_.clear(); + services_reply_deny_.clear(); + services_request_allow_.clear(); + services_request_deny_.clear(); + topics_sub_allow_.clear(); + topics_pub_allow_.clear(); + topics_sub_deny_.clear(); + topics_pub_deny_.clear(); +} + +//============================================================================== +void ConfigGenerator::fill_access_control( + zenoh::Config & config, + const std::string & node_name) +{ + json rules = json::array(); + json policies_rules = json::array(); + + if (!services_reply_allow_.empty()) { + json rule_allow_reply = json::object({ + {"id", "incoming_queries"}, + {"messages", json::array({"query"})}, + {"flows", json::array({"ingress"})}, + {"permission", "allow"}, + {"key_exprs", to_key_exprs(services_reply_allow_, domain_id_)}, + }); + rules.push_back(rule_allow_reply); + policies_rules.push_back("incoming_queries"); + + json rule_outgoing_reply = json::object({ + {"id", "outgoing_queryables_replies"}, + {"messages", json::array({"declare_queryable", "reply"})}, + {"flows", json::array({"egress"})}, + {"permission", "allow"}, + {"key_exprs", to_key_exprs(services_reply_allow_, domain_id_)}, + }); + rules.push_back(rule_outgoing_reply); + policies_rules.push_back("outgoing_queryables_replies"); + } + + if (!services_request_allow_.empty()) { + json rule_allow_request_out = json::object({ + {"id", "outgoing_queries"}, + {"messages", json::array({"query"})}, + {"flows", json::array({"egress"})}, + {"permission", "allow"}, + {"key_exprs", to_key_exprs(services_request_allow_, domain_id_)}, + }); + rules.push_back(rule_allow_request_out); + policies_rules.push_back("outgoing_queries"); + + json rule_allow_request_in = json::object({ + {"id", "incoming_queryables_replies"}, + {"messages", json::array({"declare_queryable", "reply"})}, + {"flows", json::array({"ingress"})}, + {"permission", "allow"}, + {"key_exprs", to_key_exprs(services_request_allow_, domain_id_)}, + }); + rules.push_back(rule_allow_request_in); + policies_rules.push_back("incoming_queryables_replies"); + } + + if (!topics_pub_allow_.empty()) { + json rule_allow_pub_out = json::object({ + {"id", "outgoing_publications"}, + {"messages", json::array({"put"})}, + {"flows", json::array({"egress"})}, + {"permission", "allow"}, + {"key_exprs", to_key_exprs(topics_pub_allow_, domain_id_)}, + }); + rules.push_back(rule_allow_pub_out); + policies_rules.push_back("outgoing_publications"); + + json rule_allow_pub_in = json::object({ + {"id", "incoming_subscriptions"}, + {"messages", json::array({"declare_subscriber"})}, + {"flows", json::array({"ingress"})}, + {"permission", "allow"}, + {"key_exprs", to_key_exprs(topics_pub_allow_, domain_id_)}, + }); + rules.push_back(rule_allow_pub_in); + policies_rules.push_back("incoming_subscriptions"); + } + + if (!topics_sub_allow_.empty()) { + json rule_allow_sub_out = json::object({ + {"id", "outgoing_subscriptions"}, + {"messages", json::array({"declare_subscriber"})}, + {"flows", json::array({"egress"})}, + {"permission", "allow"}, + {"key_exprs", to_key_exprs(topics_sub_allow_, domain_id_)}, + }); + rules.push_back(rule_allow_sub_out); + policies_rules.push_back("outgoing_subscriptions"); + + json rule_allow_sub_in = json::object({ + {"id", "incoming_publications"}, + {"messages", json::array({"put"})}, + {"flows", json::array({"ingress"})}, + {"permission", "allow"}, + {"key_exprs", to_key_exprs(topics_sub_allow_, domain_id_)}, + }); + rules.push_back(rule_allow_sub_in); + policies_rules.push_back("incoming_publications"); + } + + json liveliness_messages = json::array({ + "liveliness_token", "liveliness_query", "declare_liveliness_subscriber"}); + if (!services_reply_allow_.empty() || !services_request_allow_.empty()) { + liveliness_messages.push_back("reply"); + } + + json rule_liveliness = json::object({ + {"id", "liveliness_tokens"}, + {"messages", liveliness_messages}, + {"flows", json::array({"ingress", "egress"})}, + {"permission", "allow"}, + {"key_exprs", + json::array({"@ros2_lv/" + std::to_string(domain_id_) + "/**"})}, + }); + rules.push_back(rule_liveliness); + policies_rules.push_back("liveliness_tokens"); + + json policies = json::array(); + policies.push_back(json::object({ + {"rules", json::array({"liveliness_tokens"})}, + {"subjects", json::array({"router"})}, + })); + policies.push_back(json::object({ + {"rules", policies_rules}, + {"subjects", json::array({node_name})}, + })); + + json subjects = json::array({ + json::object({{"id", "router"}}), + json::object({{"id", node_name}}), + }); + + config.insert_json5("access_control/enabled", "true"); + config.insert_json5("access_control/default_permission", "'deny'"); + config.insert_json5("access_control/rules", rules.dump()); + config.insert_json5("access_control/policies", policies.dump()); + config.insert_json5("access_control/subjects", subjects.dump()); +} + +//============================================================================== +void ConfigGenerator::fill_certificates( + zenoh::Config & config, + const std::string & node_name) +{ + // Skip this step if enclaves directory was not specified. + if (!enclaves_dir_.has_value()) { + return; + } + auto enclaves_dir = enclaves_dir_.value(); + auto enclave_dir = enclaves_dir / node_name; + if (!std::filesystem::is_directory(enclaves_dir)) { + std::cout << "No directory with name " + << node_name + << " present within enclaves directory " + << enclaves_dir.string() + << ". Skipping authentication..." + << std::endl; + return; + } + + // Access the certificates using utility function from rmw_security_common. + rcutils_allocator_t allocator = rcutils_get_default_allocator(); + rcutils_string_map_t security_files = rcutils_get_zero_initialized_string_map(); + rcutils_ret_t ret = rcutils_string_map_init(&security_files, 0, allocator); + auto scope_exit = rcpputils::make_scope_exit( + [&security_files]() { + rcutils_ret_t ret = rcutils_string_map_fini(&security_files); + if (ret != RMW_RET_OK) { + std::cerr << "Failed to fini string map for security." << std::endl; + return; + } + }); + if (ret != RMW_RET_OK) { + std::cerr << "Failed to initialize string map for security." << std::endl; + return; + } + if (get_security_files_support_pkcs( + false, "", enclave_dir.string().c_str(), &security_files) != RMW_RET_OK) + { + std::cerr << "Failed to get certificates for " << node_name << " from" << + enclave_dir.string().c_str() << std::endl; + return; + } + + // TODO(Yadunund): Actually check if some of these configs are already set and only update + // their values as opposed to overwriting. + try { + json tls_config_json = { + {"link", { + {"protocols", json::array({"tls"})}, + {"tls", { + {"enable_mtls", true}, + {"verify_name_on_connect", false}, + {"root_ca_certificate", + std::string(rcutils_string_map_get(&security_files, "IDENTITY_CA"))}, + {"listen_private_key", + std::string(rcutils_string_map_get(&security_files, "PRIVATE_KEY"))}, + {"listen_certificate", + std::string(rcutils_string_map_get(&security_files, "CERTIFICATE"))}, + {"connect_private_key", + std::string(rcutils_string_map_get(&security_files, "PRIVATE_KEY"))}, + {"connect_certificate", + std::string(rcutils_string_map_get(&security_files, "CERTIFICATE"))} + }} + }} + }; + // Insert the config. + config.insert_json5("transport", tls_config_json.dump()); + } catch (const std::exception & e) { + std::cerr << "Error creating tls_config_json: " << e.what() << std::endl; + return; + } + + auto replace_with_tls = + [](const std::string & key, zenoh::Config & config) -> void + { + try { + const json endpoints = json::parse(config.get(key)); + json tls_endpoints_json = json::array(); + for (const json & endpoint : endpoints) { + std::string endpoint_str = endpoint.get(); + const std::size_t slash_pos = endpoint_str.find('/'); + if (slash_pos != std::string::npos) { + tls_endpoints_json.push_back(endpoint_str.replace(0, slash_pos, "tls")); + } + } + config.insert_json5(key, tls_endpoints_json.dump()); + } catch (const std::exception & e) { + std::cerr << "Error replacing transport with tls: " << e.what() << std::endl; + return; + } + }; + + replace_with_tls("connect/endpoints", config); + replace_with_tls("listen/endpoints", config); +} + +//============================================================================== +void ConfigGenerator::parse_topics( + const tinyxml2::XMLElement * root, + const std::string & node_name) +{ + const tinyxml2::XMLElement * topics_node = root->FirstChildElement(); + do{ + if (topics_node != nullptr) { + if (strcmp(topics_node->Name(), topics_str) == 0) { + std::string topic_type; + const char * permission_s = topics_node->Attribute("subscribe"); + if (permission_s != nullptr) { + topic_type = "subscribe"; + } else { + permission_s = topics_node->Attribute("publish"); + if (permission_s != nullptr) { + topic_type = "publish"; + } + } + + if (permission_s == nullptr) { + throw std::runtime_error("Not able to get permission from service " + + std::to_string(topics_node->GetLineNum())); + } + std::string permission = permission_s; + + const tinyxml2::XMLElement * topic_node = topics_node->FirstChildElement(); + do { + if (topic_node != nullptr) { + if (strcmp(topic_node->Name(), topic_str) == 0) { + if (topic_type == "publish") { + if (permission == "ALLOW") { + topics_pub_allow_.insert(check_name(topic_node->GetText(), node_name)); + } else if (permission == "DENY") { + topics_pub_allow_.insert(check_name(topic_node->GetText(), node_name)); + } + } else if (topic_type == "subscribe") { + if (permission == "ALLOW") { + topics_sub_allow_.insert(check_name(topic_node->GetText(), node_name)); + } else if (permission == "DENY") { + topics_sub_deny_.insert(check_name(topic_node->GetText(), node_name)); + } + } + + } else { + throw std::runtime_error("Invalid file"); + } + } + } while ((topic_node = topic_node->NextSiblingElement()) != nullptr); + } + } else { + throw std::runtime_error("Invalid file"); + } + } while ((topics_node = topics_node->NextSiblingElement()) != nullptr); +} + +//============================================================================== +void ConfigGenerator::parse_profiles(const tinyxml2::XMLElement * root) +{ + const tinyxml2::XMLElement * profiles_node = root->FirstChildElement(); + do{ + if (profiles_node != nullptr) { + if (strcmp(profiles_node->Name(), profiles_str) == 0) { + const tinyxml2::XMLElement * profile_node = profiles_node->FirstChildElement(); + do { + if (profile_node != nullptr) { + if (strcmp(profile_node->Name(), profile_str) == 0) { + const char * node_name = profile_node->Attribute("node"); + if (node_name == nullptr) { + std::string error_msg = "Attribute name is required in " + + std::string(profile_str) + " tag. Line " + + std::to_string(profiles_node->GetLineNum()); + throw std::runtime_error(error_msg); + } + + zenoh::ZResult result; + zenoh::Config config = zenoh::Config::from_file(zenoh_session_config_filepath_, + &result); + if (result != Z_OK) { + std::string error_msg = "Invalid configuration file " + + zenoh_session_config_filepath_; + throw std::runtime_error("Error getting Zenoh session config file."); + } + + parse_services(profile_node, node_name); + parse_topics(profile_node, node_name); + + this->fill_access_control(config, node_name); + this->fill_certificates(config, node_name); + + std::string filename = std::string(node_name) + ".json5"; + std::ofstream new_config_file(filename); + json j_config = json::parse(config.to_string()); + new_config_file << j_config.dump(2); + std::cout << "New file create called " << filename << std::endl; + new_config_file.close(); + + this->clear(); + } + } else { + throw std::runtime_error("Invalid file"); + } + } while ((profile_node = profile_node->NextSiblingElement()) != nullptr); + } else { + std::string error_msg = "Invalid file: Malformed Zenoh policy root. Line: " + + std::to_string(profiles_node->GetLineNum()); + throw std::runtime_error(error_msg); + } + } else { + throw std::runtime_error("Invalid file"); + } + } while ((profiles_node = profiles_node->NextSiblingElement()) != nullptr); +} + +//============================================================================== +void ConfigGenerator::parse_enclaves(const tinyxml2::XMLElement * root) +{ + const tinyxml2::XMLElement * enclaves_node = root->FirstChildElement(); + if (enclaves_node != nullptr) { + if (strcmp(enclaves_node->Name(), enclaves_str) == 0) { + const tinyxml2::XMLElement * enclave_node = enclaves_node->FirstChildElement(); + if (enclave_node != nullptr) { + if (strcmp(enclave_node->Name(), enclave_str) == 0) { + parse_profiles(enclave_node); + } + } else { + throw std::runtime_error("Invalid file"); + } + } else { + std::string error_msg = "Invalid file: Malformed Zenoh policy root. Line: " + + std::to_string(enclaves_node->GetLineNum()); + throw std::runtime_error(error_msg); + } + } else { + throw std::runtime_error("Invalid file"); + } +} + +//============================================================================== +void ConfigGenerator::generate_router_config() +{ + zenoh::ZResult result; + zenoh::Config config = zenoh::Config::from_file(zenoh_router_config_filepath_, &result); + if (result != Z_OK) { + std::string error_msg = "Invalid configuration file " + zenoh_session_config_filepath_; + throw std::runtime_error("Error getting Zenoh router config file."); + } + + this->fill_certificates(config, router_str); + + std::string filename = std::string(router_str) + ".json5"; + std::ofstream new_config_file(filename); + json j_config = json::parse(config.to_string()); + new_config_file << j_config.dump(2); + std::cout << "New file create called " << filename << std::endl; + new_config_file.close(); +} + +//============================================================================== +void ConfigGenerator::generate_session_configs() +{ + const tinyxml2::XMLElement * root = doc_.RootElement(); + if (root != nullptr) { + if (strcmp(root->Name(), root_str) == 0) { + parse_enclaves(root); + } else { + std::string error_msg = "Invalid file: Malformed Zenoh policy root. Line: " + + std::to_string(root->GetLineNum()); + throw std::runtime_error(error_msg); + } + } else { + throw std::runtime_error("Invalid file"); + } +} + +//============================================================================== +void ConfigGenerator::generate() +{ + generate_session_configs(); + generate_router_config(); +} + +} // namespace zenoh_security_tools diff --git a/zenoh_security_tools/src/config_generator.hpp b/zenoh_security_tools/src/config_generator.hpp new file mode 100644 index 00000000..859b8fa7 --- /dev/null +++ b/zenoh_security_tools/src/config_generator.hpp @@ -0,0 +1,100 @@ +// Copyright (c) 2025, Open Source Robotics Foundation, Inc. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// * Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +#ifndef CONFIG_GENERATOR_HPP_ +#define CONFIG_GENERATOR_HPP_ + +#include + +#include +#include +#include +#include +#include + +#include + +namespace zenoh_security_tools +{ +//============================================================================== +/** + * This class parses the ROS 2 secutiry policy files into json5 Zenoh Config files + **/ +class ConfigGenerator +{ +public: + /// The library is loaded in the constructor. + /** + * \param[in] policy_filepath The policy string path. + * \throws std::runtime_error if there are some invalid arguments or the library + * was not load properly + */ + ConfigGenerator( + const std::string & policy_filepath, + const std::string & enclaves_dir, + const std::string & zenoh_router_config_filepath, + const std::string & zenoh_session_config_filepath, + uint8_t domain_id); + + void generate(); + +private: + void generate_router_config(); + void generate_session_configs(); + void parse_enclaves(const tinyxml2::XMLElement * root); + void parse_profiles(const tinyxml2::XMLElement * root); + void parse_services(const tinyxml2::XMLElement * root, const std::string & node_name); + void parse_topics(const tinyxml2::XMLElement * root, const std::string & node_name); + void clear(); + void fill_access_control( + zenoh::Config & config, + const std::string & node_name); + void fill_certificates( + zenoh::Config & config, + const std::string & node_name); + + tinyxml2::XMLDocument doc_; + std::optional enclaves_dir_; + std::string zenoh_router_config_filepath_; + std::string zenoh_session_config_filepath_; + + std::set services_reply_allow_; + std::set services_reply_deny_; + std::set services_request_allow_; + std::set services_request_deny_; + + std::set topics_sub_allow_; + std::set topics_pub_allow_; + std::set topics_sub_deny_; + std::set topics_pub_deny_; + + uint8_t domain_id_; +}; +} // namespace zenoh_security_tools + +#endif // CONFIG_GENERATOR_HPP_ diff --git a/zenoh_security_tools/src/main.cpp b/zenoh_security_tools/src/main.cpp new file mode 100644 index 00000000..e6f061a5 --- /dev/null +++ b/zenoh_security_tools/src/main.cpp @@ -0,0 +1,158 @@ +// Copyright (c) 2025, Open Source Robotics Foundation, Inc. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// * Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +#include +#include +#include +#include +#include +#include + +#include "config_generator.hpp" + +namespace +{ +//============================================================================== +struct CommandLineArgs +{ + bool help = false; + std::optional policy_filepath; + std::optional enclaves_dir; + std::optional ros_domain_id; + std::optional zenoh_session_config_filepath; + std::optional zenoh_router_config_filepath; +}; + +//============================================================================== +void print_help() +{ + std::cout << "Usage: ros2 run zenoh_security_tools generate_configs [options]\n\n" + << "Generate Zenoh session and router configs with security artifacts.\n\n" + << "Options:\n" + << " -h,--help Print this help message and exit\n" + << " -p,--policy TEXT REQUIRED The path to the Access Control Policy file.\n" + << " -e,--enclaves TEXT The directory with the security enclaves " + << "for the various nodes in the policy file.\n" + << " -d,--ros-domain-id UINT REQUIRED The ROS Domain ID.\n" + << " -c,--session-config TEXT REQUIRED The path to the Zenoh session config file.\n" + << " -r,--router-config TEXT REQUIRED The path to the Zenoh router config file.\n" + << std::endl; +} + +//============================================================================== +std::optional parse_uint(const std::string & s) +{ + try { + size_t pos = 0; + uint8_t n = std::stoul(s, &pos); + if (pos == s.length()) { + return n; + } + } catch (const std::invalid_argument &) { + return {}; + } catch (const std::out_of_range &) { + return {}; + } + return {}; +} + +} // namespace + +//============================================================================== +int main(int argc, char *argv[]) +{ + CommandLineArgs args; + std::vector raw_args(argv + 1, argv + argc); + + for (size_t i = 0; i < raw_args.size(); ++i) { + const std::string & arg = raw_args[i]; + + if (arg == "-h" || arg == "--help") { + args.help = true; + } else if ((arg == "-p" || arg == "--policy") && i + 1 < raw_args.size()) { + args.policy_filepath = raw_args[++i]; + } else if ((arg == "-e" || arg == "--enclaves") && i + 1 < raw_args.size()) { + args.enclaves_dir = raw_args[++i]; + } else if ((arg == "-d" || arg == "--ros-domain-id") && i + 1 < raw_args.size()) { + auto value = parse_uint(raw_args[++i]); + if (value) { + args.ros_domain_id = value.value(); + } else { + std::cerr << "Error: Invalid value for --ros-domain-id: " << raw_args[i] << std::endl; + print_help(); + return 1; + } + } else if ((arg == "-c" || arg == "--session-config") && i + 1 < raw_args.size()) { + args.zenoh_session_config_filepath = raw_args[++i]; + } else if ((arg == "-r" || arg == "--router-config") && i + 1 < raw_args.size()) { + args.zenoh_router_config_filepath = raw_args[++i]; + } else { + std::cerr << "Error: Unknown option: " << arg << std::endl; + print_help(); + return 1; + } + } + + if (args.help) { + print_help(); + return 0; + } + + if (!args.policy_filepath) { + std::cerr << "Error: --policy is required." << std::endl; + print_help(); + return 1; + } + + if (!args.ros_domain_id) { + std::cerr << "Error: --ros-domain-id is required." << std::endl; + print_help(); + return 1; + } + + if (!args.zenoh_session_config_filepath) { + std::cerr << "Error: --session-config is required." << std::endl; + print_help(); + return 1; + } + + if (!args.zenoh_router_config_filepath) { + std::cerr << "Error: --router-config is required." << std::endl; + print_help(); + return 1; + } + + auto config_generator = zenoh_security_tools::ConfigGenerator( + args.policy_filepath.value(), + args.enclaves_dir.value(), + args.zenoh_router_config_filepath.value(), + args.zenoh_session_config_filepath.value(), + args.ros_domain_id.value()); + config_generator.generate(); + return 0; +}