Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/opcua-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,89 @@ jobs:
echo "=== ${c} logs ==="
docker logs "${c}" 2>&1 | tail -80 || true
done

integration-secured:
name: Integration (Secured A&C)
# Issue #477: proves the OPC-UA client security code's
# apply_security_config() SUCCESS path - a real Basic256Sha256 +
# SignAndEncrypt SecureChannel with a username token against the
# encryption-enabled test_alarm_server fixture. Needs fault_manager_node
# built alongside the gateway, openssl for cert generation, and
# ROS2_MEDKIT_OPCUA_SECURE_REQUIRE=1 so the test runs for real instead of
# skipping with CTest code 77.
runs-on: ubuntu-latest
container:
image: ubuntu:noble
timeout-minutes: 60
defaults:
run:
shell: bash
steps:
- name: Install Git
run: |
apt-get update
apt-get install -y git

- name: Checkout repository
uses: actions/checkout@v4

- name: Pre-install ROS 2 apt source
uses: ./.github/actions/ros-apt-source

- name: Set up ROS 2 jazzy
uses: ros-tooling/setup-ros@v0.7
with:
required-ros-distributions: jazzy

- name: Install ccache
run: apt-get install -y ccache

- name: Cache ccache
uses: actions/cache@v4
with:
path: /root/.cache/ccache
key: ccache-opcua-secured-${{ github.sha }}
restore-keys: |
ccache-opcua-secured-

- name: Install dependencies
run: |
apt-get update
# openssl: cert generation. libssl-dev: encryption backend for both
# the plugin's open62541pp and the fixture's static open62541.
apt-get install -y ros-jazzy-test-msgs libyaml-cpp-dev libssl-dev openssl
source /opt/ros/jazzy/setup.bash
rosdep update
rosdep install --from-paths src --ignore-src -y \
--skip-keys='nav2_msgs ament_cmake_clang_format ament_cmake_clang_tidy'

- name: Build gateway + OPC-UA plugin + fault_manager
env:
CCACHE_DIR: /root/.cache/ccache
CCACHE_MAXSIZE: 500M
CCACHE_SLOPPINESS: pch_defines,time_macros
run: |
source /opt/ros/jazzy/setup.bash
# fault_manager_node is a sibling package the secured test launches;
# it is not a build dependency of the plugin, so name it explicitly.
colcon build --symlink-install \
--packages-up-to ros2_medkit_opcua ros2_medkit_fault_manager \
--cmake-args -DCMAKE_BUILD_TYPE=Release \
--event-handlers console_direct+
ccache -s

- name: Run secured A&C integration test
timeout-minutes: 15
env:
ROS2_MEDKIT_OPCUA_SECURE_REQUIRE: '1'
run: |
source /opt/ros/jazzy/setup.bash
source install/setup.bash
colcon test --return-code-on-test-failure \
--packages-select ros2_medkit_opcua \
--ctest-args -R test_opcua_secured \
--event-handlers console_direct+

- name: Show test results
if: always()
run: colcon test-result --verbose
117 changes: 117 additions & 0 deletions src/ros2_medkit_gateway/config/gateway_params.secure.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# ROS 2 Medkit Gateway - secure field profile
#
# Hardened parameter preset for on-prem / plant-network (appliance)
# deployments. It turns ON every control that the default development config
# leaves OFF: JWT auth, TLS, restricted CORS, and rate limiting. Use this file
# instead of gateway_params.yaml for any deployment reachable from an
# untrusted network.
#
# ros2 run ros2_medkit_gateway gateway_node \
# --ros-args --params-file gateway_params.secure.yaml
#
# Items marked "REQUIRED" below must be set before the gateway will start /
# accept clients. See design/hardening.rst for the full checklist and
# credential / certificate provisioning steps.

ros2_medkit_gateway:
ros__parameters:
server:
# Appliance binds all interfaces; TLS + auth below protect the surface.
# Narrow to a management interface IP when the deployment allows it.
host: "0.0.0.0"
port: 8443

