Skip to content
Draft
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
20 changes: 20 additions & 0 deletions docs/security/credential-parameters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Service credentials and ROS

`INNATE_SERVICE_KEY` is private process configuration for UniNavid, training,
and telemetry. Set it in the owner's `.env` or service environment and restart
the affected node. Do not put credentials in ROS parameters, launch arguments,
or a ROS topic: parameter services, `/parameter_events`, and topic subscriptions
are reachable through the robot/simulator webapp's rosbridge connection.

The former `service_key` ROS parameter and UniNavid `/brain/backend_config`
credential update subscription are removed. Existing YAML or command-line
parameter overrides must migrate to environment configuration. Owner auth still
uses the same `AuthProvider`; no credential is returned to the browser.

`tests/test_ros_credential_parameters.py` exercises a real node's ROS parameter
service and, with `INNATE_TEST_ROSBRIDGE=1`, the HTTP `/ws` relay through an
installed `rws_server`. Run in a sourced ROS workspace with its normal DDS/Zenoh
transport available. Tests inject only a synthetic canary and never start a
navigation goal or call a provider. The private environment value remains
available to auth while parameter queries return no credential and ordinary
configuration queries still work.
7 changes: 2 additions & 5 deletions ros2_ws/src/cloud/innate_logger/innate_logger/logger_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,6 @@ def __init__(self) -> None:
"telemetry_url",
os.getenv("TELEMETRY_URL", DEFAULT_TELEMETRY_URL),
)
self.declare_parameter(
"service_key",
os.getenv("INNATE_SERVICE_KEY", ""),
)
self.declare_parameter(
"auth_issuer_url",
os.getenv("INNATE_AUTH_URL", DEFAULT_AUTH_ISSUER_URL),
Expand All @@ -80,7 +76,8 @@ def __init__(self) -> None:
self.declare_parameter("disk_boot_delay", self.DISK_BOOT_DELAY)

telemetry_url: str = str(self.get_parameter("telemetry_url").get_parameter_value().string_value)
service_key: str = str(self.get_parameter("service_key").value)
# Never declare credentials as ROS params: rosbridge can read them.
service_key: str = os.getenv("INNATE_SERVICE_KEY", "")
auth_issuer: str = str(self.get_parameter("auth_issuer_url").value)

if not service_key:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,6 @@ def __init__(self) -> None:
"server_url",
os.getenv("TRAINING_SERVER_URL", DEFAULT_SERVER_URL),
)
self.declare_parameter(
"service_key",
os.getenv("INNATE_SERVICE_KEY", ""),
)
self.declare_parameter(
"auth_issuer_url",
os.getenv("INNATE_AUTH_URL", DEFAULT_AUTH_ISSUER_URL),
Expand All @@ -175,7 +171,8 @@ def __init__(self) -> None:
self.declare_parameter("status_publish_interval_sec", 1.0)

server_url = str(self.get_parameter("server_url").value)
service_key = str(self.get_parameter("service_key").value)
# Never declare credentials as ROS params: rosbridge can read them.
service_key = os.getenv("INNATE_SERVICE_KEY", "")
auth_issuer = str(self.get_parameter("auth_issuer_url").value)
poll_sec = float(self.get_parameter("poll_interval_sec").value)
pub_sec = float(self.get_parameter("status_publish_interval_sec").value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
Usage (in a sourced workspace):

# Terminal 1 — start the node
ros2 run innate_training_node training_node \
--ros-args -p service_key:=<key>
# Set INNATE_SERVICE_KEY in the owner's .env, then start the node.
ros2 run innate_training_node training_node

# Terminal 2 — run commands
python3 test/test_training_node.py submit /path/to/skill --name my_skill
Expand Down
6 changes: 3 additions & 3 deletions ros2_ws/src/cloud/innate_uninavid/config/params.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
uninavid_node:
ros__parameters:
# ── Connection ────────────────────────────────────────────────────────
# ws_url, service_key, and auth_issuer_url default from env vars;
# override here if needed.
# ws_url and auth_issuer_url default from env vars; override here if needed.
# INNATE_SERVICE_KEY belongs only in the owner's .env/environment, never
# in ROS parameters or /brain/backend_config. Restart after changing it.
# ws_url: "wss://uninavid-v1.svc.innate.bot"
# service_key: ""
# auth_issuer_url: "https://auth-v1.svc.innate.bot"

# ── Motion ───────────────────────────────────────────────────────────
Expand Down
41 changes: 5 additions & 36 deletions ros2_ws/src/cloud/innate_uninavid/innate_uninavid/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

from __future__ import annotations

import json
import os
import statistics
import threading
Expand All @@ -36,13 +35,12 @@
from rclpy.node import Node
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
from sensor_msgs.msg import CompressedImage
from std_msgs.msg import Int32MultiArray, String
from std_msgs.msg import Int32MultiArray

from .ws_client import Action, ClientState, UninavidWsClient

DEFAULT_WS_URL = "wss://uninavid-v1.svc.innate.bot"
DEFAULT_AUTH_ISSUER_URL = "https://auth-v1.svc.innate.bot"
RUNTIME_BACKEND_CONFIG_TOPIC = "/brain/backend_config"

# Default drive speeds for UniNavid's discrete actions (overridable via uninavid_node params).
_DEFAULT_FORWARD_SPEED = 0.3 # m/s, FORWARD action
Expand Down Expand Up @@ -87,7 +85,6 @@ def __init__(self) -> None:
load_dotenv(env_path)

self.declare_parameter("ws_url", os.getenv("UNINAVID_WS_URL", DEFAULT_WS_URL))
self.declare_parameter("service_key", os.getenv("INNATE_SERVICE_KEY", ""))
self.declare_parameter("auth_issuer_url", os.getenv("INNATE_AUTH_URL", DEFAULT_AUTH_ISSUER_URL))
self.declare_parameter("cmd_duration_sec", 0.1)
self.declare_parameter("cmd_publish_hz", 50.0)
Expand All @@ -108,7 +105,9 @@ def __init__(self) -> None:
}

self._ws_url = str(self.get_parameter("ws_url").value)
service_key = str(self.get_parameter("service_key").value)
# ROS parameters and /parameter_events are readable through rosbridge.
# Credentials stay in process configuration, never in the ROS graph.
service_key = os.getenv("INNATE_SERVICE_KEY", "")
self._auth_issuer = str(self.get_parameter("auth_issuer_url").value)
self._config_lock = threading.Lock()
self._service_key = service_key.strip()
Expand All @@ -118,7 +117,7 @@ def __init__(self) -> None:
self.get_logger().warn(
"UniNavid service key is missing. "
"Vision navigation goals will fail until INNATE_SERVICE_KEY is "
"configured or a runtime service key update is received."
"configured and the node is restarted."
)

self._client: UninavidWsClient | None = None
Expand All @@ -134,9 +133,6 @@ def __init__(self) -> None:
)
self._cmd = self.create_publisher(Twist, "/cmd_vel", 10)
self._actions_pub = self.create_publisher(Int32MultiArray, "/vln/actions", 10)
self._backend_config_sub = self.create_subscription(
String, RUNTIME_BACKEND_CONFIG_TOPIC, self._backend_config_callback, 10
)

