/** * @file src/http_pairing_policy.cpp * @brief Data-only pairing protocol decisions shared by NVHTTP and unit tests. */ #include "http_pairing_policy.h" #include #include #include namespace nvhttp::pairing_policy { namespace { constexpr std::size_t salt_hex_length = 32; constexpr std::size_t pairing_secret_length = 16; std::string_view trim(std::string_view value) { const auto is_space = [](unsigned char character) { return std::isspace(character) != 0; }; while (!value.empty() && is_space(static_cast(value.front()))) { value.remove_prefix(1); } while (!value.empty() && is_space(static_cast(value.back()))) { value.remove_suffix(1); } return value; } bool equals_ignore_case(std::string_view left, std::string_view right) { return left.size() == right.size() && std::equal(left.begin(), left.end(), right.begin(), [](unsigned char lhs, unsigned char rhs) { return std::tolower(lhs) == std::tolower(rhs); }); } decision_t accepted(phase_e next_phase) { return {true, next_phase, {}}; } decision_t rejected(phase_e next_phase, std::string_view failure_message) { return {false, next_phase, failure_message}; } } // namespace std::string certificate_subject_name(const std::optional subject_name) { return subject_name ? std::string {*subject_name} : std::string {}; } bool is_placeholder_client_name(const std::string_view name) { return equals_ignore_case(trim(name), "self"); } std::string display_client_name(const std::string_view paired_name, const std::string_view device_name, const std::string_view host_name) { for (const auto name: {paired_name, device_name, host_name}) { const auto trimmed_name = trim(name); if (!trimmed_name.empty() && !is_placeholder_client_name(trimmed_name)) { return std::string {trimmed_name}; } } return "Sunshine"; } bool valid_unique_id(const std::string_view value) { return !value.empty() && value.size() <= max_unique_id_length && std::all_of(value.begin(), value.end(), [](const unsigned char character) { return std::isalnum(character) || character == '-' || character == '_' || character == '.'; }); } bool valid_hex_field(const std::string_view value, const std::size_t minimum, const std::size_t maximum) { return value.size() >= minimum && value.size() <= maximum && value.size() % 2 == 0 && std::all_of(value.begin(), value.end(), [](const unsigned char character) { return std::isxdigit(character) != 0; }); } bool valid_paired_client_uuid(const std::string_view value) { if (value.size() != 36 || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') { return false; } for (std::size_t index = 0; index < value.size(); ++index) { if (index == 8 || index == 13 || index == 18 || index == 23) { continue; } if (!std::isxdigit(static_cast(value[index]))) { return false; } } return true; } bool paired_client_state_valid(const std::span clients) { if (clients.size() > max_paired_clients) { return false; } std::unordered_set uuids; std::unordered_set certificate_identities; uuids.reserve(clients.size()); certificate_identities.reserve(clients.size()); for (const auto &client : clients) { if (!valid_paired_client_uuid(client.uuid) || client.certificate_identity.empty() || client.certificate_identity.size() > max_paired_certificate_length || !uuids.insert(client.uuid).second || !certificate_identities.insert(client.certificate_identity).second) { return false; } } return true; } paired_client_resolution_t resolve_paired_client( const std::span clients, const std::string_view presented_certificate_identity ) { if (!paired_client_state_valid(clients)) { return {paired_client_resolution_e::invalid_state, 0}; } if (presented_certificate_identity.empty() || presented_certificate_identity.size() > max_paired_certificate_length) { return {paired_client_resolution_e::unknown_certificate, 0}; } for (std::size_t index = 0; index < clients.size(); ++index) { if (clients[index].certificate_identity != presented_certificate_identity) { continue; } return { clients[index].enabled ? paired_client_resolution_e::authorized : paired_client_resolution_e::disabled, index, }; } return {paired_client_resolution_e::unknown_certificate, 0}; } admission_decision_t admit_pending_session( const std::string_view unique_id, const std::string_view client_certificate_hex, const std::string_view salt_hex, const std::size_t pending_sessions, const bool replacing_existing, const bool same_identity ) { // Pairing is accepted at the login screen too. Completing it still // requires the Web UI login to submit the PIN, so the greeter gains no // unauthenticated path; refusing here was a GameStream habit only. if (!valid_unique_id(unique_id)) { return {false, "Invalid uniqueid"}; } if (!valid_hex_field(client_certificate_hex, 2)) { return {false, "Invalid client certificate"}; } if (!valid_hex_field(salt_hex, salt_hex_length, 128)) { return {false, "Invalid pairing salt"}; } // A same-uniqueid request from a different certificate is not a // continuation: accepting it could swap the identity the operator is about // to approve. The identical certificate may replace its own pending // request, which lets a cancelled Moonlight attempt retry at once instead // of waiting out the expiry; completing the pairing still requires that // certificate's private key, so nothing else can hijack the slot. if (pending_sessions >= max_pending_sessions && !(replacing_existing && same_identity)) { return {false, "Too many pending pairing sessions"}; } return {true, {}}; } decision_t begin_get_server_certificate(const session_state_t state, const std::size_t salt_size) { if (state.phase != phase_e::none) { return rejected(state.phase, "Out of order call to getservercert"); } if (salt_size < salt_hex_length) { return rejected(phase_e::get_server_cert, "Salt too short"); } return accepted(phase_e::get_server_cert); } decision_t begin_client_challenge(const session_state_t state) { if (state.phase != phase_e::get_server_cert) { return rejected(state.phase, "Out of order call to clientchallenge"); } if (!state.has_cipher_key) { return rejected(phase_e::client_challenge, "Cipher key not set"); } return accepted(phase_e::client_challenge); } decision_t begin_server_challenge_response(const session_state_t state) { if (state.phase != phase_e::client_challenge) { return rejected(state.phase, "Out of order call to serverchallengeresp"); } if (!state.has_cipher_key || !state.has_server_secret) { return rejected(phase_e::server_challenge_response, "Cipher key or serversecret not set"); } return accepted(phase_e::server_challenge_response); } decision_t begin_client_pairing_secret(const session_state_t state, const std::size_t payload_size) { if (state.phase != phase_e::server_challenge_response) { return rejected(state.phase, "Out of order call to clientpairingsecret"); } if (payload_size <= pairing_secret_length) { return rejected(phase_e::client_pairing_secret, "Client pairing secret too short"); } return accepted(phase_e::client_pairing_secret); } decision_t decide_client_pairing_secret( const session_state_t state, const bool client_certificate_valid, const bool client_hash_matches, const bool client_secret_signature_valid ) { if (state.phase != phase_e::client_pairing_secret) { return rejected(state.phase, "Out of order call to clientpairingsecret"); } if (!client_certificate_valid) { return rejected(phase_e::client_pairing_secret, "Invalid client certificate"); } if (!client_hash_matches || !client_secret_signature_valid) { return rejected(phase_e::client_pairing_secret, {}); } return accepted(phase_e::client_pairing_secret); } } // namespace nvhttp::pairing_policy