hermes-agent/gateway/pairing.py
Teknium 249c69b958
fix(gateway): per-profile pairing whitelist isolation in multiplex mode (#53045 salvage) (#59330)
* fix(gateway): per-profile pairing whitelist isolation for multiplex gateways

Pairing approvals are stored per profile (profiles/<name>/pairing/) and
authz routes pairing checks through the serving profile's store, so one
profile's approved users no longer authorize against every other
profile's whitelist in multiplex mode.

The global store remains for the hermes pairing CLI and single-profile
gateways; unregistered/unstamped sources fall back to it, preserving
existing behavior.

Salvaged from PR #53045 (pairing half). The SOUL.md half was dropped:
the agent turn already runs inside _profile_runtime_scope on main, so
load_soul_md() resolves per-profile without changes.

Original work by @soddy022.

* ci: redispatch after arm64 docker dashboard-slot flake (unrelated to this PR)

---------

Co-authored-by: soddy022 <290613374+soddy022@users.noreply.github.com>
2026-07-07 18:25:52 +10:00

611 lines
24 KiB
Python

"""
DM Pairing System
Code-based approval flow for authorizing new users on messaging platforms.
Instead of static allowlists with user IDs, unknown users receive a one-time
pairing code that the bot owner approves via the CLI.
Security features (based on OWASP + NIST SP 800-63-4 guidance):
- 8-char codes from 32-char unambiguous alphabet (no 0/O/1/I)
- Cryptographic randomness via secrets.choice()
- 1-hour code expiry
- Max 3 pending codes per platform
- Rate limiting: 1 request per user per 10 minutes
- Lockout after 5 failed approval attempts (1 hour)
- File permissions: chmod 0600 on all data files
- Codes are never logged to stdout
Storage: ~/.hermes/pairing/
"""
import hashlib
import json
import logging
import os
import secrets
import tempfile
import threading
import time
from pathlib import Path
from typing import Optional
from gateway.whatsapp_identity import (
expand_whatsapp_aliases,
normalize_whatsapp_identifier,
)
from hermes_constants import get_hermes_dir
from utils import atomic_replace
logger = logging.getLogger(__name__)
# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
CODE_LENGTH = 8
# Timing constants
CODE_TTL_SECONDS = 3600 # Codes expire after 1 hour
RATE_LIMIT_SECONDS = 600 # 1 request per user per 10 minutes
LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures
# Limits
MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform
MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
PAIRING_DIR = get_hermes_dir("platforms/pairing", "pairing")
# Platform value -> its per-platform allowlist env var. When an operator has
# already configured an allowlist for a platform, approving a pairing code also
# writes the user into that allowlist (and revoking removes them), so the
# operator's own list stays the single visible/editable source of truth instead
# of drifting from an opaque approved.json (#23778 consolidation, option i).
# Platforms absent from this map (or with no allowlist configured) keep the
# pairing store as the sole grant record, honored by the authz union.
_PLATFORM_ALLOWLIST_ENV = {
"telegram": "TELEGRAM_ALLOWED_USERS",
"discord": "DISCORD_ALLOWED_USERS",
"whatsapp": "WHATSAPP_ALLOWED_USERS",
"whatsapp_cloud": "WHATSAPP_CLOUD_ALLOWED_USERS",
"slack": "SLACK_ALLOWED_USERS",
"signal": "SIGNAL_ALLOWED_USERS",
"email": "EMAIL_ALLOWED_USERS",
"sms": "SMS_ALLOWED_USERS",
"mattermost": "MATTERMOST_ALLOWED_USERS",
"matrix": "MATRIX_ALLOWED_USERS",
"dingtalk": "DINGTALK_ALLOWED_USERS",
"feishu": "FEISHU_ALLOWED_USERS",
"wecom": "WECOM_ALLOWED_USERS",
"wecom_callback": "WECOM_CALLBACK_ALLOWED_USERS",
"weixin": "WEIXIN_ALLOWED_USERS",
"bluebubbles": "BLUEBUBBLES_ALLOWED_USERS",
"qqbot": "QQ_ALLOWED_USERS",
"yuanbao": "YUANBAO_ALLOWED_USERS",
}
def _allowlist_env_for_platform(platform: str) -> Optional[str]:
"""Return the per-platform allowlist env var name, or None.
Falls back to the platform registry for plugin platforms so a plugin's
own ``allowed_users_env`` is honored too.
"""
platform = (platform or "").lower().strip()
env_var = _PLATFORM_ALLOWLIST_ENV.get(platform)
if env_var:
return env_var
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform)
if entry and entry.allowed_users_env:
return entry.allowed_users_env
except Exception:
pass
return None
def _split_allowlist(raw: str) -> list:
return [uid.strip() for uid in raw.split(",") if uid.strip()]
def _sync_allowlist_add(platform: str, user_id: str) -> None:
"""Add ``user_id`` to the platform allowlist env var IF one is configured.
Option (i): only materialize the grant into the allowlist when the operator
already runs an allowlist for this platform. On an open gateway (no
allowlist) we do nothing — the pairing store remains the grant record and
the authz union honors it, so we never silently convert an open gateway into
a locked one on first pairing.
"""
env_var = _allowlist_env_for_platform(platform)
if not env_var:
return
current = os.getenv(env_var, "").strip()
if not current:
return # No allowlist configured — leave the gateway open (option i).
ids = _split_allowlist(current)
if "*" in ids or str(user_id) in ids:
return # Already covered.
ids.append(str(user_id))
try:
from hermes_cli.config import save_env_value
save_env_value(env_var, ",".join(ids))
except Exception:
# Best-effort: the pairing store grant still authorizes via the union,
# so a failure here degrades to "grant recorded but not mirrored".
pass
def _sync_allowlist_remove(platform: str, user_id: str) -> None:
"""Remove ``user_id`` from the platform allowlist env var if present."""
env_var = _allowlist_env_for_platform(platform)
if not env_var:
return
current = os.getenv(env_var, "").strip()
if not current:
return
ids = _split_allowlist(current)
remaining = [i for i in ids if i != str(user_id)]
if len(remaining) == len(ids):
return # Not present.
try:
from hermes_cli.config import save_env_value, remove_env_value
if remaining:
save_env_value(env_var, ",".join(remaining))
else:
remove_env_value(env_var)
except Exception:
pass
def _secure_write(path: Path, data: str) -> None:
"""Write data to file with restrictive permissions (owner read/write only).
Uses a temp-file + atomic rename so readers always see either the old
complete file or the new one — never a partial write.
"""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, path)
try:
os.chmod(path, 0o600)
except OSError:
pass # Windows doesn't support chmod the same way
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
class PairingStore:
"""
Manages pairing codes and approved user lists.
Data files per platform:
- {platform}-pending.json : pending pairing requests
- {platform}-approved.json : approved (paired) users
- _rate_limits.json : rate limit tracking
When constructed with ``profile="<name>"``, storage lives under
``<HERMES_HOME>/profiles/<name>/pairing/`` (per-profile, used by
multiplexing gateways so each profile has its own whitelist).
Without a profile, storage is the global ``<HERMES_HOME>/pairing/``
directory (backward-compat for the ``hermes pairing`` CLI).
"""
def __init__(self, profile: Optional[str] = None):
# Resolve storage directory lazily — tests use a temp HERMES_HOME
# and PairingStore may be constructed before the env is set.
if profile:
from hermes_constants import get_hermes_home
self._dir = get_hermes_home() / "profiles" / profile / "pairing"
else:
self._dir = PAIRING_DIR
self._dir.mkdir(parents=True, exist_ok=True)
# Protects all read-modify-write cycles. The gateway runs multiple
# platform adapters concurrently in threads sharing one PairingStore.
self._lock = threading.RLock()
self._profile = profile # for diagnostics / log lines
@property
def profile(self) -> Optional[str]:
"""Profile name this store is scoped to, or None for the global store."""
return self._profile
def _pending_path(self, platform: str) -> Path:
return self._dir / f"{platform}-pending.json"
def _approved_path(self, platform: str) -> Path:
return self._dir / f"{platform}-approved.json"
def _rate_limit_path(self) -> Path:
return self._dir / "_rate_limits.json"
def _load_json(self, path: Path) -> dict:
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except PermissionError as e:
# Surface this loudly: a 0600 file owned by a different user
# (classic Docker symptom: `docker exec` runs as root and writes
# the file, then the gateway process — running as `hermes` after
# gosu drop — can't read it) would otherwise be swallowed by
# the generic OSError branch below, silently leaving the user
# marked unauthorized. See issue #10270.
try:
st = path.stat()
owner_info = f"owner_uid={st.st_uid} mode={oct(st.st_mode)[-4:]}"
except OSError:
owner_info = "<stat failed>"
# os.geteuid doesn't exist on Windows; the Docker scenario is
# POSIX-only, but the gateway (and this fallback) runs anywhere.
euid = os.geteuid() if hasattr(os, "geteuid") else "n/a"
logger.warning(
"Pairing file %s exists but is not readable as uid=%s (%s; %s). "
"If you ran `docker exec <container> hermes pairing approve ...` as root, "
"re-run with `docker exec -u hermes <container> ...` and "
"chown the existing file to the hermes user, or restart the "
"container so the entrypoint can fix ownership.",
path, euid, owner_info, e,
)
return {}
except (json.JSONDecodeError, OSError):
return {}
return {}
def _save_json(self, path: Path, data: dict) -> None:
_secure_write(path, json.dumps(data, indent=2, ensure_ascii=False))
def _normalize_user_id(self, platform: str, user_id: str) -> str:
"""Normalize platform-specific user IDs before persisting them."""
raw_user_id = str(user_id or "").strip()
if platform == "whatsapp":
return normalize_whatsapp_identifier(raw_user_id) or raw_user_id
return raw_user_id
def _user_id_aliases(self, platform: str, user_id: str) -> set[str]:
"""Return all known equivalent user IDs for auth/rate-limit checks."""
raw_user_id = str(user_id or "").strip()
if not raw_user_id:
return set()
aliases = {raw_user_id, self._normalize_user_id(platform, raw_user_id)}
if platform == "whatsapp":
aliases.update(expand_whatsapp_aliases(raw_user_id))
aliases.discard("")
return aliases
def _user_ids_match(self, platform: str, left: str, right: str) -> bool:
"""Return True when two user IDs represent the same principal."""
left_aliases = self._user_id_aliases(platform, left)
right_aliases = self._user_id_aliases(platform, right)
return bool(left_aliases and right_aliases and (left_aliases & right_aliases))
# ----- Approved users -----
def is_approved(self, platform: str, user_id: str) -> bool:
"""Check if a user is approved (paired) on a platform."""
approved = self._load_json(self._approved_path(platform))
for approved_user_id in approved:
if self._user_ids_match(platform, approved_user_id, user_id):
return True
return False
def list_approved(self, platform: str = None) -> list:
"""List approved users, optionally filtered by platform."""
results = []
platforms = [platform] if platform else self._all_platforms("approved")
for p in platforms:
approved = self._load_json(self._approved_path(p))
for uid, info in approved.items():
results.append({"platform": p, "user_id": uid, **info})
return results
def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None:
"""Add a user to the approved list. Must be called under self._lock."""
approved = self._load_json(self._approved_path(platform))
normalized_user_id = self._normalize_user_id(platform, user_id)
duplicate_ids = [
approved_user_id
for approved_user_id in approved
if self._user_ids_match(platform, approved_user_id, normalized_user_id)
]
for approved_user_id in duplicate_ids:
del approved[approved_user_id]
approved[normalized_user_id] = {
"user_name": user_name,
"approved_at": time.time(),
}
self._save_json(self._approved_path(platform), approved)
# Mirror the grant into the operator's allowlist when one is configured
# (option i), so the pairing store and the allowlist stay a single
# visible source of truth. No-op on open gateways.
_sync_allowlist_add(platform, normalized_user_id)
def revoke(self, platform: str, user_id: str) -> bool:
"""Remove a user from the approved list. Returns True if found."""
path = self._approved_path(platform)
with self._lock:
approved = self._load_json(path)
matching_ids = [
approved_user_id
for approved_user_id in approved
if self._user_ids_match(platform, approved_user_id, user_id)
]
if matching_ids:
for approved_user_id in matching_ids:
del approved[approved_user_id]
self._save_json(path, approved)
# Keep the allowlist mirror in sync: revoking a paired user
# also removes the entry the approval added (option i). No-op if
# the user was added to the allowlist by other means.
_sync_allowlist_remove(platform, user_id)
return True
return False
# ----- Pending codes -----
@staticmethod
def _hash_code(code: str, salt: bytes) -> str:
"""Hash a pairing code with the given salt using SHA-256."""
return hashlib.sha256(salt + code.encode("utf-8")).hexdigest()
def generate_code(
self, platform: str, user_id: str, user_name: str = ""
) -> Optional[str]:
"""
Generate a pairing code for a new user.
Returns the code string, or None if:
- User is rate-limited (too recent request)
- Max pending codes reached for this platform
- User/platform is in lockout due to failed attempts
The code is NOT stored in plaintext. Only a salted SHA-256 hash is
persisted so that reading the pending file does not reveal codes.
"""
with self._lock:
self._cleanup_expired(platform)
normalized_user_id = self._normalize_user_id(platform, user_id)
# Check lockout
if self._is_locked_out(platform):
return None
# Check rate limit for this specific user
if self._is_rate_limited(platform, user_id):
return None
# Check max pending
pending = self._load_json(self._pending_path(platform))
if len(pending) >= MAX_PENDING_PER_PLATFORM:
return None
# Generate cryptographically random code
code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))
# Hash the code with a random salt before storing
salt = os.urandom(16)
code_hash = self._hash_code(code, salt)
# Use a unique entry id as the key (not the code itself)
entry_id = secrets.token_hex(8)
# Store pending request with hashed code
pending[entry_id] = {
"hash": code_hash,
"salt": salt.hex(),
"user_id": normalized_user_id,
"user_name": user_name,
"created_at": time.time(),
}
self._save_json(self._pending_path(platform), pending)
# Record rate limit
self._record_rate_limit(platform, user_id)
return code
def approve_code(self, platform: str, code: str) -> Optional[dict]:
"""
Approve a pairing code. Adds the user to the approved list.
Returns ``{user_id, user_name}`` on success, ``None`` if the code is
invalid/expired OR the platform is currently locked out after
``MAX_FAILED_ATTEMPTS`` failed approvals (#10195). Callers can
disambiguate with ``_is_locked_out(platform)``.
Verification: the user-provided code is hashed with each stored
entry's salt and compared to the stored hash using constant-time
comparison. Pre-hash entries (legacy plaintext-key format from
pre-upgrade pending.json files) are silently ignored — they get
pruned at TTL by ``_cleanup_expired``.
"""
with self._lock:
self._cleanup_expired(platform)
code = code.upper().strip()
# Lockout check — must run before the pending lookup so a
# valid code (e.g. one already sitting in pending) cannot be
# accepted once the lockout fires. Without this, the lockout
# only blocks `generate_code`, not `approve_code` — nullifying
# the brute-force protection for any code already issued.
if self._is_locked_out(platform):
return None
pending = self._load_json(self._pending_path(platform))
# Find the entry whose hash matches the provided code.
# Tolerate legacy plaintext-key entries (no salt/hash) and
# malformed entries — skip them rather than KeyError, so an
# in-place upgrade across an existing pending.json doesn't
# crash on the first approve call. Legacy entries get pruned
# at their TTL by _cleanup_expired.
matched_key = None
matched_entry = None
for entry_id, entry in pending.items():
if not isinstance(entry, dict):
continue
if "salt" not in entry or "hash" not in entry:
continue
try:
salt = bytes.fromhex(entry["salt"])
except ValueError:
continue
candidate_hash = self._hash_code(code, salt)
if secrets.compare_digest(candidate_hash, entry["hash"]):
matched_key = entry_id
matched_entry = entry
break
if matched_key is None:
self._record_failed_attempt(platform)
return None
del pending[matched_key]
self._save_json(self._pending_path(platform), pending)
# Add to approved list
self._approve_user(platform, matched_entry["user_id"],
matched_entry.get("user_name", ""))
return {
"user_id": matched_entry["user_id"],
"user_name": matched_entry.get("user_name", ""),
}
def list_pending(self, platform: str = None) -> list:
"""List pending pairing requests, optionally filtered by platform.
Codes are stored hashed — the ``code`` field is replaced with the
first 8 hex characters of the hash so admins can distinguish entries
without revealing the original code. Legacy plaintext-key entries
(pre-hash format) are shown with a "legacy" placeholder so admins
can see them age out without crashing on a missing ``hash`` field.
"""
results = []
with self._lock:
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
self._cleanup_expired(p)
pending = self._load_json(self._pending_path(p))
for entry_id, info in pending.items():
if not isinstance(info, dict):
continue
created_at = info.get("created_at")
if not isinstance(created_at, (int, float)):
continue
age_min = int((time.time() - created_at) / 60)
hash_val = info.get("hash")
code_display = hash_val[:8] if isinstance(hash_val, str) else "legacy"
results.append({
"platform": p,
"code": code_display,
"user_id": info.get("user_id", ""),
"user_name": info.get("user_name", ""),
"age_minutes": age_min,
})
return results
def clear_pending(self, platform: str = None) -> int:
"""Clear all pending requests. Returns count removed."""
with self._lock:
count = 0
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
pending = self._load_json(self._pending_path(p))
count += len(pending)
self._save_json(self._pending_path(p), {})
return count
# ----- Rate limiting and lockout -----
def _is_rate_limited(self, platform: str, user_id: str) -> bool:
"""Check if a user has requested a code too recently."""
limits = self._load_json(self._rate_limit_path())
for alias in self._user_id_aliases(platform, user_id):
key = f"{platform}:{alias}"
last_request = limits.get(key, 0)
if (time.time() - last_request) < RATE_LIMIT_SECONDS:
return True
return False
def _record_rate_limit(self, platform: str, user_id: str) -> None:
"""Record the time of a pairing request for rate limiting."""
limits = self._load_json(self._rate_limit_path())
now = time.time()
for alias in self._user_id_aliases(platform, user_id):
key = f"{platform}:{alias}"
limits[key] = now
self._save_json(self._rate_limit_path(), limits)
def _is_locked_out(self, platform: str) -> bool:
"""Check if a platform is in lockout due to failed approval attempts."""
limits = self._load_json(self._rate_limit_path())
lockout_key = f"_lockout:{platform}"
lockout_until = limits.get(lockout_key, 0)
return time.time() < lockout_until
def _record_failed_attempt(self, platform: str) -> None:
"""Record a failed approval attempt. Triggers lockout after MAX_FAILED_ATTEMPTS."""
limits = self._load_json(self._rate_limit_path())
fail_key = f"_failures:{platform}"
fails = limits.get(fail_key, 0) + 1
limits[fail_key] = fails
if fails >= MAX_FAILED_ATTEMPTS:
lockout_key = f"_lockout:{platform}"
limits[lockout_key] = time.time() + LOCKOUT_SECONDS
limits[fail_key] = 0 # Reset counter
print(f"[pairing] Platform {platform} locked out for {LOCKOUT_SECONDS}s "
f"after {MAX_FAILED_ATTEMPTS} failed attempts", flush=True)
self._save_json(self._rate_limit_path(), limits)
# ----- Cleanup -----
def _cleanup_expired(self, platform: str) -> None:
"""Remove expired pending codes.
Tolerant of malformed / legacy entries — anything without a numeric
``created_at`` is treated as expired (it's effectively unusable
with the new hash-keyed schema anyway).
"""
path = self._pending_path(platform)
pending = self._load_json(path)
now = time.time()
expired = []
for entry_id, info in pending.items():
if not isinstance(info, dict):
expired.append(entry_id)
continue
created_at = info.get("created_at")
if not isinstance(created_at, (int, float)):
expired.append(entry_id)
continue
if (now - created_at) > CODE_TTL_SECONDS:
expired.append(entry_id)
if expired:
for entry_id in expired:
del pending[entry_id]
self._save_json(path, pending)
def _all_platforms(self, suffix: str) -> list:
"""List all platforms that have data files of a given suffix."""
platforms = []
for f in PAIRING_DIR.iterdir():
if f.name.endswith(f"-{suffix}.json"):
platform = f.name.replace(f"-{suffix}.json", "")
if not platform.startswith("_"):
platforms.append(platform)
return platforms