gateway setup: offer self-signed TLS cert generation (no openssl needed)
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:
1 parent
b1c9bac7d8
commit
c7a16d51e3
3 files changed
+338
-4
No files matched your search
@@ -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) ─────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user