#!/usr/bin/env python3
"""
qilin_decryptor.py : décrypteur pour la variante Qilin (Windows Rust, AES-256-CTR)
dont le build embarque sa clé privée RSA-4096 en clair (config `private_rsa_pem`).

Format de fichier (recoupé : reversing ASM local + analyses publiques AhnLab/ASEC et
derp.ca), pied de fichier de 550 octets depuis la fin :

    [ -550 .. -38 ]  512 o : clé symétrique enveloppée en RSA-4096-OAEP
    [  -38 .. -30 ]    8 o : slot de longueur/magic
    [  -30 ..   0 ]   30 o : marqueur ASCII "-----END CIPHERTEXT BLOCK-----"

Déchiffrement :
  1. localiser le marqueur en fin de fichier ;
  2. extraire le bloc RSA-OAEP de 512 o ;
  3. le désenvelopper avec la clé privée fuitée -> matériel symétrique (clé AES-256 + nonce) ;
  4. déchiffrer le corps en AES-256-CTR.

RÉSERVE : la disposition interne exacte du bloc désenveloppé (ordre/tailles clé vs nonce)
et l'init exacte du compteur CTR ne sont pas documentées publiquement ; elles sont ici
une reconstruction (clé 32 o + nonce 16 o) à CONFIRMER contre un vrai fichier chiffré Qilin.
Le self-test (--selftest) valide le flux de bout en bout sur cette hypothèse.

Analyse statique / crypto locale uniquement, aucune exécution du malware.
"""
import os
import sys
import json
import struct
import argparse
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

MARKER = b"-----END CIPHERTEXT BLOCK-----"   # 30 octets
FOOTER_LEN = 550
WRAP_LEN = 512
_OAEP = padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
KEY_LEN, NONCE_LEN = 32, 16   # hypothese : AES-256 (32 o) + nonce CTR (16 o)


def load_priv(config_path):
    cfg = json.load(open(config_path))
    pem = cfg.get("private_rsa_pem", "")
    if not pem:
        sys.exit("[!] pas de private_rsa_pem dans cette config (build non concerne)")
    # robustesse : gerer un PEM a newlines echappes (\n litteral)
    for cand in (pem, pem.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "")):
        try:
            return load_pem_private_key(cand.encode(), password=None)
        except Exception:
            continue
    sys.exit("[!] private_rsa_pem present mais non chargeable")


# Seuil de chiffrement integral : en dessous, tout le fichier est chiffre ; au-dessus,
# Qilin ne chiffre que des chunks selectifs (10x10% < 1Gio, etc.). Ce decrypteur ne gere
# pour l'instant que le chiffrement INTEGRAL (petits fichiers) de facon fiable.
FULL_ENCRYPT_MAX = 100 * 1024 * 1024   # ~100 Mio


def decrypt_file(enc_path, priv, out_path):
    data = open(enc_path, "rb").read()
    if len(data) < FOOTER_LEN:
        sys.exit("[!] fichier trop court pour contenir le pied Qilin (550 o)")
    if data[-len(MARKER):] != MARKER:
        # scan de secours
        idx = data.rfind(MARKER)
        if idx < 0:
            sys.exit("[!] marqueur de pied de fichier introuvable (pas un fichier Qilin de cette variante ?)")
        footer_start = idx + len(MARKER) - FOOTER_LEN
    else:
        footer_start = len(data) - FOOTER_LEN
    wrapped = data[footer_start:footer_start + WRAP_LEN]
    body = data[:footer_start]
    if len(body) > FULL_ENCRYPT_MAX:
        print(f"[ATTENTION] corps de {len(body)} octets > {FULL_ENCRYPT_MAX} : Qilin applique un")
        print("            chiffrement PARTIEL par chunks au-dela de ce seuil. Ce decrypteur ne")
        print("            gere que le chiffrement integral -> la sortie sera INCORRECTE (parties")
        print("            non chiffrees corrompues par le keystream). Motif de chunks a implementer.")
    try:
        sym = priv.decrypt(wrapped, _OAEP)      # necessite la cle privee (fuitee ici)
    except Exception:
        sys.exit("[!] echec du desenveloppement RSA-OAEP : soit la cle ne correspond pas a "
                 "ce fichier (build/campagne different), soit les parametres OAEP different "
                 "de l'hypothese (SHA-256). Ce fichier n'est pas dechiffrable avec cette cle.")
    if len(sym) < KEY_LEN + NONCE_LEN:
        sys.exit(f"[!] bloc desenveloppe trop court ({len(sym)} o) : disposition interne "
                 "differente de l'hypothese (cle 32 o + nonce 16 o), a caler sur un vrai fichier.")
    key, nonce = sym[:KEY_LEN], sym[KEY_LEN:KEY_LEN + NONCE_LEN]
    dec = Cipher(algorithms.AES(key), modes.CTR(nonce)).decryptor()
    plain = dec.update(body) + dec.finalize()
    open(out_path, "wb").write(plain)
    print(f"[OK] dechiffre : {enc_path} -> {out_path} ({len(plain)} octets)")


