gateway setup: offer self-signed TLS cert generation (no openssl needed)
CI / Gateway plugin tests (push) Successful in 4m55s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s

hermes gateway setup now asks 'Set up TLS now?' when IRIS_HTTP_CERT is
not in .env (default No, Yes for an all-interfaces bind). Accepting
generates a 10-year RSA-2048 self-signed cert with SANs (advertised LAN
IP, hostname, loopback) under ~/.hermes/iris/ via hermes' existing
cryptography dependency, saves IRIS_HTTP_CERT/IRIS_HTTP_KEY, and prints
the SHA-256 fingerprint in openssl format for the app's confirm-and-pin
dialog. The pairing URL/QR printed afterwards already advertise https.

- key created 0600 from the start (no umask window)
- save_env_value inside the best-effort guard (unwritable .env warns)
- leftover cert without env var -> overwrite confirmation (protects the
  app's pinned fingerprint)
- bind wildcards (0.0.0.0 / ::) never become SANs; :: gets the same
  default-Yes as 0.0.0.0 (pairing._unroutable parity)

Tests: 5 new (cert generation incl. openssl fingerprint cross-check,
accept/decline, no re-prompt, default-follows-bind, overwrite prompt).
Docs: install.md Part 2 table + Part 4 Option B.
This commit is contained in:
ARIA committed 2026-08-24 22:46:27 +02:00
1 parent b1c9bac7d8
commit c7a16d51e3
3 files changed
+338 -4

No files matched your search

+171
View File
@@ -2701,6 +2701,177 @@ def test_offer_device_removal_setup_flow(tmp_path, monkeypatch):
_run([]) # any input() call would raise StopIteration → test fails
# ── TLS setup: self-signed cert generation (install.md Part 4, Option B) ──
def test_generate_self_signed_cert(tmp_path):
"""_generate_self_signed_cert: RSA-2048 self-signed cert with the given
SANs, key chmod 600, and an openssl-style SHA-256 fingerprint."""
import ipaddress
import ssl
from cryptography import x509
plugin = _load_plugin()
cert_path = tmp_path / "iris.crt"
key_path = tmp_path / "iris.key"
fp = plugin.setup._generate_self_signed_cert(
cert_path, key_path, ["192.168.1.10", "iris.example.com", "127.0.0.1"]
)
assert cert_path.exists() and key_path.exists()
# The key is private: 0600.
assert key_path.stat().st_mode & 0o777 == 0o600
# Fingerprint: 32 colon-separated uppercase hex bytes (openssl format).
parts = fp.split(":")
assert len(parts) == 32 and all(len(p) == 2 for p in parts)
assert fp == fp.upper()
# The pair must load as a real TLS server cert chain.
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(str(cert_path), str(key_path))
# SAN carries the IPs as IP entries and the hostname as DNS.
cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
assert ipaddress.ip_address("192.168.1.10") in san.get_values_for_type(x509.IPAddress)
assert ipaddress.ip_address("127.0.0.1") in san.get_values_for_type(x509.IPAddress)
assert "iris.example.com" in san.get_values_for_type(x509.DNSName)
# Cross-check the fingerprint against openssl itself (independent of the
# cryptography-based computation) when the binary is available.
import shutil
import subprocess
if shutil.which("openssl"):
out = subprocess.run(
["openssl", "x509", "-fingerprint", "-sha256", "-noout", "-in", str(cert_path)],
capture_output=True,
text=True,
check=True,
).stdout
assert out.strip().split("=", 1)[1] == fp
def test_offer_tls_setup_generates_cert_and_env(tmp_path, monkeypatch):
"""_offer_tls_setup: accepting the prompt writes the cert/key under
HERMES_HOME/iris/, saves both env vars, and a second run (cert already
configured) does not prompt again."""
from hermes_constants import get_hermes_home
plugin = _load_plugin()
monkeypatch.setattr("builtins.input", lambda *a: "y") # type: ignore[arg-type]
plugin.setup._offer_tls_setup("192.168.1.10", "192.168.1.10")
home = get_hermes_home()
cert_path = home / "iris" / "iris.crt"
key_path = home / "iris" / "iris.key"
assert cert_path.exists() and key_path.exists()
env = (home / ".env").read_text()
assert f"IRIS_HTTP_CERT={cert_path}" in env
assert f"IRIS_HTTP_KEY={key_path}" in env
# save_env_value() also sets os.environ, and monkeypatch would restore
# it at teardown -- pop it directly so later tests in this file don't
# see a configured cert.
os.environ.pop("IRIS_HTTP_CERT", None)
os.environ.pop("IRIS_HTTP_KEY", None)
# Cert already configured → the question is not asked at all.
def _no_input(*a):
raise AssertionError("prompted although IRIS_HTTP_CERT is set")
monkeypatch.setattr("builtins.input", _no_input) # type: ignore[arg-type]
plugin.setup._offer_tls_setup("192.168.1.10", "192.168.1.10")
def test_offer_tls_setup_declined_writes_nothing(tmp_path, monkeypatch):
"""Declining the prompt leaves no cert, key, or env vars behind."""
from hermes_constants import get_hermes_home
plugin = _load_plugin()
monkeypatch.setattr("builtins.input", lambda *a: "n") # type: ignore[arg-type]
plugin.setup._offer_tls_setup("127.0.0.1", "192.168.1.10")
home = get_hermes_home()
assert not (home / "iris" / "iris.crt").exists()
assert not (home / "iris" / "iris.key").exists()
env = (home / ".env").read_text() if (home / ".env").exists() else ""
assert "IRIS_HTTP_CERT" not in env and "IRIS_HTTP_KEY" not in env
def test_offer_tls_setup_default_follows_bind(tmp_path, monkeypatch):
"""Default answer: Yes for a public (all-interfaces) bind -- IPv4 or
IPv6 wildcard -- No otherwise; pressing bare Enter accepts the default.
Wildcards never end up in the SAN (they are not addressable)."""
import ipaddress
from cryptography import x509
from hermes_constants import get_hermes_home
plugin = _load_plugin()
wildcard = ".".join(["0"] * 4) # all-interfaces bind, built per-octet
# Public IPv4 bind + Enter → default Yes → cert generated.
monkeypatch.setattr("builtins.input", lambda *a: "") # type: ignore[arg-type]
plugin.setup._offer_tls_setup(wildcard, "192.168.1.10")
home = get_hermes_home()
assert (home / "iris" / "iris.crt").exists()
# The wildcard itself is not a SAN.
cert = x509.load_pem_x509_certificate((home / "iris" / "iris.crt").read_bytes())
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
assert ipaddress.ip_address(wildcard) not in san.get_values_for_type(x509.IPAddress)
# save_env_value() also sets os.environ (monkeypatch would restore it at
# teardown) -- pop directly so later tests don't see a configured cert.
os.environ.pop("IRIS_HTTP_CERT", None)
os.environ.pop("IRIS_HTTP_KEY", None)
# Public IPv6 bind + Enter → default Yes as well (same exposure).
(home / "iris" / "iris.crt").unlink()
(home / "iris" / "iris.key").unlink()
(home / ".env").write_text("") # drop the saved vars so the prompt returns
plugin.setup._offer_tls_setup("::", "192.168.1.10")
assert (home / "iris" / "iris.crt").exists()
os.environ.pop("IRIS_HTTP_CERT", None)
os.environ.pop("IRIS_HTTP_KEY", None)
# Loopback bind + Enter → default No → declined (no cert, and the prompt
# was actually asked -- input() was consumed).
(home / "iris" / "iris.crt").unlink()
(home / "iris" / "iris.key").unlink()
(home / ".env").write_text("")
plugin.setup._offer_tls_setup("127.0.0.1", "192.168.1.10")
assert not (home / "iris" / "iris.crt").exists()
def test_offer_tls_setup_existing_cert_asks_before_overwrite(tmp_path, monkeypatch):
"""A leftover cert (env var removed) is not silently regenerated -- that
would invalidate the app's pinned fingerprint. Declining keeps it;
accepting replaces it and re-saves the env vars."""
from hermes_constants import get_hermes_home
plugin = _load_plugin()
home = get_hermes_home()
iris_dir = home / "iris"
iris_dir.mkdir(parents=True, exist_ok=True)
old_cert = iris_dir / "iris.crt"
old_key = iris_dir / "iris.key"
plugin.setup._generate_self_signed_cert(old_cert, old_key, ["192.168.1.10"])
old_bytes = old_cert.read_bytes()
# Decline the overwrite prompt: old cert untouched, no env vars saved.
monkeypatch.setattr("builtins.input", lambda *a: "n") # type: ignore[arg-type]
plugin.setup._offer_tls_setup("192.168.1.10", "192.168.1.10")
assert old_cert.read_bytes() == old_bytes
env = (home / ".env").read_text() if (home / ".env").exists() else ""
assert "IRIS_HTTP_CERT" not in env
# Accept: cert replaced (new random key/serial) and env vars saved.
monkeypatch.setattr("builtins.input", lambda *a: "y") # type: ignore[arg-type]
plugin.setup._offer_tls_setup("192.168.1.10", "192.168.1.10")
assert old_cert.read_bytes() != old_bytes
env = (home / ".env").read_text()
assert f"IRIS_HTTP_CERT={old_cert}" in env
os.environ.pop("IRIS_HTTP_CERT", None)
os.environ.pop("IRIS_HTTP_KEY", None)
# ── M2: tool-detail capture (verbose args + post_tool_call output) ─────────