Achievements: Add encryption of login tokens

This commit is contained in:
Stenzek 2024-11-08 15:33:24 +10:00
parent 23e9bf0f28
commit 116a98fd1d
No known key found for this signature in database
3 changed files with 288 additions and 3 deletions

View File

@ -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 |
\*****************************************************************************/

View File

@ -11,6 +11,10 @@
#include "rcheevos/rc_internal.h"
/* TODO: Is this appropriate? */
#include "rhash/aes.h"
#include "rhash/sha256.h"
#include <stdarg.h>
#ifdef _WIN32
@ -18,6 +22,8 @@
#include <windows.h>
#include <profileapi.h>
#else
#include <unistd.h>
#include <sys/stat.h>
#include <time.h>
#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)

View File

@ -41,6 +41,7 @@
#include "imgui_internal.h"
#include "rc_api_runtime.h"
#include "rc_client.h"
#include "rc_util.h"
#include <algorithm>
#include <atomic>
@ -123,6 +124,8 @@ struct AchievementProgressIndicator
};
} // namespace
using LoginEncryptionKey = std::array<u8, RC_CLIENT_LOGIN_ENCRYPTION_KEY_LENGTH>;
static void ReportError(std::string_view sv);
template<typename... T>
static void ReportFmtError(fmt::format_string<T...> 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<HTTPDownloader>* http);
static void DestroyClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* 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<u8> 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<std::vector<u8>> ciphertext = StringUtil::DecodeHex(encrypted_token);
if (!ciphertext.has_value() || ciphertext->size() >= std::numeric_limits<u32>::max())
return ret;
size_t token_length = ciphertext->size();
ret.reserve(static_cast<u32>(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<u32>(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();