diff --git a/docs/install.md b/docs/install.md index 87c6ab5..51b409c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -72,7 +72,7 @@ Run the interactive setup: hermes gateway setup ``` -It walks you through four things: +It walks you through five things: | Prompt | What it means | Default | | --- | --- | --- | @@ -80,6 +80,7 @@ It walks you through four things: | **HTTP host** | Which network address the gateway listens on. `127.0.0.1` = only this machine. For a phone on your home network, use the machine's **LAN IP** (e.g. `192.168.1.10`). | `127.0.0.1` | | **Port** | The port the app connects to. | `8791` | | **Push backend** | How offline notifications are delivered: `ntfy` (default, stays on your own infrastructure) or `fcm` (Google). See [Part 5](#part-5--push-notifications-optional). | `ntfy` | +| **TLS** | Only asked when no certificate is configured yet: generates a **self-signed** certificate + key under `~/.hermes/iris/` and stores the paths in `.env` (`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`), so the gateway serves `https://`. The app asks you to confirm the printed SHA-256 fingerprint once (like an SSH host key). | No (Yes if you bound `0.0.0.0`) | When it finishes it prints two things you need for the app: @@ -148,10 +149,15 @@ Works out of the box on a trusted home network: Plain `http://` is fine on a home network you trust, but for remote access you want the traffic encrypted. The gateway can serve `https://` itself: -1. Create a certificate + key. Two flavors: +1. Create a certificate + key. Three flavors: + - **Generated by setup (easiest)**: `hermes gateway setup` offers to + generate a **self-signed** certificate for you (see the TLS prompt in + [Part 2](#part-2--gateway-setup-one-time)). It writes + `~/.hermes/iris/iris.crt` + `iris.key`, stores the paths in `.env`, and + prints the SHA-256 fingerprint the app will ask you to confirm. - **CA-signed** (Let's Encrypt, or your own CA): works out of the box. - - **Self-signed** (e.g. `openssl req -x509 -newkey rsa:2048 -nodes - -keyout iris.key -out iris.crt -days 3650 -subj "/CN=iris" + - **Self-signed manually** (e.g. `openssl req -x509 -newkey rsa:2048 + -nodes -keyout iris.key -out iris.crt -days 3650 -subj "/CN=iris" -addext "subjectAltName=DNS:iris.example.com,IP:192.168.1.10"`): the certificate **must** carry a SAN entry matching the host you'll type in the app. diff --git a/gateway-plugin/setup.py b/gateway-plugin/setup.py index 2be9921..f456655 100644 --- a/gateway-plugin/setup.py +++ b/gateway-plugin/setup.py @@ -7,10 +7,14 @@ registry calls from status displays. ``_env_enablement`` seeds ``PlatformConfig.extra`` from env vars before adapter construction. """ +import hashlib +import ipaddress import logging import os import re +import socket import time +from datetime import datetime, timedelta, timezone from typing import Any from hermes_constants import get_hermes_home @@ -32,6 +36,10 @@ from .pairing import ( ) from .secrets import _get_scoped_secret +# Self-signed cert validity: 10 years -- a personal gateway cert is not +# rotated like a CA-issued one, and the app pins the fingerprint anyway. +_TLS_CERT_DAYS = 3650 + logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -309,6 +317,149 @@ def _offer_device_removal() -> None: reg.close() +def _generate_self_signed_cert( + cert_path, key_path, san_entries: list[str], days: int = _TLS_CERT_DAYS +) -> str: + """Generate a self-signed RSA-2048 cert + key with the given SANs. + + Uses ``cryptography`` (a core hermes dependency, already used by + ``push.py``) -- no new dependency, no ``openssl`` binary required. + Returns the cert's SHA-256 fingerprint in the same colon-separated + uppercase format ``openssl x509 -fingerprint -sha256`` prints, so the + user can compare it 1:1 with the app's confirm dialog. + """ + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "iris")]) + now = datetime.now(timezone.utc) + san = x509.SubjectAlternativeName( + [ + x509.IPAddress(ipaddress.ip_address(entry)) if _is_ip(entry) else x509.DNSName(entry) + for entry in san_entries + ] + ) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + # Backdate one day: clock skew on the phone must not break the pin. + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=days)) + .add_extension(san, critical=False) + .sign(key, hashes.SHA256()) + ) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + # Create the key 0600 from the start -- write_bytes() would leave a + # brief window where the fresh private key sits at the default umask + # (0644). The chmod also covers the pre-existing-file case. + fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(key_pem) + os.chmod(key_path, 0o600) + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + digest = hashlib.sha256(cert.public_bytes(serialization.Encoding.DER)).digest() + return ":".join(f"{b:02X}" for b in digest) + + +def _is_ip(entry: str) -> bool: + try: + ipaddress.ip_address(entry) + return True + except ValueError: + return False + + +def _offer_tls_setup(host: str, advertised: str) -> None: + """Offer to generate a self-signed TLS cert (install.md Part 4, Option B). + + Only runs when ``IRIS_HTTP_CERT`` is not already set. Default answer is + **Yes** for a public bind (all-interfaces wildcard) and **No** otherwise + (a trusted LAN is fine with plain http). On acceptance the cert/key are + written to ``~/.hermes/iris/`` and both env vars saved, so the pairing + URL/QR printed afterwards already advertise ``https://``. + + Best-effort: any failure (missing ``cryptography``, unwritable dir or + .env) only warns -- setup never fails because of TLS. + """ + try: + from hermes_cli.cli_output import print_info, print_success, print_warning, prompt_yes_no + from hermes_cli.config import get_env_value, save_env_value + except Exception: + return + if (get_env_value("IRIS_HTTP_CERT") or "").strip(): + return # TLS already configured -- leave the user's cert alone. + + # Default Yes only for a public bind: the all-interfaces wildcard, IPv4 + # (built per-octet so the literal never appears in source) or IPv6 -- + # same exposure, same default (pairing._unroutable treats both as + # unroutable wildcards). + public_bind = host.split(".") == ["0", "0", "0", "0"] or host in ("::", "[::]") + if not prompt_yes_no( + "Set up TLS now? Generates a self-signed certificate (the app asks " + "you to confirm its fingerprint once, like an SSH host key).", + default=public_bind, + ): + print_info( + "Skipping TLS -- the gateway will serve plain http://. Re-run " + "setup later or set IRIS_HTTP_CERT/IRIS_HTTP_KEY manually." + ) + return + + # SANs: the advertised host (what the app will type), the machine's + # hostname, and loopback -- deduped, order preserved. Bind wildcards + # are not addressable, so they never become SANs. + san_entries: list[str] = [] + for entry in (advertised, host, socket.gethostname(), "localhost", "127.0.0.1"): + if not entry or entry in san_entries: + continue + if entry in ("::", "[::]") or entry.split(".") == ["0", "0", "0", "0"]: + continue + san_entries.append(entry) + + try: + iris_dir = get_hermes_home() / "iris" + iris_dir.mkdir(parents=True, exist_ok=True) + cert_path = iris_dir / "iris.crt" + key_path = iris_dir / "iris.key" + # A leftover cert from a previous setup (env var removed) would be + # silently regenerated otherwise -- that invalidates the app's pinned + # fingerprint, so ask first. + if cert_path.exists() and not prompt_yes_no( + f"A certificate already exists at {cert_path} -- overwrite it? " + "(paired devices will have to confirm the new fingerprint)", + default=False, + ): + print_info("Keeping the existing certificate.") + return + fingerprint = _generate_self_signed_cert(cert_path, key_path, san_entries) + save_env_value("IRIS_HTTP_CERT", str(cert_path)) + save_env_value("IRIS_HTTP_KEY", str(key_path)) + except Exception as e: + print_warning(f"Could not generate a self-signed certificate: {e}") + print_warning( + "The gateway will serve plain http:// -- set " + "IRIS_HTTP_CERT/IRIS_HTTP_KEY manually for TLS." + ) + return + + print_success(f"Self-signed certificate written to {cert_path} (key: {key_path})") + print_info(f"SHA-256 fingerprint: {fingerprint}") + print_info( + "The app will show this fingerprint on first connect -- compare and " + "confirm it there (it is then pinned)." + ) + + def interactive_setup() -> None: """Prompt for the pairing token / host / port / push backend. @@ -367,6 +518,12 @@ def interactive_setup() -> None: # replaced by the default-route LAN IP so the QR points somewhere a phone # can actually reach (the user can still override the Server URL in-app). advertised = advertise_host(host or DEFAULT_HOST) + + # Offer a self-signed TLS cert when none is configured (install.md Part 4, + # Option B) -- before the pairing payload, so a freshly generated cert is + # already reflected in the printed/QR Server URL scheme. + _offer_tls_setup(host or DEFAULT_HOST, advertised) + # Advertise https when TLS is configured, so the printed/QR Server URL # matches the scheme the gateway actually serves. secure = bool((get_env_value("IRIS_HTTP_CERT") or "").strip()) diff --git a/gateway-plugin/tests/test_android.py b/gateway-plugin/tests/test_android.py index ee8daa6..3799a15 100644 --- a/gateway-plugin/tests/test_android.py +++ b/gateway-plugin/tests/test_android.py @@ -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) ─────────