Index: binaries/data/config/default.cfg
===================================================================
--- binaries/data/config/default.cfg
+++ binaries/data/config/default.cfg
@@ -397,6 +397,11 @@
[mod]
enabledmods = "mod public"
+[modio.v1]
+baseurl = "https://api.test.mod.io/v1"
+api_key = "acf8fc07e3a8e9228ef4d5704c1659a1"
+name_id = "0ad"
+
[network]
duplicateplayernames = false ; Rename joining player to "User (2)" if "User" is already connected, otherwise prohibit join.
lateobservers = everyone ; Allow observers to join the game after it started. Possible values: everyone, buddies, disabled.
Index: binaries/data/mods/mod/gui/modmod/modio.js
===================================================================
--- /dev/null
+++ binaries/data/mods/mod/gui/modmod/modio.js
@@ -0,0 +1,64 @@
+let g_Mods = [];
+
+function init()
+{
+ g_Mods = Engine.ModIoGetMods();
+
+ warn(uneval(g_Mods));
+
+ generateModsList(g_Mods);
+}
+
+function generateModsList(mods)
+{
+ var [keys, names, name_ids, versions, filesizes, filehash_md5s, downloads] = [[],[],[],[],[],[],[],[]];
+
+ let i = 0;
+ for (let mod of mods)
+ {
+ keys.push(i++);
+ names.push(mod.name);
+ name_ids.push(mod.name_id);
+ versions.push(mod.version);
+ filesizes.push(mod.filesize);
+ filehash_md5s.push(mod.filehash_md5);
+ downloads.push(mod.binary_url);
+ }
+
+ var obj = Engine.GetGUIObjectByName("modsAvailableList");
+ obj.list_name = names;
+ obj.list_modVersion = versions;
+ obj.list_modname_id = name_ids;
+ obj.list_modfilesize = filesizes;
+ obj.list_modfilehash_md5 = filehash_md5s;
+ obj.list_moddownload = downloads;
+
+ obj.list = keys;
+}
+
+function showModDescription()
+{
+ let listObject = Engine.GetGUIObjectByName("modsAvailableList");
+ let desc = "No mod has been selected.";
+ if (listObject.selected != -1)
+ desc = g_Mods[listObject.selected].summary;
+
+ Engine.GetGUIObjectByName("globalModDescription").caption = desc;
+}
+
+function downloadMod()
+{
+ let listObject = Engine.GetGUIObjectByName("modsAvailableList");
+ if (listObject.selected == -1)
+ {
+ warn("Select something first.");
+ return;
+ }
+
+ Engine.ModIoDownloadMod(listObject.selected);
+}
+
+function closePage()
+{
+ Engine.SwitchGuiPage("page_pregame.xml", {});
+}
Index: binaries/data/mods/mod/gui/modmod/modio.xml
===================================================================
--- /dev/null
+++ binaries/data/mods/mod/gui/modmod/modio.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
Index: binaries/data/mods/mod/gui/modmod/modmod.xml
===================================================================
--- binaries/data/mods/mod/gui/modmod/modmod.xml
+++ binaries/data/mods/mod/gui/modmod/modmod.xml
@@ -193,8 +193,8 @@
- Cancel
- closePage();
+ mod.io test
+ Engine.SwitchGuiPage("page_modio.xml");
Index: binaries/data/mods/mod/gui/page_modio.xml
===================================================================
--- /dev/null
+++ binaries/data/mods/mod/gui/page_modio.xml
@@ -0,0 +1,9 @@
+
+
+ common/modern/setup.xml
+ common/modern/styles.xml
+ common/modern/sprites.xml
+
+ modmod/styles.xml
+ modmod/modio.xml
+
Index: build/premake/extern_libs5.lua
===================================================================
--- build/premake/extern_libs5.lua
+++ build/premake/extern_libs5.lua
@@ -393,6 +393,22 @@
})
end,
},
+ libsodium = {
+ compile_settings = function()
+ if os.istarget("windows") or os.istarget("macosx") then
+ add_default_include_paths("libsodium")
+ end
+ end,
+ link_settings = function()
+ if os.istarget("windows") or os.istarget("macosx") then
+ add_default_lib_paths("libsodium")
+ end
+ add_default_links({
+ win_names = { "libsodium" },
+ unix_names = { "sodium" },
+ })
+ end,
+ },
libxml2 = {
compile_settings = function()
if os.istarget("windows") then
Index: build/premake/premake5.lua
===================================================================
--- build/premake/premake5.lua
+++ build/premake/premake5.lua
@@ -717,6 +717,7 @@
"tinygettext",
"icu",
"iconv",
+ "libsodium",
}
if not _OPTIONS["without-audio"] then
@@ -909,6 +910,7 @@
"tinygettext",
"icu",
"iconv",
+ "libsodium",
"valgrind",
}
Index: source/ps/GameSetup/GameSetup.cpp
===================================================================
--- source/ps/GameSetup/GameSetup.cpp
+++ source/ps/GameSetup/GameSetup.cpp
@@ -68,6 +68,7 @@
#include "ps/Joystick.h"
#include "ps/Loader.h"
#include "ps/Mod.h"
+#include "ps/ModIo.h"
#include "ps/Profile.h"
#include "ps/ProfileViewer.h"
#include "ps/Profiler2.h"
@@ -734,6 +735,8 @@
SAFE_DELETE(g_XmppClient);
+ SAFE_DELETE(g_ModIo);
+
ShutdownPs();
TIMER_BEGIN(L"shutdown TexMan");
Index: source/ps/ModIo.h
===================================================================
--- /dev/null
+++ source/ps/ModIo.h
@@ -0,0 +1,154 @@
+/* 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.
+ */
+
+#ifndef INCLUDED_MODIO
+#define INCLUDED_MODIO
+
+#include
+#include
+
+#include "lib/external_libraries/curl.h"
+
+class DownloadCallbackData;
+class ScriptInterface;
+
+// TODO: Allocate instance of the below two using sodium_malloc?
+struct PKStruct {
+ unsigned char sig_alg[2] = {}; // == "Ed"
+ unsigned char keynum[8] = {}; // should match the keynum in the sigstruct, else this is the wrong key
+ unsigned char pk[crypto_sign_PUBLICKEYBYTES] = {};
+};
+
+struct SigStruct {
+ unsigned char sig_alg[2] = {}; // "ED" (since we only support the hashed mode)
+ unsigned char keynum[8] = {}; // should match the keynum in the PKStruct
+ unsigned char sig[crypto_sign_BYTES] = {};
+};
+
+struct ModIoModData
+{
+ std::map properties;
+ std::vector dependencies;
+ SigStruct sig;
+};
+
+/**
+ * mod.io API interfacing code.
+ *
+ * Overview
+ *
+ * This class interfaces with a remote API provider that returns a list of mod files.
+ * These can then be downloaded after some cursory checking of well-formedness of the returned
+ * metadata.
+ * Downloaded files are checked for well formedness by validating that they fit the size and hash
+ * indicated by the API, then we check if the file is actually signed by a trusted key, and only
+ * if all of that is success the file is actually possible to be loaded as a mod.
+ *
+ * Security considerations
+ *
+ * This both distrusts the loaded JS mods, and the API as much as possible.
+ * We do not want a malicious mod to use this to download arbitrary files, nor do we want the API
+ * to make us download something we have not verified.
+ * Therefore we only allow mods to download one of the mods returned by this class (using indices).
+ *
+ * This (mostly) necessitates parsing the API responses here, as opposed to in JS.
+ * One could alternatively parse the responses in a locked down JS context, but that would require
+ * storing that code in here, or making sure nobody can overwrite it. Also this would possibly make
+ * some of the needed accesses for downloading and verifying files a bit more complicated.
+ *
+ * Everything downloaded from the API has its signature verified against our public key.
+ * This is a requirement, as otherwise a compromise of the API would result in users installing
+ * possibly malicious files.
+ * So a compromised API can just serve old files that we signed, so in that case there would need
+ * to be an issue in that old file that was missed.
+ *
+ * To limit the extend to how old those files could be the signing key should be rotated
+ * regularly (e.g. every release). To allow old versions of the engine to still use the API
+ * files can be signed by both the old and the new key for some amount of time, that however
+ * only makes sense in case a mod is compatible with both engine versions.
+ *
+ * TODO: One should probably sign the new key with the old one to ensure a nice upgrade path,
+ * though that does not really make any difference since releases aren't signed either.
+ * And if releases are signed, then we can just rely on that instead of complicating things.
+ *
+ * Note that this does not prevent all possible attacks a package manager/update system should
+ * defend against. This is intentionally not an update system since proper package managers already
+ * exist. However there is some possible overlap in attack vectors and these should be evalutated
+ * whether they apply and to what extend we can fix that on our side (or how to get the API provider
+ * to help us do so). For a list of some possible issues see:
+ * https://github.com/theupdateframework/specification/blob/master/tuf-spec.md
+ *
+ * The mod.io settings are also locked down such that only mods that have been authorized by us
+ * show up in API queries. This is both done so that all required information (dependencies)
+ * are stored for the files, and that only mods that have been checked for being ok are acutally
+ * shown to users.
+ */
+class ModIo
+{
+ NONCOPYABLE(ModIo);
+public:
+ ModIo();
+ ~ModIo();
+
+ const std::vector& GetMods(const ScriptInterface& scriptinterface);
+
+ void DownloadMod(size_t idx);
+
+private:
+ static size_t ReceiveCallback(void* buffer, size_t size, size_t nmemb, void* userp);
+ static size_t DownloadCallback(void* buffer, size_t size, size_t nmemb, void* userp);
+
+ CURLcode PerformRequest(const std::string& url);
+
+ static bool ParseGameIdResponse(const ScriptInterface& scriptInterface, const std::string& responseData, int& id);
+ static bool ParseModsResponse(const ScriptInterface& scriptInterface, const std::string& responseData, std::vector& modData, const PKStruct& pk);
+ static bool ParseSignature(const std::vector& minisigs, SigStruct& sig, const PKStruct& pk);
+
+ bool ParseGameId(const ScriptInterface& scriptInterface);
+ bool ParseMods(const ScriptInterface& scriptInterface);
+
+ bool VerifyDownload(const OsPath& filePath, DownloadCallbackData& callbackData, const ModIoModData& modData) const;
+
+ // Url parts
+ std::string m_BaseUrl;
+ std::string m_GamesRequest;
+ std::string m_GameId;
+
+ // Query parameters
+ std::string m_ApiKey;
+ std::string m_IdQuery;
+
+ CURL* m_Curl;
+ curl_slist* m_Headers;
+ char m_ErrorBuffer[CURL_ERROR_SIZE];
+ std::string m_ResponseData;
+
+ PKStruct m_pk;
+
+ std::vector m_ModData;
+
+ friend class TestModIo;
+};
+
+extern ModIo* g_ModIo;
+
+#endif // INCLUDED_MODIO
Index: source/ps/ModIo.cpp
===================================================================
--- /dev/null
+++ source/ps/ModIo.cpp
@@ -0,0 +1,610 @@
+/* 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 "lib/file/file_system.h"
+#include "lib/sysdep/sysdep.h"
+#include "lib/sysdep/filesystem.h"
+#include "maths/MD5.h"
+#include "ps/CLogger.h"
+#include "ps/ConfigDB.h"
+#include "ps/GameSetup/Paths.h"
+#include "ps/Mod.h"
+#include "scriptinterface/ScriptConversions.h"
+#include "scriptinterface/ScriptInterface.h"
+
+#include
+
+#include
+
+#include
+#include
+
+ModIo* g_ModIo = nullptr;
+// TODO: add a compile-time config so we can build without this
+
+struct DownloadCallbackData {
+ 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")
+{
+ // 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.
+ 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;
+ }
+
+ // Initialise everything except Win32 sockets (because our networking
+ // system already inits those)
+ curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32);
+// TODO we should only do this in one single place (same for shutdown, currently we do it here and in the userreporter)
+
+ 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
+ curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L);
+
+ 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());
+ // TODO more?
+ curl_easy_setopt(m_Curl, CURLOPT_HTTPHEADER, m_Headers);
+
+ if (sodium_init() < 0) {
+ LOGERROR("Failed to initialize libsodium");
+ ENSURE(0 && "sodium_init returned success.");
+ }
+
+ // TODO: related to that, what if one signature is valid, and another is invalid (in the actual check)? (probably consider it invalid since well wtf)
+ // Should not happen, since there should be at most one signature for a given key (else someone got quite unlucky generating one).
+ // TODO: get this from the config?
+ const std::string pk_str = "RWTA6VIoth2Q1PFLsRILr3G7NB+mwwO8BSGoXs63X6TQgNGM4cE8Pvd6";
+
+ 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)
+ {
+ LOGERROR("failed to decode base64 public key");
+ ENSURE(0 && "invalid public key");
+ }
+}
+
+ModIo::~ModIo()
+{
+ curl_slist_free_all(m_Headers);
+ curl_easy_cleanup(m_Curl);
+ curl_global_cleanup(); // TODO
+}
+
+size_t ModIo::ReceiveCallback(void* buffer, size_t size, size_t nmemb, void* userp)
+{
+ ModIo* self = static_cast(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(userp);
+
+ 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.
+
+ data->md5.Update((const u8*)buffer, len*size);
+ crypto_generichash_update(&data->hash_state, (const u8*)buffer, len*size);
+
+ return len*size;
+}
+
+CURLcode ModIo::PerformRequest(const std::string& url)
+{
+ m_ErrorBuffer[0] = '\0';
+ curl_easy_setopt(m_Curl, CURLOPT_URL, url.c_str());
+ return curl_easy_perform(m_Curl);
+}
+
+const std::vector& ModIo::GetMods(const ScriptInterface& scriptInterface)
+{
+ m_ModData.clear();
+
+ // Set IO callbacks
+ curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, ReceiveCallback);
+ curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, this);
+
+ // Get the game id if we didn't fetch it already
+ if (m_GameId.empty())
+ {
+ CURLcode err = PerformRequest(m_BaseUrl+m_GamesRequest+"?"+m_ApiKey+"&"+m_IdQuery);
+
+ if (err != CURLE_OK)
+ {
+ LOGERROR("Failure while querying for game id. Server response: %s; %s", curl_easy_strerror(err), m_ErrorBuffer);
+
+ return m_ModData;
+ }
+
+ if (!ParseGameId(scriptInterface))
+ return m_ModData;
+ }
+
+ // Get the actual mods
+ CURLcode err = PerformRequest(m_BaseUrl+m_GamesRequest+m_GameId+"/mods?"+m_ApiKey);
+ if (err != CURLE_OK)
+ {
+ LOGERROR("Failure while querying for mods. Server response: %s; %s", curl_easy_strerror(err), m_ErrorBuffer);
+ return m_ModData;
+ }
+
+ if (!ParseMods(scriptInterface))
+ m_ModData.clear(); // Failed during parsing, make sure we don't provide partial data
+
+ return m_ModData;
+}
+
+#define FAIL(...) STMT(LOGERROR(__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)
+{
+#define CLEANUP() id = -1;
+ JSContext* cx = scriptInterface.GetContext();
+ 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.");
+
+ id = -1;
+// TODO: This check currently does not really do anything if "id" is present...
+// meaning we can set id to be null and we get id=0.
+// There is a script value conversion check failed warning, but that does not change anything.
+// TODO: We should probably make those fail (hard), if that isn't done by default (which it probably should)
+// the we should add a templated variant that does.
+// TODO check if id < 0 (<=?) and if yes also fail here
+// NOTE: FromJSProperty does set things to probably 0 even if there is some stupid type conversion
+// So we check for <= 0, so we actually get proper results here...
+ // Valid ids are always > 0.
+ if (!ScriptInterface::FromJSProperty(cx, first, "id", id) || id <= 0)
+ FAIL("couldn't get id");
+
+ return true;
+#undef CLEANUP
+}
+
+bool ModIo::ParseGameId(const ScriptInterface& scriptInterface)
+{
+ int id = -1;
+ if (!ParseGameIdResponse(scriptInterface, m_ResponseData, id))
+ {
+ m_ResponseData.clear();
+ return false;
+ }
+
+ m_ResponseData.clear();
+
+ m_GameId = "/" + std::to_string(id);
+ return true;
+}
+
+/**
+ * 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& modData, const PKStruct& pk)
+{
+// Make sure we don't end up passing partial results back
+#define CLEANUP() modData.clear();
+
+ JSContext* cx = scriptInterface.GetContext();
+ 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");
+
+ std::vector minisigs;
+ if (!ScriptInterface::FromJSProperty(cx, metadata, "minisigs", minisigs))
+ FAIL("failed to get minisigs from metadata");
+
+ // Remove this entry if we did not find a valid matching signature
+ if (!ParseSignature(minisigs, modData.back().sig, pk))
+ 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& minisigs, SigStruct& sig, const PKStruct& pk)
+{
+#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
+ // TODO: Verify that both the untrusted comment and the trusted comment start with the correct prefix
+
+ std::vector sig_lines;
+ boost::split(sig_lines, file_sig, boost::is_any_of("\n"));
+ if (sig_lines.size() < 4)
+ FAIL("invalid (too short) sig");
+
+ // 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(&sig.sig_alg, "ED", 2) != 0)
+ FAIL("only hashed minisign signatures are supported");
+
+ if (memcmp(&pk.keynum, &sig.keynum, sizeof sig.keynum) != 0)
+ continue; // mismatched key, try another one
+
+ // 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_prefix = "trusted comment: ";
+ if (sig_lines[2].size() < trusted_comment_prefix.size())
+ FAIL("malformed trusted comment");
+
+ 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)
+ {
+ LOGERROR("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
+
+bool ModIo::ParseMods(const ScriptInterface& scriptInterface)
+{
+ bool ret = ParseModsResponse(scriptInterface, m_ResponseData, m_ModData, m_pk);
+ m_ResponseData.clear();
+ return ret;
+}
+
+void ModIo::DownloadMod(size_t idx)
+{
+ if (idx >= m_ModData.size())
+ return;
+
+ // TODO: do this asynchronously? or at least tell the gui that we are doing something -> progress bar (or something spinning)
+
+ 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))
+ {
+ LOGERROR("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.
+ const OsPath filePath = modPath/(m_ModData[idx].properties["name_id"]+".zip.temp");
+ DownloadCallbackData callbackData(sys_OpenFile(filePath, "wb"));
+ if (!callbackData.fp)
+ {
+ LOGERROR("Could not open temporary file for mod download: %s", filePath.string8());
+ return;
+ }
+
+ // Set IO callbacks
+ curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, DownloadCallback);
+ curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, (void*)&callbackData);
+
+ LOGERROR("Trying to download %s", m_ModData[idx].properties["binary_url"].c_str());
+
+ // The download link will most likely redirect elsewhere, so allow that.
+ // We verify the validity of the file below.
+ curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 1L);
+ // One redirect seems plenty for a CDN servint the files.
+ curl_easy_setopt(m_Curl, CURLOPT_MAXREDIRS, 1L);
+
+ // Download the file
+ CURLcode err = PerformRequest(m_ModData[idx].properties["binary_url"]);
+ fclose(callbackData.fp);
+ callbackData.fp = NULL;
+
+ // To minimise security risks, don't support redirects for queries
+ curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L);
+
+ if (err != CURLE_OK)
+ {
+ LOGERROR("Failure while downloading mod file. Server response: %s; %s", curl_easy_strerror(err), m_ErrorBuffer);
+ if (wunlink(filePath) != 0)
+ LOGERROR("Failed to delete file.");
+ return;
+ }
+
+ if (!VerifyDownload(filePath, callbackData, m_ModData[idx]))
+ {
+ LOGERROR("File %s failed to verify.", filePath.string8());
+ // delete the file again as it does not match
+ if (wunlink(filePath) != 0)
+ LOGERROR("Failed to delete file.");
+
+ return;
+ }
+
+ const OsPath finalFilePath = modPath/(m_ModData[idx].properties["name_id"]+".zip");
+ if (wrename(filePath, finalFilePath) != 0)
+ {
+ LOGERROR("failed to rename file.");
+ wunlink(filePath);
+ return;
+ }
+
+ LOGERROR("downloaded something and verified it successfully.");
+
+ // TODO: hook into .pyromod code to do the win32 specific thing because win32...
+}
+
+bool ModIo::VerifyDownload(const OsPath& filePath, DownloadCallbackData& callbackData, const ModIoModData& modData) const
+{
+ {
+ u64 filesize = std::stoull(modData.properties.at("filesize"));
+ if (filesize != FileSize(filePath))
+ {
+ LOGERROR("Invalid filesize.");
+ return false;
+ }
+ }
+
+ // MD5 (because upstream provides it)
+ // Just used to make sure there was no obvious corruption during transfer.
+ {
+ u8 digest[MD5::DIGESTSIZE];
+ 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 (modData.properties.at("filehash_md5") != md5digest.str())
+ {
+ LOGERROR("Invalid file. Expected md5 %s, got %s.", modData.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(&callbackData.hash_state, hash_fin, sizeof hash_fin) != 0)
+ {
+ LOGERROR("failed to compute final hash");
+ return false;
+ }
+
+ if (crypto_sign_verify_detached(modData.sig.sig, hash_fin, sizeof hash_fin, m_pk.pk) != 0)
+ {
+ LOGERROR("failed to verify signature");
+ return false;
+ }
+
+ return true;
+}
Index: source/ps/scripting/JSInterface_Mod.h
===================================================================
--- source/ps/scripting/JSInterface_Mod.h
+++ source/ps/scripting/JSInterface_Mod.h
@@ -26,6 +26,9 @@
JS::Value GetAvailableMods(ScriptInterface::CxPrivate* pCxPrivate);
void RestartEngine(ScriptInterface::CxPrivate* pCxPrivate);
void SetMods(ScriptInterface::CxPrivate* pCxPrivate, const std::vector& mods);
+
+ JS::Value ModIoGetMods(ScriptInterface::CxPrivate* pCxPrivate);
+ void ModIoDownloadMod(ScriptInterface::CxPrivate* pCxPrivate, uint32_t idx);
}
#endif
Index: source/ps/scripting/JSInterface_Mod.cpp
===================================================================
--- source/ps/scripting/JSInterface_Mod.cpp
+++ source/ps/scripting/JSInterface_Mod.cpp
@@ -1,4 +1,4 @@
-/* Copyright (C) 2017 Wildfire Games.
+/* Copyright (C) 2018 Wildfire Games.
* This file is part of 0 A.D.
*
* 0 A.D. is free software: you can redistribute it and/or modify
@@ -27,6 +27,7 @@
#include "ps/GameSetup/GameSetup.h"
#include "ps/GameSetup/Paths.h"
#include "ps/Mod.h"
+#include "ps/ModIo.h"
#include "ps/Profile.h"
#include "ps/scripting/JSInterface_Mod.h"
@@ -123,9 +124,53 @@
g_modsLoaded = mods;
}
+JS::Value JSI_Mod::ModIoGetMods(ScriptInterface::CxPrivate* pCxPrivate)
+{
+ ScriptInterface* scriptInterface = pCxPrivate->pScriptInterface;
+ JSContext* cx = scriptInterface->GetContext();
+ JSAutoRequest rq(cx);
+
+ if (!g_ModIo)
+ g_ModIo = new ModIo();
+
+ if (!g_ModIo)
+ return JS::NullValue(); // TODO: error/warning?
+
+ const std::vector& availableMods = g_ModIo->GetMods(*scriptInterface);
+
+ JS::RootedObject mods(cx, JS_NewArrayObject(cx, availableMods.size()));
+ if (!mods)
+ return JS::NullValue(); // TODO: error?
+
+ u32 i = 0;
+ for (const ModIoModData& mod : availableMods)
+ {
+ JS::RootedValue m(cx, JS::ObjectValue(*JS_NewPlainObject(cx)));
+ for (const std::pair& prop : mod.properties)
+ scriptInterface->SetProperty(m, prop.first.c_str(), prop.second, true);
+
+ scriptInterface->SetProperty(m, "dependencies", mod.dependencies, true);
+
+ JS_SetElement(cx, mods, i++, m);
+ }
+
+ return JS::ObjectValue(*mods);
+}
+
+void JSI_Mod::ModIoDownloadMod(ScriptInterface::CxPrivate* UNUSED(pCxPrivate), uint32_t idx)
+{
+ if (!g_ModIo)
+ return; // TODO: Warn and tell users to fix their code?
+
+ g_ModIo->DownloadMod(idx);
+}
+
void JSI_Mod::RegisterScriptFunctions(const ScriptInterface& scriptInterface)
{
scriptInterface.RegisterFunction("GetAvailableMods");
scriptInterface.RegisterFunction("RestartEngine");
scriptInterface.RegisterFunction, &JSI_Mod::SetMods>("SetMods");
+
+ scriptInterface.RegisterFunction("ModIoGetMods");
+ scriptInterface.RegisterFunction("ModIoDownloadMod");
}
Index: source/ps/tests/test_ModIo.h
===================================================================
--- /dev/null
+++ source/ps/tests/test_ModIo.h
@@ -0,0 +1,239 @@
+/* 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 "lib/self_test.h"
+
+#include "ps/ModIo.h"
+#include "ps/CLogger.h"
+#include "scriptinterface/ScriptInterface.h"
+
+#include
+
+class TestModIo : public CxxTest::TestSuite
+{
+public:
+ void setUp()
+ {
+ if (sodium_init() < 0)
+ LOGERROR("failed to initialize libsodium");
+ }
+
+ void test_id_parsing()
+ {
+ ScriptInterface script("Test", "Test", g_ScriptRuntime);
+
+ // TODO: One could probably fuzz this parsing function nicely to make sure it handles
+ // malformed input nicely.
+
+#define TS_ASSERT_PARSE(input, expected_error, expected_id) \
+ { \
+ TestLogger logger; \
+ int id = -1; \
+ TS_ASSERT(!ModIo::ParseGameIdResponse(script, input, id)); \
+ TS_ASSERT_STR_CONTAINS(logger.GetOutput(), expected_error); \
+ TS_ASSERT_EQUALS(id, expected_id); \
+ }
+
+ // Various malformed inputs
+ TS_ASSERT_PARSE("", "Failed to parse response as JSON", -1);
+ TS_ASSERT_PARSE("()", "Failed to parse response as JSON", -1);
+ TS_ASSERT_PARSE("[]", "data property not an object", -1);
+ TS_ASSERT_PARSE("null", "response not an object", -1);
+ TS_ASSERT_PARSE("{}", "data property not an object", -1);
+ TS_ASSERT_PARSE("{\"data\": null}", "data property not an object", -1);
+ TS_ASSERT_PARSE("{\"data\": {}}", "data property not an array with at least one element", -1);
+ TS_ASSERT_PARSE("{\"data\": []}", "data property not an array with at least one element", -1);
+ TS_ASSERT_PARSE("{\"data\": [null]}", "couldn't get id", -1);
+ TS_ASSERT_PARSE("{\"data\": [false]}", "couldn't get id", -1);
+ TS_ASSERT_PARSE("{\"data\": [{}]}", "couldn't get id", -1);
+ TS_ASSERT_PARSE("{\"data\": [[]]}", "couldn't get id", -1);
+
+ // TODO: These only return -1 since we set id to that when we fail.
+ // This should actually not be needed, but our parsing code does strange things.
+ TS_ASSERT_PARSE("{\"data\": [{\"id\": null}]}", "couldn't get id", -1);
+ TS_ASSERT_PARSE("{\"data\": [{\"id\": {}}]}", "couldn't get id", -1);
+ TS_ASSERT_PARSE("{\"data\": [{\"id\": -12}]}", "couldn't get id", -1);
+ TS_ASSERT_PARSE("{\"data\": [{\"id\": 0}]}", "couldn't get id", -1);
+
+ // TODO: This should fail, but our parsing code just warns in case we pass something of the wrong
+ // type, instead of failing.
+ TS_ASSERT_PARSE("{\"data\": [{\"id\": true}]}", "couldn't get id", -1); // TODO: This fails since parsing is bogus.
+
+#undef TS_ASSERT_PARSE
+
+ // Correctly formed input
+ {
+ TestLogger logger;
+ int id = -1;
+ TS_ASSERT(ModIo::ParseGameIdResponse(script, "{\"data\": [{\"id\": 42}]}", id));
+ TS_ASSERT_STR_NOT_CONTAINS(logger.GetOutput(), "ERROR");
+ TS_ASSERT_EQUALS(id, 42);
+ }
+ }
+
+ void test_mods_parsing()
+ {
+ ScriptInterface script("Test", "Test", g_ScriptRuntime);
+
+ PKStruct pk;
+
+ const std::string pk_str = "RWTA6VIoth2Q1PFLsRILr3G7NB+mwwO8BSGoXs63X6TQgNGM4cE8Pvd6";
+
+ size_t bin_len = 0;
+ if (sodium_base642bin((unsigned char*)&pk, sizeof pk, pk_str.c_str(), pk_str.size(), NULL, &bin_len, NULL, sodium_base64_VARIANT_ORIGINAL) != 0 || bin_len != sizeof pk)
+ LOGERROR("failed to decode base64 public key");
+
+#define TS_ASSERT_PARSE(input, expected_error) \
+ { \
+ TestLogger logger; \
+ std::vector mods; \
+ TS_ASSERT(!ModIo::ParseModsResponse(script, input, mods, pk)); \
+ TS_ASSERT_STR_CONTAINS(logger.GetOutput(), expected_error); \
+ TS_ASSERT_EQUALS(mods.size(), 0); \
+ }
+
+ TS_ASSERT_PARSE("", "Failed to parse response as JSON.");
+ TS_ASSERT_PARSE("()", "Failed to parse response as JSON.");
+ TS_ASSERT_PARSE("null", "response not an object");
+ TS_ASSERT_PARSE("[]", "data property not an object");
+ TS_ASSERT_PARSE("{}", "data property not an object");
+ TS_ASSERT_PARSE("{\"data\": null}", "data property not an object");
+ TS_ASSERT_PARSE("{\"data\": {}}", "data property not an array with at least one element");
+ TS_ASSERT_PARSE("{\"data\": []}", "data property not an array with at least one element");
+ TS_ASSERT_PARSE("{\"data\": [null]}", "Failed to get array element object");
+ TS_ASSERT_PARSE("{\"data\": [false]}", "Failed to get array element object");
+ TS_ASSERT_PARSE("{\"data\": [true]}", "Failed to get array element object");
+ TS_ASSERT_PARSE("{\"data\": [{}]}", "failed to get name from el");
+ TS_ASSERT_PARSE("{\"data\": [[]]}", "failed to get name from el");
+ TS_ASSERT_PARSE("{\"data\": [{\"foo\":\"bar\"}]}", "failed to get name from el");
+
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":null}]}", "failed to get name_id from el"); // also some script value conversion check warning
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":42}]}", "failed to get name_id from el"); // no conversion warning, but converting numbers to strings and vice-versa seems ok
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":false}]}", "failed to get name_id from el"); // also some script value conversion check warning
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":{}}]}", "failed to get name_id from el"); // also some script value conversion check warning
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":[]}]}", "failed to get name_id from el"); // also some script value conversion check warning
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"foobar\"}]}", "failed to get name_id from el");
+
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\"}]}", "modfile not an object");
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":null}]}", "modfile not an object");
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":[]}]}", "failed to get version from modFile");
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{}}]}", "failed to get version from modFile");
+
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":null}}]}", "failed to get filesize from modFile"); // also some script value conversion check warning
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234}}]}", "failed to get md5 from filehash");
+
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":null}}]}", "failed to get md5 from filehash");
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{}}}]}", "failed to get md5 from filehash");
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":null}}}]}", "failed to get binary_url from download"); // also some script value conversion check warning
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}}}]}", "failed to get binary_url from download");
+
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":null}}]}", "failed to get binary_url from download"); // also some script value conversion check warning
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":null}}}]}", "failed to get metadata_blob from modFile"); // also some script value conversion check warning
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":\"\"}}}]}", "failed to get metadata_blob from modFile");
+
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":\"\"},\"metadata_blob\":null}}]}", "metadata_blob not decoded as an object");
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":\"\"},\"metadata_blob\":\"\"}}]}", "Failed to parse metadata_blob as JSON");
+
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":\"\"},\"metadata_blob\":\"{}\"}}]}", "failed to get dependencies from metadata");
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":\"\"},\"metadata_blob\":\"{\\\"dependencies\\\":null}\"}}]}", "failed to get dependencies from metadata");
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":\"\"},\"metadata_blob\":\"{\\\"dependencies\\\":[]}\"}}]}", "failed to get minisigs from metadata");
+ TS_ASSERT_PARSE("{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":\"\"},\"metadata_blob\":\"{\\\"dependencies\\\":[],\\\"minisigs\\\":null}\"}}]}", "failed to get minisigs from metadata");
+
+#undef TS_ASSERT_PARSE
+
+ // Correctly formed input, but no signature matching the public key
+ // Thus all such mods/modfiles are not added, thus we get 0 parsed mods.
+ {
+ TestLogger logger;
+ std::vector mods;
+ TS_ASSERT(ModIo::ParseModsResponse(script, "{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":\"\"},\"metadata_blob\":\"{\\\"dependencies\\\":[],\\\"minisigs\\\":[]}\"}}]}", mods, pk));
+ TS_ASSERT_STR_NOT_CONTAINS(logger.GetOutput(), "ERROR");
+ TS_ASSERT_EQUALS(mods.size(), 0);
+ }
+
+ // Correctly formed input (with a signature matching the public key above, and a valid global signature)
+ {
+ TestLogger logger;
+ std::vector mods;
+ TS_ASSERT(ModIo::ParseModsResponse(script, "{\"data\": [{\"name\":\"\",\"name_id\":\"\",\"summary\":\"\",\"modfile\":{\"version\":\"\",\"filesize\":1234, \"filehash\":{\"md5\":\"abc\"}, \"download\":{\"binary_url\":\"\"},\"metadata_blob\":\"{\\\"dependencies\\\":[],\\\"minisigs\\\":[\\\"untrusted comment: signature from minisign secret key\\\\nRUTA6VIoth2Q1HUg5bwwbCUZPcqbQ/reLXqxiaWARH5PNcwxX5vBv/mLPLgdxGsIrOyK90763+rCVTmjeYx5BDz8C0CIbGZTNQs=\\\\ntrusted comment: timestamp:1517285433\\\\tfile:tm.zip\\\\nTHwNMhK4Ogj6XA4305p1K9/ouP/DrxPcDFrPaiu+Ke6/WGlHIzBZHvmHWUedvsK6dzL31Gk8YNzscKWnZqWNCw==\\\"]}\"}}]}", mods, pk));
+ TS_ASSERT_STR_NOT_CONTAINS(logger.GetOutput(), "ERROR");
+ TS_ASSERT_EQUALS(mods.size(), 1);
+ }
+ }
+
+ void test_signature_parsing()
+ {
+ PKStruct pk;
+
+ const std::string pk_str = "RWTA6VIoth2Q1PFLsRILr3G7NB+mwwO8BSGoXs63X6TQgNGM4cE8Pvd6";
+
+ size_t bin_len = 0;
+ if (sodium_base642bin((unsigned char*)&pk, sizeof pk, pk_str.c_str(), pk_str.size(), NULL, &bin_len, NULL, sodium_base64_VARIANT_ORIGINAL) != 0 || bin_len != sizeof pk)
+ LOGERROR("failed to decode base64 public key");
+
+
+ // No invalid signature at all (silent failure)
+#define TS_ASSERT_PARSE_SILENT_FAILURE(input) \
+ { \
+ TestLogger logger; \
+ SigStruct sig; \
+ TS_ASSERT(!ModIo::ParseSignature({input}, sig, pk)); \
+ TS_ASSERT_STR_NOT_CONTAINS(logger.GetOutput(), "ERROR"); \
+ }
+
+#define TS_ASSERT_PARSE(input, expected_error) \
+ { \
+ TestLogger logger; \
+ SigStruct sig; \
+ TS_ASSERT(!ModIo::ParseSignature({input}, sig, pk)); \
+ TS_ASSERT_STR_CONTAINS(logger.GetOutput(), expected_error); \
+ }
+
+ TS_ASSERT_PARSE_SILENT_FAILURE();
+
+ TS_ASSERT_PARSE("", "invalid (too short) sig");
+ TS_ASSERT_PARSE("\n\n\n", "failed to decode base64 sig");
+ TS_ASSERT_PARSE("\nZm9vYmFyCg==\n\n", "failed to decode base64 sig");
+ TS_ASSERT_PARSE("\nRWTA6VIoth2Q1HUg5bwwbCUZPcqbQ/reLXqxiaWARH5PNcwxX5vBv/mLPLgdxGsIrOyK90763+rCVTmjeYx5BDz8C0CIbGZTNQs=\n\n", "only hashed minisign signatures are supported");
+
+ // Silent failure again this one has the wrong keynum
+ TS_ASSERT_PARSE_SILENT_FAILURE("\nRUTA5VIoth2Q1HUg5bwwbCUZPcqbQ/reLXqxiaWARH5PNcwxX5vBv/mLPLgdxGsIrOyK90763+rCVTmjeYx5BDz8C0CIbGZTNQs=\n\n");
+
+ TS_ASSERT_PARSE("\nRUTA6VIoth2Q1HUg5bwwbCUZPcqbQ/reLXqxiaWARH5PNcwxX5vBv/mLPLgdxGsIrOyK90763+rCVTmjeYx5BDz8C0CIbGZTNQs=\n\n", "failed to decode base64 global_sig");
+ TS_ASSERT_PARSE("\nRUTA6VIoth2Q1HUg5bwwbCUZPcqbQ/reLXqxiaWARH5PNcwxX5vBv/mLPLgdxGsIrOyK90763+rCVTmjeYx5BDz8C0CIbGZTNQs=\n\nTHwNMhK4Ogj6XA4305p1K9/ouP/DrxPcDFrPaiu+Ke6/WGlHIzBZHvmHWUedvsK6dzL31Gk8YNzscKWnZqWNCw==", "malformed trusted comment");
+
+ // TODO: Test for both the untrusted comment and the trusted comment to actually start with that
+
+ TS_ASSERT_PARSE("\nRUTA6VIoth2Q1HUg5bwwbCUZPcqbQ/reLXqxiaWARH5PNcwxX5vBv/mLPLgdxGsIrOyK90763+rCVTmjeYx5BDz8C0CIbGZTNQs=\ntrusted comment: timestamp:1517285433\tfile:tm.zip\nAHwNMhK4Ogj6XA4305p1K9/ouP/DrxPcDFrPaiu+Ke6/WGlHIzBZHvmHWUedvsK6dzL31Gk8YNzscKWnZqWNCw==", "failed to verify global signature");
+
+ // Valid signature
+ {
+ TestLogger logger;
+ SigStruct sig;
+ TS_ASSERT(ModIo::ParseSignature({"\nRUTA6VIoth2Q1HUg5bwwbCUZPcqbQ/reLXqxiaWARH5PNcwxX5vBv/mLPLgdxGsIrOyK90763+rCVTmjeYx5BDz8C0CIbGZTNQs=\ntrusted comment: timestamp:1517285433\tfile:tm.zip\nTHwNMhK4Ogj6XA4305p1K9/ouP/DrxPcDFrPaiu+Ke6/WGlHIzBZHvmHWUedvsK6dzL31Gk8YNzscKWnZqWNCw=="}, sig, pk));
+ TS_ASSERT_STR_NOT_CONTAINS(logger.GetOutput(), "ERROR");
+ }
+
+#undef TS_ASSERT_PARSE_SILENT_FAILURE
+#undef TS_ASSERT_PARSE
+ }
+};