Files
hermes-relay/plugin/cli.py
T
Bailey DixonandClaude Opus 4.6 11bf512eaa feat(auth): pairing + security architecture — grants, TTL picker, Keystore, TOFU, Paired Devices
Full overhaul of the pairing model + session security based on Bailey's
design request. Everything lives in files we own; no upstream patches.

## Why

The existing model was minimal: one-shot pairing codes → fixed-30-day
session tokens → no channel separation → EncryptedSharedPreferences
storage. After the inbound media work exposed the "phone rate-limited
to death on relay restart" gap, the entire pairing story was ready
for a pass. Bailey asked for: user-chosen TTL at pair time (including
"never"), per-channel grants, secure-by-default transport UX, device
revocation UI, Android Keystore storage, TOFU cert pinning, QR signing,
Phase 3 bidirectional pairing foundation, and Tailscale detection.

Never expire is explicitly always selectable per Bailey's direction:
dont force check, just allow based on user intent. User agency over
policy.

## Server - plugin/relay/

- auth.py: Session gains grants + transport_hint + first_seen; math.inf
  for never-expire (serializes as null); PairingMetadata dataclass
  threaded through PairingManager.register_code + consume_code (returns
  metadata or None, not bool); SessionManager create_session accepts
  ttl_seconds + grants + transport_hint; _materialize_grants clamps
  per-channel grants to session lifetime; list_sessions + find_by_prefix
  + revoke for the new routes; RateLimiter.clear_all_blocks() called
  unconditionally when /pairing/register succeeds (fixes the stale
  block prevents re-pair bug). Default caps: terminal 30d, bridge 7d.

- server.py: /pairing/register accepts ttl_seconds/grants/transport_hint
  metadata (host wins over phone); new /pairing/approve (Phase 3 stub);
  new GET /sessions (tokens masked to 8-char prefix, is_current flag);
  new DELETE /sessions/{prefix} (200/404/409, self-revoke flagged);
  _authenticate threads pairing metadata into new session;
  _detect_transport_hint sniffs SSL from request transport; auth.ok
  payload carries expires_at + grants + transport_hint (inf to null).

- qr_sign.py NEW: HMAC-SHA256 over canonicalized JSON. Canonical form
  strips sig so signing is idempotent. load_or_create_secret reads or
  creates ~/.hermes/hermes-relay-qr-secret (32 bytes, 0o600).
  allow_nan=False so signing math.inf crashes loudly.

- pair.py + cli.py: new --ttl and --grants flags. parse_duration for
  1d/7d/30d/90d/1y/never + loose patterns. parse_grants for
  terminal=7d,bridge=1d. build_payload(sign=True) auto-bumps hermes 1
  to 2 when v2 fields present and attaches HMAC signature.
  read_relay_config detects TLS via RELAY_SSL_CERT. render_text_block
  shows Pair for 30 days / indefinitely plus grant labels.

- __version__ stays at 0.2.0 per Bailey: we never released 0.2.0.

- Tests: 4 new test modules covering qr_sign, session_grants,
  sessions_routes, rate_limit_clear. Existing media tests still pass.

## Phone - app/src/main/kotlin/.../

- auth/SessionTokenStore.kt NEW: interface + KeystoreTokenStore
  (StrongBox-preferred) + LegacyEncryptedPrefsTokenStore fallback.
  tryCreate swallows exceptions. One-shot lossless migration.
  hasHardwareBackedStorage flag surfaced in UI.

- auth/CertPinStore.kt NEW: TOFU cert pinning. SHA-256 SPKI fingerprints
  per host:port in DataStore. recordPinIfAbsent on first successful wss
  handshake; buildPinnerSnapshot produces an OkHttp CertificatePinner.
  removePinFor called by applyServerIssuedCodeAndReset on QR re-pair.

- auth/PairedSession.kt NEW: PairedSession state class + PairedDeviceInfo
  wire model.

