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
359 lines
12 KiB
Python
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",
|
|
)
|