diff --git a/dep/rcheevos/include/rc_client.h b/dep/rcheevos/include/rc_client.h index dcbd35430..fc60b890a 100644 --- a/dep/rcheevos/include/rc_client.h +++ b/dep/rcheevos/include/rc_client.h @@ -217,6 +217,47 @@ typedef struct rc_client_user_game_summary_t { */ RC_EXPORT void RC_CCONV rc_client_get_user_game_summary(const rc_client_t* client, rc_client_user_game_summary_t* summary); +/** + * Simple encryption that can be used to protect API tokens in configuration. We do not provide + * any cryptographic guarantees other than a significantly increased difficulty of getting the + * API token if a user shares their configuration file. + */ + +#define RC_CLIENT_LOGIN_ENCRYPTION_KEY_LENGTH 32 /* 128-bit key, 128-bit IV */ + +/** + * Computes the AES key to use for encryption API tokens. + * This key is based on a UUID that is unique to the computer that it is running on, with an + * optional salt. One possible option for a salt is the username. + */ +RC_EXPORT int RC_CCONV rc_client_get_login_encryption_key(uint8_t key[RC_CLIENT_LOGIN_ENCRYPTION_KEY_LENGTH], + const void* salt, size_t salt_length); + +/** + * Returns the length of a given API token after it has been encrypted. AES operates in blocks + * of 128 bits/16 bytes, therefore the encrypted token must be a multiple of 16 bytes in size. + */ +RC_EXPORT size_t RC_CCONV rc_client_get_encrypted_login_length(size_t plaintext_length); + +/** + * Encrypts an API token, protecting it against accidental exposure by sharing configurations. + * The token is encrypted using AES-128. You should convert the returned ciphertext to hex before + * storing it in a configuration file, as it will contain non-printable characters. ciphertext + * should be sized according to rc_client_get_encrypted_login_length(). + */ +RC_EXPORT int RC_CCONV rc_client_encrypt_login(const uint8_t key[RC_CLIENT_LOGIN_ENCRYPTION_KEY_LENGTH], + const char* plaintext, size_t plaintext_length, uint8_t* ciphertext, + size_t ciphertext_length); + +/** + * Decrypts a previously-encrypted API token. + * plaintext should be at least ciphertext_length in size. If the token is not a multiple of 16 + * bytes in size, the actual size of the token will be returned in plaintext_length. + */ +RC_EXPORT int RC_CCONV rc_client_decrypt_login(const uint8_t key[RC_CLIENT_LOGIN_ENCRYPTION_KEY_LENGTH], + const uint8_t* ciphertext, size_t ciphertext_length, char* plaintext, + size_t* plaintext_length); + /*****************************************************************************\ | Game | \*****************************************************************************/ diff --git a/dep/rcheevos/src/rc_client.c b/dep/rcheevos/src/rc_client.c index b7591ee1a..61110c0d7 100644 --- a/dep/rcheevos/src/rc_client.c +++ b/dep/rcheevos/src/rc_client.c @@ -11,6 +11,10 @@ #include "rcheevos/rc_internal.h" +/* TODO: Is this appropriate? */ +#include "rhash/aes.h" +#include "rhash/sha256.h" + #include #ifdef _WIN32 @@ -18,6 +22,8 @@ #include #include #else +#include +#include #include #endif @@ -901,6 +907,168 @@ void rc_client_get_user_game_summary(const rc_client_t* client, rc_client_user_g rc_mutex_unlock((rc_mutex_t*)&client->state.mutex); /* remove const cast for mutex access */ } +static int rc_client_get_machine_uuid_string(const void** uuid, size_t* uuid_length) +{ +#ifdef _WIN32 + HKEY hKey; + DWORD machine_guid_length; + const char* machine_guid; + + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Cryptography", 0, KEY_READ, &hKey) != ERROR_SUCCESS) + return 0; + + if (RegGetValueA(hKey, NULL, "MachineGuid", RRF_RT_REG_SZ, NULL, NULL, &machine_guid_length) != ERROR_SUCCESS) + { + RegCloseKey(hKey); + return 0; + } + + machine_guid = (char*)malloc(machine_guid_length); + if (machine_guid == NULL || + RegGetValueA(hKey, NULL, "MachineGuid", RRF_RT_REG_SZ, NULL, machine_guid, &machine_guid_length) != + ERROR_SUCCESS || + machine_guid_length <= 1) + { + free(machine_guid); + RegCloseKey(hKey); + return 0; + } + + RegCloseKey(hKey); + *uuid = machine_guid; + *uuid_length = machine_guid_length - 1; /* drop terminating zero */ + return 1; +#else + long hostid; + +#ifdef __linux__ + /* use /etc/machine-id on Linux */ + int fd; + struct stat sd; + char* machine_id; + + fd = open("/etc/machine-id", O_RDONLY); + if (fd >= 0) + { + if (fstat(fd, &sd) == 0 && sd.st_size > 0) + { + machine_id = (char*)malloc(sd.st_size); + if (machine_id != NULL && read(fd, machine_id, sd.st_size) == sd.st_size) + { + *uuid = machine_id; + *uuid_length = sd.st_size; + close(fd); + return 1; + } + + free(machine_id); + } + + close(fd); + } +#endif + + // fallback to POSIX gethostid() + hostid = gethostid(); + *uuid = malloc(sizeof(hostid)); + memcpy((void*)uuid, &hostid, sizeof(hostid)); + return 1; +#endif +} + +int rc_client_get_login_encryption_key(uint8_t key[RC_CLIENT_LOGIN_ENCRYPTION_KEY_LENGTH], const void* salt, + size_t salt_length) +{ + /* super basic key stretching */ + static const int EXTRA_ROUNDS = 100; + + const void* machine_uuid; + size_t machine_uuid_length; + SHA256_CTX sha256_ctx; + int i; + + if (!rc_client_get_machine_uuid_string(&machine_uuid, &machine_uuid_length)) + return RC_API_FAILURE; + + sha256_init(&sha256_ctx); + sha256_update(&sha256_ctx, (const uint8_t*)machine_uuid, machine_uuid_length); + if (salt_length > 0) + sha256_update(&sha256_ctx, (const uint8_t*)salt, salt_length); + sha256_final(&sha256_ctx, key); + + for (int i = 0; i < EXTRA_ROUNDS; i++) + { + sha256_init(&sha256_ctx); + sha256_update(&sha256_ctx, key, RC_CLIENT_LOGIN_ENCRYPTION_KEY_LENGTH); + sha256_final(&sha256_ctx, key); + } + + free(machine_uuid); + + return RC_OK; +} + +size_t rc_client_get_encrypted_login_length(size_t plaintext_length) +{ + /* ciphertext should be aligned to block size */ + return (((plaintext_length + (AES_BLOCKLEN - 1)) / AES_BLOCKLEN) * AES_BLOCKLEN); +} + +int rc_client_encrypt_login(const uint8_t key[RC_CLIENT_LOGIN_ENCRYPTION_KEY_LENGTH], const char* plaintext, + size_t plaintext_length, uint8_t* ciphertext, size_t ciphertext_length) +{ + struct AES_ctx aes_ctx; + size_t padding_size; + size_t i; + + /* ciphertext should be aligned to block size */ + if (ciphertext_length < (((plaintext_length + (AES_BLOCKLEN - 1)) / AES_BLOCKLEN) * AES_BLOCKLEN) || + (ciphertext_length % AES_BLOCKLEN) != 0) + { + return RC_INSUFFICIENT_BUFFER; + } + + memcpy(ciphertext, plaintext, plaintext_length); + padding_size = ciphertext_length - plaintext_length; + if (padding_size > 0) + memset(&ciphertext[plaintext_length], 0, padding_size); + + AES_init_ctx(&aes_ctx, key); + AES_ctx_set_iv(&aes_ctx, key + 16); /* second part of 256-bit key is the IV */ + AES_CBC_encrypt_buffer(&aes_ctx, ciphertext, ciphertext_length); + return RC_OK; +} + +int rc_client_decrypt_login(const uint8_t key[RC_CLIENT_LOGIN_ENCRYPTION_KEY_LENGTH], const uint8_t* ciphertext, + size_t ciphertext_length, char* plaintext, size_t* plaintext_length) +{ + struct AES_ctx aes_ctx; + size_t len; + + /* ciphertext should be aligned to block size */ + if ((ciphertext_length % AES_BLOCKLEN) != 0) + return RC_INSUFFICIENT_BUFFER; + + /* decrypt_buffer is an inout parameter, need to copy */ + memcpy(plaintext, ciphertext, ciphertext_length); + + AES_init_ctx(&aes_ctx, key); + AES_ctx_set_iv(&aes_ctx, key + 16); /* second part of 256-bit key is the IV */ + AES_CBC_decrypt_buffer(&aes_ctx, plaintext, ciphertext_length); + + /* may not be null terminated, and C89 has no safe strlen... */ + len = 0; + while (len < ciphertext_length) + { + if (plaintext[len] == '\0') + break; + len++; + } + + *plaintext_length = len; + return RC_OK; +} + /* ===== Game ===== */ static void rc_client_free_game(rc_client_game_info_t* game) diff --git a/src/core/achievements.cpp b/src/core/achievements.cpp index 16bfd0ed7..45cd811b3 100644 --- a/src/core/achievements.cpp +++ b/src/core/achievements.cpp @@ -41,6 +41,7 @@ #include "imgui_internal.h" #include "rc_api_runtime.h" #include "rc_client.h" +#include "rc_util.h" #include #include @@ -123,6 +124,8 @@ struct AchievementProgressIndicator }; } // namespace +using LoginEncryptionKey = std::array; + static void ReportError(std::string_view sv); template static void ReportFmtError(fmt::format_string fmt, T&&... args); @@ -144,6 +147,10 @@ static std::string GetLocalImagePath(const std::string_view image_name, int type static void DownloadImage(std::string url, std::string cache_filename); static void UpdateGlyphRanges(); +static LoginEncryptionKey GetLoginEncryptionKey(std::string_view username); +static TinyString DecryptLoginToken(std::string_view encrypted_token, std::string_view username); +static TinyString EncryptLoginToken(std::string_view token, std::string_view username); + static bool CreateClient(rc_client_t** client, std::unique_ptr* http); static void DestroyClient(rc_client_t** client, std::unique_ptr* http); static void ClientMessageCallback(const char* message, const rc_client_t* client); @@ -466,6 +473,65 @@ void Achievements::UpdateGlyphRanges() ImGuiManager::SetEmojiFontRange(ImGuiManager::CompactFontRange(sorted_codepoints)); } +Achievements::LoginEncryptionKey Achievements::GetLoginEncryptionKey(std::string_view username) +{ + LoginEncryptionKey ret; + + const int res = rc_client_get_login_encryption_key(ret.data(), username.data(), username.length()); + if (res != RC_OK) + { + WARNING_LOG("rc_client_get_login_encryption_key() failed: {}, assuming zero.", res); + ret = {}; + } + + return ret; +} + +TinyString Achievements::EncryptLoginToken(std::string_view token, std::string_view username) +{ + TinyString ret; + if (token.empty()) + return ret; + + const size_t len = rc_client_get_encrypted_login_length(token.length()); + DynamicHeapArray encrypted(len); + const int res = rc_client_encrypt_login(GetLoginEncryptionKey(username).data(), token.data(), token.length(), + encrypted.data(), len); + if (res != RC_OK) + { + ERROR_LOG("rc_client_encrypt_login() failed: {}", res); + return ret; + } + + ret.append_hex(encrypted.data(), encrypted.size()); + return ret; +} + +TinyString Achievements::DecryptLoginToken(std::string_view encrypted_token, std::string_view username) +{ + TinyString ret; + if (encrypted_token.empty()) + return ret; + + const std::optional> ciphertext = StringUtil::DecodeHex(encrypted_token); + if (!ciphertext.has_value() || ciphertext->size() >= std::numeric_limits::max()) + return ret; + + size_t token_length = ciphertext->size(); + ret.reserve(static_cast(token_length)); + + const int res = rc_client_decrypt_login(GetLoginEncryptionKey(username).data(), ciphertext->data(), + ciphertext->size(), ret.data(), &token_length); + if (res != RC_OK) + { + ERROR_LOG("rc_client_decrypt_login() failed: {}", res); + return ret; + } + + ret.set_size(static_cast(token_length)); + return ret; +} + bool Achievements::IsActive() { #ifdef ENABLE_RAINTEGRATION @@ -566,8 +632,18 @@ bool Achievements::Initialize() if (!username.empty() && !api_token.empty()) { INFO_LOG("Attempting login with user '{}'...", username); - s_login_request = rc_client_begin_login_with_token(s_client, username.c_str(), api_token.c_str(), - ClientLoginWithTokenCallback, nullptr); + + // If we can't decrypt the token, it was an old config and we need to re-login. + if (const TinyString decrypted_api_token = DecryptLoginToken(api_token, username); !decrypted_api_token.empty()) + { + s_login_request = rc_client_begin_login_with_token(s_client, username.c_str(), decrypted_api_token.c_str(), + ClientLoginWithTokenCallback, nullptr); + } + else + { + WARNING_LOG("Invalid encrypted login token, requesitng a new one."); + Host::OnAchievementsLoginRequested(LoginRequestReason::TokenInvalid); + } } // Hardcore mode isn't enabled when achievements first starts, if a game is already running. @@ -1878,7 +1954,7 @@ void Achievements::ClientLoginWithPasswordCallback(int result, const char* error // Store configuration. Host::SetBaseStringSettingValue("Cheevos", "Username", params->username); - Host::SetBaseStringSettingValue("Cheevos", "Token", user->token); + Host::SetBaseStringSettingValue("Cheevos", "Token", EncryptLoginToken(user->token, params->username)); Host::SetBaseStringSettingValue("Cheevos", "LoginTimestamp", fmt::format("{}", std::time(nullptr)).c_str()); Host::CommitBaseSettingChanges();