- auth/AuthManager.kt: lazy-init SessionTokenStore picker with
  migration; currentPairedSession StateFlow; parses expires_at/grants/
  transport_hint from auth.ok; authenticate(ttlSeconds) injects
  ttl_seconds + grants into pairing-mode auth envelope;
  applyServerIssuedCodeAndReset wipes TOFU pin for target host.

- data/PairingPreferences.kt NEW: DataStore keys pair_ttl_seconds,
  insecure_ack_seen, insecure_reason, tofu_pins.

- util/TailscaleDetector.kt NEW: NetworkInterface scan for tailscale0
  plus 100.64.0.0/10 CGNAT plus relay URL host check. Informational
  only.

- ui/components/SessionTtlPickerDialog.kt NEW: radio list
  1d/7d/30d/90d/1y/Never. Defaults: QR wins, wss/Tailscale 30d,
  ws 7d, unknown 30d. Never warning inline, NOT gated.

- ui/components/TransportSecurityBadge.kt NEW: three states, three
  sizes.

- ui/components/InsecureConnectionAckDialog.kt NEW: first-time consent
  with reason picker. Reason persists for display, not gating.

- ui/screens/PairedDevicesScreen.kt NEW: full list with transport
  badge + expiry + grant chips + revoke button. Self-revoke wipes
  local state + redirects to pair.

- network/RelayHttpClient.kt: new listSessions and
  revokeSession(tokenPrefix) methods. 404 handled as endpoint not yet
  implemented for forward-compat.

- network/ConnectionManager.kt: optional CertPinStore param. Rebuilds
  OkHttpClient on every connect with fresh CertificatePinner snapshot.

- ui/components/QrPairingScanner.kt: HermesPairingPayload gains sig
  field and defaults; RelayPairing gains ttlSeconds/grants/
  transportHint. parseHermesPairingQr no longer rejects on version
  mismatch.

- viewmodel/ConnectionViewModel.kt: wires AuthManager before
  ConnectionManager so pin store is available. Exposes
  currentPairedSession, pairedDevices, isTailscaleDetected,
  insecureAckSeen, insecureReason.

- ui/RelayApp.kt: new Screen.PairedDevices route.

- ui/screens/SettingsScreen.kt: TransportSecurityBadge + Tailscale
  chip + hardware storage chip + Paired Devices row in Connection
  card. Insecure toggle routes through ack dialog. QR scan handoff
  opens SessionTtlPickerDialog.

- ui/components/ConnectionInfoSheet.kt: SessionInfoSheet shows
  Expires / Channel grants / Transport / Key storage rows.

## Docs

DEVLOG entry, ADR 15, spec.md section 3.3/3.3.1/3.4 rewrites, relay
route tables in docs + user-docs, Paired Devices subsection in
configuration.md, pair flow extension in getting-started.md, CLAUDE.md
Current State + Key Files + Integration Points.

## Known gaps filed for follow-up

- Phone-side QR signature verification (parsing works; full verify
  needs secret distribution path)
- Bidirectional pairing full UX tied to Phase 3 bridge
- Per-device role model (admin vs user)
- Grant renewal UI on Paired Devices
- Build verification: no gradle run this session; Bailey deploys from
  Android Studio

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:06:04 -04:00

185 lines
5.9 KiB
Python

