implement save file decryption/encryption

This commit is contained in:
Martin Michelsen
2023-03-31 23:56:25 -07:00
parent 06ba95ed97
commit 3b9a76eec8
39 changed files with 666 additions and 205 deletions
+32 -134
View File
@@ -12,6 +12,7 @@
#include <phosg/Tools.hh>
#include "Loggers.hh"
#include "SaveFileFormats.hh"
#include "CommandFormats.hh"
#include "Compression.hh"
#include "PSOEncryption.hh"
@@ -23,81 +24,10 @@ using namespace std;
// GCI decoding logic
struct ShuffleTables {
uint8_t forward_table[0x100];
uint8_t reverse_table[0x100];
template <bool IsBigEndian>
struct PSOMemCardDLQFileEncryptedHeader {
using U32T = typename std::conditional<IsBigEndian, be_uint32_t, le_uint32_t>::type;
ShuffleTables(PSOV2Encryption& crypt) {
for (size_t x = 0; x < 0x100; x++) {
this->forward_table[x] = x;
}
int32_t r28 = 0xFF;
uint8_t* r31 = &this->forward_table[0xFF];
while (r28 >= 0) {
uint32_t r3 = this->pseudorand(crypt, r28 + 1);
if (r3 >= 0x100) {
throw logic_error("bad r3");
}
uint8_t t = this->forward_table[r3];
this->forward_table[r3] = *r31;
*r31 = t;
this->reverse_table[t] = r28;
r31--;
r28--;
}
}
static uint32_t pseudorand(PSOV2Encryption& crypt, uint32_t prev) {
return (((prev & 0xFFFF) * ((crypt.next() >> 16) & 0xFFFF)) >> 16) & 0xFFFF;
}
void shuffle(void* vdest, const void* vsrc, size_t size, bool reverse) {
uint8_t* dest = reinterpret_cast<uint8_t*>(vdest);
const uint8_t* src = reinterpret_cast<const uint8_t*>(vsrc);
const uint8_t* table = reverse ? this->reverse_table : this->forward_table;
for (size_t block_offset = 0; block_offset < (size & 0xFFFFFF00); block_offset += 0x100) {
for (size_t z = 0; z < 0x100; z++) {
dest[block_offset + table[z]] = src[block_offset + z];
}
}
// Any remaining bytes that don't fill an entire block are not shuffled
memcpy(&dest[size & 0xFFFFFF00], &src[size & 0xFFFFFF00], size & 0xFF);
}
};
struct PSOGCIFileHeader {
parray<char, 4> game_id; // 'GPOE', 'GPSP', etc.
parray<char, 2> developer_id; // '8P' for Sega
parray<uint8_t, 0x3A> remaining_gci_header; // There is a structure for this but we don't use it
ptext<char, 0x1C> game_name; // e.g. "PSO EPISODE I & II" or "PSO EPISODE III"
be_uint32_t embedded_seed; // Used in some of Ralf's quest packs
ptext<char, 0x20> quest_name;
parray<uint8_t, 0x2000> banner_and_icon;
// data_size specifies the number of bytes in the encrypted section, including
// the encrypted header (below) and all encrypted data after it.
be_uint32_t data_size;
// To compute checksum, set checksum to zero, then compute the CRC32 of all
// fields in this struct starting with game_name. (Yes, including the checksum
// field, which is temporarily zero.)
be_uint32_t checksum;
bool checksum_correct() const {
uint32_t cs = crc32(&this->game_name, sizeof(this->game_name));
cs = crc32(&this->embedded_seed, sizeof(this->embedded_seed), cs);
cs = crc32(&this->quest_name, sizeof(this->quest_name), cs);
cs = crc32(&this->banner_and_icon, sizeof(this->banner_and_icon), cs);
cs = crc32(&this->data_size, sizeof(this->data_size), cs);
cs = crc32("\0\0\0\0", 4, cs);
return (cs == this->checksum);
}
} __attribute__((packed));
template <typename U32T>
struct PSOMemCardFileEncryptedHeader {
U32T round2_seed;
// To compute checksum, set checksum to zero, then compute the CRC32 of the
// entire data section, including this header struct (but not the unencrypted
@@ -108,50 +38,27 @@ struct PSOMemCardFileEncryptedHeader {
// Data follows here.
} __attribute__((packed));
struct PSOVMSFileEncryptedHeader : PSOMemCardFileEncryptedHeader<le_uint32_t> { } __attribute__((packed));
struct PSOGCIFileEncryptedHeader : PSOMemCardFileEncryptedHeader<be_uint32_t> { } __attribute__((packed));
struct PSOVMSDLQFileEncryptedHeader : PSOMemCardDLQFileEncryptedHeader<false> { } __attribute__((packed));
struct PSOGCIDLQFileEncryptedHeader : PSOMemCardDLQFileEncryptedHeader<true> { } __attribute__((packed));
template <bool IsBigEndian>
string decrypt_gci_or_vms_v2_data_section(
string decrypt_gci_or_vms_v2_download_quest_data_section(
const void* data_section, size_t size, uint32_t seed) {
string decrypted(size, '\0');
{
PSOV2Encryption shuf_crypt(seed);
ShuffleTables shuf(shuf_crypt);
shuf.shuffle(decrypted.data(), data_section, size, true);
}
string decrypted = decrypt_gci_or_vms_v2_data_section<IsBigEndian>(
data_section, size, seed);
size_t orig_size = decrypted.size();
decrypted.resize((decrypted.size() + 3) & (~3));
PSOV2Encryption crypt(seed);
if (IsBigEndian) {
auto* be_dwords = reinterpret_cast<be_uint32_t*>(decrypted.data());
for (size_t z = 0; z < decrypted.size() / sizeof(be_uint32_t); z++) {
be_dwords[z] = crypt.next() - be_dwords[z];
}
} else {
auto* le_dwords = reinterpret_cast<le_uint32_t*>(decrypted.data());
for (size_t z = 0; z < decrypted.size() / sizeof(le_uint32_t); z++) {
le_dwords[z] = crypt.next() - le_dwords[z];
}
}
// Note: Other PSO save files have the round 2 seed at the end of the data,
// not at the beginning. Presumably they did this because the system,
// character, and Guild Card files are a constant size, but download quest
// files can vary in size.
using HeaderT = typename conditional<IsBigEndian, PSOMemCardFileEncryptedHeader<be_uint32_t>, PSOMemCardFileEncryptedHeader<le_uint32_t>>::type;
using HeaderT = PSOMemCardDLQFileEncryptedHeader<IsBigEndian>;
auto* header = reinterpret_cast<HeaderT*>(decrypted.data());
PSOV2Encryption round2_crypt(header->round2_seed);
if (IsBigEndian) {
round2_crypt.encrypt_big_endian(
decrypted.data() + 4, (decrypted.size() - 4));
} else {
round2_crypt.decrypt(
decrypted.data() + 4, (decrypted.size() - 4));
}
round2_crypt.encrypt_t<IsBigEndian>(
decrypted.data() + 4, (decrypted.size() - 4));
if (header->decompressed_size & 0xFFF00000) {
throw runtime_error(string_printf(
@@ -168,14 +75,17 @@ string decrypt_gci_or_vms_v2_data_section(
expected_crc, actual_crc));
}
// Unlike the above rounds, round 3 is always little-endian (it corresponds to
// the round of encryption done on the server before sending the file to the
// client in the first place)
PSOV2Encryption(header->round3_seed).decrypt(
decrypted.data() + sizeof(HeaderT),
decrypted.size() - sizeof(HeaderT));
decrypted.resize(orig_size);
// Some GCI files have decompressed_size fields that are 8 bytes smaller than
// the actual decompressed size of the data. They seem to work fine, so we
// accept both cases as correct.
// Some download quest GCI files have decompressed_size fields that are 8
// bytes smaller than the actual decompressed size of the data. They seem to
// work fine, so we accept both cases as correct.
size_t decompressed_size = prs_decompress_size(
decrypted.data() + sizeof(HeaderT),
decrypted.size() - sizeof(HeaderT));
@@ -212,13 +122,13 @@ string decrypt_vms_v1_data_section(const void* data_section, size_t size) {
}
template <bool IsBigEndian>
string find_seed_and_decrypt_gci_or_vms_v2_data_section(
string find_seed_and_decrypt_gci_or_vms_v2_download_quest_data_section(
const void* data_section, size_t size, size_t num_threads) {
mutex result_lock;
string result;
uint64_t result_seed = parallel_range<uint64_t>([&](uint64_t seed, size_t) {
try {
string ret = decrypt_gci_or_vms_v2_data_section<IsBigEndian>(
string ret = decrypt_gci_or_vms_v2_download_quest_data_section<IsBigEndian>(
data_section, size, seed);
lock_guard<mutex> g(result_lock);
result = move(ret);
@@ -703,32 +613,20 @@ string Quest::decode_gci(
StringReader r(data);
const auto& header = r.get<PSOGCIFileHeader>();
if (!header.checksum_correct()) {
throw runtime_error("GCI file unencrypted header checksum is incorrect");
}
header.check();
if (header.developer_id[0] != '8' || header.developer_id[1] != 'P') {
throw runtime_error("GCI file is not for a Sega game");
}
if (header.game_id[0] != 'G') {
throw runtime_error("GCI file is not for a GameCube game");
}
if (header.game_id[1] != 'P') {
throw runtime_error("GCI file is not for Phantasy Star Online");
}
if (header.game_id[2] == 'O') { // Episodes 1&2 (GPO*)
const auto& encrypted_header = r.get<PSOGCIFileEncryptedHeader>(false);
if (header.is_ep12()) {
const auto& dlq_header = r.get<PSOGCIDLQFileEncryptedHeader>(false);
// Unencrypted GCI files appear to always have zeroes in these fields.
// Encrypted GCI files are highly unlikely to have zeroes in ALL of these
// fields, so assume it's encrypted if any of them are nonzero.
if (encrypted_header.round2_seed || encrypted_header.checksum || encrypted_header.round3_seed) {
if (dlq_header.round2_seed || dlq_header.checksum || dlq_header.round3_seed) {
if (known_seed >= 0) {
return decrypt_gci_or_vms_v2_data_section<true>(
return decrypt_gci_or_vms_v2_download_quest_data_section<true>(
r.getv(header.data_size), header.data_size, known_seed);
} else if (header.embedded_seed != 0) {
return decrypt_gci_or_vms_v2_data_section<true>(
return decrypt_gci_or_vms_v2_download_quest_data_section<true>(
r.getv(header.data_size), header.data_size, header.embedded_seed);
} else {
@@ -738,16 +636,16 @@ string Quest::decode_gci(
if (find_seed_num_threads == 0) {
find_seed_num_threads = thread::hardware_concurrency();
}
return find_seed_and_decrypt_gci_or_vms_v2_data_section<true>(
return find_seed_and_decrypt_gci_or_vms_v2_download_quest_data_section<true>(
r.getv(header.data_size), header.data_size, find_seed_num_threads);
}
} else { // Unencrypted GCI format
r.skip(sizeof(PSOGCIFileEncryptedHeader));
string compressed_data = r.readx(header.data_size - sizeof(PSOGCIFileEncryptedHeader));
r.skip(sizeof(PSOGCIDLQFileEncryptedHeader));
string compressed_data = r.readx(header.data_size - sizeof(PSOGCIDLQFileEncryptedHeader));
size_t decompressed_bytes = prs_decompress_size(compressed_data);
size_t expected_decompressed_bytes = encrypted_header.decompressed_size - 8;
size_t expected_decompressed_bytes = dlq_header.decompressed_size - 8;
if (decompressed_bytes < expected_decompressed_bytes) {
throw runtime_error(string_printf(
"GCI decompressed data is smaller than expected size (have 0x%zX bytes, expected 0x%zX bytes)",
@@ -810,7 +708,7 @@ string Quest::decode_vms(
} catch (const exception& e) { }
if (known_seed >= 0) {
return decrypt_gci_or_vms_v2_data_section<false>(
return decrypt_gci_or_vms_v2_download_quest_data_section<false>(
data_section, header.data_size, known_seed);
} else {
@@ -820,7 +718,7 @@ string Quest::decode_vms(
if (find_seed_num_threads == 0) {
find_seed_num_threads = thread::hardware_concurrency();
}
return find_seed_and_decrypt_gci_or_vms_v2_data_section<false>(
return find_seed_and_decrypt_gci_or_vms_v2_download_quest_data_section<false>(
data_section, header.data_size, find_seed_num_threads);
}
}