| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| from datetime import datetime, timezone |
| import glob |
| import json |
| import os |
| from pathlib import Path |
| import re |
| import shutil |
| import signal |
| import subprocess |
| import sys |
| import threading |
| import time |
| from typing import * |
|
|
| import gmpy2 |
| from gmpy2 import mpz |
|
|
| from enigma_challenges.breaking_rsa import Problem, Solution |
| from enigma_challenges.solution_output import build_solution_zip, write_solution_output |
| import gpu_la |
|
|
| CHALLENGE_INPUT_FILE = "/challenge_input/challenge_input.json" |
|
|
|
|
| def _raise_fd_limit() -> None: |
| """Raise the open-file soft limit to the hard limit so the ramnfs broker, |
| CADO master and sieve clients (all children of this process) can hold the |
| thousands of relation-file descriptors a large factorization produces. |
| Without this the soft default (often 1024) is exhausted mid-run, the broker |
| returns EIO, and CADO crashes during filtering/resieving.""" |
| try: |
| import resource |
| soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) |
| if soft < hard: |
| resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard)) |
| except Exception: |
| pass |
|
|
|
|
| _raise_fd_limit() |
|
|
|
|
| def _printlog(msg: str) -> None: |
| ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") |
| print(f"[{ts}] {msg}", flush=True) |
|
|
|
|
| def _env_int(name: str, default: int) -> int: |
| try: |
| return int(os.environ.get(name, default)) |
| except (TypeError, ValueError): |
| return default |
|
|
|
|
| def _cpu_count() -> int: |
| """Cores available to this container, honoring docker --cpus (the cgroup |
| quota), which os.cpu_count() ignores (it reports host cores).""" |
| n = os.cpu_count() or 8 |
| try: |
| parts = Path("/sys/fs/cgroup/cpu.max").read_text().split() |
| if parts and parts[0] != "max": |
| return max(1, min(n, int(parts[0]) // int(parts[1]))) |
| except (OSError, ValueError, IndexError): |
| pass |
| try: |
| quota = int(Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").read_text()) |
| period = int(Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us").read_text()) |
| if quota > 0 and period > 0: |
| return max(1, min(n, quota // period)) |
| except (OSError, ValueError): |
| pass |
| return n |
|
|
|
|
| def _find_bin(env_key: str, candidates: List[str]) -> Optional[str]: |
| env = os.environ.get(env_key, "").strip() |
| if env and os.path.isfile(env): |
| return env |
| for c in candidates: |
| if os.path.isfile(c): |
| return c |
| return None |
|
|
|
|
| |
|
|
| def _find_cado_script() -> Optional[str]: |
| env = os.environ.get("CADO_NFS", "").strip() |
| if env and os.path.isfile(env): |
| return env |
| for c in ["/opt/cado-nfs/build/release/cado-nfs.py", |
| "/usr/local/bin/cado-nfs.py", "/usr/bin/cado-nfs.py"]: |
| if os.path.isfile(c): |
| return c |
| for pat in ["/opt/cado-nfs/build/*/cado-nfs.py", "/usr/local/lib/cado-nfs-*/cado-nfs.py"]: |
| m = sorted(glob.glob(pat)) |
| if m: |
| return m[-1] |
| return None |
|
|
|
|
| def _start_broker(sock_path: str, log) -> Optional[subprocess.Popen]: |
| broker = _find_bin("RAMNFS_BROKER", ["/opt/ramnfs/broker", "/app/ramnfs/broker"]) |
| if not broker: |
| log("ramnfs: broker binary not found") |
| return None |
| try: |
| os.unlink(sock_path) |
| except OSError: |
| pass |
| try: |
| |
| |
| |
| |
| |
| |
| proc = subprocess.Popen([broker, sock_path], |
| stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
| except Exception as e: |
| log(f"ramnfs: failed to start broker: {e}") |
| return None |
| for _ in range(50): |
| if os.path.exists(sock_path): |
| log(f"ramnfs: broker started (pid={proc.pid})") |
| return proc |
| time.sleep(0.1) |
| log("ramnfs: broker socket did not appear after 5s") |
| proc.kill() |
| return None |
|
|
|
|
| def _factors_from_line(line: str, n: mpz) -> Optional[Tuple[int, int]]: |
| """CADO prints the prime factors space-separated on one line ("p q").""" |
| parts = line.split() |
| if len(parts) < 2 or not all(re.fullmatch(r"\d+", p) for p in parts): |
| return None |
| prod = 1 |
| for p in parts: |
| prod *= int(p) |
| if prod == n and all(gmpy2.is_prime(mpz(int(p))) for p in parts): |
| a, b = int(parts[0]), int(parts[1]) |
| return (a, b) if a <= b else (b, a) |
| return None |
|
|
|
|
| def _invoke_cado(cmd: List[str], env: dict, n: mpz, deadline: float, label: str, |
| log) -> Tuple[Optional[Tuple[int, int]], int]: |
| """Run one cado-nfs.py invocation under the deadline watchdog. |
| |
| Returns (factors_or_None, return_code). Factors are parsed from stdout (only |
| the linalg+sqrt phases print them); a filter-only run returns (None, 0).""" |
| proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, |
| text=True, env=env, start_new_session=True) |
| factors: Optional[Tuple[int, int]] = None |
| tail: List[str] = [] |
| last_log = 0.0 |
| t0 = time.time() |
|
|
| |
| |
| |
| |
| |
| stop = threading.Event() |
|
|
| def _watchdog() -> None: |
| while not stop.wait(2.0): |
| if time.time() >= deadline: |
| log(f"Stage 2: {label} hit deadline after {int(time.time() - t0)}s; terminating") |
| _kill(proc) |
| return |
|
|
| wd = threading.Thread(target=_watchdog, daemon=True) |
| wd.start() |
| try: |
| assert proc.stdout |
| for raw in proc.stdout: |
| for line in raw.replace("\r", "\n").splitlines(): |
| s = line.strip() |
| if not s: |
| continue |
| tail.append(s) |
| del tail[:-40] |
| factors = _factors_from_line(s, n) |
| if factors: |
| log(f"Stage 2: {label} found factors") |
| break |
| now = time.time() |
| if now - last_log > 120: |
| log(f"Stage 2: {label} running ... {int(now - t0)}s elapsed") |
| last_log = now |
| if factors: |
| break |
| except Exception as e: |
| log(f"Stage 2: {label} read error: {e}") |
| finally: |
| stop.set() |
| _kill(proc) |
| rc = proc.poll() |
| if not factors and rc not in (0, None): |
| for ln in tail[-10:]: |
| log(f" cado: {ln[:200]}") |
| return factors, (rc if rc is not None else 1) |
|
|
|
|
| def _run_cado(n: mpz, deadline: float, log) -> Optional[Tuple[int, int]]: |
| """Stage 2: GNFS via CADO with ramnfs, optionally offloading the linear |
| algebra to the GPU (msieve block Lanczos) when a GPU is present. |
| |
| Always-on safety net: the GPU path is attempted only when a GPU is detected, |
| and ANY failure (export, build, LA, sqrt, or a product that doesn't equal N) |
| falls back to CADO's own linear algebra by RESUMING on the same, still-alive |
| broker — so the relations collected during sieving are never thrown away and |
| the result is never worse than the CPU-only solution.""" |
| cado = _find_cado_script() |
| if not cado: |
| log("Stage 2: CADO-NFS script not found; skipping") |
| return None |
| shim = _find_bin("RAMNFS_SHIM", ["/opt/ramnfs/shim.so", "/app/ramnfs/shim.so"]) |
| sock = os.environ.get("RAMNFS_SOCK", "/tmp/ramnfs.sock") |
| workdir = os.environ.get("RAMNFS_WORKDIR", "/ramwork/factor.work") |
| threads = _env_int("CADO_THREADS", 0) or _cpu_count() |
|
|
| |
| |
| |
| |
| |
| |
| |
| for stale in ("/tmp/cado-sqlite", |
| os.path.join(os.environ.get("TMPDIR", "/tmp"), "cado_run")): |
| shutil.rmtree(stale, ignore_errors=True) |
|
|
| broker = None |
| if shim: |
| broker = _start_broker(sock, log) |
| if not broker: |
| log("ramnfs: broker unavailable; falling back to /tmp (may ENOSPC)") |
| shim = None |
| if not shim: |
| workdir = os.path.join(os.environ.get("TMPDIR", "/tmp"), "cado_run") |
| os.makedirs(workdir, exist_ok=True) |
|
|
| env = dict(os.environ) |
| env["HOME"] = env["TMPDIR"] = "/tmp" |
| if shim: |
| env["LD_PRELOAD"] = shim |
| env["RAMNFS_SOCK"] = sock |
| env["RAMNFS_PREFIX"] = "/ramwork" |
|
|
| remaining = max(60, int(deadline - time.time())) |
| cado_build = str(Path(cado).parent) |
| ndigits = len(str(int(n))) |
| nbits = int(n).bit_length() |
| n_clients = threads |
|
|
| base_cmd = [ |
| sys.executable, cado, str(int(n)), |
| f"tasks.workdir={workdir}", |
| f"tasks.threads={threads}", |
| "server.address=localhost", "server.port=0", "server.threaded=1", |
| f"slaves.nrclients={n_clients}", |
| f"slaves.cado_nfs_client.bindir={cado_build}", |
| f"tasks.linalg.bwc.threads={threads}", |
| "tasks.sieve.las.threads=1", |
| |
| |
| |
| ] |
| if os.environ.get("CADO_ADMAX"): |
| base_cmd.append(f"tasks.polyselect.admax={os.environ['CADO_ADMAX']}") |
| if os.environ.get("CADO_DEGREE"): |
| base_cmd.append(f"tasks.polyselect.degree={os.environ['CADO_DEGREE']}") |
| if os.environ.get("CADO_IMPORT") and os.path.isfile(os.environ["CADO_IMPORT"]): |
| base_cmd.append(f"tasks.polyselect.import={os.environ['CADO_IMPORT']}") |
| |
| if os.environ.get("CADO_TARGET_DENSITY"): |
| base_cmd.append(f"tasks.filter.target_density={os.environ['CADO_TARGET_DENSITY']}") |
| if os.environ.get("CADO_RELS_WANTED"): |
| base_cmd.append(f"tasks.sieve.rels_wanted={os.environ['CADO_RELS_WANTED']}") |
|
|
| |
| |
| |
| |
| gpu_min = _env_int("GPU_MIN_DIGITS", 100) |
| use_gpu = (bool(shim) and ndigits >= gpu_min and gpu_la.gpu_available(log)) |
| factors: Optional[Tuple[int, int]] = None |
| try: |
| log(f"Stage 2: CADO c{ndigits} ({nbits}-bit) threads={threads} " |
| f"clients={n_clients} ram_shim={'on' if shim else 'off'} " |
| f"gpu_la={'on' if use_gpu else 'off'} budget={remaining}s") |
|
|
| if use_gpu: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _invoke_cado( |
| base_cmd + ["tasks.linalg.run=false", "tasks.sqrt.run=false"], |
| env, n, deadline, "CADO filter", log) |
| |
| |
| factors = gpu_la.run_gpu_linalg( |
| workdir, int(n), deadline, shim, sock, env, log) |
| if factors: |
| log("Stage 2: GPU linear algebra produced factors") |
| else: |
| log("Stage 2: GPU path unavailable; resuming CADO linear algebra") |
|
|
| if not factors: |
| |
| |
| factors, _ = _invoke_cado(base_cmd, env, n, deadline, "CADO", log) |
| finally: |
| if broker: |
| _terminate_pid(broker) |
| return factors |
|
|
|
|
| def _kill(proc: subprocess.Popen) -> None: |
| """Terminate a process and its session group (CADO spawns child workers).""" |
| if proc is None or proc.poll() is not None: |
| return |
| for sig in (signal.SIGTERM, signal.SIGKILL): |
| try: |
| os.killpg(os.getpgid(proc.pid), sig) |
| except Exception: |
| try: |
| proc.kill() |
| except Exception: |
| pass |
| try: |
| proc.wait(timeout=8) |
| return |
| except Exception: |
| continue |
|
|
|
|
| def _terminate_pid(proc: subprocess.Popen) -> None: |
| """Terminate a single process by PID only — never its process group. |
| |
| The ramnfs broker shares our process group (it must, or CADO's freerel step |
| fails), so killing its *group* would also kill this solver before it can emit |
| the solution output. The broker has no children, so a PID-targeted |
| SIGTERM->SIGKILL is sufficient and safe.""" |
| if proc is None or proc.poll() is not None: |
| return |
| for sig in (signal.SIGTERM, signal.SIGKILL): |
| try: |
| proc.send_signal(sig) |
| except Exception: |
| pass |
| try: |
| proc.wait(timeout=5) |
| return |
| except Exception: |
| continue |
|
|
|
|
| |
|
|
| def factor_semiprime(n_int: int, num_bits: int, deadline: float, |
| log) -> Tuple[Optional[int], Optional[int], str]: |
| n = mpz(n_int) |
| log(f"Factoring {num_bits}-bit ({len(str(n_int))}-digit) semiprime") |
| res = _run_cado(n, deadline, log) |
| if res: |
| return res[0], res[1], "cado_gnfs" |
| return None, None, "failed" |
|
|
|
|
| def _load_problem(log) -> Tuple[str, "Problem"]: |
| if os.path.isfile(CHALLENGE_INPUT_FILE): |
| try: |
| data = json.loads(Path(CHALLENGE_INPUT_FILE).read_text()) |
| prob = Problem(int(data["difficulty"]), int(data["num"]), int(data["num_bits"])) |
| cid = (sys.argv[1].strip() if len(sys.argv) > 1 else "") or "challenge" |
| log(f"Loaded problem from {CHALLENGE_INPUT_FILE}") |
| return cid, prob |
| except Exception as e: |
| log(f"Failed to parse {CHALLENGE_INPUT_FILE}: {e}") |
| if len(sys.argv) == 3: |
| prob = Problem.from_json(sys.argv[2].strip()) |
| log("Loaded problem from argv") |
| return sys.argv[1].strip(), prob |
| raise SystemExit("No problem input: expected /challenge_input/challenge_input.json " |
| "or <challenge_id> <problem_json> argv") |
|
|
|
|
| def main() -> None: |
| wall = _env_int("WALL_TIME", 14400) |
| margin = _env_int("DEADLINE_MARGIN", 120) |
| timestamp_start = datetime.now(timezone.utc).isoformat() |
| start = time.time() |
| deadline = start + wall - margin |
| try: |
| challenge_id, problem = _load_problem(_printlog) |
| except SystemExit as e: |
| print(str(e)) |
| sys.exit(1) |
| if problem.num < 6: |
| print("Error: number must be a positive non-trivial semiprime") |
| sys.exit(1) |
|
|
| _printlog(f"Starting Breaking RSA challenge: {challenge_id}") |
| numstr = str(problem.num) |
| _printlog(f"N = {numstr[:40]}{'...' if len(numstr) > 40 else ''} ({problem.num_bits} bits)") |
|
|
| p, q, method = factor_semiprime(problem.num, problem.num_bits, deadline, log=_printlog) |
| solve_time = time.time() - start |
|
|
| ok = (p is not None and q is not None |
| and mpz(p) * mpz(q) == problem.num |
| and gmpy2.is_prime(mpz(p)) and gmpy2.is_prime(mpz(q))) |
| if ok: |
| _printlog(f"SUCCESS via {method} in {solve_time:.2f}s") |
| solution = Solution("success", int(p), int(q)) |
| else: |
| _printlog(f"FAILED after {solve_time:.2f}s") |
| solution = Solution("failed", None, None) |
|
|
| result_json = json.dumps(solution.to_dict(), indent=2) |
| solve_info_json = json.dumps({ |
| "solution_status": solution.status, |
| "challenge_id": challenge_id, |
| "timestamp_utc": timestamp_start, |
| "solve_time_seconds": solve_time, |
| "method": method, |
| "num_bits": problem.num_bits, |
| }) |
|
|
| output_dir = os.environ.get("OUTPUT_DIR") |
| if output_dir: |
| try: |
| Path(output_dir).mkdir(exist_ok=True) |
| Path(output_dir, "result.json").write_text(result_json) |
| Path(output_dir, "solve_info.json").write_text(solve_info_json) |
| except OSError: |
| pass |
|
|
| zip_bytes = build_solution_zip({ |
| "result.json": result_json, |
| "solve_info.json": solve_info_json, |
| }) |
| write_solution_output(zip_bytes) |
| os._exit(0 if solution.status == "success" else 1) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|