# TLS/HTTPS - REQUIRED. Provision a real certificate (see
# scripts/generate_dev_certs.sh for the dev-only equivalent). The key
# file must be chmod 600 and owned by the gateway service user.
tls:
enabled: true
cert_file: "/etc/ros2_medkit/certs/cert.pem" # REQUIRED
key_file: "/etc/ros2_medkit/certs/key.pem" # REQUIRED (chmod 600)
ca_file: ""
# 1.3 preferred on a controlled fleet; drop to 1.2 only for legacy
# clients that cannot negotiate 1.3.
min_version: "1.3"

# CORS - restrict to the explicit origins that serve the operator UI.
# Never use ["*"] with credentials. Empty list disables CORS entirely
# (correct for API-only appliances with no browser UI).
cors:
allowed_origins: ["https://medkit-ui.local"] # REQUIRED if a browser UI is used; else [""]
allowed_methods: ["GET", "PUT", "POST", "DELETE", "OPTIONS"]
allowed_headers: ["Content-Type", "Accept", "Authorization"]
allow_credentials: true
max_age_seconds: 600

# Authentication (JWT + RBAC) - REQUIRED on.
auth:
enabled: true
# HS256 shared secret (>= 32 chars) or, for RS256, the private key path.
# REQUIRED - inject from a secret store / env at deploy time; do NOT
# commit a real secret to source control.
jwt_secret: "" # REQUIRED
jwt_public_key: ""
jwt_algorithm: "HS256"
token_expiry_seconds: 3600
refresh_token_expiry_seconds: 86400
# "all" forces auth on every request (reads + writes). Use "write" only
# when unauthenticated reads are explicitly acceptable on this network.
require_auth_for: "all"
issuer: "ros2_medkit_gateway"
# Provision the minimum set of role-scoped clients. Format:
# "client_id:client_secret:role" (roles: viewer/operator/configurator/admin).
# REQUIRED - replace with real, rotated credentials.
clients: [""] # REQUIRED

# Rate limiting - ON to bound abuse / runaway clients.
rate_limiting:
enabled: true
global_requests_per_minute: 600
client_requests_per_minute: 120
# Tighten mutating endpoints (operations / data writes).
endpoint_limits: ["/api/v1/*/operations/*:30"]
client_cleanup_interval_seconds: 300
client_max_idle_seconds: 600

# Diagnostic scripts - disabled by default on an appliance. Enable
# deliberately and keep uploads off (manifest-defined scripts only).
scripts:
scripts_dir: ""
allow_uploads: false
max_file_size_mb: 10
max_concurrent_executions: 5
default_timeout_sec: 300
max_execution_history: 100

# Bulk data uploads - cap the payload size; raise only if the deployment
# genuinely needs large uploads.
bulk_data:
storage_dir: "/var/lib/ros2_medkit/bulk_data"
max_upload_size: 26214400 # 25 MiB
categories: [""]

# SOVD resource locking on, so concurrent operators cannot stamp on each
# other's mutations.
locking:
enabled: true
default_max_expiration: 3600
cleanup_interval: 30
defaults:
components:
lock_required_scopes: ["operations"]
breakable: true
apps:
lock_required_scopes: ["operations"]
breakable: true

# OpenAPI /docs endpoints off on a hardened appliance (reduce surface).
docs:
enabled: false

# Peer aggregation: if used across hosts, require TLS and do NOT forward
# client tokens to mDNS-discovered peers unless every peer is trusted.
aggregation:
enabled: false
require_tls: true
forward_auth: false
peer_scheme: "https"
124 changes: 124 additions & 0 deletions src/ros2_medkit_gateway/design/hardening.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
Gateway hardening (secure field profile)
========================================

The gateway ships every transport and access control needed for a hardened
deployment - JWT authentication with RBAC, TLS/HTTPS, restricted CORS, and
token-bucket rate limiting - but they are **disabled by default** so local
development works out of the box. A gateway exposed on a plant network with the
defaults is wide open: unauthenticated reads and writes over cleartext HTTP.

