Preserve pentest suite and cloudflare.ini.example from previous remote history
Carried over from the old Kecalek_python master (214da18).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
338
tests/pentest_client.py
Normal file
338
tests/pentest_client.py
Normal file
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Security regression harness for encrypted_chat server.
|
||||
|
||||
Runs focused pentest/integration checks against a live server using real accounts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import getpass
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from chat_core import ChatClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
name: str
|
||||
outcome: str # PASS | FAIL | SKIP
|
||||
details: str
|
||||
|
||||
|
||||
def _msg(resp: dict) -> str:
|
||||
data = resp.get("data") or {}
|
||||
return str(data.get("message", ""))
|
||||
|
||||
|
||||
async def _connect_client() -> "ChatClient":
|
||||
from chat_core import ChatClient # Imported lazily so --help works without full deps
|
||||
client = ChatClient()
|
||||
await client.connect()
|
||||
client._listener_task = asyncio.create_task(client._background_listener())
|
||||
return client
|
||||
|
||||
|
||||
async def _login_client(email: str, password: str) -> tuple["ChatClient", str]:
|
||||
client = await _connect_client()
|
||||
ok, message = await client.login(email, password)
|
||||
if not ok:
|
||||
await client.close()
|
||||
raise RuntimeError(f"Login failed for {email}: {message}")
|
||||
return client, message
|
||||
|
||||
|
||||
async def _close_client(client: "ChatClient | None"):
|
||||
if not client:
|
||||
return
|
||||
try:
|
||||
await client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _too_many_attempts(resp: dict) -> bool:
|
||||
return resp.get("status") == "error" and "Too many attempts" in _msg(resp)
|
||||
|
||||
|
||||
async def test_conversation_isolation(outsider: "ChatClient", conv_id: str) -> TestResult:
|
||||
"""Outsider must not access a conversation they are not a member of."""
|
||||
fake_mid = str(uuid.uuid4())
|
||||
checks: list[tuple[str, dict]] = [
|
||||
(
|
||||
"get_messages",
|
||||
await outsider.send_and_recv("get_messages", conversation_id=conv_id, limit=5, offset=0),
|
||||
),
|
||||
(
|
||||
"mark_read",
|
||||
await outsider.send_and_recv("mark_read", conversation_id=conv_id, message_ids=[fake_mid]),
|
||||
),
|
||||
(
|
||||
"send_message",
|
||||
await outsider.send_and_recv("send_message", conversation_id=conv_id),
|
||||
),
|
||||
]
|
||||
failures: list[str] = []
|
||||
for endpoint, resp in checks:
|
||||
if resp.get("status") != "error" or "Not a member" not in _msg(resp):
|
||||
failures.append(f"{endpoint} -> status={resp.get('status')} message={_msg(resp)!r}")
|
||||
if failures:
|
||||
return TestResult("Conversation Isolation (AuthZ)", "FAIL", "; ".join(failures))
|
||||
return TestResult(
|
||||
"Conversation Isolation (AuthZ)",
|
||||
"PASS",
|
||||
"Outsider got 'Not a member' for get_messages, mark_read, send_message.",
|
||||
)
|
||||
|
||||
|
||||
async def test_session_reset_no_shared(outsider: "ChatClient", peer_user_id: str) -> TestResult:
|
||||
"""session_reset must be rejected without shared conversation."""
|
||||
resp = await outsider.send_and_recv("session_reset", peer_user_id=peer_user_id)
|
||||
if resp.get("status") == "error" and "No shared conversation" in _msg(resp):
|
||||
return TestResult("Session Reset Authorization", "PASS", "Rejected with 'No shared conversation'.")
|
||||
if resp.get("status") == "ok":
|
||||
return TestResult(
|
||||
"Session Reset Authorization",
|
||||
"SKIP",
|
||||
"Outsider appears to share a conversation with peer in current dataset.",
|
||||
)
|
||||
return TestResult(
|
||||
"Session Reset Authorization",
|
||||
"FAIL",
|
||||
f"Unexpected response: status={resp.get('status')} message={_msg(resp)!r}",
|
||||
)
|
||||
|
||||
|
||||
async def test_malformed_header_rejected(member: "ChatClient", conv_id: str) -> TestResult:
|
||||
"""Oversized ratchet header should be rejected by server-side validation."""
|
||||
huge_header = {"dh_pub": "A" * 5000, "n": 1, "pn": 0}
|
||||
resp = await member.send_and_recv(
|
||||
"send_message",
|
||||
conversation_id=conv_id,
|
||||
ratchet_header=huge_header,
|
||||
recipients=[{}],
|
||||
)
|
||||
if resp.get("status") == "error" and "Invalid ratchet_header format" in _msg(resp):
|
||||
return TestResult("Malformed Header Rejection", "PASS", "Oversized ratchet_header rejected.")
|
||||
return TestResult(
|
||||
"Malformed Header Rejection",
|
||||
"FAIL",
|
||||
f"Unexpected response: status={resp.get('status')} message={_msg(resp)!r}",
|
||||
)
|
||||
|
||||
|
||||
async def test_login_rate_limits() -> TestResult:
|
||||
"""Validate login_start per-email(case-insensitive) and per-IP limits."""
|
||||
probe = await _connect_client()
|
||||
try:
|
||||
stamp = int(time.time())
|
||||
base_local = f"pentest-login-{stamp}"
|
||||
base_domain = "example.invalid"
|
||||
base_email = f"{base_local}@{base_domain}"
|
||||
case_variants = [
|
||||
base_email,
|
||||
f"{base_local.upper()}@{base_domain}",
|
||||
f"{base_local.capitalize()}@{base_domain}",
|
||||
f"{base_local}@{base_domain.upper()}",
|
||||
f"{base_local.swapcase()}@{base_domain}",
|
||||
base_email,
|
||||
f"{base_local.upper()}@{base_domain}",
|
||||
f"{base_local.capitalize()}@{base_domain}",
|
||||
f"{base_local}@{base_domain.upper()}",
|
||||
f"{base_local.swapcase()}@{base_domain}",
|
||||
base_email, # should exceed per-email bucket (10/min)
|
||||
]
|
||||
|
||||
email_bucket_triggered = False
|
||||
phase1_last = ""
|
||||
for e in case_variants:
|
||||
resp = await probe.send_and_recv("login_start", email=e)
|
||||
phase1_last = _msg(resp)
|
||||
if _too_many_attempts(resp):
|
||||
email_bucket_triggered = True
|
||||
await asyncio.sleep(0.12) # stay under per-connection 20 req/s limiter
|
||||
|
||||
ip_bucket_triggered = False
|
||||
phase2_last = ""
|
||||
for i in range(1, 16):
|
||||
unique_email = f"{base_local}-{i}@{base_domain}"
|
||||
resp = await probe.send_and_recv("login_start", email=unique_email)
|
||||
phase2_last = _msg(resp)
|
||||
if _too_many_attempts(resp):
|
||||
ip_bucket_triggered = True
|
||||
break
|
||||
await asyncio.sleep(0.12)
|
||||
|
||||
if email_bucket_triggered and ip_bucket_triggered:
|
||||
return TestResult(
|
||||
"Login Rate Limits (case + per-IP)",
|
||||
"PASS",
|
||||
"Per-email(case-insensitive) and per-IP login_start limits both triggered.",
|
||||
)
|
||||
return TestResult(
|
||||
"Login Rate Limits (case + per-IP)",
|
||||
"FAIL",
|
||||
(
|
||||
f"email_bucket_triggered={email_bucket_triggered}, "
|
||||
f"ip_bucket_triggered={ip_bucket_triggered}, "
|
||||
f"phase1_last={phase1_last!r}, phase2_last={phase2_last!r}"
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await _close_client(probe)
|
||||
|
||||
|
||||
def _pick_password(flag_value: str | None, prompt: str) -> str:
|
||||
if flag_value is not None:
|
||||
return flag_value
|
||||
return getpass.getpass(prompt)
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> int:
|
||||
if len({args.member_email.lower(), args.peer_email.lower(), args.outsider_email.lower()}) != 3:
|
||||
print("ERROR: member/peer/outsider emails must be three different accounts.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.server_host:
|
||||
os.environ["SERVER_HOST"] = args.server_host
|
||||
if args.server_port is not None:
|
||||
os.environ["SERVER_PORT"] = str(args.server_port)
|
||||
|
||||
effective_host = os.getenv("SERVER_HOST", "127.0.0.1").strip()
|
||||
if effective_host == "0.0.0.0":
|
||||
print(
|
||||
"ERROR: SERVER_HOST=0.0.0.0 je bind adresa serveru, ne klientský TLS hostname.\n"
|
||||
"Pouzij --server-host <hostname-nebo-ip-z-certifikatu> (napr. localhost nebo 192.168.1.112).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
member_password = _pick_password(args.member_password, f"Password for {args.member_email}: ")
|
||||
peer_password = _pick_password(args.peer_password, f"Password for {args.peer_email}: ")
|
||||
outsider_password = _pick_password(args.outsider_password, f"Password for {args.outsider_email}: ")
|
||||
|
||||
member: "ChatClient | None" = None
|
||||
peer: "ChatClient | None" = None
|
||||
outsider: "ChatClient | None" = None
|
||||
results: list[TestResult] = []
|
||||
|
||||
try:
|
||||
print("[setup] Logging in member account...")
|
||||
member, _ = await _login_client(args.member_email, member_password)
|
||||
print("[setup] Logging in peer account...")
|
||||
peer, _ = await _login_client(args.peer_email, peer_password)
|
||||
print("[setup] Logging in outsider account...")
|
||||
outsider, _ = await _login_client(args.outsider_email, outsider_password)
|
||||
|
||||
if args.conversation_id:
|
||||
conv_id = args.conversation_id
|
||||
else:
|
||||
print("[setup] Finding/creating member<->peer direct conversation...")
|
||||
conv_id, err = await member.find_or_create_conversation(args.peer_email)
|
||||
if not conv_id:
|
||||
raise RuntimeError(f"Could not find/create conversation: {err}")
|
||||
|
||||
outsider_convs = {c["conversation_id"] for c in await outsider.list_conversations()}
|
||||
if conv_id in outsider_convs:
|
||||
results.append(
|
||||
TestResult(
|
||||
"Conversation Isolation (AuthZ)",
|
||||
"SKIP",
|
||||
"Outsider is already a member of target conversation; choose different outsider/account set.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(await test_conversation_isolation(outsider, conv_id))
|
||||
results.append(await test_malformed_header_rejected(member, conv_id))
|
||||
|
||||
results.append(await test_session_reset_no_shared(outsider, peer.session["user_id"]))
|
||||
|
||||
if not args.skip_login_rate_limit:
|
||||
results.append(await test_login_rate_limits())
|
||||
|
||||
except ssl.SSLCertVerificationError as e:
|
||||
results.append(
|
||||
TestResult(
|
||||
"Harness Setup",
|
||||
"FAIL",
|
||||
(
|
||||
f"{e}. Zkus --server-host s hodnotou ze SAN/CN certifikatu "
|
||||
"(napr. localhost nebo 192.168.1.112)."
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
emsg = str(e)
|
||||
if "CERTIFICATE_VERIFY_FAILED" in emsg or "IP address mismatch" in emsg:
|
||||
emsg += (
|
||||
" | Hint: pouzij --server-host s hostname/IP, ktery je v certifikatu "
|
||||
"(SERVER_HOST nesmi byt 0.0.0.0)."
|
||||
)
|
||||
results.append(TestResult("Harness Setup", "FAIL", emsg))
|
||||
finally:
|
||||
await _close_client(member)
|
||||
await _close_client(peer)
|
||||
await _close_client(outsider)
|
||||
|
||||
print("\n=== Pentest Results ===")
|
||||
for r in results:
|
||||
print(f"[{r.outcome}] {r.name}: {r.details}")
|
||||
|
||||
has_fail = any(r.outcome == "FAIL" for r in results)
|
||||
return 1 if has_fail else 0
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run focused pentest/integration checks against encrypted_chat server."
|
||||
)
|
||||
parser.add_argument("--member-email", required=True, help="Email of regular account A (conversation member).")
|
||||
parser.add_argument("--peer-email", required=True, help="Email of regular account B (other conversation member).")
|
||||
parser.add_argument("--outsider-email", required=True, help="Email of regular account C (must not be in target conversation).")
|
||||
parser.add_argument(
|
||||
"--server-host",
|
||||
default=None,
|
||||
help="Override SERVER_HOST for this run (must match TLS cert SAN/CN).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--server-port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Override SERVER_PORT for this run.",
|
||||
)
|
||||
parser.add_argument("--member-password", default=None, help="Password for --member-email (optional; prompt if omitted).")
|
||||
parser.add_argument("--peer-password", default=None, help="Password for --peer-email (optional; prompt if omitted).")
|
||||
parser.add_argument("--outsider-password", default=None, help="Password for --outsider-email (optional; prompt if omitted).")
|
||||
parser.add_argument(
|
||||
"--conversation-id",
|
||||
default=None,
|
||||
help="Optional target conversation UUID. If omitted, member<->peer DM is found/created automatically.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-login-rate-limit",
|
||||
action="store_true",
|
||||
help="Skip anonymous login_start rate-limit regression check.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
return asyncio.run(run(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user