From 8d1d75f1786ebc24d5c791c929469862ad1b430f Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:02:46 +0300 Subject: [PATCH 1/3] Decrypt PGD data files, with keys from the player's file Some games keep large data in PGD containers: Phantasy Star Portable 2 Infinity opens PSP_GAME/INSDIR/MEDIA.FPB (275 MB) with flag 0x40000000, passes its key with sceIoIoctl(fd, 0x04100001, key, 16) and then reads and seeks in the decrypted data. We answered the ioctl with nothing and handed it the encrypted bytes; it drew nothing but a cleared screen. host/crypto/pgd.* implements the container from public descriptions of the format: the 0x90-byte header, the encrypted description (data key, data size, 0x400-byte blocks from 0x90), and block-by-block decryption with a one-block cache for random access into files of any size. The IO layer turns a file opened with the flag into a decrypted view once the game gives its key; position and size then count decrypted bytes. The public descriptions do not say which KIRK slots, masks and counter conventions the console's BB cipher uses, so those are parameters: `portablekit keys pgd-check ` tries all 1024 combinations with the player's keys and prints the ones that turn a real header into a valid description, and PORTABLEKIT_PGD_SCHEME selects one. The default becomes the one that works once it has been found on a real file. Keys come only from the player's keys file: kirk.aes.38, .39, .3A, .63 and new amctrl.1 to .3; `keys status` reports "decrypt PGD data". They have no fingerprint yet. Without them the file is refused with the I/O error and one log line naming the file and the missing key. Unit tests use dummy keys: PGD files built here decrypt back, reads across block boundaries at random offsets and past the end, a changed header fails the MAC check, another version key gives no valid description, and a missing key is named. Not yet verified on a real PGD file. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 16 ++ apps/portablekit/src/app_main.cpp | 56 ++++++- apps/portablekit/src/keys_file.cpp | 24 ++- apps/portablekit/src/keys_file.hpp | 4 + cmake/PortableKit.cmake | 1 + docs/DESKTOP_APP.md | 10 ++ host/crypto/pgd.cpp | 227 +++++++++++++++++++++++++++++ host/crypto/pgd.hpp | 141 ++++++++++++++++++ host/crypto_keys.hpp | 3 + host/hle/hle_io.cpp | 63 +++++++- tests/pgd_test_keys.cpp | 6 + tests/pgd_tests.cpp | 101 +++++++++++++ 12 files changed, 645 insertions(+), 7 deletions(-) create mode 100644 host/crypto/pgd.cpp create mode 100644 host/crypto/pgd.hpp create mode 100644 tests/pgd_test_keys.cpp create mode 100644 tests/pgd_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e0e07c7..fdaf101 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -253,6 +253,22 @@ if(PSPRECOMP_BUILD_TESTS) add_test(NAME portablekit_pool_tests COMMAND portablekit_pool_tests) endif() +# PGD containers with dummy keys: no console key and no game data. +if(PSPRECOMP_BUILD_TESTS) + add_executable(portablekit_pgd_tests + "${PORTABLEKIT_ROOT}/tests/pgd_tests.cpp" + "${PORTABLEKIT_ROOT}/host/crypto/pgd.cpp" + "${PORTABLEKIT_ROOT}/host/save_data/aes128.cpp" + "${PORTABLEKIT_ROOT}/host/save_data/savedata_crypto.cpp" + "${PORTABLEKIT_ROOT}/tests/pgd_test_keys.cpp" + "${PORTABLEKIT_ROOT}/third_party/tiny_aes/aes.c") + set_source_files_properties("${PORTABLEKIT_ROOT}/third_party/tiny_aes/aes.c" PROPERTIES LANGUAGE CXX) + target_include_directories(portablekit_pgd_tests PRIVATE "${PORTABLEKIT_ROOT}/host" "${PORTABLEKIT_ROOT}/third_party" "${PORTABLEKIT_ROOT}/include") + target_link_libraries(portablekit_pgd_tests PRIVATE psprecomp_core) + set_target_properties(portablekit_pgd_tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") + add_test(NAME portablekit_pgd_tests COMMAND portablekit_pgd_tests) +endif() + # Each platform's settings defaults. if(PSPRECOMP_BUILD_TESTS) add_executable(portablekit_settings_tests diff --git a/apps/portablekit/src/app_main.cpp b/apps/portablekit/src/app_main.cpp index 8ee451b..8100a72 100644 --- a/apps/portablekit/src/app_main.cpp +++ b/apps/portablekit/src/app_main.cpp @@ -31,6 +31,8 @@ #include "text_format.hpp" #include "app_paths.hpp" +#include "crypto/pgd.hpp" +#include "kernel/iso_image.hpp" #include "corpus_abi.hpp" #include "hle/hle_extensions.hpp" #include "install/user_data.hpp" @@ -45,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -435,7 +438,56 @@ int command_keys(const Arguments &args) { } return ok ? 0 : kKeysRejected; } - if (action != "status") return report_error(kUsage, "keys takes import or status"); + // keys pgd-check : which + // way of running the PGD cipher (crypto/pgd.hpp) turns this file's + // header into a valid description, with the keys file's keys. The key is + // the one the game passes to sceIoIoctl 0x04100001 (_TRACE_IO + // shows it). Prints scheme names and the start of the first data block, + // never a key. + if (action == "pgd-check") { + if (args.positional.size() < 5) return report_error(kUsage, "keys pgd-check needs "); + const auto game = find_game(args.positional[2]); + if (!game) return report_error(kNoSuchGame, "no such game: " + args.positional[2]); + std::vector key_bytes; + const std::string hex = args.positional[4]; + for (std::size_t i = 0; i + 1 < hex.size(); i += 2) key_bytes.push_back(static_cast(std::stoul(hex.substr(i, 2), nullptr, 16))); + if (key_bytes.size() != 16u) return report_error(kUsage, "the key is 16 bytes, 32 hex digits"); + pgd::Block vkey{}; + std::copy(key_bytes.begin(), key_bytes.end(), vkey.begin()); + IsoImage iso(game->iso); + const auto entry = iso.find(args.positional[3]); + if (!entry) return report_error(kUsage, "no such file on the disc: " + args.positional[3]); + const std::uint64_t base = static_cast(entry->lba) * IsoImage::kSectorSize; + std::vector header(pgd::kHeaderSize); + iso.read(base, header); + const pgd::KeySource keys = pgd::player_keys(); + std::size_t matches = 0; + std::set missing; + for (pgd::CipherScheme scheme : pgd::candidate_schemes()) { + std::string error; + auto file = pgd::PgdFile::open(header, vkey, entry->size, keys, scheme, std::nullopt, error); + if (!file) { + if (error.find("keys file has no") != std::string::npos) missing.insert(error); + continue; + } + ++matches; + for (const bool restart : {false, true}) { + scheme.block_counters_restart = restart; + auto view = pgd::PgdFile::open(header, vkey, entry->size, keys, scheme, std::nullopt, error); + std::vector first(32); + view->read(0, first, [&iso, base](std::uint64_t offset, std::span out) { + return iso.read(base + offset, out); + }); + std::cout << scheme.describe() << ": data size " << view->size() << ", first bytes"; + for (const std::uint8_t byte : first) std::printf(" %02x", byte); + std::cout << "\n"; + } + } + for (const std::string &error : missing) std::cout << "some schemes could not be tried: " << error << "\n"; + std::cout << matches << " scheme(s) give a valid description\n"; + return matches != 0u ? 0 : 1; + } + if (action != "status") return report_error(kUsage, "keys takes import, status or pgd-check"); const KeysReport &report = active_keys(); if (g_json) { std::vector problems; @@ -443,6 +495,7 @@ int command_keys(const Arguments &args) { std::cout << Json().field("ok", true).field("path", path_text(report.path)).field("found", report.file_found) .field("executables", report.can_decrypt_executables) .field("saves", report.can_encrypt_saves) + .field("pgd", report.can_decrypt_pgd) .field("tags", static_cast(report.tag_count)) .array("problems", problems).str() << "\n"; return 0; @@ -450,6 +503,7 @@ int command_keys(const Arguments &args) { std::cout << "Keys file: " << path_text(report.path) << (report.file_found ? "" : " (none)") << "\n" << " decrypt executables: " << (report.can_decrypt_executables ? "yes" : "no") << "\n" << " encrypt saves: " << (report.can_encrypt_saves ? "yes" : "no (saves are kept unencrypted)") << "\n" + << " decrypt PGD data: " << (report.can_decrypt_pgd ? "yes" : "no") << "\n" << " tag keys: " << report.tag_count << "\n"; for (const std::string &problem : report.problems) std::cout << " problem: " << problem << "\n"; return 0; diff --git a/apps/portablekit/src/keys_file.cpp b/apps/portablekit/src/keys_file.cpp index fbfaa44..a7df0e5 100644 --- a/apps/portablekit/src/keys_file.cpp +++ b/apps/portablekit/src/keys_file.cpp @@ -37,6 +37,15 @@ constexpr Fingerprint kFingerprints[] = { {"savedata.7", "a0bf478c1471e4115011e5e0f70d5e2d43c95e5ef2645d164b76ec3cd118ec10"}, }; +// The keys the PGD format needs (host/crypto/pgd.hpp). Their fingerprints are +// added once they have been checked against a real PGD file; until then any +// 16-byte value is taken under these names, and a wrong one shows as a PGD +// header that does not decrypt. +bool pgd_key_name(const std::string &name) { + return name == "kirk.aes.38" || name == "kirk.aes.39" || name == "kirk.aes.3a" || name == "kirk.aes.63" || + name == "amctrl.1" || name == "amctrl.2" || name == "amctrl.3"; +} + const char *expected_fingerprint(const std::string &name) { for (const Fingerprint &entry : kFingerprints) if (lower(name) == lower(entry.name)) return entry.sha256; @@ -91,11 +100,11 @@ KeysReport read_keys_file(const std::filesystem::path &path) { continue; } const char *expected = expected_fingerprint(name); - if (expected == nullptr) { + if (expected == nullptr && !pgd_key_name(name)) { report.problems.push_back(raw_name + " is not a key this program uses."); continue; } - if (sha256_of(*bytes) != expected) { + if (expected != nullptr && sha256_of(*bytes) != expected) { report.problems.push_back(raw_name + " is not the right key: its fingerprint does not match."); continue; } @@ -106,6 +115,8 @@ KeysReport read_keys_file(const std::filesystem::path &path) { report.keys.kirk_cmd1 = to_key(*bytes); } else if (name.starts_with("savedata.")) { report.keys.savedata[std::atoi(name.substr(9).c_str())] = to_key(*bytes); + } else if (name.starts_with("amctrl.")) { + report.keys.amctrl[std::atoi(name.substr(7).c_str())] = to_key(*bytes); } } report.tag_count = report.keys.tags.size(); @@ -115,6 +126,11 @@ KeysReport read_keys_file(const std::filesystem::path &path) { if (report.keys.kirk(slot) == nullptr) report.can_encrypt_saves = false; for (int index = 2; index <= 7; ++index) if (report.keys.savedata_key(index) == nullptr) report.can_encrypt_saves = false; + report.can_decrypt_pgd = true; + for (const std::uint8_t slot : {0x38, 0x39, 0x3A, 0x63}) + if (report.keys.kirk(slot) == nullptr) report.can_decrypt_pgd = false; + for (int index = 1; index <= 3; ++index) + if (report.keys.amctrl.find(index) == report.keys.amctrl.end()) report.can_decrypt_pgd = false; return report; } @@ -136,7 +152,7 @@ bool import_keys_file(const std::filesystem::path &source, KeysReport &report) { } if (!report.problems.empty()) return false; if (report.keys.kirk_aes.empty() && !report.keys.kirk_cmd1 && report.keys.savedata.empty() && - report.keys.tags.empty()) { + report.keys.tags.empty() && report.keys.amctrl.empty()) { report.problems.push_back("The file holds no keys."); return false; } @@ -158,7 +174,7 @@ namespace portablekit { const CryptoKeys *crypto_keys() { const app::KeysReport &report = app::active_keys(); if (report.keys.kirk_aes.empty() && !report.keys.kirk_cmd1 && report.keys.savedata.empty() && - report.keys.tags.empty()) + report.keys.tags.empty() && report.keys.amctrl.empty()) return nullptr; return &report.keys; } diff --git a/apps/portablekit/src/keys_file.hpp b/apps/portablekit/src/keys_file.hpp index 51d6290..974ed4d 100644 --- a/apps/portablekit/src/keys_file.hpp +++ b/apps/portablekit/src/keys_file.hpp @@ -11,6 +11,9 @@ // savedata.2 = <16 bytes> save-data keys 2 to 7 // tag.C0CB167C = <16 or 0x90 bytes> what an executable's tag selects // tag.C0CB167C.slot = 5D (optional) the KIRK slot of that tag +// amctrl.1 = <16 bytes> the DRM library's fixed keys 1 to 3, which +// PGD data needs with kirk.aes.38, .39, .3A +// and .63 (host/crypto/pgd.hpp) // // The fixed keys are checked against SHA-256 fingerprints compiled into the // program, so a mistyped key is named instead of producing garbage; a @@ -33,6 +36,7 @@ struct KeysReport { // What the keys that passed allow. bool can_decrypt_executables{}; // kirk.aes.5D and kirk.cmd1 bool can_encrypt_saves{}; // every save-data key + bool can_decrypt_pgd{}; // kirk.aes.38/39/3A/63 and amctrl.1-3 std::size_t tag_count{}; }; diff --git a/cmake/PortableKit.cmake b/cmake/PortableKit.cmake index 15206c4..2a86ece 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -72,6 +72,7 @@ set(PORTABLEKIT_HOST_SOURCES host/save_data/aes128.cpp host/save_data/param_sfo.cpp host/save_data/savedata_crypto.cpp + host/crypto/pgd.cpp host/save_data/savedata_store.cpp host/save_data/save_transfer.cpp host/install/executable_preparation.cpp diff --git a/docs/DESKTOP_APP.md b/docs/DESKTOP_APP.md index e08ddb7..2becc08 100644 --- a/docs/DESKTOP_APP.md +++ b/docs/DESKTOP_APP.md @@ -160,10 +160,12 @@ kirk.cmd1 = <16 bytes> # the KIRK command 1 AES key savedata.2 = <16 bytes> # save-data keys 2 to 7 tag.C0CB167C = <16 bytes, or the 0x90-byte table of an old header layout> tag.C0CB167C.slot = 5D # optional: the KIRK slot the tag's header uses +amctrl.1 = <16 bytes> # the DRM library's fixed keys 1 to 3, for PGD data ``` - `portablekit keys import ` checks the file and copies it into the data folder. Every fixed key is checked against a SHA-256 fingerprint compiled into the program, so the player is told "`savedata.4` is not the right key" instead of getting garbage later. Tag keys have no fingerprint: an executable that decrypts to an ELF the runtime can load is the check. - What each group enables: `kirk.aes.5D` + `kirk.cmd1` + the game's `tag.*` decrypt that game's `EBOOT.BIN`; the nine save slots and `savedata.2`–`7` encrypt and decrypt saves. `portablekit keys status` says which of these the file provides. +- PGD data (below) needs `kirk.aes.38`, `kirk.aes.39`, `kirk.aes.3A`, `kirk.aes.63`, `amctrl.1`, `amctrl.2` and `amctrl.3`; `keys status` says "decrypt PGD data: yes" when all seven are there. These have no fingerprint yet: they get one once they have been checked against a real PGD file, and until then a wrong value shows as a PGD file that does not decrypt, with a log line naming the file. - Without keys, a game can still be added when its disc carries an unencrypted `BOOT.BIN` (LocoRoco 2 does), or when the player gives an executable they decrypted (`--executable`). The program never names a source of keys. Messages when something is missing, as the prototype prints them: @@ -173,6 +175,14 @@ Messages when something is missing, as the prototype prints them: - *"The save is encrypted, as a PSP writes it. Importing it needs the console's keys: add a keys file."* (the save menu's import) - In the log, for an encrypted save found in `ms0`: *"the save is encrypted, and reading it needs the console's keys (add a keys file)"*. +### PGD data + +Some games keep large data files in PGD containers, "Protected Game Data" (Phantasy Star Portable 2 Infinity's `PSP_GAME/INSDIR/MEDIA.FPB`, 275 MB). The game opens the file with flag `0x40000000` and gives the library the file's key with `sceIoIoctl(fd, 0x04100001, key, 16)`; after that it reads and seeks in the decrypted data. [`host/crypto/pgd.hpp`](../host/crypto/pgd.hpp) implements the container from public descriptions of the format: the 0x90-byte header, the encrypted description (data key, size, 0x400-byte blocks from 0x90), and block-by-block decryption for random access. The keys come only from the player's file. + +The public descriptions do not say which KIRK slots, masks and counter conventions the console's cipher uses, so these are parameters. `portablekit keys pgd-check ` tries all 1024 combinations with the player's keys and prints those that turn the file's header into a valid description (version 0, 0x400-byte blocks, data at 0x90), with the start of the first data block; `PORTABLEKIT_PGD_SCHEME` selects one. When the maintainer's keys have been checked this way against PSP2i's file, the one that works becomes the default and the keys get fingerprints. + +Without the keys, the file is refused with the I/O error (what a PSP answers for a file it cannot decrypt has not been traced), and the log names the file and the missing key once: `[pgd] : cannot decrypt: the keys file has no kirk.aes.39`. + ## Saves - **Without keys** the game's key is still read from its save request and remembered, but the save is written unencrypted (`SAVEDATA_PARAMS` flags 0), as early PPSSPP versions wrote them. The game sees exactly the same data. Such saves load in PPSSPP; a PSP does not accept them. diff --git a/host/crypto/pgd.cpp b/host/crypto/pgd.cpp new file mode 100644 index 0000000..229a79e --- /dev/null +++ b/host/crypto/pgd.cpp @@ -0,0 +1,227 @@ +#include "crypto/pgd.hpp" + +#include "save_data/aes128.hpp" +#include "save_data/savedata_crypto.hpp" + +#include +#include +#include +#include + +namespace portablekit::pgd { +namespace { + +using savedata::Aes128; + +Block xor_block(const Block &a, const Block &b) { + Block out{}; + for (std::size_t i = 0; i < out.size(); ++i) out[i] = static_cast(a[i] ^ b[i]); + return out; +} + +std::uint32_t le32(std::span bytes, std::size_t at) { + return static_cast(bytes[at]) | static_cast(bytes[at + 1u]) << 8u | + static_cast(bytes[at + 2u]) << 16u | static_cast(bytes[at + 3u]) << 24u; +} + +void put32(std::span bytes, std::size_t at, std::uint32_t value) { + for (std::size_t i = 0; i < 4u; ++i) bytes[at + i] = static_cast(value >> (8u * i)); +} + +Block read_block(std::span bytes, std::size_t at) { + Block block{}; + std::copy_n(bytes.begin() + static_cast(at), block.size(), block.begin()); + return block; +} + +std::string slot_name(std::uint8_t slot) { + char text[16]; + std::snprintf(text, sizeof(text), "kirk.aes.%02X", slot); + return text; +} + +} // namespace + +KeySource player_keys() { + return KeySource{[](std::uint8_t slot) -> const Key16 * { + const CryptoKeys *keys = crypto_keys(); + return keys != nullptr ? keys->kirk(slot) : nullptr; + }, + [](int index) -> const Key16 * { + const CryptoKeys *keys = crypto_keys(); + if (keys == nullptr) return nullptr; + const auto it = keys->amctrl.find(index); + return it != keys->amctrl.end() ? &it->second : nullptr; + }}; +} + +std::string CipherScheme::describe() const { + char text[96]; + std::snprintf(text, sizeof(text), "h%02X-m%d-%c-s%02X-%c-%c-c%u-%c", header_slot, header_mask, vkey_after ? 'a' : 'b', + stream_slot, stream_encrypts ? 'e' : 'd', chained ? 'x' : 'n', counter_start, + block_counters_restart ? 'r' : 'f'); + return text; +} + +std::vector kirk_slots_used() { return {0x38u, 0x39u, 0x3Au, 0x63u}; } + +std::vector candidate_schemes() { + std::vector schemes; + for (const std::uint8_t header : kirk_slots_used()) + for (int mask = 0; mask <= kFixedKeysUsed; ++mask) + for (const bool after : {false, true}) + for (const std::uint8_t stream : kirk_slots_used()) + for (const bool encrypts : {false, true}) + for (const bool chained : {false, true}) + for (const std::uint32_t start : {0u, 1u}) + schemes.push_back(CipherScheme{header, mask, after, stream, encrypts, chained, start, false}); + return schemes; +} + +CipherScheme active_scheme() { + if (const char *text = std::getenv("PORTABLEKIT_PGD_SCHEME"); text != nullptr && *text != '\0') { + for (CipherScheme scheme : candidate_schemes()) { + for (const bool restart : {false, true}) { + scheme.block_counters_restart = restart; + if (scheme.describe() == text) return scheme; + } + } + } + // Not yet established against a real file: see the file comment. + return CipherScheme{0x39u, 0, false, 0x39u, false, true, 1u, false}; +} + +std::optional bb_cipher(const CipherScheme &scheme, const KeySource &keys, const Block &key, + const Block &vkey, std::uint32_t seed, std::span data) { + const Key16 *header_key = keys.kirk ? keys.kirk(scheme.header_slot) : nullptr; + if (header_key == nullptr) return slot_name(scheme.header_slot); + const Key16 *stream_key = keys.kirk ? keys.kirk(scheme.stream_slot) : nullptr; + if (stream_key == nullptr) return slot_name(scheme.stream_slot); + Block context = key; + if (scheme.header_mask != 0) { + const Key16 *mask = keys.fixed ? keys.fixed(scheme.header_mask) : nullptr; + if (mask == nullptr) return "amctrl." + std::to_string(scheme.header_mask); + context = xor_block(context, *mask); + } + if (!scheme.vkey_after) context = xor_block(context, vkey); + Block prefix = Aes128(*header_key).decrypt(context); + if (scheme.vkey_after) prefix = xor_block(prefix, vkey); + const Aes128 stream(*stream_key); + Block previous{}; + for (std::size_t offset = 0, index = 0; offset < data.size(); offset += 16u, ++index) { + Block counter = prefix; + put32(counter, 12u, scheme.counter_start + seed + static_cast(index)); + Block key_stream = scheme.stream_encrypts ? stream.encrypt(counter) : stream.decrypt(counter); + if (scheme.chained) key_stream = xor_block(key_stream, previous); + previous = counter; + for (std::size_t i = 0; i < 16u && offset + i < data.size(); ++i) data[offset + i] ^= key_stream[i]; + } + return std::nullopt; +} + +std::optional bb_mac(const KeySource &keys, std::uint8_t slot, std::span data, + const Block &vkey) { + const Key16 *key = keys.kirk ? keys.kirk(slot) : nullptr; + if (key == nullptr) return std::nullopt; + const Aes128 cipher(*key); + return cipher.encrypt(xor_block(savedata::cmac(cipher, data), vkey)); +} + +bool desc_plausible(const Desc &desc, std::uint64_t file_size) { + return desc.version == 0u && desc.block_size == 0x400u && desc.data_offset == kHeaderSize && desc.data_size != 0u && + desc.data_size <= file_size; +} + +std::optional PgdFile::open(std::span header, const Block &vkey, std::uint64_t file_size, + const KeySource &keys, const CipherScheme &scheme, + std::optional mac_slot, std::string &error) { + if (header.size() < kHeaderSize || header[0] != 0u || header[1] != 'P' || header[2] != 'G' || header[3] != 'D') { + error = "not a PGD file"; + return std::nullopt; + } + if (mac_slot) { + const auto mac = bb_mac(keys, *mac_slot, header.subspan(0, 0x80u), vkey); + if (!mac) { + error = "the keys file has no " + slot_name(*mac_slot); + return std::nullopt; + } + if (*mac != read_block(header, 0x80u)) { + error = "the header MAC does not match: a wrong key, or not this file's key"; + return std::nullopt; + } + } + std::array desc_bytes{}; + std::copy_n(header.begin() + 0x30, desc_bytes.size(), desc_bytes.begin()); + if (const auto missing = bb_cipher(scheme, keys, read_block(header, 0x10u), vkey, 0u, desc_bytes)) { + error = "the keys file has no " + *missing; + return std::nullopt; + } + PgdFile file; + std::copy_n(desc_bytes.begin(), 16, file.desc_.data_key.begin()); + file.desc_.version = le32(desc_bytes, 0x10u); + file.desc_.data_size = le32(desc_bytes, 0x14u); + file.desc_.block_size = le32(desc_bytes, 0x18u); + file.desc_.data_offset = le32(desc_bytes, 0x1Cu); + if (!desc_plausible(file.desc_, file_size)) { + error = "the header does not decrypt to a valid description: a wrong key, or not this file's key"; + return std::nullopt; + } + file.vkey_ = vkey; + file.keys_ = keys; + file.scheme_ = scheme; + return file; +} + +std::size_t PgdFile::read(std::uint64_t offset, std::span out, const RawReader &read_raw) { + std::size_t done = 0; + const std::uint32_t block_size = desc_.block_size; + while (done < out.size() && offset + done < desc_.data_size) { + const std::uint64_t position = offset + done; + const std::uint64_t block = position / block_size; + if (block != cached_block_) { + cache_.assign(block_size, 0u); + const std::size_t got = read_raw(desc_.data_offset + block * block_size, cache_); + if (got == 0u) break; + const auto seed = scheme_.block_counters_restart ? 0u : static_cast(block * block_size / 16u); + if (bb_cipher(scheme_, keys_, desc_.data_key, vkey_, seed, cache_)) break; + cached_block_ = block; + } + const std::size_t within = static_cast(position % block_size); + const std::size_t take = std::min({out.size() - done, block_size - within, + static_cast(desc_.data_size - position)}); + std::copy_n(cache_.begin() + static_cast(within), take, out.begin() + static_cast(done)); + done += take; + } + return done; +} + +std::vector PgdFile::build(std::span plain, const Block &desc_key, + const Block &data_key, const Block &vkey, const KeySource &keys, + const CipherScheme &scheme, std::uint8_t mac_slot) { + constexpr std::uint32_t kBlock = 0x400u; + const std::size_t blocks = (plain.size() + kBlock - 1u) / kBlock; + std::vector file(kHeaderSize + blocks * kBlock, 0u); + file[1] = 'P'; + file[2] = 'G'; + file[3] = 'D'; + put32(file, 0x04u, 1u); + put32(file, 0x08u, 1u); + std::copy(desc_key.begin(), desc_key.end(), file.begin() + 0x10); + std::span desc(file.data() + 0x30, 0x30); + std::copy(data_key.begin(), data_key.end(), desc.begin()); + put32(desc, 0x14u, static_cast(plain.size())); + put32(desc, 0x18u, kBlock); + put32(desc, 0x1Cu, kHeaderSize); + (void)bb_cipher(scheme, keys, desc_key, vkey, 0u, desc); + std::copy(plain.begin(), plain.end(), file.begin() + kHeaderSize); + for (std::size_t b = 0; b < blocks; ++b) { + std::span block(file.data() + kHeaderSize + b * kBlock, kBlock); + const auto seed = scheme.block_counters_restart ? 0u : static_cast(b * kBlock / 16u); + (void)bb_cipher(scheme, keys, data_key, vkey, seed, block); + } + if (const auto mac = bb_mac(keys, mac_slot, std::span(file.data(), 0x80u), vkey)) + std::copy(mac->begin(), mac->end(), file.begin() + 0x80); + return file; +} + +} // namespace portablekit::pgd diff --git a/host/crypto/pgd.hpp b/host/crypto/pgd.hpp new file mode 100644 index 0000000..ced9b06 --- /dev/null +++ b/host/crypto/pgd.hpp @@ -0,0 +1,141 @@ +#pragma once + +// PGD ("Protected Game Data"): the encrypted container some games keep large +// data files in (Phantasy Star Portable 2 Infinity's INSDIR/MEDIA.FPB). A game +// opens such a file with flag 0x40000000 and hands the library the file's key +// with sceIoIoctl(fd, 0x04100001, key, 16); from then on it reads and seeks +// in the decrypted data. +// +// The layout, little-endian, as public descriptions of the format give it +// (psdevwiki "PGD"): +// +// 0x00 "\0PGD" 0x04 key index 0x08 DRM type 0x0C 0 +// 0x10 descKey (16) 0x20 file path MAC (16) +// 0x30 desc (0x30 bytes, encrypted with descKey and the version key): +// dataKey (16), version (0), dataSize, blockSize (0x400), +// dataOffset (0x90), padding (16) +// 0x60 table MAC 0x70 header MAC (device key) 0x80 header MAC (fixed key) +// 0x90 data: blocks of blockSize, each encrypted with dataKey and the +// version key, with a block-number counter +// +// The cipher is the console's "BB" cipher: a key stream of AES blocks under +// a KIRK key slot, from a prefix made by decrypting the header key; the MAC +// is a CMAC under a KIRK key slot. Which slots, masks and counter conventions +// a PSP uses are not given by the public descriptions, so they are +// parameters here (CipherScheme). The one the app uses is the one that turns +// a real PGD header's desc into its known values (version 0, block size +// 0x400, data offset 0x90); `portablekit keys pgd-check ` finds it. +// +// No key value appears here: every key comes from the player's keys file +// (crypto_keys()), and a missing one is named. + +#include "crypto_keys.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace portablekit::pgd { + +using Block = std::array; + +inline constexpr std::uint32_t kHeaderSize = 0x90u; +inline constexpr std::uint32_t kIoctlSetKey = 0x04100001u; +inline constexpr std::uint32_t kOpenFlag = 0x40000000u; + +// Where the keys come from: KIRK AES key slots, and the fixed "amctrl" keys +// (1 to 3). Null when the player's file does not have it. +struct KeySource { + std::function kirk; + std::function fixed; +}; + +// The keys from crypto_keys(). +[[nodiscard]] KeySource player_keys(); + +// One way of running the BB cipher; see the file comment. +struct CipherScheme { + std::uint8_t header_slot{}; // KIRK slot that decrypts the header key into the stream prefix + int header_mask{}; // amctrl key XORed into the header key first (0: none) + bool vkey_after{}; // the version key XORed into the decrypted prefix, else into the header key + std::uint8_t stream_slot{}; // KIRK slot of the key stream + bool stream_encrypts{}; // key stream = AES-encrypt(counter block), else AES-decrypt + bool chained{}; // each key stream block is also XORed with the previous counter block + std::uint32_t counter_start{}; // counter of the first 16 bytes of a run + bool block_counters_restart{}; // each data block's counter starts afresh, else runs on through the file + + [[nodiscard]] std::string describe() const; +}; + +// The slots and masks every scheme may use: what `keys status` checks. +[[nodiscard]] std::vector kirk_slots_used(); +inline constexpr int kFixedKeysUsed = 3; + +// Every scheme `pgd-check` tries. +[[nodiscard]] std::vector candidate_schemes(); + +// The scheme the app decrypts with: PORTABLEKIT_PGD_SCHEME (as `describe` +// prints it) or the built-in default. +[[nodiscard]] CipherScheme active_scheme(); + +// XORs `data` with the key stream of (key, vkey) starting at 16-byte +// counter `seed`. Its own inverse. Empty on success, else the missing key. +[[nodiscard]] std::optional bb_cipher(const CipherScheme &scheme, const KeySource &keys, const Block &key, + const Block &vkey, std::uint32_t seed, std::span data); + +// The BB MAC of `data` under KIRK slot `slot`, finished with the version key: +// CMAC, XOR vkey, encrypt once more. +[[nodiscard]] std::optional bb_mac(const KeySource &keys, std::uint8_t slot, std::span data, + const Block &vkey); + +struct Desc { + Block data_key{}; + std::uint32_t version{}; + std::uint32_t data_size{}; + std::uint32_t block_size{}; + std::uint32_t data_offset{}; +}; + +// The desc as a PSP writes it: version 0, 0x400-byte blocks from 0x90, and a +// size that fits in `file_size`. +[[nodiscard]] bool desc_plausible(const Desc &desc, std::uint64_t file_size); + +class PgdFile { +public: + // Reads the header (the file's first 0x90 bytes) with the version key. + // `mac_slot` set: the header MAC at 0x80 must match (the MAC check is + // off until the slot is known). Empty with `error` set when the header + // is not PGD, a key is missing, or it does not decrypt to a valid desc. + static std::optional open(std::span header, const Block &vkey, std::uint64_t file_size, + const KeySource &keys, const CipherScheme &scheme, + std::optional mac_slot, std::string &error); + + [[nodiscard]] const Desc &desc() const noexcept { return desc_; } + [[nodiscard]] std::uint32_t size() const noexcept { return desc_.data_size; } + + // Decrypts `count` bytes at plain offset `offset`, reading the encrypted + // file through `read_raw(offset, out)` (which returns the bytes read). + using RawReader = std::function out)>; + std::size_t read(std::uint64_t offset, std::span out, const RawReader &read_raw); + + // A PGD made here, for tests: `plain` encrypted with `data_key` and + // `vkey` under `scheme`, with a desc key and the MAC at 0x80 under + // `mac_slot`. + static std::vector build(std::span plain, const Block &desc_key, + const Block &data_key, const Block &vkey, const KeySource &keys, + const CipherScheme &scheme, std::uint8_t mac_slot); + +private: + Desc desc_; + Block vkey_{}; + KeySource keys_; + CipherScheme scheme_; + std::uint64_t cached_block_{~0ull}; + std::vector cache_; +}; + +} // namespace portablekit::pgd diff --git a/host/crypto_keys.hpp b/host/crypto_keys.hpp index eefca0d..1eb0431 100644 --- a/host/crypto_keys.hpp +++ b/host/crypto_keys.hpp @@ -39,6 +39,9 @@ struct CryptoKeys { std::vector table; }; std::map tags; + // The fixed keys of the console's DRM library (amctrl), 1 to 3, which the + // PGD format uses (crypto/pgd.hpp). + std::map amctrl; [[nodiscard]] const Key16 *kirk(std::uint8_t slot) const { const auto it = kirk_aes.find(slot); diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index 6388bb7..ca42bae 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -2,6 +2,8 @@ // (including raw "sce_lbn" sector files) and a host directory for ms0:. #include "../profile.hpp" #include "hle_common.hpp" + +#include "crypto/pgd.hpp" #include "install/user_data.hpp" #include "kernel/fast_loading.hpp" #include "kernel/load_trace.hpp" @@ -98,6 +100,11 @@ struct OpenFile { bool release_after_async{}; SceUID async_callback{}; std::uint32_t async_callback_argument{}; + // PGD (crypto/pgd.hpp): opened with flag 0x40000000, the file becomes a + // decrypted view once the game gives its key with ioctl 0x04100001; + // position and size then count decrypted bytes. + bool pgd_requested{}; + std::shared_ptr pgd; }; // A read-ahead the game asked the drive for: a range of sectors it wants in @@ -206,6 +213,7 @@ std::int64_t open_file(const std::string &full_path, std::uint32_t flags) { const SplitPath split = split_path(full_path); OpenFile file; file.path = full_path; + file.pgd_requested = (flags & pgd::kOpenFlag) != 0u; if (split.device == Device::Disc) { if (!io().disc) return static_cast(io_error::kDeviceNotFound); if ((flags & kOpenWrite) != 0u) return static_cast(io_error::kReadOnly); @@ -317,6 +325,21 @@ namespace { // The work of sceIoRead, sceIoWrite and sceIoLseek, shared with their // asynchronous forms. +// The file's own bytes at `offset`, before any PGD decryption. +std::size_t read_raw(OpenFile &file, std::uint64_t offset, std::span out) { + if (file.kind == OpenFile::Kind::Disc) { + if (!io().disc) return 0u; + return io().disc->read(file.disc_offset + offset, out); + } + if (file.kind != OpenFile::Kind::Host) return 0u; + file.host->clear(); + file.host->seekg(static_cast(offset)); + file.host->read(reinterpret_cast(out.data()), static_cast(out.size())); + const auto count = static_cast(file.host->gcount()); + file.host->clear(); + return count; +} + std::uint32_t read_file_op(Runtime &rt, std::uint32_t fd, std::uint32_t address, std::uint32_t requested) { auto found = io().files.find(fd); if (found == io().files.end() || @@ -325,7 +348,16 @@ std::uint32_t read_file_op(Runtime &rt, std::uint32_t fd, std::uint32_t address, OpenFile &file = found->second; std::vector buffer; std::uint32_t result = 0u; - if (file.kind == OpenFile::Kind::Disc) { + if (file.pgd) { + const std::uint64_t available = file.position < file.size ? file.size - file.position : 0u; + buffer.resize(static_cast(std::min(requested, available))); + OpenFile *raw_file = &file; + const std::size_t count = file.pgd->read(file.position, buffer, [raw_file](std::uint64_t offset, std::span out) { + return read_raw(*raw_file, offset, out); + }); + buffer.resize(count); + result = static_cast(count); + } else if (file.kind == OpenFile::Kind::Disc) { const std::uint64_t available = file.position < file.size ? file.size - file.position : 0u; requested = static_cast(std::min(requested, available)); const std::uint64_t unit = file.sector_units ? IsoImage::kSectorSize : 1u; @@ -464,7 +496,34 @@ std::uint32_t ioctl_op(Runtime &rt, AllegrexContext &ctx) { auto &memory = rt.memory(); const auto found = io().files.find(fd); if (found == io().files.end()) return io_error::kBadFileDescriptor; - const OpenFile &file = found->second; + OpenFile &file = found->second; + // PGD: the key of a file opened with flag 0x40000000. The header is read + // and checked; from here on reads and seeks see the decrypted data. + // Phantasy Star Portable 2 Infinity opens INSDIR/MEDIA.FPB this way. + if (command == pgd::kIoctlSetKey && input != 0u && input_size >= 16u && + (file.kind == OpenFile::Kind::Disc || file.kind == OpenFile::Kind::Host)) { + // What a PSP answers for a PGD file it cannot use has not been + // traced: the I/O error. + constexpr std::uint32_t kPgdUnusable = 0x80010005u; + pgd::Block vkey{}; + for (std::uint32_t i = 0; i < 16u; ++i) vkey[i] = memory.load8(input + i); + std::vector header(pgd::kHeaderSize); + header.resize(read_raw(file, 0u, header)); + std::string error; + auto opened = pgd::PgdFile::open(header, vkey, file.size, pgd::player_keys(), pgd::active_scheme(), std::nullopt, error); + if (!opened) { + log_once("pgd-" + file.path, "[pgd] " + file.path + ": cannot decrypt: " + error + + " (see docs/DESKTOP_APP.md, \"Keys\", for the PGD keys)"); + return kPgdUnusable; + } + if (trace_io()) + std::cerr << "[io] pgd " << file.path << " size " << opened->size() << " scheme " + << pgd::active_scheme().describe() << "\n"; + file.pgd = std::make_shared(std::move(*opened)); + file.size = file.pgd->size(); + file.position = 0u; + return 0u; + } if (file.kind == OpenFile::Kind::Disc && command == 0x01010005u && input != 0u && input_size >= 12u) { const auto offset = static_cast(static_cast(memory.load32(input)) | (static_cast(memory.load32(input + 4u)) << 32u)); diff --git a/tests/pgd_test_keys.cpp b/tests/pgd_test_keys.cpp new file mode 100644 index 0000000..222f7e8 --- /dev/null +++ b/tests/pgd_test_keys.cpp @@ -0,0 +1,6 @@ +// The PGD tests pass their own keys; savedata_crypto.cpp still asks here. +#include "crypto_keys.hpp" + +namespace portablekit { +const CryptoKeys *crypto_keys() { return nullptr; } +} // namespace portablekit diff --git a/tests/pgd_tests.cpp b/tests/pgd_tests.cpp new file mode 100644 index 0000000..46a1c77 --- /dev/null +++ b/tests/pgd_tests.cpp @@ -0,0 +1,101 @@ +// PGD containers (crypto/pgd.hpp) with dummy keys made up here: files built +// by the implementation itself decrypt back, random access into them works, +// and a wrong MAC, a wrong version key and a missing key are refused. No +// console key and no game data. + +#include "crypto/pgd.hpp" + +#include +#include +#include +#include + +namespace { + +int g_failures = 0; + +void check(bool condition, const char *what) { + std::printf("%s %s\n", condition ? "ok " : "FAIL", what); + if (!condition) ++g_failures; +} + +portablekit::Key16 dummy(std::uint8_t seed) { + portablekit::Key16 key{}; + for (std::size_t i = 0; i < key.size(); ++i) key[i] = static_cast(seed * 31u + i * 7u + 1u); + return key; +} + +} // namespace + +int main() { + using namespace portablekit::pgd; + static std::map kirk{{0x38, dummy(1)}, {0x39, dummy(2)}, {0x3A, dummy(3)}, {0x63, dummy(4)}}; + static std::map fixed{{1, dummy(5)}, {2, dummy(6)}, {3, dummy(7)}}; + const KeySource keys{[](std::uint8_t slot) -> const portablekit::Key16 * { + const auto it = kirk.find(slot); + return it != kirk.end() ? &it->second : nullptr; + }, + [](int index) -> const portablekit::Key16 * { + const auto it = fixed.find(index); + return it != fixed.end() ? &it->second : nullptr; + }}; + + std::vector plain(3000); + for (std::size_t i = 0; i < plain.size(); ++i) plain[i] = static_cast(i * 13u + (i >> 8u)); + const Block desc_key = dummy(20), data_key = dummy(21), vkey = dummy(22); + + for (const bool restart : {false, true}) { + CipherScheme scheme = active_scheme(); + scheme.block_counters_restart = restart; + const auto file = PgdFile::build(plain, desc_key, data_key, vkey, keys, scheme, 0x38); + check(file.size() == 0x90u + 3u * 0x400u, "three 0x400-byte blocks after the 0x90-byte header"); + const auto raw_reader = [&file](std::uint64_t offset, std::span out) -> std::size_t { + if (offset >= file.size()) return 0u; + const std::size_t n = std::min(out.size(), file.size() - offset); + std::copy_n(file.begin() + static_cast(offset), n, out.begin()); + return n; + }; + std::string error; + auto opened = PgdFile::open(std::span(file.data(), 0x90), vkey, file.size(), keys, scheme, 0x38, error); + check(opened.has_value(), "a PGD built here opens with its version key and a matching MAC"); + if (!opened) continue; + check(opened->size() == plain.size() && opened->desc().block_size == 0x400u && opened->desc().data_offset == 0x90u, + "its description decrypts to the size, block size and data offset"); + std::vector all(plain.size()); + check(opened->read(0, all, raw_reader) == plain.size() && all == plain, "the whole file decrypts back"); + std::vector middle(1500); + const std::size_t got = opened->read(1000, middle, raw_reader); + check(got == 1500u && std::equal(middle.begin(), middle.end(), plain.begin() + 1000), + "a read across a block boundary at a random offset decrypts back"); + std::vector tail(100); + check(opened->read(2950, tail, raw_reader) == 50u, "a read past the end stops at the data size"); + + auto tampered = file; + tampered[0x44] ^= 1u; + check(!PgdFile::open(std::span(tampered.data(), 0x90), vkey, tampered.size(), keys, scheme, 0x38, error) && + error.find("MAC") != std::string::npos, + "a changed header fails the MAC check"); + check(!PgdFile::open(std::span(file.data(), 0x90), dummy(99), file.size(), keys, scheme, std::nullopt, error) && + error.find("valid description") != std::string::npos, + "another version key does not give a valid description"); + } + + std::map none; + const KeySource missing{[](std::uint8_t) -> const portablekit::Key16 * { return nullptr; }, + [](int) -> const portablekit::Key16 * { return nullptr; }}; + std::vector header(0x90, 0u); + header[1] = 'P'; + header[2] = 'G'; + header[3] = 'D'; + std::string error; + check(!PgdFile::open(header, vkey, 0x10000, missing, active_scheme(), std::nullopt, error) && + error.find("kirk.aes.") != std::string::npos, + "a missing key is named"); + header[1] = 'X'; + check(!PgdFile::open(header, vkey, 0x10000, keys, active_scheme(), std::nullopt, error) && error == "not a PGD file", + "a file without the magic is not PGD"); + check(candidate_schemes().size() == 1024u, "pgd-check tries 1024 schemes"); + + if (g_failures != 0) std::printf("%d failed\n", g_failures); + return g_failures == 0 ? 0 : 1; +} From 359d81fe8ae717ffd1204641d597b299f4c9e971 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:05:11 +0300 Subject: [PATCH 2/3] Name the PGD keys as the maintainer's keys file does kirk.aes.38, kirk.aes.39, kirk.aes.63, amctrl.1CD4, amctrl.1CE4, amctrl.1CF4, amctrl.dnas.1A90 and amctrl.dnas.1AA0, the names another component already reads, so one keys.txt serves both. kirk.aes.3A is used when present. The cipher schemes now try any of the five fixed keys as a mask (1536 schemes). `keys status` says "decrypt PGD data: yes" with kirk.aes.38/39/63 and the three amctrl keys. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/portablekit/src/keys_file.cpp | 17 +++++++++++++---- apps/portablekit/src/keys_file.hpp | 9 +++++---- docs/DESKTOP_APP.md | 6 +++--- host/crypto/pgd.cpp | 13 ++++++++++++- host/crypto/pgd.hpp | 12 ++++++++---- host/crypto_keys.hpp | 5 +++-- tests/pgd_tests.cpp | 4 ++-- 7 files changed, 46 insertions(+), 20 deletions(-) diff --git a/apps/portablekit/src/keys_file.cpp b/apps/portablekit/src/keys_file.cpp index a7df0e5..c9eb14c 100644 --- a/apps/portablekit/src/keys_file.cpp +++ b/apps/portablekit/src/keys_file.cpp @@ -41,9 +41,18 @@ constexpr Fingerprint kFingerprints[] = { // added once they have been checked against a real PGD file; until then any // 16-byte value is taken under these names, and a wrong one shows as a PGD // header that does not decrypt. +int amctrl_index(const std::string &name) { + if (name == "amctrl.1cd4") return 1; + if (name == "amctrl.1ce4") return 2; + if (name == "amctrl.1cf4") return 3; + if (name == "amctrl.dnas.1a90") return 4; + if (name == "amctrl.dnas.1aa0") return 5; + return 0; +} + bool pgd_key_name(const std::string &name) { return name == "kirk.aes.38" || name == "kirk.aes.39" || name == "kirk.aes.3a" || name == "kirk.aes.63" || - name == "amctrl.1" || name == "amctrl.2" || name == "amctrl.3"; + amctrl_index(name) != 0; } const char *expected_fingerprint(const std::string &name) { @@ -115,8 +124,8 @@ KeysReport read_keys_file(const std::filesystem::path &path) { report.keys.kirk_cmd1 = to_key(*bytes); } else if (name.starts_with("savedata.")) { report.keys.savedata[std::atoi(name.substr(9).c_str())] = to_key(*bytes); - } else if (name.starts_with("amctrl.")) { - report.keys.amctrl[std::atoi(name.substr(7).c_str())] = to_key(*bytes); + } else if (const int index = amctrl_index(name); index != 0) { + report.keys.amctrl[index] = to_key(*bytes); } } report.tag_count = report.keys.tags.size(); @@ -127,7 +136,7 @@ KeysReport read_keys_file(const std::filesystem::path &path) { for (int index = 2; index <= 7; ++index) if (report.keys.savedata_key(index) == nullptr) report.can_encrypt_saves = false; report.can_decrypt_pgd = true; - for (const std::uint8_t slot : {0x38, 0x39, 0x3A, 0x63}) + for (const std::uint8_t slot : {0x38, 0x39, 0x63}) if (report.keys.kirk(slot) == nullptr) report.can_decrypt_pgd = false; for (int index = 1; index <= 3; ++index) if (report.keys.amctrl.find(index) == report.keys.amctrl.end()) report.can_decrypt_pgd = false; diff --git a/apps/portablekit/src/keys_file.hpp b/apps/portablekit/src/keys_file.hpp index 974ed4d..be1c79f 100644 --- a/apps/portablekit/src/keys_file.hpp +++ b/apps/portablekit/src/keys_file.hpp @@ -11,9 +11,10 @@ // savedata.2 = <16 bytes> save-data keys 2 to 7 // tag.C0CB167C = <16 or 0x90 bytes> what an executable's tag selects // tag.C0CB167C.slot = 5D (optional) the KIRK slot of that tag -// amctrl.1 = <16 bytes> the DRM library's fixed keys 1 to 3, which -// PGD data needs with kirk.aes.38, .39, .3A -// and .63 (host/crypto/pgd.hpp) +// amctrl.1CD4 = <16 bytes> the DRM library's fixed keys, which PGD data +// needs with kirk.aes.38, .39 and .63; also +// amctrl.1CE4, .1CF4, amctrl.dnas.1A90 and +// .1AA0 (host/crypto/pgd.hpp) // // The fixed keys are checked against SHA-256 fingerprints compiled into the // program, so a mistyped key is named instead of producing garbage; a @@ -36,7 +37,7 @@ struct KeysReport { // What the keys that passed allow. bool can_decrypt_executables{}; // kirk.aes.5D and kirk.cmd1 bool can_encrypt_saves{}; // every save-data key - bool can_decrypt_pgd{}; // kirk.aes.38/39/3A/63 and amctrl.1-3 + bool can_decrypt_pgd{}; // kirk.aes.38/39/63 and amctrl.1CD4/1CE4/1CF4 std::size_t tag_count{}; }; diff --git a/docs/DESKTOP_APP.md b/docs/DESKTOP_APP.md index 2becc08..42ebba1 100644 --- a/docs/DESKTOP_APP.md +++ b/docs/DESKTOP_APP.md @@ -160,12 +160,12 @@ kirk.cmd1 = <16 bytes> # the KIRK command 1 AES key savedata.2 = <16 bytes> # save-data keys 2 to 7 tag.C0CB167C = <16 bytes, or the 0x90-byte table of an old header layout> tag.C0CB167C.slot = 5D # optional: the KIRK slot the tag's header uses -amctrl.1 = <16 bytes> # the DRM library's fixed keys 1 to 3, for PGD data +amctrl.1CD4 = <16 bytes> # the DRM library's fixed keys, for PGD data (also .1CE4, .1CF4, amctrl.dnas.1A90, .1AA0) ``` - `portablekit keys import ` checks the file and copies it into the data folder. Every fixed key is checked against a SHA-256 fingerprint compiled into the program, so the player is told "`savedata.4` is not the right key" instead of getting garbage later. Tag keys have no fingerprint: an executable that decrypts to an ELF the runtime can load is the check. - What each group enables: `kirk.aes.5D` + `kirk.cmd1` + the game's `tag.*` decrypt that game's `EBOOT.BIN`; the nine save slots and `savedata.2`–`7` encrypt and decrypt saves. `portablekit keys status` says which of these the file provides. -- PGD data (below) needs `kirk.aes.38`, `kirk.aes.39`, `kirk.aes.3A`, `kirk.aes.63`, `amctrl.1`, `amctrl.2` and `amctrl.3`; `keys status` says "decrypt PGD data: yes" when all seven are there. These have no fingerprint yet: they get one once they have been checked against a real PGD file, and until then a wrong value shows as a PGD file that does not decrypt, with a log line naming the file. +- PGD data (below) uses `kirk.aes.38`, `kirk.aes.39`, `kirk.aes.63`, `amctrl.1CD4`, `amctrl.1CE4`, `amctrl.1CF4`, `amctrl.dnas.1A90` and `amctrl.dnas.1AA0` (and `kirk.aes.3A` if present); `keys status` says "decrypt PGD data: yes" when the first six are there. These have no fingerprint yet: they get one once they have been checked against a real PGD file, and until then a wrong value shows as a PGD file that does not decrypt, with a log line naming the file. - Without keys, a game can still be added when its disc carries an unencrypted `BOOT.BIN` (LocoRoco 2 does), or when the player gives an executable they decrypted (`--executable`). The program never names a source of keys. Messages when something is missing, as the prototype prints them: @@ -179,7 +179,7 @@ Messages when something is missing, as the prototype prints them: Some games keep large data files in PGD containers, "Protected Game Data" (Phantasy Star Portable 2 Infinity's `PSP_GAME/INSDIR/MEDIA.FPB`, 275 MB). The game opens the file with flag `0x40000000` and gives the library the file's key with `sceIoIoctl(fd, 0x04100001, key, 16)`; after that it reads and seeks in the decrypted data. [`host/crypto/pgd.hpp`](../host/crypto/pgd.hpp) implements the container from public descriptions of the format: the 0x90-byte header, the encrypted description (data key, size, 0x400-byte blocks from 0x90), and block-by-block decryption for random access. The keys come only from the player's file. -The public descriptions do not say which KIRK slots, masks and counter conventions the console's cipher uses, so these are parameters. `portablekit keys pgd-check ` tries all 1024 combinations with the player's keys and prints those that turn the file's header into a valid description (version 0, 0x400-byte blocks, data at 0x90), with the start of the first data block; `PORTABLEKIT_PGD_SCHEME` selects one. When the maintainer's keys have been checked this way against PSP2i's file, the one that works becomes the default and the keys get fingerprints. +The public descriptions do not say which KIRK slots, masks and counter conventions the console's cipher uses, so these are parameters. `portablekit keys pgd-check ` tries all 1536 combinations with the player's keys and prints those that turn the file's header into a valid description (version 0, 0x400-byte blocks, data at 0x90), with the start of the first data block; `PORTABLEKIT_PGD_SCHEME` selects one. When the maintainer's keys have been checked this way against PSP2i's file, the one that works becomes the default and the keys get fingerprints. Without the keys, the file is refused with the I/O error (what a PSP answers for a file it cannot decrypt has not been traced), and the log names the file and the missing key once: `[pgd] : cannot decrypt: the keys file has no kirk.aes.39`. diff --git a/host/crypto/pgd.cpp b/host/crypto/pgd.cpp index 229a79e..c438818 100644 --- a/host/crypto/pgd.cpp +++ b/host/crypto/pgd.cpp @@ -65,6 +65,17 @@ std::string CipherScheme::describe() const { std::vector kirk_slots_used() { return {0x38u, 0x39u, 0x3Au, 0x63u}; } +const char *fixed_key_name(int index) { + switch (index) { + case 1: return "amctrl.1CD4"; + case 2: return "amctrl.1CE4"; + case 3: return "amctrl.1CF4"; + case 4: return "amctrl.dnas.1A90"; + case 5: return "amctrl.dnas.1AA0"; + default: return "amctrl.?"; + } +} + std::vector candidate_schemes() { std::vector schemes; for (const std::uint8_t header : kirk_slots_used()) @@ -100,7 +111,7 @@ std::optional bb_cipher(const CipherScheme &scheme, const KeySource Block context = key; if (scheme.header_mask != 0) { const Key16 *mask = keys.fixed ? keys.fixed(scheme.header_mask) : nullptr; - if (mask == nullptr) return "amctrl." + std::to_string(scheme.header_mask); + if (mask == nullptr) return fixed_key_name(scheme.header_mask); context = xor_block(context, *mask); } if (!scheme.vkey_after) context = xor_block(context, vkey); diff --git a/host/crypto/pgd.hpp b/host/crypto/pgd.hpp index ced9b06..f5bc163 100644 --- a/host/crypto/pgd.hpp +++ b/host/crypto/pgd.hpp @@ -47,8 +47,10 @@ inline constexpr std::uint32_t kHeaderSize = 0x90u; inline constexpr std::uint32_t kIoctlSetKey = 0x04100001u; inline constexpr std::uint32_t kOpenFlag = 0x40000000u; -// Where the keys come from: KIRK AES key slots, and the fixed "amctrl" keys -// (1 to 3). Null when the player's file does not have it. +// Where the keys come from: KIRK AES key slots, and the DRM library's fixed +// keys, numbered here 1 to 5: amctrl.1CD4, amctrl.1CE4, amctrl.1CF4, +// amctrl.dnas.1A90 and amctrl.dnas.1AA0 in the keys file. Null when the +// player's file does not have it. struct KeySource { std::function kirk; std::function fixed; @@ -60,7 +62,7 @@ struct KeySource { // One way of running the BB cipher; see the file comment. struct CipherScheme { std::uint8_t header_slot{}; // KIRK slot that decrypts the header key into the stream prefix - int header_mask{}; // amctrl key XORed into the header key first (0: none) + int header_mask{}; // fixed key (1-5) XORed into the header key first (0: none) bool vkey_after{}; // the version key XORed into the decrypted prefix, else into the header key std::uint8_t stream_slot{}; // KIRK slot of the key stream bool stream_encrypts{}; // key stream = AES-encrypt(counter block), else AES-decrypt @@ -73,7 +75,9 @@ struct CipherScheme { // The slots and masks every scheme may use: what `keys status` checks. [[nodiscard]] std::vector kirk_slots_used(); -inline constexpr int kFixedKeysUsed = 3; +inline constexpr int kFixedKeysUsed = 5; +// The keys file's name of fixed key `index` (1-5). +[[nodiscard]] const char *fixed_key_name(int index); // Every scheme `pgd-check` tries. [[nodiscard]] std::vector candidate_schemes(); diff --git a/host/crypto_keys.hpp b/host/crypto_keys.hpp index 1eb0431..fa9c9c0 100644 --- a/host/crypto_keys.hpp +++ b/host/crypto_keys.hpp @@ -39,8 +39,9 @@ struct CryptoKeys { std::vector table; }; std::map tags; - // The fixed keys of the console's DRM library (amctrl), 1 to 3, which the - // PGD format uses (crypto/pgd.hpp). + // The fixed keys of the console's DRM library (amctrl), which the PGD + // format uses (crypto/pgd.hpp): 1-3 amctrl.1CD4/1CE4/1CF4, 4-5 + // amctrl.dnas.1A90/1AA0. std::map amctrl; [[nodiscard]] const Key16 *kirk(std::uint8_t slot) const { diff --git a/tests/pgd_tests.cpp b/tests/pgd_tests.cpp index 46a1c77..6a9cca1 100644 --- a/tests/pgd_tests.cpp +++ b/tests/pgd_tests.cpp @@ -30,7 +30,7 @@ portablekit::Key16 dummy(std::uint8_t seed) { int main() { using namespace portablekit::pgd; static std::map kirk{{0x38, dummy(1)}, {0x39, dummy(2)}, {0x3A, dummy(3)}, {0x63, dummy(4)}}; - static std::map fixed{{1, dummy(5)}, {2, dummy(6)}, {3, dummy(7)}}; + static std::map fixed{{1, dummy(5)}, {2, dummy(6)}, {3, dummy(7)}, {4, dummy(8)}, {5, dummy(9)}}; const KeySource keys{[](std::uint8_t slot) -> const portablekit::Key16 * { const auto it = kirk.find(slot); return it != kirk.end() ? &it->second : nullptr; @@ -94,7 +94,7 @@ int main() { header[1] = 'X'; check(!PgdFile::open(header, vkey, 0x10000, keys, active_scheme(), std::nullopt, error) && error == "not a PGD file", "a file without the magic is not PGD"); - check(candidate_schemes().size() == 1024u, "pgd-check tries 1024 schemes"); + check(candidate_schemes().size() == 1536u, "pgd-check tries 1536 schemes"); if (g_failures != 0) std::printf("%d failed\n", g_failures); return g_failures == 0 ? 0 : 1; From 82ee079660e19f500da1524052518baaded32f3c Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:03:22 +0300 Subject: [PATCH 3/3] Decrypt PGD with the scheme found on PSP2i's file, through a file filter The cipher and MAC are the ones that open Phantasy Star Portable 2 Infinity's MEDIA.FPB (key index 1, DRM type 1) with the maintainer's keys: the prefix is kirk.aes.39's decryption of the key masked with the version key and amctrl.1CF4, then masked with amctrl.1CE4; the key stream is kirk.aes.63's decryption of each counter block XORed with the one before, running on through the whole file; the header MAC at 0x70 is a kirk.aes.38 CMAC finished with the version key and amctrl.1CD4, and is now enforced. Other kinds of PGD are refused, since none has been checked. The parameterised schemes, PORTABLEKIT_PGD_SCHEME and the search in `keys pgd-check` are gone; pgd-check now says whether a file opens and how much of its first block is zero. The eight PGD keys get fingerprints. The decrypted view is a FileFilter set by ioctl 0x04100001, in place of the IO layer's own PGD read path. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/portablekit/src/app_main.cpp | 50 +++----- apps/portablekit/src/keys_file.cpp | 17 ++- docs/DESKTOP_APP.md | 4 +- host/crypto/pgd.cpp | 190 +++++++++++++---------------- host/crypto/pgd.hpp | 97 ++++++--------- host/hle/hle_io.cpp | 72 +++++------ tests/pgd_tests.cpp | 82 ++++++++----- 7 files changed, 235 insertions(+), 277 deletions(-) diff --git a/apps/portablekit/src/app_main.cpp b/apps/portablekit/src/app_main.cpp index 63c5e8c..67f458d 100644 --- a/apps/portablekit/src/app_main.cpp +++ b/apps/portablekit/src/app_main.cpp @@ -42,6 +42,7 @@ #include "psprecomp/interpreter.hpp" +#include #include #include #include @@ -431,11 +432,11 @@ int command_keys(const Arguments &args) { } return ok ? 0 : kKeysRejected; } - // keys pgd-check : which - // way of running the PGD cipher (crypto/pgd.hpp) turns this file's - // header into a valid description, with the keys file's keys. The key is - // the one the game passes to sceIoIoctl 0x04100001 (_TRACE_IO - // shows it). Prints scheme names and the start of the first data block, + // keys pgd-check : whether + // this PGD file (crypto/pgd.hpp) opens with the keys file's keys: its + // header MAC matches and its description is valid. The key is the one the + // game passes to sceIoIoctl 0x04100001 (_TRACE_IO shows it). + // Prints the description and how much of the first data block is zero, // never a key. if (action == "pgd-check") { if (args.positional.size() < 5) return report_error(kUsage, "keys pgd-check needs "); @@ -453,32 +454,21 @@ int command_keys(const Arguments &args) { const std::uint64_t base = static_cast(entry->lba) * IsoImage::kSectorSize; std::vector header(pgd::kHeaderSize); iso.read(base, header); - const pgd::KeySource keys = pgd::player_keys(); - std::size_t matches = 0; - std::set missing; - for (pgd::CipherScheme scheme : pgd::candidate_schemes()) { - std::string error; - auto file = pgd::PgdFile::open(header, vkey, entry->size, keys, scheme, std::nullopt, error); - if (!file) { - if (error.find("keys file has no") != std::string::npos) missing.insert(error); - continue; - } - ++matches; - for (const bool restart : {false, true}) { - scheme.block_counters_restart = restart; - auto view = pgd::PgdFile::open(header, vkey, entry->size, keys, scheme, std::nullopt, error); - std::vector first(32); - view->read(0, first, [&iso, base](std::uint64_t offset, std::span out) { - return iso.read(base + offset, out); - }); - std::cout << scheme.describe() << ": data size " << view->size() << ", first bytes"; - for (const std::uint8_t byte : first) std::printf(" %02x", byte); - std::cout << "\n"; - } + std::string error; + auto file = pgd::PgdFile::open(header, vkey, entry->size, pgd::player_keys(), error); + if (!file) { + std::cout << "does not open: " << error << "\n"; + return 1; } - for (const std::string &error : missing) std::cout << "some schemes could not be tried: " << error << "\n"; - std::cout << matches << " scheme(s) give a valid description\n"; - return matches != 0u ? 0 : 1; + std::vector first(file->desc().block_size); + const std::size_t got = file->read(0, first, [&iso, base](std::uint64_t offset, std::span out) { + return iso.read(base + offset, out); + }); + first.resize(got); + std::cout << "header MAC matches; data size " << file->size() << ", blocks of " << file->desc().block_size + << " from " << file->desc().data_offset << "; first block: " + << std::count(first.begin(), first.end(), std::uint8_t{0}) << " of " << got << " bytes zero\n"; + return 0; } if (action != "status") return report_error(kUsage, "keys takes import, status or pgd-check"); const KeysReport &report = active_keys(); diff --git a/apps/portablekit/src/keys_file.cpp b/apps/portablekit/src/keys_file.cpp index d333c67..2e62e69 100644 --- a/apps/portablekit/src/keys_file.cpp +++ b/apps/portablekit/src/keys_file.cpp @@ -38,12 +38,19 @@ constexpr Fingerprint kFingerprints[] = { {"savedata.5", "9f7bfa1f5136d4dfdd3a8322578dbbe5243bf4eb70428b101f27eb1144027461"}, {"savedata.6", "b58242561b16a8925126dbae5558dda53d6c275d712ee69f5bebce37c577af2d"}, {"savedata.7", "a0bf478c1471e4115011e5e0f70d5e2d43c95e5ef2645d164b76ec3cd118ec10"}, + {"kirk.aes.38", "b5e2a84dbaaf99015874182a8609494d3c8e38f1837755ee29858c78b986ad17"}, + {"kirk.aes.39", "be3798d2e2a46c0018a4f7e41f5557e6cc2aeb64b0b15ae6b7063a8fa41c8559"}, + {"kirk.aes.63", "16df5079b5e31b07045b9d117c9db8bf9c45e606dbe4a9c71bb763f12d61be83"}, + {"amctrl.1CD4", "dbe5dd77c4a5f3b930b30f520ae71064a4b48161ba43d79252351effc0ecb403"}, + {"amctrl.1CE4", "4aae0892755232421ac3077d844110b0c0334eeadc9cef6093e55a3fb8e56ee4"}, + {"amctrl.1CF4", "e0942366cf4a2142ac1369df3d404198ac8d9bf9df73ccde6e540f2554dbcf4c"}, + {"amctrl.dnas.1A90", "3f238f8d5e24ce9402f00be5bb869155d83bcf05457948faa36822a20af04057"}, + {"amctrl.dnas.1AA0", "47d6a491c1dc0424e79d60b28fd7f9fc9fb6f7be0552cf4e77dfdcbea3260869"}, }; -// The keys the PGD format needs (host/crypto/pgd.hpp). Their fingerprints are -// added once they have been checked against a real PGD file; until then any -// 16-byte value is taken under these names, and a wrong one shows as a PGD -// header that does not decrypt. +// The keys the PGD format needs (host/crypto/pgd.hpp), with fingerprints +// above. The DNAS ones are taken but not used yet: no PGD file of that kind +// has been checked. int amctrl_index(const std::string &name) { if (name == "amctrl.1cd4") return 1; if (name == "amctrl.1ce4") return 2; @@ -54,7 +61,7 @@ int amctrl_index(const std::string &name) { } bool pgd_key_name(const std::string &name) { - return name == "kirk.aes.38" || name == "kirk.aes.39" || name == "kirk.aes.3a" || name == "kirk.aes.63" || + return name == "kirk.aes.38" || name == "kirk.aes.39" || name == "kirk.aes.63" || amctrl_index(name) != 0; } diff --git a/docs/DESKTOP_APP.md b/docs/DESKTOP_APP.md index 4a6b0ef..565da7b 100644 --- a/docs/DESKTOP_APP.md +++ b/docs/DESKTOP_APP.md @@ -165,7 +165,7 @@ amctrl.1CD4 = <16 bytes> # the DRM library's fixed keys, for PGD data (als - `portablekit keys import ` checks the file and copies it into the data folder. Every fixed key is checked against a SHA-256 fingerprint compiled into the program, so the player is told "`savedata.4` is not the right key" instead of getting garbage later. Tag keys have no fingerprint: an executable that decrypts to an ELF the runtime can load is the check. - What each group enables: `kirk.aes.5D` + `kirk.cmd1` + the game's `tag.*` decrypt that game's `EBOOT.BIN`; the nine save slots and `savedata.2`–`7` encrypt and decrypt saves. `portablekit keys status` says which of these the file provides. -- PGD data (below) uses `kirk.aes.38`, `kirk.aes.39`, `kirk.aes.63`, `amctrl.1CD4`, `amctrl.1CE4`, `amctrl.1CF4`, `amctrl.dnas.1A90` and `amctrl.dnas.1AA0` (and `kirk.aes.3A` if present); `keys status` says "decrypt PGD data: yes" when the first six are there. These have no fingerprint yet: they get one once they have been checked against a real PGD file, and until then a wrong value shows as a PGD file that does not decrypt, with a log line naming the file. +- PGD data (below) uses `kirk.aes.38`, `kirk.aes.39`, `kirk.aes.63`, `amctrl.1CD4`, `amctrl.1CE4`, `amctrl.1CF4`, `amctrl.dnas.1A90` and `amctrl.dnas.1AA0` ; `keys status` says "decrypt PGD data: yes" when the first six are there. All eight have fingerprints, checked against PSP2i's file; the two DNAS keys are accepted but not used yet, since no PGD file of that kind has been checked. - Without keys, a game can still be added when its disc carries an unencrypted `BOOT.BIN` (LocoRoco 2 does), or when the player gives an executable they decrypted (`--executable`). The program never names a source of keys. - A name the program does not use is a warning ("... is not a key this program or its extension modules use; it is ignored.") and is left out; it does not refuse the file. HLE extension modules the build links may declare more names, which are read and checked like the program's own ([HLE_EXTENSIONS.md](HLE_EXTENSIONS.md#keys)); `keys status` lists them by module. @@ -180,7 +180,7 @@ Messages when something is missing, as the prototype prints them: Some games keep large data files in PGD containers, "Protected Game Data" (Phantasy Star Portable 2 Infinity's `PSP_GAME/INSDIR/MEDIA.FPB`, 275 MB). The game opens the file with flag `0x40000000` and gives the library the file's key with `sceIoIoctl(fd, 0x04100001, key, 16)`; after that it reads and seeks in the decrypted data. [`host/crypto/pgd.hpp`](../host/crypto/pgd.hpp) implements the container from public descriptions of the format: the 0x90-byte header, the encrypted description (data key, size, 0x400-byte blocks from 0x90), and block-by-block decryption for random access. The keys come only from the player's file. -The public descriptions do not say which KIRK slots, masks and counter conventions the console's cipher uses, so these are parameters. `portablekit keys pgd-check ` tries all 1536 combinations with the player's keys and prints those that turn the file's header into a valid description (version 0, 0x400-byte blocks, data at 0x90), with the start of the first data block; `PORTABLEKIT_PGD_SCHEME` selects one. When the maintainer's keys have been checked this way against PSP2i's file, the one that works becomes the default and the keys get fingerprints. +The public descriptions do not say which KIRK slots, masks and counter conventions the console's cipher uses; they were found by trying combinations against PSP2i's file (key index 1, DRM type 1) until its header MAC matched and its description decrypted to known values, and the data blocks to structured bytes. The result is written out in `pgd.hpp`. Other key indices and DRM types are refused with a log line, because none has been checked. The decrypted view is a file filter (`hle_extension::set_file_filter`), set by the ioctl. `portablekit keys pgd-check ` says whether a file opens with the player's keys: the MAC, the description, and how much of the first block is zero. It never prints a key. Without the keys, the file is refused with the I/O error (what a PSP answers for a file it cannot decrypt has not been traced), and the log names the file and the missing key once: `[pgd] : cannot decrypt: the keys file has no kirk.aes.39`. diff --git a/host/crypto/pgd.cpp b/host/crypto/pgd.cpp index c438818..038b815 100644 --- a/host/crypto/pgd.cpp +++ b/host/crypto/pgd.cpp @@ -4,9 +4,8 @@ #include "save_data/savedata_crypto.hpp" #include -#include -#include -#include +#include +#include namespace portablekit::pgd { namespace { @@ -40,6 +39,25 @@ std::string slot_name(std::uint8_t slot) { return text; } +// The fixed keys (KeySource::fixed): amctrl.1CD4, 1CE4, 1CF4. +constexpr int kMacMask = 1; +constexpr int kMaskOut = 2; +constexpr int kMaskIn = 3; + +const char *fixed_key_name(int index) { + switch (index) { + case kMacMask: return "amctrl.1CD4"; + case kMaskOut: return "amctrl.1CE4"; + case kMaskIn: return "amctrl.1CF4"; + default: return "amctrl.?"; + } +} + +// The only kind checked against a real file (see pgd.hpp). +constexpr std::uint32_t kKeyIndex = 1u; +constexpr std::uint32_t kDrmType = 1u; +constexpr std::size_t kMacSpan = 0x70u; + } // namespace KeySource player_keys() { @@ -55,87 +73,48 @@ KeySource player_keys() { }}; } -std::string CipherScheme::describe() const { - char text[96]; - std::snprintf(text, sizeof(text), "h%02X-m%d-%c-s%02X-%c-%c-c%u-%c", header_slot, header_mask, vkey_after ? 'a' : 'b', - stream_slot, stream_encrypts ? 'e' : 'd', chained ? 'x' : 'n', counter_start, - block_counters_restart ? 'r' : 'f'); - return text; -} - -std::vector kirk_slots_used() { return {0x38u, 0x39u, 0x3Au, 0x63u}; } - -const char *fixed_key_name(int index) { - switch (index) { - case 1: return "amctrl.1CD4"; - case 2: return "amctrl.1CE4"; - case 3: return "amctrl.1CF4"; - case 4: return "amctrl.dnas.1A90"; - case 5: return "amctrl.dnas.1AA0"; - default: return "amctrl.?"; - } -} - -std::vector candidate_schemes() { - std::vector schemes; - for (const std::uint8_t header : kirk_slots_used()) - for (int mask = 0; mask <= kFixedKeysUsed; ++mask) - for (const bool after : {false, true}) - for (const std::uint8_t stream : kirk_slots_used()) - for (const bool encrypts : {false, true}) - for (const bool chained : {false, true}) - for (const std::uint32_t start : {0u, 1u}) - schemes.push_back(CipherScheme{header, mask, after, stream, encrypts, chained, start, false}); - return schemes; -} - -CipherScheme active_scheme() { - if (const char *text = std::getenv("PORTABLEKIT_PGD_SCHEME"); text != nullptr && *text != '\0') { - for (CipherScheme scheme : candidate_schemes()) { - for (const bool restart : {false, true}) { - scheme.block_counters_restart = restart; - if (scheme.describe() == text) return scheme; - } - } - } - // Not yet established against a real file: see the file comment. - return CipherScheme{0x39u, 0, false, 0x39u, false, true, 1u, false}; -} - -std::optional bb_cipher(const CipherScheme &scheme, const KeySource &keys, const Block &key, - const Block &vkey, std::uint32_t seed, std::span data) { - const Key16 *header_key = keys.kirk ? keys.kirk(scheme.header_slot) : nullptr; - if (header_key == nullptr) return slot_name(scheme.header_slot); - const Key16 *stream_key = keys.kirk ? keys.kirk(scheme.stream_slot) : nullptr; - if (stream_key == nullptr) return slot_name(scheme.stream_slot); - Block context = key; - if (scheme.header_mask != 0) { - const Key16 *mask = keys.fixed ? keys.fixed(scheme.header_mask) : nullptr; - if (mask == nullptr) return fixed_key_name(scheme.header_mask); - context = xor_block(context, *mask); - } - if (!scheme.vkey_after) context = xor_block(context, vkey); - Block prefix = Aes128(*header_key).decrypt(context); - if (scheme.vkey_after) prefix = xor_block(prefix, vkey); +std::optional bb_cipher(const KeySource &keys, const Block &key, const Block &vkey, + std::uint32_t position, std::span data) { + const Key16 *header_key = keys.kirk ? keys.kirk(0x39u) : nullptr; + if (header_key == nullptr) return slot_name(0x39u); + const Key16 *stream_key = keys.kirk ? keys.kirk(0x63u) : nullptr; + if (stream_key == nullptr) return slot_name(0x63u); + const Key16 *mask_in = keys.fixed ? keys.fixed(kMaskIn) : nullptr; + if (mask_in == nullptr) return std::string(fixed_key_name(kMaskIn)); + const Key16 *mask_out = keys.fixed ? keys.fixed(kMaskOut) : nullptr; + if (mask_out == nullptr) return std::string(fixed_key_name(kMaskOut)); + const Block prefix = xor_block(Aes128(*header_key).decrypt(xor_block(xor_block(key, vkey), *mask_in)), *mask_out); + const auto counter_block = [&prefix](std::uint32_t n) { + Block block = prefix; + put32(block, 12u, n); + return block; + }; const Aes128 stream(*stream_key); - Block previous{}; - for (std::size_t offset = 0, index = 0; offset < data.size(); offset += 16u, ++index) { - Block counter = prefix; - put32(counter, 12u, scheme.counter_start + seed + static_cast(index)); - Block key_stream = scheme.stream_encrypts ? stream.encrypt(counter) : stream.decrypt(counter); - if (scheme.chained) key_stream = xor_block(key_stream, previous); + Block previous = position == 0u ? Block{} : counter_block(position); + for (std::size_t offset = 0; offset < data.size(); offset += 16u) { + ++position; + const Block counter = counter_block(position); + const Block key_stream = xor_block(stream.decrypt(counter), previous); previous = counter; for (std::size_t i = 0; i < 16u && offset + i < data.size(); ++i) data[offset + i] ^= key_stream[i]; } return std::nullopt; } -std::optional bb_mac(const KeySource &keys, std::uint8_t slot, std::span data, - const Block &vkey) { - const Key16 *key = keys.kirk ? keys.kirk(slot) : nullptr; - if (key == nullptr) return std::nullopt; +std::optional header_mac(const KeySource &keys, std::span data, const Block &vkey, + std::string &missing) { + const Key16 *key = keys.kirk ? keys.kirk(0x38u) : nullptr; + if (key == nullptr) { + missing = slot_name(0x38u); + return std::nullopt; + } + const Key16 *mask = keys.fixed ? keys.fixed(kMacMask) : nullptr; + if (mask == nullptr) { + missing = fixed_key_name(kMacMask); + return std::nullopt; + } const Aes128 cipher(*key); - return cipher.encrypt(xor_block(savedata::cmac(cipher, data), vkey)); + return cipher.encrypt(xor_block(xor_block(savedata::cmac(cipher, data), vkey), *mask)); } bool desc_plausible(const Desc &desc, std::uint64_t file_size) { @@ -144,27 +123,31 @@ bool desc_plausible(const Desc &desc, std::uint64_t file_size) { } std::optional PgdFile::open(std::span header, const Block &vkey, std::uint64_t file_size, - const KeySource &keys, const CipherScheme &scheme, - std::optional mac_slot, std::string &error) { + const KeySource &keys, std::string &error) { if (header.size() < kHeaderSize || header[0] != 0u || header[1] != 'P' || header[2] != 'G' || header[3] != 'D') { error = "not a PGD file"; return std::nullopt; } - if (mac_slot) { - const auto mac = bb_mac(keys, *mac_slot, header.subspan(0, 0x80u), vkey); - if (!mac) { - error = "the keys file has no " + slot_name(*mac_slot); - return std::nullopt; - } - if (*mac != read_block(header, 0x80u)) { - error = "the header MAC does not match: a wrong key, or not this file's key"; - return std::nullopt; - } + const std::uint32_t key_index = le32(header, 0x04u), drm_type = le32(header, 0x08u); + if (key_index != kKeyIndex || drm_type != kDrmType) { + error = "a kind of PGD not supported yet (key index " + std::to_string(key_index) + ", DRM type " + + std::to_string(drm_type) + ")"; + return std::nullopt; + } + std::string missing; + const auto mac = header_mac(keys, header.subspan(0, kMacSpan), vkey, missing); + if (!mac) { + error = "the keys file has no " + missing; + return std::nullopt; + } + if (*mac != read_block(header, kMacSpan)) { + error = "the header MAC does not match: a wrong key, or not this file's key"; + return std::nullopt; } std::array desc_bytes{}; std::copy_n(header.begin() + 0x30, desc_bytes.size(), desc_bytes.begin()); - if (const auto missing = bb_cipher(scheme, keys, read_block(header, 0x10u), vkey, 0u, desc_bytes)) { - error = "the keys file has no " + *missing; + if (const auto absent = bb_cipher(keys, read_block(header, 0x10u), vkey, 0u, desc_bytes)) { + error = "the keys file has no " + *absent; return std::nullopt; } PgdFile file; @@ -174,12 +157,11 @@ std::optional PgdFile::open(std::span header, const file.desc_.block_size = le32(desc_bytes, 0x18u); file.desc_.data_offset = le32(desc_bytes, 0x1Cu); if (!desc_plausible(file.desc_, file_size)) { - error = "the header does not decrypt to a valid description: a wrong key, or not this file's key"; + error = "the header does not decrypt to a valid description"; return std::nullopt; } file.vkey_ = vkey; file.keys_ = keys; - file.scheme_ = scheme; return file; } @@ -193,8 +175,8 @@ std::size_t PgdFile::read(std::uint64_t offset, std::span out, con cache_.assign(block_size, 0u); const std::size_t got = read_raw(desc_.data_offset + block * block_size, cache_); if (got == 0u) break; - const auto seed = scheme_.block_counters_restart ? 0u : static_cast(block * block_size / 16u); - if (bb_cipher(scheme_, keys_, desc_.data_key, vkey_, seed, cache_)) break; + if (bb_cipher(keys_, desc_.data_key, vkey_, static_cast(block * block_size / 16u), cache_)) + break; cached_block_ = block; } const std::size_t within = static_cast(position % block_size); @@ -207,31 +189,27 @@ std::size_t PgdFile::read(std::uint64_t offset, std::span out, con } std::vector PgdFile::build(std::span plain, const Block &desc_key, - const Block &data_key, const Block &vkey, const KeySource &keys, - const CipherScheme &scheme, std::uint8_t mac_slot) { + const Block &data_key, const Block &vkey, const KeySource &keys) { constexpr std::uint32_t kBlock = 0x400u; const std::size_t blocks = (plain.size() + kBlock - 1u) / kBlock; std::vector file(kHeaderSize + blocks * kBlock, 0u); file[1] = 'P'; file[2] = 'G'; file[3] = 'D'; - put32(file, 0x04u, 1u); - put32(file, 0x08u, 1u); + put32(file, 0x04u, kKeyIndex); + put32(file, 0x08u, kDrmType); std::copy(desc_key.begin(), desc_key.end(), file.begin() + 0x10); std::span desc(file.data() + 0x30, 0x30); std::copy(data_key.begin(), data_key.end(), desc.begin()); put32(desc, 0x14u, static_cast(plain.size())); put32(desc, 0x18u, kBlock); put32(desc, 0x1Cu, kHeaderSize); - (void)bb_cipher(scheme, keys, desc_key, vkey, 0u, desc); + (void)bb_cipher(keys, desc_key, vkey, 0u, desc); std::copy(plain.begin(), plain.end(), file.begin() + kHeaderSize); - for (std::size_t b = 0; b < blocks; ++b) { - std::span block(file.data() + kHeaderSize + b * kBlock, kBlock); - const auto seed = scheme.block_counters_restart ? 0u : static_cast(b * kBlock / 16u); - (void)bb_cipher(scheme, keys, data_key, vkey, seed, block); - } - if (const auto mac = bb_mac(keys, mac_slot, std::span(file.data(), 0x80u), vkey)) - std::copy(mac->begin(), mac->end(), file.begin() + 0x80); + (void)bb_cipher(keys, data_key, vkey, 0u, std::span(file.data() + kHeaderSize, blocks * kBlock)); + std::string missing; + if (const auto mac = header_mac(keys, std::span(file.data(), kMacSpan), vkey, missing)) + std::copy(mac->begin(), mac->end(), file.begin() + kMacSpan); return file; } diff --git a/host/crypto/pgd.hpp b/host/crypto/pgd.hpp index f5bc163..1effe2f 100644 --- a/host/crypto/pgd.hpp +++ b/host/crypto/pgd.hpp @@ -14,17 +14,25 @@ // 0x30 desc (0x30 bytes, encrypted with descKey and the version key): // dataKey (16), version (0), dataSize, blockSize (0x400), // dataOffset (0x90), padding (16) -// 0x60 table MAC 0x70 header MAC (device key) 0x80 header MAC (fixed key) -// 0x90 data: blocks of blockSize, each encrypted with dataKey and the -// version key, with a block-number counter +// 0x60 table MAC 0x70 header MAC 0x80 header MAC (device key) +// 0x90 data: blocks of blockSize, encrypted with dataKey and the version key // -// The cipher is the console's "BB" cipher: a key stream of AES blocks under -// a KIRK key slot, from a prefix made by decrypting the header key; the MAC -// is a CMAC under a KIRK key slot. Which slots, masks and counter conventions -// a PSP uses are not given by the public descriptions, so they are -// parameters here (CipherScheme). The one the app uses is the one that turns -// a real PGD header's desc into its known values (version 0, block size -// 0x400, data offset 0x90); `portablekit keys pgd-check ` finds it. +// The "BB" cipher and MAC, as established against PSP2i's MEDIA.FPB (key +// index 1, DRM type 1) with `portablekit keys pgd-check`; `vkey` is the key +// the game passes to the ioctl: +// +// prefix = AES-decrypt[kirk.aes.39](key ^ vkey ^ amctrl.1CF4) ^ amctrl.1CE4 +// counter block n = prefix[0..11], then n as 32 bits little-endian +// 16 bytes at stream position n (from 0) are XORed with +// AES-decrypt[kirk.aes.63](counter block n + 1) ^ counter block n +// (counter block 0 counts as zero). The desc is the stream of descKey +// from position 0; the data is the stream of dataKey, position = its +// offset / 16, running on through the whole file. +// +// header MAC at 0x70 = AES-encrypt[kirk.aes.38](CMAC[kirk.aes.38](bytes +// 0x00-0x6F) ^ vkey ^ amctrl.1CD4) +// +// Other key indices and DRM types are refused: none has been checked. // // No key value appears here: every key comes from the player's keys file // (crypto_keys()), and a missing one is named. @@ -48,9 +56,8 @@ inline constexpr std::uint32_t kIoctlSetKey = 0x04100001u; inline constexpr std::uint32_t kOpenFlag = 0x40000000u; // Where the keys come from: KIRK AES key slots, and the DRM library's fixed -// keys, numbered here 1 to 5: amctrl.1CD4, amctrl.1CE4, amctrl.1CF4, -// amctrl.dnas.1A90 and amctrl.dnas.1AA0 in the keys file. Null when the -// player's file does not have it. +// keys, numbered here 1 to 3: amctrl.1CD4, amctrl.1CE4 and amctrl.1CF4 in the +// keys file. Null when the player's file does not have it. struct KeySource { std::function kirk; std::function fixed; @@ -59,42 +66,15 @@ struct KeySource { // The keys from crypto_keys(). [[nodiscard]] KeySource player_keys(); -// One way of running the BB cipher; see the file comment. -struct CipherScheme { - std::uint8_t header_slot{}; // KIRK slot that decrypts the header key into the stream prefix - int header_mask{}; // fixed key (1-5) XORed into the header key first (0: none) - bool vkey_after{}; // the version key XORed into the decrypted prefix, else into the header key - std::uint8_t stream_slot{}; // KIRK slot of the key stream - bool stream_encrypts{}; // key stream = AES-encrypt(counter block), else AES-decrypt - bool chained{}; // each key stream block is also XORed with the previous counter block - std::uint32_t counter_start{}; // counter of the first 16 bytes of a run - bool block_counters_restart{}; // each data block's counter starts afresh, else runs on through the file - - [[nodiscard]] std::string describe() const; -}; - -// The slots and masks every scheme may use: what `keys status` checks. -[[nodiscard]] std::vector kirk_slots_used(); -inline constexpr int kFixedKeysUsed = 5; -// The keys file's name of fixed key `index` (1-5). -[[nodiscard]] const char *fixed_key_name(int index); - -// Every scheme `pgd-check` tries. -[[nodiscard]] std::vector candidate_schemes(); - -// The scheme the app decrypts with: PORTABLEKIT_PGD_SCHEME (as `describe` -// prints it) or the built-in default. -[[nodiscard]] CipherScheme active_scheme(); - -// XORs `data` with the key stream of (key, vkey) starting at 16-byte -// counter `seed`. Its own inverse. Empty on success, else the missing key. -[[nodiscard]] std::optional bb_cipher(const CipherScheme &scheme, const KeySource &keys, const Block &key, - const Block &vkey, std::uint32_t seed, std::span data); +// XORs `data` with the key stream of (key, vkey) from 16-byte stream position +// `position`. Its own inverse. Empty on success, else the missing key's name. +[[nodiscard]] std::optional bb_cipher(const KeySource &keys, const Block &key, const Block &vkey, + std::uint32_t position, std::span data); -// The BB MAC of `data` under KIRK slot `slot`, finished with the version key: -// CMAC, XOR vkey, encrypt once more. -[[nodiscard]] std::optional bb_mac(const KeySource &keys, std::uint8_t slot, std::span data, - const Block &vkey); +// The header MAC of `data` (a header's first 0x70 bytes) with the version key. +// Empty with `missing` set when a key is missing. +[[nodiscard]] std::optional header_mac(const KeySource &keys, std::span data, + const Block &vkey, std::string &missing); struct Desc { Block data_key{}; @@ -111,33 +91,30 @@ struct Desc { class PgdFile { public: // Reads the header (the file's first 0x90 bytes) with the version key. - // `mac_slot` set: the header MAC at 0x80 must match (the MAC check is - // off until the slot is known). Empty with `error` set when the header - // is not PGD, a key is missing, or it does not decrypt to a valid desc. + // Empty with `error` set when the header is not PGD or of a kind not + // supported, a key is missing, the MAC does not match, or it does not + // decrypt to a valid desc. static std::optional open(std::span header, const Block &vkey, std::uint64_t file_size, - const KeySource &keys, const CipherScheme &scheme, - std::optional mac_slot, std::string &error); + const KeySource &keys, std::string &error); [[nodiscard]] const Desc &desc() const noexcept { return desc_; } [[nodiscard]] std::uint32_t size() const noexcept { return desc_.data_size; } - // Decrypts `count` bytes at plain offset `offset`, reading the encrypted - // file through `read_raw(offset, out)` (which returns the bytes read). + // Decrypts up to `out.size()` bytes at plain offset `offset`, reading the + // encrypted file through `read_raw(offset, out)` (which returns the bytes + // read). Returns how many, fewer at the end. using RawReader = std::function out)>; std::size_t read(std::uint64_t offset, std::span out, const RawReader &read_raw); // A PGD made here, for tests: `plain` encrypted with `data_key` and - // `vkey` under `scheme`, with a desc key and the MAC at 0x80 under - // `mac_slot`. + // `vkey`, with a desc key and the header MAC. static std::vector build(std::span plain, const Block &desc_key, - const Block &data_key, const Block &vkey, const KeySource &keys, - const CipherScheme &scheme, std::uint8_t mac_slot); + const Block &data_key, const Block &vkey, const KeySource &keys); private: Desc desc_; Block vkey_{}; KeySource keys_; - CipherScheme scheme_; std::uint64_t cached_block_{~0ull}; std::vector cache_; }; diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index a84602b..6f8bf2c 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -106,11 +106,6 @@ struct OpenFile { bool release_after_async{}; SceUID async_callback{}; std::uint32_t async_callback_argument{}; - // PGD (crypto/pgd.hpp): opened with flag 0x40000000, the file becomes a - // decrypted view once the game gives its key with ioctl 0x04100001; - // position and size then count decrypted bytes. - bool pgd_requested{}; - std::shared_ptr pgd; }; // A read-ahead the game asked the drive for: a range of sectors it wants in @@ -219,7 +214,6 @@ std::int64_t open_file(const std::string &full_path, std::uint32_t flags) { const SplitPath split = split_path(full_path); OpenFile file; file.path = full_path; - file.pgd_requested = (flags & pgd::kOpenFlag) != 0u; if (split.device == Device::Disc) { if (!io().disc) return static_cast(io_error::kDeviceNotFound); if ((flags & kOpenWrite) != 0u) return static_cast(io_error::kReadOnly); @@ -361,6 +355,25 @@ static std::uint32_t read_filtered(Runtime &rt, std::uint32_t fd, OpenFile &file return static_cast(buffer.size()); } +// A PGD file's decrypted data as a file filter, reading the encrypted bytes +// through read_open_file. +class PgdFilter final : public hle_extension::FileFilter { +public: + PgdFilter(std::uint32_t fd, pgd::PgdFile file) : fd_(fd), file_(std::move(file)) {} + [[nodiscard]] std::uint64_t size() const override { return file_.size(); } + std::size_t read(std::uint64_t offset, std::uint8_t *destination, std::size_t length) override { + const std::uint32_t fd = fd_; + return file_.read(offset, std::span(destination, length), + [fd](std::uint64_t at, std::span out) { + return read_open_file(fd, at, out.data(), out.size()); + }); + } + +private: + std::uint32_t fd_; + pgd::PgdFile file_; +}; + std::int64_t read_guest_file(const std::string &path, std::uint64_t offset, std::uint8_t *output, std::size_t size) { const std::int64_t fd = open_file(path, kOpenRead); if (fd < 0) return fd; @@ -373,21 +386,6 @@ namespace { // The work of sceIoRead, sceIoWrite and sceIoLseek, shared with their // asynchronous forms. -// The file's own bytes at `offset`, before any PGD decryption. -std::size_t read_raw(OpenFile &file, std::uint64_t offset, std::span out) { - if (file.kind == OpenFile::Kind::Disc) { - if (!io().disc) return 0u; - return io().disc->read(file.disc_offset + offset, out); - } - if (file.kind != OpenFile::Kind::Host) return 0u; - file.host->clear(); - file.host->seekg(static_cast(offset)); - file.host->read(reinterpret_cast(out.data()), static_cast(out.size())); - const auto count = static_cast(file.host->gcount()); - file.host->clear(); - return count; -} - std::uint32_t read_file_op(Runtime &rt, std::uint32_t fd, std::uint32_t address, std::uint32_t requested) { auto found = io().files.find(fd); if (found == io().files.end() || @@ -397,16 +395,7 @@ std::uint32_t read_file_op(Runtime &rt, std::uint32_t fd, std::uint32_t address, if (file.filter) return read_filtered(rt, fd, file, address, requested); std::vector buffer; std::uint32_t result = 0u; - if (file.pgd) { - const std::uint64_t available = file.position < file.size ? file.size - file.position : 0u; - buffer.resize(static_cast(std::min(requested, available))); - OpenFile *raw_file = &file; - const std::size_t count = file.pgd->read(file.position, buffer, [raw_file](std::uint64_t offset, std::span out) { - return read_raw(*raw_file, offset, out); - }); - buffer.resize(count); - result = static_cast(count); - } else if (file.kind == OpenFile::Kind::Disc) { + if (file.kind == OpenFile::Kind::Disc) { const std::uint64_t available = file.position < file.size ? file.size - file.position : 0u; requested = static_cast(std::min(requested, available)); const std::uint64_t unit = file.sector_units ? IsoImage::kSectorSize : 1u; @@ -547,9 +536,10 @@ std::uint32_t ioctl_op(Runtime &rt, AllegrexContext &ctx) { if (found == io().files.end()) return io_error::kBadFileDescriptor; OpenFile &file = found->second; // PGD: the key of a file opened with flag 0x40000000. The header is read - // and checked; from here on reads and seeks see the decrypted data. - // Phantasy Star Portable 2 Infinity opens INSDIR/MEDIA.FPB this way. - if (command == pgd::kIoctlSetKey && input != 0u && input_size >= 16u && + // and checked; from here on reads and seeks see the decrypted data, through + // a file filter (hle_extension::set_file_filter). Phantasy Star Portable 2 + // Infinity opens INSDIR/MEDIA.FPB this way. + if (command == pgd::kIoctlSetKey && input != 0u && input_size >= 16u && !file.sector_units && (file.kind == OpenFile::Kind::Disc || file.kind == OpenFile::Kind::Host)) { // What a PSP answers for a PGD file it cannot use has not been // traced: the I/O error. @@ -557,19 +547,17 @@ std::uint32_t ioctl_op(Runtime &rt, AllegrexContext &ctx) { pgd::Block vkey{}; for (std::uint32_t i = 0; i < 16u; ++i) vkey[i] = memory.load8(input + i); std::vector header(pgd::kHeaderSize); - header.resize(read_raw(file, 0u, header)); + header.resize(read_open_file(fd, 0u, header.data(), header.size())); + const std::uint64_t raw_size = file.filter ? file.unfiltered_size : file.size; std::string error; - auto opened = pgd::PgdFile::open(header, vkey, file.size, pgd::player_keys(), pgd::active_scheme(), std::nullopt, error); + auto opened = pgd::PgdFile::open(header, vkey, raw_size, pgd::player_keys(), error); if (!opened) { log_once("pgd-" + file.path, "[pgd] " + file.path + ": cannot decrypt: " + error + - " (see docs/DESKTOP_APP.md, \"Keys\", for the PGD keys)"); + " (see docs/DESKTOP_APP.md, \"PGD data\")"); return kPgdUnusable; } - if (trace_io()) - std::cerr << "[io] pgd " << file.path << " size " << opened->size() << " scheme " - << pgd::active_scheme().describe() << "\n"; - file.pgd = std::make_shared(std::move(*opened)); - file.size = file.pgd->size(); + if (trace_io()) std::cerr << "[io] pgd " << file.path << " size " << opened->size() << "\n"; + (void)set_open_file_filter(fd, std::make_shared(fd, std::move(*opened))); file.position = 0u; return 0u; } diff --git a/tests/pgd_tests.cpp b/tests/pgd_tests.cpp index 6a9cca1..ed208f3 100644 --- a/tests/pgd_tests.cpp +++ b/tests/pgd_tests.cpp @@ -1,6 +1,7 @@ // PGD containers (crypto/pgd.hpp) with dummy keys made up here: files built // by the implementation itself decrypt back, random access into them works, -// and a wrong MAC, a wrong version key and a missing key are refused. No +// and a wrong MAC, a wrong version key, another kind of PGD and a missing +// key are refused. No // console key and no game data. #include "crypto/pgd.hpp" @@ -29,8 +30,8 @@ portablekit::Key16 dummy(std::uint8_t seed) { int main() { using namespace portablekit::pgd; - static std::map kirk{{0x38, dummy(1)}, {0x39, dummy(2)}, {0x3A, dummy(3)}, {0x63, dummy(4)}}; - static std::map fixed{{1, dummy(5)}, {2, dummy(6)}, {3, dummy(7)}, {4, dummy(8)}, {5, dummy(9)}}; + static std::map kirk{{0x38, dummy(1)}, {0x39, dummy(2)}, {0x63, dummy(4)}}; + static std::map fixed{{1, dummy(5)}, {2, dummy(6)}, {3, dummy(7)}}; const KeySource keys{[](std::uint8_t slot) -> const portablekit::Key16 * { const auto it = kirk.find(slot); return it != kirk.end() ? &it->second : nullptr; @@ -44,21 +45,18 @@ int main() { for (std::size_t i = 0; i < plain.size(); ++i) plain[i] = static_cast(i * 13u + (i >> 8u)); const Block desc_key = dummy(20), data_key = dummy(21), vkey = dummy(22); - for (const bool restart : {false, true}) { - CipherScheme scheme = active_scheme(); - scheme.block_counters_restart = restart; - const auto file = PgdFile::build(plain, desc_key, data_key, vkey, keys, scheme, 0x38); - check(file.size() == 0x90u + 3u * 0x400u, "three 0x400-byte blocks after the 0x90-byte header"); - const auto raw_reader = [&file](std::uint64_t offset, std::span out) -> std::size_t { - if (offset >= file.size()) return 0u; - const std::size_t n = std::min(out.size(), file.size() - offset); - std::copy_n(file.begin() + static_cast(offset), n, out.begin()); - return n; - }; - std::string error; - auto opened = PgdFile::open(std::span(file.data(), 0x90), vkey, file.size(), keys, scheme, 0x38, error); - check(opened.has_value(), "a PGD built here opens with its version key and a matching MAC"); - if (!opened) continue; + const auto file = PgdFile::build(plain, desc_key, data_key, vkey, keys); + check(file.size() == 0x90u + 3u * 0x400u, "three 0x400-byte blocks after the 0x90-byte header"); + const auto raw_reader = [&file](std::uint64_t offset, std::span out) -> std::size_t { + if (offset >= file.size()) return 0u; + const std::size_t n = std::min(out.size(), file.size() - offset); + std::copy_n(file.begin() + static_cast(offset), n, out.begin()); + return n; + }; + std::string error; + auto opened = PgdFile::open(std::span(file.data(), 0x90), vkey, file.size(), keys, error); + check(opened.has_value(), "a PGD built here opens with its version key and a matching MAC"); + if (opened) { check(opened->size() == plain.size() && opened->desc().block_size == 0x400u && opened->desc().data_offset == 0x90u, "its description decrypts to the size, block size and data offset"); std::vector all(plain.size()); @@ -67,34 +65,54 @@ int main() { const std::size_t got = opened->read(1000, middle, raw_reader); check(got == 1500u && std::equal(middle.begin(), middle.end(), plain.begin() + 1000), "a read across a block boundary at a random offset decrypts back"); + std::vector late(40); + check(opened->read(2100, late, raw_reader) == 40u && std::equal(late.begin(), late.end(), plain.begin() + 2100), + "the third block alone decrypts back (the stream runs on through the file)"); std::vector tail(100); check(opened->read(2950, tail, raw_reader) == 50u, "a read past the end stops at the data size"); - - auto tampered = file; - tampered[0x44] ^= 1u; - check(!PgdFile::open(std::span(tampered.data(), 0x90), vkey, tampered.size(), keys, scheme, 0x38, error) && - error.find("MAC") != std::string::npos, - "a changed header fails the MAC check"); - check(!PgdFile::open(std::span(file.data(), 0x90), dummy(99), file.size(), keys, scheme, std::nullopt, error) && - error.find("valid description") != std::string::npos, - "another version key does not give a valid description"); } - std::map none; + // The key stream is its own inverse and continuous: two runs of 16 bytes + // from positions 0 and 1 are the same as one run of 32 from 0. + std::vector one(32, 0u), two(32, 0u); + (void)bb_cipher(keys, data_key, vkey, 0u, one); + (void)bb_cipher(keys, data_key, vkey, 0u, std::span(two.data(), 16)); + (void)bb_cipher(keys, data_key, vkey, 1u, std::span(two.data() + 16, 16)); + check(one == two, "the key stream from position 1 continues the one from 0"); + + auto tampered = file; + tampered[0x44] ^= 1u; + check(!PgdFile::open(std::span(tampered.data(), 0x90), vkey, tampered.size(), keys, error) && + error.find("MAC") != std::string::npos, + "a changed header fails the MAC check"); + auto bad_mac = file; + bad_mac[0x7F] ^= 0x80u; + check(!PgdFile::open(std::span(bad_mac.data(), 0x90), vkey, bad_mac.size(), keys, error) && + error.find("MAC") != std::string::npos, + "a changed MAC is refused"); + check(!PgdFile::open(std::span(file.data(), 0x90), dummy(99), file.size(), keys, error) && + error.find("MAC") != std::string::npos, + "another version key fails the MAC check"); + auto other_kind = file; + other_kind[0x08] = 2u; + check(!PgdFile::open(std::span(other_kind.data(), 0x90), vkey, other_kind.size(), keys, error) && + error.find("not supported") != std::string::npos, + "another DRM type is refused as not supported"); + const KeySource missing{[](std::uint8_t) -> const portablekit::Key16 * { return nullptr; }, [](int) -> const portablekit::Key16 * { return nullptr; }}; std::vector header(0x90, 0u); header[1] = 'P'; header[2] = 'G'; header[3] = 'D'; - std::string error; - check(!PgdFile::open(header, vkey, 0x10000, missing, active_scheme(), std::nullopt, error) && + header[4] = 1u; + header[8] = 1u; + check(!PgdFile::open(header, vkey, 0x10000, missing, error) && error.find("kirk.aes.") != std::string::npos, "a missing key is named"); header[1] = 'X'; - check(!PgdFile::open(header, vkey, 0x10000, keys, active_scheme(), std::nullopt, error) && error == "not a PGD file", + check(!PgdFile::open(header, vkey, 0x10000, keys, error) && error == "not a PGD file", "a file without the magic is not PGD"); - check(candidate_schemes().size() == 1536u, "pgd-check tries 1536 schemes"); if (g_failures != 0) std::printf("%d failed\n", g_failures); return g_failures == 0 ? 0 : 1;