def model_encrypt(plain_path, priv, out_path):
    """Chiffre un fichier au format Qilin documente (pour self-test), avec la cle publique de la paire fuitee."""
    pub = priv.public_key()
    data = open(plain_path, "rb").read()
    key, nonce = os.urandom(KEY_LEN), os.urandom(NONCE_LEN)
    enc = Cipher(algorithms.AES(key), modes.CTR(nonce)).encryptor()
    body = enc.update(data) + enc.finalize()
    wrapped = pub.encrypt(key + nonce, _OAEP)   # bloc symetrique enveloppe RSA-OAEP
    assert len(wrapped) == WRAP_LEN, len(wrapped)
    footer = wrapped + struct.pack("<Q", len(data)) + MARKER
    assert len(footer) == FOOTER_LEN, len(footer)
    open(out_path, "wb").write(body + footer)
    print(f"[OK] (modele) chiffre : {plain_path} -> {out_path} (footer {len(footer)} o)")


def selftest(config_path):
    priv = load_priv(config_path)
    orig = b"DONNEE DE TEST JETABLE - victime simulee\n" * 50
    open("/tmp/_qtest.bin", "wb").write(orig)
    model_encrypt("/tmp/_qtest.bin", priv, "/tmp/_qtest.enc")
    decrypt_file("/tmp/_qtest.enc", priv, "/tmp/_qtest.dec")
    ok = open("/tmp/_qtest.dec", "rb").read() == orig
    for f in ("/tmp/_qtest.bin", "/tmp/_qtest.enc", "/tmp/_qtest.dec"):
        os.remove(f)
    print(f"[{'OK ' if ok else 'ERR'}] self-test roundtrip (format documente) : "
          f"{'fichier restitue identique' if ok else 'ECHEC'}")
    print("[note] valide le flux et le format documente ; la disposition interne exacte du")
    print("       bloc RSA reste a confirmer contre un vrai fichier chiffre Qilin.")
    return 0 if ok else 1


def main():
    ap = argparse.ArgumentParser(description="Decrypteur Qilin (variante a cle privee fuitee)")
    ap.add_argument("--config", help="config JSON du build (contient private_rsa_pem)")
    ap.add_argument("--in", dest="infile", help="fichier chiffre")
    ap.add_argument("--out", dest="outfile", help="fichier de sortie dechiffre")
    ap.add_argument("--selftest", metavar="CONFIG", help="self-test roundtrip avec la cle d'une config")
    args = ap.parse_args()
    if args.selftest:
        return selftest(args.selftest)
    if not (args.config and args.infile and args.outfile):
        ap.error("fournir --config, --in et --out (ou --selftest CONFIG)")
    decrypt_file(args.infile, load_priv(args.config), args.outfile)
    return 0


if __name__ == "__main__":
    sys.exit(main())
