Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

359 lines
12 KiB
Python

"""Messaging security: per-user identity, allowed-users list, and DM pairing.
This module implements the identity model for inbound messaging platforms
(Telegram, Slack, Discord). It provides:
1. MessagingIdentityPolicy — per-platform allowlist and pairing config.
2. DM pairing helpers — one-time code generation, hashing, and verification.
3. Inbound message authorization — check whether a sender is allowed.
Prerequisite for issue #1482 (conversational loop).
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import string
import time
from enum import StrEnum
from pydantic import Field
from config.strict_config import StrictConfigModel
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_PAIRING_CODE_LENGTH = 6
_PAIRING_CODE_ALPHABET = string.ascii_uppercase + string.digits
# Maximum number of failed pairing attempts before the code is invalidated.
_MAX_PAIRING_ATTEMPTS = 5
# Pairing code TTL in seconds (15 minutes).
_PAIRING_CODE_TTL_SECONDS = 900
class RejectionBehavior(StrEnum):
"""How to handle messages from non-paired users."""
REPLY = "reply"
DROP = "drop"
class MessagingPlatform(StrEnum):
"""Supported messaging platforms."""
TELEGRAM = "telegram"
SLACK = "slack"
DISCORD = "discord"
# ---------------------------------------------------------------------------
# Identity Policy Model
# ---------------------------------------------------------------------------
class MessagingIdentityPolicy(StrictConfigModel):
"""Per-platform identity policy for inbound messaging security.
Controls which users are allowed to interact with the bot and how
unauthenticated users are handled.
IMPORTANT — Stable IDs only:
All identity fields (allowed_user_ids, allowed_chat_ids) MUST use
platform-native stable identifiers, never display names or handles:
- Telegram: numeric ``from.id`` (survives username changes)
- Slack: ``U02ABC123`` user ID (not @display-name)
- Discord: snowflake user ID (not username#discriminator)
Handles are for human-facing rendering only. Keying trust decisions
on handles is an impersonation vector (handles can be reassigned).
"""
allowed_user_ids: list[str] = Field(
default_factory=list,
description="Platform-native user IDs allowed to interact (Telegram from.id, Slack user_id, Discord member.user.id)",
)
allowed_chat_ids: list[str] = Field(
default_factory=list,
description="Optional: restrict interactions to specific channels/chats",
)
require_dm_pairing: bool = Field(
default=True,
description="Whether users must complete DM pairing before interacting",
)
pairing_secret_hash: str | None = Field(
default=None,
description="SHA-256 HMAC hash of the one-time pairing code (None = no pending pairing)",
)
pairing_created_at: float | None = Field(
default=None,
description="Unix timestamp when the pairing code was generated (for TTL enforcement)",
)
pairing_attempts: int = Field(
default=0,
description="Number of failed pairing attempts (for brute-force protection)",
)
rejection_behavior: RejectionBehavior = Field(
default=RejectionBehavior.REPLY,
description="How to handle messages from non-paired users: 'reply' or 'drop'",
)
inbound_enabled: bool = Field(
default=False,
description="Whether inbound messaging is enabled for this platform",
)
# ---------------------------------------------------------------------------
# Pairing Code Helpers
# ---------------------------------------------------------------------------
def _get_hmac_key() -> bytes:
"""Derive the HMAC key from the OPENSRE_PAIRING_SECRET env var.
**Production requirement**: Set ``OPENSRE_PAIRING_SECRET`` to a strong,
unique secret in production deployments. Without it, the fallback key is
derived from the machine hostname, which is not secret — if the stored
hash leaks, an attacker could brute-force the 6-char code space offline.
The 5-attempt online limit does not protect against offline attacks on a
leaked hash.
Falls back to a per-machine default derived from the hostname so that
local development and testing work without extra configuration.
"""
env_secret = os.environ.get("OPENSRE_PAIRING_SECRET", "")
if env_secret:
return env_secret.encode()
# Fallback: derive from hostname + a fixed namespace so it's unique per machine
# but deterministic across restarts.
import platform
machine_id = platform.node() or "opensre-default"
return f"opensre-pairing-{machine_id}".encode()
def generate_pairing_code() -> str:
"""Generate a cryptographically random one-time pairing code.
Returns a 6-character uppercase alphanumeric string.
"""
return "".join(secrets.choice(_PAIRING_CODE_ALPHABET) for _ in range(_PAIRING_CODE_LENGTH))
def hash_pairing_code(code: str) -> str:
"""Compute a deterministic HMAC-SHA256 hash of a pairing code.
The hash is stored in the config; the plaintext code is shown to the
operator once and never persisted.
"""
key = _get_hmac_key()
return hmac.HMAC(key, code.upper().encode(), hashlib.sha256).hexdigest()
def verify_pairing_code(code: str, stored_hash: str) -> bool:
"""Verify a pairing code against its stored hash (constant-time comparison)."""
computed = hash_pairing_code(code)
return hmac.compare_digest(computed, stored_hash)
def _is_pairing_expired(policy: MessagingIdentityPolicy) -> bool:
"""Check if the pending pairing code has expired.
Returns True when pairing_created_at is None (missing timestamp is
treated as expired to be safe — legacy hashes without a timestamp
should be regenerated).
"""
if policy.pairing_created_at is None:
return True
return (time.time() - policy.pairing_created_at) > _PAIRING_CODE_TTL_SECONDS
# ---------------------------------------------------------------------------
# Authorization Check
# ---------------------------------------------------------------------------
class AuthorizationResult:
"""Result of an inbound message authorization check."""
def __init__(
self,
*,
allowed: bool,
reason: str,
is_pairing_attempt: bool = False,
) -> None:
self.allowed = allowed
self.reason = reason
self.is_pairing_attempt = is_pairing_attempt
def __bool__(self) -> bool:
return self.allowed
def __repr__(self) -> str:
return f"AuthorizationResult(allowed={self.allowed}, reason={self.reason!r})"
def authorize_inbound_message(
*,
policy: MessagingIdentityPolicy,
user_id: str,
chat_id: str | None = None,
message_text: str | None = None,
) -> AuthorizationResult:
"""Check whether an inbound message is authorized under the given policy.
Returns an AuthorizationResult indicating whether the message should be
processed, and if not, why.
"""
if not policy.inbound_enabled:
return AuthorizationResult(
allowed=False,
reason="Inbound messaging is not enabled for this platform",
)
# Check allowed chat IDs first (if configured).
# This runs before the /pair check so that pairing cannot bypass chat restrictions.
# When allowed_chat_ids is set, a None chat_id means the message is from
# an unidentifiable context (e.g. a DM with no chat_id) — treat as blocked.
if policy.allowed_chat_ids and (not chat_id or chat_id not in policy.allowed_chat_ids):
return AuthorizationResult(
allowed=False,
reason=f"Chat {chat_id or 'N/A'} is not in the allowed chat list",
)
# Already-authorized users skip the pairing path entirely.
# This prevents an allowed user from accidentally consuming a pending
# pairing code meant for someone else.
if user_id in policy.allowed_user_ids:
return AuthorizationResult(allowed=True, reason="User is authorized")
# Check if this is a pairing attempt (only when a pairing is actually pending)
if message_text and message_text.strip().lower().startswith("/pair "):
if policy.pairing_secret_hash:
return AuthorizationResult(
allowed=True,
reason="Pairing attempt",
is_pairing_attempt=True,
)
return AuthorizationResult(
allowed=False,
reason="No pairing is pending",
)
# Check allowed user IDs
if not policy.allowed_user_ids:
if policy.require_dm_pairing:
return AuthorizationResult(
allowed=False,
reason="No users have been paired yet. Use /pair <code> to pair.",
)
return AuthorizationResult(allowed=True, reason="No allowlist configured, open access")
return AuthorizationResult(
allowed=False,
reason=f"User {user_id} is not in the allowed users list",
)
def complete_pairing(
*,
policy: MessagingIdentityPolicy,
user_id: str,
code: str,
) -> tuple[bool, str]:
"""Attempt to complete DM pairing for a user.
On success, adds the user to allowed_user_ids and clears the pairing
secret. Returns (success, message).
Includes brute-force protection: after MAX_PAIRING_ATTEMPTS failed
attempts, the pairing code is invalidated. Codes also expire after
PAIRING_CODE_TTL_SECONDS.
IMPORTANT: The caller MUST persist the updated policy after every call,
regardless of the return value. Failed attempts increment
pairing_attempts; if the caller only persists on success, the counter
resets on the next load and brute-force protection is defeated.
"""
if not policy.pairing_secret_hash:
return False, "No pairing is pending. Ask the operator to run `opensre messaging pair`."
# Check TTL expiry
if _is_pairing_expired(policy):
policy.pairing_secret_hash = None
policy.pairing_created_at = None
policy.pairing_attempts = 0
return (
False,
"Pairing code has expired. Ask the operator to run `opensre messaging pair` again.",
)
# Check brute-force limit
if policy.pairing_attempts >= _MAX_PAIRING_ATTEMPTS:
policy.pairing_secret_hash = None
policy.pairing_created_at = None
policy.pairing_attempts = 0
return (
False,
"Too many failed attempts. Pairing code invalidated. Ask the operator to generate a new one.",
)
if not verify_pairing_code(code, policy.pairing_secret_hash):
policy.pairing_attempts += 1
remaining = _MAX_PAIRING_ATTEMPTS - policy.pairing_attempts
if remaining <= 0:
policy.pairing_secret_hash = None
policy.pairing_created_at = None
policy.pairing_attempts = 0
return False, "Too many failed attempts. Pairing code invalidated."
return False, f"Invalid pairing code. {remaining} attempts remaining."
# Pairing successful
if user_id not in policy.allowed_user_ids:
policy.allowed_user_ids.append(user_id)
policy.pairing_secret_hash = None
policy.pairing_created_at = None
policy.pairing_attempts = 0
logger.info("DM pairing completed for user %s", user_id)
return True, "Pairing successful! You can now interact with the bot."
# ---------------------------------------------------------------------------
# Audit Logging
# ---------------------------------------------------------------------------
def audit_log_inbound_message(
*,
platform: str,
user_id: str,
chat_id: str | None,
message_hash: str | None = None,
authorized: bool,
reason: str,
) -> None:
"""Emit a structured audit log entry for an inbound message.
Message body is hashed (not stored in plaintext) to enable misuse
investigation without leaking content.
"""
logger.info(
"[messaging-audit] platform=%s user_id=%s chat_id=%s authorized=%s reason=%s msg_hash=%s",
platform,
user_id,
chat_id or "N/A",
authorized,
reason,
message_hash or "N/A",
)