"""CLI sub-command registration for the hermes-android plugin.
Registers the following top-level `hermes` sub-commands:
hermes pair [--png] [--no-qr] [--host HOST] [--port PORT]
hermes relay start [--port PORT] [--no-ssl] [--log-level LEVEL]
Discovered by the hermes-agent v0.8.0+ plugin CLI registration system. The
plugin loader calls ``register_cli(subparser)`` with a freshly-built parser
for each sub-command; handlers are dispatched via ``args.func``.
"""
from __future__ import annotations
# ── hermes pair ───────────────────────────────────────────────────────────────
def register_cli(subparser) -> None:
"""Attach `hermes pair` arguments to the provided subparser."""
subparser.add_argument(
"--png",
action="store_true",
help="Save PNG to the system temp dir only (no terminal QR)",
)
subparser.add_argument(
"--no-qr",
action="store_true",
dest="no_qr",
help="Show connection details as text only (no QR code)",
)
subparser.add_argument(
"--no-relay",
action="store_true",
dest="no_relay",
help=(
"Skip relay pre-pairing. Renders an API-only QR — useful if "
"you're only pairing for direct chat and haven't started the "
"relay server."
),
)
subparser.add_argument(
"--host",
metavar="HOST",
help="Override API server host (default: auto-detect LAN IP)",
)
subparser.add_argument(
"--port",
metavar="PORT",
type=int,
help="Override API server port (default: 8642)",
)
subparser.add_argument(
"--ttl",
metavar="DURATION",
default="30d",
help=(
"Session TTL — one of 1d/7d/30d/90d/1y/never, or an explicit "
"<N><unit> like 12h/4w. 'never' means the session never expires. "
"Default: 30d."
),
)
subparser.add_argument(
"--grants",
metavar="SPEC",
default=None,
help=(
"Per-channel grant overrides, comma-separated channel=duration "
"pairs, e.g. 'terminal=7d,bridge=1d'. Unspecified channels fall "
"back to server-side defaults (terminal capped at 30d, bridge "
"capped at 7d)."
),
)
subparser.set_defaults(func=pair_command)
def pair_command(args) -> None:
"""Dispatch to the pairing module (lazy import for fast CLI startup)."""
from .pair import pair_command as _pair
_pair(args)
# ── hermes relay ──────────────────────────────────────────────────────────────
def register_relay_cli(subparser) -> None:
"""Attach `hermes relay` sub-parser.
Currently exposes ``hermes relay start`` (run the WSS server in the
foreground). Lifecycle management (``status``/``stop``) is deferred to
a later session since the plan calls for libtmux-driven persistence
that we haven't built yet.
"""
sub = subparser.add_subparsers(dest="relay_cmd", required=True)
start = sub.add_parser(
"start",
help="Run the Hermes-Relay WSS server (chat + terminal + bridge)",
)
start.add_argument("--host", metavar="HOST", help="Bind address (default: 0.0.0.0)")
start.add_argument("--port", type=int, help="Listen port (default: 8767)")
start.add_argument(
"--no-ssl",
action="store_true",
help="Disable SSL (development only)",
)
start.add_argument(
"--shell",
metavar="PATH",
help="Default shell for terminal sessions (absolute path; default: $SHELL)",
)
start.add_argument(
"--webapi-url",
metavar="URL",
help="Hermes WebAPI base URL (default: http://localhost:8642)",
)
start.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level (default: INFO)",
)
start.set_defaults(func=relay_start_command)
def relay_start_command(args) -> None:
"""Run the relay server in the foreground.
Assembles a ``RelayConfig`` from env, applies CLI overrides, then hands
off to :func:`plugin.relay.server.main`. Lazy-imports so ``hermes --help``
stays fast.
"""
import logging
import sys
from aiohttp import web
from .relay import __version__
from .relay.config import RelayConfig
from .relay.server import create_app, _create_ssl_context
config = RelayConfig.from_env()
if getattr(args, "host", None):
config.host = args.host
if getattr(args, "port", None):
config.port = args.port
if getattr(args, "webapi_url", None):
config.webapi_url = args.webapi_url
if getattr(args, "log_level", None):
config.log_level = args.log_level
if getattr(args, "shell", None):
config.terminal_shell = args.shell
logging.basicConfig(
level=getattr(logging, config.log_level, logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
app = create_app(config)
ssl_ctx = None if getattr(args, "no_ssl", False) else _create_ssl_context(config)
if ssl_ctx is None and not getattr(args, "no_ssl", False):
logging.warning(
"No SSL cert/key configured. Use --no-ssl for development, "
"or set RELAY_SSL_CERT and RELAY_SSL_KEY for production."
)
scheme = "wss" if ssl_ctx else "ws"
logging.info(
"Starting Hermes-Relay v%s on %s://%s:%d",
__version__,
scheme,
config.host,
config.port,
)
web.run_app(
app,
host=config.host,
port=config.port,
ssl_context=ssl_ctx,
print=None,
)