From 2ba24028a01b30c49a737c9f95a46b4603ede289 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 7 Sep 2026 22:45:00 -0400 Subject: [PATCH] Port PSN pkg decryption to pure Python (drop Wine requirement) --- README.md | 31 +++-- app/gui.py | 2 +- app/nopaystation.py | 57 +++++++-- app/pkg.py | 257 ++++++++++++++++++++++++++++++++++++++++ app/transfers.py | 11 +- data/bin/ATTRIBUTION.md | 6 +- requirements.txt | 1 + 7 files changed, 330 insertions(+), 35 deletions(-) create mode 100644 app/pkg.py diff --git a/README.md b/README.md index 632836b..a1f8331 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,14 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew. ## Requirements - Linux + Python 3 -- `PySide6` — the only Python dependency (`pip install -r requirements.txt`) +- `PySide6` + `pycryptodome` (`pip install -r requirements.txt`) - `7z` (p7zip) — used to peek/extract disc images from store archives -- `pkg2zip` (bundled in `data/bin/`, run via Wine on Linux) — decrypts - NoPayStation `.pkg` files (see [NoPayStation](#4-nopaystation)) - PPSSPP (optional) — to launch discs from the details panel +NoPayStation `.pkg` files are decrypted by the bundled **pure-Python +decryptor** (`app/pkg.py`) — no Wine or `pkg2zip` required +(see [NoPayStation](#4-nopaystation)). + ## Install ```bash @@ -105,8 +107,10 @@ Discs are matched by hash first (most precise), then serial, then filename. to drawn art when they're absent. - `data/nps/` — NoPayStation TSV lists (cached here on first store scan; drop `PSP_GAMES.tsv` / `PSX_GAMES.tsv` manually to work offline). -- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) used to - decrypt NoPayStation `.pkg` files; see `data/bin/ATTRIBUTION.md`. +- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept + only as an optional fallback decryptor; the app normally uses its own + pure-Python decryptor (`app/pkg.py`) and never needs Wine. + See `data/bin/ATTRIBUTION.md`. - State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`): - `library.sqlite` — the catalog + library database. - `covers/` — downloaded cover cache. @@ -276,16 +280,17 @@ next to the source selector. Requirements & behaviour: -1. `pkg2zip` decrypts the `.pkg` files (they're encrypted PSN packages, not - ZIPs). A copy ships in `data/bin/pkg2zip.exe` and runs via **Wine** on - Linux — just make sure Wine is installed. You can also put a native - `pkg2zip` on `PATH` or set its path in **Settings → Store**. +1. `.pkg` files are decrypted by the built-in **pure-Python decryptor** + (`app/pkg.py`, powered by `pycryptodome` for speed) — no Wine and no + `pkg2zip` install needed. A bundled `pkg2zip.exe` is kept as a fallback + only, and only runs if Python decryption fails and Wine is present. 2. Set **NoPayStation output dir** in **Settings → Store** (default `$PSP_STATE_DIR/nps`). -3. Downloading a PSP game writes `PSP//EBOOT.PBP`; a PS1 Classic - writes `PSX//EBOOT.PBP`. Both then show up in the **Library** - (PS1 Classics under **PS1**, PSP games under **Games**) and can be copied to - the PSP like any other item. +3. Downloading a PSP game writes `PSP//EBOOT.PBP` (plus + `PSP-KEY.EDAT` / `CONTENT.DAT` when the package ships them); a PS1 Classic + writes `PSX//EBOOT.PBP` (+ `DOCUMENT.DAT` and `KEYS.BIN`). Both + then show up in the **Library** (PS1 Classics under **PS1**, PSP games + under **Games**) and can be copied to the PSP like any other item. 4. The TSV lists are cached in `data/nps/` and refreshed on each store scan (drop the `.tsv` files there manually to work offline). diff --git a/app/gui.py b/app/gui.py index be0eb3b..8ceeae9 100644 --- a/app/gui.py +++ b/app/gui.py @@ -1112,7 +1112,7 @@ class SettingsPage(QWidget): self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user:", store_archive_user) self.store_edit = self._dir_row(form, "Local store dir:", store_dir) self.nps_edit = self._dir_row(form, "NoPayStation output dir:", nps_dir) - self.pkg2zip_edit = self._file_row(form, "pkg2zip executable (optional):", pkg2zip_path) + self.pkg2zip_edit = self._file_row(form, "pkg2zip executable (optional fallback):", pkg2zip_path) self.tabs.addTab(page, "Store") # -- Saves ------------------------------------------------------- # diff --git a/app/nopaystation.py b/app/nopaystation.py index 2bd65d9..5dca267 100644 --- a/app/nopaystation.py +++ b/app/nopaystation.py @@ -2,9 +2,9 @@ NoPayStation publishes per-platform TSV lists (``https://nopaystation.com/tsv/``) with one row per downloadable PSN item. Each row links to an encrypted PSN -``.pkg`` (a ``\\x7FPKG`` container, not a ZIP) that must be decrypted with the -``pkg2zip`` tool before it can be installed as a ``PSP/GAME//`` -folder. +``.pkg`` (a ``\\x7FPKG`` container, not a ZIP) that is decrypted by the bundled +pure-Python decryptor (``app/pkg.py``) into a ``PSP/GAME//``-style +folder — no Wine or ``pkg2zip`` required. """ from __future__ import annotations @@ -173,22 +173,55 @@ def _safe_folder(value: str) -> str: return cleaned or "PKG" -def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path, - progress_cb=None, cancel_event=None) -> Path: - """Decrypt/extract a ``.pkg`` with pkg2zip into ``dest_dir//``. +def _ensure_keys_bin(folder, kind): + """PS1 Classics need KEYS.BIN next to EBOOT.PBP to boot on CFW (npdrm).""" + if kind != "psx": + return + src = config.DATA_DIR / "loader" / "KEYS.BIN" + dest = Path(folder) / "KEYS.BIN" + if src.is_file() and not dest.exists(): + try: + shutil.copy2(src, dest) + except OSError: + pass - pkg2zip extracts PSP/PSX content into a folder layout we then normalize: - the game's ``EBOOT.PBP`` (plus siblings) are copied into the destination - game folder. Returns the destination folder path. + +def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None, + progress_cb=None, cancel_event=None) -> Path: + """Decrypt/extract a ``.pkg`` into ``dest_dir//``. + + Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls + back to pkg2zip only if that fails and a binary is available. Returns the + destination game folder path. """ + from . import pkg as pkgmod from .store import TransferCancelled pkg_path = Path(pkg_path) - folder_name = _safe_folder(title_id) - dest = Path(dest_dir) / folder_name + dest = Path(dest_dir) / _safe_folder(title_id) if (dest / "EBOOT.PBP").exists(): return dest + # 1. Pure-Python decryptor (primary). + try: + _kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, progress_cb, cancel_event) + _ensure_keys_bin(folder, _kind) + return folder + except TransferCancelled: + raise + except Exception as exc: # noqa: BLE001 + py_exc = exc + log.warning("python pkg decrypt failed (%s); trying pkg2zip fallback", exc) + + # 2. pkg2zip fallback (needs a binary; usually Wine + the bundled .exe). + if pkg2zip is None: + pkg2zip = find_pkg2zip() + if pkg2zip is None: + raise RuntimeError( + f"could not decrypt {pkg_path.name} (no pkg2zip available): {py_exc}" + ) + + folder_name = _safe_folder(title_id) tmp = Path(dest_dir).with_name(".nps-tmp-" + folder_name) if tmp.exists(): shutil.rmtree(tmp, ignore_errors=True) @@ -197,7 +230,7 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path, if progress_cb is not None: progress_cb("Extracting", 0.0, "") try: - cmd = _pkg2zip_cmd(pkg2zip) + ["-x", str(pkg_path)] + cmd = _pkg2zip_cmd(Path(pkg2zip)) + ["-x", str(pkg_path)] env = dict(os.environ) env["WINEDEBUG"] = "-all" # silence Wine's console chatter proc = subprocess.Popen( diff --git a/app/pkg.py b/app/pkg.py new file mode 100644 index 0000000..2fdfe46 --- /dev/null +++ b/app/pkg.py @@ -0,0 +1,257 @@ +"""Pure-Python PSP/PSX PSN ``.pkg`` decryption (no Wine / pkg2zip needed). + +Ports the folder-mode extraction used by pkgi-psp's depackager and pkg2zip: +parse the ``\\x7FPKG`` header, derive the PSP AES key, AES-CTR-decrypt the file +entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder. The +resulting ``EBOOT.PBP`` keeps its DATA.PSP EDAT-encrypted, so it runs on CFW +with the ``npdrm_free`` plugin (ARK ships it) — same as pkgi-psp's install. +""" +from __future__ import annotations + +from pathlib import Path + +PKG_MAGIC = 0x7F504B47 + +# Static PSN keys (key type 1 = PSP). +PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b") +PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8") + +try: + from Crypto.Cipher import AES as _AES # pycryptodome (fast) + _HAVE_CRYPTO = True +except ImportError: # noqa: BLE001 + _HAVE_CRYPTO = False + + +# --------------------------------------------------------------------------- # +# Pure-Python AES-128 (fallback when pycryptodome is unavailable) +# --------------------------------------------------------------------------- # +_SBOX = bytes.fromhex( + "637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0" + "b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275" + "09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf" + "d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2" + "cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb" + "e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08" + "ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e" + "e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16" +) +_RCON = (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36) + + +def _key_schedule(key): + w = list(key) + for i in range(4, 44): + t = list(w[(i - 1) * 4:(i - 1) * 4 + 4]) + if i % 4 == 0: + t = t[1:] + t[:1] + t = [_SBOX[b] for b in t] + t[0] ^= _RCON[i // 4 - 1] + w += [w[(i - 4) * 4 + j] ^ t[j] for j in range(4)] + return [bytes(w[i * 16:(i + 1) * 16]) for i in range(11)] + + +def _shift_rows(s): + return [s[0], s[5], s[10], s[15], s[4], s[9], s[14], s[3], + s[8], s[13], s[2], s[7], s[12], s[1], s[6], s[11]] + + +def _xtime(a): + a <<= 1 + return (a ^ 0x1B) & 0xFF if a & 0x100 else a & 0xFF + + +def _mix_columns(s): + out = [0] * 16 + for c in range(4): + a0, a1, a2, a3 = s[4 * c], s[4 * c + 1], s[4 * c + 2], s[4 * c + 3] + out[4 * c] = _xtime(a0) ^ (_xtime(a1) ^ a1) ^ a2 ^ a3 + out[4 * c + 1] = a0 ^ _xtime(a1) ^ (_xtime(a2) ^ a2) ^ a3 + out[4 * c + 2] = a0 ^ a1 ^ _xtime(a2) ^ (_xtime(a3) ^ a3) + out[4 * c + 3] = (_xtime(a0) ^ a0) ^ a1 ^ a2 ^ _xtime(a3) + return out + + +def _aes_encrypt_block(rk, block): + s = [block[i] ^ rk[0][i] for i in range(16)] + for rnd in range(1, 10): + s = [_SBOX[b] for b in s] + s = _shift_rows(s) + s = _mix_columns(s) + s = [s[i] ^ rk[rnd][i] for i in range(16)] + s = [_SBOX[b] for b in s] + s = _shift_rows(s) + s = [s[i] ^ rk[10][i] for i in range(16)] + return bytes(s) + + +def _add128(counter, n): + c = bytearray(counter) + for i in range(15, -1, -1): + n += c[i] + c[i] = n & 0xFF + n >>= 8 + return bytes(c) + + +def _ctr_decrypt_py(key, iv, block_index, data): + """AES-CTR decrypt (pure-Python): counter = iv + block_index, +1 per block.""" + rk = _key_schedule(key) + counter = _add128(iv, block_index) + out = bytearray() + for i in range(0, len(data), 16): + ks = _aes_encrypt_block(rk, counter) + out += bytes(b ^ k for b, k in zip(data[i:i + 16], ks)) + counter = _add128(counter, 1) + return bytes(out) + + +def _ctr_decrypt(key, iv, block_index, data): + """AES-CTR decrypt: 128-bit big-endian counter at ``iv + block_index``.""" + if _HAVE_CRYPTO: + nonce = iv[:8] + init = int.from_bytes(iv[8:16], "big") + block_index + return _AES.new(key, _AES.MODE_CTR, nonce=nonce, initial_value=init).decrypt(data) + return _ctr_decrypt_py(key, iv, block_index, data) + + +def _make_ctr_decryptor(key, iv, block_index): + """Return a streaming decryptor (callable: bytes -> bytes) for a data run.""" + if _HAVE_CRYPTO: + nonce = iv[:8] + init = int.from_bytes(iv[8:16], "big") + block_index + cipher = _AES.new(key, _AES.MODE_CTR, nonce=nonce, initial_value=init) + return cipher.decrypt + rk = _key_schedule(key) + counter = _add128(iv, block_index) + state = {"rk": rk, "counter": counter} + + def decrypt(chunk): + out = bytearray() + for i in range(0, len(chunk), 16): + ks = _aes_encrypt_block(state["rk"], state["counter"]) + out += bytes(b ^ k for b, k in zip(chunk[i:i + 16], ks)) + state["counter"] = _add128(state["counter"], 1) + return bytes(out) + return decrypt + + +# --------------------------------------------------------------------------- # +# PKG extraction +# --------------------------------------------------------------------------- # +def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None): + """Decrypt a PSP/PSX PSN pkg into ``dest_dir//``. + + Returns ``(kind, title_id, folder)`` where kind is ``"psp"`` or ``"psx"``. + """ + from .store import TransferCancelled + + pkg_path = Path(pkg_path) + dest_dir = Path(dest_dir) + + with open(pkg_path, "rb") as fh: + header = fh.read(0x100) + if len(header) < 0xE8 or int.from_bytes(header[0:4], "big") != PKG_MAGIC: + raise RuntimeError(f"not a PKG file: {pkg_path.name}") + + meta_offset = int.from_bytes(header[8:12], "big") + meta_count = int.from_bytes(header[12:16], "big") + item_count = int.from_bytes(header[20:24], "big") + enc_offset = int.from_bytes(header[32:40], "big") + iv = header[0x70:0x80] + key_type = header[0xE7] & 7 + title_id = header[0x37:0x40].decode("ascii", "replace").rstrip("\x00").strip() or "PKG" + + content_type = 0 + items_offset = 0 + with open(pkg_path, "rb") as fh: + off = meta_offset + for _ in range(meta_count): + fh.seek(off) + m = fh.read(16) + if len(m) < 16: + break + mtype = int.from_bytes(m[0:4], "big") + msize = int.from_bytes(m[4:8], "big") + if mtype == 2: + content_type = int.from_bytes(m[8:12], "big") + elif mtype == 13: + items_offset = int.from_bytes(m[8:12], "big") + off += 8 + msize + + if content_type == 6: + kind = "psx" + elif content_type in (7, 0xE, 0xF, 0x10): + kind = "psp" + else: + raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}") + + if key_type != 1: + raise RuntimeError(f"unsupported PKG key type {key_type}") + + main_key = PSP_KEY + ps3_key = PS3_KEY + + # Decrypt the whole item table in one shot. + with open(pkg_path, "rb") as fh: + fh.seek(enc_offset + items_offset) + table = fh.read(item_count * 32) + table = _ctr_decrypt(main_key, iv, items_offset // 16, table) + + entries = [] + for i in range(item_count): + e = table[i * 32:(i + 1) * 32] + name_offset = int.from_bytes(e[0:4], "big") + name_size = int.from_bytes(e[4:8], "big") + data_offset = int.from_bytes(e[8:16], "big") + data_size = int.from_bytes(e[16:24], "big") + psp_type = e[24] + flags = e[27] + if flags in (4, 18) or data_size == 0: + continue + item_key = main_key if psp_type == 0x90 else ps3_key + entries.append((name_offset, name_size, data_offset, data_size, item_key)) + + folder = dest_dir / title_id + folder.mkdir(parents=True, exist_ok=True) + written = [] + + total = sum(ds for *_, ds, _ in entries) or 1 + done = 0 + + with open(pkg_path, "rb") as fh: + for name_offset, name_size, data_offset, data_size, item_key in entries: + if cancel_event is not None and cancel_event.is_set(): + raise TransferCancelled() + fh.seek(enc_offset + name_offset) + name = _ctr_decrypt(item_key, iv, name_offset // 16, fh.read(name_size)) + name = name.split(b"\x00", 1)[0].decode("utf-8", "replace") + base = name.rsplit("/", 1)[-1] + + if kind == "psx" and base not in ("EBOOT.PBP", "DOCUMENT.DAT"): + continue + if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"): + continue + + dest = folder / base + decrypt = _make_ctr_decryptor(item_key, iv, data_offset // 16) + fh.seek(enc_offset + data_offset) + with open(dest, "wb") as out: + remaining = data_size + while remaining > 0: + if cancel_event is not None and cancel_event.is_set(): + raise TransferCancelled() + chunk = fh.read(min(1 << 20, remaining)) + if not chunk: + break + out.write(decrypt(chunk)) + remaining -= len(chunk) + done += len(chunk) + if progress_cb is not None: + progress_cb("Extracting", min(done / total, 1.0), "") + written.append(dest) + + if not (folder / "EBOOT.PBP").exists(): + raise RuntimeError(f"no EBOOT.PBP found in {pkg_path.name}") + + return kind, title_id, folder diff --git a/app/transfers.py b/app/transfers.py index ac49b3f..4740b59 100644 --- a/app/transfers.py +++ b/app/transfers.py @@ -469,8 +469,8 @@ class NoPayStationSource(Source): """Browse NoPayStation .pkg lists and install PSP/PSX PSN content. ``content_type`` is ``"psp"`` (PSP_GAMES.tsv) or ``"psx"`` (PSX_GAMES.tsv). - Downloading decrypts the ``.pkg`` with the ``pkg2zip`` binary and writes a - ``/EBOOT.PBP`` game folder into ``dest_dir``. + Downloading decrypts the ``.pkg`` with the bundled pure-Python decryptor + and writes a ``/EBOOT.PBP`` game folder into ``dest_dir``. """ label = "NoPayStation" @@ -490,12 +490,9 @@ class NoPayStationSource(Source): dest_dir = Path(dest_dir) dest_dir.mkdir(parents=True, exist_ok=True) + # The bundled Python decryptor handles PSP/PSX directly; pkg2zip is + # only an optional fallback if a binary happens to be configured. pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip) - if pkg2zip is None: - raise RuntimeError( - "pkg2zip not found — install it (https://github.com/mmozeiko/pkg2zip) " - "or set its path in Settings → Store." - ) title_id = item.get("title_id") or item.get("content_id") or "" pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg" tmp = Path(tempfile.mkdtemp(prefix="nps-download-")) diff --git a/data/bin/ATTRIBUTION.md b/data/bin/ATTRIBUTION.md index d026e82..c163ef5 100644 --- a/data/bin/ATTRIBUTION.md +++ b/data/bin/ATTRIBUTION.md @@ -1,7 +1,9 @@ # pkg2zip -The `pkg2zip.exe` binary in this folder decrypts and extracts NoPayStation -`.pkg` files (PSP / PSX / PS3 / PSVita content). +The `pkg2zip.exe` binary in this folder is kept as an optional fallback for +decrypting NoPayStation `.pkg` files (PSP / PSX / PS3 / PSVita content). The +app normally uses its own pure-Python decryptor (`app/pkg.py`) and only runs +this binary if that fails. - Project: - Author: Marcin Mikołajczak (mmozeiko) diff --git a/requirements.txt b/requirements.txt index 9406ffd..e790235 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ PySide6>=6.6 curl_cffi>=0.7 +pycryptodome>=3.20