From eda162692b56d16bc3c48ff05e3b42eb4f7c8352 Mon Sep 17 00:00:00 2001 From: Patric Stout Date: Fri, 19 Jan 2024 14:41:11 +0100 Subject: [PATCH] Add: signature validation before loading plugins This prevents us from executing unauthorized plugins. --- cmake/Options.cmake | 10 ++ src/CMakeLists.txt | 2 + src/lang/english.txt | 1 + src/settings_gui.cpp | 1 + src/signature.cpp | 279 +++++++++++++++++++++++++++++++++++++ src/signature.h | 15 ++ src/social_integration.cpp | 33 +++-- src/social_integration.h | 1 + src/survey.cpp | 1 + 9 files changed, 333 insertions(+), 10 deletions(-) create mode 100644 src/signature.cpp create mode 100644 src/signature.h diff --git a/cmake/Options.cmake b/cmake/Options.cmake index 371b03d841..3c8692fe4a 100644 --- a/cmake/Options.cmake +++ b/cmake/Options.cmake @@ -67,6 +67,7 @@ function(set_options) option(OPTION_USE_NSIS "Use NSIS to create windows installer; enable only for stable releases" OFF) option(OPTION_TOOLS_ONLY "Build only tools target" OFF) option(OPTION_DOCS_ONLY "Build only docs target" OFF) + option(OPTION_ALLOW_INVALID_SIGNATURE "Allow loading of content with invalid signatures" OFF) if (OPTION_DOCS_ONLY) set(OPTION_TOOLS_ONLY ON PARENT_SCOPE) @@ -92,6 +93,11 @@ function(show_options) else() message(STATUS "Option Survey Key - NOT USED") endif() + + if(OPTION_ALLOW_INVALID_SIGNATURE) + message(STATUS "Option Allow Invalid Signature - USED") + message(WARNING "Ignoring invalid signatures is a security risk! Use with care!") + endif() endfunction() # Add the definitions for the options that are selected. @@ -116,4 +122,8 @@ function(add_definitions_based_on_options) if(OPTION_SURVEY_KEY) add_definitions(-DSURVEY_KEY="${OPTION_SURVEY_KEY}") endif() + + if(OPTION_ALLOW_INVALID_SIGNATURE) + add_definitions(-DALLOW_INVALID_SIGNATURE) + endif() endfunction() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6071530de0..ebb946ae4e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -382,6 +382,8 @@ add_files( signal.cpp signal_func.h signal_type.h + signature.cpp + signature.h signs.cpp signs_base.h signs_cmd.cpp diff --git a/src/lang/english.txt b/src/lang/english.txt index 8eb614bf9e..af9418d5b1 100644 --- a/src/lang/english.txt +++ b/src/lang/english.txt @@ -1096,6 +1096,7 @@ STR_GAME_OPTIONS_SOCIAL_PLUGIN_STATE_PLATFORM_NOT_RUNNING :{ORANGE}{RAW_ST STR_GAME_OPTIONS_SOCIAL_PLUGIN_STATE_UNLOADED :{RED}Unloaded STR_GAME_OPTIONS_SOCIAL_PLUGIN_STATE_DUPLICATE :{RED}Duplicated plugin STR_GAME_OPTIONS_SOCIAL_PLUGIN_STATE_UNSUPPORTED_API :{RED}Unsupported version +STR_GAME_OPTIONS_SOCIAL_PLUGIN_STATE_INVALID_SIGNATURE :{RED}Invalid signature STR_BASESET_STATUS :{RAW_STRING} {RED}({NUM} missing/corrupted file{P "" s}) diff --git a/src/settings_gui.cpp b/src/settings_gui.cpp index 4e96a1e7ec..aca64de902 100644 --- a/src/settings_gui.cpp +++ b/src/settings_gui.cpp @@ -285,6 +285,7 @@ public: { SocialIntegrationPlugin::UNLOADED, STR_GAME_OPTIONS_SOCIAL_PLUGIN_STATE_UNLOADED }, { SocialIntegrationPlugin::DUPLICATE, STR_GAME_OPTIONS_SOCIAL_PLUGIN_STATE_DUPLICATE }, { SocialIntegrationPlugin::UNSUPPORTED_API, STR_GAME_OPTIONS_SOCIAL_PLUGIN_STATE_UNSUPPORTED_API }, + { SocialIntegrationPlugin::INVALID_SIGNATURE, STR_GAME_OPTIONS_SOCIAL_PLUGIN_STATE_INVALID_SIGNATURE }, }; /* For SetupSmallestSize, use the longest string we have. */ diff --git a/src/signature.cpp b/src/signature.cpp new file mode 100644 index 0000000000..a329410e35 --- /dev/null +++ b/src/signature.cpp @@ -0,0 +1,279 @@ +/* + * This file is part of OpenTTD. + * OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2. + * OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see . + */ + +/** @file signature.cpp Implementation of signature validation routines. */ + +#include "stdafx.h" + +#include "signature.h" + +#include "debug.h" +#include "fileio_func.h" +#include "string_func.h" + +#include "3rdparty/monocypher/monocypher.h" +#include "3rdparty/monocypher/monocypher-ed25519.h" +#include "3rdparty/nlohmann/json.hpp" + +#include "safeguards.h" + +/** The public keys used for signature validation. */ +static const std::initializer_list> _public_keys_v1 = { + /* 2024-01-20 - Public key for Social Integration Plugins. */ + { 0xed, 0x5d, 0x57, 0x47, 0x21, 0x99, 0x8b, 0x02, 0xdf, 0x6e, 0x3d, 0x69, 0xe1, 0x87, 0xca, 0xd0, 0x0e, 0x88, 0xc3, 0xe2, 0xb2, 0xa6, 0x7b, 0xc0, 0x42, 0xc8, 0xd6, 0x4b, 0x65, 0xe6, 0x48, 0xf7 }, +}; + +/** + * Calculate the 32-byte blake2b hash of a file. + * + * @param filename The filename to calculate the hash of. + * @return The 32-byte blake2b hash of the file, hex-encoded. + */ +static std::string CalculateHashV1(const std::string &filename) +{ + FILE *f = FioFOpenFile(filename, "rb", NO_DIRECTORY); + if (f == nullptr) { + return ""; + } + + std::array digest; + crypto_blake2b_ctx ctx; + crypto_blake2b_init(&ctx, digest.size()); + + while (!feof(f)) { + std::array buf; + size_t len = fread(buf.data(), 1, buf.size(), f); + + crypto_blake2b_update(&ctx, buf.data(), len); + } + fclose(f); + + crypto_blake2b_final(&ctx, digest.data()); + return FormatArrayAsHex(digest); +} + +/** + * Validate whether the checksum of a file is the same. + * + * @param filename The filename to validate the checksum of. + * @param checksum The expected checksum. + * @return True iff the checksum of the file is the same as the expected checksum. + */ +static bool ValidateChecksum(const std::string &filename, const std::string &checksum) +{ + /* Checksums are "$". Split out the version. */ + auto pos = checksum.find('$'); + assert(pos != std::string::npos); // Already validated by ValidateSchema(). + const std::string version = checksum.substr(0, pos); + const std::string hash = checksum.substr(pos + 1); + + /* Calculate the checksum over the file. */ + std::string calculated_hash; + if (version == "1") { + calculated_hash = CalculateHashV1(filename); + } else { + Debug(misc, 0, "Failed to validate signature: unknown checksum version: {}", filename); + return false; + } + + /* Validate the checksum is the same. */ + if (calculated_hash.empty()) { + Debug(misc, 0, "Failed to validate signature: couldn't calculate checksum for: {}", filename); + return false; + } + if (calculated_hash != hash) { + Debug(misc, 0, "Failed to validate signature: checksum mismatch for: {}", filename); + return false; + } + + return true; +} + +/** + * Validate whether the signature is valid for this set of files. + * + * @param signature The signature to validate. + * @param files The files to validate the signature against. + * @param filename The filename of the signatures file (for error-reporting). + * @return True iff the signature is valid for this set of files. + */ +static bool ValidateSignature(const std::string &signature, const nlohmann::json &files, const std::string &filename) +{ + /* Signatures are "$". Split out the version. */ + auto pos = signature.find('$'); + assert(pos != std::string::npos); // Already validated by ValidateSchema(). + const std::string version = signature.substr(0, pos); + const std::string sig_value = signature.substr(pos + 1); + + /* Create the message we are going to validate. */ + std::string message = files.dump(-1); + + /* Validate the signature. */ + if (version == "1") { + std::array sig; + if (sig_value.size() != 128 || !ConvertHexToBytes(sig_value, sig)) { + Debug(misc, 0, "Failed to validate signature: invalid signature: {}", filename); + return false; + } + + for (auto &pk_value : _public_keys_v1) { + /* Check if the message is valid with this public key. */ + auto res = crypto_ed25519_check(sig.data(), pk_value.data(), reinterpret_cast(message.data()), message.size()); + if (res == 0) { + return true; + } + } + + Debug(misc, 0, "Failed to validate signature: signature validation failed: {}", filename); + return false; + } else { + Debug(misc, 0, "Failed to validate signature: unknown signature version: {}", filename); + return false; + } + + return true; +} + +/** + * Validate the signatures file complies with the JSON schema. + * + * @param signatures The signatures JSON to validate. + * @param filename The filename of the signatures file (for error-reporting). + * @return True iff the signatures file complies with the JSON schema. + */ +static bool ValidateSchema(const nlohmann::json &signatures, const std::string &filename) +{ + if (signatures["files"].is_null()) { + Debug(misc, 0, "Failed to validate signature: no files found: {}", filename); + return false; + } + + if (signatures["signature"].is_null()) { + Debug(misc, 0, "Failed to validate signature: no signature found: {}", filename); + return false; + } + + for (auto &signature : signatures["files"]) { + if (signature["filename"].is_null() || signature["checksum"].is_null()) { + Debug(misc, 0, "Failed to validate signature: invalid entry in files: {}", filename); + return false; + } + + const std::string sig_filename = signature["filename"]; + const std::string sig_checksum = signature["checksum"]; + + if (sig_filename.empty() || sig_checksum.empty()) { + Debug(misc, 0, "Failed to validate signature: invalid entry in files: {}", filename); + return false; + } + + auto pos = sig_checksum.find('$'); + if (pos == std::string::npos) { + Debug(misc, 0, "Failed to validate signature: invalid checksum format: {}", filename); + return false; + } + } + + const std::string signature = signatures["signature"]; + auto pos = signature.find('$'); + if (pos == std::string::npos) { + Debug(misc, 0, "Failed to validate signature: invalid signature format: {}", filename); + return false; + } + + return true; +} + +/** + * Validate that the signatures mentioned in the signature file are matching + * the files in question. + * + * @return True iff the files in the signature file passed validation. + */ +static bool _ValidateSignatureFile(const std::string &filename) +{ + size_t filesize; + FILE *f = FioFOpenFile(filename, "rb", NO_DIRECTORY, &filesize); + if (f == nullptr) { + Debug(misc, 0, "Failed to validate signature: file not found: {}", filename); + return false; + } + + std::string text(filesize, '\0'); + size_t len = fread(text.data(), filesize, 1, f); + if (len != 1) { + Debug(misc, 0, "Failed to validate signature: failed to read file: {}", filename); + return false; + } + + nlohmann::json signatures; + try { + signatures = nlohmann::json::parse(text); + } catch (nlohmann::json::exception &) { + Debug(misc, 0, "Failed to validate signature: not a valid JSON file: {}", filename); + return false; + } + + /* + * The JSON file should look like: + * + * { + * "files": [ + * { + * "checksum": "version$hash" + * "filename": "filename", + * }, + * ... + * ], + * "signature": "version$signature" + * } + * + * The signature is a signed message of the content of "files", dumped as + * JSON without spaces / newlines, keys in the order as indicated above. + */ + + if (!ValidateSchema(signatures, filename)) { + return false; + } + + if (!ValidateSignature(signatures["signature"], signatures["files"], filename)) { + return false; + } + + std::string dirname = std::filesystem::path(filename).parent_path().string(); + + for (auto &signature : signatures["files"]) { + const std::string sig_filename = dirname + PATHSEPCHAR + signature["filename"].get(); + const std::string sig_checksum = signature["checksum"]; + + if (!ValidateChecksum(sig_filename, sig_checksum)) { + return false; + } + } + + return true; +} + +/** + * Validate that the signatures mentioned in the signature file are matching + * the files in question. + * + * @note if ALLOW_INVALID_SIGNATURE is defined, this function will always + * return true (but will still report any errors in the console). + * + * @return True iff the files in the signature file passed validation. + */ +bool ValidateSignatureFile(const std::string &filename) +{ + auto res = _ValidateSignatureFile(filename);; +#if defined(ALLOW_INVALID_SIGNATURE) + (void)res; // Ignore the result. + return true; +#else + return res; +#endif +} diff --git a/src/signature.h b/src/signature.h new file mode 100644 index 0000000000..769aeee591 --- /dev/null +++ b/src/signature.h @@ -0,0 +1,15 @@ +/* + * This file is part of OpenTTD. + * OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2. + * OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see . + */ + +/** @file signature.h Routines to validate signature files. */ + +#ifndef SIGNATURE_H +#define SIGNATURE_H + +bool ValidateSignatureFile(const std::string &filename); + +#endif /* SIGNATURE_H */ diff --git a/src/social_integration.cpp b/src/social_integration.cpp index 528dd4b696..18e8e44eb7 100644 --- a/src/social_integration.cpp +++ b/src/social_integration.cpp @@ -17,6 +17,7 @@ #include "library_loader.h" #include "rev.h" #include "string_func.h" +#include "signature.h" #include "safeguards.h" @@ -25,16 +26,23 @@ */ class InternalSocialIntegrationPlugin { public: - InternalSocialIntegrationPlugin(const std::string &filename, const std::string &basepath) : library(filename), external(basepath) + InternalSocialIntegrationPlugin(const std::string &filename, const std::string &basepath) : library(nullptr), external(basepath) { openttd_info.openttd_version = _openttd_revision; + + if (!ValidateSignatureFile(fmt::format("{}.sig", filename))) { + external.state = SocialIntegrationPlugin::INVALID_SIGNATURE; + return; + } + + this->library = std::make_unique(filename); } OpenTTD_SocialIntegration_v1_PluginInfo plugin_info = {}; ///< Information supplied by plugin. OpenTTD_SocialIntegration_v1_PluginApi plugin_api = {}; ///< API supplied by plugin. OpenTTD_SocialIntegration_v1_OpenTTDInfo openttd_info = {}; ///< Information supplied by OpenTTD. - LibraryLoader library; ///< Library handle. + std::unique_ptr library = nullptr; ///< Library handle. SocialIntegrationPlugin external; ///< Information of the plugin to be used by other parts of our codebase. }; @@ -65,26 +73,31 @@ public: auto &plugin = _plugins.emplace_back(std::make_unique(filename, basepath)); - if (plugin->library.HasError()) { + /* Validation failed, so no library was loaded. */ + if (plugin->library == nullptr) { + return false; + } + + if (plugin->library->HasError()) { plugin->external.state = SocialIntegrationPlugin::FAILED; - Debug(misc, 0, "[Social Integration: {}] Failed to load library: {}", basepath, plugin->library.GetLastError()); + Debug(misc, 0, "[Social Integration: {}] Failed to load library: {}", basepath, plugin->library->GetLastError()); return false; } - OpenTTD_SocialIntegration_v1_GetInfo getinfo_func = plugin->library.GetFunction("SocialIntegration_v1_GetInfo"); - if (plugin->library.HasError()) { + OpenTTD_SocialIntegration_v1_GetInfo getinfo_func = plugin->library->GetFunction("SocialIntegration_v1_GetInfo"); + if (plugin->library->HasError()) { plugin->external.state = SocialIntegrationPlugin::UNSUPPORTED_API; - Debug(misc, 0, "[Social Integration: {}] Failed to find symbol SocialPlugin_v1_GetInfo: {}", basepath, plugin->library.GetLastError()); + Debug(misc, 0, "[Social Integration: {}] Failed to find symbol SocialPlugin_v1_GetInfo: {}", basepath, plugin->library->GetLastError()); return false; } - OpenTTD_SocialIntegration_v1_Init init_func = plugin->library.GetFunction("SocialIntegration_v1_Init"); - if (plugin->library.HasError()) { + OpenTTD_SocialIntegration_v1_Init init_func = plugin->library->GetFunction("SocialIntegration_v1_Init"); + if (plugin->library->HasError()) { plugin->external.state = SocialIntegrationPlugin::UNSUPPORTED_API; - Debug(misc, 0, "[Social Integration: {}] Failed to find symbol SocialPlugin_v1_Init: {}", basepath, plugin->library.GetLastError()); + Debug(misc, 0, "[Social Integration: {}] Failed to find symbol SocialPlugin_v1_Init: {}", basepath, plugin->library->GetLastError()); return false; } diff --git a/src/social_integration.h b/src/social_integration.h index 05c69ad4bc..b3c9b092c2 100644 --- a/src/social_integration.h +++ b/src/social_integration.h @@ -20,6 +20,7 @@ public: UNLOADED, ///< The plugin is unloaded upon request. DUPLICATE, ///< Another plugin of the same Social Platform is already loaded. UNSUPPORTED_API, ///< The plugin does not support the current API version. + INVALID_SIGNATURE, ///< The signature of the plugin is invalid. }; std::string basepath; ///< Base path of the plugin. diff --git a/src/survey.cpp b/src/survey.cpp index fd76e22e8f..89a57fc717 100644 --- a/src/survey.cpp +++ b/src/survey.cpp @@ -90,6 +90,7 @@ NLOHMANN_JSON_SERIALIZE_ENUM(SocialIntegrationPlugin::State, { {SocialIntegrationPlugin::State::UNLOADED, "unloaded"}, {SocialIntegrationPlugin::State::DUPLICATE, "duplicate"}, {SocialIntegrationPlugin::State::UNSUPPORTED_API, "unsupported_api"}, + {SocialIntegrationPlugin::State::INVALID_SIGNATURE, "invalid_signature"}, })