Add NoPayStation themes, DLC and minis support
This commit is contained in:
+147
-30
@@ -1,10 +1,19 @@
|
||||
"""Pure-Python PSP/PSX PSN ``.pkg`` decryption (no Wine / pkg2zip needed).
|
||||
"""Pure-Python PSP/PSX/theme/DLC PSN ``.pkg`` decryption (no Wine / pkg2zip).
|
||||
|
||||
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.
|
||||
parse the ``\\x7FPKG`` header, derive the PSP/PS3 AES key, AES-CTR-decrypt the
|
||||
file entries, and write the game's ``EBOOT.PBP`` (+ siblings) into a folder.
|
||||
|
||||
Supported content:
|
||||
|
||||
* PSP games / minis (``EBOOT.PBP``) — ``DATA.PSP`` stays EDAT-encrypted, so it
|
||||
runs on CFW with ``npdrm_free`` (ARK ships it), same as pkgi-psp's install.
|
||||
* PS1 Classics (``EBOOT.PBP`` + ``DOCUMENT.DAT``).
|
||||
* PSP themes (``.ptf``) — the packaged PTF is EDAT-wrapped and is unwrapped here.
|
||||
* PSP DLC (``.edat``) — extracted as-is into the owning game's folder.
|
||||
|
||||
Theme unwrapping needs ``pycryptodome`` (for AES-CMAC + ECB); everything else
|
||||
works with either pycryptodome or the built-in pure-Python AES fallback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,6 +25,14 @@ PKG_MAGIC = 0x7F504B47
|
||||
PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b")
|
||||
PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8")
|
||||
|
||||
# PSP KIRK/AMCTL keys used to unwrap theme PTF EDATs (pkgi-psp / libkirk).
|
||||
_KIRK38 = bytes.fromhex("12468d7e1c42209bba5426835eb03303")
|
||||
_KIRK39 = bytes.fromhex("c43bb6d653ee67493ea95fbc0ced6f8a")
|
||||
_KIRK63 = bytes.fromhex("9c9b1372f8c640cf1c62f5d592ddb582")
|
||||
_AMCTL3 = bytes.fromhex("e350ed1d910a1fd029bb1c3ef34077fb")
|
||||
_AMCTL4 = bytes.fromhex("135fa47cab395ba476b8cca98f3a0445")
|
||||
_AMCTL5 = bytes.fromhex("678d7fa32a9ca0d1508ad8385e4b017e")
|
||||
|
||||
try:
|
||||
from Crypto.Cipher import AES as _AES # pycryptodome (fast)
|
||||
_HAVE_CRYPTO = True
|
||||
@@ -136,13 +153,86 @@ def _make_ctr_decryptor(key, iv, block_index):
|
||||
return decrypt
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PSP theme PTF EDAT unwrapping (pkgi-psp's unpack_psp_edat)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _need_crypto():
|
||||
if not _HAVE_CRYPTO:
|
||||
raise RuntimeError("theme extraction needs pycryptodome (pip install pycryptodome)")
|
||||
|
||||
|
||||
def _ecb_decrypt(key, block):
|
||||
_need_crypto()
|
||||
return _AES.new(key, _AES.MODE_ECB).decrypt(block)
|
||||
|
||||
|
||||
def _cmac(key, data):
|
||||
_need_crypto()
|
||||
from Crypto.Hash import CMAC
|
||||
c = CMAC.new(key, ciphermod=_AES)
|
||||
c.update(data)
|
||||
return c.digest()
|
||||
|
||||
|
||||
def _kirk_iv(mac, key_header, offset2):
|
||||
tmp = _ecb_decrypt(_KIRK38, bytes(key_header[0x70:0x80]))
|
||||
iv = bytes(mac[i] ^ tmp[i] ^ key_header[offset2 + i] ^ _AMCTL3[i] ^ _AMCTL5[i] for i in range(16))
|
||||
iv = _ecb_decrypt(_KIRK39, iv)
|
||||
return bytes(b ^ _AMCTL4[i] for i, b in enumerate(iv))
|
||||
|
||||
|
||||
def _kirk_cbc_decrypt(iv, index, data):
|
||||
prev = bytearray(16) if index == 0 else bytearray(iv[:12]) + index.to_bytes(4, "little")
|
||||
block = bytearray(iv[:12]) + index.to_bytes(4, "little")
|
||||
out = bytearray()
|
||||
for i in range(0, len(data), 16):
|
||||
v = int.from_bytes(block[12:16], "little") + 1
|
||||
block[12:16] = v.to_bytes(4, "little")
|
||||
o = _ecb_decrypt(_KIRK63, bytes(block))
|
||||
out += bytes(b ^ prev[k] ^ o[k] for k, b in enumerate(data[i:i + 16]))
|
||||
prev = bytes(block)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _unwrap_theme_ptf(buf):
|
||||
"""Unwrap a PSP theme PTF EDAT (``buf`` is the fully CTR-decrypted entry)."""
|
||||
if len(buf) < 0x90 + 0xA0:
|
||||
raise RuntimeError("theme EDAT is too short")
|
||||
kh_off = buf[0xC]
|
||||
key_header = bytearray(buf[kh_off:kh_off + 0xA0])
|
||||
if bytes(key_header[:4]) != b"\x00PGD":
|
||||
raise RuntimeError("theme EDAT: bad PGD header")
|
||||
if int.from_bytes(key_header[4:8], "little") != 1 or int.from_bytes(key_header[8:12], "little") != 1:
|
||||
raise RuntimeError("theme EDAT: unsupported key/drm type")
|
||||
|
||||
mac = _cmac(_KIRK38, bytes(key_header[:0x70]))
|
||||
|
||||
# Step 1: reveal the data size/offset fields (bytes 0x30..0x60).
|
||||
key_header[0x30:0x60] = _kirk_cbc_decrypt(_kirk_iv(mac, key_header, 0x10), 0, bytes(key_header[0x30:0x60]))
|
||||
data_size = int.from_bytes(key_header[0x44:0x48], "little")
|
||||
data_offset = int.from_bytes(key_header[0x4c:0x50], "little")
|
||||
if data_offset != 0x90:
|
||||
raise RuntimeError("theme EDAT: unexpected data offset")
|
||||
|
||||
# Step 2: decrypt the PTF body.
|
||||
iv = _kirk_iv(mac, key_header, 0x30)
|
||||
out = bytearray()
|
||||
nblocks = (data_size + 15) // 16
|
||||
for i in range(nblocks):
|
||||
chunk = buf[kh_off + data_offset + i * 16:kh_off + data_offset + (i + 1) * 16]
|
||||
out += _kirk_cbc_decrypt(iv, i, chunk)
|
||||
return bytes(out[:data_size])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PKG extraction
|
||||
# --------------------------------------------------------------------------- #
|
||||
def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
||||
"""Decrypt a PSP/PSX PSN pkg into ``dest_dir/<TITLE_ID>/``.
|
||||
def extract(pkg_path, dest_dir, kind_hint=None, progress_cb=None, cancel_event=None):
|
||||
"""Decrypt a PSN pkg into ``dest_dir/<TITLE_ID>/``.
|
||||
|
||||
Returns ``(kind, title_id, folder)`` where kind is ``"psp"`` or ``"psx"``.
|
||||
Returns ``(kind, title_id, folder)`` where kind is ``"psp"``, ``"psx"``,
|
||||
``"theme"`` or ``"dlc"``. ``kind_hint`` ("theme"/"dlc") overrides the
|
||||
pkg-header content-type detection (NoPayStation DLC ships as two formats).
|
||||
"""
|
||||
from .store import TransferCancelled
|
||||
|
||||
@@ -179,19 +269,30 @@ def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
||||
items_offset = int.from_bytes(m[8:12], "big")
|
||||
off += 8 + msize
|
||||
|
||||
if content_type == 6:
|
||||
if kind_hint in ("theme", "dlc"):
|
||||
kind = kind_hint
|
||||
elif content_type == 6:
|
||||
kind = "psx"
|
||||
elif content_type in (7, 0xE, 0xF, 0x10):
|
||||
kind = "psp"
|
||||
elif content_type == 9:
|
||||
kind = "theme"
|
||||
elif content_type == 0xB:
|
||||
kind = "dlc"
|
||||
else:
|
||||
raise RuntimeError(f"unsupported NoPayStation content type 0x{content_type:x}")
|
||||
|
||||
if key_type != 1:
|
||||
# NoPayStation DLC is wrapped in a PS3-format pkg (key type 4 in the header
|
||||
# but actually encrypted with the PS3 key); everything else uses the PSP key.
|
||||
if content_type == 0xB:
|
||||
main_key = PS3_KEY
|
||||
ps3_key = PS3_KEY
|
||||
elif key_type == 1:
|
||||
main_key = PSP_KEY
|
||||
ps3_key = PS3_KEY
|
||||
else:
|
||||
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)
|
||||
@@ -232,26 +333,42 @@ def extract(pkg_path, dest_dir, progress_cb=None, cancel_event=None):
|
||||
continue
|
||||
if kind == "psp" and base not in ("EBOOT.PBP", "PSP-KEY.EDAT", "CONTENT.DAT"):
|
||||
continue
|
||||
if kind == "theme" and not base.lower().endswith(".ptf"):
|
||||
continue
|
||||
if kind == "dlc" and not base.lower().endswith(".edat"):
|
||||
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), "")
|
||||
if kind == "theme":
|
||||
# Read the (small) entry fully, CTR-decrypt, then unwrap the EDAT.
|
||||
fh.seek(enc_offset + data_offset)
|
||||
raw = _ctr_decrypt(item_key, iv, data_offset // 16, fh.read(data_size))
|
||||
dest = folder / base
|
||||
dest.write_bytes(_unwrap_theme_ptf(raw))
|
||||
done += data_size
|
||||
else:
|
||||
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():
|
||||
if kind in ("psp", "psx") and not (folder / "EBOOT.PBP").exists():
|
||||
raise RuntimeError(f"no EBOOT.PBP found in {pkg_path.name}")
|
||||
if kind == "theme" and not any(p.suffix.lower() == ".ptf" for p in written):
|
||||
raise RuntimeError(f"no PTF found in {pkg_path.name}")
|
||||
if kind == "dlc" and not written:
|
||||
raise RuntimeError(f"no DLC EDAT found in {pkg_path.name}")
|
||||
|
||||
return kind, title_id, folder
|
||||
|
||||
Reference in New Issue
Block a user