From 0c14b0c8a70799072ddccb648006914af7c62855 Mon Sep 17 00:00:00 2001 From: LillyJadeKatrin Date: Fri, 21 Jun 2024 23:44:21 -0400 Subject: Add Approved Patch Allowlist for Achievements Prototype of a system to whitelist known game patches that are allowed to be used while RetroAchievements Hardcore mode is active. ApprovedInis.txt contains known hashes for the ini files as they appear in the repo, and can be compared to the local versions of these files to ensure they have not been edited locally by the player. ApprovedInis.txt is hashed and verified similarly first, with its hash residing as a const string within AchievementManager.h, ensuring ApprovedInis and the hashes within cannot be modified without editing Dolphin's source code and recompiling completely. --- Source/Core/Core/AchievementManager.cpp | 72 +++++++++++++++++++++++++++++++++ Source/Core/Core/AchievementManager.h | 20 ++++++++- Source/Core/Core/PatchEngine.cpp | 10 +++-- 3 files changed, 98 insertions(+), 4 deletions(-) (limited to 'Source') diff --git a/Source/Core/Core/AchievementManager.cpp b/Source/Core/Core/AchievementManager.cpp index 7d7c0af374..1920ad0552 100644 --- a/Source/Core/Core/AchievementManager.cpp +++ b/Source/Core/Core/AchievementManager.cpp @@ -14,6 +14,7 @@ #include #include "Common/Assert.h" +#include "Common/BitUtils.h" #include "Common/CommonPaths.h" #include "Common/FileUtil.h" #include "Common/IOFile.h" @@ -26,6 +27,7 @@ #include "Core/Core.h" #include "Core/HW/Memmap.h" #include "Core/HW/VideoInterface.h" +#include "Core/PatchEngine.h" #include "Core/PowerPC/MMU.h" #include "Core/System.h" #include "DiscIO/Blob.h" @@ -70,6 +72,34 @@ void AchievementManager::Init() } } +void AchievementManager::LoadApprovedList() +{ + picojson::value temp; + std::string error; + if (!JsonFromFile(fmt::format("{}{}{}", File::GetSysDirectory(), DIR_SEP, APPROVED_LIST_FILENAME), + &temp, &error)) + { + WARN_LOG_FMT(ACHIEVEMENTS, "Failed to load approved game settings list {}", + APPROVED_LIST_FILENAME); + WARN_LOG_FMT(ACHIEVEMENTS, "Error: {}", error); + return; + } + auto context = Common::SHA1::CreateContext(); + context->Update(temp.serialize()); + auto digest = context->Finish(); + if (digest != APPROVED_LIST_HASH) + { + WARN_LOG_FMT(ACHIEVEMENTS, "Failed to verify approved game settings list {}", + APPROVED_LIST_FILENAME); + WARN_LOG_FMT(ACHIEVEMENTS, "Expected hash {}, found hash {}", + Common::SHA1::DigestToString(APPROVED_LIST_HASH), + Common::SHA1::DigestToString(digest)); + return; + } + std::lock_guard lg{m_lock}; + m_ini_root = std::move(temp); +} + void AchievementManager::SetUpdateCallback(UpdateCallback callback) { m_update_callback = std::move(callback); @@ -322,6 +352,48 @@ bool AchievementManager::IsHardcoreModeActive() const return rc_client_is_processing_required(m_client); } +void AchievementManager::FilterApprovedPatches(std::vector& patches, + const std::string& game_ini_id) const +{ + if (!IsHardcoreModeActive()) + return; + + if (!m_ini_root.contains(game_ini_id)) + patches.clear(); + auto patch_itr = patches.begin(); + while (patch_itr != patches.end()) + { + INFO_LOG_FMT(ACHIEVEMENTS, "Verifying patch {}", patch_itr->name); + + auto context = Common::SHA1::CreateContext(); + context->Update(Common::BitCastToArray(static_cast(patch_itr->entries.size()))); + for (const auto& entry : patch_itr->entries) + { + context->Update(Common::BitCastToArray(entry.type)); + context->Update(Common::BitCastToArray(entry.address)); + context->Update(Common::BitCastToArray(entry.value)); + context->Update(Common::BitCastToArray(entry.comparand)); + context->Update(Common::BitCastToArray(entry.conditional)); + } + auto digest = context->Finish(); + + bool verified = m_ini_root.get(game_ini_id).contains(Common::SHA1::DigestToString(digest)); + if (!verified) + { + patch_itr = patches.erase(patch_itr); + OSD::AddMessage( + fmt::format("Failed to verify patch {} from file {}.", patch_itr->name, game_ini_id), + OSD::Duration::VERY_LONG, OSD::Color::RED); + OSD::AddMessage("Disable hardcore mode to enable this patch.", OSD::Duration::VERY_LONG, + OSD::Color::RED); + } + else + { + patch_itr++; + } + } +} + void AchievementManager::SetSpectatorMode() { rc_client_set_spectator_mode_enabled(m_client, Config::Get(Config::RA_SPECTATOR_ENABLED)); diff --git a/Source/Core/Core/AchievementManager.h b/Source/Core/Core/AchievementManager.h index 3996003040..efddd003a9 100644 --- a/Source/Core/Core/AchievementManager.h +++ b/Source/Core/Core/AchievementManager.h @@ -27,6 +27,7 @@ #include "Common/CommonTypes.h" #include "Common/Event.h" #include "Common/HttpRequest.h" +#include "Common/JsonUtil.h" #include "Common/WorkQueueThread.h" #include "DiscIO/Volume.h" #include "VideoCommon/Assets/CustomTextureData.h" @@ -37,6 +38,11 @@ class CPUThreadGuard; class System; } // namespace Core +namespace PatchEngine +{ +struct Patch; +} // namespace PatchEngine + class AchievementManager { public: @@ -60,6 +66,10 @@ public: static constexpr std::string_view GRAY = "transparent"; static constexpr std::string_view GOLD = "#FFD700"; static constexpr std::string_view BLUE = "#0B71C1"; + static constexpr std::string_view APPROVED_LIST_FILENAME = "ApprovedInis.json"; + static const inline Common::SHA1::Digest APPROVED_LIST_HASH = { + 0x01, 0x1E, 0x2E, 0x74, 0xDD, 0x07, 0x79, 0xDA, 0x0E, 0x5D, + 0xF8, 0x51, 0x09, 0xC7, 0x9B, 0x46, 0x22, 0x95, 0x50, 0xE9}; struct LeaderboardEntry { @@ -109,6 +119,9 @@ public: std::recursive_mutex& GetLock(); void SetHardcoreMode(); bool IsHardcoreModeActive() const; + void SetGameIniId(const std::string& game_ini_id) { m_game_ini_id = game_ini_id; } + void FilterApprovedPatches(std::vector& patches, + const std::string& game_ini_id) const; void SetSpectatorMode(); std::string_view GetPlayerDisplayName() const; u32 GetPlayerScore() const; @@ -132,7 +145,7 @@ public: void Shutdown(); private: - AchievementManager() = default; + AchievementManager() { LoadApprovedList(); }; struct FilereaderState { @@ -140,6 +153,8 @@ private: std::unique_ptr volume; }; + void LoadApprovedList(); + static void* FilereaderOpenByFilepath(const char* path_utf8); static void* FilereaderOpenByVolume(const char* path_utf8); static void FilereaderSeek(void* file_handle, int64_t offset, int origin); @@ -211,6 +226,9 @@ private: std::chrono::steady_clock::time_point m_last_rp_time = std::chrono::steady_clock::now(); std::chrono::steady_clock::time_point m_last_progress_message = std::chrono::steady_clock::now(); + picojson::value m_ini_root; + std::string m_game_ini_id; + std::unordered_map m_leaderboard_map; bool m_challenges_updated = false; std::unordered_set m_active_challenges; diff --git a/Source/Core/Core/PatchEngine.cpp b/Source/Core/Core/PatchEngine.cpp index f020c754eb..0728637e47 100644 --- a/Source/Core/Core/PatchEngine.cpp +++ b/Source/Core/Core/PatchEngine.cpp @@ -182,6 +182,13 @@ void LoadPatches() LoadPatchSection("OnFrame", &s_on_frame, globalIni, localIni); +#ifdef USE_RETRO_ACHIEVEMENTS + { + std::lock_guard lg{AchievementManager::GetInstance().GetLock()}; + AchievementManager::GetInstance().FilterApprovedPatches(s_on_frame, sconfig.GetGameID()); + } +#endif // USE_RETRO_ACHIEVEMENTS + // Check if I'm syncing Codes if (Config::Get(Config::SESSION_CODE_SYNC_OVERRIDE)) { @@ -197,9 +204,6 @@ void LoadPatches() static void ApplyPatches(const Core::CPUThreadGuard& guard, const std::vector& patches) { - if (AchievementManager::GetInstance().IsHardcoreModeActive()) - return; - for (const Patch& patch : patches) { if (patch.enabled) -- cgit v1.2.3 From 3ca50f7879d79339fac66e79bdd2ddfa5e69235f Mon Sep 17 00:00:00 2001 From: "Admiral H. Curtiss" Date: Sun, 7 Jul 2024 21:03:29 +0200 Subject: Implement File::GetExePath() for FreeBSD --- Source/Core/Common/FileUtil.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'Source') diff --git a/Source/Core/Common/FileUtil.cpp b/Source/Core/Common/FileUtil.cpp index 5582018b30..545bfaba44 100644 --- a/Source/Core/Common/FileUtil.cpp +++ b/Source/Core/Common/FileUtil.cpp @@ -60,6 +60,10 @@ #include "jni/AndroidCommon/AndroidCommon.h" #endif +#if defined(__FreeBSD__) +#include +#endif + namespace fs = std::filesystem; namespace File @@ -738,6 +742,15 @@ std::string GetExePath() return PathToString(exe_path_absolute); #elif defined(__APPLE__) return GetBundleDirectory(); +#elif defined(__FreeBSD__) + int name[4]{CTL_KERN, KERN_PROC, KERN_PROC_PATHNAME, -1}; + size_t length = 0; + if (sysctl(name, 4, nullptr, &length, nullptr, 0) != 0 || length == 0) + return {}; + std::string dolphin_exe_path(length, '\0'); + if (sysctl(name, 4, dolphin_exe_path.data(), &length, nullptr, 0) != 0) + return {}; + return dolphin_exe_path; #else char dolphin_exe_path[PATH_MAX]; ssize_t len = ::readlink("/proc/self/exe", dolphin_exe_path, sizeof(dolphin_exe_path)); -- cgit v1.2.3 From ae87bf9af5d6a09a380949b47f2d7c484cea7d01 Mon Sep 17 00:00:00 2001 From: LillyJadeKatrin Date: Mon, 1 Jul 2024 20:49:51 -0400 Subject: Add Unit Test for Patch Allowlist This unit test compares ApprovedInis.json with the contents of the GameSettings folder to verify that every patch marked allowed for use with RetroAchievements has a hash in ApprovedInis.json. If not, that hash is reported in the test logs so that the hash may be updated more easily. --- Source/UnitTests/CMakeLists.txt | 3 + Source/UnitTests/Core/CMakeLists.txt | 1 + Source/UnitTests/Core/PatchAllowlistTest.cpp | 138 +++++++++++++++++++++++++++ Source/UnitTests/UnitTests.vcxproj | 5 + 4 files changed, 147 insertions(+) create mode 100644 Source/UnitTests/Core/PatchAllowlistTest.cpp (limited to 'Source') diff --git a/Source/UnitTests/CMakeLists.txt b/Source/UnitTests/CMakeLists.txt index 158b523cbe..96f26a8793 100644 --- a/Source/UnitTests/CMakeLists.txt +++ b/Source/UnitTests/CMakeLists.txt @@ -8,6 +8,9 @@ add_executable(tests EXCLUDE_FROM_ALL UnitTestsMain.cpp StubHost.cpp) set_target_properties(tests PROPERTIES FOLDER Tests) target_link_libraries(tests PRIVATE fmt::fmt gtest::gtest core uicommon) add_test(NAME tests COMMAND tests) +add_custom_command(TARGET tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/Data/Sys" "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Sys" +) add_dependencies(unittests tests) macro(add_dolphin_test target) diff --git a/Source/UnitTests/Core/CMakeLists.txt b/Source/UnitTests/Core/CMakeLists.txt index f2de6f13ad..865064ed68 100644 --- a/Source/UnitTests/Core/CMakeLists.txt +++ b/Source/UnitTests/Core/CMakeLists.txt @@ -1,6 +1,7 @@ add_dolphin_test(MMIOTest MMIOTest.cpp) add_dolphin_test(PageFaultTest PageFaultTest.cpp) add_dolphin_test(CoreTimingTest CoreTimingTest.cpp) +add_dolphin_test(PatchAllowlistTest PatchAllowlistTest.cpp) add_dolphin_test(DSPAcceleratorTest DSP/DSPAcceleratorTest.cpp) add_dolphin_test(DSPAssemblyTest diff --git a/Source/UnitTests/Core/PatchAllowlistTest.cpp b/Source/UnitTests/Core/PatchAllowlistTest.cpp new file mode 100644 index 0000000000..779b49791f --- /dev/null +++ b/Source/UnitTests/Core/PatchAllowlistTest.cpp @@ -0,0 +1,138 @@ +// Copyright 2024 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "Common/BitUtils.h" +#include "Common/CommonPaths.h" +#include "Common/Crypto/SHA1.h" +#include "Common/FileUtil.h" +#include "Common/IOFile.h" +#include "Common/IniFile.h" +#include "Common/JsonUtil.h" +#include "Core/CheatCodes.h" +#include "Core/PatchEngine.h" + +struct GameHashes +{ + std::string game_title; + std::map hashes; +}; + +TEST(PatchAllowlist, VerifyHashes) +{ + // Load allowlist + static constexpr std::string_view APPROVED_LIST_FILENAME = "ApprovedInis.json"; + picojson::value json_tree; + std::string error; + std::string cur_directory = File::GetExeDirectory() +#if defined(__APPLE__) + + DIR_SEP "Tests" // FIXME: Ugly hack. +#endif + ; + std::string sys_directory = cur_directory + DIR_SEP "Sys"; + const auto& list_filepath = fmt::format("{}{}{}", sys_directory, DIR_SEP, APPROVED_LIST_FILENAME); + ASSERT_TRUE(JsonFromFile(list_filepath, &json_tree, &error)) + << "Failed to open file at " << list_filepath; + // Parse allowlist - Map + ASSERT_TRUE(json_tree.is()); + std::map allow_list; + for (const auto& entry : json_tree.get()) + { + ASSERT_TRUE(entry.second.is()); + GameHashes& game_entry = allow_list[entry.first]; + for (const auto& line : entry.second.get()) + { + ASSERT_TRUE(line.second.is()); + if (line.first == "title") + game_entry.game_title = line.second.get(); + else + game_entry.hashes[line.first] = line.second.get(); + } + } + // Iterate over GameSettings directory + auto directory = + File::ScanDirectoryTree(fmt::format("{}{}GameSettings", sys_directory, DIR_SEP), false); + for (const auto& file : directory.children) + { + // Load ini file + Common::IniFile ini_file; + ini_file.Load(file.physicalName, true); + std::string game_id = file.virtualName.substr(0, file.virtualName.find_first_of('.')); + std::vector patches; + PatchEngine::LoadPatchSection("OnFrame", &patches, ini_file, Common::IniFile()); + // Filter patches for RetroAchievements approved + ReadEnabledOrDisabled(ini_file, "OnFrame", false, &patches); + ReadEnabledOrDisabled(ini_file, "Patches_RetroAchievements_Verified", true, + &patches); + // Get game section from allow list + auto game_itr = allow_list.find(game_id); + // Iterate over approved patches + for (const auto& patch : patches) + { + if (!patch.enabled) + continue; + // Hash patch + auto context = Common::SHA1::CreateContext(); + context->Update(Common::BitCastToArray(static_cast(patch.entries.size()))); + for (const auto& entry : patch.entries) + { + context->Update(Common::BitCastToArray(entry.type)); + context->Update(Common::BitCastToArray(entry.address)); + context->Update(Common::BitCastToArray(entry.value)); + context->Update(Common::BitCastToArray(entry.comparand)); + context->Update(Common::BitCastToArray(entry.conditional)); + } + auto digest = context->Finish(); + std::string hash = Common::SHA1::DigestToString(digest); + // Check patch in list + if (game_itr == allow_list.end()) + { + // Report: no patches in game found in list + ADD_FAILURE() << "Approved hash missing from list." << std::endl + << "Game ID: " << game_id << std::endl + << "Patch: \"" << hash << "\" : \"" << patch.name << "\""; + continue; + } + auto hash_itr = game_itr->second.hashes.find(hash); + if (hash_itr == game_itr->second.hashes.end()) + { + // Report: patch not found in list + ADD_FAILURE() << "Approved hash missing from list." << std::endl + << "Game ID: " << game_id << ":" << game_itr->second.game_title << std::endl + << "Patch: \"" << hash << "\" : \"" << patch.name << "\""; + } + else + { + // Remove patch from map if found + game_itr->second.hashes.erase(hash_itr); + } + } + // Report missing patches in map + if (game_itr == allow_list.end()) + continue; + for (auto& remaining_hashes : game_itr->second.hashes) + { + ADD_FAILURE() << "Hash in list not approved in ini." << std::endl + << "Game ID: " << game_id << ":" << game_itr->second.game_title << std::endl + << "Patch: " << remaining_hashes.second << ":" << remaining_hashes.first; + } + // Remove section from map + allow_list.erase(game_itr); + } + // Report remaining sections in map + for (auto& remaining_games : allow_list) + { + ADD_FAILURE() << "Game in list has no ini file." << std::endl + << "Game ID: " << remaining_games.first << ":" + << remaining_games.second.game_title; + } +} diff --git a/Source/UnitTests/UnitTests.vcxproj b/Source/UnitTests/UnitTests.vcxproj index 9e44cec8c3..229e5b576f 100644 --- a/Source/UnitTests/UnitTests.vcxproj +++ b/Source/UnitTests/UnitTests.vcxproj @@ -24,6 +24,9 @@ Console + + xcopy /i /e /s /y /f "$(ProjectDir)\..\..\Data\Sys\" "$(TargetDir)Sys" + @@ -70,6 +73,7 @@ + @@ -101,6 +105,7 @@ + -- cgit v1.2.3