Decrypt the NPDRM modules a PKG game update installs

A .sprx from one of these packages is an NPDRM "\0PSPEDAT" container: a
0x90-byte header, then an ordinary ~PSP PRX. The loader only ever saw the
EDAT magic and gave up with SCE_KERNEL_ERROR_UNSUPPORTED_PRX_TYPE.

Step over the header, then derive the key the PRX inside is really
encrypted against: sceNpDrmGetFixedKey() over the content ID, XOR in the
licensee key the game handed us through sceNpDrmSetLicenseeKey(), then AES
under a module key that had to be added. Both halves of that were already
lying around unused - sceNpDrmGetFixedKey() had no callers at all, and the
licensee key was being kept and never read.

The rest of it is a fixed XOR that the PRX header's decrypt_mode selects
rather than its tag, so it's applied on the mode the way JPCSP does it and
the tag table is left alone - tag 0x407810F0 carries no seed of its own
there either, so ours was never wrong about it. pspDecryptType5() already
had a slot for both XORs; no new decryption logic was needed.

Decryption is only half of it: these modules are KL4E-compressed rather
than gzipped, so they also need Core/Util/KL4E.cpp, which is already there
for the firmware modules that use the same compression. With both halves
Shiren 4 Plus loads its one big .sprx and runs. God Eater 2 needed one
further fix that isn't in this commit - the type-B relocation bug in
ElfReader::LoadRelocations2, issue #8075 - and then plays.

docs/pkg_notes.md has the container layout and the key derivation.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-09-08 11:15:15 -06:00
1 parent f0dafdee10
commit 19723f59eb
5 files changed
+199 -33

No files matched your search

