ref:4d768847fcd88cc94ac745c4611715c67d7d67e1

Merge commit from fork

SHA: 4d768847fcd88cc94ac745c4611715c67d7d67e1
Author: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com>
Date: 2026-09-03 00:33
Parents: 82bccdf
8 files changed +493 -79
Type
src/crypto.cpp +9 −13
@@ -18,6 +18,7 @@
* @brief OpenSSL X.509 subject/issuer name pointer with automatic release.
*/
using x509_name_t = util::safe_ptr<X509_NAME, &X509_NAME_free>;
constexpr char UNPAIRED_CERTIFICATE_ERROR[] = "Client certificate identity is not paired"; ///< Error returned when no exact stored certificate matches.
cert_chain_t::cert_chain_t():
_certs {},
@@ -51,19 +52,9 @@
}
}
/**
* @brief Verify the certificate chain.
* When certificates from two or more instances of Moonlight have been added to x509_store_t,
* only one of them will be verified by X509_verify_cert, resulting in only a single instance of
* Moonlight to be able to use Sunshine
*
* To circumvent this, x509_store_t instance will be created for each instance of the certificates.
* @param cert The certificate to verify.
* @return nullptr if the certificate is valid, otherwise an error string.
*/
const char *cert_chain_t::verify(x509_t::element_type *cert) {
int err_code = 0;
for (auto &[_, x509_store] : _certs) {
for (auto &[trusted_cert, x509_store] : _certs) {
auto fg = util::fail_guard([this]() {
X509_STORE_CTX_cleanup(_cert_ctx.get());
});
@@ -79,7 +70,12 @@
auto err = X509_verify_cert(_cert_ctx.get());
if (err == 1) {
if (X509_cmp(trusted_cert.get(), cert) == 0) {
return nullptr;
}
return nullptr;
err_code = X509_V_ERR_CERT_REJECTED;
continue;
}
err_code = X509_STORE_CTX_get_error(_cert_ctx.get());
@@ -89,7 +85,7 @@
}
}
return X509_verify_cert_error_string(err_code);
return err_code == 0 ? UNPAIRED_CERTIFICATE_ERROR : X509_verify_cert_error_string(err_code);
}
namespace cipher {
src/crypto.h +6 −0
@@ -192,6 +192,12 @@
*/
void clear();
/**
* @brief Verify that a certificate exactly matches one stored trust anchor.
*
* @param cert Client certificate to verify.
* @return `nullptr` when the exact certificate is trusted, otherwise an OpenSSL error string.
*/
const char *verify(x509_t::element_type *cert);
private:
src/nvhttp.cpp +148 −47
@@ -48,7 +48,8 @@
namespace fs = std::filesystem;
namespace pt = boost::property_tree;
crypto::cert_chain_t cert_chain; ///< Certificate chain presented by Sunshine's GameStream HTTPS server.
crypto::cert_chain_t cert_chain; ///< Enabled paired-client certificates accepted by Sunshine's GameStream HTTPS server.
std::mutex client_auth_mutex; ///< Serializes paired-client state and certificate authorization changes.
/**
* @brief HTTPS server backend that adds Sunshine's client-certificate verification.
@@ -272,6 +273,77 @@
}
/**
* @brief Convert a PEM certificate to Sunshine's canonical OpenSSL serialization.
*
* @param cert_pem PEM-encoded certificate to canonicalize.
* @return Canonical PEM text, or an empty string when the certificate is invalid.
*/
std::string canonical_certificate_pem(const std::string_view cert_pem) {
auto certificate = crypto::x509(cert_pem);
return certificate ? crypto::pem(certificate) : std::string {};
}
/**
* @brief Rebuild the GameStream trust stores from enabled paired-client records.
*
* @note The caller must hold `client_auth_mutex`.
*/
void rebuild_client_cert_chain() {
cert_chain.clear();
for (const auto &named_cert : client_root.named_devices) {
if (!named_cert.enabled) {
continue;
}
auto certificate = crypto::x509(named_cert.cert);
if (!certificate) {
BOOST_LOG(warning) << "Ignoring invalid paired-client certificate"sv;
continue;
}
cert_chain.add(std::move(certificate));
}
}
/**
* @brief Check whether a certificate exactly matches an enabled paired-client record.
*
* @param certificate Parsed client certificate to compare by canonical X.509 identity.
* @return `true` only when exactly one matching paired-client record is enabled.
* @note The caller must hold `client_auth_mutex`.
*/
bool is_client_enabled(const X509 *certificate) {
bool matched = false;
for (const auto &named_cert : client_root.named_devices) {
auto stored_certificate = crypto::x509(named_cert.cert);
if (!stored_certificate || X509_cmp(stored_certificate.get(), certificate) != 0) {
continue;
}
if (matched || !named_cert.enabled) {
return false;
}
matched = true;
}
return matched;
}
/**
* @brief Verify a client certificate against the exact enabled paired identity.
*
* @param certificate Parsed client certificate presented during the TLS handshake.
* @return `nullptr` when authorized, otherwise a non-sensitive error string.
* @note The caller must hold `client_auth_mutex`.
*/
const char *verify_client_certificate(X509 *certificate) {
auto error = cert_chain.verify(certificate);
if (error) {
return error;
}
return is_client_enabled(certificate) ? nullptr : "Client certificate identity is not enabled";
}
/**
* @brief Load state from its backing store.
*/
void load_state() {
@@ -330,13 +402,16 @@
}
}
// Empty certificate chain and import certs from file
cert_chain.clear();
for (auto &named_cert : client.named_devices) {
cert_chain.add(crypto::x509(named_cert.cert));
auto canonical_certificate = canonical_certificate_pem(named_cert.cert);
if (!canonical_certificate.empty()) {
named_cert.cert = std::move(canonical_certificate);
}
}
std::lock_guard lock {client_auth_mutex};
client_root = client;
client_root = std::move(client);
rebuild_client_cert_chain();
}
/**
@@ -344,18 +419,27 @@
*
* @param name Human-readable name to assign.
* @param cert Certificate data or object used by the operation.
* @return Persistent UUID for the added client, or an empty string when the certificate is invalid.
*/
void add_authorized_client(const std::string &name, std::string &&cert) {
client_t &client = client_root;
std::string add_authorized_client(const std::string &name, std::string &&cert) {
auto canonical_certificate = canonical_certificate_pem(cert);
if (canonical_certificate.empty()) {
return {};
}
named_cert_t named_cert;
named_cert.name = name;
named_cert.cert = std::move(cert);
named_cert.cert = std::move(canonical_certificate);
named_cert.uuid = uuid_util::uuid_t::generate().string();
std::lock_guard lock {client_auth_mutex};
client.named_devices.emplace_back(named_cert);
client_root.named_devices.emplace_back(std::move(named_cert));
rebuild_client_cert_chain();
if (!config::sunshine.flags[config::flag::FRESH_STATE]) {
save_state();
}
return client_root.named_devices.back().uuid;
}
/**
@@ -710,11 +794,10 @@
* @brief Handle the client pairing-secret phase of GameStream pairing.
*
* @param sess Pairing session that owns the request state.
* @param add_cert Add cert.
* @param tree XML property tree used for the response body.
* @param client_pairing_secret Client pairing secret.
*/
void clientpairingsecret(pair_session_t &sess, pt::ptree &tree, const std::string &client_pairing_secret) {
void clientpairingsecret(pair_session_t &sess, std::shared_ptr<safe::queue_t<crypto::x509_t>> &add_cert, pt::ptree &tree, const std::string &client_pairing_secret) {
if (sess.last_phase != PAIR_PHASE::SERVERCHALLENGERESP) {
fail_pair(sess, tree, "Out of order call to clientpairingsecret");
return;
@@ -751,11 +834,8 @@
bool same_hash = hash.size() == sess.clienthash.size() && std::equal(hash.begin(), hash.end(), sess.clienthash.begin());
auto verify = crypto::verify256(crypto::x509(client.cert), secret, sign);
if (same_hash && verify) {
tree.put("root.paired", 1);
add_cert->raise(crypto::x509(client.cert));
// The client is now successfully paired and will be authorized to connect
add_authorized_client(client.name, std::move(client.cert));
tree.put("root.paired", add_authorized_client(client.name, std::move(client.cert)).empty() ? 0 : 1);
} else {
tree.put("root.paired", 0);
}
@@ -835,12 +915,11 @@
/**
* @brief Dispatch the top-level GameStream pairing request by phase.
*
* @param add_cert Add cert.
* @param response HTTP response object to populate.
* @param request HTTP request data from the client.
*/
template<class T>
void pair(std::shared_ptr<safe::queue_t<crypto::x509_t>> &add_cert, std::shared_ptr<typename SimpleWeb::ServerBase<T>::Response> response, std::shared_ptr<typename SimpleWeb::ServerBase<T>::Request> request) {
void pair(std::shared_ptr<typename SimpleWeb::ServerBase<T>::Response> response, std::shared_ptr<typename SimpleWeb::ServerBase<T>::Request> request) {
print_req<T>(request);
pt::ptree tree;
@@ -950,7 +1029,7 @@
serverchallengeresp(sess_it->second, tree, encrypted_response);
} else if (it = args.find("clientpairingsecret"); it != std::end(args)) {
auto pairingsecret = util::from_hex_vec(it->second, true);
clientpairingsecret(sess_it->second, add_cert, tree, pairingsecret);
clientpairingsecret(sess_it->second, tree, pairingsecret);
pairing_complete = true;
} else {
fail_pair(sess_it->second, tree, "Invalid pairing request");
@@ -1106,8 +1185,8 @@
nlohmann::json get_all_clients() {
nlohmann::json named_cert_nodes = nlohmann::json::array();
client_t &client = client_root;
for (auto &named_cert : client.named_devices) {
std::lock_guard lock {client_auth_mutex};
for (const auto &named_cert : client_root.named_devices) {
nlohmann::json named_cert_node;
named_cert_node["name"] = named_cert.name;
named_cert_node["uuid"] = named_cert.uuid;
@@ -1431,7 +1510,6 @@
enabled device and "name" is the friendly client name set during pairing.
*/
std::pair<bool, std::string> get_client_status(const std::string_view cert_pem);
void start() {
platf::set_thread_name("nvhttp");
auto shutdown_event = mail::man->event<bool>(mail::shutdown);
@@ -1450,8 +1528,6 @@
auto cert = file_handler::read_file(config::nvhttp.cert.c_str());
setup(pkey, cert);
auto add_cert = std::make_shared<safe::queue_t<crypto::x509_t>>(30);
// resume doesn't always get the parameter "localAudioPlayMode"
// launch will store it in host_audio
bool host_audio {};
@@ -1460,7 +1536,7 @@
http_server_t http_server;
// Verify certificates after establishing connection
https_server.verify = [](SSL *ssl) {
https_server.verify = [add_cert](SSL *ssl) {
crypto::x509_t x509 {
#if OPENSSL_VERSION_MAJOR >= 3
SSL_get1_peer_certificate(ssl)
@@ -1483,16 +1559,7 @@
BOOST_LOG(debug) << subject_name << " -- "sv << (verified ? "verified"sv : "denied"sv);
});
while (add_cert->peek()) {
char subject_name[256];
auto cert = add_cert->pop();
X509_NAME_oneline(X509_get_subject_name(cert.get()), subject_name, sizeof(subject_name));
BOOST_LOG(debug) << "Added cert ["sv << subject_name << ']';
cert_chain.add(std::move(cert));
}
auto err_str = cert_chain.verify(x509.get());
std::lock_guard lock {client_auth_mutex};
auto err_str = verify_client_certificate(x509.get());
if (err_str) {
BOOST_LOG(warning) << "SSL Verification error :: "sv << err_str;
@@ -1532,8 +1599,8 @@
https_server.default_resource["GET"] = not_found<SunshineHTTPS>;
https_server.resource["^/serverinfo$"]["GET"] = serverinfo<SunshineHTTPS>;
https_server.resource["^/pair$"]["GET"] = [&add_cert](auto resp, auto req) {
pair<SunshineHTTPS>(add_cert, resp, req);
https_server.resource["^/pair$"]["GET"] = [](auto resp, auto req) {
pair<SunshineHTTPS>(resp, req);
};
https_server.resource["^/applist$"]["GET"] = applist;
https_server.resource["^/appasset$"]["GET"] = appasset;
@@ -1551,8 +1618,8 @@
http_server.default_resource["GET"] = not_found<SimpleWeb::HTTP>;
http_server.resource["^/serverinfo$"]["GET"] = serverinfo<SimpleWeb::HTTP>;
http_server.resource["^/pair$"]["GET"] = [&add_cert](auto resp, auto req) {
pair<SimpleWeb::HTTP>(add_cert, resp, req);
http_server.resource["^/pair$"]["GET"] = [](auto resp, auto req) {
pair<SimpleWeb::HTTP>(resp, req);
};
http_server.config.reuse_address = true;
@@ -1589,34 +1656,35 @@
}
void erase_all_clients() {
std::lock_guard lock {client_auth_mutex};
client_root = {};
client_t client;
client_root = client;
cert_chain.clear();
save_state();
}
bool unpair_client(const std::string_view uuid) {
std::lock_guard lock {client_auth_mutex};
bool removed = false;
client_t &client = client_root;
for (auto it = client.named_devices.begin(); it != client.named_devices.end();) {
for (auto it = client_root.named_devices.begin(); it != client_root.named_devices.end();) {
if ((*it).uuid == uuid) {
it = client.named_devices.erase(it);
it = client_root.named_devices.erase(it);
removed = true;
} else {
++it;
}
}
rebuild_client_cert_chain();
save_state();
load_state();
return removed;
}
bool set_client_enabled(const std::string_view uuid, bool enabled) {
client_t &client = client_root;
for (auto &named_cert : client.named_devices) {
std::lock_guard lock {client_auth_mutex};
for (auto &named_cert : client_root.named_devices) {
if (named_cert.uuid == uuid) {
named_cert.enabled = enabled;
rebuild_client_cert_chain();
save_state();
return true;
}
@@ -1628,6 +1696,7 @@
* @brief Get cert by UUID.
*/
std::string get_cert_by_uuid(const std::string_view uuid) {
std::lock_guard lock {client_auth_mutex};
for (const auto &named_cert : client_root.named_devices) {
if (named_cert.uuid == uuid) {
return named_cert.cert;
@@ -1648,4 +1717,36 @@
}
return {true, {}};
}
#ifdef SUNSHINE_TESTS
namespace test_support {
void reset_client_state() {
std::lock_guard lock {client_auth_mutex};
client_root = {};
cert_chain.clear();
}
std::string add_client(const std::string &name, std::string cert, bool enabled) {
auto uuid = add_authorized_client(name, std::move(cert));
if (!uuid.empty() && !enabled) {
set_client_enabled(uuid, false);
}
return uuid;
}
bool authorize_client_certificate(const std::string_view cert) {
auto certificate = crypto::x509(cert);
if (!certificate) {
return false;
}
std::lock_guard lock {client_auth_mutex};
return verify_client_certificate(certificate.get()) == nullptr;
}
void reload_client_state() {
load_state();
}
} // namespace test_support
#endif
} // namespace nvhttp
src/nvhttp.h +36 −3
@@ -19,7 +19,6 @@
// local includes
#include "crypto.h"
#include "thread_safe.h"
/**
* @brief Contains all the functions and variables related to the nvhttp (GameStream) server.
@@ -288,11 +287,10 @@
* Then using the client certificate public key we should be able to verify that
* the client secret has been signed by Moonlight
* @param sess Pairing session that owns the request state.
* @param add_cert Add cert.
* @param tree XML property tree used for the response body.
* @param client_pairing_secret Client pairing secret.
*/
void clientpairingsecret(pair_session_t &sess, boost::property_tree::ptree &tree, const std::string &client_pairing_secret);
void clientpairingsecret(pair_session_t &sess, std::shared_ptr<safe::queue_t<crypto::x509_t>> &add_cert, boost::property_tree::ptree &tree, const std::string &client_pairing_secret);
/**
* @brief Apply the user supplied PIN to the explicitly selected pairing request.
@@ -348,4 +346,39 @@
* @examples_end
*/
void erase_all_clients();
#ifdef SUNSHINE_TESTS
/**
* @brief Test-only accessors for paired-client authorization state.
*/
namespace test_support {
/**
* @brief Clear in-memory paired-client records without changing persisted state.
*/
void reset_client_state();
/**
* @brief Add a paired-client record for authorization tests.
*
* @param name Human-readable client name.
* @param cert PEM-encoded client certificate.
* @param enabled Whether the client may connect.
* @return Persistent UUID for the added client, or an empty string when invalid.
*/
std::string add_client(const std::string &name, std::string cert, bool enabled);
/**
* @brief Run the production certificate authorization checks against PEM input.
*
* @param cert PEM-encoded certificate presented by a client.
* @return `true` when the exact certificate belongs to one enabled paired client.
*/
bool authorize_client_certificate(std::string_view cert);
/**
* @brief Reload paired-client authorization state from the configured state file.
*/
void reload_client_state();
} // namespace test_support
#endif
} // namespace nvhttp
tests/certificate_test_utils.h +90 −0
@@ -1,0 +1,90 @@
/**
* @file tests/certificate_test_utils.h
* @brief Certificate fixtures shared by Sunshine security tests.
*/
#pragma once
// standard includes
#include <algorithm>
// lib includes
#include <openssl/x509v3.h>
// local includes
#include <src/crypto.h>
namespace test_utils::certificates {
/**
* @brief Generate a self-signed certificate that OpenSSL may use as a certificate authority.
*
* @param common_name Common name for the generated certificate.
* @return PEM-encoded certificate-authority credentials.
*/
inline crypto::creds_t generate_ca_credentials(const std::string_view common_name = "Sunshine Test Client CA") {
auto credentials = crypto::gen_creds(common_name, 2048);
auto certificate = crypto::x509(credentials.x509);
auto private_key = crypto::pkey(credentials.pkey);
util::safe_ptr<BASIC_CONSTRAINTS, BASIC_CONSTRAINTS_free> constraints {BASIC_CONSTRAINTS_new()};
constraints->ca = 1;
X509_add1_ext_i2d(certificate.get(), NID_basic_constraints, constraints.get(), 1, X509V3_ADD_DEFAULT);
X509_sign(certificate.get(), private_key.get(), EVP_sha256());
credentials.x509 = crypto::pem(certificate);
return credentials;
}
/**
* @brief Generate a leaf certificate signed by the supplied certificate authority.
*
* @param issuer_credentials PEM-encoded issuer certificate and private key.
* @return PEM-encoded leaf credentials.
*/
inline crypto::creds_t generate_derived_leaf(const crypto::creds_t &issuer_credentials) {
auto credentials = crypto::gen_creds("Sunshine Test Derived Client", 2048);
auto certificate = crypto::x509(credentials.x509);
auto issuer_certificate = crypto::x509(issuer_credentials.x509);
auto issuer_private_key = crypto::pkey(issuer_credentials.pkey);
X509_set_issuer_name(certificate.get(), X509_get_subject_name(issuer_certificate.get()));
X509_sign(certificate.get(), issuer_private_key.get(), EVP_sha256());
credentials.x509 = crypto::pem(certificate);
return credentials;
}
/**
* @brief Change a certificate's validity period so it is already expired.
*
* @param credentials PEM-encoded certificate and its signing key.
* @return The supplied credentials with an expired, re-signed certificate.
*/
inline crypto::creds_t expire_credentials(crypto::creds_t credentials) {
auto certificate = crypto::x509(credentials.x509);
auto private_key = crypto::pkey(credentials.pkey);
constexpr long two_hours = 2 * 60 * 60;
constexpr long one_hour = 60 * 60;
X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -two_hours);
X509_gmtime_adj(X509_getm_notAfter(certificate.get()), -one_hour);
X509_sign(certificate.get(), private_key.get(), EVP_sha256());
credentials.x509 = crypto::pem(certificate);
return credentials;
}
/**
* @brief Convert canonical PEM line endings to CRLF without changing certificate identity.
*
* @param pem Canonical PEM text.
* @return Equivalent PEM text using CRLF line endings.
*/
inline std::string to_crlf_pem(const std::string_view pem) {
std::string converted;
converted.reserve(pem.size() + std::ranges::count(pem, '\n'));
for (const char character : pem) {
if (character == '\n') {
converted.push_back('\r');
}
converted.push_back(character);
}
return converted;
}
} // namespace test_utils::certificates
tests/unit/test_crypto.cpp +15 −0
@@ -9,6 +9,8 @@
#include <openssl/x509.h>
// local includes
#include "../certificate_test_utils.h"
#include <src/crypto.h>
TEST(CryptoTest, GeneratedCredentialsExposeSubjectAndVerifySignatures) {
@@ -47,4 +49,17 @@
const auto signature = crypto::sign256(pkey, payload);
ASSERT_FALSE(signature.empty());
ASSERT_TRUE(crypto::verify256(cert, payload, {reinterpret_cast<const char *>(signature.data()), signature.size()}));
}
TEST(CryptoTest, CertificateChainRequiresExactPairedIdentity) {
const auto issuer_credentials = test_utils::certificates::generate_ca_credentials();
const auto derived_credentials = test_utils::certificates::generate_derived_leaf(issuer_credentials);
auto paired_certificate = crypto::x509(issuer_credentials.x509);
auto derived_certificate = crypto::x509(derived_credentials.x509);
crypto::cert_chain_t certificate_chain;
certificate_chain.add(crypto::x509(issuer_credentials.x509));
EXPECT_EQ(certificate_chain.verify(paired_certificate.get()), nullptr);
EXPECT_NE(certificate_chain.verify(derived_certificate.get()), nullptr);
}
tests/unit/test_http_pairing.cpp +27 −16
@@ -14,6 +14,7 @@
#include <vector>
// local includes
#include <src/config.h>
#include <src/nvhttp.h>
using namespace nvhttp;
@@ -85,7 +86,29 @@
9SVDV76xJzM36Uq7Kg3QJYTlY04WmPIdJHkCtXWf9g==
-----END CERTIFICATE-----)";
struct PairingTest: BaseTest, testing::WithParamInterface<std::tuple<pairing_input, pairing_output>> {
/**
* @brief Isolate pairing authorization state and suppress persistence for the test.
*/
void SetUp() override {
BaseTest::SetUp();
original_fresh_state = config::sunshine.flags[config::flag::FRESH_STATE];
config::sunshine.flags[config::flag::FRESH_STATE] = true;
nvhttp::test_support::reset_client_state();
}
/**
* @brief Restore the caller's fresh-state configuration after the pairing test.
*/
void TearDown() override {
nvhttp::test_support::reset_client_state();
config::sunshine.flags[config::flag::FRESH_STATE] = original_fresh_state;
BaseTest::TearDown();
}
private:
struct PairingTest: BaseTest, testing::WithParamInterface<std::tuple<pairing_input, pairing_output>> {};
bool original_fresh_state; ///< Fresh-state flag restored after each pairing test.
};
TEST_P(PairingTest, Run) {
auto [input, expected] = GetParam();
@@ -118,21 +141,10 @@
// phase 4
auto input_client_cert = input.session->client.cert; // Will be moved
auto add_cert = std::make_shared<safe::queue_t<crypto::x509_t>>(30);
clientpairingsecret(*input.session, add_cert, tree, input.client_pairing_secret);
clientpairingsecret(*input.session, tree, input.client_pairing_secret);
ASSERT_EQ(tree.get<int>("root.paired") == 1, expected.phase_4_success);
// Check that we actually added the input client certificate to `add_cert`
if (expected.phase_4_success) {
ASSERT_TRUE(nvhttp::test_support::authorize_client_certificate(input_client_cert));
ASSERT_EQ(add_cert->peek(), true);
auto cert = add_cert->pop();
char added_subject_name[256];
X509_NAME_oneline(X509_get_subject_name(cert.get()), added_subject_name, sizeof(added_subject_name));
auto input_cert = crypto::x509(input_client_cert);
char original_suject_name[256];
X509_NAME_oneline(X509_get_subject_name(input_cert.get()), original_suject_name, sizeof(original_suject_name));
ASSERT_EQ(std::string(added_subject_name), std::string(original_suject_name));
}
}
@@ -261,8 +273,7 @@
serverchallengeresp(sess, tree, "test");
ASSERT_FALSE(tree.get<int>("root.paired") == 1);
clientpairingsecret(sess, tree, "test");
auto add_cert = std::make_shared<safe::queue_t<crypto::x509_t>>(30);
clientpairingsecret(sess, add_cert, tree, "test");
ASSERT_FALSE(tree.get<int>("root.paired") == 1);
// This should work, it's the first time we call it
tests/unit/test_nvhttp_client_auth.cpp +162 −0
@@ -1,0 +1,162 @@
/**
* @file tests/unit/test_nvhttp_client_auth.cpp
* @brief Test exact paired-client certificate authorization and persistence.
*/
#include "../certificate_test_utils.h"
#include "../tests_common.h"
// standard includes
#include <atomic>
#include <filesystem>
#include <thread>
#include <vector>
// local includes
#include <src/config.h>
#include <src/nvhttp.h>
namespace fs = std::filesystem;
/**
* @brief Isolate paired-client authorization tests from the user's Sunshine state.
*/
class ClientAuthorizationTest: public BaseTest {
protected:
/**
* @brief Redirect persisted client state to the test build directory.
*/
void SetUp() override {
BaseTest::SetUp();
original_state_file = config::nvhttp.file_state;
original_fresh_state = config::sunshine.flags[config::flag::FRESH_STATE];
state_file = fs::path {SUNSHINE_TEST_BIN_DIR} / "client_authorization_state.json";
config::nvhttp.file_state = state_file.string();
config::sunshine.flags[config::flag::FRESH_STATE] = false;
nvhttp::test_support::reset_client_state();
std::error_code error;
fs::remove(state_file, error);
}
/**
* @brief Remove test state and restore the caller's configuration.
*/
void TearDown() override {
nvhttp::test_support::reset_client_state();
std::error_code error;
fs::remove(state_file, error);
config::nvhttp.file_state = original_state_file;
config::sunshine.flags[config::flag::FRESH_STATE] = original_fresh_state;
BaseTest::TearDown();
}
fs::path state_file; ///< Task-specific persisted state fixture.
std::string original_state_file; ///< State-file setting restored after each test.
bool original_fresh_state; ///< Fresh-state flag restored after each test.
};
TEST_F(ClientAuthorizationTest, CanonicalIdentityFailsClosedAndTracksEnableState) {
const auto paired_credentials = test_utils::certificates::generate_ca_credentials();
const auto crlf_certificate = test_utils::certificates::to_crlf_pem(paired_credentials.x509);
const auto unknown_credentials = crypto::gen_creds("Sunshine Unknown Client", 2048);
const auto uuid = nvhttp::test_support::add_client("paired", crlf_certificate, true);
ASSERT_FALSE(uuid.empty());
EXPECT_EQ(nvhttp::get_cert_by_uuid(uuid), paired_credentials.x509);
EXPECT_TRUE(nvhttp::test_support::authorize_client_certificate(paired_credentials.x509));
EXPECT_TRUE(nvhttp::test_support::authorize_client_certificate(crlf_certificate));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(unknown_credentials.x509));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate("not a certificate"));
ASSERT_TRUE(nvhttp::set_client_enabled(uuid, false));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(paired_credentials.x509));
ASSERT_TRUE(nvhttp::set_client_enabled(uuid, true));
EXPECT_TRUE(nvhttp::test_support::authorize_client_certificate(paired_credentials.x509));
}
TEST_F(ClientAuthorizationTest, DerivedLeafDoesNotInheritPairedAuthorization) {
const auto paired_credentials = test_utils::certificates::generate_ca_credentials();
const auto derived_credentials = test_utils::certificates::generate_derived_leaf(paired_credentials);
const auto uuid = nvhttp::test_support::add_client("paired issuer", paired_credentials.x509, true);
ASSERT_FALSE(uuid.empty());
EXPECT_TRUE(nvhttp::test_support::authorize_client_certificate(paired_credentials.x509));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(derived_credentials.x509));
ASSERT_TRUE(nvhttp::set_client_enabled(uuid, false));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(paired_credentials.x509));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(derived_credentials.x509));
}
TEST_F(ClientAuthorizationTest, MultipleClientsPersistAndUnpairIndependently) {
const auto enabled_credentials = test_utils::certificates::generate_ca_credentials("Sunshine Enabled Client");
const auto disabled_credentials = crypto::gen_creds("Sunshine Disabled Client", 2048);
const auto expired_credentials = test_utils::certificates::expire_credentials(
test_utils::certificates::generate_ca_credentials("Sunshine Expired Client")
);
const auto enabled_uuid = nvhttp::test_support::add_client("enabled", enabled_credentials.x509, true);
const auto disabled_uuid = nvhttp::test_support::add_client("disabled", disabled_credentials.x509, false);
const auto expired_uuid = nvhttp::test_support::add_client("expired", expired_credentials.x509, true);
ASSERT_FALSE(enabled_uuid.empty());
ASSERT_FALSE(disabled_uuid.empty());
ASSERT_FALSE(expired_uuid.empty());
EXPECT_TRUE(nvhttp::test_support::authorize_client_certificate(enabled_credentials.x509));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(disabled_credentials.x509));
EXPECT_TRUE(nvhttp::test_support::authorize_client_certificate(expired_credentials.x509));
nvhttp::test_support::reset_client_state();
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(enabled_credentials.x509));
nvhttp::test_support::reload_client_state();
EXPECT_TRUE(nvhttp::test_support::authorize_client_certificate(enabled_credentials.x509));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(disabled_credentials.x509));
EXPECT_TRUE(nvhttp::test_support::authorize_client_certificate(expired_credentials.x509));
ASSERT_TRUE(nvhttp::set_client_enabled(disabled_uuid, true));
EXPECT_TRUE(nvhttp::test_support::authorize_client_certificate(disabled_credentials.x509));
ASSERT_TRUE(nvhttp::unpair_client(enabled_uuid));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(enabled_credentials.x509));
nvhttp::erase_all_clients();
nvhttp::test_support::reset_client_state();
nvhttp::test_support::reload_client_state();
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(disabled_credentials.x509));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(expired_credentials.x509));
}
TEST_F(ClientAuthorizationTest, DuplicateCertificateIdentityFailsClosed) {
const auto credentials = test_utils::certificates::generate_ca_credentials();
ASSERT_FALSE(nvhttp::test_support::add_client("first", credentials.x509, true).empty());
ASSERT_FALSE(nvhttp::test_support::add_client("second", credentials.x509, true).empty());
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(credentials.x509));
}
TEST_F(ClientAuthorizationTest, ConcurrentStateChangesRemainConsistent) {
const auto credentials = test_utils::certificates::generate_ca_credentials();
const auto uuid = nvhttp::test_support::add_client("concurrent", credentials.x509, true);
ASSERT_FALSE(uuid.empty());
std::atomic_bool operations_succeeded {true};
std::vector<std::jthread> workers;
for (std::size_t worker = 0; worker < 4; ++worker) {
workers.emplace_back([&operations_succeeded, uuid, certificate = credentials.x509, worker]() {
for (std::size_t iteration = 0; iteration < 8; ++iteration) {
if (!nvhttp::set_client_enabled(uuid, (worker + iteration) % 2 == 0)) {
operations_succeeded = false;
}
static_cast<void>(nvhttp::test_support::authorize_client_certificate(certificate));
}
});
}
workers.clear();
EXPECT_TRUE(operations_succeeded);
ASSERT_TRUE(nvhttp::set_client_enabled(uuid, false));
EXPECT_FALSE(nvhttp::test_support::authorize_client_certificate(credentials.x509));
}