Spaces:
Sleeping
Sleeping
| """ | |
| crypto_generator.py | |
| =================== | |
| Generates fully-realized encryption challenges for the CyberArena pool. | |
| Contract (this is one of N challenge-type generators; see main.py for the | |
| orchestrator that runs all of them): | |
| main.py watcher (polls encryption_challenges count) | |
| │ | |
| ▼ | |
| refill_pool(team_role, count) | |
| │ | |
| ▼ | |
| _refill_pool_unlocked() | |
| │ | |
| │ 1. System pre-selects algorithm (ALGORITHM_ROTATION) | |
| │ 2. AI generates: title + story + task + hints + plaintext | |
| │ 3. Python encrypts the plaintext with the pre-selected algo | |
| │ 4. ChallengeBuilder produces a Challenge, insert to DB | |
| │ | |
| ▼ | |
| public.encryption_challenges | |
| The algorithm is FIXED per slot — the AI is only asked to write flavor text | |
| (title, story, task, hints) plus the plaintext. Python handles all | |
| encryption deterministically. This guarantees the algorithm always matches | |
| its module, and the crypto logic is provably correct (no LLM hallucination). | |
| Public API (used by main.py): | |
| - POOL_TARGET = 5 per (team, module) | |
| - async refill_pool(team_role, count) -> int (returns # inserted) | |
| - async start_pool_watcher(team_role, module) (long-running task) | |
| CLI: | |
| python crypto_generator.py --team blue --seed-only | |
| python crypto_generator.py --team red --seed-only | |
| python crypto_generator.py --team blue --ai --count 1 (requires GROQ_API_KEY) | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import asyncio | |
| import base64 | |
| import hashlib | |
| import hmac | |
| import json | |
| import os | |
| import random | |
| import re | |
| import sys | |
| import time | |
| import uuid | |
| from dataclasses import dataclass, field, asdict | |
| from typing import Optional, Literal | |
| from app.core.config import ( | |
| SUPABASE_URL, SUPABASE_ANON_KEY, | |
| CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_MODEL, CLOUDFLARE_URL, | |
| GROQ_API_KEY, GROQ_MODEL, | |
| NVIDIA_API_KEY, NVIDIA_MODEL, NVIDIA_URL, | |
| MISTRAL_API_KEY, MISTRAL_MODEL, MISTRAL_API_URL, | |
| ) | |
| # Load .env early so the CLI works without manual export | |
| try: | |
| from dotenv import load_dotenv | |
| _env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") | |
| if os.path.exists(_env_path): | |
| load_dotenv(_env_path) | |
| except ImportError: | |
| pass | |
| import httpx | |
| try: | |
| from app.services.dedup import fetch_existing_titles | |
| except ImportError: | |
| def fetch_existing_titles(table: str, role_filter: Optional[str] = None, limit: int = 50) -> list[str]: | |
| return [] | |
| from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes | |
| from cryptography.hazmat.primitives import hashes, serialization, padding as sympad | |
| from cryptography.hazmat.primitives.asymmetric import rsa, padding as apad | |
| from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC | |
| from cryptography.hazmat.backends import default_backend | |
| # --------------------------------------------------------------------------- # | |
| # 0. Constants | |
| # --------------------------------------------------------------------------- # | |
| ALLOWED_MODULES = ("encryption-basics", "hash-cracking", "rsa-aes") | |
| ALLOWED_DIFFICULTIES = ("مبتدئ", "متوسط", "قوي") | |
| ALLOWED_TEAMS = ("blue", "red") | |
| # Map each module to the algorithms that legally belong to it. Used to | |
| # validate AI responses and reject any that don't match the requested | |
| # module (so a wrong combo falls back to a curated seed). | |
| ALGOS_BY_MODULE = { | |
| "encryption-basics": {"AES-256-CBC", "AES-256-GCM", "VIGENERE", "CAESAR", "XOR"}, | |
| "hash-cracking": {"MD5", "SHA-1+SALT", "SHA-256", "HMAC-SHA256"}, | |
| "rsa-aes": {"RSA-1024"}, | |
| } | |
| # Round-robin list of algorithms the system pre-selects for each slot. | |
| # The AI no longer picks the algorithm — it only generates the flavor | |
| # (title, story, task, hints, plaintext). Python then encrypts the | |
| # plaintext using the algorithm the system chose, so the algorithm is | |
| # always guaranteed to match the module and the crypto is deterministic. | |
| ALGORITHM_ROTATION = [ | |
| ("encryption-basics", "AES-256-CBC"), | |
| ("hash-cracking", "SHA-256"), | |
| ("encryption-basics", "CAESAR"), | |
| ("encryption-basics", "VIGENERE"), | |
| ("hash-cracking", "MD5"), | |
| ("encryption-basics", "AES-256-GCM"), | |
| ("encryption-basics", "XOR"), | |
| ("rsa-aes", "RSA-1024"), | |
| ("hash-cracking", "HMAC-SHA256"), | |
| ("hash-cracking", "SHA-1+SALT"), | |
| ] | |
| def _pick_algorithm_for_slot(i: int) -> tuple[str, str]: | |
| """Return (module, algorithm) for slot i in the refill cycle. | |
| The AI is NOT asked to pick an algorithm — the system pre-selects | |
| one from ALGORITHM_ROTATION, guaranteeing it always belongs to a | |
| legal module. | |
| """ | |
| return ALGORITHM_ROTATION[i % len(ALGORITHM_ROTATION)] | |
| def _generate_key_for_algo(algorithm: str, extra: dict) -> tuple[str, dict]: | |
| """Auto-generate key_material (and any extra params) for the given algorithm. | |
| Returns (key_material, updated_extra). Called when the AI didn't supply | |
| a key (or supplied an empty one). The output is what the | |
| CryptoExecutor expects for that algorithm. | |
| """ | |
| algo = _normalize_algo(algorithm).upper() | |
| out_extra = dict(extra) if extra else {} | |
| if algo == "AES-256-CBC": | |
| passwords = [ | |
| "P@ssw0rd!2024", "CryptoK3y#Secure", "AES-Strong!Key9", | |
| "B1ueT34m$Lock", "C0mpl3x!Pass2025", "R3dT3am@K3y256", | |
| "S3cur3Pass!2024", "QuantumSafe!Key", | |
| ] | |
| return random.choice(passwords), out_extra | |
| if algo == "AES-256-GCM": | |
| passwords = [ | |
| "GCM@SecureK3y!", "AuthT4g$Pass2024", "AEAD#K3yStr0ng", | |
| "NetS3cure!2024", "Cloud!Encrypt#9", "P@cketK3y$AEAD", | |
| ] | |
| return random.choice(passwords), out_extra | |
| if algo == "RSA-1024": | |
| return "", out_extra # RSA generates its own key | |
| if algo == "VIGENERE": | |
| keys = ["RADIO", "ENIGMA", "BLITZ", "LONDON", "AXIS", "OMEGA", "NEXUS", "KILO", "TANGO"] | |
| return random.choice(keys), out_extra | |
| if algo == "CAESAR": | |
| shift = random.choice([3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25]) | |
| out_extra["shift"] = shift | |
| return f"shift={shift}", out_extra | |
| if algo == "XOR": | |
| key_byte = random.choice([0x42, 0x55, 0x77, 0xA3, 0xC9, 0xE5, 0x4F, 0x9B, | |
| 0x88, 0xAA, 0xC7, 0xE2, 0xB1, 0x9C, 0xD4, 0xF6]) | |
| out_extra["key_byte"] = key_byte | |
| return f"key_byte=0x{key_byte:02X}", out_extra | |
| if algo == "MD5": | |
| return f"$P${_rand8_alnum()}{_rand8_alnum()[:4]}", out_extra | |
| if algo == "SHA-1+SALT": | |
| salt = random.choice(["apex2026", "linux42", "r00tme", "cisco99", "salted", "redhat9"]) | |
| out_extra["salt"] = salt | |
| return f"$1${salt}$", out_extra | |
| if algo == "SHA-256": | |
| return "", out_extra | |
| if algo == "HMAC-SHA256": | |
| keys = ["hmac-shared-secret-2024", "RedTeam@K3y!9", "APEX#MAC$K3y", "Auth$Sign@2025"] | |
| return random.choice(keys), out_extra | |
| return "", out_extra | |
| def _rand8_alnum() -> str: | |
| """Return 8 random alphanumeric characters (used for MD5 fake hash prefixes).""" | |
| return "".join(random.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(8)) | |
| def _normalize_algo(algorithm: str) -> str: | |
| """If Groq returns a pipe-separated list like 'AES-256-CBC|VIGENERE', | |
| pick the first token so downstream checks can match it.""" | |
| if "|" in algorithm: | |
| return algorithm.split("|")[0].strip() | |
| return algorithm.strip() | |
| def _algo_matches_module(algorithm: str, module: str) -> bool: | |
| """True if the algorithm is allowed inside the given module bucket.""" | |
| algo = _normalize_algo(algorithm) | |
| if algo.startswith("RSA"): | |
| return module == "rsa-aes" | |
| return algo in ALGOS_BY_MODULE.get(module, set()) | |
| # Pool tuning — per (team, module) bucket. | |
| POOL_TARGET = 5 # max cached | |
| POOL_THRESHOLD = 2 # when count drops to this, trigger refill | |
| POOL_BATCH = 3 # how many to insert per refill | |
| WATCHER_POLL_SECS = 60 # how often the background task polls the DB | |
| WATCHER_COOLDOWN = 12 # sleep between AI calls (Groq rate-limit guard) | |
| IDLE_POLL_SECS = 60 # how long to wait when the pool is full (1 min) | |
| _AI_BACKOFF_UNTIL: dict[str, float] = {} # per-team epoch timestamp until which AI is paused after a 429 | |
| # Concurrency control — see refill_pool / start_pool_watcher. | |
| # One asyncio.Lock per team serialises any concurrent refill attempts | |
| # (watcher vs CLI --refill, two watcher tasks, etc.) so the pool never | |
| # overshoots POOL_TARGET due to a stale count. | |
| _POOL_LOCKS: dict[str, asyncio.Lock] = {} | |
| # Dedup set so two `start_pool_watcher` calls in the same process don't | |
| # both spin up a loop. | |
| _WATCHER_STARTED: set[tuple[str, str]] = set() # (generator_name, team_role) | |
| def _get_pool_lock(team_role: str) -> asyncio.Lock: | |
| if team_role not in _POOL_LOCKS: | |
| _POOL_LOCKS[team_role] = asyncio.Lock() | |
| return _POOL_LOCKS[team_role] | |
| def _repair_json(raw: str) -> dict: | |
| """Best-effort JSON repair for common LLM malformations. | |
| Handles: trailing commas, truncated output (missing closing braces), | |
| truncated mid-string, and stray text around the JSON blob. | |
| """ | |
| import re | |
| text = raw.strip() | |
| if text.startswith("```"): | |
| parts = text.split("```") | |
| if len(parts) >= 3: | |
| text = parts[1] | |
| if text.startswith("json"): | |
| text = text[4:] | |
| text = text.strip().rstrip("`") | |
| first_brace = text.find("{") | |
| last_brace = text.rfind("}") | |
| if first_brace != -1 and last_brace > first_brace: | |
| text = text[first_brace:last_brace + 1] | |
| text = re.sub(r",\s*([}\]])", r"\1", text) | |
| # Quick path: already valid JSON | |
| try: | |
| return json.loads(text) | |
| except json.JSONDecodeError: | |
| pass | |
| # Detect mid-string truncation: if the text ends inside an unescaped | |
| # double-quote, close it. This is the common case when the Groq SDK | |
| # returns a body that was cut off at max_completion_tokens. | |
| def _close_truncated_string(s: str) -> str: | |
| # Walk the string tracking string state (rough but works for | |
| # our LLM JSON output which is single-line except for \n escapes). | |
| in_string = False | |
| escape = False | |
| last_quote_idx = -1 | |
| for i, ch in enumerate(s): | |
| if escape: | |
| escape = False | |
| continue | |
| if ch == '\\': | |
| escape = True | |
| continue | |
| if ch == '"': | |
| in_string = not in_string | |
| last_quote_idx = i | |
| if in_string and last_quote_idx >= 0: | |
| return s + '"' | |
| return s | |
| open_braces = text.count("{") - text.count("}") | |
| open_brackets = text.count("[") - text.count("]") | |
| candidate = _close_truncated_string(text) | |
| candidate = candidate + "]" * max(0, open_brackets) + "}" * max(0, open_braces) | |
| try: | |
| return json.loads(candidate) | |
| except json.JSONDecodeError: | |
| pass | |
| # Last resort: chop off the partial last key/value pair and try again. | |
| # Find the last complete top-level key by looking for the last `,"` or | |
| # `{"` boundary. | |
| idx = candidate.rfind(",") | |
| if idx > 0: | |
| snippet = candidate[:idx] + "]" * max(0, open_brackets) + "}" * max(0, open_braces) | |
| try: | |
| return json.loads(snippet) | |
| except json.JSONDecodeError: | |
| pass | |
| raise ValueError(f"Could not repair JSON: {raw[:200]}") | |
| XP_BY_DIFFICULTY = {"مبتدئ": 100, "متوسط": 150, "قوي": 200} | |
| # Default tool whitelist for the in-browser terminal sandbox. | |
| DEFAULT_TOOLS = ["cat", "ls", "echo", "openssl", "sha256sum", "base64", "file"] | |
| # Flag format: CyberArena{<hex>} | |
| FLAG_PREFIX = "CyberArena{" | |
| # --------------------------------------------------------------------------- # | |
| # 1. ScenarioSpec — what the AI / human provides | |
| # --------------------------------------------------------------------------- # | |
| class ScenarioSpec: | |
| """Inputs the AI generates (or the human hand-writes). | |
| The AI never invents the encrypted output, the flag, or the command | |
| outputs — Python computes those. This dataclass only contains the | |
| cryptographic *recipe*. | |
| """ | |
| team_role: Literal["blue", "red"] | |
| module: Literal["encryption-basics", "hash-cracking", "rsa-aes"] | |
| algorithm: str # AES-256-CBC | AES-256-GCM | RSA-1024 | SHA-256 | HMAC-SHA256 | Vigenere | Caesar | XOR | MD5 | SHA-1+salt | |
| difficulty: Literal["مبتدئ", "متوسط", "قوي"] | |
| # Story / task (the natural-language shell — fed to the student) | |
| title: str | |
| story: str | |
| task_outline: str | |
| # Crypto recipe (Python will execute) | |
| plaintext: str | |
| key_material: str = "" # password, key bytes (b64), or salt | |
| extra: dict = field(default_factory=dict) # algorithm-specific (iv, salt, shift, etc.) | |
| # Optional manual override | |
| expected_flag: Optional[str] = None # if pre-computed externally | |
| # Theme-specific Socratic hints. If provided, ChallengeBuilder prefers | |
| # these over the generic HINTS_BY_DIFFICULTY fallbacks. | |
| hints: list = field(default_factory=list) | |
| def validate(self): | |
| assert self.team_role in ALLOWED_TEAMS | |
| assert self.module in ALLOWED_MODULES | |
| assert self.difficulty in ALLOWED_DIFFICULTIES | |
| # Normalize: Groq sometimes returns pipe-separated algos like | |
| # "AES-256-CBC|VIGENERE" — take the first token. | |
| self.algorithm = _normalize_algo(self.algorithm) | |
| assert self.algorithm, "algorithm required" | |
| assert self.story and self.task_outline | |
| # Anti-pattern: never reveal the exact algorithm in the task_outline. | |
| # (The story is allowed to use contextual jargon like "hash", "salt", | |
| # "signature" — but the task must not say "use AES-256-CBC" etc.) | |
| for forbidden in ("AES", "RSA", "Vigenere", "Caesar", "MD5", "SHA-1", | |
| "SHA-256", "HMAC", "XOR"): | |
| if forbidden.lower() in self.task_outline.lower(): | |
| raise ValueError( | |
| f"Algorithm name '{forbidden}' leaked into task_outline. " | |
| "Reframe without naming the algorithm — describe intent, not implementation." | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # 2. Challenge — DB-ready output | |
| # --------------------------------------------------------------------------- # | |
| _SCENARIO_FIELDS = frozenset({ | |
| "team_role", "module", "algorithm", "difficulty", | |
| "title", "story", "task_outline", "plaintext", | |
| "key_material", "extra", "expected_flag", "hints", | |
| }) | |
| def _spec_kwargs(d: dict) -> dict: | |
| """Filter a (possibly theme-enriched) seed dict down to the fields | |
| ScenarioSpec accepts. Theme-pool keys (title_pool, story_pool, | |
| plaintext_pool, theme, etc.) are consumed by randomize_template and | |
| must not leak into the builder.""" | |
| return {k: v for k, v in d.items() if k in _SCENARIO_FIELDS} | |
| class Challenge: | |
| team_role: str | |
| module: str | |
| title: str | |
| story: str | |
| task_outline: str | |
| files: dict # filename -> base64 | |
| file_metadata: dict | |
| command_outputs: dict # command_key -> {"stdout": ..., "stderr": ..., "exit_code": ...} | |
| hints: list | |
| tools_whitelist: list | |
| flag_hash: str # sha256 of flag value | |
| flag_preview: str # the actual flag (CyberArena{...}) | |
| difficulty: str | |
| xp_reward: int | |
| created_at: str = "" | |
| def to_db_row(self) -> dict: | |
| return { | |
| "team_role": self.team_role, | |
| "module": self.module, | |
| "title": self.title, | |
| "story": self.story, | |
| "task_outline": self.task_outline, | |
| "files": self.files, | |
| "file_metadata": self.file_metadata, | |
| "command_outputs": self.command_outputs, | |
| "hints": self.hints, | |
| "tools_whitelist": self.tools_whitelist, | |
| "flag_hash": self.flag_hash, | |
| "flag_preview": self.flag_preview, | |
| "difficulty": self.difficulty, | |
| "xp_reward": self.xp_reward, | |
| } | |
| # --------------------------------------------------------------------------- # | |
| # 3. CryptoExecutor — runs the algorithm | |
| # --------------------------------------------------------------------------- # | |
| class CryptoExecutor: | |
| """Pure-Python crypto executors. No `openssl` shell-out required. | |
| Each method returns (output_bytes, metadata). The metadata is what the | |
| sandbox needs to render the in-browser terminal output. | |
| """ | |
| # ----------------- Symmetric ----------------- # | |
| def aes_cbc(plaintext: str, password: str, salt: bytes = None) -> tuple[bytes, dict]: | |
| salt = salt or os.urandom(8) | |
| iv = os.urandom(16) | |
| kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, | |
| iterations=100_000, backend=default_backend()) | |
| key = kdf.derive(password.encode()) | |
| padder = sympad.PKCS7(128).padder() | |
| padded = padder.update(plaintext.encode()) + padder.finalize() | |
| cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) | |
| enc = cipher.encryptor() | |
| ct = enc.update(padded) + enc.finalize() | |
| blob = b"Salted__" + salt + ct # OpenSSL -salt format | |
| return blob, {"salt_hex": salt.hex(), | |
| "iv_hex": iv.hex(), | |
| "key_hex": key.hex(), | |
| "openssl_format": "Salted__"} | |
| def aes_gcm(plaintext: str, password: str) -> tuple[bytes, dict]: | |
| iv = os.urandom(12) | |
| kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=b"apex_gcm_salt", | |
| iterations=100_000, backend=default_backend()) | |
| key = kdf.derive(password.encode()) | |
| cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend()) | |
| enc = cipher.encryptor() | |
| ct = enc.update(plaintext.encode()) + enc.finalize() | |
| blob = iv + ct + enc.tag # iv | ciphertext | tag | |
| return blob, {"iv_hex": iv.hex(), | |
| "tag_hex": enc.tag.hex(), | |
| "key_hex": key.hex()} | |
| # ----------------- Asymmetric ----------------- # | |
| def rsa_sign(plaintext: str, key_size: int = 1024) -> tuple[bytes, dict]: | |
| priv = rsa.generate_private_key(public_exponent=65537, key_size=key_size, | |
| backend=default_backend()) | |
| pem = priv.private_bytes( | |
| encoding=serialization.Encoding.PEM, | |
| format=serialization.PrivateFormat.PKCS8, | |
| encryption_algorithm=serialization.NoEncryption()) | |
| sig = priv.sign( | |
| plaintext.encode(), | |
| apad.PSS(mgf=apad.MGF1(hashes.SHA256()), salt_length=apad.PSS.MAX_LENGTH), | |
| hashes.SHA256()) | |
| return sig, {"private_key_pem": pem.decode(), | |
| "key_size": key_size, | |
| "signature_b64": base64.b64encode(sig).decode()} | |
| # ----------------- Hash / HMAC ----------------- # | |
| def sha256(data: bytes) -> bytes: | |
| return hashlib.sha256(data).digest() | |
| def md5(data: bytes) -> bytes: | |
| return hashlib.md5(data).digest() | |
| def sha1_salt(data: bytes, salt: str) -> bytes: | |
| return hashlib.sha1((salt + data.decode(errors="ignore")).encode()).digest() | |
| def hmac_sha256(key: str, data: str) -> bytes: | |
| return hmac.new(key.encode(), data.encode(), hashlib.sha256).digest() | |
| # ----------------- Classical (Red team) ----------------- # | |
| def vigenere_encrypt(plaintext: str, key: str) -> str: | |
| out = [] | |
| key = key.lower() | |
| ki = 0 | |
| for ch in plaintext: | |
| if ch.isalpha(): | |
| base = ord('A') if ch.isupper() else ord('a') | |
| k = ord(key[ki % len(key)]) - ord('a') | |
| out.append(chr((ord(ch) - base + k) % 26 + base)) | |
| ki += 1 | |
| else: | |
| out.append(ch) | |
| return "".join(out) | |
| def caesar_shift(plaintext: str, shift: int) -> str: | |
| out = [] | |
| for ch in plaintext: | |
| if ch.isalpha(): | |
| base = ord('A') if ch.isupper() else ord('a') | |
| out.append(chr((ord(ch) - base + shift) % 26 + base)) | |
| else: | |
| out.append(ch) | |
| return "".join(out) | |
| def xor_bytes(plaintext: bytes, key_byte: int) -> bytes: | |
| return bytes(b ^ key_byte for b in plaintext) | |
| # --------------------------------------------------------------------------- # | |
| # 4. ChallengeBuilder — wires spec → executed challenge | |
| # --------------------------------------------------------------------------- # | |
| class ChallengeBuilder: | |
| def _pick_hints(self, spec, key_hint: str | None = None) -> list: | |
| """Pick the hint set for a challenge. | |
| - If the spec carries theme-specific Socratic hints, return those | |
| (purely guiding, no direct answer). | |
| - Otherwise fall back to [key_hint, *HINTS_BY_DIFFICULTY] (legacy). | |
| """ | |
| if spec.hints: | |
| return list(spec.hints) | |
| if key_hint is not None: | |
| return [key_hint] + self.HINTS_BY_DIFFICULTY[spec.difficulty] | |
| return self.HINTS_BY_DIFFICULTY[spec.difficulty] | |
| """Takes a ScenarioSpec, runs the crypto, returns a Challenge ready for DB.""" | |
| HINTS_BY_DIFFICULTY = { | |
| "مبتدئ": ["راجع أدوات سطر الأوامر المتوفرة في القائمة البيضاء", | |
| "اقرأ التعليمات بعناية — الـ key معطى في القصة", | |
| "الناتج النهائي يجب أن يكون بصيغة SHA-256 hex"], | |
| "متوسط": ["استخدم openssl بالأمر الصحيح من القصة", | |
| "الـ IV والـ salt يُولّدان تلقائياً — ركّز على الـ password"], | |
| "قوي": ["راجع صيغة PSS في التواقيع الرقمية", | |
| "تأكد من الـ padding: PKCS7 لـ CBC، tag 16-byte لـ GCM"], | |
| } | |
| def build(self, spec: ScenarioSpec) -> Challenge: | |
| spec.validate() | |
| algo = spec.algorithm.upper() | |
| # --- Dispatch by algorithm --- | |
| if algo == "AES-256-CBC": | |
| blob, meta = CryptoExecutor.aes_cbc(spec.plaintext, spec.key_material) | |
| return self._build_symmetric( | |
| spec, blob, meta, | |
| filename=spec.extra.get("filename", "data.txt"), | |
| cmd=f"openssl enc -d -aes-256-cbc -salt -pbkdf2 -in {spec.extra.get('filename', 'data.txt')} -pass pass:{spec.key_material}", | |
| final_hash=hashlib.sha256(blob).hexdigest(), | |
| ) | |
| if algo == "AES-256-GCM": | |
| blob, meta = CryptoExecutor.aes_gcm(spec.plaintext, spec.key_material) | |
| return self._build_symmetric( | |
| spec, blob, meta, | |
| filename=spec.extra.get("filename", "data.txt"), | |
| cmd=f"openssl enc -d -aes-256-gcm -in {spec.extra.get('filename', 'data.txt')} -pass pass:{spec.key_material}", | |
| final_hash=hashlib.sha256(blob).hexdigest(), | |
| ) | |
| if algo == "RSA-1024" or algo.startswith("RSA"): | |
| sig, meta = CryptoExecutor.rsa_sign(spec.plaintext, key_size=int(algo.split("-")[1]) if "-" in algo else 1024) | |
| return self._build_rsa(spec, sig, meta) | |
| if algo == "SHA-256": | |
| blob = spec.plaintext.encode() | |
| return self._build_hash(spec, blob, hashlib.sha256(blob).hexdigest(), | |
| cmd="sha256sum input.bin") | |
| if algo == "HMAC-SHA256": | |
| mac = CryptoExecutor.hmac_sha256(spec.key_material, spec.plaintext) | |
| return self._build_hash(spec, spec.plaintext.encode(), mac.hex(), | |
| cmd=f"openssl dgst -sha256 -hmac '{spec.key_material}' input.json") | |
| if algo == "MD5": | |
| # Red-team: student sees the MD5 hash in the file, cracks it to | |
| # recover the plaintext password, then submits CyberArena{sha256(pw)}. | |
| md5_hex = hashlib.md5(spec.plaintext.encode()).hexdigest() | |
| sha256_of_pw = hashlib.sha256(spec.plaintext.encode()).hexdigest() | |
| return self._build_hash(spec, md5_hex.encode(), sha256_of_pw, | |
| cmd="md5sum input.txt") | |
| if algo == "SHA-1+SALT": | |
| # Red-team: student sees the salted sha1 hash, cracks it to recover | |
| # the password, then submits CyberArena{sha256(pw)}. | |
| digest = CryptoExecutor.sha1_salt(spec.plaintext.encode(), spec.key_material) | |
| sha256_of_pw = hashlib.sha256(spec.plaintext.encode()).hexdigest() | |
| return self._build_hash(spec, digest.hex().encode(), sha256_of_pw, | |
| cmd="cat /etc/shadow", # red-team context | |
| extra_files={"/etc/shadow": f"root:$dynamic_$1${spec.key_material}${digest.hex()}:19000:0:99999:7:::"}) | |
| # --- Red team classical ciphers (student decrypts) --- | |
| if algo == "VIGENERE": | |
| cipher = CryptoExecutor.vigenere_encrypt(spec.plaintext, spec.key_material) | |
| return self._build_classical_red(spec, cipher, spec.plaintext, "VIGENERE", | |
| key=spec.key_material, | |
| key_hint=f"الطول: {len(spec.key_material)} حرف") | |
| if algo == "CAESAR": | |
| shift = int(spec.extra.get("shift", 3)) | |
| cipher = CryptoExecutor.caesar_shift(spec.plaintext, shift) | |
| return self._build_classical_red(spec, cipher, spec.plaintext, "CAESAR", | |
| key=shift, | |
| key_hint="إزاحة أحادية — جرّب 1..25") | |
| if algo == "XOR": | |
| key_byte = int(spec.extra.get("key_byte", 0x42)) | |
| pt = spec.plaintext.encode() | |
| cipher = CryptoExecutor.xor_bytes(pt, key_byte) | |
| return self._build_classical_red(spec, cipher.hex(), pt.decode(errors="ignore"), | |
| "XOR", key=key_byte, | |
| key_hint="single-byte XOR — 256 احتمال") | |
| raise ValueError(f"Unsupported algorithm: {algo}") | |
| # ----------------------- private builders ----------------------- # | |
| def _build_symmetric(self, spec, blob, meta, filename, cmd, final_hash): | |
| """Build a symmetric encryption challenge (RED TEAM ONLY). | |
| The file always holds the ENCRYPTED blob. The student must decrypt | |
| it (e.g. `openssl enc -d -aes-256-cbc -salt -pbkdf2 -in <file> -pass | |
| pass:<password>`), recover the plaintext, and submit | |
| `CyberArena{sha256(plaintext)}`. | |
| """ | |
| cat_stdout = base64.b64encode(blob).decode() | |
| if len(cat_stdout) > 240: | |
| cat_stdout = cat_stdout[:240] + "..." | |
| flag_inner = hashlib.sha256(spec.plaintext.encode()).hexdigest() | |
| flag_value = f"CyberArena{{{flag_inner}}}" | |
| files = {filename: base64.b64encode(blob).decode()} | |
| return Challenge( | |
| team_role=spec.team_role, module=spec.module, title=spec.title, | |
| story=spec.story, task_outline=spec.task_outline, | |
| files=files, file_metadata={filename: {"encoding": "utf-8", "size": len(blob)}}, | |
| command_outputs={ | |
| f"cat:{filename}": {"stdout": cat_stdout, "stderr": "", "exit_code": 0}, | |
| "openssl:enc": {"stdout": blob.hex()[:80] + "..." if len(blob.hex()) > 80 else blob.hex(), | |
| "stderr": "", "exit_code": 0}, | |
| "sha256sum": {"stdout": f"{flag_inner} recovered_plaintext.txt", "stderr": "", "exit_code": 0}, | |
| "ls": {"stdout": filename, "stderr": "", "exit_code": 0}, | |
| }, | |
| hints=self._pick_hints(spec), | |
| tools_whitelist=["cat", "ls", "echo", "python", "openssl", "sha256sum", "base64"], | |
| flag_hash=hashlib.sha256(flag_value.encode()).hexdigest(), | |
| flag_preview=flag_value, | |
| difficulty=spec.difficulty, | |
| xp_reward=XP_BY_DIFFICULTY[spec.difficulty], | |
| ) | |
| def _build_rsa(self, spec, sig, meta): | |
| flag_value = f"CyberArena{{{base64.b64encode(sig).decode().rstrip('=')[:64]}}}" | |
| files = { | |
| spec.extra.get("filename", "contract.txt"): | |
| base64.b64encode(spec.plaintext.encode()).decode(), | |
| "private_key.pem": base64.b64encode(meta["private_key_pem"].encode()).decode(), | |
| } | |
| return Challenge( | |
| team_role=spec.team_role, module=spec.module, title=spec.title, | |
| story=spec.story, task_outline=spec.task_outline, | |
| files=files, | |
| file_metadata={k: {"encoding": "utf-8"} for k in files}, | |
| command_outputs={ | |
| f"cat:{spec.extra.get('filename', 'contract.txt')}": {"stdout": spec.plaintext, "stderr": "", "exit_code": 0}, | |
| "openssl:sign": {"stdout": meta["signature_b64"][:80] + "...", | |
| "stderr": "", "exit_code": 0}, | |
| "ls": {"stdout": "\n".join(files.keys()), "stderr": "", "exit_code": 0}, | |
| }, | |
| hints=self._pick_hints(spec), | |
| tools_whitelist=DEFAULT_TOOLS, | |
| flag_hash=hashlib.sha256(flag_value.encode()).hexdigest(), | |
| flag_preview=flag_value, | |
| difficulty=spec.difficulty, | |
| xp_reward=XP_BY_DIFFICULTY[spec.difficulty], | |
| ) | |
| def _build_hash(self, spec, blob, hex_digest, cmd, extra_files=None): | |
| flag_value = f"CyberArena{{{hex_digest}}}" | |
| files = {"input.bin" if "input" not in spec.extra else spec.extra["input"]: | |
| base64.b64encode(blob).decode()} | |
| if extra_files: | |
| files.update(extra_files) | |
| return Challenge( | |
| team_role=spec.team_role, module=spec.module, title=spec.title, | |
| story=spec.story, task_outline=spec.task_outline, | |
| files=files, file_metadata={k: {"encoding": "utf-8"} for k in files}, | |
| command_outputs={ | |
| cmd.split()[0]: {"stdout": f"{hex_digest} input", "stderr": "", "exit_code": 0}, | |
| "ls": {"stdout": "\n".join(files.keys()), "stderr": "", "exit_code": 0}, | |
| }, | |
| hints=self._pick_hints(spec), | |
| tools_whitelist=DEFAULT_TOOLS, | |
| flag_hash=hashlib.sha256(flag_value.encode()).hexdigest(), | |
| flag_preview=flag_value, | |
| difficulty=spec.difficulty, | |
| xp_reward=XP_BY_DIFFICULTY[spec.difficulty], | |
| ) | |
| def _build_classical_red(self, spec, cipher_display, plaintext, family, key_hint, key=None): | |
| """Red team: student decrypts → finds plaintext → flag is sha256(plaintext). | |
| `key` is accepted for caller convenience (used by VIGENERE/CAESAR/XOR | |
| builders to record the actual key material); it's stored in the | |
| challenge spec but doesn't need to appear in the public payload. | |
| The simulator file name comes from `spec.extra["filename"]` (set by | |
| seeds via `file_convention` or by the AI). When the name in the | |
| story doesn't match, the story is rewritten so they stay in sync. | |
| """ | |
| inner = hashlib.sha256(plaintext.encode()).hexdigest() | |
| flag_value = f"CyberArena{{{inner}}}" | |
| filename = spec.extra.get("filename") or "cipher.txt" | |
| spec.story = _align_filename_in_story(spec.story, filename) | |
| spec.task_outline = _align_filename_in_story(spec.task_outline, filename) | |
| files = {filename: base64.b64encode(cipher_display.encode()).decode()} | |
| return Challenge( | |
| team_role=spec.team_role, module=spec.module, title=spec.title, | |
| story=spec.story, task_outline=spec.task_outline, | |
| files=files, file_metadata={filename: {"encoding": "utf-8"}}, | |
| command_outputs={ | |
| f"cat:{filename}": {"stdout": cipher_display if len(cipher_display) < 200 | |
| else cipher_display[:200] + "...", "stderr": "", "exit_code": 0}, | |
| "ls": {"stdout": filename, "stderr": "", "exit_code": 0}, | |
| }, | |
| hints=self._pick_hints(spec, key_hint=key_hint), | |
| tools_whitelist=["cat", "ls", "echo", "python", "openssl", "sha256sum", "base64"], | |
| flag_hash=hashlib.sha256(flag_value.encode()).hexdigest(), | |
| flag_preview=flag_value, | |
| difficulty=spec.difficulty, | |
| xp_reward=XP_BY_DIFFICULTY[spec.difficulty], | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # 5. AI integration (Groq) — produces a ScenarioSpec | |
| # --------------------------------------------------------------------------- # | |
| CRYPTO_SCENARIO_PROMPT = """أنت مصمم تحديات أمن سيبراني محترف. كل تحدي يجب أن يكون سيناريو فريد وواقعي يبدو وكأنه من عملية اختراق حقيقية. | |
| المطلوب: ولّد SPEC لتحدي تشفير للفريق {team_role} في موديول {module}. | |
| قواعد حاسمة: | |
| - الخوارزمية محددة لك مسبقاً ({algorithm}) — لا تخترع خوارزمية أخرى. اكتب سيناريو يناسبها. | |
| - لا تذكر اسم الخوارزمية (AES/RSA/Vigenere/Caesar/MD5/SHA/HMAC/XOR) داخل story أو task. | |
| - الإيجاز إلزامي: الـ story 2 جمل لا أكثر، الـ task جملة واحدة، الـ title 5-10 كلمات. لا تكرر نفسك. | |
| - الـ title: عنوان عربي محدد وجذاب — ليس عاماً. كل تحدي يجب أن يكون قصة مختلفة جذرياً عن أي تحدي آخر. | |
| - الـ story: سيناريو واقعي من 2 جمل يربط الـ title بسياق حقيقي. | |
| - الـ task_outline: ماذا يجب على الطالب فعله، جملة واحدة بدون ذكر الأوامر بالأحرف. | |
| - للفريق blue: سيناريو تطبيق تشفير/توقيع على ملف موجود. | |
| - للفريق red: سيناريو كسر/فك تشفير/استخراج plaintext من مشفّر. | |
| - الـ plaintext: محتوى ملف input قصير وواقعي (CSV, JSON, config, log, password, payload...). اكتبه بالعربية أو بالإنجليزية حسب القصة، واجعله قصيراً (سطرين إلى خمسة أسطر). | |
| - الـ key_material (اختياري): إذا خطر ببالك key أو password مناسب للقصة، ضعه هنا. لا تذكره داخل الـ story. إذا لم يخطر لك، اتركه فاضياً وسيتم توليده تلقائياً. | |
| - الصعوبة: {difficulty} | |
| - اسم الملف الذي يحتوي المحتوى (مثال: intercept.txt، leak.bin، firmware.bin، contract.txt، payload.json): ضعه في `extra.filename` واستعمل نفس الاسم داخل story و task_outline بالضبط. | |
| - الـ hints: 3 تلميحات سقراطية قصيرة (جملة واحدة لكل تلميح) — توجّه الطالب للتفكير بدون إعطاء الجواب. | |
| تنبيه لمنع التكرار (قاعدة صارمة ضد التشابه): | |
| يجب أن تتجنب تماماً تكرار أي من العناوين أو الأفكار أو السياقات المستعملة مؤخراً. | |
| العناوين التي تم توليدها مؤخراً ويُمنع استخدامها أو تكرار أي فكرة منها هي: | |
| {blacklisted_titles} | |
| 禁止 التكرار — قواعد صارمة ضد التشابه: | |
| - لا تستخدم نفس السياق (embassy, leak, ransomware, military, hospital) أكثر من مرة. | |
| - غيّر الإعداد الجغرافي والزمني والمهني بالكامل عن أي تحدي سابق. | |
| - العنوان والقصة يجب أن يكونا مختلفين جذرياً — لا تكتفي بتغيير كلمة واحدة. | |
| - الـ plaintext يجب أن يكون محتوى مختلف تماماً (不同类型 من الملفات، محتوى مختلف). | |
| أمثلة على عناوين مميزة (استلهم منها، لا تنسخها حرفياً): | |
| - "اعتراض بث مشفر من ضابط ألماني في الـ Afrika Korps" | |
| - "تسريب رسالة بين CFO و broker في تحقيق SEC" | |
| - "تحليل memory dump من راوتر Cisco مخترق" | |
| - "كسر enable password من Cisco IOS" | |
| - "اكتشاف payload مشفر في dropper من عيلة Chaos" | |
| - "برقية من SOE agent في فرنسا المحتلة" | |
| - "فك نقش هيروغليفي على جدار مقبرة فرعونية" | |
| - "خريطة كنز من قرصان الكاريبي في القرن الثامن عشر" | |
| مهم جداً: | |
| - لا تكتب أي نص قبل أو بعد الـ JSON. أخرج JSON فقط. | |
| - لا تضف ```json أو ``` — JSON خام فقط. | |
| - أبقِ الإجمالي أقل من 1200 كلمة لتفادي القطع. | |
| أعد JSON حصراً بهذا الشكل (بدون أي نص خارجه): | |
| {{ | |
| "title": "عنوان عربي محدد وجذاب", | |
| "story": "قصة واقعية 2 جمل بدون أسماء خوارزميات", | |
| "task_outline": "مطلوب الطالب بدقة بدون أوامر", | |
| "plaintext": "محتوى الملف input (سطرين إلى خمسة أسطر)", | |
| "key_material": "password أو key أو salt (اختياري — فاضي مقبول)", | |
| "hints": ["تلميح 1 غير مباشر", "تلميح 2 غير مباشر", "تلميح 3 غير مباشر"], | |
| "extra": {{ | |
| "filename": "اسم_الملف.لاحقة" | |
| }} | |
| }}""" | |
| async def ai_generate_scenario_via_cloudflare( | |
| team_role: str, module: str, difficulty: str, | |
| api_token: str, account_id: str, model: str = CLOUDFLARE_MODEL, | |
| algorithm: str | None = None, | |
| blacklisted_titles: str = "", | |
| ) -> ScenarioSpec: | |
| """Primary AI: Cloudflare Workers AI (minimax-m3). | |
| Uses the Cloudflare REST API. The model is pre-selected by the system | |
| (see _pick_algorithm_for_slot) — the AI only writes flavor text. | |
| """ | |
| if not api_token or not account_id: | |
| raise ValueError("CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID not set") | |
| url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model}" | |
| prompt = CRYPTO_SCENARIO_PROMPT.format( | |
| team_role=team_role, module=module, difficulty=difficulty, | |
| algorithm=algorithm or "any", blacklisted_titles=blacklisted_titles, | |
| ) | |
| payload = { | |
| "messages": [ | |
| {"role": "system", "content": "You output valid JSON only. No prose, no markdown fences."}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| "max_tokens": 2500, | |
| "temperature": 0.7, | |
| } | |
| headers = { | |
| "Authorization": f"Bearer {api_token}", | |
| "Content-Type": "application/json", | |
| } | |
| def _call() -> str: | |
| with httpx.Client(timeout=60) as client: | |
| r = client.post(url, headers=headers, json=payload) | |
| if r.status_code == 429: | |
| raise RuntimeError(f"Cloudflare 429: {r.text[:200]}") | |
| if r.status_code >= 400: | |
| raise RuntimeError(f"Cloudflare HTTP {r.status_code}: {r.text[:200]}") | |
| data = r.json() | |
| # Cloudflare returns {"success": true, "result": {"response": "..."}} | |
| if not data.get("success"): | |
| errors = data.get("errors", []) | |
| raise RuntimeError(f"Cloudflare error: {errors}") | |
| result = data.get("result", {}) or {} | |
| return (result.get("response") or "").strip() | |
| content = await asyncio.to_thread(_call) | |
| if content.startswith("```"): | |
| content = content.split("```", 2)[1] | |
| if content.startswith("json"): | |
| content = content[4:] | |
| content = content.strip().rstrip("`") | |
| try: | |
| data = _repair_json(content) | |
| except (ValueError, json.JSONDecodeError) as e: | |
| raise _ResponseTruncated(f"Could not repair JSON: {e}") from e | |
| extra = data.get("extra", {}) or {} | |
| if "filename" not in extra or not extra["filename"]: | |
| m = _FILENAME_RE.findall(data.get("story", "") or "") | |
| if m: | |
| extra["filename"] = m[0] | |
| if extra.get("filename"): | |
| data["story"] = _align_filename_in_story(data.get("story", ""), extra["filename"]) | |
| data["task_outline"] = _align_filename_in_story(data.get("task_outline", ""), extra["filename"]) | |
| # Sanitize any algorithm-name leaks in the task before validation. | |
| data["task_outline"] = _sanitize_task_outline(data.get("task_outline", "")) | |
| final_algo = _normalize_algo(algorithm or data.get("algorithm", "")) | |
| ai_key = (data.get("key_material") or "").strip() | |
| if ai_key: | |
| key_material, extra = ai_key, extra | |
| else: | |
| key_material, extra = _generate_key_for_algo(final_algo, extra) | |
| hints_raw = data.get("hints") or [] | |
| if isinstance(hints_raw, list): | |
| hints = [str(h).strip() for h in hints_raw if str(h).strip()] | |
| else: | |
| hints = [] | |
| return ScenarioSpec( | |
| team_role=team_role, module=module, difficulty=difficulty, | |
| algorithm=final_algo, plaintext=data["plaintext"], | |
| key_material=key_material, | |
| title=data["title"], story=data["story"], task_outline=data["task_outline"], | |
| extra=extra, hints=hints, | |
| ) | |
| async def ai_generate_scenario(team_role: str, module: str, difficulty: str, | |
| groq_api_key: str, model: str | None = None, | |
| algorithm: str | None = None, | |
| blacklisted_titles: str = "") -> ScenarioSpec: | |
| """Call Groq to produce a scenario. Validates and returns a ScenarioSpec. | |
| Uses the official `groq` Python SDK (sync). The SDK call is wrapped in | |
| `asyncio.to_thread` so the existing async call sites in `refill_pool` | |
| keep working without blocking the event loop. | |
| The `algorithm` is now pre-selected by the system (see _pick_algorithm_for_slot). | |
| The AI is asked to write a story that fits it, but the algorithm itself | |
| is fixed — the AI only generates flavor (title, story, task, hints, plaintext) | |
| plus an optional key_material suggestion. | |
| """ | |
| if model is None: | |
| model = GROQ_MODEL # env var or default to llama-3.1-8b-instant | |
| from groq import Groq | |
| prompt = CRYPTO_SCENARIO_PROMPT.format( | |
| team_role=team_role, module=module, difficulty=difficulty, | |
| algorithm=algorithm or "any", blacklisted_titles=blacklisted_titles, | |
| ) | |
| def _call_groq() -> str: | |
| client = Groq(api_key=groq_api_key) | |
| completion = client.chat.completions.create( | |
| model=model, | |
| messages=[ | |
| {"role": "system", "content": "You output valid JSON only. No prose, no markdown fences."}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| temperature=0.7, | |
| max_completion_tokens=2500, | |
| top_p=1, | |
| stream=False, | |
| stop=None, | |
| ) | |
| msg = completion.choices[0].message | |
| content = (msg.content or "").strip() | |
| finish = completion.choices[0].finish_reason | |
| # If the model hit the token cap, the JSON is likely truncated | |
| # mid-string — treat as a transient error so the caller falls | |
| # through to DeepSeek/seed. | |
| if finish == "length": | |
| raise _ResponseTruncated("Groq response hit max_completion_tokens") | |
| return content | |
| content = await asyncio.to_thread(_call_groq) | |
| # Strip optional code fences | |
| if content.startswith("```"): | |
| content = content.split("```", 2)[1] | |
| if content.startswith("json"): | |
| content = content[4:] | |
| content = content.strip().rstrip("`") | |
| try: | |
| data = _repair_json(content) | |
| except (ValueError, json.JSONDecodeError) as e: | |
| # The Groq SDK sometimes returns finish_reason="stop" with a | |
| # body that is actually truncated (connection was closed | |
| # mid-stream). Treat any JSON-parse failure as a transient | |
| # truncation so the caller falls through to DeepSeek/seed. | |
| raise _ResponseTruncated(f"Could not repair JSON: {e}") from e | |
| extra = data.get("extra", {}) or {} | |
| if "filename" not in extra or not extra["filename"]: | |
| m = _FILENAME_RE.findall(data.get("story", "") or "") | |
| if m: | |
| extra["filename"] = m[0] | |
| if extra.get("filename"): | |
| data["story"] = _align_filename_in_story(data.get("story", ""), extra["filename"]) | |
| data["task_outline"] = _align_filename_in_story(data.get("task_outline", ""), extra["filename"]) | |
| # Sanitize any algorithm-name leaks in the task before validation. | |
| data["task_outline"] = _sanitize_task_outline(data.get("task_outline", "")) | |
| # The system pre-selects the algorithm — always use it, never the AI's pick. | |
| final_algo = _normalize_algo(algorithm or data.get("algorithm", "")) | |
| ai_key = (data.get("key_material") or "").strip() | |
| # If the AI didn't suggest a key, auto-generate one that fits the algorithm. | |
| if ai_key: | |
| key_material, extra = ai_key, extra | |
| else: | |
| key_material, extra = _generate_key_for_algo(final_algo, extra) | |
| hints_raw = data.get("hints") or [] | |
| if isinstance(hints_raw, list): | |
| hints = [str(h).strip() for h in hints_raw if str(h).strip()] | |
| else: | |
| hints = [] | |
| return ScenarioSpec( | |
| team_role=team_role, module=module, difficulty=difficulty, | |
| algorithm=final_algo, plaintext=data["plaintext"], | |
| key_material=key_material, | |
| title=data["title"], story=data["story"], task_outline=data["task_outline"], | |
| extra=extra, hints=hints, | |
| ) | |
| # Transient errors from Groq that should trigger a Gemini fallback | |
| # (NOT 429 — that's a hard rate-limit and we should back off, not retry | |
| # against a second provider that may share the same backend limit). | |
| _GROQ_TRANSIENT_EXCEPTIONS = (httpx.TimeoutException, httpx.ReadTimeout, | |
| httpx.ConnectError, httpx.RemoteProtocolError) | |
| class _ResponseTruncated(Exception): | |
| """Raised when the Groq model returns a finish_reason=length response | |
| (i.e. the JSON was cut off mid-string). Treated as transient.""" | |
| pass | |
| async def ai_generate_scenario_via_deepseek(team_role: str, module: str, difficulty: str, | |
| nvidia_api_key: str, | |
| model: str | None = None, | |
| algorithm: str | None = None, | |
| blacklisted_titles: str = "") -> ScenarioSpec: | |
| """Fallback AI: call NVIDIA integrate (DeepSeek v4-pro) when Groq is unreachable. | |
| OpenAI-compatible chat-completions endpoint. Returns a ScenarioSpec the | |
| same way ai_generate_scenario does. Raises on transport errors so the | |
| caller can decide what to do next (e.g. fall back to a curated seed). | |
| The `algorithm` is pre-selected by the system (see _pick_algorithm_for_slot). | |
| """ | |
| if not nvidia_api_key: | |
| raise RuntimeError("NVIDIA_API_KEY not configured") | |
| model = model or NVIDIA_MODEL | |
| prompt = CRYPTO_SCENARIO_PROMPT.format( | |
| team_role=team_role, module=module, difficulty=difficulty, | |
| algorithm=algorithm or "any", blacklisted_titles=blacklisted_titles, | |
| ) | |
| body = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": "You output valid JSON only. No prose, no markdown fences."}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| "temperature": 0.7, | |
| "top_p": 1, | |
| "max_tokens": 2500, | |
| "extra_body": {"chat_template_kwargs": {"thinking": False}}, | |
| "stream": False, | |
| } | |
| async with httpx.AsyncClient(timeout=60) as client: | |
| r = await client.post( | |
| NVIDIA_URL, | |
| headers={"Authorization": f"Bearer {nvidia_api_key}", "Content-Type": "application/json"}, | |
| json=body, | |
| ) | |
| r.raise_for_status() | |
| content = r.json()["choices"][0]["message"]["content"].strip() | |
| if content.startswith("```"): | |
| content = content.split("```", 2)[1] | |
| if content.startswith("json"): | |
| content = content[4:] | |
| content = content.strip().rstrip("`") | |
| data = _repair_json(content) | |
| extra = data.get("extra", {}) or {} | |
| if "filename" not in extra or not extra["filename"]: | |
| m = _FILENAME_RE.findall(data.get("story", "") or "") | |
| if m: | |
| extra["filename"] = m[0] | |
| if extra.get("filename"): | |
| data["story"] = _align_filename_in_story(data.get("story", ""), extra["filename"]) | |
| data["task_outline"] = _align_filename_in_story(data.get("task_outline", ""), extra["filename"]) | |
| # Sanitize any algorithm-name leaks in the task before validation. | |
| data["task_outline"] = _sanitize_task_outline(data.get("task_outline", "")) | |
| # The system pre-selects the algorithm — always use it, never the AI's pick. | |
| final_algo = _normalize_algo(algorithm or data.get("algorithm", "")) | |
| ai_key = (data.get("key_material") or "").strip() | |
| # If the AI didn't suggest a key, auto-generate one that fits the algorithm. | |
| if ai_key: | |
| key_material, extra = ai_key, extra | |
| else: | |
| key_material, extra = _generate_key_for_algo(final_algo, extra) | |
| hints_raw = data.get("hints") or [] | |
| if isinstance(hints_raw, list): | |
| hints = [str(h).strip() for h in hints_raw if str(h).strip()] | |
| else: | |
| hints = [] | |
| return ScenarioSpec( | |
| team_role=team_role, module=module, difficulty=difficulty, | |
| algorithm=final_algo, plaintext=data["plaintext"], | |
| key_material=key_material, | |
| title=data["title"], story=data["story"], task_outline=data["task_outline"], | |
| extra=extra, hints=hints, | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # 6. DB insert | |
| # --------------------------------------------------------------------------- # | |
| async def insert_to_db(challenge: Challenge) -> bool: | |
| """Insert one Challenge into public.encryption_challenges. | |
| Uses the atomic insert guard so the dedup check + insert are serialised | |
| per (table, team_role), eliminating race conditions. | |
| """ | |
| from app.services.insert_guard import atomic_insert | |
| from app.services.dedup import is_duplicate_encryption | |
| from app.core.config import normalize_row_module | |
| payload = challenge.to_db_row() | |
| payload = normalize_row_module("encryption_challenges", payload) | |
| return await atomic_insert( | |
| table="encryption_challenges", | |
| team_role=challenge.team_role, | |
| row=payload, | |
| dedup_func=is_duplicate_encryption, | |
| dedup_args=[ | |
| payload.get("title", ""), | |
| payload.get("story", ""), | |
| payload.get("task_outline", ""), | |
| ], | |
| dedup_kwargs={"role_filter": payload.get("team_role")}, | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # 7. Pre-baked scenario library (5 Blue + 5 Red, hand-curated, all passing | |
| # the build+insert path through this same module) | |
| # --------------------------------------------------------------------------- # | |
| BLUE_SEEDS = [ | |
| dict( | |
| module="encryption-basics", difficulty="مبتدئ", | |
| algorithm="AES-256-CBC", | |
| title="تشفير بيانات العملاء في حالة السكون", | |
| story="تدير شركة Apex قسم payments يخزن بيانات بطاقات ائتمان العملاء في CSV. طلب منك مدير الأمن تشفير الملف قبل نقله إلى S3 bucket. الـ password المعطى من قبل المدير مكتوب في الـ task.", | |
| task_outline="استخدم الأمر المتوفر في القائمة البيضاء لتشفير customer_data.csv بكلمة مرور معطاة. ثم احسب hash للمخرجات.", | |
| plaintext="CustomerID,Name,CardNumber,Expiry,CVV\n1001,Ahmed Ali,4532-1234-5678-9010,12/27,123\n1002,Sara Khan,5500-9876-5432-1098,03/26,456\n1003,Omar Yusuf,4716-1111-2222-3333,08/28,789", | |
| key_material="ApexSecure2026", | |
| extra={"filename": "customer_data.csv"}, | |
| ), | |
| dict( | |
| module="encryption-basics", difficulty="متوسط", | |
| algorithm="AES-256-GCM", | |
| title="تشفير سجل طبي بـ authenticated encryption", | |
| story="السجل الطبي للمريضة Fatima Hassan يجب إرساله إلى مستشفى آخر عبر قناة غير آمنة. الـ key تتولد من كلمة مرور ثابتة.", | |
| task_outline="شفّر patient_record.txt بخوارزمية authenticated. كلمة المرور مذكورة في الـ task.", | |
| plaintext="Patient: Fatima Hassan\nDOB: 1985-03-12\nDiagnosis: T2DM, Hypertension\nMedications: Metformin 500mg BID, Lisinopril 10mg QD\nNotes: A1C trending down, follow-up in 3 months", | |
| key_material="MedGCM_2026", | |
| extra={"filename": "patient_record.txt"}, | |
| ), | |
| dict( | |
| module="rsa-aes", difficulty="قوي", | |
| algorithm="RSA-1024", | |
| title="توقيع عقد رقمي", | |
| story="محامي الشركة يحتاج توقيع رقمي على contract.txt باستخدام المفتاح الخاص المرفق. التوقيع يجب أن يكون حقيقياً وقابلاً للتحقق.", | |
| task_outline="وقّع contract.txt باستخدام private_key.pem. الـ flag هو hash 256-bit للتوقيع base64.", | |
| plaintext="SERVICE AGREEMENT\n\nThis agreement is entered into on 2026-04-15\nbetween Apex Corp (Provider) and ZenTech GmbH (Client).\n\nScope: Security audit of ZenTech infrastructure\nDuration: 90 days\nFee: $250,000 USD\n\nSigned: ____________________", | |
| key_material="", | |
| extra={"filename": "contract.txt"}, | |
| ), | |
| dict( | |
| module="hash-cracking", difficulty="مبتدئ", | |
| algorithm="SHA-256", | |
| title="حساب SHA-256 لـ firmware قبل التوزيع", | |
| story="فريق QA رفع firmware.bin للحجم 256 بايت. قبل توزيعه، يجب حساب hash كامل.", | |
| task_outline="احسب hash كامل (256-bit) للملف. الـ flag هو الـ hash نفسه.", | |
| plaintext="", | |
| key_material="", | |
| extra={"input": "firmware.bin"}, | |
| ), | |
| dict( | |
| module="hash-cracking", difficulty="متوسط", | |
| algorithm="HMAC-SHA256", | |
| title="توقيع Webhook", | |
| story="خدمة الدفعات ترسل webhook مع توقيع integrity في الـ header. الـ secret مشترك.", | |
| task_outline="احسب توقيع integrity لـ payload.json بالـ key: Zapier_Webhook_2026_Secret.", | |
| plaintext='{"event":"payment.completed","amount":2500.00,"currency":"USD","timestamp":"2026-04-15T14:30:00Z","txn_id":"tx_abc123"}', | |
| key_material="Zapier_Webhook_2026_Secret", | |
| extra={"input": "payload.json"}, | |
| ), | |
| ] | |
| RED_SEEDS = [ | |
| # ===== VIGENERE — 3 radically different archetypes ===== | |
| dict( | |
| module="encryption-basics", difficulty="مبتدئ", | |
| algorithm="VIGENERE", | |
| theme="ww2_spy_radio", | |
| title_pool=[ | |
| "فك رسالة لاسلكية من عميل مزدوج في الـ Abwehr", | |
| "اعتراض بث مشفر من ضابط ألماني في الـ Afrika Korps", | |
| "برقية من SOE agent في فرنسا المحتلة", | |
| ], | |
| story_pool=[ | |
| "في 1943 اعترضت غرفة عمليات الحلفاء رسالة لاسلكية من عميل مزدوج في الـ Abwehr. البث استعمل مفتاح قصير مكرر (4 أحرف). الملف intercept.txt يحوي النص الخام بأحرف لاتينية كبيرة.", | |
| "خلال عملية استخباراتية في الـ SOE (Special Operations Executive)، عثر فريقنا على مفكرة لضابط اتصال ألماني. كانت الرسائل تُرسل بمفتاح قصير قابل للتكرار. الملف radio_msg.txt يحوي أحد البثوث.", | |
| "في أرشيفات الحكومة البريطانية، عُثر على تسجيلات Morse من WWII. تفريغ أحد البثوث يظهر كنص بأحرف لاتينية كبيرة. الملف intercepted_msg.txt يحوي الرسالة.", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي رسالة مشفرة بـ cipher كلاسيكي. حدد طول الـ key بالأسلوب التراثي، ثم فك التشفير. الـ flag هو sha256 للنص الأصلي.", | |
| ], | |
| plaintext_pool=[ | |
| "MEET ME AT THE BRIDGE AT MIDNIGHT BRING THE DOCUMENTS", | |
| "THE PACKAGE WILL ARRIVE BY FERRY ON TUESDAY AT DAWN", | |
| "OPERATION NIGHTFALL IS A GO PROCEED TO SAFEHOUSE B", | |
| "TARGET COORDINATES ARE FORTY SEVEN NORTH TWELVE EAST", | |
| "ABORT THE MISSION THE ENEMY KNOWS ABOUT OUR PLAN", | |
| ], | |
| key_pool=["RADIO", "ENIGMA", "BLITZ", "LONDON", "AXIS", "LORE", "KILO"], | |
| file_convention="intercept.txt", | |
| hints=[ | |
| "كل رسائل اللاسلكي بالحرب العالمية كانت بأحرف لاتينية كبيرة بدون علامات ترقيم. المسافات هنا تسهيل قراءة فقط.", | |
| "ابحث عن ثلاثيات أحرف متكررة (مثل THE أو ING). المسافة بين تكرار كل ثلاثية تعطيك مضاعفات طول الـ key.", | |
| "عندما تعرف طول الـ key، حلل كل عمود (col[0], col[k], col[2k]...) باستقلال. كل عمود Caesar cipher — الـshift يكشف الكلمة.", | |
| ], | |
| ), | |
| dict( | |
| module="encryption-basics", difficulty="متوسط", | |
| algorithm="VIGENERE", | |
| theme="corporate_insider_trade", | |
| title_pool=[ | |
| "اعتراض رسالة مشفرة من insider trader في وول ستريت", | |
| "تسريب رسالة بين CFO و broker في تحقيق SEC", | |
| "كسر cipher من رسالة tips في hedge fund", | |
| ], | |
| story_pool=[ | |
| "في تحقيق SEC، صودر laptop من CFO في شركة Fortune 500. عثر المحققون على ملف leak.bin يحوي رسالة مشفرة بخوارزمية قديمة. المحتوى يبدو يحوي توصيات stock. الـ key (5 أحرف) هو اسم الـ project السري المذكور في الـ task.", | |
| "كجزء من تحقيق in insider trading، اعترضت NSA رسالة بين trader في نيويورك و broker في سويسرا. الـ key قصير. الملف insider_msg.txt يحوي النص.", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي رسالة مشفرة. فك الـ cipher بالأداة الصحيحة. الـ flag هو sha256 للنص الأصلي.", | |
| ], | |
| plaintext_pool=[ | |
| "BUY TEN THOUSAND SHARES OF APEX BEFORE THE EARNINGS CALL", | |
| "SELL ALL POSITIONS IN CRYPTO BY END OF WEEK REGULATORS ARE WATCHING", | |
| "TRANSFER FUNDS TO SWISS ACCOUNT FOUR SEVEN EIGHT NINE BY FRIDAY", | |
| "PROJECT OMEGA IS CANCELLED INFORM THE BOARD IMMEDIATELY", | |
| "MERGER ANNOUNCEMENT POSTPONED UNTIL NEXT QUARTER", | |
| ], | |
| key_pool=["APEXS", "FORTE", "TRUST", "OMEGA", "NEXUS", "MONEY", "STOCK"], | |
| file_convention="leak.bin", | |
| hints=[ | |
| "نص مالي فيه كلمات مثل BUY، SELL، TRANSFER، SHARES. هذه كلمات انجليزية مألوفة للـ frequency analysis.", | |
| "الـ key طوله 5. ابدأ بهذا الافتراض قبل تجربة أطوال أخرى.", | |
| "كاختصار: حساب IC (Index of Coincidence) للنص. إذا ≈ 0.065 فالـkey طوله 1 (Caesar). أقل من ذلك يعني Vigenere.", | |
| ], | |
| ), | |
| dict( | |
| module="encryption-basics", difficulty="قوي", | |
| algorithm="VIGENERE", | |
| theme="ancient_pharaonic_scroll", | |
| title_pool=[ | |
| "فك نقش هيروغليفي على جدار مقبرة فرعونية", | |
| "ترجمة رسالة من مقبرة توت عنخ آمون", | |
| "كسر cipher من بردية فرعونية قديمة", | |
| ], | |
| story_pool=[ | |
| "في اكتشاف أثري جديد في الأقصر، عثر علماء المصريات على جدار مقبرة فرعونية يحوي نقشاً بأبجدية لاتينية. اللغويون يشتبهون بوجود رسالة مخفية بـ cipher قديم. الملف hieroglyph.txt يحوي الـ transcription.", | |
| "كجزء من تعاون مع متحف تورينو، عُثر على بردية من عصر الرعامسة. الرسالة تبدو مع نص واضح لكن مع إزاحة منتظمة. الـ key قصير (3-4 أحرف).", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي نقشاً من مقبرة فرعونية بأحرف لاتينية. فك الـ cipher بأسلوب تراثي. الـ flag هو sha256 للنص الأصلي.", | |
| ], | |
| plaintext_pool=[ | |
| "OPEN THE BURIAL CHAMBER BEFORE THE NEXT MOON CYCLE", | |
| "THE CURSE WILL FALL UPON ANY WHO DISTURB THIS RESTING PLACE", | |
| "BEYOND THIS DOOR LIES THE TREASURE OF THE PHARAOH", | |
| "GUARD THIS SEAL UNTIL THE GODS RETURN", | |
| ], | |
| key_pool=["ANUB", "RAXX", "ISIS", "HORUS", "OSIRIS", "AMUN"], | |
| file_convention="hieroglyph.txt", | |
| hints=[ | |
| "نصوص قديمة فيها كلمات مألوفة (مثل THE، OF، THE، WILL). هذا يسهّل التحليل الإحصائي.", | |
| "الـ key هنا مرتبط بالإله المصري. ابدأ بأسماء آلهة معروفة.", | |
| "Kasiski works: ابحث عن ثلاثيات متكررة. النقوش الطويلة فيها تكرار أكثر من النصوص القصيرة.", | |
| ], | |
| ), | |
| # ===== CAESAR — 2 archetypes ===== | |
| dict( | |
| module="encryption-basics", difficulty="مبتدئ", | |
| algorithm="CAESAR", | |
| theme="roman_legion_dispatch", | |
| title_pool=[ | |
| "برقية جحش روماني من حدود الأمبراطورية", | |
| "فك رسالة من Legio X Fretensis", | |
| "اعتراض أمر تكتيكي من قنصل روماني", | |
| ], | |
| story_pool=[ | |
| "في 117 م، اعترضت دورية رومانية رسالة من جنرال متمرد في الأمبراطورية. الحروف تبدو منزاحة بشكل منهجي. الملف scroll.txt يحوي الرسالة الأصلية. الـ shift واحد من 2-25.", | |
| "اكتشف علماء الآثار لفافة بردي من بومبي (79 م). النص يبدو صحيح نحوياً لكن الحروف غير مرتبة. الإزاحة بسيطة.", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي نص روماني قديم منزاح بعدد ثابت من الأحرف. جرب كل الـ shifts من 1 إلى 25. الـ flag هو sha256 للنص الأصلي.", | |
| ], | |
| plaintext_pool=[ | |
| "ATTACK AT DAWN BRING THE SHIELDS AND SWORDS", | |
| "RETURN TO CAMP BEFORE SUNSET OR FACE PUNISHMENT", | |
| "THE ENEMY APPROACHES FROM THE EAST PREPARE FOR BATTLE", | |
| "SUPPLIES WILL ARRIVE BY SHIP ON THE THIRD DAY", | |
| "RAISE THE BANNERS THE EMPEROR HAS ARRIVED", | |
| ], | |
| shift_pool=[3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25], | |
| file_convention="scroll.txt", | |
| hints=[ | |
| "كل حرف أزيح بمقدار ثابت. لو جربت shift=1, 2, 3, ... ستصل للنص القابل للقراءة في 25 محاولة أو أقل.", | |
| "في الإنجليزية، الحرف الأكثر تكراراً عادةً E. الحرف الأكثر تكراراً في الـcipher إذا كان H، فالـshift=3. إذا كان J، فالـshift=5.", | |
| "هناك 25 إزاحة فقط ممكنة. لا تضيّع وقتك — جربها كلها بالأداة الصحيحة (مثلاً Python loop).", | |
| ], | |
| ), | |
| dict( | |
| module="encryption-basics", difficulty="متوسط", | |
| algorithm="CAESAR", | |
| theme="pirate_caribbean_treasure", | |
| title_pool=[ | |
| "خريطة كنز من قرصان الكاريبي في القرن الثامن عشر", | |
| "فك رسالة من Edward Teach (Blackbeard)", | |
| "إشارة من سفينة قراصنة في تريسور كوست", | |
| ], | |
| story_pool=[ | |
| "عُثر على صندوق خشبي في حطام سفينة Queen Anne's Revenge. بداخله رسالة مشفرة من Blackbeard. الإزاحة هنا مرتبطة برقم السفن المحظوظ عند البحارة.", | |
| "في متحف Maritime في نيو أورليانز، عُرضت دفتر ملاحظات من قرصان القرن الثامن عشر. آخر صفحة فيها ما يبدو خريطة كنز مع إزاحة أحادية. الملف treasure.txt يحوي الرسالة.", | |
| ], | |
| task_outline_pool=[ | |
| "خريطة الكنز في الملف. النص منزاح بعدد ثابت من الأحرف. الـ flag هو sha256 للنص الأصلي.", | |
| ], | |
| plaintext_pool=[ | |
| "DIG THREE PACES EAST OF THE PALM TREE BENEATH THE STONE", | |
| "FOLLOW THE RIVER NORTH UNTIL YOU SEE THE BRIDGE", | |
| "BURIED ON THE EAST SIDE OF THE HILL UNDER THE OAK TREE", | |
| "TREASURE LIES BENEATH THE FORT AT MIDNIGHT", | |
| "SAIL TO COORDINATES TWELVE NORTH SIXTY SIX WEST", | |
| ], | |
| shift_pool=[3, 5, 7, 9, 11, 13], | |
| file_convention="treasure_map.txt", | |
| hints=[ | |
| "القراصنة استخدموا cipher اسمه ROT13. لو الـshift=13، الحروف الـ13 الأولى تستبدل بالثانية. جرّب.", | |
| "إزاحات مثل 7 أو 9 شائعة لكن 13 (ROT13) مفضّلة في الـ pirate lore.", | |
| ], | |
| ), | |
| # ===== XOR — 2 archetypes ===== | |
| dict( | |
| module="encryption-basics", difficulty="متوسط", | |
| algorithm="XOR", | |
| theme="malware_c2_beacon", | |
| title_pool=[ | |
| "تحليل payload من {malware} RAT", | |
| "فك تشفير C2 beacon من stealer", | |
| "استخراج URL command-and-control من عينة خبيثة", | |
| ], | |
| story_pool=[ | |
| "عُثر على memory dump من خادم مخترق. في أحد الـ buffers، يبدو هناك payload مشفر بطريقة بدائية. الـ 256 احتمال كلها قابلة للاختبار. الملف payload.bin يحوي الـ blob.", | |
| "في تقرير threat intel، عثر فريقنا على dropper من عيلة Chaos. الـ config مخزنة كـ XOR single-byte في نهاية الـ binary. الملف sample.bin يحوي الـ config.", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي blob مشفر بعملية أحادية على byte واحد. جرّب كل القيم الـ 256 (0-255). الـ flag هو sha256 للنص الأصلي.", | |
| ], | |
| plaintext_pool=[ | |
| "reverse_shell_established_attacker_ip_192_168_99_42", | |
| "c2_domain=malicious-cdn.example.com_path=/api/beacon", | |
| "keylogger_active_credentials_harvested_telegram_dump", | |
| "ransomware_note_payment_address_1A2B3C4D5E6F", | |
| "exfiltrate_documents_to_dropbox_upload_id_xyz_889", | |
| ], | |
| key_pool=[0x42, 0x55, 0x77, 0xA3, 0xC9, 0xE5, 0x4F, 0x9B], | |
| file_convention="payload.bin", | |
| hints=[ | |
| "XOR single-byte = plaintext = ciphertext XOR key. جرّب كل قيمة من 0-255 على byte واحد. ستعرف الإجابة عندما تشوف نصاً مقروءاً.", | |
| "XOR يحتفظ بـ bit-pattern. لو الـplaintext فيه ASCII قابل للقراءة، الـkey المعكوس سيجعل الناتج ASCII مقروء. ابحث عن النتيجة التي فيها مسافات وحروف ASCII.", | |
| ], | |
| ), | |
| dict( | |
| module="encryption-basics", difficulty="قوي", | |
| algorithm="XOR", | |
| theme="stolen_iot_firmware", | |
| title_pool=[ | |
| "استعادة firmware مسروق من {fw_target}", | |
| "فك dump من كاميرا مراقبة مسروقة", | |
| "تحليل binary مسروب من راوتر Cisco", | |
| ], | |
| story_pool=[ | |
| "في اختراق لـ supply chain، سُرق firmware.bin من مصنع {vendor}. الـ dump مشفر بـ XOR single-byte. المفتاح غير معروف لكن الـ plaintext المتوقع من version string.", | |
| "كجزء من تحقيق في counterfeit electronics، صودر شحنة من {fw_target}. الـ firmware مشفر بطريقة قابلة للعكس. استخرج الـ version info.", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي firmware مشفر بـ single-byte operation. استخرج النص الأصلي. الـ flag هو sha256 للنص الأصلي.", | |
| ], | |
| plaintext_pool=[ | |
| "production_unit_9842_compiled_2026_06_01_v3_2_1", | |
| "secure_boot_v2_image_hash_abc123def456", | |
| "firmware_signature_oem_authorized_release_2026_q2", | |
| "device_serial_range_1000000_to_1999999_factory_3", | |
| ], | |
| key_pool=[0x88, 0xAA, 0xC7, 0xE2, 0xB1, 0x9C, 0xD4, 0xF6], | |
| file_convention="firmware_dump.bin", | |
| hints=[ | |
| "firmware يبدأ بـ magic bytes معروفة. لو عرفت magic الـ vendor، تقدر تستخرج الـkey: key = ciphertext[0] XOR known_magic[0].", | |
| "تطبيق منهجي: خذ byte من ciphertext وعكسه بكل 256 قيمة. عندما تشوف ASCII string معروف (مثل 'production_unit' أو 'compiled_2026')، هذا الـkey.", | |
| ], | |
| ), | |
| # ===== MD5 — 2 archetypes ===== | |
| dict( | |
| module="hash-cracking", difficulty="مبتدئ", | |
| algorithm="MD5", | |
| theme="wordpress_leak", | |
| title_pool=[ | |
| "كسر MD5 password من {leak}", | |
| "اختراق admin من WordPress 4.2", | |
| "تسريب كلمات سر من مدونة WordPress", | |
| ], | |
| story_pool=[ | |
| "في 2015، تسربت قاعدة بيانات WordPress من موقع إخباري كبير. الـ admin password محفوظ بـ MD5 خام بدون salt. الـ hash معروض في الملف.", | |
| "كجزء من اختبار اختراق، حصلنا على dump من wp_users لـ target. أحد admins يحوي password مكرر. الـ MD5 hash مكشوف في الملف.", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي hash قديم 128-bit. كسر الـ hash بكلمة السر الأصلية. الـ flag هو CyberArena{sha256(original_password)}.", | |
| ], | |
| password_pool=[ | |
| "admin123", "qwerty456", "letmein2024", "P@ssw0rd1", | |
| "welcome123", "abc123def", "trustno1x", "iloveyou99", | |
| ], | |
| file_convention="wp_users.txt", | |
| hints=[ | |
| "MD5 ينتج 32 حرف hex. ابدأ بـ rainbow table — جداول 10GB تحوي معظم كلمات السر تحت طول 8.", | |
| "الـ target هنا blog. كلمات السر الأكثر شيوعاً في WordPress: قوائم 1-2-3، أسماء فرق رياضية، أشهر الأسماء. جرّبها أولاً.", | |
| "الأدوات: hashcat -m 0 (raw MD5) أو John the Ripper --format=raw-md5.", | |
| ], | |
| ), | |
| dict( | |
| module="hash-cracking", difficulty="متوسط", | |
| algorithm="MD5", | |
| theme="healthcare_breach", | |
| title_pool=[ | |
| "كسر admin password من نظام مستشفى", | |
| "اختراق نظام EMR عبر crack MD5", | |
| "استخراج EHR credentials من تسريب صحي", | |
| ], | |
| story_pool=[ | |
| "في اختراق 2019 لـ hospital network، سُربت قاعدة بيانات نظام إدارة المرضى. كلمات السر محفوظة بـ MD5 خام. النظام يحوي سجلات طبية لـ 100,000 مريض.", | |
| "كجزء من تحقيق في ransomware attack على عيادة، عُثر على ملف يحوي hashed credentials. الـ hashes MD5 خام بدون salt.", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي hash قديم 128-bit من نظام طبي. كسر الـ hash. الـ flag هو CyberArena{sha256(original_password)}.", | |
| ], | |
| password_pool=[ | |
| "Doctor!2024", "MedCenter#9", "Nurse!Pass1", "EMR@dm1n", | |
| "HealthSecure8", "Pharma!X42", "Clinic!Pass99", | |
| ], | |
| file_convention="emr_users.txt", | |
| hints=[ | |
| "في healthcare، كلمات السر تميل لاحتواء كلمات طبية: doctor, nurse, med, clinic. أضف هذه لقائمتك.", | |
| "الـ target متوسط التعقيد — كلمات سر 8-10 chars مع capitalized letter و رقم. wordlist مخصص أفضل من rainbow table.", | |
| ], | |
| ), | |
| # ===== SHA-1+SALT — 2 archetypes ===== | |
| dict( | |
| module="hash-cracking", difficulty="قوي", | |
| algorithm="SHA-1+SALT", | |
| theme="linux_root_password", | |
| title_pool=[ | |
| "كسر حساب root من خادم Linux قديم", | |
| "اختراق /etc/shadow من Ubuntu 12.04", | |
| "استخراج root password من server مخترق", | |
| ], | |
| story_pool=[ | |
| "في اختبار اختراق على خادم Linux، حصلنا على /etc/shadow. أحد الحسابات (root) يحوي hash قابل للكسر. الـ salt مكشوف، الـ hash قديم ($1$).", | |
| "من حادثة ransomware، صودر ملف passwd/shadow. الـ hashes تستخدم آلية قديمة. أحد الحسابات الـ admin موجود. كسر الـ password.", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي سطر من /etc/shadow. الـ salt مكشوف. كسر الـ password. الـ flag هو CyberArena{sha256(original_password)}.", | |
| ], | |
| password_pool=[ | |
| "S3cur3!Root", "Linux@2025", "R00t!Pass1", "OldSys_Adm1n", | |
| "P@ssw0rd!RHEL", "ServerH@rd", "DataCent3r!", | |
| ], | |
| salt_pool=["apex2026", "linux42", "r00tme", "cisco99", "salted", "redhat9"], | |
| file_convention="etc_shadow.txt", | |
| hints=[ | |
| "الـ $1$ prefix يعني MD5-crypt (وليس raw MD5). hashcat -m 500 يتعامل معه. الـsalt جزء من الـhash.", | |
| "Salt يمنع rainbow tables لكن brute-force بكلمة شائعة + الـsalt ما يزال سريعاً لو الـpassword ضعيف.", | |
| "الأداة: hashcat -m 500 -a 0 hash.txt wordlist.txt (أو John --format=md5crypt).", | |
| ], | |
| ), | |
| dict( | |
| module="hash-cracking", difficulty="قوي", | |
| algorithm="SHA-1+SALT", | |
| theme="cisco_router_enable", | |
| title_pool=[ | |
| "اختراق راوتر Cisco عبر كلمة سر enable", | |
| "كسر enable password من Cisco IOS", | |
| "استخراج admin من router مخترق", | |
| ], | |
| story_pool=[ | |
| "في red team engagement، اخترقنا router Cisco من internal network. الـ enable password محفوظ بـ type 5 (MD5 crypt) في الـ config. الـ salt مكشوف. استخرج الـ enable password.", | |
| "كجزء من pentest، فحصنا router قديم. الـ config يحوي username + secret من نوع type 5. الـ secret مكشوف. الـ password قابل للكسر.", | |
| ], | |
| task_outline_pool=[ | |
| "الملف يحوي سطر من Cisco config. الـ secret مكشوف. كسر الـ password. الـ flag هو CyberArena{sha256(original_password)}.", | |
| ], | |
| password_pool=[ | |
| "C1sc0!R0uter", "Enable@2025", "NetAdmin!9", "Cisco42@Pass", | |
| "RouterH@rd1", "IOS!Secret9", "Network!42", | |
| ], | |
| salt_pool=["cisco12", "enable1", "ios2024", "router", "switch1"], | |
| file_convention="cisco_config.txt", | |
| hints=[ | |
| "Cisco type 5 = MD5 crypt ($1$). نفس أداة hashcat -m 500.", | |
| "Enable passwords في Cisco عادةً تحتوي كلمات: cisco, enable, secret, router. أضفها لـwordlist.", | |
| ], | |
| ), | |
| ] | |
| def _materialize_firmware_seed() -> dict: | |
| """For the SHA-256 challenge, generate a random 256-byte firmware blob.""" | |
| return {"plaintext": bytes(random.randint(0, 255) for _ in range(256)).hex()} | |
| # --------------------------------------------------------------------------- # | |
| # Word pools for randomization (used by randomize_template) | |
| # --------------------------------------------------------------------------- # | |
| _SALT_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789" | |
| def _rand4_alpha() -> str: | |
| return "".join(random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ") for _ in range(4)) | |
| def _rand8_alnum() -> str: | |
| return "".join(random.choice(_SALT_CHARS) for _ in range(8)) | |
| def _rand_four_digit_year() -> int: | |
| return random.choice([2025, 2026, 2027]) | |
| # --------------------------------------------------------------------------- # | |
| # Variable pools for placeholder substitution in {name_en} / {brand} / etc. | |
| # These are filled into theme story/title templates so every challenge reads | |
| # differently. Each algorithm has 2-3 themes (see RED_SEEDS above); within | |
| # each theme, the per-algorithm plaintext/key/shift pools control the | |
| # crypto recipe. The pools below control the *narrative variation* inside | |
| # stories. | |
| # --------------------------------------------------------------------------- # | |
| _NAMES_AR = ["أحمد علي", "سارة خان", "عمر يوسف", "ليلى حسن", "خالد العتيبي", | |
| "مريم الزهراني", "يوسف النجار", "فاطمة الشيخ", "حسن المالكي", "نور الشمري"] | |
| _NAMES_EN = ["Ahmed Ali", "Sara Khan", "Omar Yusuf", "Laila Hassan", "Khaled Al-Otaibi", | |
| "Mariam Al-Zahrani", "Yousef Al-Najjar", "Fatima Al-Sheikh", "Hassan Al-Maliki", "Noor Al-Shammari"] | |
| _DIAGNOSES = ["T2DM, Hypertension", "Asthma, mild anemia", "Hypothyroidism, GERD", | |
| "Migraine, insomnia", "Hyperlipidemia, vitamin D deficiency", | |
| "PCOS, insulin resistance", "Rheumatoid arthritis"] | |
| _HOSPITALS = ["مستشفى الملك فهد", "مستشفى المواساة", "King's College Hospital", | |
| "Mayo Clinic", "Cleveland Clinic", "مستشفى الحبيب"] | |
| _VENDORS = ["ZenTech GmbH", "QSecure Holding", "NetGuard Inc.", "BlueChip Labs", | |
| "CipherWave BV", "QuantumSec SA", "IronVault LLC"] | |
| _MALWARE_TYPES = ["RAT", "keylogger", "stealer", "dropper", "ransomware", | |
| "cryptominer", "rootkit", "backdoor"] | |
| _INFECTION_VECTORS = ["phishing email", "watering hole", "supply-chain attack", | |
| "USB drop", "drive-by download", "smoke-screened ads"] | |
| _WEBHOOK_SERVICES = ["Slack", "GitHub", "Stripe", "PayPal", "Zapier", | |
| "Twilio", "Discord", "Shopify"] | |
| _WEBHOOK_EVENTS = ["payment.completed", "user.signup", "invoice.paid", | |
| "subscription.canceled", "file.uploaded", "order.shipped", | |
| "ticket.opened", "deploy.finished"] | |
| _LEAK_SOURCES = ["تسريب WordPress قديم", "dump من منتدى phpBB", "تسريب Magento 2017", | |
| "قاعدة بيانات Joomla مكشوفة", "تفريغ Sentry مكشوف", | |
| "leak من Drupal 8 قديم", "تسريب vBulletin"] | |
| _PCAP_SOURCES = ["honeypot في DMZ", "SIEM log", "tcpdump على البوابة", | |
| "Wireshark capture من الـSOC", "تسجيل من IDS sensor", | |
| "traffic capture من جدار الحماية"] | |
| _FIRMWARE_SOURCES = ["جهاز IoT", "router منزلي", "كاميرا مراقبة", "طابعة مكتبية", | |
| "medical device", "industrial PLC", "GPS tracker", "smart hub"] | |
| _HASH_AUDIT_SOURCES = ["تدقيق أمني لـ", "مراجعة دورية لـ", "فحص منتظم لـ", | |
| "penetration test على", "audit لاختراق"] | |
| _BRANDS = ["Apex Corp", "ZenithLabs", "QuantumSec", "IronVault", "Aegis Holding", "N3xus Industries"] | |
| _PLAINTIFFS = ["موظف سابق", "عميل قديم", "طرف ثالث", "شريك تجاري", "متعاقد مستقل"] | |
| _PROTOCOLS = ["HTTPS", "SFTP", "TLS tunnel", "SSH tunnel", "VPN", "WSS"] | |
| _ATMOSPHERE = ["ليلة عاصفة", "صمت الفجر", "غيوم الفجر", "هطول أمطار غزيرة", "ضباب كثيف", "عاصفة رملية"] | |
| def _fill(template: str) -> str: | |
| """Replace {placeholder} tokens in `template` with random picks from | |
| the variable pools above. Unknown placeholders are left as-is.""" | |
| table = { | |
| "name_ar": random.choice(_NAMES_AR), | |
| "name_en": random.choice(_NAMES_EN), | |
| "diagnosis": random.choice(_DIAGNOSES), | |
| "hospital": random.choice(_HOSPITALS), | |
| "vendor": random.choice(_VENDORS), | |
| "malware": random.choice(_MALWARE_TYPES), | |
| "vector": random.choice(_INFECTION_VECTORS), | |
| "service": random.choice(_WEBHOOK_SERVICES), | |
| "event": random.choice(_WEBHOOK_EVENTS), | |
| "leak": random.choice(_LEAK_SOURCES), | |
| "pcap_src": random.choice(_PCAP_SOURCES), | |
| "fw_target": random.choice(_FIRMWARE_SOURCES), | |
| "audit_src": random.choice(_HASH_AUDIT_SOURCES), | |
| "brand": random.choice(_BRANDS), | |
| "plaintiff": random.choice(_PLAINTIFFS), | |
| "protocol": random.choice(_PROTOCOLS), | |
| "atmosphere": random.choice(_ATMOSPHERE), | |
| "year": str(_rand_four_digit_year()), | |
| } | |
| out = template | |
| for k, v in table.items(): | |
| out = out.replace("{" + k + "}", v) | |
| return out | |
| # Filename extension → extension used in the simulator. When the AI/seed | |
| # picked a specific name, we rewrite any *other* common filename in the | |
| # story/task to match, so the simulator file matches the narrative. | |
| _FILENAME_RE = re.compile(r"\b[\w\-./]+\.(?:txt|bin|dat|json|csv|log|enc|sha1|md5|pcap|eml|msg|xml|yaml|yml|sql|conf|cfg|ini)\b", re.IGNORECASE) | |
| def _align_filename_in_story(text: str, canonical: str) -> str: | |
| """Rewrite any *.ext filename in `text` so it matches `canonical`. | |
| Without this, a seed/AI might say "الملف intercept.txt" in the story | |
| while the simulator drops `leak.bin` in the workdir — confusing the | |
| student. The first filename seen in the text is replaced everywhere | |
| with `canonical`; if none, the text is returned unchanged. | |
| """ | |
| if not text or not canonical: | |
| return text | |
| matches = _FILENAME_RE.findall(text) | |
| if not matches: | |
| return text | |
| seen = [] | |
| for m in matches: | |
| if m not in seen and m != canonical: | |
| seen.append(m) | |
| for old in seen: | |
| text = text.replace(old, canonical) | |
| return text | |
| # Algorithm-name leak sanitizer: when the AI accidentally writes "SHA-256" | |
| # or "AES" in the task_outline, we replace the term with a neutral phrase | |
| # so the spec still passes validation. The story is allowed to keep its | |
| # jargon (hash, salt, signature, ciphertext) — only the task gets sanitized. | |
| _LEAK_REPLACEMENTS = { | |
| "AES-256-CBC": "this encryption", | |
| "AES-256-GCM": "this encryption", | |
| "AES": "this cipher", | |
| "RSA-1024": "this asymmetric cipher", | |
| "RSA": "asymmetric encryption", | |
| "Vigenere": "this substitution cipher", | |
| "Caesar": "this shift cipher", | |
| "MD5": "this weak hash", | |
| "SHA-1": "this legacy hash", | |
| "SHA-256": "this hash", | |
| "HMAC-SHA256": "this keyed hash", | |
| "HMAC": "this keyed hash", | |
| "XOR": "this simple cipher", | |
| } | |
| def _sanitize_task_outline(text: str) -> str: | |
| """Strip algorithm-name leaks from task_outline. Case-insensitive. | |
| Replaces long-form names first (so "AES-256-CBC" wins over "AES"), | |
| then short forms. The replacement is wrapped in a word-boundary regex | |
| so we don't leave stray letters when the term sits inside another word | |
| (e.g. "Caesar" → "this shift cipher" must not produce "Cthis shift | |
| cipherar"). | |
| """ | |
| if not text: | |
| return text | |
| out = text | |
| # Sort by length desc so longer terms (AES-256-CBC) are replaced | |
| # before shorter prefixes (AES) — otherwise "AES" would shadow the | |
| # longer match. | |
| for term, replacement in sorted(_LEAK_REPLACEMENTS.items(), | |
| key=lambda kv: -len(kv[0])): | |
| # \b doesn't work cleanly for hyphens; use lookarounds. | |
| # A "word" here means alnum, underscore, or hyphen. | |
| pattern = re.compile( | |
| r"(?<![A-Za-z0-9_\-])" + re.escape(term) + r"(?![A-Za-z0-9_\-])", | |
| re.IGNORECASE, | |
| ) | |
| out = pattern.sub(replacement, out) | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # randomize_template — pick a concrete recipe from a theme-based seed. | |
| # | |
| # Two modes: | |
| # 1) Theme-based (RED_SEEDS): seed has `theme`, `title_pool`, `story_pool`, | |
| # `task_outline_pool`, `plaintext_pool` / `password_pool`, `key_pool` / | |
| # `shift_pool` / `salt_pool`, `file_convention`. Each call samples a | |
| # different combination → a different challenge with a different flag. | |
| # 2) Legacy simple (BLUE_SEEDS): seed has plain `title`, `story`, | |
| # `plaintext`, `key_material`. We just fill any placeholders. | |
| # | |
| # Story titles never contain algorithm names; they describe intent, not | |
| # implementation. Different story + different recipe → different challenge | |
| # + different flag_hash. | |
| # --------------------------------------------------------------------------- # | |
| def randomize_template(seed_dict: dict) -> dict: | |
| s = dict(seed_dict) | |
| algo = (s.get("algorithm") or "").upper() | |
| if "theme" in s and s.get("title_pool"): | |
| # ----- Theme-based seed (red team) ----- | |
| s["title"] = _fill(random.choice(s["title_pool"])) | |
| s["story"] = _fill(random.choice(s["story_pool"])) | |
| s["task_outline"] = _fill(random.choice(s["task_outline_pool"])) | |
| if algo == "VIGENERE": | |
| s["plaintext"] = random.choice(s["plaintext_pool"]) | |
| s["key_material"] = random.choice(s["key_pool"]) | |
| elif algo == "CAESAR": | |
| s["plaintext"] = random.choice(s["plaintext_pool"]) | |
| shift = random.choice(s["shift_pool"]) | |
| s["key_material"] = f"shift={shift}" | |
| s.setdefault("extra", {})["shift"] = shift | |
| elif algo == "XOR": | |
| s["plaintext"] = random.choice(s["plaintext_pool"]) | |
| key_byte = random.choice(s["key_pool"]) | |
| s["key_material"] = f"key_byte=0x{key_byte:02X}" | |
| s.setdefault("extra", {})["key_byte"] = key_byte | |
| elif algo == "MD5": | |
| s["plaintext"] = random.choice(s["password_pool"]) | |
| s["key_material"] = "$P$" + _rand8_alnum() + _rand8_alnum()[:4] | |
| elif algo == "SHA-1+SALT": | |
| s["plaintext"] = random.choice(s["password_pool"]) | |
| salt = random.choice(s["salt_pool"]) | |
| s["key_material"] = f"$1${salt}$" | |
| # Pass the file name to the builder via extra. | |
| if s.get("file_convention"): | |
| extra = s.setdefault("extra", {}) | |
| if algo in ("AES-256-CBC", "AES-256-GCM", "RSA-1024"): | |
| extra.setdefault("filename", s["file_convention"]) | |
| elif algo in ("SHA-256", "HMAC-SHA256"): | |
| extra.setdefault("input", s["file_convention"]) | |
| else: | |
| extra.setdefault("filename", s["file_convention"]) | |
| else: | |
| # ----- Legacy simple seed (blue team, no theme) ----- | |
| for k in ("title", "story", "task_outline"): | |
| if s.get(k): | |
| s[k] = _fill(s[k]) | |
| return s | |
| def build_seeds(team_role: str) -> list[Challenge]: | |
| """Return the curated 5 challenges for a team, built through ChallengeBuilder. | |
| Each call randomizes title/story/plaintext/key from the theme's pools, | |
| so the same seed set produces a *different* batch of challenges every | |
| run. The test suite asserts that all 5 are different (no duplicate | |
| flag_hashes). | |
| """ | |
| seeds = BLUE_SEEDS if team_role == "blue" else RED_SEEDS | |
| out = [] | |
| for s in seeds: | |
| s = randomize_template(s) | |
| if not s.get("plaintext"): | |
| s = {**s, **_materialize_firmware_seed()} | |
| spec = ScenarioSpec(team_role=team_role, **_spec_kwargs(s)) | |
| out.append(ChallengeBuilder().build(spec)) | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # 8. Pool management — get count, refill, watcher (used by main.py) | |
| # --------------------------------------------------------------------------- # | |
| def get_pool_count(team_role: str, module: Optional[str] = None) -> int: | |
| """Return the number of cached challenges for a team (or a specific module). | |
| When `module` is None, returns the team-wide total (the pool the watcher | |
| tracks). When `module` is given, returns the per-module subset. | |
| """ | |
| if not SUPABASE_ANON_KEY or not SUPABASE_URL: | |
| return 0 | |
| base = f"{SUPABASE_URL}/rest/v1/encryption_challenges" | |
| headers = { | |
| "apikey": SUPABASE_ANON_KEY, | |
| "Authorization": f"Bearer {SUPABASE_ANON_KEY}", | |
| } | |
| url = f"{base}?select=id&team_role=eq.{team_role}" | |
| if module: | |
| url += f"&module=eq.{module}" | |
| try: | |
| r = httpx.get(url, headers=headers, timeout=10) | |
| if r.status_code == 200: | |
| return len(r.json()) | |
| except Exception as e: | |
| print(f"[crypto_generator] get_pool_count error: {e}") | |
| return 0 | |
| async def ai_generate_scenario_via_mistral(team_role: str, module: str, difficulty: str, | |
| mistral_api_key: str, | |
| model: str | None = None, | |
| algorithm: str | None = None, | |
| blacklisted_titles: str = "") -> ScenarioSpec: | |
| """Primary AI: call Mistral (mistral-large-latest) for scenario generation. | |
| Per the user's request, Mistral is the first provider tried for | |
| challenge generation. OpenAI-compatible chat-completions endpoint. | |
| Returns a ScenarioSpec the same way ai_generate_scenario does. | |
| Raises on transport errors so the caller can decide what to do | |
| next (fall back to Cloudflare, then Groq, then NVIDIA/DeepSeek, | |
| then seed). | |
| The ``algorithm`` is pre-selected by the system; the AI is asked | |
| to write a story that fits it, not to invent one. | |
| """ | |
| if not mistral_api_key: | |
| raise RuntimeError("MISTRAL_API_KEY not configured") | |
| model = model or MISTRAL_MODEL | |
| prompt = CRYPTO_SCENARIO_PROMPT.format( | |
| team_role=team_role, module=module, difficulty=difficulty, | |
| algorithm=algorithm or "any", blacklisted_titles=blacklisted_titles, | |
| ) | |
| body = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": "You output valid JSON only. No prose, no markdown fences."}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| "temperature": 0.7, | |
| "max_tokens": 2500, | |
| "response_format": {"type": "json_object"}, | |
| } | |
| async with httpx.AsyncClient(timeout=60) as client: | |
| r = await client.post( | |
| MISTRAL_API_URL, | |
| headers={"Authorization": f"Bearer {mistral_api_key}", "Content-Type": "application/json"}, | |
| json=body, | |
| ) | |
| if r.status_code == 429: | |
| raise RuntimeError("429 from Mistral - rate limit") | |
| if r.status_code != 200: | |
| raise RuntimeError(f"Mistral HTTP {r.status_code}: {r.text[:200]}") | |
| content = (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "") | |
| if not content: | |
| raise RuntimeError("Mistral returned empty content") | |
| if "```" in content: | |
| m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL) | |
| if m: | |
| content = m.group(1) | |
| content = content.strip() | |
| if content.lower().startswith("json"): | |
| content = content[4:] | |
| content = content.strip().rstrip("`") | |
| data = _repair_json(content) | |
| extra = data.get("extra", {}) or {} | |
| if "filename" not in extra or not extra["filename"]: | |
| m = _FILENAME_RE.findall(data.get("story", "") or "") | |
| if m: | |
| extra["filename"] = m[0] | |
| if extra.get("filename"): | |
| data["story"] = _align_filename_in_story(data.get("story", ""), extra["filename"]) | |
| data["task_outline"] = _align_filename_in_story(data.get("task_outline", ""), extra["filename"]) | |
| data["task_outline"] = _sanitize_task_outline(data.get("task_outline", "")) | |
| final_algo = _normalize_algo(algorithm or data.get("algorithm", "")) | |
| ai_key = (data.get("key_material") or "").strip() | |
| if ai_key: | |
| key_material, extra = ai_key, extra | |
| else: | |
| key_material, extra = _generate_key_for_algo(final_algo, extra) | |
| hints_raw = data.get("hints") or [] | |
| if isinstance(hints_raw, list): | |
| hints = [str(h).strip() for h in hints_raw if str(h).strip()] | |
| else: | |
| hints = [] | |
| return ScenarioSpec( | |
| team_role=team_role, module=module, difficulty=difficulty, | |
| algorithm=final_algo, plaintext=data["plaintext"], | |
| key_material=key_material, | |
| title=data["title"], story=data["story"], task_outline=data["task_outline"], | |
| extra=extra, hints=hints, | |
| ) | |
| async def refill_pool(team_role: str, count: int = POOL_BATCH, | |
| groq_api_key: str = GROQ_API_KEY) -> int: | |
| """Generate and insert up to `count` challenges for a team. | |
| AI is the PRIMARY source of every scenario. Curated seeds are only a | |
| last-resort fallback when Groq fails (rate-limit, timeout, parse error, | |
| or build failure). The fallback path is per-team backoff-aware: after | |
| a 429 we don't call Groq again for 5 minutes. | |
| Modules are picked round-robin so the team-wide pool stays balanced | |
| across the 3 crypto modules (encryption-basics, hash-cracking, rsa-aes). | |
| Concurrency: this function takes the per-team lock and **re-reads the | |
| count under the lock** before inserting. If the pool is already at | |
| POOL_TARGET (e.g. another watcher / CLI call filled it first), this | |
| call is a no-op. This is the fix for the "pool goes over 5" bug — two | |
| callers used to see the same stale count and both insert. | |
| Returns the number of challenges actually inserted (≤ `count`). | |
| """ | |
| lock = _get_pool_lock(team_role) | |
| async with lock: | |
| # Re-check under the lock: if another caller already filled the | |
| # pool, don't add any more. | |
| current = get_pool_count(team_role) | |
| gap = max(0, POOL_TARGET - current) | |
| if gap == 0: | |
| return 0 | |
| if count > gap: | |
| count = gap | |
| return await _refill_pool_unlocked(team_role, count, groq_api_key) | |
| async def _refill_pool_unlocked(team_role: str, count: int, | |
| groq_api_key: str) -> int: | |
| """Inner refill body — assumes the per-team lock is already held.""" | |
| inserted = 0 | |
| difficulties = ["مبتدئ", "متوسط", "قوي"] | |
| # Both teams' seeds are used as fallback so we have full algo coverage. | |
| # BLUE_SEEDS covers AES-CBC, AES-GCM, RSA, SHA-256, HMAC. | |
| # RED_SEEDS covers VIGENERE, CAESAR, XOR, MD5, SHA-1+SALT. | |
| team_seeds = BLUE_SEEDS if team_role == "blue" else RED_SEEDS | |
| seed_templates = list(BLUE_SEEDS) + list(RED_SEEDS) | |
| seed_cycle = list(seed_templates) | |
| random.shuffle(seed_cycle) | |
| seed_idx = 0 | |
| # Per-team AI gate: skip Groq during a backoff window after a 429. | |
| now = asyncio.get_event_loop().time() | |
| in_backoff = bool(groq_api_key) and now < _AI_BACKOFF_UNTIL.get(team_role, 0.0) | |
| recent_titles = [] | |
| try: | |
| recent_titles = fetch_existing_titles("encryption_challenges", team_role, limit=30) | |
| except Exception as e: | |
| print(f"[crypto_generator] Failed to fetch recent titles: {e}") | |
| blacklisted_titles_str = "\n".join([f"- {t}" for t in recent_titles]) if recent_titles else "لا يوجد عناوين سابقة حالياً." | |
| for i in range(count): | |
| # The system pre-selects the algorithm — the AI only writes flavor. | |
| module, algorithm = _pick_algorithm_for_slot(i) | |
| difficulty = difficulties[i % len(difficulties)] | |
| spec = None | |
| source = "ai" | |
| _maybe_try_cf = False | |
| _maybe_try_groq = False | |
| _maybe_try_deepseek = False | |
| # ---- 0) Mistral (PRIMARY AI per user request) ---- | |
| if MISTRAL_API_KEY: | |
| try: | |
| spec = await ai_generate_scenario_via_mistral( | |
| team_role, module, difficulty, | |
| MISTRAL_API_KEY, | |
| algorithm=algorithm, | |
| blacklisted_titles=blacklisted_titles_str, | |
| ) | |
| print(f"[crypto_generator] Mistral OK: algo={spec.algorithm} title={spec.title[:50]}") | |
| except Exception as e: | |
| err = str(e) | |
| if "429" in err or "Rate Limit" in err: | |
| print(f"[crypto_generator] Mistral 429 — falling through to Cloudflare") | |
| _maybe_try_cf = True | |
| elif "Could not repair JSON" in err or "empty content" in err: | |
| print(f"[crypto_generator] Mistral bad response ({type(e).__name__}) — trying Cloudflare") | |
| _maybe_try_cf = True | |
| else: | |
| print(f"[crypto_generator] Mistral failed ({type(e).__name__}): {e} — trying Cloudflare") | |
| _maybe_try_cf = True | |
| await asyncio.sleep(0.3) | |
| else: | |
| _maybe_try_cf = bool(CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID) | |
| # ---- 1) Cloudflare (SECONDARY AI) ---- | |
| if spec is None and _maybe_try_cf and CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID: | |
| try: | |
| spec = await ai_generate_scenario_via_cloudflare( | |
| team_role, module, difficulty, | |
| CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, | |
| algorithm=algorithm, | |
| blacklisted_titles=blacklisted_titles_str, | |
| ) | |
| print(f"[crypto_generator] CF OK: algo={spec.algorithm} title={spec.title[:50]}") | |
| except Exception as e: | |
| err = str(e) | |
| if "429" in err or "Too Many" in err or "Rate Limit" in err: | |
| # Hard rate limit — try Groq as a different infra. | |
| print(f"[crypto_generator] CF 429 — falling through to Groq") | |
| _maybe_try_groq = True | |
| elif "Could not repair JSON" in err or isinstance(e, _ResponseTruncated): | |
| # Truncated/bad response — try Groq. | |
| print(f"[crypto_generator] CF bad response ({type(e).__name__}) — trying Groq") | |
| _maybe_try_groq = True | |
| else: | |
| # Any other CF error (network, server, auth) — try Groq. | |
| print(f"[crypto_generator] CF failed ({type(e).__name__}): {e} — trying Groq") | |
| _maybe_try_groq = True | |
| # Small delay between AI calls to stay below the per-minute limit. | |
| await asyncio.sleep(0.3) | |
| else: | |
| _maybe_try_groq = bool(groq_api_key) | |
| # ---- 2) Groq (SECONDARY AI) ---- | |
| if spec is None and _maybe_try_groq and groq_api_key and not in_backoff: | |
| try: | |
| spec = await ai_generate_scenario( | |
| team_role, module, difficulty, groq_api_key, | |
| algorithm=algorithm, | |
| blacklisted_titles=blacklisted_titles_str, | |
| ) | |
| print(f"[crypto_generator] Groq OK: algo={spec.algorithm} title={spec.title[:50]}") | |
| except Exception as e: | |
| err = str(e) | |
| if "429" in err or "Too Many" in err or "Rate Limit" in err: | |
| # Hard rate limit — back off. Don't try DeepSeek (it | |
| # may share the same backend infrastructure). | |
| _AI_BACKOFF_UNTIL[team_role] = asyncio.get_event_loop().time() + 300 | |
| in_backoff = True | |
| print(f"[crypto_generator] Groq 429 — backoff 5min for {team_role}") | |
| elif isinstance(e, _GROQ_TRANSIENT_EXCEPTIONS) or isinstance(e, _ResponseTruncated): | |
| # Timeout / network blip / truncated response — try | |
| # DeepSeek before giving up. | |
| print(f"[crypto_generator] Groq transient error ({type(e).__name__}) — trying DeepSeek") | |
| _maybe_try_deepseek = True | |
| else: | |
| print(f"[crypto_generator] Groq failed ({team_role}/{module}/{algorithm}): {e}") | |
| # Unparseable JSON or schema mismatch — also try | |
| # DeepSeek as a sanity check before falling back to seed. | |
| if "Could not repair JSON" in err or "JSON" in err or "KeyError" in err: | |
| _maybe_try_deepseek = True | |
| await asyncio.sleep(0.3) | |
| # ---- 3) DeepSeek (TERTIARY AI — only on Cloudflare/Groq transient) ---- | |
| if spec is None and _maybe_try_deepseek and NVIDIA_API_KEY: | |
| try: | |
| spec = await ai_generate_scenario_via_deepseek( | |
| team_role, module, difficulty, NVIDIA_API_KEY, | |
| algorithm=algorithm, | |
| blacklisted_titles=blacklisted_titles_str, | |
| ) | |
| print(f"[crypto_generator] DeepSeek OK: algo={spec.algorithm} title={spec.title[:50]}") | |
| except Exception as e: | |
| err = str(e) | |
| if "429" in err or "Too Many" in err or "Rate Limit" in err: | |
| print(f"[crypto_generator] DeepSeek 429 — falling through to seed") | |
| else: | |
| print(f"[crypto_generator] DeepSeek failed: {e}") | |
| await asyncio.sleep(0.3) | |
| # ---- 2) Last-resort fallback to a curated theme seed ---- | |
| if spec is None: | |
| source = "seed" | |
| # Try to find a seed that matches the pre-selected algorithm. | |
| # Fall back to a random seed if no match exists. | |
| matching_seeds = [s for s in seed_cycle | |
| if (s.get("algorithm") or "").upper() == algorithm.upper()] | |
| if matching_seeds: | |
| template = matching_seeds[seed_idx % len(matching_seeds)] | |
| else: | |
| template = seed_cycle[seed_idx % len(seed_cycle)] | |
| seed_idx += 1 | |
| randomized = randomize_template(template) | |
| rand_tag = uuid.uuid4().hex[:6] | |
| randomized["title"] = f"{randomized.get('title', 'تحدي تشفير')} - رمز {rand_tag}" | |
| randomized["story"] = randomized.get("story", "") + f" [معرف التدقيق: {rand_tag}]" | |
| try: | |
| spec = ScenarioSpec(team_role=team_role, **_spec_kwargs(randomized)) | |
| except Exception as e: | |
| print(f"[crypto_generator] Seed fallback spec failed: {e}") | |
| continue | |
| # ---- 3) Build the Challenge (algorithm runs in-process) ---- | |
| try: | |
| ch = ChallengeBuilder().build(spec) | |
| if await insert_to_db(ch): | |
| inserted += 1 | |
| algo = getattr(spec, "algorithm", "?") | |
| tag = f"theme={randomized.get('theme')}" if source == "seed" else f"title={spec.title[:40]}" | |
| print(f"[crypto_generator] {source.upper():4s} → algo={algo:11s} {tag}") | |
| except Exception as e: | |
| print(f"[crypto_generator] Build/insert failed: {e}") | |
| matching_seeds = [s for s in seed_templates | |
| if (s.get("algorithm") or "").upper() == algorithm.upper()] | |
| pool_to_use = matching_seeds if matching_seeds else seed_templates | |
| template = pool_to_use[i % len(pool_to_use)] | |
| randomized = randomize_template(template) | |
| try: | |
| spec_obj = ScenarioSpec(team_role=team_role, **_spec_kwargs(randomized)) | |
| ch = ChallengeBuilder().build(spec_obj) | |
| if await insert_to_db(ch): | |
| inserted += 1 | |
| except Exception as e2: | |
| print(f"[crypto_generator] Randomized fallback build failed: {e2}") | |
| await asyncio.sleep(0.2) | |
| return inserted | |
| async def start_pool_watcher(team_role: str, groq_api_key: str = GROQ_API_KEY) -> None: | |
| """Background task: keep the team-wide pool at POOL_TARGET=5. | |
| Pool model: each team (red) keeps POOL_TARGET=5 cached challenges total, | |
| distributed round-robin across modules. Whenever the count is below | |
| POOL_TARGET, the watcher refills exactly the missing slots (no waiting | |
| for the count to drop to a threshold). Above POOL_TARGET it does nothing. | |
| Concurrency: | |
| - One watcher task per (generator, team) is enforced via | |
| `_WATCHER_STARTED` — second call is a no-op. | |
| - The refill itself runs under the per-team lock, so even a manual | |
| CLI `--refill` can't overshoot POOL_TARGET. | |
| Polling cadence: | |
| - When the pool is full (count >= target): poll every IDLE_POLL_SECS | |
| seconds (cheap, just a count() call). | |
| - When the pool is below target: poll every 5 seconds so a consumed | |
| challenge is replaced almost in real-time. | |
| """ | |
| key = ("crypto", team_role) | |
| if key in _WATCHER_STARTED: | |
| print(f"[crypto:{team_role}] watcher already running — skipping duplicate start.") | |
| return | |
| _WATCHER_STARTED.add(key) | |
| try: | |
| label = f"[crypto:{team_role}]" | |
| print(f"{label} watcher started (target={POOL_TARGET}).") | |
| while True: | |
| try: | |
| # Hold the lock across the count-check + refill so a racing | |
| # CLI --refill or second watcher can't double-insert. | |
| # The lock is released BEFORE the sleep so we don't block | |
| # other callers for IDLE_POLL_SECS seconds. | |
| sleep_secs = IDLE_POLL_SECS | |
| async with _get_pool_lock(team_role): | |
| count = get_pool_count(team_role) | |
| if count < POOL_TARGET: | |
| needed = POOL_TARGET - count | |
| print(f"{label} pool below target ({count}/{POOL_TARGET}) — refilling {needed}…") | |
| added = await _refill_pool_unlocked(team_role, needed, groq_api_key) | |
| new_count = count + added | |
| print(f"{label} refilled: {count} → {new_count} (target {POOL_TARGET}).") | |
| sleep_secs = 5 | |
| # else: pool healthy — silent (keep logs clean) | |
| await asyncio.sleep(sleep_secs) | |
| except Exception as e: | |
| import traceback | |
| print(f"{label} watcher error: {e}") | |
| traceback.print_exc() | |
| await asyncio.sleep(10) | |
| finally: | |
| _WATCHER_STARTED.discard(key) | |
| # --------------------------------------------------------------------------- # | |
| # 9. CLI | |
| # --------------------------------------------------------------------------- # | |
| def main(): | |
| # Force UTF-8 on Windows (cp1252 default breaks Arabic output) | |
| try: | |
| sys.stdout.reconfigure(encoding="utf-8", errors="replace") | |
| sys.stderr.reconfigure(encoding="utf-8", errors="replace") | |
| except Exception: | |
| pass | |
| p = argparse.ArgumentParser(description="CyberArena crypto challenge generator (RED TEAM ONLY)") | |
| p.add_argument("--team", choices=("red",), help="Crypto is red-team only") | |
| p.add_argument("--count", type=int, default=5) | |
| p.add_argument("--module", choices=ALLOWED_MODULES, default="encryption-basics") | |
| p.add_argument("--seed-only", action="store_true", help="Use curated seeds, no AI") | |
| p.add_argument("--ai", action="store_true", help="Use AI to generate (needs GROQ_API_KEY)") | |
| p.add_argument("--dry-run", action="store_true", help="Build but don't insert") | |
| p.add_argument("--out", help="Write built challenges to JSON file") | |
| p.add_argument("--count-pool", metavar="TEAM", | |
| help="Print current team-wide pool count, then exit") | |
| p.add_argument("--refill", metavar="TEAM", | |
| help="Refill the team-wide pool (count=POOL_BATCH)") | |
| args = p.parse_args() | |
| # Pool inspection | |
| if args.count_pool: | |
| print(get_pool_count(args.count_pool)) | |
| return | |
| # Pool refill (sync wrapper for refill_pool) | |
| if args.refill: | |
| if args.refill != "red": | |
| p.error("crypto challenges are red-team only") | |
| added = asyncio.run(refill_pool(args.refill, args.count)) | |
| print(f"Refilled {args.refill}: +{added}") | |
| return | |
| if not args.team: | |
| p.error("--team is required unless using --count-pool or --refill") | |
| # Crypto challenges are RED-TEAM ONLY. Blue team gets evaluated fixes | |
| # via /api/training/evaluate, not generated challenges. | |
| if args.team != "red": | |
| p.error("crypto challenges are red-team only") | |
| print(f"Building {args.count} {args.team.upper()} challenges for {args.module}...") | |
| if args.seed_only or not args.ai: | |
| challenges = build_seeds(args.team)[:args.count] | |
| else: | |
| # AI flow | |
| async def _ai(): | |
| out = [] | |
| for i in range(args.count): | |
| # Pre-select an algorithm that matches the requested module | |
| chosen_module, chosen_algo = _pick_algorithm_for_slot(i) | |
| spec = await ai_generate_scenario( | |
| args.team, chosen_module, "متوسط", GROQ_API_KEY, | |
| algorithm=chosen_algo, | |
| ) | |
| out.append(ChallengeBuilder().build(spec)) | |
| return out | |
| challenges = asyncio.run(_ai()) | |
| for c in challenges: | |
| print(f" • {c.title} | {c.difficulty} | flag={c.flag_preview[:40]}...") | |
| if not args.dry_run: | |
| asyncio.run(insert_to_db(c)) | |
| if args.out: | |
| with open(args.out, "w", encoding="utf-8") as f: | |
| json.dump([c.to_db_row() for c in challenges], f, ensure_ascii=False, indent=2) | |
| print(f"Wrote {len(challenges)} challenges to {args.out}") | |
| if __name__ == "__main__": | |
| main() | |