+19 -1
View File
@@ -14,6 +14,20 @@ extern "C"
#define ROUNDUP16(x) (((x)+15)&~15)
// PSP_Header::decrypt_mode, the byte at 0x7C. Only the one we act on is named; the rest select
// which decryption variant a real PSP would use, which we don't need since we try them in turn.
enum {
PRX_DECRYPT_MODE_SPRX = 23,
};
// A module that arrived inside an NPDRM EDAT carries this fixed XOR on top of its tag's key. It
// goes with the decrypt_mode above rather than with any particular tag - tag 0x407810F0's table
// entry has no seed of its own, in JPCSP's tables as well as ours, so keying this on the tag
// would be wrong for a 0x407810F0 module that arrived some other way.
static const u8 xor_91E0A9AD[16] = {
0x84, 0x7B, 0xF5, 0xFE, 0xE8, 0x4D, 0xAD, 0x7A, 0xB5, 0x06, 0x28, 0x0E, 0x09, 0xFA, 0x81, 0xE1,
};
// Thank you PSARDUMPER & JPCSP keys
// PRXDecrypter 16-byte tag keys.
@@ -966,9 +980,13 @@ static int pspDecryptType5(KirkState *kirk, const u8 *inbuf, u8 *outbuf, u32 siz
// expand the seed into a xor buffer
auto xorbuf = expandSeed(pti->key, pti->code, seed);
// The XOR the decrypt_mode implies wins over the tag table's, which is only the fallback -
// same precedence as JPCSP, and it leaves every tag that has a seed of its own alone.
const u8 *xor1 = inbuf[0x7C] == PRX_DECRYPT_MODE_SPRX ? xor_91E0A9AD : pti->seed;
// construct the header format for a type 2 prx
PRXType5 type5(inbuf);
type5.decrypt(pti->code, pti->seed, seed);
type5.decrypt(pti->code, xor1, seed);
SHA_CTX ctx;
SHAInit(&ctx);
+39 -4
View File
@@ -48,6 +48,7 @@
#include "Core/ELF/ElfReader.h"
#include "Core/ELF/PBPReader.h"
#include "Core/ELF/PrxDecrypter.h"
#include "Core/HLE/scePspNpDrm_user.h"
#include "Core/Util/KL4E.h"
#include "Core/FileSystems/FileSystem.h"
#include "Core/FileSystems/MetaFileSystem.h"
@@ -1216,7 +1217,9 @@ static void LoadAndStartVshKernelModules() {
}
// filename is only used for dumping/metadata.
static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 loadAddress, bool fromTop, std::string *error_string, u32 *magic, std::string_view filename, u32 &error) {
// prxSeed is the extra key a module that came out of an NPDRM container needs to decrypt - see
// NpDrmDeriveModuleKey(). Null for everything else, which is the overwhelming majority.
static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 loadAddress, bool fromTop, std::string *error_string, u32 *magic, std::string_view filename, u32 &error, const u8 *prxSeed = nullptr) {
// The magic reads below need four bytes, and the ~SCE branch another four after that. Everything
// downstream checks its own sizes; this is just so we can look at the magic at all. The PBP path
// in __KernelLoadModule computes elfSize from two offsets in the file and doesn't floor it.
@@ -1289,7 +1292,7 @@ static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 load
newptr = new u8[maxElfSize];
elfSize = maxElfSize;
ptr = newptr;
int decryptedSize = pspDecryptPRX(in, (u8*)ptr, head->psp_size);
int decryptedSize = pspDecryptPRX(in, (u8*)ptr, head->psp_size, prxSeed);
// If decryption got us nowhere, the PRX may simply not be encrypted - in which case the ELF
// starts right after the header. Check the source buffer, not the destination: on the paths
// where decryption bails early nothing has been written to newptr yet, so this used to read
@@ -2304,6 +2307,37 @@ u32 sceKernelLoadModule(const char *name, u32 flags, u32 optionAddr) {
return hleDelayResult(error, "module loaded", 500);
}
// A .sprx installed by a PKG game update comes wrapped in an NPDRM "\0PSPEDAT" container: a
// 0x90-byte header naming the content ID, then the payload at the offset in its u16 at 0x0C.
// The payload is an ordinary ~PSP PRX, so stepping over the header is enough to get it to the
// decrypter - otherwise the ELF check sees the EDAT magic and the load fails with
// SCE_KERNEL_ERROR_UNSUPPORTED_PRX_TYPE. It doesn't decrypt with the tag's key alone though;
// the header also yields the seed it's really encrypted against.
//
// Data EDATs put a PGD at the payload offset instead (0x0003 rather than 0x0101 at 0x0E), and
// those aren't loaded as modules - they go through sceNpDrmEdataSetupKey and the io layer.
//
// Hardware only unwraps this for sceKernelLoadModuleNpDrm, but keying off the file's own magic
// costs nothing: an unwrapped module never has it. See docs/pkg_notes.md.
u8 prxSeed[16];
bool havePrxSeed = false;
if (fileData.size() > 0x90 && !memcmp(fileData.data(), "\0PSPEDAT", 8)) {
const size_t payloadOffset = fileData[0x0C] | (fileData[0x0D] << 8);
if (payloadOffset >= 0x90 && payloadOffset < fileData.size()) {
havePrxSeed = NpDrmDeriveModuleKey(fileData.data(), prxSeed);
if (!havePrxSeed) {
// Not fatal on its own - a module that needs no seed decrypts without one, and one
// that does will fail below with the same error as any other undecryptable module.
WARN_LOG(Log::Loader, "Couldn't derive the NPDRM key for '%s'", name);
}
DEBUG_LOG(Log::Loader, "Unwrapping NPDRM module '%s' (%d bytes of EDAT header)", name, (int)payloadOffset);
fileData.erase(fileData.begin(), fileData.begin() + payloadOffset);
} else {
// Fall through - the magic check further down reports it like any other bad module.
WARN_LOG(Log::Loader, "'%s' has an EDAT header with a bad payload offset %d", name, (int)payloadOffset);
}
}
// We log before hand because ELF loading logs a bunch.
DEBUG_LOG(Log::Loader, "sceKernelLoadModule(%s, %08x)", name, flags);
@@ -2332,7 +2366,7 @@ u32 sceKernelLoadModule(const char *name, u32 flags, u32 optionAddr) {
u32 magic;
u32 error;
std::string error_string;
module = __KernelLoadELFFromPtr(fileData.data(), fileData.size(), 0, lmoption ? lmoption->position == PSP_SMEM_High : false, &error_string, &magic, name, error);
module = __KernelLoadELFFromPtr(fileData.data(), fileData.size(), 0, lmoption ? lmoption->position == PSP_SMEM_High : false, &error_string, &magic, name, error, havePrxSeed ? prxSeed : nullptr);
if (!module) {
if (magic == 0x46535000) {
@@ -2368,7 +2402,8 @@ u32 sceKernelLoadModule(const char *name, u32 flags, u32 optionAddr) {
}
static u32 sceKernelLoadModuleNpDrm(const char *name, u32 flags, u32 optionAddr) {
// Just forward it, same parameters so the logging will make sense.
// Just forward it, same parameters so the logging will make sense. The NPDRM EDAT wrapper these
// modules carry is stepped over in there, since that's keyed off the file's own magic.
return sceKernelLoadModule(name, flags, optionAddr);
}
+48
View File
@@ -1,7 +1,16 @@
// kirk_engine.h includes AES.h from outside its own extern "C" block, so whoever pulls it in first
// decides the linkage the AES_* functions get. Do it here, the way PrxDecrypter.cpp does, or they
// come through sceChnnlsv.h below with C++ linkage and fail to link.
extern "C" {
#include "ext/libkirk/kirk_engine.h"
#include "ext/libkirk/amctrl.h"
}
#include "Core/HLE/scePspNpDrm_user.h"
#include "Core/MemMapHelpers.h"
#include "Core/HLE/HLE.h"
#include "Core/HLE/FunctionWrappers.h"
#include "Core/HLE/sceChnnlsv.h"
#include "Core/HLE/sceIo.h"
#include "Core/FileSystems/MetaFileSystem.h"
@@ -132,6 +141,45 @@ static int sceNpDrmOpen() {
return hleLogError(Log::sceIo, 0, "UNIMPL: sceNpDrmOpen()");
}
// The last pass over a module key. This one belongs to npdrm.prx's module path rather than to the
// PGD/amctrl side, so it isn't among the keys in ext/libkirk.
static const u8 drmModuleKey[16] = {
0xBA, 0x87, 0xE4, 0xAB, 0x2C, 0x60, 0x5F, 0x59, 0xB8, 0x3B, 0xDB, 0xA6, 0x82, 0xFD, 0xAE, 0x14,
};
bool NpDrmDeriveModuleKey(const u8 *edatHeader, u8 *keyOut) {
// The low byte of the u32 at 0x08 picks which fixed key to derive from the content ID at 0x10.
char contentId[0x31]{};
memcpy(contentId, edatHeader + 0x10, 0x30);
const int keyMode = 0x01000000 | edatHeader[0x08];
const int result = sceNpDrmGetFixedKey(__ChnnlsvKirkState(), keyOut, contentId, keyMode);
if (result != 0) {
return false;
}
const u8 flags = edatHeader[0x0F];
if (flags & 1) {
// The game is meant to hand the licensee key over before it loads the module.
if (!isLicenseeKeySet) {
return false;
}
for (int i = 0; i < PSP_NPDRM_LICENSEE_KEY_LENGTH; i++) {
keyOut[i] ^= licenseeKey[i];
}
}
if (flags & 2) {
for (int i = 0; i < 16; i++) {
keyOut[i] ^= edatHeader[0x40 + i];
}
}
// JPCSP does this as CBC with an all-zero IV, which over a single block is a plain decrypt.
AES_ctx ctx;
AES_set_key(&ctx, drmModuleKey, 128);
AES_decrypt(&ctx, keyOut, keyOut);
return true;
}
const HLEFunction sceNpDrm[] = {
{0XA1336091, &WrapI_U<sceNpDrmSetLicenseeKey>, "sceNpDrmSetLicenseeKey", 'i', "x"},
{0X9B745542, &WrapI_V<sceNpDrmClearLicenseeKey>, "sceNpDrmClearLicenseeKey", 'i', "" },
+12
View File
@@ -1,5 +1,17 @@
#pragma once
#include "Common/CommonTypes.h"
class PointerWrap;
void Register_sceNpDrm();
// A module wrapped in an NPDRM "\0PSPEDAT" container has its PRX encrypted against a key built
// from that container's header and, usually, the licensee key the game handed over through
// sceNpDrmSetLicenseeKey - which is why this lives here rather than with the PRX decrypter.
// Writes 16 bytes to keyOut, to be passed to pspDecryptPRX() as the seed.
//
// edatHeader must point at the 0x90 readable bytes of the container header. Returns false if the
// header asks for something we can't build, notably a licensee key the game never set.
// See docs/pkg_notes.md for the layout and where this came from.
bool NpDrmDeriveModuleKey(const u8 *edatHeader, u8 *keyOut);
+81 -28
View File
@@ -115,8 +115,11 @@ encrypted - a decoder can tell what it is holding before looking at it:
| 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`.
Both flags 5 and 8 give you a `\0PSPEDAT`, but they are not the same thing inside. A flags-5 data
file wraps a PGD, which PPSSPP decrypts at runtime through `sceNpDrmEdataSetupKey()` in
`Core/HLE/scePspNpDrm_user.cpp`. A flags-8 `.sprx` wraps an encrypted PRX instead, and goes through
the module loader - see "NPDRM `.sprx` modules" below. The byte at 0x0E of the header tells them apart
(3 for the PGD kind, 1 for the PRX kind).
## What an update package contains
@@ -253,43 +256,93 @@ because the dumps in circulation are later disc revisions than the updates were
Refusing outright would make most of them unusable. Elminage Original was the one clean
exact-match case, disc 1.01 against an update for 1.01, and it boots without a warning.
### Known limitation: PGD-wrapped `.sprx` modules don't load
### NPDRM `.sprx` modules
Package payloads are full of `\0PSPEDAT` files. That's fine for
*data*: `sceNpDrmEdataSetupKey()` in `Core/HLE/scePspNpDrm_user.cpp` wraps an open file descriptor
with the `0x04100002`/`0x04100001` ioctl pair, and the game reads plaintext.
A few packages ship `\0PSPEDAT` **executables** - `.sprx` modules - and that path is not
implemented. God Eater 2 (NPJH50832, 45 of them) installs cleanly, boots its `PBOOT.PBP`, and then
loops forever on:
A few packages wrap **executables** that way - `.sprx` modules the game loads with
`sceKernelLoadModuleNpDrm`. Those need more than the data path does. Until they were handled, God
Eater 2 (NPJH50832) installed cleanly, booted its `PBOOT.PBP`, and then looped forever failing to
load `system.sprx`; Shiren 4 Plus (NPJH50698), which keeps the whole game in one `.sprx` behind a
small loader, failed the same way.
An NPDRM module is two layers, and the loader originally saw only the outer one:
```
E Loader: SCE_KERNEL_ERROR_UNSUPPORTED_PRX_TYPE=sceKernelLoadModuleNpDrm(
ms0:/PSP/GAME/NPJH50832/system.sprx, 00000000, 00000000): failed to load
E sceModule: Wrong magic number 50535000
+0x00 "\0PSPEDAT" header, 0x90 bytes
+0x08 u32 key mode; low byte is what sceNpDrmGetFixedKey takes (3 in all of these)
+0x0C u16 payload offset (0x90 in everything seen)
+0x0F u8 flag bits: 1 = XOR in the licensee key, 2 = XOR in the 16 bytes at 0x40
+0x10 char[0x30] content ID, "JP0365-NPJH50698_00-SIREN4PLUS2012MA"
+0x90 a normal "~PSP" PRX: tag 0x407810F0 at 0xD0, decrypt_mode 23 at 0x7C
```
Shiren 4 Plus (NPJH50698, one `.sprx` holding the whole game behind a 62 KB loader) fails
identically. Tales of the World Radiant Mythology 3 (NPJH50353, two) doesn't reach its modules
within 100 seconds of headless boot, so it gets past startup - it presumably hits the same wall
whenever it does load them.
`sceKernelLoadModule()` steps over the EDAT header, and the PRX inside then decrypts. Two keys go
into that, both worked out from JPCSP (`ModuleMgrForUser.sceKernelLoadModuleNpDrm`, `crypto/DRM.java`,
`crypto/PRX.java`, `crypto/KeyVault.java`), and both feed `pspDecryptType5()`, which already had a
slot for each.
`sceKernelLoadModuleNpDrm()` in `Core/HLE/sceKernelModule.cpp` is a one-line forward to
`sceKernelLoadModule()`, which sees something that isn't ELF magic and gives up. Fixing it means
unwrapping the EDAT before handing the bytes to the ELF loader, using the licensee key the game has
already set via `sceNpDrmSetLicenseeKey` - the same material the data path uses.
**xor2, the per-content key** - `NpDrmDeriveModuleKey()` in `Core/HLE/scePspNpDrm_user.cpp`, four
steps in order:
This is a pre-existing emulator gap rather than something the installer gets wrong: until now
nothing put such a file on the memory stick, so it was unreachable. The unpatched God Eater 2 never
calls `sceKernelLoadModuleNpDrm` at all - the update is what introduces the modules - so for that
title the update is currently better not installed, since the unpatched game boots. It's also why a
"God Eater 2 DLC Update v1.40 *Decrypted for PPSSPP Emulator*" folder circulates: the `PBOOT.PBP` in
it is byte-for-byte what `InstallPkg()` writes, and the only difference is that its `.sprx` files
have been unwrapped to plain ELF by hand.
1. `sceNpDrmGetFixedKey(kirk, key, edat+0x10, 0x01000000 | edat[0x08])` - already in
`ext/libkirk/amctrl.c`, and identical to JPCSP's `hleNpDrmGetFixedKey`: our `key_363C` is its
`drmFixedKey`, our `key_357C[0/1/2]` are its `drmEncKey1/2/3`, and its AES-CBC under an all-zero
IV over one block is our `AES_encrypt`. Nothing had called this function before.
2. If `edat[0x0F] & 1`: XOR the licensee key the game passed to `sceNpDrmSetLicenseeKey()`, which
this file already kept but never used. The game sets it before it loads the module.
3. If `edat[0x0F] & 2`: XOR the 16 bytes at `edat+0x40`. None seen here use it.
4. AES-128 decrypt the result under `drmModuleKey`, the one constant that had to be added here.
JPCSP does CBC with a zero IV; over a single block that is a plain `AES_decrypt`.
**xor1, a static key picked by `decrypt_mode`** - the PRX header byte at 0x7C being 23
(`DECRYPT_MODE_SPRX`), which is what these payloads are. JPCSP keys this on the mode rather than on
the tag, and so do we: tag 0x407810F0's table entry has no seed of its own in JPCSP's tables either,
so our table was never wrong, it just had nothing to say about a case selected somewhere else.
`pspDecryptType5()` takes the mode-derived XOR when the mode calls for it and falls back to the tag
table's otherwise - the same precedence JPCSP uses, which leaves every tag that does carry a seed
(the `pauth` ones) exactly as it was.
No new decryption logic was needed. `pspDecryptType5()` is structurally identical to JPCSP's "new
method" for type 5: `expandSeed(pti->key, pti->code, seed)` XORs xor2 over the 0x90-byte scrambled
key buffer as `RoundXOR(buf2, 0, 0x90, xor2, null)` does; `PRXType5::decrypt()` XORs both over the
0x50-byte kirk header and SHA1 as `RoundXOR(buf2, 0x14, 0x50, xor1, xor2)` does, and then xor1 alone
over the 0x60 bytes at `id` as `RoundXOR(buf4, 0x14, 0x60, xor1, null)` does. JPCSP's `RoundXOR` is
`buf[i] ^= key[i & 0xF]`, the same repeating XOR as our `xor[i % 0x10]`.
### The other half of it: KL4E
Decrypting is only half the job. Shiren 4 Plus's `f5psp.sprx` decrypts to bytes that start with
`KL4E`: every one of these modules has `comp_attribute = 0x0201`, i.e. compressed, and
`(comp_attribute & 0xF00) != 0` means KL4E/KL3E rather than gzip. So decryption alone would just
move the failure from "unsupported PRX type" to "decompression failed".
`Core/Util/KL4E.cpp` already handles that - it went in for firmware modules that use the same
compression, and these get it for free. It's worth knowing the two halves are independent, because
each one on its own leaves the module unloadable and the error doesn't say which is missing.
Note that none of this can be checked offline against the hand-decrypted `.sprx` files that
circulate for God Eater 2: their flag byte says the licensee key is part of the derivation, and that
key only exists while the game is running. Those files are still useful as a cross-check of the
installer itself - the `PBOOT.PBP` in such a folder is byte-for-byte what `InstallPkg()` writes, and
the only difference is that the `.sprx` files beside it were decrypted by hand.
### Where that leaves the three module titles
- **God Eater 2 (NPJH50832)** installs, boots, loads its modules and plays. Decryption alone wasn't
enough for it: it also needed the type-B relocation fix in `ElfReader::LoadRelocations2` (issue
#8075), where two `lui`s sharing one `addiu` got different high halves, so a callback pointer
landed 0x48 bytes inside a function.
- **Shiren 4 Plus (NPJH50698)** loads `f5psp.sprx` - the log says `'FDS3PSP' is KL4E-compressed,
decompressing` - and runs.
- **Tales of the World Radiant Mythology 3 (NPJH50353)** still doesn't reach its modules inside a
headless boot, so it remains untested rather than known-good.
### Still not tested
- Nothing here plays past a title screen. The runs are 25-second headless boots, so "the update is
in use" means the patched executable is what loaded and ran - not that a patched *asset* was read.
LittleBigPlanet's `PATCH.ARC` covers that for a UMD title; there's no equivalent observation for a
digital one yet.
- Only God Eater 2 has been played past a title screen. For the rest the runs are short headless
boots, so "the update is in use" means the patched executable is what loaded and ran - not that a
patched *asset* was read. LittleBigPlanet's `PATCH.ARC` covers that for a UMD title; there's no
equivalent observation for a digital one yet.