diff --git a/CMakeLists.txt b/CMakeLists.txt index e8fe6b8..2e4ec44 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -293,6 +293,22 @@ if(PSPRECOMP_BUILD_TESTS) add_test(NAME portablekit_rtc_tests COMMAND portablekit_rtc_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 7131aa2..67f458d 100644 --- a/apps/portablekit/src/app_main.cpp +++ b/apps/portablekit/src/app_main.cpp @@ -32,6 +32,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" @@ -40,12 +42,14 @@ #include "psprecomp/interpreter.hpp" +#include #include #include #include #include #include #include +#include #include #include #include @@ -428,7 +432,45 @@ 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 : 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 "); + 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); + 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; + } + 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(); if (g_json) { std::vector problems; @@ -441,6 +483,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).array("warnings", warnings) .array("extension_keys", declared).str() << "\n"; @@ -449,6 +492,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"; for (const std::string &warning : report.warnings) std::cout << " warning: " << warning << "\n"; diff --git a/apps/portablekit/src/keys_file.cpp b/apps/portablekit/src/keys_file.cpp index 4f48340..2e62e69 100644 --- a/apps/portablekit/src/keys_file.cpp +++ b/apps/portablekit/src/keys_file.cpp @@ -38,8 +38,33 @@ 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), 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; + 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.63" || + amctrl_index(name) != 0; +} + const char *expected_fingerprint(const std::string &name) { for (const Fingerprint &entry : kFingerprints) if (lower(name) == lower(entry.name)) return entry.sha256; @@ -154,11 +179,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; } @@ -169,6 +194,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 (const int index = amctrl_index(name); index != 0) { + report.keys.amctrl[index] = to_key(*bytes); } } sort_out_unused_names(report, declared_found); @@ -180,6 +207,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, 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; } @@ -202,7 +234,7 @@ bool import_keys_file(const std::filesystem::path &source, KeysReport &report) { if (!report.problems.empty()) return false; if (!report.keys.named.empty()) return copy_to_home(source, report); // keys only modules use 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; } @@ -225,7 +257,7 @@ const CryptoKeys *crypto_keys() { const app::KeysReport &report = app::active_keys(); if (!report.keys.named.empty()) return &report.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 c1d5a26..39c1378 100644 --- a/apps/portablekit/src/keys_file.hpp +++ b/apps/portablekit/src/keys_file.hpp @@ -11,6 +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.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) // // HLE extension modules may declare more names (Registry::declare_key), each // with its length and optionally a fingerprint; they are read and checked the @@ -48,6 +52,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/63 and amctrl.1CD4/1CE4/1CF4 std::size_t tag_count{}; }; diff --git a/cmake/PortableKit.cmake b/cmake/PortableKit.cmake index fc0c01f..4f87ea4 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -78,6 +78,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 e1e9e14..565da7b 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.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) 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. @@ -174,6 +176,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; 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`. + ## 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..038b815 --- /dev/null +++ b/host/crypto/pgd.cpp @@ -0,0 +1,216 @@ +#include "crypto/pgd.hpp" + +#include "save_data/aes128.hpp" +#include "save_data/savedata_crypto.hpp" + +#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; +} + +// 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() { + 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::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 = 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 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(xor_block(savedata::cmac(cipher, data), vkey), *mask)); +} + +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, 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; + } + 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 absent = bb_cipher(keys, read_block(header, 0x10u), vkey, 0u, desc_bytes)) { + error = "the keys file has no " + *absent; + 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"; + return std::nullopt; + } + file.vkey_ = vkey; + file.keys_ = keys; + 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; + 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); + 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) { + 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, 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(keys, desc_key, vkey, 0u, desc); + std::copy(plain.begin(), plain.end(), file.begin() + kHeaderSize); + (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; +} + +} // namespace portablekit::pgd diff --git a/host/crypto/pgd.hpp b/host/crypto/pgd.hpp new file mode 100644 index 0000000..1effe2f --- /dev/null +++ b/host/crypto/pgd.hpp @@ -0,0 +1,122 @@ +#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 0x80 header MAC (device key) +// 0x90 data: blocks of blockSize, encrypted with dataKey and the version key +// +// 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. + +#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 DRM library's fixed +// 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; +}; + +// The keys from crypto_keys(). +[[nodiscard]] KeySource player_keys(); + +// 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 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{}; + 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. + // 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, std::string &error); + + [[nodiscard]] const Desc &desc() const noexcept { return desc_; } + [[nodiscard]] std::uint32_t size() const noexcept { return desc_.data_size; } + + // 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`, 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); + +private: + Desc desc_; + Block vkey_{}; + KeySource keys_; + 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 d69bbe7..83d4eb8 100644 --- a/host/crypto_keys.hpp +++ b/host/crypto_keys.hpp @@ -43,6 +43,10 @@ struct CryptoKeys { std::vector table; }; std::map tags; + // 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 { const auto it = kirk_aes.find(slot); diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index c314d90..63173f8 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" @@ -367,6 +369,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; @@ -527,7 +548,33 @@ 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, 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. + 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_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, raw_size, pgd::player_keys(), error); + if (!opened) { + log_once("pgd-" + file.path, "[pgd] " + file.path + ": cannot decrypt: " + error + + " (see docs/DESKTOP_APP.md, \"PGD data\")"); + return kPgdUnusable; + } + 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; + } 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..ed208f3 --- /dev/null +++ b/tests/pgd_tests.cpp @@ -0,0 +1,119 @@ +// 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, another kind of PGD 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)}, {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); + + 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()); + 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 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"); + } + + // 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'; + 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, error) && error == "not a PGD file", + "a file without the magic is not PGD"); + + if (g_failures != 0) std::printf("%d failed\n", g_failures); + return g_failures == 0 ? 0 : 1; +}