Index: binaries/data/config/default.cfg
===================================================================
--- binaries/data/config/default.cfg
+++ binaries/data/config/default.cfg
@@ -397,6 +397,14 @@
[mod]
enabledmods = "mod public"
+[modio]
+public_key = "RWTA6VIoth2Q1PFLsRILr3G7NB+mwwO8BSGoXs63X6TQgNGM4cE8Pvd6" ; Public key corresponding to the private key valid mods are signed with
+
+[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/common/functions_msgbox.js
===================================================================
--- /dev/null
+++ binaries/data/mods/mod/gui/common/functions_msgbox.js
@@ -0,0 +1,48 @@
+// We want to pass callback functions for the different buttons in a convenient way.
+// Because passing functions accross compartment boundaries is a pain, we just store them here together with some optional arguments.
+// The messageBox page will return the code of the pressed button and the according function will be called.
+var g_MessageBoxBtnFunctions = [];
+var g_MessageBoxCallbackArgs = [];
+
+var g_MessageBoxCallbackFunction = function(btnCode)
+{
+ if (btnCode !== undefined && g_MessageBoxBtnFunctions[btnCode])
+ {
+ // Cache the variables to make it possible to call a messageBox from a callback function.
+ let callbackFunction = g_MessageBoxBtnFunctions[btnCode];
+ let callbackArgs = g_MessageBoxCallbackArgs[btnCode];
+
+ g_MessageBoxBtnFunctions = [];
+ g_MessageBoxCallbackArgs = [];
+
+ if (callbackArgs !== undefined)
+ callbackFunction(callbackArgs);
+ else
+ callbackFunction();
+ return;
+ }
+
+ g_MessageBoxBtnFunctions = [];
+ g_MessageBoxCallbackArgs = [];
+};
+
+function messageBox(mbWidth, mbHeight, mbMessage, mbTitle, mbButtonCaptions, mbBtnCode, mbCallbackArgs)
+{
+ if (g_MessageBoxBtnFunctions && g_MessageBoxBtnFunctions.length)
+ {
+ warn("A messagebox was called when a previous callback function is still set, aborting!");
+ return;
+ }
+
+ g_MessageBoxBtnFunctions = mbBtnCode;
+ g_MessageBoxCallbackArgs = mbCallbackArgs || g_MessageBoxCallbackArgs;
+
+ Engine.PushGuiPage("page_msgbox.xml", {
+ "width": mbWidth,
+ "height": mbHeight,
+ "message": mbMessage,
+ "title": mbTitle,
+ "buttonCaptions": mbButtonCaptions,
+ "callback": mbBtnCode && "g_MessageBoxCallbackFunction"
+ });
+}
Index: binaries/data/mods/mod/gui/modmod/modio.js
===================================================================
--- /dev/null
+++ binaries/data/mods/mod/gui/modmod/modio.js
@@ -0,0 +1,72 @@
+let g_ModsAvailableOnline = [];
+
+function init()
+{
+ g_ModsAvailableOnline = Engine.ModIoGetMods();
+
+ generateModsList(g_ModsAvailableOnline);
+}
+
+function filesizeToString(filesize)
+{
+ let suffixes = ["B", "kiB", "MiB", "GiB"]; // bigger values are currently unlikely to occur here...
+ let i = 0;
+ while (i < suffixes.length-1)
+ {
+ if (filesize < 1024)
+ break;
+ filesize /= 1024;
+ ++i;
+ }
+
+ return filesize.toFixed(i == 0 ? 0 : 1) + suffixes[i];
+}
+
+function generateModsList(mods)
+{
+ var [keys, names, name_ids, versions, filesizes, dependencies] = [[],[],[],[],[],[]];
+
+ 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(filesizeToString(mod.filesize));
+ dependencies.push((mod.dependencies || []).join(" "));
+ }
+
+ 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_dependencies = dependencies;
+
+ obj.list = keys;
+}
+
+function showModDescription()
+{
+ let listObject = Engine.GetGUIObjectByName("modsAvailableList");
+ if (listObject.selected != -1)
+ Engine.GetGUIObjectByName("modDescription").caption = g_ModsAvailableOnline[listObject.selected].summary;
+}
+
+function downloadMod()
+{
+ let listObject = Engine.GetGUIObjectByName("modsAvailableList");
+ if (listObject.selected == -1)
+ {
+ warn("Select something first.");
+ return;
+ }
+
+ Engine.ModIoDownloadMod(+listObject.list[listObject.selected]);
+}
+
+function closePage()
+{
+ Engine.PopGuiPage();
+}
Index: binaries/data/mods/mod/gui/modmod/modio.xml
===================================================================
--- /dev/null
+++ binaries/data/mods/mod/gui/modmod/modio.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
Index: binaries/data/mods/mod/gui/modmod/modmod.js
===================================================================
--- binaries/data/mods/mod/gui/modmod/modmod.js
+++ binaries/data/mods/mod/gui/modmod/modmod.js
@@ -342,6 +342,16 @@
Engine.SwitchGuiPage("page_pregame.xml", {});
}
+function modIo()
+{
+ messageBox(500, 200,
+ translate("You are about to connect to a server that is not under the control of Wildfire Games, that allows you to download mods. While we have taken care to make this secure, there is no absolute certainty that this is not a security risk. Do you really want to connect?"),
+ translate("Connect to mod.io?"),
+ [translate("Cancel"), translateWithContext("mod.io connection message box", "Connect")],
+ [null, function() { Engine.PushGuiPage("page_modio.xml"); }]
+ );
+}
+
/**
* Moves an item in the list @p objectName up or down depending on the value of @p up.
*/
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
@@ -3,7 +3,7 @@
-
+
@@ -92,7 +92,7 @@
showModDescription(this.name);
@@ -121,8 +121,10 @@
Website
-
- [color="100 100 100"]Description[/color]
+
+
+ Check Online
+ modIo();Enable
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: binaries/data/mods/mod/gui/page_msgbox.xml
===================================================================
--- /dev/null
+++ binaries/data/mods/mod/gui/page_msgbox.xml
@@ -0,0 +1,8 @@
+
+
+ common/modern/setup.xml
+ common/modern/styles.xml
+ common/modern/sprites.xml
+
+ msgbox/msgbox.xml
+
Index: binaries/data/mods/public/gui/common/functions_global_object.js
===================================================================
--- binaries/data/mods/public/gui/common/functions_global_object.js
+++ binaries/data/mods/public/gui/common/functions_global_object.js
@@ -1,52 +1,3 @@
-// We want to pass callback functions for the different buttons in a convenient way.
-// Because passing functions accross compartment boundaries is a pain, we just store them here together with some optional arguments.
-// The messageBox page will return the code of the pressed button and the according function will be called.
-var g_MessageBoxBtnFunctions = [];
-var g_MessageBoxCallbackArgs = [];
-
-var g_MessageBoxCallbackFunction = function(btnCode)
-{
- if (btnCode !== undefined && g_MessageBoxBtnFunctions[btnCode])
- {
- // Cache the variables to make it possible to call a messageBox from a callback function.
- let callbackFunction = g_MessageBoxBtnFunctions[btnCode];
- let callbackArgs = g_MessageBoxCallbackArgs[btnCode];
-
- g_MessageBoxBtnFunctions = [];
- g_MessageBoxCallbackArgs = [];
-
- if (callbackArgs !== undefined)
- callbackFunction(callbackArgs);
- else
- callbackFunction();
- return;
- }
-
- g_MessageBoxBtnFunctions = [];
- g_MessageBoxCallbackArgs = [];
-};
-
-function messageBox(mbWidth, mbHeight, mbMessage, mbTitle, mbButtonCaptions, mbBtnCode, mbCallbackArgs)
-{
- if (g_MessageBoxBtnFunctions && g_MessageBoxBtnFunctions.length)
- {
- warn("A messagebox was called when a previous callback function is still set, aborting!");
- return;
- }
-
- g_MessageBoxBtnFunctions = mbBtnCode;
- g_MessageBoxCallbackArgs = mbCallbackArgs || g_MessageBoxCallbackArgs;
-
- Engine.PushGuiPage("page_msgbox.xml", {
- "width": mbWidth,
- "height": mbHeight,
- "message": mbMessage,
- "title": mbTitle,
- "buttonCaptions": mbButtonCaptions,
- "callback": mbBtnCode && "g_MessageBoxCallbackFunction"
- });
-}
-
function openURL(url)
{
Engine.OpenURL(url);
Index: binaries/data/mods/public/gui/msgbox/msgbox.js
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/msgbox/msgbox.js
@@ -1,67 +0,0 @@
-/**
- * Currently limited to at most 3 buttons per message box.
- * The convention is to have "cancel" appear first.
- */
-function init(data)
-{
- // Set title
- Engine.GetGUIObjectByName("mbTitleBar").caption = data.title;
-
- // Set subject
- let mbTextObj = Engine.GetGUIObjectByName("mbText");
- mbTextObj.caption = data.message;
- if (data.font)
- mbTextObj.font = data.font;
-
- // Default behaviour
- let mbCancelHotkey = Engine.GetGUIObjectByName("mbCancelHotkey");
- mbCancelHotkey.onPress = Engine.PopGuiPage;
-
- // Calculate size
- let mbLRDiff = data.width / 2;
- let mbUDDiff = data.height / 2;
- Engine.GetGUIObjectByName("mbMain").size = "50%-" + mbLRDiff + " 50%-" + mbUDDiff + " 50%+" + mbLRDiff + " 50%+" + mbUDDiff;
-
- let captions = data.buttonCaptions || [translate("OK")];
-
- // Set button captions and visibility
- let mbButton = [];
- captions.forEach((caption, i) => {
- mbButton[i] = Engine.GetGUIObjectByName("mbButton" + (i + 1));
-
- let action = function()
- {
- if (data.callback)
- Engine.PopGuiPageCB(i);
- else
- Engine.PopGuiPage();
- };
-
- mbButton[i].caption = caption;
- mbButton[i].onPress = action;
- mbButton[i].hidden = false;
-
- // Convention: Cancel is the first button
- if (i == 0)
- mbCancelHotkey.onPress = action;
- });
-
- // Distribute buttons horizontally
- let y1 = "100%-46";
- let y2 = "100%-18";
- switch (captions.length)
- {
- case 1:
- mbButton[0].size = "18 " + y1 + " 100%-18 " + y2;
- break;
- case 2:
- mbButton[0].size = "18 " + y1 + " 50%-5 " + y2;
- mbButton[1].size = "50%+5 " + y1 + " 100%-18 " + y2;
- break;
- case 3:
- mbButton[0].size = "18 " + y1 + " 33%-5 " + y2;
- mbButton[1].size = "33%+5 " + y1 + " 66%-5 " + y2;
- mbButton[2].size = "66%+5 " + y1 + " 100%-18 " + y2;
- break;
- }
-}
Index: binaries/data/mods/public/gui/msgbox/msgbox.xml
===================================================================
--- /dev/null
+++ binaries/data/mods/public/gui/msgbox/msgbox.xml
@@ -1,42 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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,155 @@
+/* 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.
+ * Only a madman would use the same key for both, but it seems that needs to be stated.
+ *
+ * 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,612 @@
+/* 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;
+
+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.
+ 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;
+ }
+
+ // TODO we should only do this in one single place (same for shutdown, currently we do it here and in the userreporter)
+ {
+ // Initialise everything except Win32 sockets (because our networking
+ // system already inits those)
+ curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32);
+ }
+
+ 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.");
+ }
+
+ 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.
+// This should check for id != -1, but the conversion code is broken.
+// 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)
+// then 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.
+ // TODO: Add a shutdown hook to remove that file if a user requests shutdown.
+ // Which should remove this temporary file if the user terminates the game.
+ // This would require us to actually listen to anything during the download...
+ 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,55 @@
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();
+
+ ENSURE(g_ModIo);
+
+ 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)
+ {
+ LOGERROR("ModIoDownloadMod called before ModIoGetMods");
+ return;
+ }
+
+ 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
+ }
+};