For any deployment reachable from an untrusted network, start from the secure
field profile preset ``config/gateway_params.secure.yaml`` instead of
``config/gateway_params.yaml``:

.. code-block:: bash

ros2 run ros2_medkit_gateway gateway_node \
--ros-args --params-file gateway_params.secure.yaml \
-p auth.jwt_secret:="$MEDKIT_JWT_SECRET" \
-p 'auth.clients:=["operator:'"$OP_SECRET"':operator"]'

What the secure profile turns on
--------------------------------

================================ ============== ===========================================
Control Default Secure profile
================================ ============== ===========================================
``auth.enabled`` false true
``auth.require_auth_for`` write all (auth on reads + writes)
``server.tls.enabled`` false true (HTTPS, min TLS 1.3)
``cors.allowed_origins`` ``[]`` explicit origin list (no wildcard)
``rate_limiting.enabled`` false true (global + per-client + per-endpoint)
``scripts.allow_uploads`` true false (manifest-defined scripts only)
``docs.enabled`` true false (reduced surface)
``bulk_data.max_upload_size`` 100 MiB 25 MiB
``locking`` on operations none lock required before mutation
================================ ============== ===========================================

Credential and certificate provisioning
----------------------------------------

1. **TLS certificate.** Provision a real server certificate + private key and
point ``server.tls.cert_file`` / ``server.tls.key_file`` at them. The key
file must be ``chmod 600`` and owned by the gateway service user. For a
dev/test box only, ``scripts/generate_dev_certs.sh`` emits a self-signed
``cert.pem`` / ``key.pem`` / ``ca.pem`` (never use these in production).

2. **JWT secret.** Generate a high-entropy secret of at least 32 characters
Comment thread
mfaferek93 marked this conversation as resolved.
(HS256) or provision an RS256 key pair. Inject it at deploy time from a
secret store or environment variable - do not commit it to source control.

.. warning::

``auth.jwt_secret`` is a plain readable ROS 2 parameter. Beyond source
control it is exposed on two planes:

- **DDS control plane.** Any peer on the ROS 2 graph can read it with
``ros2 param get /<gateway_node> auth.jwt_secret`` - the parameter is
declared readable and the DDS domain is unauthenticated by default.
- **Process table.** Passing it inline (``-p auth.jwt_secret:=...`` as in
the launch example above) also leaks the value via ``ps`` and
``/proc/<pid>/cmdline``.

Injecting from an environment variable / params file instead of an inline
``-p`` closes the process-table leak, but the value still lands in a
readable parameter, so it remains exposed on the DDS plane. To close that,
lock down the control plane: ROS 2 security (SROS2) with an access-control
policy that denies parameter reads to untrusted participants, or a
dedicated / firewalled ``ROS_DOMAIN_ID`` (optionally with
``ROS_LOCALHOST_ONLY=1``) that no untrusted peer can join.

3. **Role-scoped clients.** Create the minimum set of clients in
``auth.clients`` (``client_id:client_secret:role``). Roles, least to most
privileged: ``viewer`` (read), ``operator`` (+ trigger ops / ack faults /
publish), ``configurator`` (+ modify configs), ``admin`` (+ auth
management). Rotate secrets periodically.

4. **Obtain a token** and call the API over HTTPS:

.. code-block:: bash

curl -sk -X POST https://gateway:8443/api/v1/auth/authorize \
-H 'Content-Type: application/json' \
-d '{"client_id":"operator","client_secret":"...","grant_type":"client_credentials"}'
# use the returned access_token:
curl -sk https://gateway:8443/api/v1/faults -H "Authorization: Bearer $TOKEN"

Hardening checklist
-------------------

Before exposing a gateway on a shared / plant network, confirm:

- [ ] ``auth.enabled: true`` and ``auth.require_auth_for`` is ``all`` (or
``write`` only if unauthenticated reads are explicitly acceptable).
- [ ] ``auth.jwt_secret`` is set to a >= 32-char secret injected from a secret
store (not the placeholder, not in version control).
- [ ] ``auth.clients`` lists only the role-scoped clients you need; default /
example credentials removed; secrets rotated.
- [ ] ``server.tls.enabled: true`` with a real certificate; private key is
``chmod 600``; ``min_version`` is ``1.3`` (or ``1.2`` only for legacy
clients).
- [ ] ``cors.allowed_origins`` is an explicit list (no ``*``); ``*`` is never
combined with ``allow_credentials: true``.
- [ ] ``rate_limiting.enabled: true`` with per-client and mutating-endpoint
limits tuned to the deployment.
- [ ] ``scripts.allow_uploads: false`` unless remote script upload is a
required, reviewed capability.
- [ ] ``bulk_data.max_upload_size`` bounded to what the deployment needs.
- [ ] If peer aggregation is used: ``aggregation.require_tls: true`` and
``forward_auth`` only enabled when every peer is trusted.
- [ ] Bind ``server.host`` to a management interface where the network layout
allows, and place the gateway behind the plant firewall / segmentation.
- [ ] Back the gateway with persistent storage on a volume with restricted
permissions (faults DB, triggers DB, rosbag snapshots).

OPC-UA plugin (southbound) hardening
------------------------------------

The gateway controls the northbound REST surface; the OPC-UA plugin controls
the southbound connection to the PLC. Harden both. The plugin supports
SecurityPolicy (Basic256Sha256 / Aes128 / Aes256), MessageSecurityMode
(Sign / SignAndEncrypt), a client application-instance certificate, a server
trust store with reject-untrusted, and user identity (anonymous /
username-password / X.509). See ``ros2_medkit_opcua`` README, section
"OPC-UA client security".
1 change: 1 addition & 0 deletions src/ros2_medkit_gateway/design/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ Additional Design Documents
aggregation
dto_contract
entity_cache_architecture
hardening
lifecycle
plugin_entity_notifications
ros2_subscription_architecture
22 changes: 18 additions & 4 deletions src/ros2_medkit_gateway/src/gateway_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -496,8 +496,15 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki
.build();
// Note: HttpServerManager will log TLS configuration details
} catch (const std::exception & e) {
RCLCPP_ERROR(get_logger(), "Invalid TLS configuration: %s. TLS disabled.", e.what());
tls_config_ = TlsConfig{}; // Disabled
// Fail closed: TLS was explicitly requested but could not be built (e.g.
// missing/invalid cert or key). Refuse to start rather than silently
// serving plaintext HTTP. Disabling here would expose the API in the
// clear under a configuration that asked for encryption.
RCLCPP_FATAL(get_logger(),
"TLS is enabled (server.tls.enabled=true) but the TLS configuration is invalid: %s. "
"Refusing to start in plaintext - fix the certificate/key configuration or disable TLS.",
e.what());
throw std::runtime_error(std::string("Invalid TLS configuration: ") + e.what());
}
} else {
RCLCPP_INFO(get_logger(), "TLS/HTTPS: disabled");
Expand Down Expand Up @@ -551,8 +558,15 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki
algorithm_to_string(auth_config_.jwt_algorithm).c_str(),
get_parameter("auth.require_auth_for").as_string().c_str());
} catch (const std::exception & e) {
RCLCPP_ERROR(get_logger(), "Invalid authentication configuration: %s. Authentication disabled.", e.what());
auth_config_ = AuthConfig{}; // Disabled
// Fail closed: authentication was explicitly requested but could not be
// built (e.g. empty jwt_secret). Refuse to start rather than silently
// serving an unauthenticated API under a configuration that asked for
// auth.
RCLCPP_FATAL(get_logger(),
"Authentication is enabled (auth.enabled=true) but the auth configuration is invalid: %s. "
"Refusing to start unauthenticated - fix the auth configuration or disable auth.",
e.what());
throw std::runtime_error(std::string("Invalid authentication configuration: ") + e.what());
}
} else {
RCLCPP_INFO(get_logger(), "Authentication: disabled");
Expand Down
Loading
Loading