From 72d4fca63cc10010cc0b5c53ec9ac448ee665866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Verstraeten?= Date: Wed, 12 Aug 2026 14:28:01 +0000 Subject: [PATCH 1/6] Add RTSPS support and configuration options for secure camera streams - Updated README.md to include instructions for using RTSPS camera URLs. - Enhanced gortsplib.go to support insecure TLS configuration for self-signed certificates. - Added unit tests for RTSPS TLS configuration in gortsplib_test.go. --- README.md | 16 ++++++++++++++-- machinery/src/capture/gortsplib.go | 14 ++++++++++++++ machinery/src/capture/gortsplib_test.go | 22 ++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 machinery/src/capture/gortsplib_test.go diff --git a/README.md b/README.md index 98b6f8bd..53ad4671 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,17 @@ Next to attaching the configuration file, it is also possible to override the co -e AGENT_CAPTURE_CONTINUOUS=true \ -d --restart=always kerberos/agent:latest +### Secure camera streams (RTSPS) + +The Agent accepts `rtsps://` camera URLs. Do not use `srtsp://`; RTSPS is RTSP over TLS. For a Bosch FLEXIDOME micro 3100i, enable **Secure RTSP** under **Network > Network Services** and use port `9554`: + +```bash +AGENT_CAPTURE_IPCAMERA_RTSP='rtsps://username:password@camera.example:9554/?inst=1' +AGENT_CAPTURE_IPCAMERA_SUB_RTSP='rtsps://username:password@camera.example:9554/?inst=2' +``` + +Certificate verification is enabled by default. The URL hostname must match the camera certificate. For a private CA or self-signed camera certificate, mount its CA certificate and set `SSL_CERT_FILE` to that PEM file. As a temporary fallback, `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` disables certificate verification for camera streams only. + | Name | Description | Default Value | | --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ | | `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" | @@ -208,8 +219,9 @@ Next to attaching the configuration file, it is also possible to override the co | `AGENT_TIME` | Enable the timetable for Kerberos Agent | "false" | | `AGENT_TIMETABLE` | A (weekly) time table to specify when to make recordings "start1,end1,start2,end2;start1.. | "" | | `AGENT_REGION_POLYGON` | A single polygon set for motion detection: "x1,y1;x2,y2;x3,y3;... | "" | -| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP endpoint to the camera you're targetting. | "" | -| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | Sub-stream RTSP endpoint used for livestreaming (WebRTC). | "" | +| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP or RTSPS endpoint for the target camera. | "" | +| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | RTSP or RTSPS sub-stream endpoint used for livestreaming (WebRTC). | "" | +| `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE` | Disable RTSPS camera certificate verification; use only when a trusted CA cannot be installed. | "false" | | `AGENT_CAPTURE_IPCAMERA_BASE_WIDTH` | Force a specific width resolution for live view processing. | "" | | `AGENT_CAPTURE_IPCAMERA_BASE_HEIGHT` | Force a specific height resolution for live view processing. | "" | | `AGENT_CAPTURE_IPCAMERA_ONVIF` | Mark as a compliant ONVIF device. | "" | diff --git a/machinery/src/capture/gortsplib.go b/machinery/src/capture/gortsplib.go index 956bf3dc..3a6b43e2 100644 --- a/machinery/src/capture/gortsplib.go +++ b/machinery/src/capture/gortsplib.go @@ -8,9 +8,11 @@ import "C" import ( "context" + "crypto/tls" "errors" "fmt" "image" + "os" "reflect" "strconv" "sync" @@ -38,6 +40,16 @@ import ( var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/capture") +const rtspsInsecureEnv = "AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE" + +func rtspsTLSConfig() *tls.Config { + if os.Getenv(rtspsInsecureEnv) != "true" { + return nil + } + + return &tls.Config{InsecureSkipVerify: true} // #nosec G402 -- explicit opt-in for cameras with self-signed certificates +} + // Implements the RTSPClient interface. type Golibrtsp struct { RTSPClient @@ -322,6 +334,7 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e g.Client = gortsplib.Client{ RequestBackChannels: false, Protocol: &protocol, + TLSConfig: rtspsTLSConfig(), // Route gortsplib's packet-loss / decode-error reporting through our // structured logger with stream context (replaces its plain stdout // logging). These hooks are what let us tell whether the camera is @@ -599,6 +612,7 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context. g.Client = gortsplib.Client{ RequestBackChannels: true, Protocol: &protocol, + TLSConfig: rtspsTLSConfig(), } // parse URL u, err := base.ParseURL(g.Url) diff --git a/machinery/src/capture/gortsplib_test.go b/machinery/src/capture/gortsplib_test.go new file mode 100644 index 00000000..adfbfb21 --- /dev/null +++ b/machinery/src/capture/gortsplib_test.go @@ -0,0 +1,22 @@ +package capture + +import "testing" + +func TestRTSPSTLSConfig(t *testing.T) { + t.Run("verifies certificates by default", func(t *testing.T) { + t.Setenv(rtspsInsecureEnv, "") + + if got := rtspsTLSConfig(); got != nil { + t.Fatalf("rtspsTLSConfig() = %#v, want nil", got) + } + }) + + t.Run("allows explicit insecure mode", func(t *testing.T) { + t.Setenv(rtspsInsecureEnv, "true") + + got := rtspsTLSConfig() + if got == nil || !got.InsecureSkipVerify { + t.Fatalf("rtspsTLSConfig() = %#v, want InsecureSkipVerify enabled", got) + } + }) +} From 2bb8144e792dc20d714078a3d103e8800f563080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Verstraeten?= Date: Wed, 12 Aug 2026 14:47:24 +0000 Subject: [PATCH 2/6] Update RTSPS certificate verification instructions in README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 53ad4671..e3fbcc51 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,9 @@ AGENT_CAPTURE_IPCAMERA_RTSP='rtsps://username:password@camera.example:9554/?inst AGENT_CAPTURE_IPCAMERA_SUB_RTSP='rtsps://username:password@camera.example:9554/?inst=2' ``` -Certificate verification is enabled by default. The URL hostname must match the camera certificate. For a private CA or self-signed camera certificate, mount its CA certificate and set `SSL_CERT_FILE` to that PEM file. As a temporary fallback, `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` disables certificate verification for camera streams only. +Certificate verification is enabled by default. The URL hostname or IP address must match the camera certificate SAN. On this Bosch firmware, RTSPS presents the certificate assigned to **HTTPS**; there is no separate SRTSP certificate usage. Leave **CBS client** assigned to the Bosch device certificate. + +For a private CA, mount a PEM trust bundle containing every CA certificate needed to build the camera certificate chain and set `SSL_CERT_FILE` to that file. This Bosch firmware presents only its leaf certificate, so include both the issuing intermediate and root certificates in the bundle. As a temporary fallback, `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` disables certificate verification for camera streams only. | Name | Description | Default Value | | --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ | From 250e3b0b20c1649743b62b176b6233e9934f8e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Verstraeten?= Date: Wed, 12 Aug 2026 15:09:32 +0000 Subject: [PATCH 3/6] Add detailed guide for RTSPS and TLS certificate configuration --- README-RTSPS-TLS.md | 525 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 + 2 files changed, 527 insertions(+) create mode 100644 README-RTSPS-TLS.md diff --git a/README-RTSPS-TLS.md b/README-RTSPS-TLS.md new file mode 100644 index 00000000..c39c6270 --- /dev/null +++ b/README-RTSPS-TLS.md @@ -0,0 +1,525 @@ +# RTSPS and TLS certificates + +This guide explains how Kerberos Agent connects to an IP camera over RTSPS, +how to issue a camera certificate with a private CA, and how to validate the +complete trust path. It also explains why some apparently corrupted trust +bundles can still allow a connection. + +The camera-specific steps were verified with a Bosch FLEXIDOME micro 3100i. +Other Bosch firmware versions may use different labels or ports. + +## Tested configuration + +| Setting | Value | +| --- | --- | +| Camera | Bosch FLEXIDOME micro 3100i | +| Example camera address | `10.0.30.11` | +| RTSPS port | `9554` | +| Main stream | `rtsps://:@10.0.30.11:9554/?inst=1` | +| Sub stream | `rtsps://:@10.0.30.11:9554/?inst=2` | +| Certificate SAN | `IP Address:10.0.30.11` | +| Bosch certificate usage | `HTTPS` | +| Agent trust input | Issuing intermediate plus root CA | + +Replace the example address and certificate names throughout this guide. Keep +camera credentials out of source control and percent-encode reserved URL +characters in usernames and passwords. + +## Mental model + +### RTSPS, SRTSP, TLS, and SRTP + +- The standard URL scheme is `rtsps://`. Do not use `srtsp://`. +- Bosch interfaces and documentation may use SRTSP or Secure RTSP as product + terminology. +- RTSPS carries the RTSP control connection over TLS. With gortsplib, media is + normally interleaved over the same TCP/TLS connection for this camera. +- SRTP is a separate media protection mechanism and is negotiated only when the + camera advertises an appropriate secure RTP profile. + +Encryption alone does not prove which camera the Agent reached. Verified TLS +also checks that: + +1. The camera certificate is signed by a trusted authority. +2. The certificate is valid at the current time. +3. The URL host matches a certificate Subject Alternative Name (SAN). + +Modern Go verification uses SANs for identity. A Common Name alone is not +sufficient. Connecting to `10.0.30.11` requires an IP SAN with that exact value, +not `DNS:10.0.30.11` and not only a device-name DNS SAN. + +### Agent behavior + +Kerberos Agent uses gortsplib for RTSP and RTSPS. With the normal configuration, +gortsplib receives a nil custom TLS configuration and Go performs standard +certificate and hostname verification with the process trust pool. + +`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` is an explicit escape hatch that +sets `InsecureSkipVerify` for camera clients. It should be false in a verified +deployment. + +## Decide the certificate identity first + +Choose the stable name used in every Agent URL before creating the certificate: + +- For an IP URL, add that address as an IP SAN. +- For a DNS URL, add the exact hostname as a DNS SAN. +- Add both when clients legitimately use both forms. + +A certificate stops matching if the camera IP changes. Use a static address, +DHCP reservation, or stable DNS name. + +## Configure RTSPS in the Bosch UI + +1. Sign in to the camera as an administrator. +2. Open **Configuration**. +3. Open **Network > Network Services**. +4. Enable **RTSPS**. +5. Confirm port `9554`, or record the configured alternative. +6. Click **Set**. + +RTSP on port `554` and RTSPS on port `9554` are separate services. Enabling +RTSPS does not make an `rtsp://` URL secure. + +## Generate the private key and CSR on the camera + +Keeping the TLS private key on the camera avoids exporting it to an operator +workstation or deployment system. + +1. Open **Service > Certificates**. +2. Click **Add**. +3. Select **Generate signing request**. +4. Select `RSA 2048bit` or the stronger option supported by all clients. +5. Enter a unique file name, such as `agent-rtsps`. +6. Enter a descriptive Common Name and any required organization fields. +7. Click **Generate**. +8. Download the resulting CSR from the certificate table. + +On the tested firmware, this form contains no SAN field. The downloaded CSR +therefore has no IP SAN. The CA must add the SAN while signing. + +Inspect the CSR before signing: + +```bash +openssl req -in camera.csr.pem -noout -verify -subject +openssl req -in camera.csr.pem -noout -text +``` + +The first command must report `Certificate request self-signature verify OK`. +An absent `Subject Alternative Name` section is expected for this firmware. + +## Prepare Smallstep + +Use an existing organizational CA when one is available. Creating a new CA +creates a new long-lived trust domain that must be distributed, protected, +backed up, and eventually rotated. + +### Install the CLI on Debian amd64 + +```bash +curl -fsSL \ + https://dl.smallstep.com/cli/docs-ca-install/latest/step-cli_amd64.deb \ + -o /tmp/step-cli_amd64.deb +sudo dpkg -i /tmp/step-cli_amd64.deb +rm /tmp/step-cli_amd64.deb +step version +``` + +Use the official package matching the host architecture on other systems. + +### Create a dedicated offline CA + +Skip this section when using an existing CA. + +```bash +umask 077 +mkdir -p "$HOME/.step/secrets" "$HOME/.step/camera" + +openssl rand -base64 48 > "$HOME/.step/secrets/camera_ca_password" +chmod 600 "$HOME/.step/secrets/camera_ca_password" + +step ca init \ + --pki \ + --name "UUG Camera CA" \ + --password-file "$HOME/.step/secrets/camera_ca_password" +``` + +This produces: + +```text +$HOME/.step/certs/root_ca.crt +$HOME/.step/certs/intermediate_ca.crt +$HOME/.step/secrets/root_ca_key +$HOME/.step/secrets/intermediate_ca_key +$HOME/.step/secrets/camera_ca_password +``` + +The files under `secrets/` are sensitive. Keep them mode `600`, never commit +them, and back them up to encrypted persistent storage. A devcontainer can be +rebuilt or deleted; it is not sufficient as the only CA backup. + +## Add the SAN while signing + +Copy the camera CSR into a protected working directory: + +```bash +cp camera.csr.pem "$HOME/.step/camera/camera.csr.pem" +``` + +Create `$HOME/.step/camera/bosch-rtsps.tpl`: + +```json +{ + "subject": { + "commonName": {{ toJson .Insecure.CR.Subject.CommonName }} + }, + "ipAddresses": ["10.0.30.11"], + "keyUsage": ["keyEncipherment", "digitalSignature"], + "extKeyUsage": ["serverAuth", "clientAuth"] +} +``` + +The template preserves the camera CSR public key, sets the IP identity, and +creates a TLS leaf rather than a CA certificate. + +Sign it with a validity period that ends before the intermediate CA expires. +A one-year leaf is preferable to a ten-year leaf when automated renewal is +available: + +```bash +step certificate sign \ + --template "$HOME/.step/camera/bosch-rtsps.tpl" \ + --bundle \ + --not-after 8760h \ + --password-file "$HOME/.step/secrets/camera_ca_password" \ + "$HOME/.step/camera/camera.csr.pem" \ + "$HOME/.step/certs/intermediate_ca.crt" \ + "$HOME/.step/secrets/intermediate_ca_key" \ + > "$HOME/.step/camera/bosch-rtsps-chain.pem" +``` + +For an online `step-ca`, do not assume `step ca sign` accepts a `--san` flag. It +does not. Authorize SANs in the one-time token or configure a provisioner +template that produces the required SANs. + +## Validate before upload + +Inspect the leaf certificate, which is the first PEM block in the chain file: + +```bash +openssl x509 \ + -in "$HOME/.step/camera/bosch-rtsps-chain.pem" \ + -noout -subject -issuer -dates -ext subjectAltName -ext extendedKeyUsage +``` + +Confirm the SAN separately because some OpenSSL versions display only the last +requested extension: + +```bash +openssl x509 \ + -in "$HOME/.step/camera/bosch-rtsps-chain.pem" \ + -noout -ext subjectAltName +``` + +Verify the path and IP identity: + +```bash +openssl verify \ + -CAfile "$HOME/.step/certs/root_ca.crt" \ + -untrusted "$HOME/.step/certs/intermediate_ca.crt" \ + -verify_ip 10.0.30.11 \ + "$HOME/.step/camera/bosch-rtsps-chain.pem" +``` + +Confirm that the signed leaf uses the exact public key from the camera CSR: + +```bash +csr_key=$( + openssl req -in "$HOME/.step/camera/camera.csr.pem" -pubkey -noout | + openssl pkey -pubin -outform DER 2>/dev/null | + sha256sum | cut -d' ' -f1 +) + +cert_key=$( + openssl x509 -in "$HOME/.step/camera/bosch-rtsps-chain.pem" -pubkey -noout | + openssl pkey -pubin -outform DER 2>/dev/null | + sha256sum | cut -d' ' -f1 +) + +test "$csr_key" = "$cert_key" +``` + +Do not upload a certificate when any of these checks fail. + +## Upload and assign the certificate + +1. Return to **Service > Certificates**. +2. Click **Add > Upload certificate**. +3. Select the leaf-plus-intermediate PEM chain. +4. Click **Upload** and wait for `100%`. +5. Confirm that the former CSR row is now a `Certificate`. +6. Confirm that the key icon is present. It proves that the camera associated + the certificate with its retained private key. +7. Open the new certificate's **Usage** selector. +8. Select only **HTTPS**. +9. Leave **CBS client** assigned to the original Bosch `DeviceCertificate`. +10. Click **Set** and wait for the table to reload. + +On the tested firmware, there is no separate SRTSP usage. RTSPS presents the +certificate assigned to HTTPS. Reassigning HTTPS therefore changes both the +web interface and RTSPS certificate. + +After saving, the expected split is: + +| Certificate | Usage | +| --- | --- | +| Private-CA camera certificate | `HTTPS` | +| Bosch `DeviceCertificate` | `CBS client` | + +The browser may warn about the new HTTPS certificate until the private root CA +is trusted by the workstation. + +## Account for the Bosch chain behavior + +The tested firmware served only the leaf certificate on ports `443` and `9554`, +even when the uploaded file contained the leaf and intermediate. Uploading the +intermediate separately as a trusted camera certificate did not change the +served chain. + +Confirm the behavior: + +```bash +openssl s_client \ + -connect 10.0.30.11:9554 \ + -showcerts /dev/null | + grep -c '^-----BEGIN CERTIFICATE-----$' +``` + +A result of `1` means the client must already have the issuing intermediate. +Create a portable trust bundle containing the intermediate and root: + +```bash +step certificate bundle \ + "$HOME/.step/certs/intermediate_ca.crt" \ + "$HOME/.step/certs/root_ca.crt" \ + "$HOME/.step/camera/uug-camera-trust-bundle.pem" + +chmod 644 "$HOME/.step/camera/uug-camera-trust-bundle.pem" +``` + +The trust bundle is public material. The CA private keys and password are not. + +## Configure Kerberos Agent + +For a process running directly in the same environment: + +```dotenv +AGENT_CAPTURE_IPCAMERA_RTSP="rtsps://:@10.0.30.11:9554/?inst=1" +AGENT_CAPTURE_IPCAMERA_SUB_RTSP="rtsps://:@10.0.30.11:9554/?inst=2" +AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false +SSL_CERT_FILE=/home/agent/certs/uug-camera-trust-bundle.pem +``` + +For a container, mount the public bundle read-only at the exact path visible +inside the container: + +```bash +docker run \ + -v /secure/config/uug-camera-trust-bundle.pem:/home/agent/certs/uug-camera-trust-bundle.pem:ro \ + -e SSL_CERT_FILE=/home/agent/certs/uug-camera-trust-bundle.pem \ + -e AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \ + -e 'AGENT_CAPTURE_IPCAMERA_RTSP=rtsps://:@10.0.30.11:9554/?inst=1' \ + -e 'AGENT_CAPTURE_IPCAMERA_SUB_RTSP=rtsps://:@10.0.30.11:9554/?inst=2' \ + kerberos/agent:latest +``` + +Restart the Agent after changing trust files. Go can cache the process system +certificate pool after its first use, so editing a file does not guarantee that +an already-running process reloads it. + +## Validate the live endpoints + +### Strict TLS and identity check + +Use only the specified bundle, without OpenSSL's default CA locations: + +```bash +openssl s_client \ + -brief \ + -connect 10.0.30.11:9554 \ + -verify_ip 10.0.30.11 \ + -verify_return_error \ + -CAfile "$HOME/.step/camera/uug-camera-trust-bundle.pem" \ + -no-CApath \ + -no-CAstore \ + /dev/null | + openssl x509 -noout -fingerprint -sha256 | + cut -d= -f2 +) + +local_fingerprint=$( + openssl x509 \ + -in "$HOME/.step/camera/bosch-rtsps-chain.pem" \ + -noout -fingerprint -sha256 | + cut -d= -f2 +) + +test "$live_fingerprint" = "$local_fingerprint" +``` + +### Validate the media path + +A successful TLS handshake does not prove that RTSP authentication, DESCRIBE, +SETUP, PLAY, and RTP delivery work. Start a fresh Agent with verified TLS and +confirm that it connects without an x509 error and receives frames. During the +verified setup described here, a gortsplib probe completed all RTSP operations +and received an RTP packet over TCP. + +## Why a tampered bundle may still connect + +Editing PEM text is not always a useful negative TLS test. + +### A certificate can still parse after a byte change + +Base64 can remain syntactically valid when one character changes. OpenSSL may +still list the certificate subject and issuer even though a signature is now +invalid. Parsing and signature verification are different operations. + +### Trust anchors are not validated through a parent + +Every certificate loaded into Go's root pool is a trust anchor, including a +non-self-signed intermediate CA. Verification can terminate at that certificate. + +If tampering changes only the intermediate's signature from its parent root, +but does not change its public key, that intermediate can still validate the +camera leaf when it is trusted directly. Its now-invalid parent signature is +not consulted at the trust boundary. + +This is equivalent to OpenSSL's partial-chain behavior: + +```bash +openssl s_client \ + -connect 10.0.30.11:9554 \ + -verify_ip 10.0.30.11 \ + -verify_return_error \ + -partial_chain \ + -CAfile tampered-bundle.pem \ + -no-CApath \ + -no-CAstore \ + Date: Wed, 12 Aug 2026 15:11:13 +0000 Subject: [PATCH 4/6] Update README-RTSPS-TLS.md with configuration details and validation steps for RTSPS --- README-RTSPS-TLS.md | 46 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/README-RTSPS-TLS.md b/README-RTSPS-TLS.md index c39c6270..3e65e716 100644 --- a/README-RTSPS-TLS.md +++ b/README-RTSPS-TLS.md @@ -8,6 +8,11 @@ bundles can still allow a connection. The camera-specific steps were verified with a Bosch FLEXIDOME micro 3100i. Other Bosch firmware versions may use different labels or ports. +The commands were tested with Smallstep CLI `0.30.6` and OpenSSL `3.5.6` on +Debian. Check `step certificate sign --help` when using an older Smallstep CLI. +The OpenSSL isolation flags `-no-CApath` and `-no-CAstore` require a version that +lists them in `openssl s_client -help`. + ## Tested configuration | Setting | Value | @@ -317,16 +322,17 @@ For a process running directly in the same environment: AGENT_CAPTURE_IPCAMERA_RTSP="rtsps://:@10.0.30.11:9554/?inst=1" AGENT_CAPTURE_IPCAMERA_SUB_RTSP="rtsps://:@10.0.30.11:9554/?inst=2" AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false -SSL_CERT_FILE=/home/agent/certs/uug-camera-trust-bundle.pem +SSL_CERT_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem ``` For a container, mount the public bundle read-only at the exact path visible -inside the container: +inside the container. The Agent image creates `/home/agent/data/config` and +includes Debian's `ca-certificates` package: ```bash docker run \ - -v /secure/config/uug-camera-trust-bundle.pem:/home/agent/certs/uug-camera-trust-bundle.pem:ro \ - -e SSL_CERT_FILE=/home/agent/certs/uug-camera-trust-bundle.pem \ + -v /secure/config/uug-camera-trust-bundle.pem:/home/agent/data/config/uug-camera-trust-bundle.pem:ro \ + -e SSL_CERT_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem \ -e AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \ -e 'AGENT_CAPTURE_IPCAMERA_RTSP=rtsps://:@10.0.30.11:9554/?inst=1' \ -e 'AGENT_CAPTURE_IPCAMERA_SUB_RTSP=rtsps://:@10.0.30.11:9554/?inst=2' \ @@ -337,6 +343,18 @@ Restart the Agent after changing trust files. Go can cache the process system certificate pool after its first use, so editing a file does not guarantee that an already-running process reloads it. +The default production mode retains the image's normal public roots in addition +to the private camera CA. For a deliberately private-CA-only deployment, mount +an empty directory and set `SSL_CERT_DIR` to it: + +```bash +-v /secure/config/empty-ca-dir:/home/agent/data/config/empty-ca-dir:ro \ +-e SSL_CERT_DIR=/home/agent/data/config/empty-ca-dir +``` + +Only use that mode when the Agent does not need public roots for other TLS +connections. + ## Validate the live endpoints ### Strict TLS and identity check @@ -357,6 +375,10 @@ openssl s_client \ Repeat with port `443`. Both must report `Verification: OK`. +Confirm that identity checking is active by repeating the command with a wrong +address, such as `-verify_ip 10.0.30.12`. It must fail with an IP address +mismatch. + ### Confirm the live leaf is the generated leaf ```bash @@ -476,6 +498,22 @@ openssl verify \ Store and compare approved SHA-256 fingerprints when detecting unauthorized certificate-file changes is a requirement. +Restore an accidentally edited bundle from the protected CA certificates, then +restart the Agent: + +```bash +step certificate bundle -f \ + "$HOME/.step/certs/intermediate_ca.crt" \ + "$HOME/.step/certs/root_ca.crt" \ + "$HOME/.step/camera/uug-camera-trust-bundle.pem" + +openssl verify \ + -CAfile "$HOME/.step/certs/root_ca.crt" \ + -no-CApath \ + -no-CAstore \ + "$HOME/.step/certs/intermediate_ca.crt" +``` + ## Optional system trust installation On Debian, install both public CA certificates when every process in the system From de5b0666bd1adbab5dc4fb6483e8bdfd83f493f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Verstraeten?= Date: Wed, 12 Aug 2026 15:13:54 +0000 Subject: [PATCH 5/6] Clarify SSL_CERT_DIR usage and provide examples for empty trust sources in README-RTSPS-TLS.md --- README-RTSPS-TLS.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README-RTSPS-TLS.md b/README-RTSPS-TLS.md index 3e65e716..914d6a30 100644 --- a/README-RTSPS-TLS.md +++ b/README-RTSPS-TLS.md @@ -345,13 +345,16 @@ an already-running process reloads it. The default production mode retains the image's normal public roots in addition to the private camera CA. For a deliberately private-CA-only deployment, mount -an empty directory and set `SSL_CERT_DIR` to it: +an empty directory and set `SSL_CERT_DIR` to its path: ```bash -v /secure/config/empty-ca-dir:/home/agent/data/config/empty-ca-dir:ro \ -e SSL_CERT_DIR=/home/agent/data/config/empty-ca-dir ``` +Do not use `SSL_CERT_DIR=`. Go treats an empty value as unset and scans its +default certificate directories. + Only use that mode when the Agent does not need public roots for other TLS connections. @@ -447,6 +450,24 @@ still scans default certificate directories such as `/etc/ssl/certs`. Setting `SSL_CERT_FILE` alone therefore does not remove CA certificates installed with `update-ca-certificates`. +Blank values do not select empty trust sources. Both `SSL_CERT_FILE=` and +`SSL_CERT_DIR=` are treated as unset, so Go falls back to its default aggregate +CA file and certificate directories. To test with no trusted certificates on +Linux, use a non-empty file path that contains no certificates and a non-empty +directory path that contains no certificates: + +```bash +mkdir -p /tmp/empty-ca-dir +SSL_CERT_FILE=/dev/null \ +SSL_CERT_DIR=/tmp/empty-ca-dir \ +AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \ +GOWORK=off \ +go run -tags moq . -action run -port 8080 +``` + +That fresh process must fail with `x509: certificate signed by unknown +authority`. + Use exactly one trust-distribution approach when possible: 1. Mount a private trust bundle and set `SSL_CERT_FILE`; or From 97a8c1fcafc7fd811db8066727c1afb75c3d3614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Verstraeten?= Date: Thu, 13 Aug 2026 09:49:45 +0200 Subject: [PATCH 6/6] Update README-RTSPS-TLS.md --- README-RTSPS-TLS.md | 55 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README-RTSPS-TLS.md b/README-RTSPS-TLS.md index 914d6a30..aec391d0 100644 --- a/README-RTSPS-TLS.md +++ b/README-RTSPS-TLS.md @@ -63,6 +63,61 @@ certificate and hostname verification with the process trust pool. sets `InsecureSkipVerify` for camera clients. It should be false in a verified deployment. +## Communication and certificate flow + +The certificate is used during the TLS handshake, before the first RTSP command +is exchanged. It is not attached to `DESCRIBE`, `SETUP`, or `PLAY`, and the CA +trust bundle is never sent to the camera. + +```mermaid +sequenceDiagram + participant Agent as Kerberos Agent + participant Trust as Go trust pool + participant Camera as Camera RTSPS :9554 + + Agent->>Trust: Load trusted CAs from SSL_CERT_FILE and CA directories + Agent->>Camera: Open TCP connection + Agent->>Camera: Send TLS ClientHello + Camera-->>Agent: Send TLS ServerHello and camera certificate + Agent->>Trust: Verify chain, validity, serverAuth, and URL host against SAN + Trust-->>Agent: Accept or reject the camera identity + Agent->>Camera: Complete TLS handshake + Note over Agent,Camera: All following traffic is encrypted by TLS + Agent->>Camera: DESCRIBE with RTSP authentication + Camera-->>Agent: Return SDP and available media tracks + Agent->>Camera: SETUP selected video and audio tracks over TCP + Agent->>Camera: PLAY + Camera-->>Agent: Send interleaved RTP and RTCP media over TLS +``` + +The files and keys have distinct roles: + +| Material | Location | Purpose | Sent over the connection | +| --- | --- | --- | --- | +| Camera leaf certificate | Camera | Identifies the camera and binds its public key to its SAN | Yes, by the camera during the TLS handshake | +| Camera private key | Camera | Proves that the camera owns the presented certificate | No | +| Intermediate and root CA PEM bundle | Agent | Lets Go build and trust the camera certificate chain | No | +| RTSP username and password | Agent configuration or URL | Authenticates the Agent to the RTSP service after TLS succeeds | An authentication response is sent inside TLS; its form depends on the RTSP authentication method | + +For an `rtsps://` URL, the Agent parses the URL and gives gortsplib the host and +TLS settings. gortsplib opens the TCP connection and starts TLS. Go compares the +certificate presented by the camera with the local trust pool, checks its +validity period and server usage, and matches the URL hostname or IP address to +the certificate SAN. Only a successful handshake creates the encrypted channel +needed for the RTSP exchange. + +The Agent then sends `DESCRIBE`, selects the advertised video and audio tracks, +sends `SETUP`, and starts delivery with `PLAY`. For the tested camera, gortsplib +uses interleaved TCP, so the RTSP control messages and RTP/RTCP media remain +inside the same encrypted TLS connection. Main stream, sub stream, and enabled +audio backchannel clients each establish and verify their own connection. + +If certificate verification fails, the TLS handshake does not complete and no +usable RTSP session is established. Setting +`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` keeps traffic encrypted but skips +certificate-chain and hostname verification, so an attacker could impersonate +the camera. It is not equivalent to trusting the camera certificate. + ## Decide the certificate identity first Choose the stable name used in every Agent URL before creating the certificate: