diff --git a/docs/juce-module.md b/docs/juce-module.md index 7e17351..c03b3e3 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -133,6 +133,8 @@ The screens: `onDiagnostic`. If the request can't be started, the welcome screen's message says whether Moonbase was unreachable, busy (rate limiting or a server error), or refused it for this product, or that the machine has no identity to activate. + A failure that is none of those (a bug in a custom device id resolver, say) is + shown in its own words rather than passed off as a connection problem. - **Success** — animated confirmation with a mini license card. - **Offline** — two-step machine-file flow: save the request (`generate_device_token`), then load the response file (`read_offline_license`, validated locally). @@ -386,8 +388,11 @@ builds the module and runs the behavioral suite on macOS. ## Diagnostics -The UI shows friendly, end-user-facing copy. To see the underlying reason behind a failure -(bad config, rejected token, unreachable server, persist failure), wire a diagnostic sink: +The UI shows friendly, end-user-facing copy for every failure it can name. One it can't (an +exception that is neither Moonbase's answer nor a failed connection) is shown verbatim, so a +customer's screenshot carries the actual error. To see the underlying reason behind any +failure (bad config, rejected token, unreachable server, persist failure), wire a diagnostic +sink: ```cpp config.onDiagnostic = [] (const juce::String& message) { diff --git a/include/moonbase/client.hpp b/include/moonbase/client.hpp index bfed993..1b1ee9e 100644 --- a/include/moonbase/client.hpp +++ b/include/moonbase/client.hpp @@ -407,6 +407,26 @@ class activation_poll_gate { clock::time_point hold_until_{}; }; +// `text` with each byte that is not part of valid UTF-8 replaced by U+FFFD. +// nlohmann::json's own validator decides, so the result is exactly what a strict +// dump() accepts rather than a second opinion on it. +[[nodiscard]] inline std::string replace_invalid_utf8(const std::string& text) +{ + return nlohmann::json::parse( + nlohmann::json(text).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace)) + .get(); +} + +// A device id is never repaired the way a device name is. The server would bind +// the repaired id, which the resolver never returns, so the license would take a +// seat and then fail to validate on this very device. Refuse it unsent instead. +inline void require_utf8_device_id(const std::string& device_id) +{ + if (replace_invalid_utf8(device_id) != device_id) { + throw configuration_error("The device id resolver returned a device id that is not valid UTF-8"); + } +} + } // namespace detail class license_client { @@ -443,9 +463,11 @@ class license_client { // The API refuses a blank device name or id outright. The name is only a // label, so stand in for one the host could not supply (a failed host - // name lookup, a custom resolver). The id is the binding itself, and a - // resolver that returns none is misconfigured. - auto device_name = device_ids_->device_name(); + // name lookup, a custom resolver), and let a byte in it that is not UTF-8 + // cost a replacement character rather than the activation. The id is the + // binding itself, and a resolver that returns none, or one that is not + // UTF-8, is misconfigured. + auto device_name = detail::replace_invalid_utf8(device_ids_->device_name()); if (detail::trim_ascii_whitespace(device_name).empty()) { device_name = "Unknown device"; } @@ -453,6 +475,7 @@ class license_client { if (detail::trim_ascii_whitespace(device_id).empty()) { throw configuration_error("The device id resolver returned an empty device id"); } + detail::require_utf8_device_id(device_id); const auto payload = nlohmann::json{ {"deviceName", device_name}, diff --git a/include/moonbase/detail/unicode/utf16.hpp b/include/moonbase/detail/unicode/utf16.hpp new file mode 100644 index 0000000..6569cfe --- /dev/null +++ b/include/moonbase/detail/unicode/utf16.hpp @@ -0,0 +1,59 @@ +#pragma once + +// UTF-16 to UTF-8, for the strings Windows only hands out faithfully through its +// wide APIs. The narrow ("A") variants answer in the ANSI code page instead, so a +// computer name such as "Björn-PC" comes back as bytes that are not UTF-8, and +// nlohmann::json refuses to serialize those. +// +// Plain C++ with no OS headers, so it is tested on every platform rather than +// only on the one it runs on. + +#include +#include +#include + +namespace moonbase::detail::unicode { + +/// Transcode UTF-16 to UTF-8. +/// +/// Never fails: an unpaired surrogate becomes U+FFFD, as WideCharToMultiByte +/// does, so the result is always valid UTF-8 and always safe to serialize. +[[nodiscard]] inline std::string utf16_to_utf8(std::u16string_view text) +{ + std::string out; + out.reserve(text.size()); + + for (std::size_t index = 0; index != text.size(); ++index) { + char32_t code_point = text[index]; + + const bool high_surrogate = code_point >= 0xD800 && code_point <= 0xDBFF; + const bool low_surrogate = code_point >= 0xDC00 && code_point <= 0xDFFF; + if (high_surrogate && index + 1 != text.size() && text[index + 1] >= 0xDC00 + && text[index + 1] <= 0xDFFF) { + code_point = 0x10000 + ((code_point - 0xD800) << 10U) + (text[index + 1] - 0xDC00U); + ++index; + } else if (high_surrogate || low_surrogate) { + code_point = 0xFFFD; + } + + if (code_point < 0x80) { + out.push_back(static_cast(code_point)); + } else if (code_point < 0x800) { + out.push_back(static_cast(0xC0U | (code_point >> 6U))); + out.push_back(static_cast(0x80U | (code_point & 0x3FU))); + } else if (code_point < 0x10000) { + out.push_back(static_cast(0xE0U | (code_point >> 12U))); + out.push_back(static_cast(0x80U | ((code_point >> 6U) & 0x3FU))); + out.push_back(static_cast(0x80U | (code_point & 0x3FU))); + } else { + out.push_back(static_cast(0xF0U | (code_point >> 18U))); + out.push_back(static_cast(0x80U | ((code_point >> 12U) & 0x3FU))); + out.push_back(static_cast(0x80U | ((code_point >> 6U) & 0x3FU))); + out.push_back(static_cast(0x80U | (code_point & 0x3FU))); + } + } + + return out; +} + +} // namespace moonbase::detail::unicode diff --git a/include/moonbase/http.hpp b/include/moonbase/http.hpp index 5dc2e86..1ee0087 100644 --- a/include/moonbase/http.hpp +++ b/include/moonbase/http.hpp @@ -24,6 +24,14 @@ struct http_response { class http_transport { public: virtual ~http_transport() = default; + + /// Perform one exchange and return the response, whatever its status. + /// + /// When no response arrives at all (DNS, a refused connection, TLS, a timeout, + /// cancellation), throw api_error with status code 0, as both bundled + /// transports do. That is what tells "Moonbase could not be reached" apart from + /// a failure on this machine: the JUCE activation screen asks the user to check + /// their connection for the first, and shows any other exception as it is. [[nodiscard]] virtual http_response send(const http_request& request) = 0; }; diff --git a/include/moonbase/legacy_fingerprint.hpp b/include/moonbase/legacy_fingerprint.hpp index ad920a7..7b7f84c 100644 --- a/include/moonbase/legacy_fingerprint.hpp +++ b/include/moonbase/legacy_fingerprint.hpp @@ -39,6 +39,7 @@ #endif #include "moonbase/detail/crypto/crypto.hpp" +#include "moonbase/detail/unicode/utf16.hpp" #include "moonbase/device_id_resolver.hpp" namespace moonbase { @@ -146,8 +147,42 @@ class legacy_cpp_device_id_resolver : public device_id_resolver { return parameters; } + // Only a label, so unlike the rest of this class it is free to be correct: read + // through the wide API as UTF-8 on Windows, where the ANSI reading is not UTF-8 + // and cannot be serialized. device_id() still hashes that ANSI reading. [[nodiscard]] std::string device_name() const override { +#if defined(_WIN32) + wchar_t buffer[128]{}; + auto size = static_cast(sizeof(buffer) / sizeof(buffer[0])); + if (!GetComputerNameExW(ComputerNamePhysicalDnsHostname, buffer, &size)) { + return {}; + } + std::u16string name; + name.reserve(size); + for (DWORD index = 0; index != size; ++index) { + name.push_back(static_cast(buffer[index])); + } + return detail::unicode::utf16_to_utf8(name); +#else + return hashed_host_name(); +#endif + } + + [[nodiscard]] std::string device_id() const override + { + auto parameters = identity_parameters(); + if (parameters.empty()) { + append_parameter(parameters, "deviceName", hashed_host_name()); + } + return hash_identity_parameters(parameters); + } + +private: + // The host name as device_id() hashes it. On Windows that is the ANSI code + // page reading, byte for byte. + [[nodiscard]] static std::string hashed_host_name() + { #if defined(_WIN32) char buffer[128]{}; DWORD size = static_cast(sizeof(buffer)) - 1; @@ -177,16 +212,6 @@ class legacy_cpp_device_id_resolver : public device_id_resolver { #endif } - [[nodiscard]] std::string device_id() const override - { - auto parameters = identity_parameters(); - if (parameters.empty()) { - append_parameter(parameters, "deviceName", device_name()); - } - return hash_identity_parameters(parameters); - } - -private: [[nodiscard]] static std::string read_file(const std::string& path) { std::ifstream file(path); diff --git a/include/moonbase/licensing.hpp b/include/moonbase/licensing.hpp index fd3ac9c..c923ff3 100644 --- a/include/moonbase/licensing.hpp +++ b/include/moonbase/licensing.hpp @@ -110,9 +110,13 @@ class licensing { // offline-activated license token in return. [[nodiscard]] std::string generate_device_token() const { + // As request_activation does: a name that is not UTF-8 is repaired, since + // it is only a label, and an id that is not UTF-8 is refused. + const auto device_id = device_ids_->device_id(); + detail::require_utf8_device_id(device_id); const nlohmann::json payload{ - {"id", device_ids_->device_id()}, - {"name", device_ids_->device_name()}, + {"id", device_id}, + {"name", detail::replace_invalid_utf8(device_ids_->device_name())}, {"productId", options_.product_id}, // The Moonbase API expects this to always be "JWT". {"format", "JWT"}, diff --git a/include/moonbase/moonbase_device_id_resolver.hpp b/include/moonbase/moonbase_device_id_resolver.hpp index a9af1db..137cba8 100644 --- a/include/moonbase/moonbase_device_id_resolver.hpp +++ b/include/moonbase/moonbase_device_id_resolver.hpp @@ -103,6 +103,7 @@ #endif #endif +#include "moonbase/detail/unicode/utf16.hpp" #include "moonbase/device_id_resolver.hpp" #include "moonbase/errors.hpp" #include "moonbase/fingerprint_spec.hpp" @@ -204,7 +205,7 @@ class moonbase_device_id_resolver : public device_id_resolver { } } - /// The host name, with a trailing ".local" removed on macOS. + /// The host name as UTF-8, with a trailing ".local" removed on macOS. /// /// Never throws: a machine with no readable identity still has to be able to /// label itself, since activation sends the name alongside the id. @@ -257,15 +258,24 @@ class moonbase_device_id_resolver : public device_id_resolver { return identity; } + /// The host name as UTF-8, with a trailing ".local" removed on macOS. [[nodiscard]] static std::string read_host_name() { #if defined(_WIN32) - std::array buffer{}; - auto size = static_cast(buffer.size()) - 1; - if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) { - return std::string(buffer.data(), size); + // The wide API, transcoded. GetComputerNameExA answers in the ANSI code + // page, so a name with any non-ASCII letter came back as bytes that are + // not UTF-8, and serializing the activation request threw on them. + std::array buffer{}; + auto size = static_cast(buffer.size()); + if (!GetComputerNameExW(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) { + return {}; } - return {}; + std::u16string name; + name.reserve(size); + for (DWORD index = 0; index != size; ++index) { + name.push_back(static_cast(buffer[index])); + } + return detail::unicode::utf16_to_utf8(name); #else std::array buffer{}; if (gethostname(buffer.data(), buffer.size() - 1) != 0) { @@ -340,7 +350,7 @@ class moonbase_device_id_resolver : public device_id_resolver { throw; } description_ = describe( - {{"deviceName", read.device_name}}, fingerprint_spec::device_id_source::device_name); + {{"deviceName", fallback_host_name(read)}}, fingerprint_spec::device_id_source::device_name); } described_ = true; } @@ -364,6 +374,38 @@ class moonbase_device_id_resolver : public device_id_resolver { return described; } + // What the host-name fallback hashes: the name itself, except under the native + // Windows read. The fallback has always hashed the ANSI code page reading + // there, and still does, so a machine already bound to it keeps its id now + // that the name is read as UTF-8. Canonicalization drops non-ASCII either way, + // so the two only differ where the ANSI reading turned a letter into ASCII: a + // best-fit or "?" substitution, or a double-byte code page's trail byte. In + // those cases the reference SDK, which hashes the UTF-8 name, disagrees; moving + // to it would rebind those machines, so it needs a migration, not a bug fix. + [[nodiscard]] std::string fallback_host_name(const device_identity& read) const + { +#if defined(_WIN32) + // An empty name is a failed read, and stays one. + if (!options_.reader && !read.device_name.empty()) { + return read_ansi_host_name(); + } +#endif + return read.device_name; + } + +#if defined(_WIN32) + // Fallback material only, never a label: see fallback_host_name. + [[nodiscard]] static std::string read_ansi_host_name() + { + std::array buffer{}; + auto size = static_cast(buffer.size()) - 1; + if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) { + return std::string(buffer.data(), size); + } + return {}; + } +#endif + [[nodiscard]] static std::string read_file(const char* path) { std::ifstream file(path, std::ios::binary); diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index 0309f29..329afb2 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -15,7 +15,10 @@ constexpr int kPollIntervalMs = 2000; // in the friendly, user-facing screen text. juce::String describeError(const std::exception& ex) { - juce::String message = ex.what(); + // fromUTF8, because the SDK's messages are UTF-8 (a store path under + // C:\Users\Björn, a server's own words) and juce::String's const char* + // constructor reads ASCII: it asserts on anything else and garbles it. + auto message = juce::String::fromUTF8(ex.what()); if (const auto* api = dynamic_cast(&ex)) if (! api->detail().empty()) message << " (" << api->detail() << ")"; @@ -42,6 +45,7 @@ enum class Failure Refused, // Moonbase answered and said no; trying again won't change that StoreClosed, // the merchant closed their Moonbase account, for good NoIdentity, // this machine has nothing to bind a license to + Unexpected, // neither Moonbase's answer nor a transport failure: show what it was }; Failure classifyFailure(const std::exception& ex) @@ -67,8 +71,21 @@ Failure classifyFailure(const std::exception& ex) if (dynamic_cast(&ex) != nullptr) return Failure::Refused; - // A custom transport's own exception type: treat it like the bundled ones. - return Failure::Unreachable; + // Not from Moonbase, and not the connection either: a transport reports a + // failed connection as api_error (the http_transport::send contract, which + // juce_http_transport keeps). So something failed on this machine, such as + // building the request, and calling that a connection problem sends the + // user and the developer looking in the wrong place. + return Failure::Unexpected; +} + +// Screen copy for Failure::Unexpected. Nobody anticipated it, so there is no +// friendlier way to put it, and the most useful thing the user can do is pass +// its own words on to the developer. +juce::String unexpectedFailure(const juce::String& lead, const std::exception& ex) +{ + const auto reason = juce::String::fromUTF8(ex.what()).trim(); + return reason.isEmpty() ? lead + "." : lead + ": " + reason; } } // namespace @@ -384,6 +401,9 @@ void ActivationController::beginOnlineActivation() userMessage = "This computer can't be identified, so it can't be activated. " "Contact the developer for help."; break; + case Failure::Unexpected: + userMessage = unexpectedFailure("Activation couldn't start", ex); + break; } } @@ -396,7 +416,8 @@ void ActivationController::beginOnlineActivation() if (! request) { // Full reason (incl. the entitlement hint) goes to the developer - // sink; the user sees friendly, fixed copy for the kind of failure. + // sink; the user sees friendly, fixed copy for the kind of failure, + // or the reason itself when the failure is of no known kind. self->emitDiagnostic("request_activation failed: " + error); self->setScreen(Screen::Error, userMessage); return; @@ -469,7 +490,7 @@ void ActivationController::refreshLicense(bool force, std::function // Re-validation says it has ended (e.g. a trial that was still valid // locally). Distinct from a network blip: this should lock. expired = true; - diag = ex.what(); + diag = juce::String::fromUTF8(ex.what()); } catch (const moonbase::license_invalid_error& ex) { @@ -477,7 +498,7 @@ void ActivationController::refreshLicense(bool force, std::function // deleted, or the store has closed. Also not a network blip, so this // locks too. rejected = true; - diag = ex.what(); + diag = juce::String::fromUTF8(ex.what()); } catch (const std::exception& ex) { @@ -572,13 +593,13 @@ void ActivationController::timerCallback() // The server answered 400: the request expired or was cancelled, so // it can never complete. The server's reason goes to diagnostics. fatal = true; - error = ex.what(); + error = juce::String::fromUTF8(ex.what()); userMessage = "This activation expired or was cancelled. Activate again to continue."; } catch (const moonbase::store_closed_error& ex) { fatal = true; - error = ex.what(); + error = juce::String::fromUTF8(ex.what()); userMessage = "This store has closed, so activation isn't available."; } // The reason the SDK gives is written for developers (a device-binding @@ -587,13 +608,13 @@ void ActivationController::timerCallback() catch (const moonbase::license_invalid_error& ex) { fatal = true; - error = ex.what(); + error = juce::String::fromUTF8(ex.what()); userMessage = "Activation was rejected. If this keeps happening, contact the developer."; } catch (const moonbase::license_expired_error& ex) { fatal = true; - error = ex.what(); + error = juce::String::fromUTF8(ex.what()); userMessage = "This license has expired, so it can't be activated."; } catch (const std::exception& ex) @@ -657,23 +678,32 @@ bool ActivationController::saveOfflineRequest(const juce::File& destination) if (! ensureReady()) return false; + std::string token; try { - const auto token = licensing_->generate_device_token(); - if (destination.replaceWithText(juce::String(token))) - { - offlineRequestSaved_ = true; - offlineError_.clear(); - sendChangeMessage(); - return true; - } - emitDiagnostic("Couldn't write the machine file to " + destination.getFullPathName()); + token = licensing_->generate_device_token(); } catch (const std::exception& ex) { - emitDiagnostic(juce::String("Generating the machine file failed: ") + ex.what()); + // Nothing reached the disk, so this is no file problem: say what it was. + emitDiagnostic("Generating the machine file failed: " + describeError(ex)); + offlineError_ = classifyFailure(ex) == Failure::NoIdentity + ? juce::String("This computer can't be identified, so it can't be activated. " + "Contact the developer for help.") + : unexpectedFailure("Couldn't create the machine file", ex); + sendChangeMessage(); + return false; } + if (destination.replaceWithText(juce::String(token))) + { + offlineRequestSaved_ = true; + offlineError_.clear(); + sendChangeMessage(); + return true; + } + + emitDiagnostic("Couldn't write the machine file to " + destination.getFullPathName()); offlineError_ = "Couldn't write the request file."; sendChangeMessage(); return false; @@ -768,16 +798,20 @@ void ActivationController::deactivate() { licensing->revoke_activation(token); } - catch (const moonbase::operation_not_supported_error& ex) { outcome = Outcome::NotRevokable; diag = ex.what(); } + catch (const moonbase::operation_not_supported_error& ex) { outcome = Outcome::NotRevokable; diag = juce::String::fromUTF8(ex.what()); } catch (const moonbase::license_invalid_error&) { outcome = Outcome::Revoked; } catch (const moonbase::license_expired_error&) { outcome = Outcome::Revoked; } catch (const std::exception& ex) { outcome = Outcome::Unreachable; diag = describeError(ex); - userMessage = classifyFailure(ex) == Failure::Busy - ? "Moonbase couldn't deactivate right now. Try again in a minute." - : "Couldn't reach Moonbase to deactivate. Try again when online."; + const auto failure = classifyFailure(ex); + if (failure == Failure::Busy) + userMessage = "Moonbase couldn't deactivate right now. Try again in a minute."; + else if (failure == Failure::Unexpected) + userMessage = unexpectedFailure("Couldn't deactivate", ex); + else + userMessage = "Couldn't reach Moonbase to deactivate. Try again when online."; } const int outcomeCode = static_cast(outcome); @@ -798,7 +832,7 @@ void ActivationController::deactivate() self->clearLicense(); break; case Outcome::Unreachable: - self->emitDiagnostic("revoke_activation couldn't reach Moonbase: " + diag); + self->emitDiagnostic("revoke_activation failed, license kept: " + diag); self->setScreen(Screen::Details, userMessage); break; } @@ -1113,6 +1147,8 @@ void ActivationController::startUpdateDownload() userMessage = "There's no download of this update for your system yet."; else if (classifyFailure(ex) == Failure::Busy) userMessage = "Moonbase couldn't prepare the download right now. Try again in a minute."; + else if (classifyFailure(ex) == Failure::Unexpected) + userMessage = unexpectedFailure("Couldn't start the download", ex); else userMessage = "Couldn't reach Moonbase to download the update. Try again when online."; } diff --git a/modules/moonbase_licensing/moonbase/client.hpp b/modules/moonbase_licensing/moonbase/client.hpp index bfed993..1b1ee9e 100644 --- a/modules/moonbase_licensing/moonbase/client.hpp +++ b/modules/moonbase_licensing/moonbase/client.hpp @@ -407,6 +407,26 @@ class activation_poll_gate { clock::time_point hold_until_{}; }; +// `text` with each byte that is not part of valid UTF-8 replaced by U+FFFD. +// nlohmann::json's own validator decides, so the result is exactly what a strict +// dump() accepts rather than a second opinion on it. +[[nodiscard]] inline std::string replace_invalid_utf8(const std::string& text) +{ + return nlohmann::json::parse( + nlohmann::json(text).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace)) + .get(); +} + +// A device id is never repaired the way a device name is. The server would bind +// the repaired id, which the resolver never returns, so the license would take a +// seat and then fail to validate on this very device. Refuse it unsent instead. +inline void require_utf8_device_id(const std::string& device_id) +{ + if (replace_invalid_utf8(device_id) != device_id) { + throw configuration_error("The device id resolver returned a device id that is not valid UTF-8"); + } +} + } // namespace detail class license_client { @@ -443,9 +463,11 @@ class license_client { // The API refuses a blank device name or id outright. The name is only a // label, so stand in for one the host could not supply (a failed host - // name lookup, a custom resolver). The id is the binding itself, and a - // resolver that returns none is misconfigured. - auto device_name = device_ids_->device_name(); + // name lookup, a custom resolver), and let a byte in it that is not UTF-8 + // cost a replacement character rather than the activation. The id is the + // binding itself, and a resolver that returns none, or one that is not + // UTF-8, is misconfigured. + auto device_name = detail::replace_invalid_utf8(device_ids_->device_name()); if (detail::trim_ascii_whitespace(device_name).empty()) { device_name = "Unknown device"; } @@ -453,6 +475,7 @@ class license_client { if (detail::trim_ascii_whitespace(device_id).empty()) { throw configuration_error("The device id resolver returned an empty device id"); } + detail::require_utf8_device_id(device_id); const auto payload = nlohmann::json{ {"deviceName", device_name}, diff --git a/modules/moonbase_licensing/moonbase/detail/unicode/utf16.hpp b/modules/moonbase_licensing/moonbase/detail/unicode/utf16.hpp new file mode 100644 index 0000000..6569cfe --- /dev/null +++ b/modules/moonbase_licensing/moonbase/detail/unicode/utf16.hpp @@ -0,0 +1,59 @@ +#pragma once + +// UTF-16 to UTF-8, for the strings Windows only hands out faithfully through its +// wide APIs. The narrow ("A") variants answer in the ANSI code page instead, so a +// computer name such as "Björn-PC" comes back as bytes that are not UTF-8, and +// nlohmann::json refuses to serialize those. +// +// Plain C++ with no OS headers, so it is tested on every platform rather than +// only on the one it runs on. + +#include +#include +#include + +namespace moonbase::detail::unicode { + +/// Transcode UTF-16 to UTF-8. +/// +/// Never fails: an unpaired surrogate becomes U+FFFD, as WideCharToMultiByte +/// does, so the result is always valid UTF-8 and always safe to serialize. +[[nodiscard]] inline std::string utf16_to_utf8(std::u16string_view text) +{ + std::string out; + out.reserve(text.size()); + + for (std::size_t index = 0; index != text.size(); ++index) { + char32_t code_point = text[index]; + + const bool high_surrogate = code_point >= 0xD800 && code_point <= 0xDBFF; + const bool low_surrogate = code_point >= 0xDC00 && code_point <= 0xDFFF; + if (high_surrogate && index + 1 != text.size() && text[index + 1] >= 0xDC00 + && text[index + 1] <= 0xDFFF) { + code_point = 0x10000 + ((code_point - 0xD800) << 10U) + (text[index + 1] - 0xDC00U); + ++index; + } else if (high_surrogate || low_surrogate) { + code_point = 0xFFFD; + } + + if (code_point < 0x80) { + out.push_back(static_cast(code_point)); + } else if (code_point < 0x800) { + out.push_back(static_cast(0xC0U | (code_point >> 6U))); + out.push_back(static_cast(0x80U | (code_point & 0x3FU))); + } else if (code_point < 0x10000) { + out.push_back(static_cast(0xE0U | (code_point >> 12U))); + out.push_back(static_cast(0x80U | ((code_point >> 6U) & 0x3FU))); + out.push_back(static_cast(0x80U | (code_point & 0x3FU))); + } else { + out.push_back(static_cast(0xF0U | (code_point >> 18U))); + out.push_back(static_cast(0x80U | ((code_point >> 12U) & 0x3FU))); + out.push_back(static_cast(0x80U | ((code_point >> 6U) & 0x3FU))); + out.push_back(static_cast(0x80U | (code_point & 0x3FU))); + } + } + + return out; +} + +} // namespace moonbase::detail::unicode diff --git a/modules/moonbase_licensing/moonbase/http.hpp b/modules/moonbase_licensing/moonbase/http.hpp index 5dc2e86..1ee0087 100644 --- a/modules/moonbase_licensing/moonbase/http.hpp +++ b/modules/moonbase_licensing/moonbase/http.hpp @@ -24,6 +24,14 @@ struct http_response { class http_transport { public: virtual ~http_transport() = default; + + /// Perform one exchange and return the response, whatever its status. + /// + /// When no response arrives at all (DNS, a refused connection, TLS, a timeout, + /// cancellation), throw api_error with status code 0, as both bundled + /// transports do. That is what tells "Moonbase could not be reached" apart from + /// a failure on this machine: the JUCE activation screen asks the user to check + /// their connection for the first, and shows any other exception as it is. [[nodiscard]] virtual http_response send(const http_request& request) = 0; }; diff --git a/modules/moonbase_licensing/moonbase/legacy_fingerprint.hpp b/modules/moonbase_licensing/moonbase/legacy_fingerprint.hpp index ad920a7..7b7f84c 100644 --- a/modules/moonbase_licensing/moonbase/legacy_fingerprint.hpp +++ b/modules/moonbase_licensing/moonbase/legacy_fingerprint.hpp @@ -39,6 +39,7 @@ #endif #include "moonbase/detail/crypto/crypto.hpp" +#include "moonbase/detail/unicode/utf16.hpp" #include "moonbase/device_id_resolver.hpp" namespace moonbase { @@ -146,8 +147,42 @@ class legacy_cpp_device_id_resolver : public device_id_resolver { return parameters; } + // Only a label, so unlike the rest of this class it is free to be correct: read + // through the wide API as UTF-8 on Windows, where the ANSI reading is not UTF-8 + // and cannot be serialized. device_id() still hashes that ANSI reading. [[nodiscard]] std::string device_name() const override { +#if defined(_WIN32) + wchar_t buffer[128]{}; + auto size = static_cast(sizeof(buffer) / sizeof(buffer[0])); + if (!GetComputerNameExW(ComputerNamePhysicalDnsHostname, buffer, &size)) { + return {}; + } + std::u16string name; + name.reserve(size); + for (DWORD index = 0; index != size; ++index) { + name.push_back(static_cast(buffer[index])); + } + return detail::unicode::utf16_to_utf8(name); +#else + return hashed_host_name(); +#endif + } + + [[nodiscard]] std::string device_id() const override + { + auto parameters = identity_parameters(); + if (parameters.empty()) { + append_parameter(parameters, "deviceName", hashed_host_name()); + } + return hash_identity_parameters(parameters); + } + +private: + // The host name as device_id() hashes it. On Windows that is the ANSI code + // page reading, byte for byte. + [[nodiscard]] static std::string hashed_host_name() + { #if defined(_WIN32) char buffer[128]{}; DWORD size = static_cast(sizeof(buffer)) - 1; @@ -177,16 +212,6 @@ class legacy_cpp_device_id_resolver : public device_id_resolver { #endif } - [[nodiscard]] std::string device_id() const override - { - auto parameters = identity_parameters(); - if (parameters.empty()) { - append_parameter(parameters, "deviceName", device_name()); - } - return hash_identity_parameters(parameters); - } - -private: [[nodiscard]] static std::string read_file(const std::string& path) { std::ifstream file(path); diff --git a/modules/moonbase_licensing/moonbase/licensing.hpp b/modules/moonbase_licensing/moonbase/licensing.hpp index fd3ac9c..c923ff3 100644 --- a/modules/moonbase_licensing/moonbase/licensing.hpp +++ b/modules/moonbase_licensing/moonbase/licensing.hpp @@ -110,9 +110,13 @@ class licensing { // offline-activated license token in return. [[nodiscard]] std::string generate_device_token() const { + // As request_activation does: a name that is not UTF-8 is repaired, since + // it is only a label, and an id that is not UTF-8 is refused. + const auto device_id = device_ids_->device_id(); + detail::require_utf8_device_id(device_id); const nlohmann::json payload{ - {"id", device_ids_->device_id()}, - {"name", device_ids_->device_name()}, + {"id", device_id}, + {"name", detail::replace_invalid_utf8(device_ids_->device_name())}, {"productId", options_.product_id}, // The Moonbase API expects this to always be "JWT". {"format", "JWT"}, diff --git a/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp b/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp index a9af1db..137cba8 100644 --- a/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp +++ b/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp @@ -103,6 +103,7 @@ #endif #endif +#include "moonbase/detail/unicode/utf16.hpp" #include "moonbase/device_id_resolver.hpp" #include "moonbase/errors.hpp" #include "moonbase/fingerprint_spec.hpp" @@ -204,7 +205,7 @@ class moonbase_device_id_resolver : public device_id_resolver { } } - /// The host name, with a trailing ".local" removed on macOS. + /// The host name as UTF-8, with a trailing ".local" removed on macOS. /// /// Never throws: a machine with no readable identity still has to be able to /// label itself, since activation sends the name alongside the id. @@ -257,15 +258,24 @@ class moonbase_device_id_resolver : public device_id_resolver { return identity; } + /// The host name as UTF-8, with a trailing ".local" removed on macOS. [[nodiscard]] static std::string read_host_name() { #if defined(_WIN32) - std::array buffer{}; - auto size = static_cast(buffer.size()) - 1; - if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) { - return std::string(buffer.data(), size); + // The wide API, transcoded. GetComputerNameExA answers in the ANSI code + // page, so a name with any non-ASCII letter came back as bytes that are + // not UTF-8, and serializing the activation request threw on them. + std::array buffer{}; + auto size = static_cast(buffer.size()); + if (!GetComputerNameExW(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) { + return {}; } - return {}; + std::u16string name; + name.reserve(size); + for (DWORD index = 0; index != size; ++index) { + name.push_back(static_cast(buffer[index])); + } + return detail::unicode::utf16_to_utf8(name); #else std::array buffer{}; if (gethostname(buffer.data(), buffer.size() - 1) != 0) { @@ -340,7 +350,7 @@ class moonbase_device_id_resolver : public device_id_resolver { throw; } description_ = describe( - {{"deviceName", read.device_name}}, fingerprint_spec::device_id_source::device_name); + {{"deviceName", fallback_host_name(read)}}, fingerprint_spec::device_id_source::device_name); } described_ = true; } @@ -364,6 +374,38 @@ class moonbase_device_id_resolver : public device_id_resolver { return described; } + // What the host-name fallback hashes: the name itself, except under the native + // Windows read. The fallback has always hashed the ANSI code page reading + // there, and still does, so a machine already bound to it keeps its id now + // that the name is read as UTF-8. Canonicalization drops non-ASCII either way, + // so the two only differ where the ANSI reading turned a letter into ASCII: a + // best-fit or "?" substitution, or a double-byte code page's trail byte. In + // those cases the reference SDK, which hashes the UTF-8 name, disagrees; moving + // to it would rebind those machines, so it needs a migration, not a bug fix. + [[nodiscard]] std::string fallback_host_name(const device_identity& read) const + { +#if defined(_WIN32) + // An empty name is a failed read, and stays one. + if (!options_.reader && !read.device_name.empty()) { + return read_ansi_host_name(); + } +#endif + return read.device_name; + } + +#if defined(_WIN32) + // Fallback material only, never a label: see fallback_host_name. + [[nodiscard]] static std::string read_ansi_host_name() + { + std::array buffer{}; + auto size = static_cast(buffer.size()) - 1; + if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) { + return std::string(buffer.data(), size); + } + return {}; + } +#endif + [[nodiscard]] static std::string read_file(const char* path) { std::ifstream file(path, std::ios::binary); diff --git a/tests/client_tests.cpp b/tests/client_tests.cpp index 86f0a3d..e65ed3a 100644 --- a/tests/client_tests.cpp +++ b/tests/client_tests.cpp @@ -745,6 +745,57 @@ TEST_CASE("request_activation stands in for a blank device name") CHECK(body.at("deviceSignature") == "device-id"); } +TEST_CASE("request_activation sends a device name that is not UTF-8 instead of throwing") +{ + client_fixture fixture({ + http_response{200, {}, R"({"id":"request-123","request":"https://demo.moonbase.sh/r","browser":"https://demo.moonbase.sh/b"})"}, + }); + // "Björn-PC" as GetComputerNameExA returned it on a Western code page. + const license_client client( + fixture.make_options(), + std::make_shared("Bj\xF6rn-PC", "device-id"), + fixture.validator, + fixture.transport); + + (void)client.request_activation(); + + REQUIRE(fixture.transport->requests.size() == 1); + const auto body = nlohmann::json::parse(fixture.transport->requests[0].body); + CHECK(body.at("deviceName") == "Bj\xEF\xBF\xBDrn-PC"); + CHECK(body.at("deviceSignature") == "device-id"); +} + +TEST_CASE("request_activation refuses a device id that is not UTF-8 without contacting the API") +{ + // Repairing it would have the server bind an id this resolver never returns. + client_fixture fixture({}); + const license_client client( + fixture.make_options(), + std::make_shared("Test Device", "id-\xF6"), + fixture.validator, + fixture.transport); + + CHECK_THROWS_AS((void)client.request_activation(), configuration_error); + CHECK(fixture.transport->requests.empty()); +} + +TEST_CASE("replace_invalid_utf8 replaces only what a strict dump rejects") +{ + using moonbase::detail::replace_invalid_utf8; + + // Valid text comes back byte for byte, including what JSON has to escape and + // a replacement character that was already there. + const std::string valid = "Studio \"A\" \\ \x01\t Bj\xC3\xB6rn \xEF\xBF\xBD \xF0\x9F\x8E\xB5"; + CHECK(replace_invalid_utf8(valid) == valid); + CHECK(replace_invalid_utf8("").empty()); + + // A lone byte, a truncated sequence, an overlong encoding and a surrogate. + CHECK(replace_invalid_utf8("Bj\xF6rn") == "Bj\xEF\xBF\xBDrn"); + CHECK_NOTHROW((void)nlohmann::json(replace_invalid_utf8("a\xE3\x82")).dump()); + CHECK_NOTHROW((void)nlohmann::json(replace_invalid_utf8("\xC0\xAF")).dump()); + CHECK_NOTHROW((void)nlohmann::json(replace_invalid_utf8("\xED\xA0\x80")).dump()); +} + TEST_CASE("request_activation refuses an empty device id without contacting the API") { client_fixture fixture({}); diff --git a/tests/fingerprint_reader_tests.cpp b/tests/fingerprint_reader_tests.cpp index 2cfb3c7..64bdf14 100644 --- a/tests/fingerprint_reader_tests.cpp +++ b/tests/fingerprint_reader_tests.cpp @@ -13,7 +13,9 @@ #include #include +#include +#include "moonbase/detail/unicode/utf16.hpp" #include "moonbase/errors.hpp" #include "moonbase/fingerprint_spec.hpp" #include "moonbase/moonbase_device_id_resolver.hpp" @@ -244,6 +246,45 @@ TEST_CASE("device_name survives a machine with no identity") CHECK_THROWS_AS(resolver.device_id(), moonbase::insufficient_device_identity_error); } +TEST_CASE("the host name reads as UTF-8") +{ + // Strict dump() throws on anything that is not UTF-8, which is how an ANSI code + // page reading of a non-ASCII Windows computer name failed every activation. + const auto name = moonbase::moonbase_device_id_resolver::read_host_name(); + INFO("host name: " << name); + CHECK_NOTHROW((void)nlohmann::json(name).dump()); +} + +TEST_CASE("utf16_to_utf8 transcodes every plane and replaces unpaired surrogates") +{ + using moonbase::detail::unicode::utf16_to_utf8; + + // Spelled as code units and bytes: MSVC without /utf-8 reads a raw non-ASCII + // literal in the ANSI code page. A literal is split wherever the next + // character would otherwise extend a hex escape. + CHECK(utf16_to_utf8(u"").empty()); + CHECK(utf16_to_utf8(u"PC-1") == "PC-1"); + CHECK(utf16_to_utf8(u"Bj\x00F6rn-PC") == "Bj\xC3\xB6rn-PC"); + CHECK(utf16_to_utf8(u"\x30B9\x30BF\x30B8\x30AA") == "\xE3\x82\xB9\xE3\x82\xBF\xE3\x82\xB8\xE3\x82\xAA"); + CHECK(utf16_to_utf8(u"\xD83C\xDFB5") == "\xF0\x9F\x8E\xB5"); + + // Each encoded length at both of its edges. + CHECK(utf16_to_utf8(u"\x007F") == "\x7F"); + CHECK(utf16_to_utf8(u"\x0080") == "\xC2\x80"); + CHECK(utf16_to_utf8(u"\x07FF") == "\xDF\xBF"); + CHECK(utf16_to_utf8(u"\x0800") == "\xE0\xA0\x80"); + CHECK(utf16_to_utf8(u"\xFFFF") == "\xEF\xBF\xBF"); + CHECK(utf16_to_utf8(u"\xD800\xDC00") == "\xF0\x90\x80\x80"); + CHECK(utf16_to_utf8(u"\xDBFF\xDFFF") == "\xF4\x8F\xBF\xBF"); + + // An unpaired surrogate is U+FFFD, and never swallows its neighbour. + CHECK(utf16_to_utf8(u"a\xD83C") == "a\xEF\xBF\xBD"); + CHECK(utf16_to_utf8(u"\xD83C" u"b") == "\xEF\xBF\xBD" "b"); + CHECK(utf16_to_utf8(u"\xDFB5" u"b") == "\xEF\xBF\xBD" "b"); + CHECK(utf16_to_utf8(u"\xDFB5\xD83C") == "\xEF\xBF\xBD\xEF\xBF\xBD"); + CHECK(utf16_to_utf8(u"\xD83C\xD83C\xDFB5") == "\xEF\xBF\xBD\xF0\x9F\x8E\xB5"); +} + TEST_CASE("the host-name fallback is opt-in and separately stamped") { moonbase::moonbase_device_id_resolver_options options; diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index 5742973..927eeff 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -864,11 +864,11 @@ TEST_CASE("a failed activation start says what kind of failure it was") struct expectation { const char* name; - std::optional response; // none: the transport throws + std::optional response; // none: the connection fails const char* copy; }; const std::vector cases{ - {"unreachable", std::nullopt, "Couldn't reach Moonbase"}, + {"connection failed", std::nullopt, "Couldn't reach Moonbase"}, {"server error", moonbase::http_response{500, {}, ""}, "Try again in a minute"}, {"rate limited", moonbase::http_response{429, {{"Retry-After", "60"}}, ""}, "Try again in a minute"}, // A 404 or 403 is no verdict: a proxy or misrouted request sends them too. @@ -891,7 +891,14 @@ TEST_CASE("a failed activation start says what kind of failure it was") if (c.response) fx.transport->responses.push_back(*c.response); - ActivationController controller(fx.config, fx.makeLicensing()); + // A failed connection is an api_error with status 0 from both bundled + // transports, which is what hinted_failure_transport throws. + auto licensing = c.response ? fx.makeLicensing() + : std::make_shared( + fx.config.toLicensingOptions(), fx.store, fx.fingerprint, + std::make_shared()); + + ActivationController controller(fx.config, licensing); controller.beginOnlineActivation(); REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Error; })); @@ -900,6 +907,85 @@ TEST_CASE("a failed activation start says what kind of failure it was") } } +namespace { +// A resolver with a bug in it: the stand-in for any failure that is neither +// Moonbase's answer nor the connection's, such as the type_error nlohmann::json +// threw when a Windows computer name was read in the ANSI code page. The message +// is UTF-8, to check it is decoded as such on the way to the screen. +struct broken_resolver : moonbase::device_id_resolver +{ + std::string device_name() const override { return "Studio PC"; } + std::string device_id() const override + { + throw std::runtime_error("resolver failed on Bj\xC3\xB6rn-PC"); + } +}; +} // namespace + +TEST_CASE("an unexpected failure starting activation shows what it was, not a connection problem") +{ + controller_fixture fx; + juce::StringArray diags; + fx.config.onDiagnostic = [&](const juce::String& m) { diags.add(m); }; + auto licensing = std::make_shared( + fx.config.toLicensingOptions(), fx.store, std::make_shared(), fx.transport); + + ActivationController controller(fx.config, licensing); + controller.beginOnlineActivation(); + + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Error; })); + const auto reason = juce::String::fromUTF8("resolver failed on Bj\xC3\xB6rn-PC"); + CHECK(controller.statusMessage().contains(reason)); + CHECK_FALSE(controller.statusMessage().containsIgnoreCase("reach Moonbase")); + CHECK_FALSE(controller.statusMessage().containsIgnoreCase("connection")); + CHECK(diags.joinIntoString(" ").contains(reason)); + CHECK(fx.transport->requests.empty()); +} + +TEST_CASE("a machine file that can't be generated says why, not that the write failed") +{ + const auto requestFile = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("moonbase-juce-tests") + .getChildFile(juce::Uuid().toString() + ".dt"); + + SUBCASE("an unexpected failure shows its own words") + { + controller_fixture fx; + juce::StringArray diags; + fx.config.onDiagnostic = [&](const juce::String& m) { diags.add(m); }; + auto licensing = std::make_shared( + fx.config.toLicensingOptions(), fx.store, std::make_shared(), fx.transport); + ActivationController controller(fx.config, licensing); + + CHECK_FALSE(controller.saveOfflineRequest(requestFile)); + const auto reason = juce::String::fromUTF8("resolver failed on Bj\xC3\xB6rn-PC"); + CHECK(controller.offlineError().contains(reason)); + CHECK_FALSE(controller.offlineError().containsIgnoreCase("write")); + CHECK(diags.joinIntoString(" ").contains(reason)); + } + + SUBCASE("an unidentifiable machine is told so") + { + controller_fixture fx; + struct no_identity : moonbase::device_id_resolver + { + std::string device_name() const override { return "Studio Mac"; } + std::string device_id() const override + { + throw moonbase::insufficient_device_identity_error("test"); + } + }; + auto licensing = std::make_shared( + fx.config.toLicensingOptions(), fx.store, std::make_shared(), fx.transport); + ActivationController controller(fx.config, licensing); + + CHECK_FALSE(controller.saveOfflineRequest(requestFile)); + CHECK(controller.offlineError().contains("can't be identified")); + } + + CHECK_FALSE(requestFile.existsAsFile()); +} + TEST_CASE("an unidentifiable machine is not told to check its connection") { controller_fixture fx; diff --git a/tests/licensing_tests.cpp b/tests/licensing_tests.cpp index 63cc796..3b19c15 100644 --- a/tests/licensing_tests.cpp +++ b/tests/licensing_tests.cpp @@ -410,6 +410,38 @@ TEST_CASE("generate_device_token emits a base64 JSON descriptor of the device an CHECK(fixture.transport->requests.empty()); } +TEST_CASE("generate_device_token survives a device name that is not UTF-8") +{ + facade_fixture fixture; + // "Björn-PC" as GetComputerNameExA returned it on a Western code page. + const licensing instance( + fixture.prepare({}), + nullptr, + std::make_shared("Bj\xF6rn-PC", "device-id"), + fixture.transport); + + const auto device_token = instance.generate_device_token(); + const auto json = nlohmann::json::parse( + moonbase::detail::bytes_to_string(moonbase::detail::base64_decode(device_token))); + + CHECK(json.at("name").get() == "Bj\xEF\xBF\xBDrn-PC"); + CHECK(json.at("id").get() == "device-id"); +} + +TEST_CASE("generate_device_token refuses a device id that is not UTF-8") +{ + // The portal would issue a license for the repaired id, which this device's + // resolver never returns, so the license file could never be read back here. + facade_fixture fixture; + const licensing instance( + fixture.prepare({}), + nullptr, + std::make_shared("Test Device", "id-\xF6"), + fixture.transport); + + CHECK_THROWS_AS((void)instance.generate_device_token(), configuration_error); +} + TEST_CASE("read_offline_license accepts an offline token for this device") { facade_fixture fixture;