Line data Source code
1 : /* Copyright (C) 2021 Wildfire Games.
2 : *
3 : * Permission is hereby granted, free of charge, to any person obtaining
4 : * a copy of this software and associated documentation files (the
5 : * "Software"), to deal in the Software without restriction, including
6 : * without limitation the rights to use, copy, modify, merge, publish,
7 : * distribute, sublicense, and/or sell copies of the Software, and to
8 : * permit persons to whom the Software is furnished to do so, subject to
9 : * the following conditions:
10 : *
11 : * The above copyright notice and this permission notice shall be included
12 : * in all copies or substantial portions of the Software.
13 : *
14 : * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 : * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 : * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17 : * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18 : * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19 : * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20 : * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 : */
22 :
23 : #include "precompiled.h"
24 :
25 : #include "ModIo.h"
26 :
27 : #include "i18n/L10n.h"
28 : #include "lib/file/file_system.h"
29 : #include "lib/sysdep/filesystem.h"
30 : #include "lib/sysdep/sysdep.h"
31 : #include "maths/MD5.h"
32 : #include "ps/CLogger.h"
33 : #include "ps/ConfigDB.h"
34 : #include "ps/GameSetup/CmdLineArgs.h"
35 : #include "ps/GameSetup/Paths.h"
36 : #include "ps/Mod.h"
37 : #include "ps/ModInstaller.h"
38 : #include "ps/Util.h"
39 : #include "scriptinterface/ScriptConversions.h"
40 : #include "scriptinterface/ScriptContext.h"
41 : #include "scriptinterface/ScriptRequest.h"
42 : #include "scriptinterface/JSON.h"
43 :
44 : #include <boost/algorithm/string.hpp>
45 : #include <iomanip>
46 :
47 : ModIo* g_ModIo = nullptr;
48 :
49 : struct DownloadCallbackData
50 : {
51 : DownloadCallbackData()
52 : : fp(nullptr), md5(), hash_state(nullptr)
53 : {
54 : }
55 :
56 0 : DownloadCallbackData(FILE* _fp)
57 0 : : fp(_fp), md5()
58 : {
59 0 : hash_state = static_cast<crypto_generichash_state*>(
60 0 : sodium_malloc(crypto_generichash_statebytes()));
61 :
62 0 : ENSURE(hash_state);
63 :
64 0 : crypto_generichash_init(hash_state, nullptr, 0U, crypto_generichash_BYTES_MAX);
65 0 : }
66 :
67 0 : ~DownloadCallbackData()
68 0 : {
69 0 : if (hash_state)
70 0 : sodium_free(hash_state);
71 0 : }
72 :
73 : FILE* fp;
74 : MD5 md5;
75 : crypto_generichash_state* hash_state;
76 : };
77 :
78 0 : ModIo::ModIo()
79 0 : : m_GamesRequest("/games"), m_CallbackData(nullptr)
80 : {
81 : // Get config values from the default namespace.
82 : // This can be overridden on the command line.
83 : //
84 : // We do this so a malicious mod cannot change the base url and
85 : // get the user to make connections to someone else's endpoint.
86 : // If another user of the engine wants to provide different values
87 : // here, while still using the same engine version, they can just
88 : // provide some shortcut/script that sets these using command line
89 : // parameters.
90 0 : std::string pk_str;
91 0 : g_ConfigDB.GetValue(CFG_DEFAULT, "modio.public_key", pk_str);
92 0 : g_ConfigDB.GetValue(CFG_DEFAULT, "modio.v1.baseurl", m_BaseUrl);
93 : {
94 0 : std::string api_key;
95 0 : g_ConfigDB.GetValue(CFG_DEFAULT, "modio.v1.api_key", api_key);
96 0 : m_ApiKey = "api_key=" + api_key;
97 : }
98 : {
99 0 : std::string nameid;
100 0 : g_ConfigDB.GetValue(CFG_DEFAULT, "modio.v1.name_id", nameid);
101 0 : m_IdQuery = "name_id="+nameid;
102 : }
103 :
104 0 : m_CurlMulti = curl_multi_init();
105 0 : ENSURE(m_CurlMulti);
106 :
107 0 : m_Curl = curl_easy_init();
108 0 : ENSURE(m_Curl);
109 :
110 : // Capture error messages
111 0 : curl_easy_setopt(m_Curl, CURLOPT_ERRORBUFFER, m_ErrorBuffer);
112 :
113 : // Fail if the server did
114 0 : curl_easy_setopt(m_Curl, CURLOPT_FAILONERROR, 1L);
115 :
116 : // Disable signal handlers (required for multithreaded applications)
117 0 : curl_easy_setopt(m_Curl, CURLOPT_NOSIGNAL, 1L);
118 :
119 : // To minimise security risks, don't support redirects (except for file
120 : // downloads, for which this setting will be enabled).
121 0 : curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L);
122 :
123 : // For file downloads, one redirect seems plenty for a CDN serving the files.
124 0 : curl_easy_setopt(m_Curl, CURLOPT_MAXREDIRS, 1L);
125 :
126 0 : m_Headers = NULL;
127 0 : std::string ua = "User-Agent: pyrogenesis ";
128 0 : ua += curl_version();
129 0 : ua += " (https://play0ad.com/)";
130 0 : m_Headers = curl_slist_append(m_Headers, ua.c_str());
131 0 : curl_easy_setopt(m_Curl, CURLOPT_HTTPHEADER, m_Headers);
132 :
133 0 : if (sodium_init() < 0)
134 0 : ENSURE(0 && "Failed to initialize libsodium.");
135 :
136 0 : size_t bin_len = 0;
137 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)
138 0 : ENSURE(0 && "Failed to decode base64 public key. Please fix your configuration or mod.io will be unusable.");
139 0 : }
140 :
141 0 : ModIo::~ModIo()
142 : {
143 : // Clean things up to avoid unpleasant surprises,
144 : // and delete the temporary file if any.
145 0 : TearDownRequest();
146 0 : if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
147 0 : DeleteDownloadedFile();
148 :
149 0 : curl_slist_free_all(m_Headers);
150 0 : curl_easy_cleanup(m_Curl);
151 0 : curl_multi_cleanup(m_CurlMulti);
152 :
153 0 : delete m_CallbackData;
154 0 : }
155 :
156 0 : size_t ModIo::ReceiveCallback(void* buffer, size_t size, size_t nmemb, void* userp)
157 : {
158 0 : ModIo* self = static_cast<ModIo*>(userp);
159 :
160 0 : self->m_ResponseData += std::string((char*)buffer, (char*)buffer+size*nmemb);
161 :
162 0 : return size*nmemb;
163 : }
164 :
165 0 : size_t ModIo::DownloadCallback(void* buffer, size_t size, size_t nmemb, void* userp)
166 : {
167 0 : DownloadCallbackData* data = static_cast<DownloadCallbackData*>(userp);
168 0 : if (!data->fp)
169 0 : return 0;
170 :
171 0 : size_t len = fwrite(buffer, size, nmemb, data->fp);
172 :
173 : // Only update the hash with data we actually managed to write.
174 : // In case we did not write all of it we will fail the download,
175 : // but we do not want to have a possibly valid hash in that case.
176 0 : size_t written = len*size;
177 :
178 0 : data->md5.Update(static_cast<const u8*>(buffer), written);
179 :
180 0 : ENSURE(data->hash_state);
181 :
182 0 : crypto_generichash_update(data->hash_state, static_cast<const u8*>(buffer), written);
183 :
184 0 : return written;
185 : }
186 :
187 0 : int ModIo::DownloadProgressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t UNUSED(ultotal), curl_off_t UNUSED(ulnow))
188 : {
189 0 : DownloadProgressData* data = static_cast<DownloadProgressData*>(clientp);
190 :
191 : // If we got more data than curl expected, something is very wrong, abort.
192 0 : if (dltotal != 0 && dlnow > dltotal)
193 0 : return 1;
194 :
195 0 : data->progress = dltotal == 0 ? 0 : static_cast<double>(dlnow) / static_cast<double>(dltotal);
196 :
197 0 : return 0;
198 : }
199 :
200 0 : CURLMcode ModIo::SetupRequest(const std::string& url, bool fileDownload)
201 : {
202 0 : if (fileDownload)
203 : {
204 : // The download link will most likely redirect elsewhere, so allow that.
205 : // We verify the validity of the file later.
206 0 : curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 1L);
207 : // Enable the progress meter
208 0 : curl_easy_setopt(m_Curl, CURLOPT_NOPROGRESS, 0L);
209 :
210 : // Set IO callbacks
211 0 : curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, DownloadCallback);
212 0 : curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, static_cast<void*>(m_CallbackData));
213 0 : curl_easy_setopt(m_Curl, CURLOPT_XFERINFOFUNCTION, DownloadProgressCallback);
214 0 : curl_easy_setopt(m_Curl, CURLOPT_XFERINFODATA, static_cast<void*>(&m_DownloadProgressData));
215 :
216 : // Initialize the progress counter
217 0 : m_DownloadProgressData.progress = 0;
218 : }
219 : else
220 : {
221 : // To minimise security risks, don't support redirects
222 0 : curl_easy_setopt(m_Curl, CURLOPT_FOLLOWLOCATION, 0L);
223 : // Disable the progress meter
224 0 : curl_easy_setopt(m_Curl, CURLOPT_NOPROGRESS, 1L);
225 :
226 : // Set IO callbacks
227 0 : curl_easy_setopt(m_Curl, CURLOPT_WRITEFUNCTION, ReceiveCallback);
228 0 : curl_easy_setopt(m_Curl, CURLOPT_WRITEDATA, this);
229 : }
230 :
231 0 : m_ErrorBuffer[0] = '\0';
232 0 : curl_easy_setopt(m_Curl, CURLOPT_URL, url.c_str());
233 0 : return curl_multi_add_handle(m_CurlMulti, m_Curl);
234 : }
235 :
236 0 : void ModIo::TearDownRequest()
237 : {
238 0 : ENSURE(curl_multi_remove_handle(m_CurlMulti, m_Curl) == CURLM_OK);
239 :
240 0 : if (m_CallbackData)
241 : {
242 0 : if (m_CallbackData->fp)
243 0 : fclose(m_CallbackData->fp);
244 0 : m_CallbackData->fp = nullptr;
245 : }
246 0 : }
247 :
248 0 : void ModIo::StartGetGameId()
249 : {
250 : // Don't start such a request during active downloads.
251 0 : if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID ||
252 0 : m_DownloadProgressData.status == DownloadProgressStatus::LISTING ||
253 0 : m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
254 0 : return;
255 :
256 0 : m_GameId.clear();
257 :
258 0 : CURLMcode err = SetupRequest(m_BaseUrl+m_GamesRequest+"?"+m_ApiKey+"&"+m_IdQuery, false);
259 0 : if (err != CURLM_OK)
260 : {
261 0 : TearDownRequest();
262 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID;
263 0 : m_DownloadProgressData.error = fmt::sprintf(
264 0 : g_L10n.Translate("Failure while starting querying for game id. Error: %s; %s."),
265 0 : curl_multi_strerror(err), m_ErrorBuffer);
266 0 : return;
267 : }
268 :
269 0 : m_DownloadProgressData.status = DownloadProgressStatus::GAMEID;
270 : }
271 :
272 0 : void ModIo::StartListMods()
273 : {
274 : // Don't start such a request during active downloads.
275 0 : if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID ||
276 0 : m_DownloadProgressData.status == DownloadProgressStatus::LISTING ||
277 0 : m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
278 0 : return;
279 :
280 0 : m_ModData.clear();
281 :
282 0 : if (m_GameId.empty())
283 : {
284 0 : LOGERROR("Game ID not fetched from mod.io. Call StartGetGameId first and wait for it to finish.");
285 0 : return;
286 : }
287 :
288 0 : CURLMcode err = SetupRequest(m_BaseUrl+m_GamesRequest+m_GameId+"/mods?"+m_ApiKey, false);
289 0 : if (err != CURLM_OK)
290 : {
291 0 : TearDownRequest();
292 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING;
293 0 : m_DownloadProgressData.error = fmt::sprintf(
294 0 : g_L10n.Translate("Failure while starting querying for mods. Error: %s; %s."),
295 0 : curl_multi_strerror(err), m_ErrorBuffer);
296 0 : return;
297 : }
298 :
299 0 : m_DownloadProgressData.status = DownloadProgressStatus::LISTING;
300 : }
301 :
302 0 : void ModIo::StartDownloadMod(u32 idx)
303 : {
304 : // Don't start such a request during active downloads.
305 0 : if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID ||
306 0 : m_DownloadProgressData.status == DownloadProgressStatus::LISTING ||
307 0 : m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
308 0 : return;
309 :
310 0 : if (idx >= m_ModData.size())
311 0 : return;
312 :
313 0 : const Paths paths(g_CmdLineArgs);
314 0 : const OsPath modUserPath = paths.UserData()/"mods";
315 0 : const OsPath modPath = modUserPath/m_ModData[idx].properties["name_id"];
316 0 : if (!DirectoryExists(modPath) && INFO::OK != CreateDirectories(modPath, 0700, false))
317 : {
318 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
319 0 : m_DownloadProgressData.error = fmt::sprintf(
320 0 : g_L10n.Translate("Could not create mod directory: %s."), modPath.string8());
321 0 : return;
322 : }
323 :
324 : // Name the file after the name_id, since using the filename would mean that
325 : // we could end up with multiple zip files in the folder that might not work
326 : // as expected for a user (since a later version might remove some files
327 : // that aren't compatible anymore with the engine version).
328 : // So we ignore the filename provided by the API and assume that we do not
329 : // care about handling update.zip files. If that is the case we would need
330 : // a way to find out what files are required by the current one and which
331 : // should be removed for everything to work. This seems to be too complicated
332 : // so we just do not support that usage.
333 : // NOTE: We do save the file under a slightly different name from the final
334 : // one, to ensure that in case a download aborts and the file stays
335 : // around, the game will not attempt to open the file which has not
336 : // been verified.
337 0 : m_DownloadFilePath = modPath/(m_ModData[idx].properties["name_id"]+".zip.temp");
338 :
339 0 : delete m_CallbackData;
340 0 : m_CallbackData = new DownloadCallbackData(sys_OpenFile(m_DownloadFilePath, "wb"));
341 0 : if (!m_CallbackData->fp)
342 : {
343 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
344 0 : m_DownloadProgressData.error = fmt::sprintf(
345 0 : g_L10n.Translate("Could not open temporary file for mod download: %s."), m_DownloadFilePath.string8());
346 0 : return;
347 : }
348 :
349 0 : CURLMcode err = SetupRequest(m_ModData[idx].properties["binary_url"], true);
350 0 : if (err != CURLM_OK)
351 : {
352 0 : TearDownRequest();
353 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
354 0 : m_DownloadProgressData.error = fmt::sprintf(
355 0 : g_L10n.Translate("Failed to start the download. Error: %s; %s."), curl_multi_strerror(err), m_ErrorBuffer);
356 0 : return;
357 : }
358 :
359 0 : m_DownloadModID = idx;
360 0 : m_DownloadProgressData.status = DownloadProgressStatus::DOWNLOADING;
361 : }
362 :
363 0 : void ModIo::CancelRequest()
364 : {
365 0 : TearDownRequest();
366 :
367 0 : switch (m_DownloadProgressData.status)
368 : {
369 0 : case DownloadProgressStatus::GAMEID:
370 : case DownloadProgressStatus::FAILED_GAMEID:
371 0 : m_DownloadProgressData.status = DownloadProgressStatus::NONE;
372 0 : break;
373 0 : case DownloadProgressStatus::LISTING:
374 : case DownloadProgressStatus::FAILED_LISTING:
375 0 : m_DownloadProgressData.status = DownloadProgressStatus::READY;
376 0 : break;
377 0 : case DownloadProgressStatus::DOWNLOADING:
378 : case DownloadProgressStatus::FAILED_DOWNLOADING:
379 0 : m_DownloadProgressData.status = DownloadProgressStatus::LISTED;
380 0 : DeleteDownloadedFile();
381 0 : break;
382 0 : default:
383 0 : break;
384 : }
385 0 : }
386 :
387 0 : bool ModIo::AdvanceRequest(const ScriptInterface& scriptInterface)
388 : {
389 : // If the request was cancelled, stop trying to advance it
390 0 : if (m_DownloadProgressData.status != DownloadProgressStatus::GAMEID &&
391 0 : m_DownloadProgressData.status != DownloadProgressStatus::LISTING &&
392 0 : m_DownloadProgressData.status != DownloadProgressStatus::DOWNLOADING)
393 0 : return true;
394 :
395 : int stillRunning;
396 0 : CURLMcode err = curl_multi_perform(m_CurlMulti, &stillRunning);
397 0 : if (err != CURLM_OK)
398 : {
399 : std::string error = fmt::sprintf(
400 0 : g_L10n.Translate("Asynchronous download failure: %s, %s."), curl_multi_strerror(err), m_ErrorBuffer);
401 0 : TearDownRequest();
402 0 : if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID)
403 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID;
404 0 : else if (m_DownloadProgressData.status == DownloadProgressStatus::LISTING)
405 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING;
406 0 : else if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
407 : {
408 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
409 0 : DeleteDownloadedFile();
410 : }
411 0 : m_DownloadProgressData.error = error;
412 0 : return true;
413 : }
414 :
415 : CURLMsg* message;
416 0 : do
417 : {
418 : int in_queue;
419 0 : message = curl_multi_info_read(m_CurlMulti, &in_queue);
420 :
421 0 : if (!message)
422 0 : continue;
423 :
424 0 : if (message->data.result == CURLE_OK)
425 0 : continue;
426 :
427 : std::string error = fmt::sprintf(
428 0 : g_L10n.Translate("Download failure. Server response: %s; %s."), curl_easy_strerror(message->data.result), m_ErrorBuffer);
429 0 : TearDownRequest();
430 0 : if (m_DownloadProgressData.status == DownloadProgressStatus::GAMEID)
431 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID;
432 0 : else if (m_DownloadProgressData.status == DownloadProgressStatus::LISTING)
433 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING;
434 0 : else if (m_DownloadProgressData.status == DownloadProgressStatus::DOWNLOADING)
435 : {
436 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_DOWNLOADING;
437 0 : DeleteDownloadedFile();
438 : }
439 0 : m_DownloadProgressData.error = error;
440 0 : return true;
441 0 : } while (message);
442 :
443 0 : if (stillRunning)
444 0 : return false;
445 :
446 : // Download finished.
447 0 : TearDownRequest();
448 :
449 : // Perform parsing and/or checks
450 0 : std::string error;
451 0 : switch (m_DownloadProgressData.status)
452 : {
453 0 : case DownloadProgressStatus::GAMEID:
454 0 : if (!ParseGameId(scriptInterface, error))
455 : {
456 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_GAMEID;
457 0 : m_DownloadProgressData.error = error;
458 0 : break;
459 : }
460 :
461 0 : m_DownloadProgressData.status = DownloadProgressStatus::READY;
462 0 : break;
463 0 : case DownloadProgressStatus::LISTING:
464 0 : if (!ParseMods(scriptInterface, error))
465 : {
466 0 : m_ModData.clear(); // Failed during parsing, make sure we don't provide partial data
467 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_LISTING;
468 0 : m_DownloadProgressData.error = error;
469 0 : break;
470 : }
471 :
472 0 : m_DownloadProgressData.status = DownloadProgressStatus::LISTED;
473 0 : break;
474 0 : case DownloadProgressStatus::DOWNLOADING:
475 0 : if (!VerifyDownloadedFile(error))
476 : {
477 0 : m_DownloadProgressData.status = DownloadProgressStatus::FAILED_FILECHECK;
478 0 : m_DownloadProgressData.error = error;
479 0 : DeleteDownloadedFile();
480 0 : break;
481 : }
482 :
483 0 : m_DownloadProgressData.status = DownloadProgressStatus::SUCCESS;
484 :
485 : {
486 0 : Paths paths(g_CmdLineArgs);
487 0 : CModInstaller installer(paths.UserData() / "mods", paths.Cache());
488 0 : CModInstaller::ModInstallationResult result = installer.Install(m_DownloadFilePath, g_ScriptContext, false);
489 0 : if (result != CModInstaller::ModInstallationResult::SUCCESS)
490 0 : LOGERROR("Failed to install '%s'", m_DownloadFilePath.string8().c_str());
491 0 : g_Mods.UpdateAvailableMods(scriptInterface);
492 : }
493 0 : break;
494 0 : default:
495 0 : break;
496 : }
497 :
498 0 : return true;
499 : }
500 :
501 0 : bool ModIo::ParseGameId(const ScriptInterface& scriptInterface, std::string& err)
502 : {
503 0 : int id = -1;
504 0 : bool ret = ParseGameIdResponse(scriptInterface, m_ResponseData, id, err);
505 0 : m_ResponseData.clear();
506 0 : if (!ret)
507 0 : return false;
508 :
509 0 : m_GameId = "/" + std::to_string(id);
510 0 : return true;
511 : }
512 :
513 0 : bool ModIo::ParseMods(const ScriptInterface& scriptInterface, std::string& err)
514 : {
515 0 : bool ret = ParseModsResponse(scriptInterface, m_ResponseData, m_ModData, m_pk, err);
516 0 : m_ResponseData.clear();
517 0 : return ret;
518 : }
519 :
520 0 : void ModIo::DeleteDownloadedFile()
521 : {
522 0 : if (wunlink(m_DownloadFilePath) != 0)
523 0 : LOGERROR("Failed to delete temporary file.");
524 0 : m_DownloadFilePath = OsPath();
525 0 : }
526 :
527 0 : bool ModIo::VerifyDownloadedFile(std::string& err)
528 : {
529 : // Verify filesize, as a first basic download check.
530 : {
531 0 : u64 filesize = std::stoull(m_ModData[m_DownloadModID].properties.at("filesize"));
532 0 : if (filesize != FileSize(m_DownloadFilePath))
533 : {
534 0 : err = g_L10n.Translate("Mismatched filesize.");
535 0 : return false;
536 : }
537 : }
538 :
539 0 : ENSURE(m_CallbackData);
540 :
541 : // MD5 (because upstream provides it)
542 : // Just used to make sure there was no obvious corruption during transfer.
543 : {
544 : u8 digest[MD5::DIGESTSIZE];
545 0 : m_CallbackData->md5.Final(digest);
546 0 : std::string md5digest = Hexify(digest, MD5::DIGESTSIZE);
547 :
548 :
549 0 : if (m_ModData[m_DownloadModID].properties.at("filehash_md5") != md5digest)
550 : {
551 0 : err = fmt::sprintf(
552 0 : g_L10n.Translate("Invalid file. Expected md5 %s, got %s."),
553 0 : m_ModData[m_DownloadModID].properties.at("filehash_md5").c_str(),
554 : md5digest);
555 0 : return false;
556 : }
557 : }
558 :
559 : // Verify file signature.
560 : // Used to make sure that the downloaded file was actually checked and signed
561 : // by Wildfire Games. And has not been tampered with by the API provider, or the CDN.
562 :
563 0 : unsigned char hash_fin[crypto_generichash_BYTES_MAX] = {};
564 0 : ENSURE(m_CallbackData->hash_state);
565 0 : if (crypto_generichash_final(m_CallbackData->hash_state, hash_fin, sizeof hash_fin) != 0)
566 : {
567 0 : err = g_L10n.Translate("Failed to compute final hash.");
568 0 : return false;
569 : }
570 :
571 0 : if (crypto_sign_verify_detached(m_ModData[m_DownloadModID].sig.sig, hash_fin, sizeof hash_fin, m_pk.pk) != 0)
572 : {
573 0 : err = g_L10n.Translate("Failed to verify signature.");
574 0 : return false;
575 : }
576 :
577 0 : return true;
578 : }
579 :
580 : #define FAIL(...) STMT(err = fmt::sprintf(__VA_ARGS__); CLEANUP(); return false;)
581 :
582 : /**
583 : * Parses the current content of m_ResponseData to extract m_GameId.
584 : *
585 : * The JSON data is expected to look like
586 : * { "data": [{"id": 42, ...}, ...], ... }
587 : * where we are only interested in the value of the id property.
588 : *
589 : * @returns true iff it successfully parsed the id.
590 : */
591 18 : bool ModIo::ParseGameIdResponse(const ScriptInterface& scriptInterface, const std::string& responseData, int& id, std::string& err)
592 : {
593 : #define CLEANUP() id = -1;
594 36 : ScriptRequest rq(scriptInterface);
595 :
596 36 : JS::RootedValue gameResponse(rq.cx);
597 :
598 18 : if (!Script::ParseJSON(rq, responseData, &gameResponse))
599 2 : FAIL("Failed to parse response as JSON.");
600 :
601 16 : if (!gameResponse.isObject())
602 1 : FAIL("response not an object.");
603 :
604 30 : JS::RootedObject gameResponseObj(rq.cx, gameResponse.toObjectOrNull());
605 30 : JS::RootedValue dataVal(rq.cx);
606 15 : if (!JS_GetProperty(rq.cx, gameResponseObj, "data", &dataVal))
607 0 : FAIL("data property not in response.");
608 :
609 : // [{"id": 42, ...}, ...]
610 15 : if (!dataVal.isObject())
611 3 : FAIL("data property not an object.");
612 :
613 24 : JS::RootedObject data(rq.cx, dataVal.toObjectOrNull());
614 : u32 length;
615 : bool isArray;
616 12 : if (!JS::IsArrayObject(rq.cx, data, &isArray) || !isArray || !JS::GetArrayLength(rq.cx, data, &length) || !length)
617 2 : FAIL("data property not an array with at least one element.");
618 :
619 : // {"id": 42, ...}
620 20 : JS::RootedValue first(rq.cx);
621 10 : if (!JS_GetElement(rq.cx, data, 0, &first))
622 0 : FAIL("Couldn't get first element.");
623 10 : if (!first.isObject())
624 2 : FAIL("First element not an object.");
625 :
626 16 : JS::RootedObject firstObj(rq.cx, &first.toObject());
627 : bool hasIdProperty;
628 8 : if (!JS_HasProperty(rq.cx, firstObj, "id", &hasIdProperty) || !hasIdProperty)
629 2 : FAIL("No id property in first element.");
630 :
631 12 : JS::RootedValue idProperty(rq.cx);
632 6 : ENSURE(JS_GetProperty(rq.cx, firstObj, "id", &idProperty));
633 :
634 : // Make sure the property is not set to something that could be converted to a bogus value
635 : // TODO: We should be able to convert JS::Values to C++ variables in a way that actually
636 : // fails when types do not match (see https://trac.wildfiregames.com/ticket/5128).
637 6 : if (!idProperty.isNumber())
638 3 : FAIL("id property not a number.");
639 :
640 3 : id = -1;
641 3 : if (!Script::FromJSVal(rq, idProperty, id) || id <= 0)
642 2 : FAIL("Invalid id.");
643 :
644 1 : return true;
645 : #undef CLEANUP
646 : }
647 :
648 : /**
649 : * Parses the current content of m_ResponseData into m_ModData.
650 : *
651 : * The JSON data is expected to look like
652 : * { data: [modobj1, modobj2, ...], ... (including result_count) }
653 : * where modobjN has the following structure
654 : * { homepage_url: "url", name: "displayname", nameid: "short-non-whitespace-name",
655 : * summary: "short desc.", modfile: { version: "1.2.4", filename: "asdf.zip",
656 : * filehash: { md5: "deadbeef" }, filesize: 1234, download: { binary_url: "someurl", ... } }, ... }.
657 : * Only the listed properties are of interest to consumers, and we flatten
658 : * the modfile structure as that simplifies handling and there are no conflicts.
659 : */
660 43 : bool ModIo::ParseModsResponse(const ScriptInterface& scriptInterface, const std::string& responseData, std::vector<ModIoModData>& modData, const PKStruct& pk, std::string& err)
661 : {
662 : // Make sure we don't end up passing partial results back
663 : #define CLEANUP() modData.clear();
664 :
665 86 : ScriptRequest rq(scriptInterface);
666 :
667 86 : JS::RootedValue modResponse(rq.cx);
668 :
669 43 : if (!Script::ParseJSON(rq, responseData, &modResponse))
670 2 : FAIL("Failed to parse response as JSON.");
671 :
672 41 : if (!modResponse.isObject())
673 1 : FAIL("response not an object.");
674 :
675 80 : JS::RootedObject modResponseObj(rq.cx, modResponse.toObjectOrNull());
676 80 : JS::RootedValue dataVal(rq.cx);
677 40 : if (!JS_GetProperty(rq.cx, modResponseObj, "data", &dataVal))
678 0 : FAIL("data property not in response.");
679 :
680 : // [modobj1, modobj2, ... ]
681 40 : if (!dataVal.isObject())
682 3 : FAIL("data property not an object.");
683 :
684 74 : JS::RootedObject rData(rq.cx, dataVal.toObjectOrNull());
685 : u32 length;
686 : bool isArray;
687 37 : if (!JS::IsArrayObject(rq.cx, rData, &isArray) || !isArray || !JS::GetArrayLength(rq.cx, rData, &length) || !length)
688 2 : FAIL("data property not an array with at least one element.");
689 :
690 35 : modData.clear();
691 35 : modData.reserve(length);
692 :
693 : #define INVALIDATE_DATA_AND_CONTINUE(...) \
694 : {\
695 : data.properties.emplace("invalid", "true");\
696 : data.properties.emplace("error", __VA_ARGS__);\
697 : continue;\
698 : }
699 :
700 70 : for (u32 i = 0; i < length; ++i)
701 : {
702 35 : modData.emplace_back();
703 35 : ModIoModData& data = modData.back();
704 36 : JS::RootedValue el(rq.cx);
705 38 : if (!JS_GetElement(rq.cx, rData, i, &el) || !el.isObject())
706 6 : INVALIDATE_DATA_AND_CONTINUE("Failed to get array element object.")
707 :
708 32 : bool ok = true;
709 33 : std::string copyStringError;
710 : #define COPY_STRINGS_ELSE_CONTINUE(prefix, obj, ...) \
711 : for (const std::string& prop : { __VA_ARGS__ }) \
712 : { \
713 : std::string val; \
714 : if (!Script::FromJSProperty(rq, obj, prop.c_str(), val, true)) \
715 : { \
716 : ok = false; \
717 : copyStringError = "Failed to get " + prop + " from " + #obj + "."; \
718 : break; \
719 : }\
720 : data.properties.emplace(prefix+prop, val); \
721 : } \
722 : if (!ok) \
723 : INVALIDATE_DATA_AND_CONTINUE(copyStringError);
724 :
725 : // TODO: Currently the homepage_url field does not contain a non-null value for any entry.
726 32 : COPY_STRINGS_ELSE_CONTINUE("", el, "name", "name_id", "summary")
727 :
728 : // Now copy over the modfile part, but without the pointless substructure
729 24 : JS::RootedObject elObj(rq.cx, el.toObjectOrNull());
730 24 : JS::RootedValue modFile(rq.cx);
731 23 : if (!JS_GetProperty(rq.cx, elObj, "modfile", &modFile))
732 0 : INVALIDATE_DATA_AND_CONTINUE("Failed to get modfile data.");
733 :
734 25 : if (!modFile.isObject())
735 4 : INVALIDATE_DATA_AND_CONTINUE("modfile not an object.");
736 :
737 21 : COPY_STRINGS_ELSE_CONTINUE("", modFile, "version", "filesize");
738 :
739 18 : JS::RootedObject modFileObj(rq.cx, modFile.toObjectOrNull());
740 18 : JS::RootedValue filehash(rq.cx);
741 17 : if (!JS_GetProperty(rq.cx, modFileObj, "filehash", &filehash))
742 0 : INVALIDATE_DATA_AND_CONTINUE("Failed to get filehash data.");
743 :
744 17 : COPY_STRINGS_ELSE_CONTINUE("filehash_", filehash, "md5");
745 :
746 14 : JS::RootedValue download(rq.cx);
747 13 : if (!JS_GetProperty(rq.cx, modFileObj, "download", &download))
748 0 : INVALIDATE_DATA_AND_CONTINUE("Failed to get download data.");
749 :
750 13 : COPY_STRINGS_ELSE_CONTINUE("", download, "binary_url");
751 :
752 : // Parse metadata_blob (sig+deps)
753 11 : std::string metadata_blob;
754 12 : if (!Script::FromJSProperty(rq, modFile, "metadata_blob", metadata_blob, true))
755 4 : INVALIDATE_DATA_AND_CONTINUE("Failed to get metadata_blob from modFile.");
756 :
757 9 : JS::RootedValue metadata(rq.cx);
758 9 : if (!Script::ParseJSON(rq, metadata_blob, &metadata))
759 2 : INVALIDATE_DATA_AND_CONTINUE("Failed to parse metadata_blob as JSON.");
760 :
761 8 : if (!metadata.isObject())
762 2 : INVALIDATE_DATA_AND_CONTINUE("metadata_blob is not decoded as an object.");
763 :
764 8 : if (!Script::FromJSProperty(rq, metadata, "dependencies", data.dependencies, true))
765 4 : INVALIDATE_DATA_AND_CONTINUE("Failed to get dependencies from metadata_blob.");
766 :
767 5 : std::vector<std::string> minisigs;
768 6 : if (!Script::FromJSProperty(rq, metadata, "minisigs", minisigs, true))
769 4 : INVALIDATE_DATA_AND_CONTINUE("Failed to get minisigs from metadata_blob.");
770 :
771 : // Check we did find a valid matching signature.
772 3 : std::string signatureParsingErr;
773 3 : if (!ParseSignature(minisigs, data.sig, pk, signatureParsingErr))
774 2 : INVALIDATE_DATA_AND_CONTINUE(signatureParsingErr);
775 :
776 : #undef COPY_STRINGS_ELSE_CONTINUE
777 : #undef INVALIDATE_DATA_AND_CONTINUE
778 : }
779 :
780 35 : return true;
781 : #undef CLEANUP
782 : }
783 :
784 : /**
785 : * Parse signatures to find one that matches the public key, and has a valid global signature.
786 : * Returns true and sets @param sig to the valid matching signature.
787 : */
788 15 : bool ModIo::ParseSignature(const std::vector<std::string>& minisigs, SigStruct& sig, const PKStruct& pk, std::string& err)
789 : {
790 : #define CLEANUP() sig = {};
791 16 : for (const std::string& file_sig : minisigs)
792 : {
793 : // Format of a .minisig file (created using minisign(1) with -SHm file.zip)
794 : // untrusted comment: .*\nb64sign_of_file\ntrusted comment: .*\nb64sign_of_sign_of_file_and_trusted_comment
795 :
796 14 : std::vector<std::string> sig_lines;
797 14 : boost::split(sig_lines, file_sig, boost::is_any_of("\n"));
798 14 : if (sig_lines.size() < 4)
799 2 : FAIL("Invalid (too short) sig.");
800 :
801 : // Verify that both the untrusted comment and the trusted comment start with the correct prefix
802 : // because that is easy.
803 12 : const std::string untrusted_comment_prefix = "untrusted comment: ";
804 12 : const std::string trusted_comment_prefix = "trusted comment: ";
805 12 : if (!boost::algorithm::starts_with(sig_lines[0], untrusted_comment_prefix))
806 2 : FAIL("Malformed untrusted comment.");
807 10 : if (!boost::algorithm::starts_with(sig_lines[2], trusted_comment_prefix))
808 2 : FAIL("Malformed trusted comment.");
809 :
810 : // We only _really_ care about the second line which is the signature of the file (b64-encoded)
811 : // Also handling the other signature is nice, but not really required.
812 8 : const std::string& msg_sig = sig_lines[1];
813 :
814 8 : size_t bin_len = 0;
815 8 : 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)
816 2 : FAIL("Failed to decode base64 sig.");
817 :
818 : cassert(sizeof pk.keynum == sizeof sig.keynum);
819 :
820 6 : if (memcmp(&pk.keynum, &sig.keynum, sizeof sig.keynum) != 0)
821 1 : continue; // mismatched key, try another one
822 :
823 5 : if (memcmp(&sig.sig_alg, "ED", 2) != 0)
824 1 : FAIL("Only hashed minisign signatures are supported.");
825 :
826 : // Signature matches our public key
827 :
828 : // Now verify the global signature (sig || trusted_comment)
829 :
830 : unsigned char global_sig[crypto_sign_BYTES];
831 4 : 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)
832 1 : FAIL("Failed to decode base64 global_sig.");
833 :
834 6 : const std::string trusted_comment = sig_lines[2].substr(trusted_comment_prefix.size());
835 :
836 3 : unsigned char* sig_and_trusted_comment = (unsigned char*)sodium_malloc((sizeof sig.sig) + trusted_comment.size());
837 3 : if (!sig_and_trusted_comment)
838 0 : FAIL("sodium_malloc failed.");
839 :
840 3 : memcpy(sig_and_trusted_comment, sig.sig, sizeof sig.sig);
841 3 : memcpy(sig_and_trusted_comment + sizeof sig.sig, trusted_comment.data(), trusted_comment.size());
842 :
843 3 : if (crypto_sign_verify_detached(global_sig, sig_and_trusted_comment, (sizeof sig.sig) + trusted_comment.size(), pk.pk) != 0)
844 : {
845 1 : err = "Failed to verify global signature.";
846 1 : sodium_free(sig_and_trusted_comment);
847 1 : return false;
848 : }
849 :
850 2 : sodium_free(sig_and_trusted_comment);
851 :
852 : // Valid global sig, and the keynum matches the real one
853 2 : return true;
854 : }
855 :
856 2 : FAIL("Invalid signature.");
857 : #undef CLEANUP
858 3 : }
859 :
860 : #undef FAIL
|