Cemu/src/Cafe/Filesystem/FST/FST.cpp
Exzap db53f3b980 Fixes for titles in NUS format
Symlinks were not handled correctly
2023-10-02 21:24:50 +02:00

1118 lines
No EOL
38 KiB
C++

#include "Common/precompiled.h"
#include "Common/FileStream.h"
#include "Cemu/ncrypto/ncrypto.h"
#include "Cafe/Filesystem/WUD/wud.h"
#include "util/crypto/aes128.h"
#include "openssl/evp.h" /* EVP_Digest */
#include "openssl/sha.h" /* SHA1 / SHA256_DIGEST_LENGTH */
#include "fstUtil.h"
#include "FST.h"
#include "KeyCache.h"
#include "boost/range/adaptor/reversed.hpp"
#define SET_FST_ERROR(__code) if (errorCodeOut) *errorCodeOut = ErrorCode::__code
class FSTDataSource
{
public:
virtual uint64 readData(uint16 clusterIndex, uint64 clusterOffset, uint64 offset, void* data, uint64 size) = 0;
virtual ~FSTDataSource() {};
protected:
FSTDataSource() {};
bool m_isOpen;
};
class FSTDataSourceWUD : public FSTDataSource
{
public:
static FSTDataSourceWUD* Open(const fs::path& path)
{
wud_t* wudFile = wud_open(path);
if (!wudFile)
return nullptr;
FSTDataSourceWUD* ds = new FSTDataSourceWUD();
ds->m_wudFile = wudFile;
return ds;
}
void SetBaseOffset(uint64 baseOffset)
{
m_baseOffset = baseOffset;
}
uint64 GetBaseOffset() const
{
return m_baseOffset;
}
uint64 readData(uint16 clusterIndex, uint64 clusterOffset, uint64 offset, void* data, uint64 size) override
{
cemu_assert_debug(size <= 0xFFFFFFFF);
return wud_readData(m_wudFile, data, (uint32)size, clusterOffset + offset + m_baseOffset);
}
~FSTDataSourceWUD() override
{
if(m_wudFile)
wud_close(m_wudFile);
}
protected:
FSTDataSourceWUD() {}
wud_t* m_wudFile;
uint64 m_baseOffset{};
std::vector<uint64> m_clusterOffset;
};
class FSTDataSourceApp : public FSTDataSource
{
public:
static FSTDataSourceApp* Open(fs::path path, NCrypto::TMDParser& tmd)
{
std::vector<std::unique_ptr<FileStream>> clusterFile;
uint32 maxIndex = 0;
for (auto& itr : tmd.GetContentList())
maxIndex = std::max(maxIndex, (uint32)itr.index);
clusterFile.resize(maxIndex + 1);
// open all the app files
for (auto& itr : tmd.GetContentList())
{
FileStream* appFile = FileStream::openFile2(path / fmt::format("{:08x}.app", itr.contentId));
if (!appFile)
return nullptr;
clusterFile[itr.index].reset(appFile);
}
// construct FSTDataSourceApp
FSTDataSourceApp* dsApp = new FSTDataSourceApp(std::move(clusterFile));
return dsApp;
}
uint64 readData(uint16 clusterIndex, uint64 clusterOffset, uint64 offset, void* data, uint64 size) override
{
// ignore clusterOffset for .app files since each file is already relative to the cluster base
cemu_assert_debug(clusterIndex < m_clusterFile.size());
cemu_assert_debug(m_clusterFile[clusterIndex].get());
cemu_assert_debug(size <= 0xFFFFFFFF);
if (!m_clusterFile[clusterIndex].get())
return 0;
m_clusterFile[clusterIndex].get()->SetPosition(offset);
return m_clusterFile[clusterIndex].get()->readData(data, (uint32)size);
}
~FSTDataSourceApp() override
{
}
private:
FSTDataSourceApp(std::vector<std::unique_ptr<FileStream>>&& clusterFiles)
{
m_clusterFile = std::move(clusterFiles);
}
std::vector<std::unique_ptr<FileStream>> m_clusterFile;
};
constexpr size_t DISC_SECTOR_SIZE = 0x8000;
struct DiscHeaderA
{
// header in first sector (0x0)
uint8 productCode[22]; // ?
};
struct DiscHeaderB
{
// header at 0x10000
static constexpr uint32 MAGIC_VALUE = 0xCC549EB9;
/* +0x00 */ uint32be magic;
};
static_assert(sizeof(DiscHeaderB) == 0x04);
struct DiscPartitionTableHeader
{
// header at 0x18000, encrypted
static constexpr uint32 MAGIC_VALUE = 0xCCA6E67B;
/* +0x00 */ uint32be magic;
/* +0x04 */ uint32be sectorSize; // must be 0x8000?
/* +0x08 */ uint8 partitionTableHash[20]; // hash of the data range at +0x800 to end of sector (0x8000)
/* +0x1C */ uint32be numPartitions;
};
static_assert(sizeof(DiscPartitionTableHeader) == 0x20);
struct DiscPartitionTableEntry
{
/* +0x00 */ uint8be partitionName[31];
/* +0x1F */ uint8be numAddresses; // ?
/* +0x20 */ uint32be partitionAddress; // this is an array?
/* +0x24 */ uint8 padding[0x80 - 0x24];
};
static_assert(sizeof(DiscPartitionTableEntry) == 0x80);
struct DiscPartitionHeader
{
// header at the beginning of each partition
static constexpr uint32 MAGIC_VALUE = 0xCC93A4F5;
/* +0x00 */ uint32be magic;
/* +0x04 */ uint32be sectorSize; // must match DISC_SECTOR_SIZE
/* +0x08 */ uint32be ukn008;
/* +0x0C */ uint32be ukn00C;
/* +0x10 */ uint32be h3HashNum;
/* +0x14 */ uint32be fstSize; // in bytes
/* +0x18 */ uint32be fstSector; // relative to partition start
/* +0x1C */ uint32be ukn01C;
/* +0x20 */ uint32be ukn020;
// the hash and encryption mode for the FST cluster
/* +0x24 */ uint8 fstHashType;
/* +0x25 */ uint8 fstEncryptionType; // purpose of this isn't really understood. Maybe it controls which key is being used? (1 -> disc key, 2 -> partition key)
/* +0x26 */ uint8 versionA;
/* +0x27 */ uint8 ukn027; // also a version field?
// there is an array at +0x40 ? Related to H3 list. Also related to value at +0x0C and h3HashNum
};
static_assert(sizeof(DiscPartitionHeader) == 0x28);
bool FSTVolume::FindDiscKey(const fs::path& path, NCrypto::AesKey& discTitleKey)
{
std::unique_ptr<FSTDataSourceWUD> dataSource(FSTDataSourceWUD::Open(path));
if (!dataSource)
return false;
// read section of header which should only contain zero bytes if decrypted
uint8 header[16*3];
if (dataSource->readData(0, 0, 0x18000 + 0x100, header, sizeof(header)) != sizeof(header))
return false;
// try all the keys
uint8 headerDecrypted[sizeof(header)-16];
for (sint32 i = 0; i < 0x7FFFFFFF; i++)
{
uint8* key128 = KeyCache_GetAES128(i);
if (key128 == NULL)
break;
AES128_CBC_decrypt(headerDecrypted, header + 16, sizeof(headerDecrypted), key128, header);
if (std::all_of(headerDecrypted, headerDecrypted + sizeof(headerDecrypted), [](const uint8 v) {return v == 0; }))
{
// key found
std::memcpy(discTitleKey.b, key128, 16);
return true;
}
}
return false;
}
// open WUD image using key cache
// if no matching key is found then keyFound will return false
FSTVolume* FSTVolume::OpenFromDiscImage(const fs::path& path, ErrorCode* errorCodeOut)
{
SET_FST_ERROR(UNKNOWN_ERROR);
KeyCache_Prepare();
NCrypto::AesKey discTitleKey;
if (!FindDiscKey(path, discTitleKey))
{
SET_FST_ERROR(DISC_KEY_MISSING);
return nullptr;
}
return OpenFromDiscImage(path, discTitleKey, errorCodeOut);
}
// open WUD image
FSTVolume* FSTVolume::OpenFromDiscImage(const fs::path& path, NCrypto::AesKey& discTitleKey, ErrorCode* errorCodeOut)
{
// WUD images support multiple partitions, each with their own key and FST
// the process for loading game data FSTVolume from a WUD image is as follows:
// 1) parse WUD headers and verify
// 2) read SI partition FST
// 3) find main GM partition
// 4) use SI information to get titleKey for GM partition
// 5) Load FST for GM
SET_FST_ERROR(UNKNOWN_ERROR);
std::unique_ptr<FSTDataSourceWUD> dataSource(FSTDataSourceWUD::Open(path));
if (!dataSource)
return nullptr;
// check HeaderA (only contains product code?)
DiscHeaderA headerA{};
if (dataSource->readData(0, 0, 0, &headerA, sizeof(headerA)) != sizeof(headerA))
return nullptr;
// check HeaderB
DiscHeaderB headerB{};
if (dataSource->readData(0, 0, DISC_SECTOR_SIZE * 2, &headerB, sizeof(headerB)) != sizeof(headerB))
return nullptr;
if (headerB.magic != headerB.MAGIC_VALUE)
return nullptr;
// read, decrypt and parse partition table
uint8 partitionSector[DISC_SECTOR_SIZE];
if (dataSource->readData(0, 0, DISC_SECTOR_SIZE * 3, partitionSector, DISC_SECTOR_SIZE) != DISC_SECTOR_SIZE)
return nullptr;
uint8 iv[16]{};
AES128_CBC_decrypt(partitionSector, partitionSector, DISC_SECTOR_SIZE, discTitleKey.b, iv);
// parse partition info
DiscPartitionTableHeader* partitionHeader = (DiscPartitionTableHeader*)partitionSector;
if (partitionHeader->magic != DiscPartitionTableHeader::MAGIC_VALUE)
{
cemuLog_log(LogType::Force, "Disc image rejected because decryption failed");
return nullptr;
}
if (partitionHeader->sectorSize != DISC_SECTOR_SIZE)
{
cemuLog_log(LogType::Force, "Disc image rejected because partition sector size is invalid");
return nullptr;
}
uint32 numPartitions = partitionHeader->numPartitions;
if (numPartitions > 30) // there is space for up to 240 partitions but we use a more reasonable limit
{
cemuLog_log(LogType::Force, "Disc image rejected due to exceeding the partition limit (has {} partitions)", numPartitions);
return nullptr;
}
DiscPartitionTableEntry* partitionArray = (DiscPartitionTableEntry*)(partitionSector + 0x800);
// validate partitions and find SI partition
uint32 siPartitionIndex = std::numeric_limits<uint32>::max();
uint32 gmPartitionIndex = std::numeric_limits<uint32>::max();
for (uint32 i = 0; i < numPartitions; i++)
{
if (partitionArray[i].numAddresses != 1)
{
cemuLog_log(LogType::Force, "Disc image has unsupported partition with {} addresses", (uint32)partitionArray[i].numAddresses);
return nullptr;
}
auto& name = partitionArray[i].partitionName;
if (name[0] == 'S' && name[1] == 'I')
{
if (siPartitionIndex != std::numeric_limits<uint32>::max())
{
cemuLog_log(LogType::Force, "Disc image has multiple SI partitions. Not supported");
return nullptr;
}
siPartitionIndex = i;
}
if (name[0] == 'G' && name[1] == 'M')
{
if (gmPartitionIndex == std::numeric_limits<uint32>::max())
gmPartitionIndex = i; // we use the first GM partition we find. This is likely not correct but it seems to work for practically all disc images
}
}
if (siPartitionIndex == std::numeric_limits<uint32>::max() || gmPartitionIndex == std::numeric_limits<uint32>::max())
{
cemuLog_log(LogType::Force, "Disc image has no SI or GM partition. Cannot read game data");
return nullptr;
}
// read and verify partition headers for SI and GM
auto readPartitionHeader = [&](DiscPartitionHeader& partitionHeader, uint32 partitionIndex) -> bool
{
cemu_assert_debug(dataSource->GetBaseOffset() == 0);
if (dataSource->readData(0, 0, partitionArray[partitionIndex].partitionAddress * DISC_SECTOR_SIZE, &partitionHeader, sizeof(DiscPartitionHeader)) != sizeof(DiscPartitionHeader))
return false;
if (partitionHeader.magic != partitionHeader.MAGIC_VALUE && partitionHeader.sectorSize != DISC_SECTOR_SIZE)
return false;
return true;
};
// SI partition
DiscPartitionHeader partitionHeaderSI{};
if (!readPartitionHeader(partitionHeaderSI, siPartitionIndex))
{
cemuLog_log(LogType::Force, "Disc image SI partition header is invalid");
return nullptr;
}
cemu_assert_debug(partitionHeaderSI.fstHashType == 0);
cemu_assert_debug(partitionHeaderSI.fstEncryptionType == 1);
// todo - check other fields?
// GM partition
DiscPartitionHeader partitionHeaderGM{};
if (!readPartitionHeader(partitionHeaderGM, gmPartitionIndex))
{
cemuLog_log(LogType::Force, "Disc image GM partition header is invalid");
return nullptr;
}
cemu_assert_debug(partitionHeaderGM.fstHashType == 1);
cemu_assert_debug(partitionHeaderGM.fstEncryptionType == 2);
// if decryption is necessary
// load SI FST
dataSource->SetBaseOffset((uint64)partitionArray[siPartitionIndex].partitionAddress * DISC_SECTOR_SIZE);
auto siFST = OpenFST(dataSource.get(), (uint64)partitionHeaderSI.fstSector * DISC_SECTOR_SIZE, partitionHeaderSI.fstSize, &discTitleKey, static_cast<FSTVolume::ClusterHashMode>(partitionHeaderSI.fstHashType));
if (!siFST)
return nullptr;
// load ticket file for partition that we want to decrypt
NCrypto::ETicketParser ticketParser;
std::vector<uint8> ticketData = siFST->ExtractFile(fmt::format("{:02x}/title.tik", gmPartitionIndex));
if (ticketData.empty() || !ticketParser.parse(ticketData.data(), ticketData.size()))
{
cemuLog_log(LogType::Force, "Disc image ticket file is invalid");
return nullptr;
}
delete siFST;
NCrypto::AesKey gmTitleKey;
ticketParser.GetTitleKey(gmTitleKey);
// load GM partition
dataSource->SetBaseOffset((uint64)partitionArray[gmPartitionIndex].partitionAddress * DISC_SECTOR_SIZE);
FSTVolume* r = OpenFST(std::move(dataSource), (uint64)partitionHeaderGM.fstSector * DISC_SECTOR_SIZE, partitionHeaderGM.fstSize, &gmTitleKey, static_cast<FSTVolume::ClusterHashMode>(partitionHeaderGM.fstHashType));
if (r)
SET_FST_ERROR(OK);
return r;
}
FSTVolume* FSTVolume::OpenFromContentFolder(fs::path folderPath, ErrorCode* errorCodeOut)
{
SET_FST_ERROR(UNKNOWN_ERROR);
// load TMD
FileStream* tmdFile = FileStream::openFile2(folderPath / "title.tmd");
if (!tmdFile)
return nullptr;
std::vector<uint8> tmdData;
tmdFile->extract(tmdData);
delete tmdFile;
NCrypto::TMDParser tmdParser;
if (!tmdParser.parse(tmdData.data(), tmdData.size()))
{
SET_FST_ERROR(BAD_TITLE_TMD);
return nullptr;
}
// load ticket
FileStream* ticketFile = FileStream::openFile2(folderPath / "title.tik");
if (!ticketFile)
{
SET_FST_ERROR(TITLE_TIK_MISSING);
return nullptr;
}
std::vector<uint8> ticketData;
ticketFile->extract(ticketData);
delete ticketFile;
NCrypto::ETicketParser ticketParser;
if (!ticketParser.parse(ticketData.data(), ticketData.size()))
{
SET_FST_ERROR(BAD_TITLE_TIK);
return nullptr;
}
NCrypto::AesKey titleKey;
ticketParser.GetTitleKey(titleKey);
// open data source
std::unique_ptr<FSTDataSource> dataSource(FSTDataSourceApp::Open(folderPath, tmdParser));
if (!dataSource)
return nullptr;
// get info about FST from first cluster (todo - is this correct or does the TMD store info about the fst?)
ClusterHashMode fstHashMode = ClusterHashMode::RAW;
uint32 fstSize = 0;
for (auto& itr : tmdParser.GetContentList())
{
if (itr.index == 0)
{
if (HAS_FLAG(itr.contentFlags, NCrypto::TMDParser::TMDContentFlags::FLAG_HASHED_CONTENT))
fstHashMode = ClusterHashMode::HASH_INTERLEAVED;
cemu_assert_debug(itr.size <= 0xFFFFFFFF);
fstSize = (uint32)itr.size;
}
}
// load FST
// fstSize = size of first cluster?
FSTVolume* fstVolume = FSTVolume::OpenFST(std::move(dataSource), 0, fstSize, &titleKey, fstHashMode);
if (fstVolume)
SET_FST_ERROR(OK);
return fstVolume;
}
FSTVolume* FSTVolume::OpenFST(FSTDataSource* dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode)
{
cemu_assert_debug(fstHashMode != ClusterHashMode::RAW || fstHashMode != ClusterHashMode::RAW2);
if (fstSize < sizeof(FSTHeader))
return nullptr;
constexpr uint64 FST_CLUSTER_OFFSET = 0;
uint32 fstSizePadded = (fstSize + 15) & ~15; // pad to AES block size
// read FST data and decrypt
std::vector<uint8> fstData(fstSizePadded);
if (dataSource->readData(0, FST_CLUSTER_OFFSET, fstOffset, fstData.data(), fstSizePadded) != fstSizePadded)
return nullptr;
uint8 iv[16]{};
AES128_CBC_decrypt(fstData.data(), fstData.data(), fstSizePadded, partitionTitleKey->b, iv);
// validate header
FSTHeader* fstHeader = (FSTHeader*)fstData.data();
const void* fstEnd = fstData.data() + fstSize;
if (fstHeader->magic != 0x46535400 || fstHeader->numCluster >= 0x1000)
{
cemuLog_log(LogType::Force, "FST has invalid header");
return nullptr;
}
// load cluster table
uint32 numCluster = fstHeader->numCluster;
FSTHeader_ClusterEntry* clusterDataTable = (FSTHeader_ClusterEntry*)(fstData.data() + sizeof(FSTHeader));
if ((clusterDataTable + numCluster) > fstEnd)
return nullptr;
std::vector<FSTCluster> clusterTable;
clusterTable.resize(numCluster);
for (size_t i = 0; i < numCluster; i++)
{
clusterTable[i].offset = clusterDataTable[i].offset;
clusterTable[i].size = clusterDataTable[i].size;
clusterTable[i].hashMode = static_cast<FSTVolume::ClusterHashMode>((uint8)clusterDataTable[i].hashMode);
}
// preprocess FST table
FSTHeader_FileEntry* fileTable = (FSTHeader_FileEntry*)(clusterDataTable + numCluster);
if ((fileTable + 1) > fstEnd)
return nullptr;
if (fileTable[0].GetType() != FSTHeader_FileEntry::TYPE::DIRECTORY)
return nullptr;
uint32 numFileEntries = fileTable[0].size;
if (numFileEntries == 0 || (fileTable + numFileEntries) > fstEnd)
return nullptr;
// load name string table
ptrdiff_t nameLookupTableSize = ((const uint8*)fstEnd - (const uint8*)(fileTable + numFileEntries));
if (nameLookupTableSize < 1)
return nullptr;
std::vector<char> nameStringTable(nameLookupTableSize);
std::memcpy(nameStringTable.data(), (fileTable + numFileEntries), nameLookupTableSize);
// process FST
std::vector<FSTEntry> fstEntries;
if (!ProcessFST(fileTable, numFileEntries, numCluster, nameStringTable, fstEntries))
return nullptr;
// construct FSTVolume from the processed data
FSTVolume* fstVolume = new FSTVolume();
fstVolume->m_dataSource = dataSource;
fstVolume->m_offsetFactor = fstHeader->offsetFactor;
fstVolume->m_sectorSize = DISC_SECTOR_SIZE;
fstVolume->m_partitionTitlekey = *partitionTitleKey;
std::swap(fstVolume->m_cluster, clusterTable);
std::swap(fstVolume->m_entries, fstEntries);
std::swap(fstVolume->m_nameStringTable, nameStringTable);
return fstVolume;
}
FSTVolume* FSTVolume::OpenFST(std::unique_ptr<FSTDataSource> dataSource, uint64 fstOffset, uint32 fstSize, NCrypto::AesKey* partitionTitleKey, ClusterHashMode fstHashMode)
{
FSTDataSource* ds = dataSource.release();
FSTVolume* fstVolume = OpenFST(ds, fstOffset, fstSize, partitionTitleKey, fstHashMode);
if (!fstVolume)
{
delete ds;
return nullptr;
}
fstVolume->m_sourceIsOwned = true;
return fstVolume;
}
bool FSTVolume::ProcessFST(FSTHeader_FileEntry* fileTable, uint32 numFileEntries, uint32 numCluster, std::vector<char>& nameStringTable, std::vector<FSTEntry>& fstEntries)
{
struct DirHierachyInfo
{
DirHierachyInfo(uint32 parentIndex, uint32 endIndex) : parentIndex(parentIndex), endIndex(endIndex) {};
uint32 parentIndex;
uint32 endIndex;
};
std::vector<DirHierachyInfo> currentDirEnd;
currentDirEnd.reserve(32);
currentDirEnd.emplace_back(0, numFileEntries); // create a fake parent for the root directory, the root's parent index is zero (referencing itself)
uint32 currentIndex = 0;
FSTHeader_FileEntry* pFileIn = fileTable + currentIndex;
fstEntries.resize(numFileEntries);
FSTEntry* pFileOut = fstEntries.data();
// validate root directory
if (pFileIn->GetType() != FSTHeader_FileEntry::TYPE::DIRECTORY || pFileIn->GetDirectoryEndIndex() != numFileEntries || pFileIn->GetDirectoryParent() != 0)
{
cemuLog_log(LogType::Force, "FSTVolume::ProcessFST() - root node is invalid");
return false;
}
for (; currentIndex < numFileEntries; currentIndex++)
{
while (currentIndex >= currentDirEnd.back().endIndex)
currentDirEnd.pop_back();
// process entry name
uint32 nameOffset = pFileIn->GetNameOffset();
uint32 pos = nameOffset;
while (true)
{
if (pos >= nameStringTable.size())
return false; // name exceeds string table
if (nameStringTable[pos] == '\0')
break;
pos++;
}
uint32 nameLen = pos - nameOffset;
pFileOut->nameOffset = nameOffset;
pFileOut->nameHash = _QuickNameHash(nameStringTable.data() + nameOffset, nameLen);
// parent directory index
pFileOut->parentDirIndex = currentDirEnd.back().parentIndex;
//if (currentDirEnd.back().parentIndex == 0)
// pFileOut->parentDirIndex = std::numeric_limits<uint32>::max();
//else
// pFileOut->parentDirIndex = currentDirEnd.back().parentIndex;
// process type specific data
auto entryType = pFileIn->GetType();
uint8 flags = 0;
if (pFileIn->HasFlagLink())
flags |= FSTEntry::FLAG_LINK;
if (pFileIn->HasUknFlag02())
flags |= FSTEntry::FLAG_UKN02;
pFileOut->SetFlags((FSTEntry::FLAGS)flags);
if (entryType == FSTHeader_FileEntry::TYPE::FILE)
{
bool isSysLink = entryType == FSTHeader_FileEntry::TYPE::FILE;
if (pFileIn->clusterIndex >= numCluster)
{
cemuLog_log(LogType::Force, "FST: File references cluster out of range");
return false;
}
cemu_assert_debug(pFileIn->flagsOrPermissions != 0x4004);
pFileOut->SetType(FSTEntry::TYPE::FILE);
pFileOut->fileInfo.fileOffset = pFileIn->offset;
pFileOut->fileInfo.fileSize = pFileIn->size;
pFileOut->fileInfo.clusterIndex = pFileIn->clusterIndex;
}
else if (entryType == FSTHeader_FileEntry::TYPE::DIRECTORY)
{
cemu_assert_debug(pFileIn->flagsOrPermissions != 0x4004);
pFileOut->SetType(FSTEntry::TYPE::DIRECTORY);
uint32 endIndex = pFileIn->GetDirectoryEndIndex();
uint32 parentIndex = pFileIn->GetDirectoryParent();
if (endIndex < currentIndex || endIndex > currentDirEnd.back().endIndex)
{
cemuLog_log(LogType::Force, "FST: Directory range out of bounds");
return false; // dir index out of range
}
if (parentIndex != currentDirEnd.back().parentIndex)
{
cemuLog_log(LogType::Force, "FST: Parent index does not match");
cemu_assert_debug(false);
return false;
}
currentDirEnd.emplace_back(currentIndex, endIndex);
pFileOut->dirInfo.endIndex = endIndex;
}
else
{
cemuLog_log(LogType::Force, "FST: Encountered node with unknown type");
cemu_assert_debug(false);
return false;
}
pFileIn++;
pFileOut++;
}
// end remaining directory hierarchy with final index
cemu_assert_debug(currentIndex == numFileEntries);
while (!currentDirEnd.empty() && currentIndex >= currentDirEnd.back().endIndex)
currentDirEnd.pop_back();
cemu_assert_debug(currentDirEnd.empty()); // no entries should remain
return true;
}
uint32 FSTVolume::GetFileCount() const
{
uint32 fileCount = 0;
for (auto& itr : m_entries)
{
if (itr.GetType() == FSTEntry::TYPE::FILE)
fileCount++;
}
return fileCount;
}
bool FSTVolume::OpenFile(std::string_view path, FSTFileHandle& fileHandleOut, bool openOnlyFiles)
{
FSCPath fscPath(path);
if (fscPath.GetNodeCount() == 0)
{
// empty path pointers to root directory
if(openOnlyFiles)
return false;
fileHandleOut.m_fstIndex = 0;
return true;
}
// scan directory and find sub folder or file
// skips iterating subdirectories
auto findSubentry = [this](size_t firstIndex, size_t lastIndex, std::string_view nodeName) -> sint32
{
uint16 nodeHash = _QuickNameHash(nodeName.data(), nodeName.size());
size_t index = firstIndex;
while (index < lastIndex)
{
if (m_entries[index].nameHash == nodeHash && MatchFSTEntryName(m_entries[index], nodeName))
return (sint32)index;
if (m_entries[index].GetType() == FSTEntry::TYPE::DIRECTORY)
index = m_entries[index].dirInfo.endIndex;
else
index++;
}
return -1;
};
// current FST range we iterate, starting with root directory which covers all entries
uint32 parentIndex = std::numeric_limits<uint32>::max();
size_t curDirStart = 1; // skip root directory
size_t curDirEnd = m_entries[0].dirInfo.endIndex;
// find the subdirectory
for (size_t nodeIndex = 0; nodeIndex < fscPath.GetNodeCount() - 1; nodeIndex++)
{
// get hash of node name
sint32 fstIndex = findSubentry(curDirStart, curDirEnd, fscPath.GetNodeName(nodeIndex));
if (fstIndex < 0)
return false;
if (m_entries[fstIndex].GetType() != FSTEntry::TYPE::DIRECTORY)
return false;
parentIndex = fstIndex;
curDirStart = fstIndex + 1;
curDirEnd = m_entries[fstIndex].dirInfo.endIndex;
}
// find the entry
sint32 fstIndex = findSubentry(curDirStart, curDirEnd, fscPath.GetNodeName(fscPath.GetNodeCount() - 1));
if (fstIndex < 0)
return false;
if (openOnlyFiles && m_entries[fstIndex].GetType() != FSTEntry::TYPE::FILE)
return false;
fileHandleOut.m_fstIndex = fstIndex;
return true;
}
bool FSTVolume::IsDirectory(const FSTFileHandle& fileHandle) const
{
cemu_assert_debug(fileHandle.m_fstIndex < m_entries.size());
return m_entries[fileHandle.m_fstIndex].GetType() == FSTEntry::TYPE::DIRECTORY;
};
bool FSTVolume::IsFile(const FSTFileHandle& fileHandle) const
{
cemu_assert_debug(fileHandle.m_fstIndex < m_entries.size());
return m_entries[fileHandle.m_fstIndex].GetType() == FSTEntry::TYPE::FILE;
};
bool FSTVolume::HasLinkFlag(const FSTFileHandle& fileHandle) const
{
cemu_assert_debug(fileHandle.m_fstIndex < m_entries.size());
return HAS_FLAG(m_entries[fileHandle.m_fstIndex].GetFlags(), FSTEntry::FLAGS::FLAG_LINK);
};
std::string_view FSTVolume::GetName(const FSTFileHandle& fileHandle) const
{
if (fileHandle.m_fstIndex > m_entries.size())
return "";
const char* entryName = m_nameStringTable.data() + m_entries[fileHandle.m_fstIndex].nameOffset;
return entryName;
}
std::string FSTVolume::GetPath(const FSTFileHandle& fileHandle) const
{
std::string path;
auto& entry = m_entries[fileHandle.m_fstIndex];
// get parent chain
boost::container::small_vector<uint32, 8> parentChain;
if (entry.HasNonRootNodeParent())
{
parentChain.emplace_back(entry.parentDirIndex);
auto* parentItr = &m_entries[entry.parentDirIndex];
while (parentItr->HasNonRootNodeParent())
{
cemu_assert_debug(parentItr->GetType() == FSTEntry::TYPE::DIRECTORY);
parentChain.emplace_back(parentItr->parentDirIndex);
parentItr = &m_entries[parentItr->parentDirIndex];
}
}
// build path
cemu_assert_debug(parentChain.size() <= 1); // test this case
for (auto& itr : parentChain | boost::adaptors::reversed)
{
const char* name = m_nameStringTable.data() + m_entries[itr].nameOffset;
path.append(name);
path.push_back('/');
}
// append node name
const char* name = m_nameStringTable.data() + entry.nameOffset;
path.append(name);
return path;
}
uint32 FSTVolume::GetFileSize(const FSTFileHandle& fileHandle) const
{
if (m_entries[fileHandle.m_fstIndex].GetType() != FSTEntry::TYPE::FILE)
return 0;
return m_entries[fileHandle.m_fstIndex].fileInfo.fileSize;
}
uint32 FSTVolume::ReadFile(FSTFileHandle& fileHandle, uint32 offset, uint32 size, void* dataOut)
{
FSTEntry& entry = m_entries[fileHandle.m_fstIndex];
if (entry.GetType() != FSTEntry::TYPE::FILE)
return 0;
cemu_assert_debug(!HAS_FLAG(entry.GetFlags(), FSTEntry::FLAGS::FLAG_LINK));
FSTCluster& cluster = m_cluster[entry.fileInfo.clusterIndex];
if (cluster.hashMode == ClusterHashMode::RAW || cluster.hashMode == ClusterHashMode::RAW2)
return ReadFile_HashModeRaw(entry.fileInfo.clusterIndex, entry, offset, size, dataOut);
else if (cluster.hashMode == ClusterHashMode::HASH_INTERLEAVED)
return ReadFile_HashModeHashed(entry.fileInfo.clusterIndex, entry, offset, size, dataOut);
cemu_assert_debug(false);
return 0;
}
uint32 FSTVolume::ReadFile_HashModeRaw(uint32 clusterIndex, FSTEntry& entry, uint32 readOffset, uint32 readSize, void* dataOut)
{
const uint32 readSizeInput = readSize;
uint8* dataOutU8 = (uint8*)dataOut;
if (readOffset >= entry.fileInfo.fileSize)
return 0;
else if ((readOffset + readSize) >= entry.fileInfo.fileSize)
readSize = (entry.fileInfo.fileSize - readOffset);
const FSTCluster& cluster = m_cluster[clusterIndex];
uint64 clusterOffset = (uint64)cluster.offset * m_sectorSize;
uint64 absFileOffset = entry.fileInfo.fileOffset * m_offsetFactor + readOffset;
// make sure the raw range we read is aligned to AES block size (16)
uint64 readAddrStart = absFileOffset & ~0xF;
uint64 readAddrEnd = (absFileOffset + readSize + 0xF) & ~0xF;
bool usesInitialIV = readOffset < 16;
if (!usesInitialIV)
readAddrStart -= 16; // read previous AES block since we require it for the IV
uint32 prePadding = (uint32)(absFileOffset - readAddrStart); // number of extra bytes we read before readOffset (for AES alignment and IV calculation)
uint32 postPadding = (uint32)(readAddrEnd - (absFileOffset + readSize));
uint8 readBuffer[64 * 1024];
// read first chunk
// if file read offset (readOffset) is within the first AES-block then use initial IV calculated from cluster index
// otherwise read previous AES-block is the IV (AES-CBC)
uint64 readAddrCurrent = readAddrStart;
uint32 rawBytesToRead = (uint32)std::min((readAddrEnd - readAddrStart), (uint64)sizeof(readBuffer));
if (m_dataSource->readData(clusterIndex, clusterOffset, readAddrCurrent, readBuffer, rawBytesToRead) != rawBytesToRead)
{
cemuLog_log(LogType::Force, "FST read error in raw content");
return 0;
}
readAddrCurrent += rawBytesToRead;
uint8 iv[16]{};
if (usesInitialIV)
{
// for the first AES block, the IV is initialized from cluster index
iv[0] = (uint8)(clusterIndex >> 8);
iv[1] = (uint8)(clusterIndex >> 0);
AES128_CBC_decrypt_updateIV(readBuffer, readBuffer, rawBytesToRead, m_partitionTitlekey.b, iv);
std::memcpy(dataOutU8, readBuffer + prePadding, rawBytesToRead - prePadding - postPadding);
dataOutU8 += (rawBytesToRead - prePadding - postPadding);
readSize -= (rawBytesToRead - prePadding - postPadding);
}
else
{
// IV is initialized from previous AES block (AES-CBC)
std::memcpy(iv, readBuffer, 16);
AES128_CBC_decrypt_updateIV(readBuffer + 16, readBuffer + 16, rawBytesToRead - 16, m_partitionTitlekey.b, iv);
std::memcpy(dataOutU8, readBuffer + prePadding, rawBytesToRead - prePadding - postPadding);
dataOutU8 += (rawBytesToRead - prePadding - postPadding);
readSize -= (rawBytesToRead - prePadding - postPadding);
}
// read remaining chunks
while (readSize > 0)
{
uint32 bytesToRead = (uint32)std::min((uint32)sizeof(readBuffer), readSize);
uint32 alignedBytesToRead = (bytesToRead + 15) & ~0xF;
if (m_dataSource->readData(clusterIndex, clusterOffset, readAddrCurrent, readBuffer, alignedBytesToRead) != alignedBytesToRead)
{
cemuLog_log(LogType::Force, "FST read error in raw content");
return 0;
}
AES128_CBC_decrypt_updateIV(readBuffer, readBuffer, alignedBytesToRead, m_partitionTitlekey.b, iv);
std::memcpy(dataOutU8, readBuffer, bytesToRead);
dataOutU8 += bytesToRead;
readSize -= bytesToRead;
readAddrCurrent += alignedBytesToRead;
}
return readSizeInput - readSize;
}
constexpr size_t BLOCK_SIZE = 0x10000;
constexpr size_t BLOCK_HASH_SIZE = 0x0400;
constexpr size_t BLOCK_FILE_SIZE = 0xFC00;
struct FSTHashedBlock
{
uint8 rawData[BLOCK_SIZE];
uint8* getHashData()
{
return rawData;
}
uint8* getH0Hash(uint32 index)
{
cemu_assert_debug(index < 16);
return getHashData() + 20 * index;
}
uint8* getH1Hash(uint32 index)
{
cemu_assert_debug(index < 16);
return getHashData() + (20 * 16) * 1 + 20 * index;
}
uint8* getH2Hash(uint32 index)
{
cemu_assert_debug(index < 16);
return getHashData() + (20 * 16) * 2 + 20 * index;
}
uint8* getFileData()
{
return rawData + BLOCK_HASH_SIZE;
}
uint8* getH0Hash(size_t index)
{
cemu_assert_debug(index < 16);
return rawData + index * 20;
}
};
static_assert(sizeof(FSTHashedBlock) == BLOCK_SIZE);
struct FSTCachedHashedBlock
{
FSTHashedBlock blockData;
uint64 lastAccess;
};
FSTCachedHashedBlock* FSTVolume::GetDecryptedHashedBlock(uint32 clusterIndex, uint32 blockIndex)
{
const FSTCluster& cluster = m_cluster[clusterIndex];
uint64 clusterOffset = (uint64)cluster.offset * m_sectorSize;
// generate id for cache
uint64 cacheBlockId = ((uint64)clusterIndex << (64 - 16)) | (uint64)blockIndex;
// lookup block in cache
FSTCachedHashedBlock* block = nullptr;
auto itr = m_cacheDecryptedHashedBlocks.find(cacheBlockId);
if (itr != m_cacheDecryptedHashedBlocks.end())
{
block = itr->second;
block->lastAccess = ++m_cacheAccessCounter;
return block;
}
// if cache already full, drop least recently accessed block (but recycle the FSTHashedBlock* object)
if (m_cacheDecryptedHashedBlocks.size() >= 16)
{
auto dropItr = std::min_element(m_cacheDecryptedHashedBlocks.begin(), m_cacheDecryptedHashedBlocks.end(), [](const auto& a, const auto& b) -> bool
{ return a.second->lastAccess < b.second->lastAccess; });
block = dropItr->second;
m_cacheDecryptedHashedBlocks.erase(dropItr);
}
else
block = new FSTCachedHashedBlock();
// block not cached, read new
block->lastAccess = ++m_cacheAccessCounter;
if (m_dataSource->readData(clusterIndex, clusterOffset, blockIndex * BLOCK_SIZE, block->blockData.rawData, BLOCK_SIZE) != BLOCK_SIZE)
{
cemuLog_log(LogType::Force, "Failed to read FST block");
delete block;
return nullptr;
}
// decrypt hash data
uint8 iv[16]{};
AES128_CBC_decrypt(block->blockData.getHashData(), block->blockData.getHashData(), BLOCK_HASH_SIZE, m_partitionTitlekey.b, iv);
// decrypt file data
AES128_CBC_decrypt(block->blockData.getFileData(), block->blockData.getFileData(), BLOCK_FILE_SIZE, m_partitionTitlekey.b, block->blockData.getH0Hash(blockIndex%16));
// register in cache
m_cacheDecryptedHashedBlocks.emplace(cacheBlockId, block);
return block;
}
uint32 FSTVolume::ReadFile_HashModeHashed(uint32 clusterIndex, FSTEntry& entry, uint32 readOffset, uint32 readSize, void* dataOut)
{
/*
Data is divided into 0x10000 (64KiB) blocks
Layout:
+0x0000 Hash20[16] H0 hashes
+0x0140 Hash20[16] H1 hashes
+0x0240 Hash20[16] H2 hashes
+0x03C0 uint8[64] padding
+0x0400 uint8[0xFC00] fileData
The hash part (0-0x3FF) uses AES-CBC with IV initialized to zero
The file part (0x400 - 0xFFFF) uses AES-CBC with IV initialized to block->h0Hash[blockIndex % 16]
The hash data itself is calculated over 4096 blocks. Where each individual H0 entry hashes a single 0xFC00 file data block (unencrypted)
Each H1 hash is calculated from 16 H0 hashes
Each H2 hash is calculated from 16 H1 hashes.
The H3 hash is calculated from 16 H2 hashes.
Thus for each 4096 block group we end up with:
4096 H0 hashes
256 H1 hashes
16 H2 hashes
1 H3 hash
The embedded H0/H1 hashes per block are only a slice of the larger array. Whereas H2 always get embedded as a whole, due to only having 16 hashes in total
There is also a H4 hash that covers all H3 hashes and is stored in the TMD
*/
const FSTCluster& cluster = m_cluster[clusterIndex];
uint64 clusterBaseOffset = (uint64)cluster.offset * m_sectorSize;
uint64 fileReadOffset = entry.fileInfo.fileOffset * m_offsetFactor + readOffset;
uint32 blockIndex = (uint32)(fileReadOffset / BLOCK_FILE_SIZE);
uint32 bytesRemaining = readSize;
uint32 offsetWithinBlock = (uint32)(fileReadOffset % BLOCK_FILE_SIZE);
while (bytesRemaining > 0)
{
FSTCachedHashedBlock* block = GetDecryptedHashedBlock(clusterIndex, blockIndex);
if (!block)
return 0;
uint32 bytesToRead = std::min(bytesRemaining, (uint32)BLOCK_FILE_SIZE - offsetWithinBlock);
std::memcpy(dataOut, block->blockData.getFileData() + offsetWithinBlock, bytesToRead);
dataOut = (uint8*)dataOut + bytesToRead;
bytesRemaining -= bytesToRead;
blockIndex++;
offsetWithinBlock = 0;
}
return readSize - bytesRemaining;
}
bool FSTVolume::OpenDirectoryIterator(std::string_view path, FSTDirectoryIterator& directoryIteratorOut)
{
FSTFileHandle fileHandle;
if (!OpenFile(path, fileHandle, false))
return false;
if (!IsDirectory(fileHandle))
return false;
auto const& fstEntry = m_entries[fileHandle.m_fstIndex];
directoryIteratorOut.dirHandle = fileHandle;
directoryIteratorOut.startIndex = fileHandle.m_fstIndex + 1;
directoryIteratorOut.endIndex = fstEntry.dirInfo.endIndex;
directoryIteratorOut.currentIndex = directoryIteratorOut.startIndex;
return true;
}
bool FSTVolume::Next(FSTDirectoryIterator& directoryIterator, FSTFileHandle& fileHandleOut)
{
if (directoryIterator.currentIndex >= directoryIterator.endIndex)
return false;
auto const& fstEntry = m_entries[directoryIterator.currentIndex];
fileHandleOut.m_fstIndex = directoryIterator.currentIndex;
if (fstEntry.GetType() == FSTEntry::TYPE::DIRECTORY)
{
cemu_assert_debug(fstEntry.dirInfo.endIndex > directoryIterator.currentIndex);
directoryIterator.currentIndex = fstEntry.dirInfo.endIndex;
}
else
directoryIterator.currentIndex++;
return true;
}
FSTVolume::~FSTVolume()
{
for (auto& itr : m_cacheDecryptedHashedBlocks)
delete itr.second;
if (m_sourceIsOwned)
delete m_dataSource;
}
bool FSTVerifier::VerifyContentFile(FileStream* fileContent, const NCrypto::AesKey* key, uint32 contentIndex, uint32 contentSize, uint32 contentSizePadded, bool isSHA1, const uint8* tmdContentHash)
{
cemu_assert_debug(isSHA1); // test this case
cemu_assert_debug(((contentSize+0xF)&~0xF) == contentSizePadded);
std::vector<uint8> buffer;
buffer.resize(64 * 1024);
if ((uint32)fileContent->GetSize() != contentSizePadded)
return false;
fileContent->SetPosition(0);
uint8 iv[16]{};
iv[0] = (contentIndex >> 8) & 0xFF;
iv[1] = (contentIndex >> 0) & 0xFF;
// raw content
uint64 remainingBytes = contentSize;
uint8 calculatedHash[SHA256_DIGEST_LENGTH];
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
EVP_DigestInit(ctx, isSHA1 ? EVP_sha1() : EVP_sha256());
while (remainingBytes > 0)
{
uint32 bytesToRead = (uint32)std::min(remainingBytes, (uint64)buffer.size());
uint32 bytesToReadPadded = ((bytesToRead + 0xF) & ~0xF);
uint32 bytesRead = fileContent->readData(buffer.data(), bytesToReadPadded);
if (bytesRead != bytesToReadPadded)
return false;
AES128_CBC_decrypt_updateIV(buffer.data(), buffer.data(), bytesToReadPadded, key->b, iv);
EVP_DigestUpdate(ctx, buffer.data(), bytesToRead);
remainingBytes -= bytesToRead;
}
unsigned int md_len;
EVP_DigestFinal_ex(ctx, calculatedHash, &md_len);
EVP_MD_CTX_free(ctx);
return memcmp(calculatedHash, tmdContentHash, md_len) == 0;
}
bool FSTVerifier::VerifyHashedContentFile(FileStream* fileContent, const NCrypto::AesKey* key, uint32 contentIndex, uint32 contentSize, uint32 contentSizePadded, bool isSHA1, const uint8* tmdContentHash)
{
if (!isSHA1)
return false; // not supported
if ((contentSize % sizeof(FSTHashedBlock)) != 0)
return false;
if ((uint32)fileContent->GetSize() != contentSize)
return false;
fileContent->SetPosition(0);
std::vector<NCrypto::CHash160> h0List(4096);
FSTHashedBlock block;
uint32 numBlocks = contentSize / sizeof(FSTHashedBlock);
for (uint32 blockIndex = 0; blockIndex < numBlocks; blockIndex++)
{
if (fileContent->readData(&block, sizeof(FSTHashedBlock)) != sizeof(FSTHashedBlock))
return false;
uint32 h0Index = (blockIndex % 4096);
// decrypt hash data and file data
uint8 iv[16]{};
AES128_CBC_decrypt(block.getHashData(), block.getHashData(), BLOCK_HASH_SIZE, key->b, iv);
AES128_CBC_decrypt(block.getFileData(), block.getFileData(), BLOCK_FILE_SIZE, key->b, block.getH0Hash(blockIndex % 16));
// generate H0 hash and compare
NCrypto::CHash160 h0;
SHA1(block.getFileData(), BLOCK_FILE_SIZE, h0.b);
if (memcmp(h0.b, block.getH0Hash(h0Index & 0xF), sizeof(h0.b)) != 0)
return false;
std::memcpy(h0List[h0Index].b, h0.b, sizeof(h0.b));
// Sixteen H0 hashes become one H1 hash
if (((h0Index + 1) % 16) == 0 && h0Index > 0)
{
uint32 h1Index = ((h0Index - 15) / 16);
NCrypto::CHash160 h1;
SHA1((unsigned char *) (h0List.data() + h1Index * 16), sizeof(NCrypto::CHash160) * 16, h1.b);
if (memcmp(h1.b, block.getH1Hash(h1Index&0xF), sizeof(h1.b)) != 0)
return false;
}
// todo - repeat same for H1 and H2
// At the end all H3 hashes are hashed into a single H4 hash which is then compared with the content hash from the TMD
// Checking only H0 and H1 is sufficient enough for verifying if the file data is intact
// but if we wanted to be strict and only allow correctly signed data we would have to hash all the way up to H4
}
return true;
}
void FSTVolumeTest()
{
FSTPathUnitTest();
}