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

+157
View File
@@ -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())