#!/usr/bin/env python3
"""Decrypt mRemoteNG confCons.xml passwords.

Crypto parameters are read from the file header (KdfIterations / BlockCipherMode /
FullFileEncryption) instead of being hardcoded:

  GCM (>= 1.75) : field layout (base64) = salt[16] | nonce[16] | ciphertext | tag[16]
                  key = PBKDF2-HMAC-SHA1(master, salt, KdfIterations, dklen=32), AAD = salt
  CBC (< 1.75)  : field layout (base64) = iv[16] | ciphertext
                  key = md5(master), AES-CBC + PKCS7

Default master key: 'mR3m'.  Exit code is non-zero if anything failed to decrypt.

Usage:
    python3 mremoteng_decrypt.py -f confCons.xml
    python3 mremoteng_decrypt.py -f confCons.xml -p <custom-master-key>
    python3 mremoteng_decrypt.py -s '<base64 Password attr>'
"""

import argparse
import base64
import hashlib
import sys
import xml.etree.ElementTree as ET

try:
    from Cryptodome.Cipher import AES
    from Cryptodome.Util.Padding import unpad
except ImportError:
    from Crypto.Cipher import AES  # pycryptodome fallback
    from Crypto.Util.Padding import unpad

MRNG_NS = 'xmlns:mrng="http://mremoteng.org"'


def gcm_decrypt(enc_b64: str, master: str = "mR3m", iters: int = 1000) -> str:
    data = base64.b64decode(enc_b64.strip())
    salt, nonce, ct, tag = data[:16], data[16:32], data[32:-16], data[-16:]
    key = hashlib.pbkdf2_hmac("sha1", master.encode(), salt, iters, dklen=32)
    cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
    cipher.update(salt)  # associated data
    return cipher.decrypt_and_verify(ct, tag).decode("utf-8")


def cbc_decrypt(enc_b64: str, master: str = "mR3m") -> str:
    """mRemoteNG < 1.75: iv[16] | ciphertext, key = md5(master), AES-CBC."""
    data = base64.b64decode(enc_b64.strip())
    iv, ct = data[:16], data[16:]
    cipher = AES.new(hashlib.md5(master.encode()).digest(), AES.MODE_CBC, iv=iv)
    return unpad(cipher.decrypt(ct), AES.block_size).decode("utf-8")


def parse_encrypted_body(xml_text: str) -> ET.Element:
    """FullFileEncryption=true 时明文是 <Node> 片段（可能不带根节点），补根后再解析。"""
    try:
        return ET.fromstring(xml_text)
    except ET.ParseError:
        return ET.fromstring(f"<mrng:Connections {MRNG_NS}>{xml_text}</mrng:Connections>")


def main() -> None:
    ap = argparse.ArgumentParser(description="Decrypt mRemoteNG stored passwords")
    ap.add_argument("-f", "--file", help="confCons.xml file")
    ap.add_argument("-s", "--string", help="single base64 Password value")
    ap.add_argument("-p", "--password", default="mR3m", help="master key (default: mR3m)")
    args = ap.parse_args()

    if args.string:
        # 单串模式拿不到文件头，GCM(默认 1000 轮) 与 CBC 各试一次
        for fn in (gcm_decrypt, cbc_decrypt):
            try:
                print(fn(args.string, args.password))
                return
            except Exception:
                continue
        print("decrypt failed: wrong master key or malformed value", file=sys.stderr)
        sys.exit(1)

    if not args.file:
        ap.print_help()
        sys.exit(1)

    root = ET.parse(args.file).getroot()
    protected = root.attrib.get("Protected")   # 整体加密时 root 会被替换，先取出

    iters = int(root.attrib.get("KdfIterations") or 1000)
    mode = (root.attrib.get("BlockCipherMode") or "CBC").upper()
    decrypt = (lambda s: gcm_decrypt(s, args.password, iters)) if mode == "GCM" \
        else (lambda s: cbc_decrypt(s, args.password))
    failed = 0

    if (root.attrib.get("FullFileEncryption") or "false").lower() == "true":
        try:
            root = parse_encrypted_body(decrypt((root.text or "").strip()))
        except Exception as e:
            print(f"[full-file] decrypt failed: {e}", file=sys.stderr)
            sys.exit(1)

    if protected:
        try:
            print(f"[root Protected] -> {decrypt(protected)}")
        except Exception as e:
            failed += 1
            print(f"[root Protected] decrypt failed: {e}")

    found = False
    for node in root.iter():
        if node.tag.endswith("Node") and node.attrib.get("Password"):
            found = True
            try:
                pw = decrypt(node.attrib["Password"])
            except Exception as e:
                failed += 1
                pw = f"<decrypt failed: {e}>"
            print(f"[{node.attrib.get('Name')}] "
                  f"{node.attrib.get('Username')}@{node.attrib.get('Domain')} "
                  f"host={node.attrib.get('Hostname')} password={pw}")
    if not found:
        print("No Node with Password found")

    sys.exit(1 if failed else 0)


if __name__ == "__main__":
    main()