self._action_server = ActionServer(
self,
Expand All @@ -159,33 +155,6 @@ def _make_auth_provider(self, service_key: str) -> AuthProvider | None:
return None
return AuthProvider(issuer_url=self._auth_issuer, service_key=service_key)

def _backend_config_callback(self, msg: String) -> None:
payload = json.loads(msg.data)
service_key = payload.get("service_key") or payload.get("token")
if service_key is None:
return

new_service_key = str(service_key).strip()
with self._config_lock:
if new_service_key == self._service_key:
self.get_logger().info("UniNavid service key unchanged.")
return
self._service_key = new_service_key
self._auth = self._make_auth_provider(new_service_key)
configured = self._auth is not None
active_client = self._client is not None and self._client.state in (
ClientState.CONNECTING,
ClientState.CONNECTED,
)

if configured:
message = "UniNavid service key updated from runtime backend config."
if active_client:
message += " The new key will be used by the next navigation goal."
self.get_logger().info(message)
else:
self.get_logger().warn("UniNavid received an empty service key; vision navigation remains unavailable.")

# ── Preemption (Nav2 pattern) ─────────────────────────────────────────

def _on_accepted(self, goal_handle):
Expand Down
143 changes: 143 additions & 0 deletions tests/test_ros_credential_parameters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Innate Inc
"""Real ROS parameter and rosbridge disclosure regression (run in the ROS image).

No provider connection or motion action is invoked. INNATE_TEST_ROSBRIDGE=1
also exercises the HTTP /ws front door and installed rws_server on loopback.
"""

import asyncio
import importlib
import json
import os
import socket
import subprocess
import sys
import threading
from pathlib import Path

import pytest

rclpy = pytest.importorskip("rclpy")
from rcl_interfaces.srv import GetParameters # noqa: E402
from rclpy.executors import MultiThreadedExecutor # noqa: E402
from rclpy.node import Node # noqa: E402

ROOT = Path(__file__).resolve().parents[1]
CLOUD = ROOT / "ros2_ws/src/cloud"
for package in ("innate_uninavid", "innate_logger", "innate_training_node"):
sys.path.insert(0, str(CLOUD / package))
for package in ("proxy-client", "auth-client", "training-client"):
sys.path.insert(0, str(CLOUD / "clients" / package))
sys.path.insert(0, str(ROOT / "webapp/proxy"))

from innate_uninavid.node import UninavidNode # noqa: E402

CANARY = "synthetic-ros-credential-canary"


@pytest.fixture
def ros_node(monkeypatch):
monkeypatch.setenv("INNATE_SERVICE_KEY", CANARY)
monkeypatch.delenv("INNATE_PUBLIC_DEMO", raising=False)
rclpy.init()
node = UninavidNode()
executor = MultiThreadedExecutor(num_threads=2)
executor.add_node(node)
thread = threading.Thread(target=executor.spin, daemon=True)
thread.start()
try:
yield node
finally:
executor.shutdown()
thread.join(timeout=5)
node.destroy_node()
rclpy.shutdown()


def test_service_key_is_private_configuration_not_a_ros_parameter(ros_node):
# Owner auth still receives the environment key; no external auth is invoked.
assert ros_node._service_key == CANARY and ros_node._auth is not None
peer = Node("credential_audit_peer")
try:
client = peer.create_client(GetParameters, "/uninavid_node/get_parameters")
assert client.wait_for_service(timeout_sec=10)
result = client.call_async(GetParameters.Request(names=["service_key"]))
rclpy.spin_until_future_complete(peer, result, timeout_sec=10)
values = result.result().values
assert values == []
normal = client.call_async(GetParameters.Request(names=["forward_speed"]))
rclpy.spin_until_future_complete(peer, normal, timeout_sec=10)
assert normal.result().values[0].double_value == 0.3
assert "/brain/backend_config" not in dict(ros_node.get_topic_names_and_types())
finally:
peer.destroy_node()


@pytest.mark.skipif(os.environ.get("INNATE_TEST_ROSBRIDGE") != "1", reason="requires installed rws_server")
def test_public_ws_cannot_retrieve_service_key(ros_node):
import aiohttp
from aiohttp.test_utils import TestClient, TestServer

saved = sys.argv
sys.argv = sys.argv[:1]
try:
frontdoor = importlib.import_module("https_server")
finally:
sys.argv = saved
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
bridge = subprocess.Popen(
["ros2", "run", "rws", "rws_server", "--ros-args", "-p", f"port:={port}", "-p", "rosbridge_compatible:=true"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
saved_url = frontdoor.ROSBRIDGE_URL
frontdoor.ROSBRIDGE_URL = f"ws://127.0.0.1:{port}"

async def request():
async with TestClient(TestServer(frontdoor.build_app())) as client:
# Readiness retries are bounded; only loopback is contacted.
for _ in range(100):
try:
reader, writer = await asyncio.open_connection("127.0.0.1", port)
writer.close()
await writer.wait_closed()
break
except OSError:
await asyncio.sleep(0.1)
else:
pytest.fail("local rws server did not start")
async with client.ws_connect("/ws") as ws:

async def call(name):
await ws.send_json(
{
"op": "call_service",
"id": name,
"service": "/uninavid_node/get_parameters",
"type": "rcl_interfaces/srv/GetParameters",
"args": {"names": [name]},
}
)
async for message in ws:
if message.type != aiohttp.WSMsgType.TEXT:
continue
payload = json.loads(message.data)
if payload.get("id") == name and payload.get("op") == "service_response":
assert CANARY not in message.data
assert payload.get("result") is True
return payload["values"]["values"]
pytest.fail("no ROS parameter service response received")

assert await asyncio.wait_for(call("service_key"), timeout=20) == []
values = await asyncio.wait_for(call("forward_speed"), timeout=20)
assert values[0]["double_value"] == 0.3

try:
asyncio.run(request())
finally:
frontdoor.ROSBRIDGE_URL = saved_url
bridge.terminate()
bridge.wait(timeout=10)
Loading