Changeset View
Changeset View
Standalone View
Standalone View
ps/trunk/source/ps/ModIo.cpp
Property | Old Value | New Value |
---|---|---|
svn:eol-style | null | native \ No newline at end of property |
/* Copyright (C) 2018 Wildfire Games. | |||||
* | |||||
* Permission is hereby granted, free of charge, to any person obtaining | |||||
* a copy of this software and associated documentation files (the | |||||
* "Software"), to deal in the Software without restriction, including | |||||
* without limitation the rights to use, copy, modify, merge, publish, | |||||
* distribute, sublicense, and/or sell copies of the Software, and to | |||||
* permit persons to whom the Software is furnished to do so, subject to | |||||
* the following conditions: | |||||
* | |||||
* The above copyright notice and this permission notice shall be included | |||||
* in all copies or substantial portions of the Software. | |||||
* | |||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. | |||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY | |||||
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, | |||||
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE | |||||
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |||||
*/ | |||||
#include "precompiled.h" | |||||
#include "ModIo.h" | |||||
#include "i18n/L10n.h" | |||||
#include "lib/file/file_system.h" | |||||
#include "lib/sysdep/filesystem.h" | |||||
#include "lib/sysdep/sysdep.h" | |||||
#include "maths/MD5.h" | |||||
#include "ps/CLogger.h" | |||||
#include "ps/ConfigDB.h" | |||||
#include "ps/GameSetup/Paths.h" | |||||
#include "ps/Mod.h" | |||||
#include "ps/ModInstaller.h" | |||||
#include "scriptinterface/ScriptConversions.h" | |||||
#include "scriptinterface/ScriptInterface.h" | |||||
#include <boost/algorithm/string.hpp> | |||||
#include <iomanip> | |||||
ModIo* g_ModIo = nullptr; | |||||
struct DownloadCallbackData | |||||
{ | |||||
DownloadCallbackData() | |||||
: fp(nullptr), md5() | |||||
{ | |||||
} | |||||
DownloadCallbackData(FILE* _fp) | |||||
: fp(_fp), md5() | |||||
{ | |||||
crypto_generichash_init(&hash_state, NULL, 0U, crypto_generichash_BYTES_MAX); | |||||
} | |||||
FILE* fp; | |||||
MD5 md5; | |||||
crypto_generichash_state hash_state; | |||||
}; | |||||
ModIo::ModIo() | |||||
: m_GamesRequest("/games"), m_CallbackData(nullptr) | |||||
{ | |||||
// Get config values from the sytem namespace, or below (default). | |||||
// This can be overridden on the command line. | |||||
// | |||||
// We do this so a malicious mod cannot change the base url and | |||||
// get the user to make connections to someone else's endpoint. | |||||
// If another user of the engine wants to provide different values | |||||
// here, while still using the same engine version, they can just | |||||
// provide some shortcut/script that sets these using command line | |||||
// parameters. | |||||
std::string pk_str; | |||||
g_ConfigDB.GetValue(CFG_SYSTEM, "modio.public_key", pk_str); | |||||
g_ConfigDB.GetValue(CFG_SYSTEM, "modio.v1.baseurl", m_BaseUrl); | |||||
{ | |||||
std::string api_key; | |||||
g_ConfigDB.GetValue(CFG_SYSTEM, "modio.v1.api_key", api_key); | |||||
m_ApiKey = "api_key=" + api_key; | |||||
} | |||||
{ | |||||
std::string nameid; | |||||
g_ConfigDB.GetValue(CFG_SYSTEM, "modio.v1.name_id", nameid); | |||||
m_IdQuery = "name_id="+nameid; | |||||
} | |||||
m_CurlMulti = curl_multi_init(); | |||||
ENSURE(m_CurlMulti); | |||||
m_Curl = curl_easy_init(); | |||||
ENSURE(m_Curl); | |||||
// Capture error messages | |||||
curl_easy_setopt(m_Curl, CURLOPT_ERRORBUFFER, m_ErrorBuffer); | |||||
// Fail if the server did | |||||
curl_easy_setopt(m_Curl, CURLOPT_FAILONERROR, 1L); | |||||
// Disable signal handlers (required for multithreaded applications) | |||||
curl_easy_setopt(m_Curl, CURLOPT_NOSIGNAL, 1L); | |||||
// To minimise security risks, don't support redirects (except for file | |||||
// downloads, for which this setting will be enabled). | |||||
curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L); | |||||
// For file downloads, one redirect seems plenty for a CDN serving the files. | |||||
curl_easy_setopt(m_Curl, CURLOPT_MAXREDIRS, 1L); | |||||
m_Headers = NULL; | |||||
std::string ua = "User-Agent: pyrogenesis "; | |||||
ua += curl_version(); | |||||
ua += " (https://play0ad.com/)"; | |||||
m_Headers = curl_slist_append(m_Headers, ua.c_str()); | |||||
curl_easy_setopt(m_Curl, CURLOPT_HTTPHEADER, m_Headers); | |||||
if (sodium_init() < 0) | |||||
ENSURE(0 && "Failed to initialize libsodium."); | |||||
size_t bin_len = 0; | |||||
if (sodium_base642bin((unsigned char*)&m_pk, sizeof m_pk, pk_str.c_str(), pk_str.size(), NULL, &bin_len, NULL, sodium_base64_VARIANT_ORIGINAL) != 0 || bin_len != sizeof m_pk) | |||||
ENSURE(0 && "Failed to decode base64 public key. Please fix your configuration or mod.io will be unusable."); | |||||
} | |||||
ModIo::~ModIo() | |||||
{ | |||||
// Clean things up to avoid unpleasant surprises, | |||||
// and delete the temporary file if any. | |||||
TearDownRequest(); | |||||
if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) | |||||
DeleteDownloadedFile(); | |||||
curl_slist_free_all(m_Headers); | |||||
curl_easy_cleanup(m_Curl); | |||||
curl_multi_cleanup(m_CurlMulti); | |||||
delete m_CallbackData; | |||||
} | |||||
size_t ModIo::ReceiveCallback(void* buffer, size_t size, size_t nmemb, void* userp) | |||||
{ | |||||
ModIo* self = static_cast<ModIo*>(userp); | |||||
self->m_ResponseData += std::string((char*)buffer, (char*)buffer+size*nmemb); | |||||
return size*nmemb; | |||||
} | |||||
size_t ModIo::DownloadCallback(void* buffer, size_t size, size_t nmemb, void* userp) | |||||
{ | |||||
DownloadCallbackData* data = static_cast<DownloadCallbackData*>(userp); | |||||
if (!data->fp) | |||||
return 0; | |||||
size_t len = fwrite(buffer, size, nmemb, data->fp); | |||||
// Only update the hash with data we actually managed to write. | |||||
// In case we did not write all of it we will fail the download, | |||||
// but we do not want to have a possibly valid hash in that case. | |||||
size_t written = len*size; | |||||
data->md5.Update((const u8*)buffer, written); | |||||
crypto_generichash_update(&data->hash_state, (const u8*)buffer, written); | |||||
return written; | |||||
} | |||||
int ModIo::DownloadProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t UNUSED(ultotal), curl_off_t UNUSED(ulnow)) | |||||
{ | |||||
DownloadProgressData* data = static_cast<DownloadProgressData*>(clientp); | |||||
// If we got more data than curl expected, something is very wrong, abort. | |||||
if (dltotal != 0 && dlnow > dltotal) | |||||
return 1; | |||||
data->progress = dltotal == 0 ? 0 : static_cast<double>(dlnow) / static_cast<double>(dltotal); | |||||
return 0; | |||||
} | |||||
CURLMcode ModIo::SetupRequest(const std::string& url, bool fileDownload) | |||||
{ | |||||
if (fileDownload) | |||||
{ | |||||
// The download link will most likely redirect elsewhere, so allow that. | |||||
// We verify the validity of the file later. | |||||
curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 1L); | |||||
// Enable the progress meter | |||||
curl_easy_setopt(m_Curl, CURLOPT_NOPROGRESS, 0L); | |||||
// Set IO callbacks | |||||
curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, DownloadCallback); | |||||
curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, (void*)m_CallbackData); | |||||
curl_easy_setopt(m_Curl, CURLOPT_XFERINFOFUNCTION, DownloadProgressCallback); | |||||
curl_easy_setopt(m_Curl, CURLOPT_XFERINFODATA, (void*)&m_DownloadProgressData); | |||||
// Initialize the progress counter | |||||
m_DownloadProgressData.progress = 0; | |||||
} | |||||
else | |||||
{ | |||||
// To minimise security risks, don't support redirects | |||||
curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L); | |||||
// Disable the progress meter | |||||
curl_easy_setopt(m_Curl, CURLOPT_NOPROGRESS, 1L); | |||||
// Set IO callbacks | |||||
curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, ReceiveCallback); | |||||
curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, this); | |||||
} | |||||
m_ErrorBuffer[0] = '\0'; | |||||
curl_easy_setopt(m_Curl, CURLOPT_URL, url.c_str()); | |||||
return curl_multi_add_handle(m_CurlMulti, m_Curl); | |||||
} | |||||
void ModIo::TearDownRequest() | |||||
{ | |||||
ENSURE(curl_multi_remove_handle(m_CurlMulti, m_Curl) == CURLM_OK); | |||||
if (m_CallbackData) | |||||
{ | |||||
if (m_CallbackData->fp) | |||||
fclose(m_CallbackData->fp); | |||||
m_CallbackData->fp = nullptr; | |||||
} | |||||
} | |||||
void ModIo::StartGetGameId() | |||||
{ | |||||
// Don't start such a request during active downloads. | |||||
if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID || | |||||
m_DownloadProgressData.status == DownloadProgressStatus::LISTING || | |||||
m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) | |||||
return; | |||||
m_GameId.clear(); | |||||
CURLMcode err = SetupRequest(m_BaseUrl+m_GamesRequest+"?"+m_ApiKey+"&"+m_IdQuery, false); | |||||
if (err != CURLM_OK) | |||||
{ | |||||
TearDownRequest(); | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID; | |||||
m_DownloadProgressData.error = fmt::sprintf( | |||||
g_L10n.Translate("Failure while starting querying for game id. Error: %s; %s."), | |||||
curl_multi_strerror(err), m_ErrorBuffer); | |||||
return; | |||||
} | |||||
m_DownloadProgressData.status = DownloadProgressStatus::GAMEID; | |||||
} | |||||
void ModIo::StartListMods() | |||||
{ | |||||
// Don't start such a request during active downloads. | |||||
if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID || | |||||
m_DownloadProgressData.status == DownloadProgressStatus::LISTING || | |||||
m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) | |||||
return; | |||||
m_ModData.clear(); | |||||
if (m_GameId.empty()) | |||||
{ | |||||
LOGERROR("Game ID not fetched from mod.io. Call StartGetGameId first and wait for it to finish."); | |||||
return; | |||||
} | |||||
CURLMcode err = SetupRequest(m_BaseUrl+m_GamesRequest+m_GameId+"/mods?"+m_ApiKey, false); | |||||
if (err != CURLM_OK) | |||||
{ | |||||
TearDownRequest(); | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING; | |||||
m_DownloadProgressData.error = fmt::sprintf( | |||||
g_L10n.Translate("Failure while starting querying for mods. Error: %s; %s."), | |||||
curl_multi_strerror(err), m_ErrorBuffer); | |||||
return; | |||||
} | |||||
m_DownloadProgressData.status = DownloadProgressStatus::LISTING; | |||||
} | |||||
void ModIo::StartDownloadMod(size_t idx) | |||||
{ | |||||
// Don't start such a request during active downloads. | |||||
if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID || | |||||
m_DownloadProgressData.status == DownloadProgressStatus::LISTING || | |||||
m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) | |||||
return; | |||||
if (idx >= m_ModData.size()) | |||||
return; | |||||
const Paths paths(g_args); | |||||
const OsPath modUserPath = paths.UserData()/"mods"; | |||||
const OsPath modPath = modUserPath/m_ModData[idx].properties["name_id"]; | |||||
if (!DirectoryExists(modPath) && INFO::OK != CreateDirectories(modPath, 0700, false)) | |||||
{ | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; | |||||
m_DownloadProgressData.error = fmt::sprintf( | |||||
g_L10n.Translate("Could not create mod directory: %s."), modPath.string8()); | |||||
return; | |||||
} | |||||
// Name the file after the name_id, since using the filename would mean that | |||||
// we could end up with multiple zip files in the folder that might not work | |||||
// as expected for a user (since a later version might remove some files | |||||
// that aren't compatible anymore with the engine version). | |||||
// So we ignore the filename provided by the API and assume that we do not | |||||
// care about handling update.zip files. If that is the case we would need | |||||
// a way to find out what files are required by the current one and which | |||||
// should be removed for everything to work. This seems to be too complicated | |||||
// so we just do not support that usage. | |||||
// NOTE: We do save the file under a slightly different name from the final | |||||
// one, to ensure that in case a download aborts and the file stays | |||||
// around, the game will not attempt to open the file which has not | |||||
// been verified. | |||||
m_DownloadFilePath = modPath/(m_ModData[idx].properties["name_id"]+".zip.temp"); | |||||
delete m_CallbackData; | |||||
m_CallbackData = new DownloadCallbackData(sys_OpenFile(m_DownloadFilePath, "wb")); | |||||
if (!m_CallbackData->fp) | |||||
{ | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; | |||||
m_DownloadProgressData.error = fmt::sprintf( | |||||
g_L10n.Translate("Could not open temporary file for mod download: %s."), m_DownloadFilePath.string8()); | |||||
return; | |||||
} | |||||
CURLMcode err = SetupRequest(m_ModData[idx].properties["binary_url"], true); | |||||
if (err != CURLM_OK) | |||||
{ | |||||
TearDownRequest(); | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; | |||||
m_DownloadProgressData.error = fmt::sprintf( | |||||
g_L10n.Translate("Failed to start the download. Error: %s; %s."), curl_multi_strerror(err), m_ErrorBuffer); | |||||
return; | |||||
} | |||||
m_DownloadModID = idx; | |||||
m_DownloadProgressData.status = DownloadProgressStatus::DOWNLOADING; | |||||
} | |||||
void ModIo::CancelRequest() | |||||
{ | |||||
TearDownRequest(); | |||||
switch (m_DownloadProgressData.status) | |||||
{ | |||||
case DownloadProgressStatus::GAMEID: | |||||
case DownloadProgressStatus::FAILED_GAMEID: | |||||
m_DownloadProgressData.status = DownloadProgressStatus::NONE; | |||||
break; | |||||
case DownloadProgressStatus::LISTING: | |||||
case DownloadProgressStatus::FAILED_LISTING: | |||||
m_DownloadProgressData.status = DownloadProgressStatus::READY; | |||||
break; | |||||
case DownloadProgressStatus::DOWNLOADING: | |||||
case DownloadProgressStatus::FAILED_DOWNLOADING: | |||||
m_DownloadProgressData.status = DownloadProgressStatus::LISTED; | |||||
DeleteDownloadedFile(); | |||||
break; | |||||
default: | |||||
break; | |||||
} | |||||
} | |||||
bool ModIo::AdvanceRequest(const ScriptInterface& scriptInterface) | |||||
{ | |||||
// If the request was cancelled, stop trying to advance it | |||||
if (m_DownloadProgressData.status != DownloadProgressStatus::GAMEID && | |||||
m_DownloadProgressData.status != DownloadProgressStatus::LISTING && | |||||
m_DownloadProgressData.status != DownloadProgressStatus::DOWNLOADING) | |||||
return true; | |||||
int stillRunning; | |||||
CURLMcode err = curl_multi_perform(m_CurlMulti, &stillRunning); | |||||
if (err != CURLM_OK) | |||||
{ | |||||
std::string error = fmt::sprintf( | |||||
g_L10n.Translate("Asynchronous download failure: %s, %s."), curl_multi_strerror(err), m_ErrorBuffer); | |||||
TearDownRequest(); | |||||
if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID) | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID; | |||||
else if (m_DownloadProgressData.status == DownloadProgressStatus::LISTING) | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING; | |||||
else if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) | |||||
{ | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; | |||||
DeleteDownloadedFile(); | |||||
} | |||||
m_DownloadProgressData.error = error; | |||||
return true; | |||||
} | |||||
CURLMsg* message; | |||||
do | |||||
{ | |||||
int in_queue; | |||||
message = curl_multi_info_read(m_CurlMulti, &in_queue); | |||||
if (!message || message->msg == CURLMSG_DONE || message->easy_handle == m_Curl) | |||||
continue; | |||||
CURLcode err = message->data.result; | |||||
if (err == CURLE_OK) | |||||
continue; | |||||
std::string error = fmt::sprintf( | |||||
g_L10n.Translate("Download failure. Server response: %s; %s."), curl_easy_strerror(err), m_ErrorBuffer); | |||||
TearDownRequest(); | |||||
if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID) | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID; | |||||
else if (m_DownloadProgressData.status == DownloadProgressStatus::LISTING) | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING; | |||||
else if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING) | |||||
{ | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING; | |||||
DeleteDownloadedFile(); | |||||
} | |||||
m_DownloadProgressData.error = error; | |||||
return true; | |||||
} while (message); | |||||
if (stillRunning) | |||||
return false; | |||||
// Download finished. | |||||
TearDownRequest(); | |||||
// Perform parsing and/or checks | |||||
std::string error; | |||||
switch (m_DownloadProgressData.status) | |||||
{ | |||||
case DownloadProgressStatus::GAMEID: | |||||
if (!ParseGameId(scriptInterface, error)) | |||||
{ | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID; | |||||
m_DownloadProgressData.error = error; | |||||
break; | |||||
} | |||||
m_DownloadProgressData.status = DownloadProgressStatus::READY; | |||||
break; | |||||
case DownloadProgressStatus::LISTING: | |||||
if (!ParseMods(scriptInterface, error)) | |||||
{ | |||||
m_ModData.clear(); // Failed during parsing, make sure we don't provide partial data | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING; | |||||
m_DownloadProgressData.error = error; | |||||
break; | |||||
} | |||||
m_DownloadProgressData.status = DownloadProgressStatus::LISTED; | |||||
break; | |||||
case DownloadProgressStatus::DOWNLOADING: | |||||
if (!VerifyDownloadedFile(error)) | |||||
{ | |||||
m_DownloadProgressData.status = DownloadProgressStatus::FAILED_FILECHECK; | |||||
m_DownloadProgressData.error = error; | |||||
DeleteDownloadedFile(); | |||||
break; | |||||
} | |||||
m_DownloadProgressData.status = DownloadProgressStatus::SUCCESS; | |||||
{ | |||||
Paths paths(g_args); | |||||
CModInstaller installer(paths.UserData() / "mods", paths.Cache()); | |||||
installer.Install(m_DownloadFilePath, g_ScriptRuntime, false); | |||||
} | |||||
break; | |||||
default: | |||||
break; | |||||
} | |||||
return true; | |||||
} | |||||
bool ModIo::ParseGameId(const ScriptInterface& scriptInterface, std::string& err) | |||||
{ | |||||
int id = -1; | |||||
bool ret = ParseGameIdResponse(scriptInterface, m_ResponseData, id, err); | |||||
m_ResponseData.clear(); | |||||
if (!ret) | |||||
return false; | |||||
m_GameId = "/" + std::to_string(id); | |||||
return true; | |||||
} | |||||
bool ModIo::ParseMods(const ScriptInterface& scriptInterface, std::string& err) | |||||
{ | |||||
bool ret = ParseModsResponse(scriptInterface, m_ResponseData, m_ModData, m_pk, err); | |||||
m_ResponseData.clear(); | |||||
return ret; | |||||
} | |||||
void ModIo::DeleteDownloadedFile() | |||||
{ | |||||
if (wunlink(m_DownloadFilePath) != 0) | |||||
LOGERROR("Failed to delete temporary file."); | |||||
m_DownloadFilePath = OsPath(); | |||||
} | |||||
bool ModIo::VerifyDownloadedFile(std::string& err) | |||||
{ | |||||
// Verify filesize, as a first basic download check. | |||||
{ | |||||
u64 filesize = std::stoull(m_ModData[m_DownloadModID].properties.at("filesize")); | |||||
if (filesize != FileSize(m_DownloadFilePath)) | |||||
{ | |||||
err = g_L10n.Translate("Mismatched filesize."); | |||||
return false; | |||||
} | |||||
} | |||||
ENSURE(m_CallbackData); | |||||
// MD5 (because upstream provides it) | |||||
// Just used to make sure there was no obvious corruption during transfer. | |||||
{ | |||||
u8 digest[MD5::DIGESTSIZE]; | |||||
m_CallbackData->md5.Final(digest); | |||||
std::stringstream md5digest; | |||||
md5digest << std::hex << std::setfill('0'); | |||||
for (size_t i = 0; i < MD5::DIGESTSIZE; ++i) | |||||
md5digest << std::setw(2) << (int)digest[i]; | |||||
if (m_ModData[m_DownloadModID].properties.at("filehash_md5") != md5digest.str()) | |||||
{ | |||||
err = fmt::sprintf( | |||||
g_L10n.Translate("Invalid file. Expected md5 %s, got %s."), | |||||
m_ModData[m_DownloadModID].properties.at("filehash_md5").c_str(), | |||||
md5digest.str()); | |||||
return false; | |||||
} | |||||
} | |||||
// Verify file signature. | |||||
// Used to make sure that the downloaded file was actually checked and signed | |||||
// by Wildfire Games. And has not been tampered with by the API provider, or the CDN. | |||||
unsigned char hash_fin[crypto_generichash_BYTES_MAX] = {}; | |||||
if (crypto_generichash_final(&m_CallbackData->hash_state, hash_fin, sizeof hash_fin) != 0) | |||||
{ | |||||
err = g_L10n.Translate("Failed to compute final hash."); | |||||
return false; | |||||
} | |||||
if (crypto_sign_verify_detached(m_ModData[m_DownloadModID].sig.sig, hash_fin, sizeof hash_fin, m_pk.pk) != 0) | |||||
{ | |||||
err = g_L10n.Translate("Failed to verify signature."); | |||||
return false; | |||||
} | |||||
return true; | |||||
} | |||||
#define FAIL(...) STMT(err = fmt::sprintf(__VA_ARGS__); CLEANUP(); return false;) | |||||
/** | |||||
* Parses the current content of m_ResponseData to extract m_GameId. | |||||
* | |||||
* The JSON data is expected to look like | |||||
* { "data": [{"id": 42, ...}, ...], ... } | |||||
* where we are only interested in the value of the id property. | |||||
* | |||||
* @returns true iff it successfully parsed the id. | |||||
*/ | |||||
bool ModIo::ParseGameIdResponse(const ScriptInterface& scriptInterface, const std::string& responseData, int& id, std::string& err) | |||||
{ | |||||
#define CLEANUP() id = -1; | |||||
JSContext* cx = scriptInterface.GetContext(); | |||||
JSAutoRequest rq(cx); | |||||
JS::RootedValue gameResponse(cx); | |||||
if (!scriptInterface.ParseJSON(responseData, &gameResponse)) | |||||
FAIL("Failed to parse response as JSON."); | |||||
if (!gameResponse.isObject()) | |||||
FAIL("response not an object."); | |||||
JS::RootedObject gameResponseObj(cx, gameResponse.toObjectOrNull()); | |||||
JS::RootedValue dataVal(cx); | |||||
if (!JS_GetProperty(cx, gameResponseObj, "data", &dataVal)) | |||||
FAIL("data property not in response."); | |||||
// [{"id": 42, ...}, ...] | |||||
if (!dataVal.isObject()) | |||||
FAIL("data property not an object."); | |||||
JS::RootedObject data(cx, dataVal.toObjectOrNull()); | |||||
u32 length; | |||||
if (!JS_IsArrayObject(cx, data) || !JS_GetArrayLength(cx, data, &length) || !length) | |||||
FAIL("data property not an array with at least one element."); | |||||
// {"id": 42, ...} | |||||
JS::RootedValue first(cx); | |||||
if (!JS_GetElement(cx, data, 0, &first)) | |||||
FAIL("Couldn't get first element."); | |||||
if (!first.isObject()) | |||||
FAIL("First element not an object."); | |||||
JS::RootedObject firstObj(cx, &first.toObject()); | |||||
bool hasIdProperty; | |||||
if (!JS_HasProperty(cx, firstObj, "id", &hasIdProperty) || !hasIdProperty) | |||||
FAIL("No id property in first element."); | |||||
JS::RootedValue idProperty(cx); | |||||
ENSURE(JS_GetProperty(cx, firstObj, "id", &idProperty)); | |||||
// Make sure the property is not set to something that could be converted to a bogus value | |||||
// TODO: We should be able to convert JS::Values to C++ variables in a way that actually | |||||
// fails when types do not match (see https://trac.wildfiregames.com/ticket/5128). | |||||
if (!idProperty.isNumber()) | |||||
FAIL("id property not a number."); | |||||
id = -1; | |||||
if (!ScriptInterface::FromJSVal(cx, idProperty, id) || id <= 0) | |||||
FAIL("Invalid id."); | |||||
return true; | |||||
#undef CLEANUP | |||||
} | |||||
/** | |||||
* Parses the current content of m_ResponseData into m_ModData. | |||||
* | |||||
* The JSON data is expected to look like | |||||
* { data: [modobj1, modobj2, ...], ... (including result_count) } | |||||
* where modobjN has the following structure | |||||
* { homepage: "url", name: "displayname", nameid: "short-non-whitespace-name", | |||||
* summary: "short desc.", modfile: { version: "1.2.4", filename: "asdf.zip", | |||||
* filehash: { md5: "deadbeef" }, filesize: 1234, download: { binary_url: "someurl", ... } }, ... }. | |||||
* Only the listed properties are of interest to consumers, and we flatten | |||||
* the modfile structure as that simplifies handling and there are no conflicts. | |||||
*/ | |||||
bool ModIo::ParseModsResponse(const ScriptInterface& scriptInterface, const std::string& responseData, std::vector<ModIoModData>& modData, const PKStruct& pk, std::string& err) | |||||
{ | |||||
// Make sure we don't end up passing partial results back | |||||
#define CLEANUP() modData.clear(); | |||||
JSContext* cx = scriptInterface.GetContext(); | |||||
JSAutoRequest rq(cx); | |||||
JS::RootedValue modResponse(cx); | |||||
if (!scriptInterface.ParseJSON(responseData, &modResponse)) | |||||
FAIL("Failed to parse response as JSON."); | |||||
if (!modResponse.isObject()) | |||||
FAIL("response not an object."); | |||||
JS::RootedObject modResponseObj(cx, modResponse.toObjectOrNull()); | |||||
JS::RootedValue dataVal(cx); | |||||
if (!JS_GetProperty(cx, modResponseObj, "data", &dataVal)) | |||||
FAIL("data property not in response."); | |||||
// [modobj1, modobj2, ... ] | |||||
if (!dataVal.isObject()) | |||||
FAIL("data property not an object."); | |||||
JS::RootedObject data(cx, dataVal.toObjectOrNull()); | |||||
u32 length; | |||||
if (!JS_IsArrayObject(cx, data) || !JS_GetArrayLength(cx, data, &length) || !length) | |||||
FAIL("data property not an array with at least one element."); | |||||
modData.clear(); | |||||
modData.reserve(length); | |||||
for (u32 i = 0; i < length; ++i) | |||||
{ | |||||
JS::RootedValue el(cx); | |||||
if (!JS_GetElement(cx, data, i, &el) || !el.isObject()) | |||||
FAIL("Failed to get array element object."); | |||||
modData.emplace_back(); | |||||
#define COPY_STRINGS(prefix, obj, ...) \ | |||||
for (const std::string& prop : { __VA_ARGS__ }) \ | |||||
{ \ | |||||
std::string val; \ | |||||
if (!ScriptInterface::FromJSProperty(cx, obj, prop.c_str(), val)) \ | |||||
FAIL("Failed to get %s from %s.", prop, #obj);\ | |||||
modData.back().properties.emplace(prefix+prop, val); \ | |||||
} | |||||
// TODO: Currently the homepage field does not contain a non-null value for any entry. | |||||
COPY_STRINGS("", el, "name", "name_id", "summary"); | |||||
// Now copy over the modfile part, but without the pointless substructure | |||||
JS::RootedObject elObj(cx, el.toObjectOrNull()); | |||||
JS::RootedValue modFile(cx); | |||||
if (!JS_GetProperty(cx, elObj, "modfile", &modFile)) | |||||
FAIL("Failed to get modfile data."); | |||||
if (!modFile.isObject()) | |||||
FAIL("modfile not an object."); | |||||
COPY_STRINGS("", modFile, "version", "filesize"); | |||||
JS::RootedObject modFileObj(cx, modFile.toObjectOrNull()); | |||||
JS::RootedValue filehash(cx); | |||||
if (!JS_GetProperty(cx, modFileObj, "filehash", &filehash)) | |||||
FAIL("Failed to get filehash data."); | |||||
COPY_STRINGS("filehash_", filehash, "md5"); | |||||
JS::RootedValue download(cx); | |||||
if (!JS_GetProperty(cx, modFileObj, "download", &download)) | |||||
FAIL("Failed to get download data."); | |||||
COPY_STRINGS("", download, "binary_url"); | |||||
// Parse metadata_blob (sig+deps) | |||||
std::string metadata_blob; | |||||
if (!ScriptInterface::FromJSProperty(cx, modFile, "metadata_blob", metadata_blob)) | |||||
FAIL("Failed to get metadata_blob from modFile."); | |||||
JS::RootedValue metadata(cx); | |||||
if (!scriptInterface.ParseJSON(metadata_blob, &metadata)) | |||||
FAIL("Failed to parse metadata_blob as JSON."); | |||||
if (!metadata.isObject()) | |||||
FAIL("metadata_blob not decoded as an object."); | |||||
if (!ScriptInterface::FromJSProperty(cx, metadata, "dependencies", modData.back().dependencies)) | |||||
FAIL("Failed to get dependencies from metadata_blob."); | |||||
std::vector<std::string> minisigs; | |||||
if (!ScriptInterface::FromJSProperty(cx, metadata, "minisigs", minisigs)) | |||||
FAIL("Failed to get minisigs from metadata_blob."); | |||||
// Remove this entry if we did not find a valid matching signature. | |||||
std::string signatureParsingErr; | |||||
if (!ParseSignature(minisigs, modData.back().sig, pk, signatureParsingErr)) | |||||
modData.pop_back(); | |||||
#undef COPY_STRINGS | |||||
} | |||||
return true; | |||||
#undef CLEANUP | |||||
} | |||||
/** | |||||
* Parse signatures to find one that matches the public key, and has a valid global signature. | |||||
* Returns true and sets @param sig to the valid matching signature. | |||||
*/ | |||||
bool ModIo::ParseSignature(const std::vector<std::string>& minisigs, SigStruct& sig, const PKStruct& pk, std::string& err) | |||||
{ | |||||
#define CLEANUP() sig = {}; | |||||
for (const std::string& file_sig : minisigs) | |||||
{ | |||||
// Format of a .minisig file (created using minisign(1) with -SHm file.zip) | |||||
// untrusted comment: .*\nb64sign_of_file\ntrusted comment: .*\nb64sign_of_sign_of_file_and_trusted_comment | |||||
std::vector<std::string> sig_lines; | |||||
boost::split(sig_lines, file_sig, boost::is_any_of("\n")); | |||||
if (sig_lines.size() < 4) | |||||
FAIL("Invalid (too short) sig."); | |||||
// Verify that both the untrusted comment and the trusted comment start with the correct prefix | |||||
// because that is easy. | |||||
const std::string untrusted_comment_prefix = "untrusted comment: "; | |||||
const std::string trusted_comment_prefix = "trusted comment: "; | |||||
if (!boost::algorithm::starts_with(sig_lines[0], untrusted_comment_prefix)) | |||||
FAIL("Malformed untrusted comment."); | |||||
if (!boost::algorithm::starts_with(sig_lines[2], trusted_comment_prefix)) | |||||
FAIL("Malformed trusted comment."); | |||||
// We only _really_ care about the second line which is the signature of the file (b64-encoded) | |||||
// Also handling the other signature is nice, but not really required. | |||||
const std::string& msg_sig = sig_lines[1]; | |||||
size_t bin_len = 0; | |||||
if (sodium_base642bin((unsigned char*)&sig, sizeof sig, msg_sig.c_str(), msg_sig.size(), NULL, &bin_len, NULL, sodium_base64_VARIANT_ORIGINAL) != 0 || bin_len != sizeof sig) | |||||
FAIL("Failed to decode base64 sig."); | |||||
cassert(sizeof pk.keynum == sizeof sig.keynum); | |||||
if (memcmp(&pk.keynum, &sig.keynum, sizeof sig.keynum) != 0) | |||||
continue; // mismatched key, try another one | |||||
if (memcmp(&sig.sig_alg, "ED", 2) != 0) | |||||
FAIL("Only hashed minisign signatures are supported."); | |||||
// Signature matches our public key | |||||
// Now verify the global signature (sig || trusted_comment) | |||||
unsigned char global_sig[crypto_sign_BYTES]; | |||||
if (sodium_base642bin(global_sig, sizeof global_sig, sig_lines[3].c_str(), sig_lines[3].size(), NULL, &bin_len, NULL, sodium_base64_VARIANT_ORIGINAL) != 0 || bin_len != sizeof global_sig) | |||||
FAIL("Failed to decode base64 global_sig."); | |||||
const std::string trusted_comment = sig_lines[2].substr(trusted_comment_prefix.size()); | |||||
unsigned char* sig_and_trusted_comment = (unsigned char*)sodium_malloc((sizeof sig.sig) + trusted_comment.size()); | |||||
if (!sig_and_trusted_comment) | |||||
FAIL("sodium_malloc failed."); | |||||
memcpy(sig_and_trusted_comment, sig.sig, sizeof sig.sig); | |||||
memcpy(sig_and_trusted_comment + sizeof sig.sig, trusted_comment.data(), trusted_comment.size()); | |||||
if (crypto_sign_verify_detached(global_sig, sig_and_trusted_comment, (sizeof sig.sig) + trusted_comment.size(), pk.pk) != 0) | |||||
{ | |||||
err = "Failed to verify global signature."; | |||||
sodium_free(sig_and_trusted_comment); | |||||
return false; | |||||
} | |||||
sodium_free(sig_and_trusted_comment); | |||||
// Valid global sig, and the keynum matches the real one | |||||
return true; | |||||
} | |||||
return false; | |||||
#undef CLEANUP | |||||
} | |||||
#undef FAIL |
Wildfire Games · Phabricator