diff --git a/Tools/pkg.py b/Tools/pkg.py new file mode 100755 index 0000000000..dc63ad9335 --- /dev/null +++ b/Tools/pkg.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""PSP .PKG (NPDRM package) parser/extractor. + +Lists or extracts a package - PSP game updates in particular, which pkg2zip skips. +See docs/pkg_notes.md for the format, and for what these packages turn out to contain. + + pkg.py FILE.pkg ... list contents + pkg.py -x OUTDIR FILE.pkg ... extract into OUTDIR + +Needs the "cryptography" module for AES. +""" +import sys, os, struct +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +PKG_PSP_KEY = bytes.fromhex("07f2c68290b50d2c33818d709b60e62b") +PKG_PS3_KEY = bytes.fromhex("2e7b71d7c9c9a14ea3221f188828b8f8") +PKG_VITA_2 = bytes.fromhex("e31a70c9ce1dd72bf3c0622963f2eccb") +PKG_VITA_3 = bytes.fromhex("423aca3a2bd5649f9686abad6fd8801f") +PKG_VITA_4 = bytes.fromhex("af07fd59652527baf13389668b17d9ea") + +def aes_ecb_enc(key, block): + c = Cipher(algorithms.AES(key), modes.ECB()).encryptor() + return c.update(block) + c.finalize() + +def ctr_xor(key, iv, block_index, data): + ctr = (int.from_bytes(iv, 'big') + block_index) % (1 << 128) + c = Cipher(algorithms.AES(key), modes.CTR(ctr.to_bytes(16, 'big'))).decryptor() + return c.update(data) + c.finalize() + +TYPES = {0: "?", 1: "NPDRM", 2: "NPDRM_EDAT", 3: "FILE", 4: "DIRECTORY", + 9: "SELF", 11: "PSP_FILE", 18: "DIRECTORY2"} + +META_NAMES = {1: "DRM_TYPE", 2: "CONTENT_TYPE", 3: "PACKAGE_TYPE", 4: "PACKAGE_SIZE", + 5: "SDK/NPDRM_REV", 6: "TITLE_ID", 7: "QA_DIGEST", 8: "UNK_0x8", + 9: "UNK_0x9", 10: "INSTALL_DIR", 11: "UNK_0xB", 12: "UNK_0xC", + 13: "ITEMS_TABLE", 14: "PARAM_SFO", 15: "UNK_0xF"} + +CONTENT_TYPES = {0x4: "PS3_GameData", 0x5: "PS3_GameExec", 0x6: "PS1_PSN", + 0x7: "PSP_PSN", 0x9: "Theme", 0xB: "License", 0xE: "PSP_PCEngine", + 0xF: "PSP_Minis", 0x10: "PSP_NeoGeo", 0x15: "PSVita_App", + 0x16: "PSVita_DLC", 0x18: "PSM"} + + +class Item: + __slots__ = ("name", "off", "size", "psp_type", "flags", "key") + def is_dir(self): + return self.flags in (4, 18) + + +class Pkg: + def __init__(self, path): + self.path = path + self.f = open(path, 'rb') + h = self.f.read(0x100) + if h[:4] != b'\x7fPKG': + raise ValueError("not a PKG") + (self.rev, self.type, self.meta_off, self.meta_cnt, self.meta_size, + self.item_cnt, self.total_size, self.data_off, self.data_size) = \ + struct.unpack(">HHIIIIQQQ", h[4:0x30]) + self.content_id = h[0x30:0x60].split(b'\0')[0].decode('ascii', 'replace') + self.digest = h[0x60:0x70] + self.riv = h[0x70:0x80] + self.key_id = h[0xE7] & 7 + self.ext = h[0xC0:0xC4] == b'\x7fext' + + self.meta = self._read_meta() + self.content_type = 0 + self.items_off = 0 + self.sfo_off = self.sfo_size = 0 + for ident, val in self.meta: + if ident == 2: + self.content_type = struct.unpack(">I", val[:4])[0] + elif ident == 13: + self.items_off, self.items_size = struct.unpack(">II", val[:8]) + elif ident == 14: + self.sfo_off, self.sfo_size = struct.unpack(">II", val[:8]) + + if self.type == 2: # PSP / Vita + if self.key_id == 1: + self.main_key = PKG_PSP_KEY + else: + vk = {2: PKG_VITA_2, 3: PKG_VITA_3, 4: PKG_VITA_4}[self.key_id] + self.main_key = aes_ecb_enc(vk, self.riv) + else: + self.main_key = PKG_PS3_KEY + self.iv = self.riv + + def _read_meta(self): + self.f.seek(self.meta_off) + buf = self.f.read(self.meta_size if self.meta_size else 0x1000) + out, p = [], 0 + for _ in range(self.meta_cnt): + if p + 8 > len(buf): + break + ident, size = struct.unpack(">II", buf[p:p+8]) + out.append((ident, buf[p+8:p+8+size])) + p += 8 + size + return out + + def dec(self, offset, size, key=None): + """Read+decrypt `size` bytes at `offset` relative to the encrypted data area.""" + key = key or self.main_key + pre = offset & 0xF + base = offset - pre + self.f.seek(self.data_off + base) + raw = self.f.read(((size + pre + 15) // 16) * 16) + return ctr_xor(key, self.iv, base // 16, raw)[pre:pre+size] + + def items(self): + tbl = self.dec(self.items_off, self.item_cnt * 0x20) + res = [] + for i in range(self.item_cnt): + no, ns, doff, dsize, psp_type, _, _, flags = struct.unpack( + ">IIQQBBBB", tbl[i*0x20:i*0x20+0x1C]) + it = Item() + it.psp_type, it.flags, it.off, it.size = psp_type, flags, doff, dsize + it.key = self.main_key if (self.type != 2 or psp_type == 0x90) else PKG_PS3_KEY + it.name = self.dec(no, ns, it.key).decode('utf-8', 'replace') + res.append(it) + return res + + def read_item(self, it, maxsize=None): + n = it.size if maxsize is None else min(it.size, maxsize) + return self.dec(it.off, n, it.key) + + def sfo(self): + if self.sfo_size: + return parse_sfo(self.dec(self.sfo_off, self.sfo_size)) + for it in self.items(): + if it.name.upper().endswith("PARAM.SFO"): + return parse_sfo(self.read_item(it)) + return {} + + +def parse_sfo(data): + if len(data) < 0x14 or data[:4] != b'\0PSF': + return {} + key_tab, data_tab, count = struct.unpack(" len(data): + break + ko, fmt, ln, maxln, do = struct.unpack("/`. The patched EBOOT then runs with the original UMD (or the original +PSN game) still supplying everything it doesn't override. + +`Tools/pkg.py` is a working parser/extractor. PPSSPP itself cannot yet read or use these - see +"What PPSSPP is missing" at the end. + +Format reference: , cross-checked against +[pkg2zip](https://github.com/mmozeiko/pkg2zip). + +## File layout + +``` ++0x000 header (0xC0 bytes, plaintext) ++0x0C0 extended header (0x40 bytes, plaintext, PSP/Vita only) ++0x100 hashes/signatures ++0x280 metadata (plaintext, offset and count are in the header) + ... ++data_offset encrypted area: item table, then filenames, then file contents +``` + +### Header + +``` ++0x00 u32 "\x7FPKG" magic (0x7F504B47) ++0x04 u16 revision. 0x8000 retail, 0x0000 debug ++0x06 u16 type. 1 = PS3, 2 = PSP/Vita ++0x08 u32 metadata offset (0x280 in everything seen) ++0x0C u32 metadata entry count ++0x10 u32 metadata size ++0x14 u32 item count ++0x18 u64 total package size ++0x20 u64 data offset - start of the encrypted area ++0x28 u64 data size ++0x30 char[0x30] content id, e.g. "JP0177-ULJM05681_00-PJD2UPDATEVR0101" ++0x60 u8[0x10] QA digest ++0x70 u8[0x10] riv - the AES counter block (see below) ++0x80 u8[0x40] header CMAC and signatures +``` + +### Extended header + +Present on PSP and Vita packages, magic `"\x7Fext"` (0x7F657874) at +0xC0. The only field that +matters for reading the package is the **key index**, a u32 at +0xE4 - or equivalently +`header[0xE7] & 7`, which is how pkg2zip reads it. Every PSP package seen uses key index 1. + +### Metadata + +A flat sequence of `u32 id, u32 size, u8 value[size]` records. The ones worth reading: + +| id | Meaning | +| --- | --- | +| 2 | content type. **7 = PSP** (also 0xE/0xF/0x10 for PC Engine / Minis / NeoGeo) | +| 4 | package size | +| 6 | title id | +| 13 | offset and size of the item table, inside the encrypted area | +| 14 | offset and size of `PARAM.SFO`, inside the encrypted area | + +Everything else (DRM type, SDK revision, QA digest, install dir) is informational. + +## Encryption + +The whole area from `data_offset` on is AES-128-CTR, with the counter block starting at `riv` +(header +0x70) and incrementing once per 16 bytes: the block at byte offset *n* of the encrypted +area uses counter `riv + n/16`. Offsets in the item table are relative to `data_offset`, so +that division is straightforward - no separate bookkeeping. + +Two different keys are used *within the same package*, and each item says which one applies via +its own `psp_type` byte (see the item table below): + +| Package / item | Key | +| --- | --- | +| PS3 package, or PSP item with `psp_type != 0x90` | `2e7b71d7c9c9a14ea3221f188828b8f8` | +| PSP item with `psp_type == 0x90` | `07f2c68290b50d2c33818d709b60e62b` | +| Vita, key index 2/3/4 | `AES-ECB(vita_key_N, riv)`, key by index | + +That per-item split is the one thing that isn't obvious from the wiki page and will make a reader +produce garbage filenames for most of a package while a couple of entries decode perfectly. In a +game update it's the `PBOOT.PBP` and the patch data files that carry `0x90`; the icons, +`PARAM.SFO`, `PS3LOGO.DAT`, the directory entries and `ISO.BIN.EDAT` use the PS3 key. + +## Item table + +`item_count` records of 0x20 bytes, at the item table offset from metadata id 13 (0 in every +update package seen, i.e. right at the start of the encrypted area): + +``` ++0x00 u32 filename offset (relative to data_offset, always 16-byte aligned) ++0x04 u32 filename length ++0x08 u64 data offset (relative to data_offset, always 16-byte aligned) ++0x10 u64 data size ++0x18 u8 psp_type - 0x90 selects the PSP key, see above ++0x19 u8[2] padding ++0x1B u8 flags ++0x1C u32 padding +``` + +Filenames are stored in the encrypted area too, and are decrypted with **the item's own key**, not +the package's main key. + +`flags` is a content type, and in these packages it maps 1:1 onto how the file contents are +encrypted - a decoder can tell what it is holding before looking at it: + +| flags | Meaning | Contents start with | +| --- | --- | --- | +| 2 | NPDRM EDAT | `NPD\0` - only ever `ISO.BIN.EDAT` | +| 3 | plain file | whatever it is (PNG, `\0PSF`, ...) | +| 4 | directory | - | +| 5 | PSP EDAT | `\0PSPEDAT` | +| 8 | PSP EDAT (`.sprx` modules) | `\0PSPEDAT` | +| 11 | PBP | `\0PBP` | + +The `\0PSPEDAT` files are the PGD-wrapped kind PPSSPP already decrypts at runtime, via +`sceNpDrmEdataSetupKey` in `Core/HLE/scePspNpDrm_user.cpp`. + +## What an update package contains + +Always this shape: + +``` +PARAM.SFO CATEGORY=PP, TITLE_ID, VERSION +PS3LOGO.DAT a PNG, despite the name +ICON0.PNG / PIC0.PNG / PIC2.PNG sometimes +USRDIR/ directory entry +USRDIR/CONTENT/ directory entry +USRDIR/CONTENT/PBOOT.PBP the patched EBOOT +USRDIR/CONTENT/... the patch data files +USRDIR/ISO.BIN.EDAT 272 bytes, NPD header +``` + +The outer `PARAM.SFO` is the *package's* - `CATEGORY=PP` (game patch), and its `VERSION` is the +package version, not the patch version. The interesting SFO is the one **inside** `PBOOT.PBP`: +that one has `CATEGORY=PG`, `DISC_ID`, `DISC_VERSION`, `APP_VER` (the patch version) and +`PSP_SYSTEM_VER` (the firmware the patch needs), which is what a real PSP matches against the +disc before deciding to boot the patch. + +## What the packages measured + +- All are content type 7, `CATEGORY=PP`. Some are for digital NP\* titles, others for UMD UL\*/UC\* + titles. `PSP_SYSTEM_VER` ranges 6.10 to 6.60. +- **Every single `PBOOT.PBP` uses PRX tag `0x2E5E10F0`** in its `DATA.PSP`. PPSSPP already has + that key - `Core/ELF/PrxDecrypter.cpp`, in the `TAG_INFO2` table, commented + "5.00 PSP-2000 (Game PSN Update 2 LBP)". So **no new crypto is needed to run these**. +- Verified end to end rather than assumed: extract Hatsune Miku Project DIVA 2nd's update, rename + `PBOOT.PBP` to `EBOOT.PBP`, and boot it headless with the UMD mounted - + + ``` + ./build/PPSSPPHeadless -i --graphics=software --memstick= \ + --mount="Hatsune Miku - Project Diva 2nd (Japan).iso" \ + /PSP/GAME/ULJM05681/EBOOT.PBP + ``` + + It logs `Decrypting tag 2E5E10F0`, loads the ELF (`tag=ELF/PdvApp`), resolves its imports, and + reads `Diva2Data.cpk` / `Diva2Script.cpk` / `Diva2Sound.cpk` off `disc0:`. Runs without error. + (`--mount` is what makes that work: `Load_PSP_ELF_PBP` in `Core/PSPLoaders.cpp` mounts the ISO + on `disc0:`, `umd:` and `umd1:` when booting an ELF or PBP.) + +**pkg2zip cannot extract update packages**, so it is not an alternative here. Its PSP path only +recognises `USRDIR/CONTENT/EBOOT.PBP` (a full PSN game, which it converts to an ISO), +`PSP-KEY.EDAT` and `CONTENT.DAT`, and `continue`s past everything else - `PBOOT.PBP` and every +patch file are silently dropped, with no warning that anything was skipped. + +## What PPSSPP is missing + +`grep -r PBOOT` over the tree returns nothing, so both halves are unwritten: + +1. **Reading and installing a `.pkg`** - unpack it into `ms0:/PSP/GAME//`, next to the + existing ZIP/ISO install paths in `Core/Util/GameManager.cpp`. +2. **Booting the patch** - when starting a game, notice + `ms0:/PSP/GAME//PBOOT.PBP` whose inner SFO matches the disc's `DISC_ID` and + `DISC_VERSION`, and boot that instead with the ISO mounted on `disc0:`. The mounting already + exists (see `--mount` above); what's missing is the decision. + +Only the UMD half of this is proven. The digital NP\* titles patch a PSN `EBOOT.PBP` / +NPUMDIMG rather than a UMD, and `ISO.BIN.EDAT` most likely re-keys it - that path has not been +tested.