TLS over a transport¶
Pass a TLSConfig to protect the rmote byte stream with TLS 1.3. Serialization,
compression, headers, and frames all run above TLS. The configuration is opt-in;
Python connections without it keep their existing behavior. The shell, repl,
rsync, and sshmux commands enable TLS automatically when they find a complete
local certificate and key pair. Use --no-tls to disable that automatic choice.
Both processes need Python with the standard-library ssl module and TLS 1.3
support in OpenSSL. No third-party Python package is needed on either side.
Why use TLS?¶
A transport can connect two processes without encrypting their traffic.
A plain TCP relay, such as nc, can carry rmote commands and results, but
anyone who can observe that connection can read the transferred data.
Serialization and compression do not protect secrets in arguments, file
contents, environment variables, or command output.
TLS adds encryption to the rmote stream independently of the transport. After a trusted bootstrap and successful TLS handshake, it protects tool code, RPC arguments, results, and protocol frames from reading or undetected changes in transit. This is useful when the available transport only carries bytes. SSH already protects its connection; rmote TLS is optional when using SSH.
The current implementation starts TLS after the plaintext bootstrap. It can protect subsequent traffic from passive observation on an open channel, but it does not secure an unauthenticated bootstrap against an active attacker. An attacker who replaces the bootstrap can also replace its certificate checks. The bootstrap must reach the intended peer without modification; see Trust boundary before using an unencrypted transport.
An unencrypted Telnet connection has the same confidentiality problem. However, Telnet also carries control commands and negotiates terminal behavior, as described in RFC 854. A Telnet transport would need an adapter that handles those commands and preserves binary data in both directions. rmote does not provide that adapter. TLS inside rmote would not protect Telnet login credentials or other data sent before the TLS handshake.
CLI options¶
The shell, repl, rsync, and sshmux commands share these options:
Option |
Default |
|---|---|
|
Existing |
|
Existing |
|
Existing |
|
Current controller hostname |
|
Disable TLS, including stored certificates |
The directory is $XDG_DATA_HOME/rmote/tls, with ~/.local/share/rmote/tls as
its fallback. Each command’s --help shows existing file paths as defaults.
Without a complete default certificate and key pair, the command uses plaintext.
An explicitly supplied missing file is an error. Invalid credentials or a failed
handshake stop the connection; they never cause a fallback to plaintext.
For example, generate credentials once and use them with either command:
rmote certs
rmote shell ssh host
rmote repl ssh host
To bypass stored credentials for one connection:
rmote shell --no-tls ssh host
Do not combine --no-tls with explicit certificate, key, or CA arguments.
Use --tls-hostname if the certificate contains a name other than the current
controller hostname.
Controller certificate¶
The controller acts as the TLS server, even though it starts the transport. It keeps the certificate and private key locally. The remote process acts as the TLS client and verifies the controller’s certificate chain and expected name before exchanging rmote data. The certificate must allow TLS server authentication and contain the expected name in its Subject Alternative Name.
Generate a certificate with the CLI¶
Run this command on the controller:
rmote certs
It creates a self-signed controller.crt and an unencrypted controller.key,
valid for 365 days. The command prints a TLSConfig example with absolute paths.
Use the same certificate for certfile and cafile. The remote process trusts
that certificate directly; replacing it also changes the trust configuration.
The default directory is $XDG_DATA_HOME/rmote/tls, or
~/.local/share/rmote/tls when XDG_DATA_HOME is unset, empty, or relative.
This follows the XDG Base Directory Specification.
The command creates the certificate directory with mode 0700 and the private
key with mode 0600. It refuses an existing output directory.
Use --output-dir to select a new directory and --days to change the lifetime.
The default verified name is the controller’s current hostname from
socket.gethostname(). Use --hostname to select another ASCII DNS name or
IP address. The name does not need to resolve for this stream transport.
rmote certs --hostname controller.example.org --days 90 \
--output-dir ./controller-tls --openssl-bin /path/to/openssl
--openssl-bin defaults to the executable found by shutil.which("openssl").
If none is found, argparse requires an explicit --openssl-bin argument.
The helper checks that the executable runs before it creates output directories.
Other rmote commands do not require the OpenSSL executable.
Keep the private key on the controller. Never commit it or send it to the remote process. The helper uses an OpenSSL configuration file and also supports LibreSSL. For a separate CA that can sign replacement certificates, use the commands below.
Generate certificates with a separate CA¶
Run these commands on the controller with OpenSSL 1.1.1 or later. They create
a private CA valid for 10 years and a controller certificate valid for one year.
Replace controller.example.org in both the commands and TLSConfig with the
name you want to verify. For this stream transport, the name identifies the
controller; it does not need to resolve in DNS.
Check openssl version first. On macOS, the system command may be LibreSSL;
use an OpenSSL installation for the commands below, including hostname verification.
Create a new directory and restrict access to the generated keys:
umask 077
mkdir rmote-tls
cd rmote-tls
Create the CA certificate and its signing key:
openssl req -x509 -newkey rsa:3072 -sha256 -nodes \
-keyout controller-ca.key -out controller-ca.crt -days 3650 \
-subj '/CN=rmote controller CA' \
-addext 'basicConstraints=critical,CA:TRUE,pathlen:0' \
-addext 'keyUsage=critical,keyCertSign,cRLSign' \
-addext 'subjectKeyIdentifier=hash'
Create a separate controller key and certificate signing request:
openssl req -new -newkey rsa:3072 -sha256 -nodes \
-keyout controller.key -out controller.csr \
-subj '/CN=controller.example.org'
Set the server identity and permitted uses, then sign the request:
cat > controller.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:controller.example.org
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid,issuer
EOF
openssl x509 -req -in controller.csr \
-CA controller-ca.crt -CAkey controller-ca.key -CAcreateserial \
-out controller.crt -days 365 -sha256 -extfile controller.ext
openssl verify -x509_strict -CAfile controller-ca.crt \
-purpose sslserver -verify_hostname controller.example.org controller.crt
The verification command must print controller.crt: OK. The connection uses
controller.crt, controller.key, and controller-ca.crt; it does not need
controller-ca.key. Keep the CA key separately for future certificate signing.
Both private keys are unencrypted, so restrict their file access and never
send them to the remote process or commit them to version control. Renew the
controller certificate before it expires, using the same CA if you want to
keep the existing trust configuration.
See the OpenSSL documentation for
req and
x509 for command options.
Connect with the certificates¶
Run the example from rmote-tls, or use absolute paths in TLSConfig.
All three paths below refer to files on the controller:
from rmote.protocol import TLSConfig
from rmote.sync import Connection
from rmote.tools.exec import Exec
tls = TLSConfig(
certfile="controller.crt",
keyfile="controller.key",
cafile="controller-ca.crt",
server_hostname="controller.example.org",
handshake_timeout=10.0,
)
with Connection.from_local(tls=tls) as remote:
result = remote(Exec.command, "uname", "-s", capture_output=True)
print(result.stdout)
The same tls= argument is available on synchronous from_command and
from_ssh, and on asynchronous Protocol.from_command, from_ssh, and
from_subprocess. Enter the asynchronous protocol context to complete TLS
and the rmote handshake:
from rmote.protocol import Protocol
async def connect(tls: TLSConfig) -> None:
async with await Protocol.from_command("ssh", "-T", "host", tls=tls) as remote:
result = await remote(Exec.command, "uname", "-s", capture_output=True)
print(result.stdout)
The TLS handshake has its own timeout. The synchronous client’s
connect_timeout also bounds the complete connection setup. A failed TLS
handshake closes the connection; there is no fallback to plaintext.
Trust boundary¶
The existing bootstrap sends executable Python, public CA certificates, and
the expected controller name through the original transport. TLS starts after
that bootstrap and before PROTOCOL READY. An attacker who can replace the
bootstrap can replace its verification code or trust configuration. Use a
trusted bootstrap transport, such as SSH with host verification; this option
does not make unauthenticated remote code delivery safe.
This configuration authenticates the controller to the remote process. It does not authenticate the remote process to the controller and does not implement mutual TLS. The underlying transport must establish the remote host’s identity. Only use rmote with trusted peers: TLS does not make received pickle data safe.
Application compression remains enabled. TLS hides frame contents, but does not hide traffic lengths. Avoid compressing secrets together with data an attacker controls when the attacker can observe those lengths.
Local test credentials¶
The repository contains a certificate and a publicly known private key in
tests/fixtures/tls. For a local test, set certfile and cafile to
tests/fixtures/tls/controller.crt, keyfile to
tests/fixtures/tls/controller.key, and server_hostname to rmote.test.
These certificates last 100 years to keep the tests independent of routine certificate renewal. Never use them in production or install them in a system trust store. The adjacent README contains the generation command.