mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-27 03:52:47 +02:00
0404b3ab44
A blank-password login replied success (read-only guest) without ever creating an ACL entry or storing the ECDH shared secret. The client app believed it was logged in, but the room server's text handler and sync loop only see ACL members: the client's posts were dropped without a delivery ACK (send shows failed) and posts were never pushed to it. Rooms with only an admin password configured were fully affected since every guest login is blank-password. Add the guest to the ACL with guest permissions, the shared secret, and sync_since (mirroring the password path), reject when the ACL is full, and refresh activity timestamps on repeat blank logins. Refs #286
201 lines
7.3 KiB
Python
201 lines
7.3 KiB
Python
import logging
|
|
import time
|
|
from typing import Dict, Optional
|
|
|
|
from openhop_core.protocol import Identity
|
|
from openhop_core.protocol.constants import PUB_KEY_SIZE
|
|
|
|
logger = logging.getLogger("ACL")
|
|
|
|
PERM_ACL_GUEST = 0x01
|
|
PERM_ACL_ADMIN = 0x02
|
|
PERM_ACL_READ_WRITE = 0x01
|
|
PERM_ACL_ROLE_MASK = 0x03
|
|
|
|
|
|
class ClientInfo:
|
|
"""Represents an authenticated client in the access control list."""
|
|
|
|
def __init__(self, identity: Identity, permissions: int = 0):
|
|
self.id = identity
|
|
self.permissions = permissions
|
|
self.shared_secret = b""
|
|
self.last_timestamp = 0
|
|
self.last_activity = 0
|
|
self.last_login_success = 0
|
|
self.out_path_len = -1
|
|
self.out_path = bytearray()
|
|
self.sync_since = 0 # For room servers - timestamp of last synced message
|
|
|
|
def is_admin(self) -> bool:
|
|
return (self.permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN
|
|
|
|
def is_guest(self) -> bool:
|
|
return (self.permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST
|
|
|
|
|
|
class ACL:
|
|
def __init__(
|
|
self,
|
|
max_clients: int = 50,
|
|
admin_password: Optional[str] = None,
|
|
guest_password: Optional[str] = None,
|
|
allow_read_only: bool = True,
|
|
):
|
|
self.max_clients = max_clients
|
|
self.admin_password = admin_password or ""
|
|
self.guest_password = guest_password or ""
|
|
self.allow_read_only = allow_read_only
|
|
self.clients: Dict[bytes, ClientInfo] = {}
|
|
|
|
def _is_replay(self, client: ClientInfo, timestamp: int) -> bool:
|
|
if timestamp <= client.last_timestamp:
|
|
logger.warning(
|
|
f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}"
|
|
)
|
|
return True
|
|
return False
|
|
|
|
def _touch_client_session(
|
|
self,
|
|
client: ClientInfo,
|
|
shared_secret: bytes,
|
|
timestamp: int,
|
|
sync_since: int = None,
|
|
) -> None:
|
|
now = int(time.time())
|
|
client.last_timestamp = timestamp
|
|
client.last_activity = now
|
|
client.last_login_success = now
|
|
client.shared_secret = shared_secret
|
|
if sync_since is not None:
|
|
client.sync_since = sync_since
|
|
logger.debug(f"Stored sync_since={sync_since} for client")
|
|
|
|
def authenticate_client(
|
|
self,
|
|
client_identity: Identity,
|
|
shared_secret: bytes,
|
|
password: str,
|
|
timestamp: int,
|
|
sync_since: int = None,
|
|
target_identity_hash: int = None,
|
|
target_identity_name: str = None,
|
|
target_identity_config: dict = None,
|
|
) -> tuple[bool, int]:
|
|
|
|
target_identity_config = target_identity_config or {}
|
|
|
|
# Check for identity-specific passwords (required for room servers)
|
|
identity_settings = target_identity_config.get("settings", {})
|
|
|
|
# Determine if this is a room server by checking the type field
|
|
identity_type = target_identity_config.get("type", "")
|
|
is_room_server = identity_type == "room_server"
|
|
|
|
# Log sync_since if provided (room server format)
|
|
if sync_since is not None:
|
|
logger.debug(f"Client sync_since timestamp: {sync_since}")
|
|
|
|
if is_room_server:
|
|
# Room servers use passwords from their settings section only
|
|
# Empty strings are treated as "not set"
|
|
admin_pwd = identity_settings.get("admin_password") or None
|
|
guest_pwd = identity_settings.get("guest_password") or None
|
|
|
|
if not admin_pwd and not guest_pwd:
|
|
logger.error(
|
|
f"Room server '{target_identity_name}' has no passwords configured! Set admin_password and/or guest_password in settings."
|
|
)
|
|
return False, 0
|
|
else:
|
|
# Repeater uses global passwords from its own security section
|
|
admin_pwd = self.admin_password
|
|
guest_pwd = self.guest_password
|
|
logger.debug(
|
|
f"Repeater passwords - admin: {'SET' if admin_pwd else 'NONE'}, "
|
|
f"guest: {'SET' if guest_pwd else 'NONE'}"
|
|
)
|
|
|
|
admin_pwd = admin_pwd or ""
|
|
guest_pwd = guest_pwd or ""
|
|
|
|
if target_identity_name:
|
|
logger.debug(
|
|
f"Authenticating for identity '{target_identity_name}' (room_server={is_room_server})"
|
|
)
|
|
|
|
pub_key = client_identity.get_public_key()[:PUB_KEY_SIZE]
|
|
|
|
if not password:
|
|
client = self.clients.get(pub_key)
|
|
if client is None:
|
|
if not self.allow_read_only:
|
|
logger.info("Blank password, sender not in ACL and read-only disabled")
|
|
return False, 0
|
|
if len(self.clients) >= self.max_clients:
|
|
logger.warning("ACL full, cannot add client")
|
|
return False, 0
|
|
client = ClientInfo(client_identity, PERM_ACL_GUEST)
|
|
self.clients[pub_key] = client
|
|
logger.info("Blank password, allowing read-only guest access")
|
|
else:
|
|
logger.info(f"ACL-based login for {pub_key[:6].hex()}...")
|
|
|
|
if self._is_replay(client, timestamp):
|
|
return False, 0
|
|
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
|
|
if (client.permissions & PERM_ACL_ROLE_MASK) == 0:
|
|
client.permissions |= PERM_ACL_GUEST
|
|
return True, client.permissions
|
|
|
|
permissions = 0
|
|
logger.debug(f"Comparing password (len={len(password)}) against admin/guest")
|
|
logger.debug(
|
|
f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}"
|
|
)
|
|
if admin_pwd and password == admin_pwd:
|
|
permissions = PERM_ACL_ADMIN
|
|
logger.info(f"Admin password validated for '{target_identity_name or 'unknown'}'")
|
|
elif guest_pwd and password == guest_pwd:
|
|
permissions = PERM_ACL_READ_WRITE
|
|
logger.info(f"Guest password validated for '{target_identity_name or 'unknown'}'")
|
|
else:
|
|
logger.info(f"Invalid password for '{target_identity_name or 'unknown'}'")
|
|
return False, 0
|
|
|
|
client = self.clients.get(pub_key)
|
|
if client is None:
|
|
if len(self.clients) >= self.max_clients:
|
|
logger.warning("ACL full, cannot add client")
|
|
return False, 0
|
|
|
|
client = ClientInfo(client_identity, 0)
|
|
self.clients[pub_key] = client
|
|
logger.info(f"Added new client {pub_key[:6].hex()}...")
|
|
|
|
if self._is_replay(client, timestamp):
|
|
return False, 0
|
|
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
|
|
client.permissions &= ~PERM_ACL_ROLE_MASK
|
|
client.permissions |= permissions
|
|
|
|
logger.info(f"Login success! Permissions: {'ADMIN' if client.is_admin() else 'GUEST'}")
|
|
return True, client.permissions
|
|
|
|
def get_client(self, pub_key: bytes) -> Optional[ClientInfo]:
|
|
return self.clients.get(pub_key[:PUB_KEY_SIZE])
|
|
|
|
def get_num_clients(self) -> int:
|
|
return len(self.clients)
|
|
|
|
def get_all_clients(self):
|
|
return list(self.clients.values())
|
|
|
|
def remove_client(self, pub_key: bytes) -> bool:
|
|
key = pub_key[:PUB_KEY_SIZE]
|
|
if key in self.clients:
|
|
del self.clients[key]
|
|
return True
|
|
return False
|