ref:main
/**
* @file tests/e2e/moonlight_client/gamestream.cpp
* @brief Minimal GameStream HTTP client (serverinfo, pairing, applist, launch) for end-to-end tests.
* @details Implements the client side of the pairing handshake that Sunshine's `src/nvhttp.cpp`
* serves: AES-128-ECB with a key derived from SHA-256(salt || PIN), SHA-256 challenge hashes and
* RSA-SHA256 signatures.
*/
// class header include
#include "gamestream.h"
// standard includes
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <future>
#include <memory>
#include <regex>
#include <sstream>
#include <stdexcept>
// lib includes
#include <curl/curl.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/rand.h>
#include <openssl/x509.h>
namespace e2e {
namespace {
/**
* @brief Read a whole file.
*
* @param path File path.
* @return File contents.
*/
std::string read_file(const std::filesystem::path &path) {
std::ifstream in {path, std::ios::binary};
std::stringstream ss;
ss << in.rdbuf();
return ss.str();
}
/**
* @brief Write a whole file.
*
* @param path File path.
* @param data Contents.
*/
void write_file(const std::filesystem::path &path, const std::string &data) {
std::ofstream out {path, std::ios::binary | std::ios::trunc};
out << data;
}
/**
* @brief Decode hex text.
*
* @param hex Hex string.
* @return Bytes.
*/
std::vector<std::uint8_t> from_hex(const std::string &hex) {
std::vector<std::uint8_t> out;
for (std::size_t i = 0; i + 1 < hex.size(); i += 2) {
out.push_back((std::uint8_t) std::stoi(hex.substr(i, 2), nullptr, 16));
}
return out;
}
/**
* @brief SHA-256 of a buffer.
*
* @param data Input bytes.
* @return 32-byte digest.
*/
std::vector<std::uint8_t> sha256(const std::vector<std::uint8_t> &data) {
std::vector<std::uint8_t> out(32);
unsigned int len = 0;
EVP_Digest(data.data(), data.size(), out.data(), &len, EVP_sha256(), nullptr);
out.resize(len);
return out;
}
/**
* @brief AES-128-ECB without padding.
*
* @param key 16-byte key.
* @param data Input, a multiple of 16 bytes.
* @param encrypt Whether to encrypt or decrypt.
* @return Output bytes.
*/
std::vector<std::uint8_t> aes_ecb(const std::vector<std::uint8_t> &key, const std::vector<std::uint8_t> &data, bool encrypt) {
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> ctx {EVP_CIPHER_CTX_new(), &EVP_CIPHER_CTX_free};
EVP_CipherInit_ex(ctx.get(), EVP_aes_128_ecb(), nullptr, key.data(), nullptr, encrypt ? 1 : 0);
EVP_CIPHER_CTX_set_padding(ctx.get(), 0);
std::vector<std::uint8_t> out(data.size() + 16);
int len = 0;
int total = 0;
EVP_CipherUpdate(ctx.get(), out.data(), &len, data.data(), (int) data.size());
total = len;
EVP_CipherFinal_ex(ctx.get(), out.data() + total, &len);
total += len;
out.resize(total);
return out;
}
/**
* @brief Sign with RSA-SHA256.
*
* @param key_pem Private key.
* @param data Data to sign.
* @return Signature.
*/
std::vector<std::uint8_t> sign_sha256(const std::string &key_pem, const std::vector<std::uint8_t> &data) {
std::unique_ptr<BIO, decltype(&BIO_free)> bio {BIO_new_mem_buf(key_pem.data(), (int) key_pem.size()), &BIO_free};
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> pkey {PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr), &EVP_PKEY_free};
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> ctx {EVP_MD_CTX_new(), &EVP_MD_CTX_free};
EVP_DigestSignInit(ctx.get(), nullptr, EVP_sha256(), nullptr, pkey.get());
std::size_t len = 0;
EVP_DigestSign(ctx.get(), nullptr, &len, data.data(), data.size());
std::vector<std::uint8_t> sig(len);
EVP_DigestSign(ctx.get(), sig.data(), &len, data.data(), data.size());
sig.resize(len);
return sig;
}
/**
* @brief Get the signature bytes embedded in a PEM certificate.
*
* @param cert_pem Certificate.
* @return Signature bytes.
*/
std::vector<std::uint8_t> cert_signature(const std::string &cert_pem) {
std::unique_ptr<BIO, decltype(&BIO_free)> bio {BIO_new_mem_buf(cert_pem.data(), (int) cert_pem.size()), &BIO_free};
std::unique_ptr<X509, decltype(&X509_free)> x509 {PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), &X509_free};
if (!x509) {
return {};
}
const ASN1_BIT_STRING *sig = nullptr;
const X509_ALGOR *alg = nullptr;
X509_get0_signature(&sig, &alg, x509.get());
return {sig->data, sig->data + sig->length};
}
/**
* @brief Create a self-signed RSA-2048 certificate.
*
* @return Certificate and key PEM.
*/
std::pair<std::string, std::string> make_certificate() {
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> pkey {EVP_RSA_gen(2048), &EVP_PKEY_free};
std::unique_ptr<X509, decltype(&X509_free)> x509 {X509_new(), &X509_free};
X509_set_version(x509.get(), 2);
ASN1_INTEGER_set(X509_get_serialNumber(x509.get()), 1);
X509_gmtime_adj(X509_getm_notBefore(x509.get()), 0);
X509_gmtime_adj(X509_getm_notAfter(x509.get()), 60L * 60 * 24 * 365 * 20);
X509_set_pubkey(x509.get(), pkey.get());
auto name = X509_get_subject_name(x509.get());
X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (const unsigned char *) "Sunshine E2E Client", -1, -1, 0);
X509_set_issuer_name(x509.get(), name);
X509_sign(x509.get(), pkey.get(), EVP_sha256());
std::unique_ptr<BIO, decltype(&BIO_free)> cert_bio {BIO_new(BIO_s_mem()), &BIO_free};
PEM_write_bio_X509(cert_bio.get(), x509.get());
std::unique_ptr<BIO, decltype(&BIO_free)> key_bio {BIO_new(BIO_s_mem()), &BIO_free};
PEM_write_bio_PrivateKey(key_bio.get(), pkey.get(), nullptr, nullptr, 0, nullptr, nullptr);
char *data = nullptr;
auto cert_len = BIO_get_mem_data(cert_bio.get(), &data);
std::string cert {data, (std::size_t) cert_len};
auto key_len = BIO_get_mem_data(key_bio.get(), &data);
std::string key {data, (std::size_t) key_len};
return {cert, key};
}
/**
* @brief libcurl write callback appending to a string.
*
* @param ptr Data.
* @param size Element size.
* @param nmemb Element count.
* @param userdata Target string.
* @return Bytes consumed.
*/
std::size_t append_body(char *ptr, std::size_t size, std::size_t nmemb, void *userdata) {
((std::string *) userdata)->append(ptr, size * nmemb);
return size * nmemb;
}
/**
* @brief Load a PEM string into a curl blob option.
*
* @param curl Handle.
* @param option Blob option.
* @param pem PEM text.
*/
void set_blob(CURL *curl, CURLoption option, const std::string &pem) {
curl_blob blob {};
blob.data = (void *) pem.data();
blob.len = pem.size();
blob.flags = CURL_BLOB_COPY;
curl_easy_setopt(curl, option, &blob);
}
} // namespace
identity_t load_or_create_identity(const std::string &dir) {
std::filesystem::create_directories(dir);
const auto cert_path = std::filesystem::path {dir} / "client.pem";
const auto key_path = std::filesystem::path {dir} / "client.key";
const auto id_path = std::filesystem::path {dir} / "uniqueid";
identity_t identity;
if (std::filesystem::exists(cert_path) && std::filesystem::exists(key_path) && std::filesystem::exists(id_path)) {
identity.cert_pem = read_file(cert_path);
identity.key_pem = read_file(key_path);
identity.unique_id = read_file(id_path);
return identity;
}
std::tie(identity.cert_pem, identity.key_pem) = make_certificate();
identity.unique_id = to_hex(random_bytes(8));
write_file(cert_path, identity.cert_pem);
write_file(key_path, identity.key_pem);
write_file(id_path, identity.unique_id);
return identity;
}
std::string to_hex(const std::vector<std::uint8_t> &data) {
static constexpr char digits[] = "0123456789ABCDEF";
std::string out;
out.reserve(data.size() * 2);
for (auto b : data) {
out.push_back(digits[b >> 4]);
out.push_back(digits[b & 0xf]);
}
return out;
}
std::vector<std::uint8_t> random_bytes(std::size_t n) {
std::vector<std::uint8_t> out(n);
RAND_bytes(out.data(), (int) n);
return out;
}
std::optional<std::string> xml_tag(const std::string &xml, const std::string &tag) {
std::smatch match;
if (std::regex_search(xml, match, std::regex {"<" + tag + ">([\\s\\S]*?)</" + tag + ">"})) {
return match[1].str();
}
return std::nullopt;
}
int xml_status(const std::string &xml) {
std::smatch match;
if (std::regex_search(xml, match, std::regex {"status_code=\"(-?\\d+)\""})) {
return std::stoi(match[1].str());
}
return -1;
}
client_t::client_t(std::string host, int http_port, identity_t identity):
host {std::move(host)},
http {http_port},
id {std::move(identity)} {
}
std::optional<std::string> client_t::get(bool use_https, const std::string &path, const std::string &query, long timeout_s) {
std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> curl {curl_easy_init(), &curl_easy_cleanup};
auto url = std::string {use_https ? "https://" : "http://"} + host + ":" + std::to_string(use_https ? https : http) + path + "?uniqueid=" + id.unique_id + "&uuid=" + to_hex(random_bytes(16)) + (query.empty() ? "" : "&" + query);
std::string body;
curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str());
curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, &append_body);
curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, timeout_s);
curl_easy_setopt(curl.get(), CURLOPT_NOSIGNAL, 1L);
if (use_https) {
// Sunshine's certificate is self-signed; pairing pins it, so peer verification is skipped here.
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST, 0L);
set_blob(curl.get(), CURLOPT_SSLCERT_BLOB, id.cert_pem);
curl_easy_setopt(curl.get(), CURLOPT_SSLCERTTYPE, "PEM");
set_blob(curl.get(), CURLOPT_SSLKEY_BLOB, id.key_pem);
curl_easy_setopt(curl.get(), CURLOPT_SSLKEYTYPE, "PEM");
}
auto rc = curl_easy_perform(curl.get());
if (rc != CURLE_OK) {
std::fprintf(stderr, "e2e: GET %s failed: %s\n", path.c_str(), curl_easy_strerror(rc));
return std::nullopt;
}
return body;
}
std::optional<server_info_t> client_t::server_info(bool use_https) {
auto body = get(use_https, "/serverinfo", "");
if (!body || xml_status(*body) != 200) {
return std::nullopt;
}
server_info_t info;
info.app_version = xml_tag(*body, "appversion").value_or("");
info.gfe_version = xml_tag(*body, "GfeVersion").value_or("");
info.https_port = std::stoi(xml_tag(*body, "HttpsPort").value_or("0"));
info.codec_mode_support = std::stoi(xml_tag(*body, "ServerCodecModeSupport").value_or("0"));
info.paired = xml_tag(*body, "PairStatus").value_or("0") == "1";
info.state = xml_tag(*body, "state").value_or("");
https = info.https_port;
return info;
}
bool client_t::pair(const std::string &pin, const std::string &device_name, const std::function<bool()> &approve) {
auto salt = random_bytes(16);
std::vector<std::uint8_t> salted {salt};
salted.insert(salted.end(), pin.begin(), pin.end());
auto key = sha256(salted);
key.resize(16);
// Phase 1: send our certificate; the request stays pending until the PIN is approved.
auto approval = std::async(std::launch::async, approve);
auto phase1 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&phrase=getservercert&salt=" + to_hex(salt) + "&clientcert=" + to_hex({id.cert_pem.begin(), id.cert_pem.end()}), 120);
if (!phase1 || xml_tag(*phase1, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: getservercert failed: %s\n", phase1 ? phase1->c_str() : "no response");
approval.wait();
return false;
}
auto server_cert_hex = xml_tag(*phase1, "plaincert").value_or("");
auto server_cert_bytes = from_hex(server_cert_hex);
std::string server_cert {server_cert_bytes.begin(), server_cert_bytes.end()};
// Phase 2: client challenge
auto client_challenge = random_bytes(16);
auto phase2 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&clientchallenge=" + to_hex(aes_ecb(key, client_challenge, true)));
if (!phase2 || xml_tag(*phase2, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: clientchallenge failed\n");
approval.wait();
return false;
}
auto challenge_response = aes_ecb(key, from_hex(xml_tag(*phase2, "challengeresponse").value_or("")), false);
if (challenge_response.size() < 48) {
std::fprintf(stderr, "e2e: short challenge response\n");
approval.wait();
return false;
}
std::vector<std::uint8_t> server_response_hash {challenge_response.begin(), challenge_response.begin() + 32};
std::vector<std::uint8_t> server_challenge {challenge_response.begin() + 32, challenge_response.begin() + 48};
// Phase 3: answer the server challenge
auto client_secret = random_bytes(16);
std::vector<std::uint8_t> to_hash {server_challenge};
auto client_signature = cert_signature(id.cert_pem);
to_hash.insert(to_hash.end(), client_signature.begin(), client_signature.end());
to_hash.insert(to_hash.end(), client_secret.begin(), client_secret.end());
auto phase3 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&serverchallengeresp=" + to_hex(aes_ecb(key, sha256(to_hash), true)));
if (!phase3 || xml_tag(*phase3, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: serverchallengeresp failed\n");
approval.wait();
return false;
}
auto pairing_secret = from_hex(xml_tag(*phase3, "pairingsecret").value_or(""));
if (pairing_secret.size() < 16) {
std::fprintf(stderr, "e2e: short pairing secret\n");
approval.wait();
return false;
}
// Detect a man in the middle: the server proves it knew the PIN and owns its certificate.
std::vector<std::uint8_t> server_secret {pairing_secret.begin(), pairing_secret.begin() + 16};
std::vector<std::uint8_t> expected {client_challenge};
auto server_signature = cert_signature(server_cert);
expected.insert(expected.end(), server_signature.begin(), server_signature.end());
expected.insert(expected.end(), server_secret.begin(), server_secret.end());
if (sha256(expected) != server_response_hash) {
std::fprintf(stderr, "e2e: server response hash mismatch (wrong PIN?)\n");
approval.wait();
return false;
}
// Phase 4: send our signed secret
std::vector<std::uint8_t> client_pairing_secret {client_secret};
auto secret_signature = sign_sha256(id.key_pem, client_secret);
client_pairing_secret.insert(client_pairing_secret.end(), secret_signature.begin(), secret_signature.end());
auto phase4 = get(false, "/pair", "devicename=" + device_name + "&updateState=1&clientpairingsecret=" + to_hex(client_pairing_secret));
bool approved = approval.get();
if (!phase4 || xml_tag(*phase4, "paired").value_or("0") != "1") {
std::fprintf(stderr, "e2e: clientpairingsecret failed\n");
return false;
}
// Phase 5: confirm over HTTPS with the now-trusted client certificate
if (https == 0) {
server_info(false);
}
auto phase5 = get(true, "/pair", "devicename=" + device_name + "&updateState=1&phrase=pairchallenge");
return approved && phase5 && xml_tag(*phase5, "paired").value_or("0") == "1";
}
std::vector<app_t> client_t::app_list() {
std::vector<app_t> apps;
auto body = get(true, "/applist", "");
if (!body) {
return apps;
}
std::regex app_re {"<App>([\\s\\S]*?)</App>"};
for (std::sregex_iterator it {body->begin(), body->end(), app_re}, end; it != end; ++it) {
auto app_xml = (*it)[1].str();
app_t app;
app.title = xml_tag(app_xml, "AppTitle").value_or("");
app.id = std::stoi(xml_tag(app_xml, "ID").value_or("0"));
apps.push_back(app);
}
return apps;
}
std::optional<std::string> client_t::launch(int app_id, int width, int height, int fps, const std::vector<std::uint8_t> &ri_key, int ri_key_id, const std::string &extra_query) {
auto query = "appid=" + std::to_string(app_id) + "&mode=" + std::to_string(width) + "x" + std::to_string(height) + "x" + std::to_string(fps) + "&additionalStates=1&sops=0&rikey=" + to_hex(ri_key) + "&rikeyid=" + std::to_string(ri_key_id) + "&localAudioPlayMode=0&surroundAudioInfo=196610&remoteControllersBitmap=0&gcmap=0" + extra_query;
auto body = get(true, "/launch", query, 60);
if (!body || xml_status(*body) != 200) {
std::fprintf(stderr, "e2e: launch failed: %s\n", body ? body->c_str() : "no response");
return std::nullopt;
}
return xml_tag(*body, "sessionUrl0");
}
bool client_t::cancel() {
auto body = get(true, "/cancel", "");
return body && xml_status(*body) == 200;
}
std::optional<std::string> api_request(const std::string &method, const std::string &url, const std::string &user, const std::string &password, const std::string &body) {
std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> curl {curl_easy_init(), &curl_easy_cleanup};
std::string response;
auto userpwd = user + ":" + password;
curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str());
curl_easy_setopt(curl.get(), CURLOPT_CUSTOMREQUEST, method.c_str());
curl_easy_setopt(curl.get(), CURLOPT_USERPWD, userpwd.c_str());
curl_easy_setopt(curl.get(), CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST, 0L);
curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, &append_body);
curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response);
curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, 60L);
curl_easy_setopt(curl.get(), CURLOPT_NOSIGNAL, 1L);
std::unique_ptr<curl_slist, decltype(&curl_slist_free_all)> headers {nullptr, &curl_slist_free_all};
if (!body.empty()) {
headers.reset(curl_slist_append(nullptr, "Content-Type: application/json"));
curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, headers.get());
curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, body.c_str());
}
auto rc = curl_easy_perform(curl.get());
if (rc != CURLE_OK) {
std::fprintf(stderr, "e2e: %s %s failed: %s\n", method.c_str(), url.c_str(), curl_easy_strerror(rc));
return std::nullopt;
}
return response;
}
} // namespace e2e