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
@@ -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())
|
||||
|
||||
@@ -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