CyberArena / app /generators /crypto.py
Hussien Haider
H
5c14256
Raw History Blame Contribute Delete
112 kB
"""
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
# --------------------------------------------------------------------------- #
@dataclass
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}
@dataclass
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 ----------------- #
@staticmethod
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__"}
@staticmethod
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 ----------------- #
@staticmethod
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 ----------------- #
@staticmethod
def sha256(data: bytes) -> bytes:
return hashlib.sha256(data).digest()
@staticmethod
def md5(data: bytes) -> bytes:
return hashlib.md5(data).digest()
@staticmethod
def sha1_salt(data: bytes, salt: str) -> bytes:
return hashlib.sha1((salt + data.decode(errors="ignore")).encode()).digest()
@staticmethod
def hmac_sha256(key: str, data: str) -> bytes:
return hmac.new(key.encode(), data.encode(), hashlib.sha256).digest()
# ----------------- Classical (Red team) ----------------- #
@staticmethod
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)
@staticmethod
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)
@staticmethod
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()