diff --git a/.gitignore b/.gitignore index 7abdb57..023606b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Build directories build/ build-tests/ +build-review/ build_*/ cmake-build-*/ out/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 848f309..9afc34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to the LicenseSeat C++ SDK will be documented in this file. ## [Unreleased] +## [0.7.0] - 2026-09-05 + +### Changed + +- `Activation::id()` and `Deactivation::activation_id` are now `std::string`. + Update integer variables and remove `std::to_string` calls around these values. + Hosted UUIDs and positive integer IDs from self-hosted engines are accepted. +- Offline startup explicitly requires application-pinned signing keys, an enabled + fallback policy, a positive offline duration and persistent storage. Untrusted + local key files do not establish signing authority. + +### Fixed + +- Accept UUID activation and deactivation responses without weakening identifier checks. +- Save the activated local device's session identity so a fresh client can restore + a verified cached machine file without a preceding online validation call. +- Correct automatic offline, imported certificate, signing-key and JUCE dependency examples. + ## [0.6.1] - 2026-08-26 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index 08778a9..830838d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.14) project(licenseseat - VERSION 0.6.1 + VERSION 0.7.0 DESCRIPTION "C++ SDK for LicenseSeat licensing API" HOMEPAGE_URL "https://github.com/licenseseat/licenseseat-cpp" LANGUAGES CXX C diff --git a/README.md b/README.md index 13e7e33..e6b9e0f 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,8 @@ The single-header still requires two external header-only libraries plus OpenSSL - **OpenSSL** – required for HTTPS and machine-file AES-256-GCM verification in the full SDK path - **macOS Security.framework** – used by cpp-httplib to load trusted roots from the system Keychain -The only zero-OpenSSL path in this repo is the dedicated JUCE standalone helper above. The full/amalgamated SDK now requires OpenSSL for machine files. +Both JUCE adapters also require the core SDK and OpenSSL. The `Standalone` class +name is retained for source compatibility; it delegates to the same core client. ### Generate Locally @@ -555,7 +556,7 @@ The SDK collects anonymous platform telemetry to help developers understand thei | Field | Type | Example | Description | | ------------------- | ------ | ------------------------ | ------------------------------------------------------ | | `sdk_name` | string | `"cpp"` | Always `"cpp"` for this SDK | -| `sdk_version` | string | `"0.6.1"` | SDK version | +| `sdk_version` | string | `"0.7.0"` | SDK version | | `os_name` | string | `"macOS"` | Operating system (`"macOS"`, `"Windows"`, `"Linux"`) | | `os_version` | string | `"15.3"` | OS version string | | `platform` | string | `"native"` | Always `"native"` for this SDK | @@ -827,19 +828,38 @@ The SDK supports offline license validation with two artifacts: ### Automatic (Recommended) -Just set `storage_path` and call `activate()` — the SDK automatically syncs a machine file: +Enable offline use explicitly, pin your organization's signing key, and use a +persistent cache. Offline access is disabled by default. The public key is a raw +32-byte Ed25519 key encoded as Base64; see [Pinning the signing key](#pinning-the-signing-key). ```cpp config.storage_path = "/path/to/cache"; +config.signing_public_key = "YOUR_BASE64_RAW_ED25519_PUBLIC_KEY"; +config.signing_key_id = "YOUR_ORGANIZATION_SIGNING_KEY_ID"; +config.offline_fallback_mode = licenseseat::OfflineFallbackMode::NetworkOnly; +config.max_offline_days = 30; // Choose the offline duration your product permits licenseseat::Client client(config); -client.activate("LICENSE-KEY"); // Automatically syncs a machine file - -// If network fails later, validation prefers the cached machine file -auto result = client.validate("LICENSE-KEY"); // Works offline! +auto activated = client.activate("LICENSE-KEY"); +if (activated.is_error()) throw std::runtime_error(activated.error_message()); +// Activation saves the local session and attempts machine-file sync. Check +// checkout explicitly before telling a user that offline access is ready. +auto machine = client.checkout_machine_file("LICENSE-KEY"); +if (machine.is_error()) throw std::runtime_error(machine.error_message()); + +// A later process uses the SAME configuration, cache path and fingerprint: +// licenseseat::Client restarted(config); +// auto restored = restarted.restore_license(); +// Check restored.success; OfflineValid means the signed cache was verified. ``` -If you still need the old token path, enable it explicitly: +The key must be part of trusted application configuration, not a key supplied by +the end user alongside the certificate. Keys fetched online are trusted only for +that Client's lifetime. A pinned key permits verification after an offline restart. +`activate()` success alone does not guarantee its best-effort offline sync succeeded. +Signed certificate and license expiry can shorten the configured offline period. + +If you still need the old token path, keep the configuration above and enable it explicitly: ```cpp config.enable_legacy_offline_tokens = true; @@ -850,12 +870,20 @@ client.activate("LICENSE-KEY"); // Syncs machine file first, then legacy token ### Manual Storage -For custom storage (encrypted, database, etc.), store the machine file certificate directly: +For custom storage (encrypted, database, etc.), store the machine file certificate directly. +`verify_machine_file()` is the offline entry point and never fetches the signing key, so pin +`config.signing_public_key` (see [Pinning the signing key](#pinning-the-signing-key)). +If using curl on an online helper computer, activate and request the certificate +with the TARGET machine's `client.fingerprint()`. Use `"include": ["license"]` +to include license details. Transfer `data.attributes.certificate`, not the JSON +response wrapper. A wrong fingerprint prevents decryption; omitting the include +leaves `payload.license` empty. ```cpp // Save (online) -auto machine_file = client.checkout_machine_file("LICENSE-KEY").value(); -save_to_secure_storage(machine_file.certificate); +auto checkout = client.checkout_machine_file("LICENSE-KEY"); +if (checkout.is_error()) throw std::runtime_error(checkout.error_message()); +save_to_secure_storage(checkout.value().certificate); // Load and verify (offline) licenseseat::MachineFile loaded; @@ -864,7 +892,7 @@ loaded.license_key = "LICENSE-KEY"; loaded.fingerprint = client.fingerprint(); auto verified = client.verify_machine_file(loaded); -if (verified.is_ok() && verified.value().valid) { +if (verified.is_ok() && verified.value().valid && verified.value().payload) { const auto& payload = *verified.value().payload; if (payload.license) { std::cout << "Plan: " << payload.license->plan_key() << "\n"; @@ -872,6 +900,11 @@ if (verified.is_ok() && verified.value().valid) { } ``` +This direct verifier never contacts the network and does not import a session for +`restore_license()`. For custom storage, load and verify the certificate on every +startup. Use the SDK-managed workflow above for automatic session restore and its +configured fallback policy. See the [complete offline integration guide](docs/offline-integration.md). + If you still need portable JSON serialization for a legacy integration, use offline tokens explicitly: ```cpp @@ -887,13 +920,18 @@ auto verified = client.verify_offline_token(loaded, key); ### Pre-configured Public Key -For simpler deployments, you can pre-configure the signing public key: +### Pinning the signing key + +For simpler deployments, and for any app that must verify offline after a restart, +pre-configure the signing public key. Use the `public_key` value returned by +`GET /api/v1/signing_keys/{key_id}` verbatim: it is the raw 32-byte Ed25519 key, +base64-encoded (44 characters). A PEM/DER string (`MCowBQYDK2VwAyEA...`) is rejected. ```cpp licenseseat::Config config; config.api_key = "your-api-key"; config.product_slug = "your-product"; -config.signing_public_key = "MCowBQYDK2VwAyEA..."; // Your public key +config.signing_public_key = "<44-char base64 public_key from /api/v1/signing_keys/{key_id}>"; config.offline_fallback_mode = licenseseat::OfflineFallbackMode::NetworkOnly; config.max_offline_days = 30; diff --git a/conanfile.py b/conanfile.py index e80c5ff..db17713 100644 --- a/conanfile.py +++ b/conanfile.py @@ -6,7 +6,7 @@ class LicenseSeatConan(ConanFile): name = "licenseseat" - version = "0.6.1" + version = "0.7.0" license = "MIT" author = "LicenseSeat" url = "https://github.com/licenseseat/licenseseat-cpp" diff --git a/docs/offline-integration.md b/docs/offline-integration.md new file mode 100644 index 0000000..dfa09ee --- /dev/null +++ b/docs/offline-integration.md @@ -0,0 +1,88 @@ +# C++ offline integration + +This guide covers the v0.7.0 API with application-pinned signing keys. Keep the +trusted public key in your application's configuration so it can verify licenses +without an internet connection, including after a restart. + +## Configure the application + +Fetch the organization's public key from its HTTPS signing-key endpoint during +integration and embed the exact `public_key` value in trusted application configuration. +Use the raw 32-byte key encoded as standard Base64, not PEM or DER. A key supplied by +an end user alongside a machine file is not an independent trust anchor. + +```cpp +licenseseat::Config config; +config.api_key = "YOUR_PUBLISHABLE_KEY"; +config.product_slug = "YOUR_PRODUCT_SLUG"; +config.storage_path = "/YOUR_WRITABLE_APPLICATION_CACHE"; +config.signing_public_key = "YOUR_BASE64_RAW_ED25519_PUBLIC_KEY"; +config.signing_key_id = "YOUR_ORGANIZATION_SIGNING_KEY_ID"; +config.offline_fallback_mode = licenseseat::OfflineFallbackMode::NetworkOnly; +config.max_offline_days = 30; // Choose your product's permitted offline duration. +``` + +Keep this configuration, product, cache path and device fingerprint consistent across +restarts. Offline support defaults to disabled; setting only `storage_path` is insufficient. +Thirty days is a local maximum, not a promise that an expired/revoked artifact remains +usable for that long. Verification also checks signed artifact and license expiry. + +## Online provisioning, then offline restart + +Check every result. `activate()` succeeding does not prove an offline artifact was +saved: its automatic sync is best effort. The explicit checkout makes a failure visible. +Successful local activation saves the session identity needed by `restore_license()`. +Online validation is optional at provisioning time and retrieves current entitlements. + +```cpp +licenseseat::Client client(config); +auto activated = client.activate(license_key); +if (activated.is_error()) throw std::runtime_error(activated.error_message()); +auto validated = client.validate(license_key); +if (validated.is_error()) throw std::runtime_error(validated.error_message()); +if (!validated.value().valid) throw std::runtime_error(validated.value().message); +auto machine = client.checkout_machine_file(license_key); +if (machine.is_error()) throw std::runtime_error(machine.error_message()); +``` + +On subsequent runs, construct a fresh Client with the same config and check +`restore_license().success`. Treat `OfflineValid` as successful offline verification. +Do not ask the user to activate again on every app launch. `restore_license()` performs +a connectivity check; use `verify_machine_file()` when a strictly local operation is needed. + +## Import a certificate fetched using curl + +The online provisioning machine must activate and request the machine file using the +TARGET machine's exact fingerprint, collected from `client.fingerprint()` there. The +online helper's own fingerprint is irrelevant. Include `"include": ["license"]` in the +machine-file request if the app needs license details. Transfer the certificate from +`data.attributes.certificate`, not the enclosing JSON response. + +A plain certificate can be loaded directly using the public SDK type; no private +`crypto::internal` API or custom certificate parser is necessary when the key is pinned. + +```cpp +licenseseat::Client client(config); +licenseseat::MachineFile imported; +imported.certificate = certificate_text; +auto verified = client.verify_machine_file(imported, "", license_key, client.fingerprint()); +if (verified.is_error()) throw std::runtime_error(verified.error_message()); +if (!verified.value().valid || !verified.value().payload) + throw std::runtime_error(verified.value().message); +const auto& payload = *verified.value().payload; +if (payload.license) { + // Use authenticated license details here. +} +``` + +This verifies independently of a preexisting cached license record. If the application +manages certificate storage itself, persist and reload the certificate there and run +this verification on each startup. Direct verification does not import a session for +`restore_license()`. Do not infer automatic storage or fallback-policy enforcement from +calling the lower-level verifier. + +## Dependencies and migration + +Both `Activation::id()` and `Deactivation::activation_id` are strings in v0.7.0. +Change integer variables and remove `std::to_string` calls around these fields. +Both JUCE adapters delegate to the core SDK and require OpenSSL. diff --git a/include/licenseseat/json.hpp b/include/licenseseat/json.hpp index 30a57d7..2f3f1ce 100644 --- a/include/licenseseat/json.hpp +++ b/include/licenseseat/json.hpp @@ -440,12 +440,33 @@ inline constexpr std::size_t MAX_JSON_STRING_BYTES = 256 * 1024; // ==================== Activation Parsing ==================== +// Hosted UUIDs and integer-primary-key engines share this identifier contract. +// Keep positive integer compatibility without truncating unsigned values or floats. +[[nodiscard]] inline std::string parse_activation_identifier(const json& value) { + if (value.is_string()) { + const auto id = value.get(); + if (id.empty() || id.size() > 255) + return {}; + for (const unsigned char character : id) { + if (character < 0x21 || character > 0x7e) + return {}; + } + return id; + } + if (value.is_number_unsigned()) { + const auto id = value.get(); + return id > 0 ? std::to_string(id) : std::string{}; + } + if (value.is_number_integer()) { + const auto id = value.get(); + return id > 0 ? std::to_string(id) : std::string{}; + } + return {}; +} + /// Parse Activation from JSON response (new API format) [[nodiscard]] inline Activation parse_activation(const json& j) { - int64_t id = 0; - if (j.contains("id") && j["id"].is_number()) { - id = j["id"].get(); - } + const auto id = j.contains("id") ? parse_activation_identifier(j["id"]) : std::string{}; std::string device_id; if (j.contains("fingerprint")) { @@ -501,9 +522,8 @@ inline constexpr std::size_t MAX_JSON_STRING_BYTES = 256 * 1024; [[nodiscard]] inline Deactivation parse_deactivation(const json& j) { Deactivation result; - if (j.contains("activation_id") && j["activation_id"].is_number()) { - result.activation_id = j["activation_id"].get(); - } + if (j.contains("activation_id")) + result.activation_id = parse_activation_identifier(j["activation_id"]); if (j.contains("deactivated_at") && !j["deactivated_at"].is_null()) { auto ts = parse_timestamp(j["deactivated_at"].get()); diff --git a/include/licenseseat/licenseseat.hpp b/include/licenseseat/licenseseat.hpp index 13bdeec..89e042f 100644 --- a/include/licenseseat/licenseseat.hpp +++ b/include/licenseseat/licenseseat.hpp @@ -21,7 +21,7 @@ namespace licenseseat { /// Library version -constexpr const char* VERSION = "0.6.1"; +constexpr const char* VERSION = "0.7.0"; /// Metadata type used throughout the SDK using Metadata = std::map; @@ -485,16 +485,18 @@ class Activation { public: Activation() = default; - Activation(int64_t id, std::string device_id, std::string device_name, std::string license_key, - Timestamp activated_at, std::optional deactivated_at, - std::string ip_address, Metadata metadata) - : id_(id), device_id_(std::move(device_id)), device_name_(std::move(device_name)), + Activation(std::string id, std::string device_id, std::string device_name, + std::string license_key, Timestamp activated_at, + std::optional deactivated_at, std::string ip_address, + Metadata metadata) + : id_(std::move(id)), device_id_(std::move(device_id)), device_name_(std::move(device_name)), license_key_(std::move(license_key)), activated_at_(activated_at), deactivated_at_(deactivated_at), ip_address_(std::move(ip_address)), metadata_(std::move(metadata)) {} - /// Get the activation ID - [[nodiscard]] int64_t id() const noexcept { return id_; } + /// Get the activation ID. + /// The server issues UUIDs, so this is an opaque string — never assume it is numeric. + [[nodiscard]] const std::string& id() const noexcept { return id_; } /// Get the device ID [[nodiscard]] const std::string& device_id() const noexcept { return device_id_; } @@ -526,7 +528,7 @@ class Activation { [[nodiscard]] bool is_active() const noexcept { return !deactivated_at_.has_value(); } private: - int64_t id_ = 0; + std::string id_; std::string device_id_; std::string device_name_; std::string license_key_; @@ -692,7 +694,8 @@ struct DownloadToken { * @brief Deactivation response */ struct Deactivation { - int64_t activation_id = 0; + /// The server issues UUIDs, so this is an opaque string — never assume it is numeric. + std::string activation_id; Timestamp deactivated_at; }; @@ -743,9 +746,14 @@ struct Config { /// Storage prefix for file names std::string storage_prefix = "licenseseat"; - /// Ed25519 public key for offline artifact verification. - /// Used for machine files and legacy offline tokens. If not provided, it - /// will be fetched from the API on first use when possible. + /// Ed25519 public key for offline artifact verification, as the raw 32-byte key + /// base64-encoded (the `public_key` value from GET /api/v1/signing_keys/{key_id}), + /// not a PEM/DER encoding. + /// + /// Online flows (activate(), checkout_machine_file()) fetch the key from the API and + /// cache it in memory for this Client. verify_machine_file() and the offline restore + /// path never fetch: they are the offline entry points. Pin this value for any app + /// that must verify a stored machine file after a restart or without network. std::string signing_public_key; /// Key ID for the signing public key diff --git a/integrations/unreal/LicenseSeat/LicenseSeat.uplugin b/integrations/unreal/LicenseSeat/LicenseSeat.uplugin index c6ee53b..78597a3 100644 --- a/integrations/unreal/LicenseSeat/LicenseSeat.uplugin +++ b/integrations/unreal/LicenseSeat/LicenseSeat.uplugin @@ -1,7 +1,7 @@ { "FileVersion": 3, "Version": 1, - "VersionName": "0.6.1", + "VersionName": "0.7.0", "FriendlyName": "LicenseSeat", "Description": "Online license validation and activation for Unreal Engine applications.", "Category": "Licensing", diff --git a/integrations/unreal/LicenseSeat/Source/LicenseSeat/Private/LicenseSeatSubsystem.cpp b/integrations/unreal/LicenseSeat/Source/LicenseSeat/Private/LicenseSeatSubsystem.cpp index c9a20c0..789db23 100644 --- a/integrations/unreal/LicenseSeat/Source/LicenseSeat/Private/LicenseSeatSubsystem.cpp +++ b/integrations/unreal/LicenseSeat/Source/LicenseSeat/Private/LicenseSeatSubsystem.cpp @@ -12,6 +12,7 @@ #include "Serialization/JsonReader.h" #include "Serialization/JsonSerializer.h" #include "TimerManager.h" +#include // PicoSHA2 is used only to retain the plugin's stable device fingerprint. THIRD_PARTY_INCLUDES_START @@ -125,6 +126,26 @@ FString ToJsonString(const TSharedPtr& Json) { return RequestBody; } +// Hosted UUIDs and integer-primary-key engines share the same identifier contract. +bool TryGetIdentifierField(const TSharedPtr& JsonResponse, const TCHAR* FieldName, + FString& OutValue) { + const TSharedPtr Field = JsonResponse->TryGetField(FieldName); + if (!Field.IsValid()) return false; + if (Field->Type == EJson::String) + return Field->TryGetString(OutValue) && IsSafeText(OutValue, 255) && + !OutValue.Contains(TEXT(" ")); + double NumericValue = 0.0; + // Unreal stores JSON numbers as doubles. Reject values outside its exact + // integer range instead of rounding or invoking an out-of-range cast. + if (Field->Type == EJson::Number && Field->TryGetNumber(NumericValue) && + std::isfinite(NumericValue) && NumericValue > 0.0 && + NumericValue <= 9007199254740991.0 && std::floor(NumericValue) == NumericValue) { + OutValue = LexToString(static_cast(NumericValue)); + return true; + } + return false; +} + FString ExtractErrorMessage(const TSharedPtr& JsonResponse) { if (!JsonResponse.IsValid()) { return TEXT("Request failed"); @@ -201,9 +222,9 @@ bool ParseActivationResponse(const FString& Response, const FString& ExpectedLic FString LicenseKey; FString Fingerprint; FString ActivatedAt; - double ActivationId = 0.0; + FString ActivationId; if (!JsonResponse->TryGetStringField(TEXT("object"), Object) || Object != TEXT("activation") || - !JsonResponse->TryGetNumberField(TEXT("id"), ActivationId) || ActivationId <= 0.0 || + !TryGetIdentifierField(JsonResponse, TEXT("id"), ActivationId) || !JsonResponse->TryGetStringField(TEXT("license_key"), LicenseKey) || LicenseKey != ExpectedLicenseKey || !(JsonResponse->TryGetStringField(TEXT("fingerprint"), Fingerprint) || @@ -224,7 +245,7 @@ bool ParseActivationResponse(const FString& Response, const FString& ExpectedLic } Result.bSuccess = true; - Result.ActivationId = LexToString(static_cast(ActivationId)); + Result.ActivationId = ActivationId; Result.DeviceId = ExpectedFingerprint; return true; } @@ -237,12 +258,11 @@ bool ParseDeactivationResponse(const FString& Response) { } FString Object; FString DeactivatedAt; - double ActivationId = 0.0; + FString ActivationId; FDateTime Parsed; return JsonResponse->TryGetStringField(TEXT("object"), Object) && Object == TEXT("deactivation") && - JsonResponse->TryGetNumberField(TEXT("activation_id"), ActivationId) && - ActivationId > 0.0 && + TryGetIdentifierField(JsonResponse, TEXT("activation_id"), ActivationId) && JsonResponse->TryGetStringField(TEXT("deactivated_at"), DeactivatedAt) && FDateTime::ParseIso8601(*DeactivatedAt, Parsed); } diff --git a/src/client.cpp b/src/client.cpp index 19f514c..d4d0fe8 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -470,7 +470,8 @@ class Client::Impl : public std::enable_shared_from_this { result.license.mode() == LicenseMode::Unknown || result.license.plan_key().empty() || !result.license.is_valid() || (result.activation.has_value() && - (!constant_time_equal(result.activation->license_key(), license_key) || + (result.activation->id().empty() || + !constant_time_equal(result.activation->license_key(), license_key) || !constant_time_equal(result.activation->fingerprint(), request.fingerprint) || !result.activation->is_active()))) { throw std::invalid_argument("Validation response identity is inconsistent"); @@ -554,7 +555,7 @@ class Client::Impl : public std::enable_shared_from_this { throw std::invalid_argument("Activation response has an invalid object type"); } auto activation = json::parse_activation(j); - if (activation.id() <= 0 || + if (activation.id().empty() || !constant_time_equal(activation.license_key(), license_key) || !constant_time_equal(activation.fingerprint(), request.fingerprint) || !activation.is_active() || activation.activated_at() == Timestamp{}) { @@ -567,6 +568,25 @@ class Client::Impl : public std::enable_shared_from_this { current_activation_ = activation; } + // Persist only a resume identity for this machine. An activation is + // not a validation result, and these untrusted disk fields must never + // grant offline authority without a verified signed artifact. + if (constant_time_equal(request.fingerprint, device_id_)) { + auto cached = storage_->get_license(); + if (!cached || cached->license_key != license_key || + cached->device_id != request.fingerprint) { + CachedLicense identity; + identity.license_key = license_key; + identity.device_id = request.fingerprint; + identity.activated_at = activation.activated_at(); + if (!storage_->set_license(identity)) { + return Result::error(ErrorCode::FileError, + "Device activated, but the local session could not be saved; " + "check the storage path and retry with the same fingerprint"); + } + } + } + event_bus_.emit(events::ACTIVATION_SUCCESS, activation); // Sync offline assets AFTER releasing the lock to avoid deadlock @@ -613,7 +633,7 @@ class Client::Impl : public std::enable_shared_from_this { throw std::invalid_argument("Deactivation response has an invalid object type"); } auto deactivation = json::parse_deactivation(j); - if (deactivation.activation_id <= 0 || deactivation.deactivated_at == Timestamp{}) { + if (deactivation.activation_id.empty() || deactivation.deactivated_at == Timestamp{}) { throw std::invalid_argument("Deactivation response is incomplete"); } @@ -893,8 +913,10 @@ class Client::Impl : public std::enable_shared_from_this { return Result::error(verification.error_code(), verification.error_message()); } - if (constant_time_equal(request.fingerprint, device_id_)) { - storage_->set_machine_file(machine_file); + if (constant_time_equal(request.fingerprint, device_id_) && + !storage_->set_machine_file(machine_file)) { + return Result::error(ErrorCode::FileError, + "Verified machine file could not be saved"); } event_bus_.emit(events::MACHINE_FILE_FETCHED, machine_file); diff --git a/tests/integration_test.cpp b/tests/integration_test.cpp index 0f9ac39..79e7e2f 100644 --- a/tests/integration_test.cpp +++ b/tests/integration_test.cpp @@ -368,7 +368,7 @@ void test_activation_deactivation() { if (result.is_ok()) { const auto& activation = result.value(); pass("Activate license"); - info("Activation ID: " + std::to_string(activation.id())); + info("Activation ID: " + activation.id()); info("Device ID: " + activation.device_id()); info("Device Name: " + activation.device_name()); info("Is Active: " + std::string(activation.is_active() ? "yes" : "no")); @@ -406,7 +406,7 @@ void test_activation_deactivation() { if (result.is_ok()) { pass("Deactivate license"); - info("Deactivated activation ID: " + std::to_string(result.value().activation_id)); + info("Deactivated activation ID: " + result.value().activation_id); } else { if (result.error_code() == licenseseat::ErrorCode::ActivationNotFound) { pass("Deactivation - activation not found (may have been cleaned up)"); diff --git a/tests/scenario_test.cpp b/tests/scenario_test.cpp index d63b0c6..3d0f5ba 100644 --- a/tests/scenario_test.cpp +++ b/tests/scenario_test.cpp @@ -181,7 +181,7 @@ void test_scenario_1_first_launch() { auto activate_result = client.activate(LICENSE_KEY, config.device_id, "Test Device"); if (activate_result.is_ok()) { pass("Activation succeeds"); - info("Activation ID: " + std::to_string(activate_result.value().id())); + info("Activation ID: " + activate_result.value().id()); } else if (activate_result.error_code() == licenseseat::ErrorCode::SeatLimitExceeded || activate_result.error_code() == licenseseat::ErrorCode::DeviceAlreadyActivated) { pass("Activation handled (seat limit or already activated)"); diff --git a/tests/telemetry_stress_test.cpp b/tests/telemetry_stress_test.cpp index 736366b..1b1280d 100644 --- a/tests/telemetry_stress_test.cpp +++ b/tests/telemetry_stress_test.cpp @@ -125,7 +125,7 @@ static void scenario1_activation_with_telemetry() { if (result.is_ok()) { printTest(true, "Activation succeeded"); logInfo("device_id: " + result.value().device_id()); - logInfo("activation_id: " + std::to_string(result.value().id())); + logInfo("activation_id: " + result.value().id()); } else if (result.error_code() == licenseseat::ErrorCode::DeviceAlreadyActivated) { printTest(true, "Activation returned already_activated (expected, passing)"); logInfo("device_id: " + sdk->device_id()); diff --git a/tests/test_client.cpp b/tests/test_client.cpp index 132766c..6954078 100644 --- a/tests/test_client.cpp +++ b/tests/test_client.cpp @@ -263,6 +263,291 @@ class ClientTest : public ::testing::Test { Config config_; }; +// Customer-workflow review: loopback only, real signed/encrypted machine files. +class CustomerWorkflowReview : public ClientTest { + protected: + void SetUp() override { + ClientTest::SetUp(); + path_ = std::filesystem::temp_directory_path() / + ("ls-customer-review-" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + config_.storage_path = path_.string(); + config_.network_recheck_interval = 3600; + machine_ = build_test_machine_file("KEY-123", config_.device_id, &public_key_); + server_.Post("/api/v1/products/test_product/licenses/activate", + [&](const httplib::Request&, httplib::Response& res) { + nlohmann::json j = {{"object", "activation"}, {"id", id_}, + {"license_key", "KEY-123"}, {"fingerprint", config_.device_id}, + {"activated_at", "2026-09-01T12:00:00Z"}}; + res.status = 201; + res.set_content(j.dump(), "application/json"); + }); + server_.Post("/api/v1/products/test_product/licenses/deactivate", + [&](const httplib::Request&, httplib::Response& res) { + nlohmann::json j = {{"object", "deactivation"}, {"activation_id", id_}, + {"deactivated_at", "2026-09-05T12:00:00Z"}}; + res.set_content(j.dump(), "application/json"); + }); + server_.Get("/api/v1/signing_keys/test-kid", + [&](const httplib::Request&, httplib::Response& res) { + nlohmann::json j = {{"object", "signing_key"}, {"key_id", "test-kid"}, + {"algorithm", "Ed25519"}, {"status", "active"}, {"public_key", public_key_}}; + res.set_content(j.dump(), "application/json"); + }); + server_.Post("/api/v1/products/test_product/licenses/validate", + [&](const httplib::Request&, httplib::Response& res) { + nlohmann::json j = {{"object", "validation_result"}, {"valid", true}, + {"license", {{"object", "license"}, {"key", "KEY-123"}, + {"status", "active"}, {"mode", "hardware_locked"}, {"plan_key", "pro"}, + {"seat_limit", 1}, {"active_seats", 1}, + {"product", {{"slug", "test_product"}, {"name", "Test Product"}}}}}, + {"activation", {{"object", "activation"}, {"id", id_}, + {"license_key", "KEY-123"}, {"fingerprint", config_.device_id}, + {"activated_at", "2026-09-01T12:00:00Z"}}}}; + res.set_content(j.dump(), "application/json"); + }); + server_.Post("/api/v1/products/test_product/licenses/machine-file", + [&](const httplib::Request&, httplib::Response& res) { + ++checkouts_; + const auto now = std::chrono::system_clock::now(); + nlohmann::json j = {{"data", {{"type", "machine-files"}, + {"attributes", {{"certificate", machine_.certificate}, + {"algorithm", "aes-256-gcm+ed25519"}, {"ttl", 86400}, + {"issued", json::format_timestamp(now)}, + {"expiry", json::format_timestamp(now + std::chrono::hours(24))}}}, + {"relationships", { + {"license", {{"data", {{"type", "licenses"}, {"id", "KEY-123"}}}}}, + {"machine", {{"data", {{"type", "machines"}, {"id", config_.device_id}}}}} + }}}}}; + res.status = 201; + res.set_content(j.dump(), "application/json"); + }); + const auto port = server_.bind_to_any_port("127.0.0.1"); + ASSERT_GT(port, 0); + config_.api_url = "http://127.0.0.1:" + std::to_string(port) + "/api/v1"; + thread_ = std::thread([this] { server_.listen_after_bind(); }); + } + void TearDown() override { + server_.stop(); + if (thread_.joinable()) thread_.join(); + std::error_code ignored; + std::filesystem::remove_all(path_, ignored); + } + httplib::Server server_; + std::thread thread_; + std::filesystem::path path_; + MachineFile machine_; + std::string public_key_; + nlohmann::json id_ = "9d063849-d144-49a5-bf91-2af06e700421"; + std::atomic checkouts_{0}; +}; + +TEST_F(CustomerWorkflowReview, UuidActivateVerifyAndDeactivateClearArtifacts) { + config_.signing_public_key = public_key_; + Client client(config_); + auto activated = client.activate("KEY-123"); + ASSERT_TRUE(activated.is_ok()) << activated.error_message(); + EXPECT_EQ(activated.value().id(), id_.get()); + FileStorage storage(path_.string()); + ASSERT_TRUE(storage.get_machine_file().has_value()); + ASSERT_TRUE(storage.set_license(build_cached_license("KEY-123", config_.device_id))); + auto verified = client.verify_machine_file(*storage.get_machine_file()); + ASSERT_TRUE(verified.is_ok()); + EXPECT_TRUE(verified.value().valid); + auto deactivated = client.deactivate("KEY-123", config_.device_id); + ASSERT_TRUE(deactivated.is_ok()) << deactivated.error_message(); + EXPECT_EQ(deactivated.value().activation_id, activated.value().id()); + EXPECT_FALSE(storage.get_license().has_value()); + EXPECT_FALSE(storage.get_machine_file().has_value()); +} + +TEST_F(CustomerWorkflowReview, DefaultConfigurationDoesNotEnableOfflineAuthority) { + config_.offline_fallback_mode = Config{}.offline_fallback_mode; + config_.max_offline_days = Config{}.max_offline_days; + Client client(config_); + ASSERT_TRUE(client.activate("KEY-123").is_ok()); + EXPECT_EQ(checkouts_.load(), 0); + EXPECT_FALSE(FileStorage(path_.string()).get_machine_file().has_value()); +} + +TEST_F(CustomerWorkflowReview, ActivateWithPinAndOfflinePolicyShouldRestoreAfterRestart) { + config_.signing_public_key = public_key_; + { + Client client(config_); + ASSERT_TRUE(client.activate("KEY-123").is_ok()); + ASSERT_TRUE(FileStorage(path_.string()).get_machine_file().has_value()); + } + server_.stop(); + Client restarted(config_); + auto result = restarted.restore_license(); + EXPECT_TRUE(result.success) << result.message; + EXPECT_EQ(result.status, ClientStatus::OfflineValid); +} + +TEST_F(CustomerWorkflowReview, RestartRequiresApplicationPinEvenWithPersistedKey) { + { + Client client(config_); + ASSERT_TRUE(client.activate("KEY-123").is_ok()); + // Disk key bytes are diagnostic data, not a cross-process trust anchor. + ASSERT_TRUE(FileStorage(path_.string()).set_signing_key("test-kid", public_key_)); + auto verified = client.verify_machine_file(machine_); + ASSERT_TRUE(verified.is_ok()); + ASSERT_TRUE(verified.value().valid); + } + server_.stop(); + Client restarted(config_); + auto result = restarted.restore_license(); + EXPECT_FALSE(result.success); + EXPECT_EQ(result.status, ClientStatus::OfflineInvalid); + EXPECT_NE(result.message.find("missing_parameter"), std::string::npos); +} + +TEST_F(CustomerWorkflowReview, PinnedKeyWithCompleteCacheRestoresOffline) { + config_.signing_public_key = public_key_; + { + Client client(config_); + ASSERT_TRUE(client.activate("KEY-123").is_ok()); + ASSERT_TRUE(FileStorage(path_.string()).set_license( + build_cached_license("KEY-123", config_.device_id))); + } + server_.stop(); + Client restarted(config_); + auto result = restarted.restore_license(); + EXPECT_TRUE(result.success) << result.message; + EXPECT_EQ(result.status, ClientStatus::OfflineValid); +} + +TEST_F(CustomerWorkflowReview, SupportedProvisioningValidateThenRestartWorks) { + config_.signing_public_key = public_key_; + config_.signing_key_id = "test-kid"; + { + Client client(config_); + ASSERT_TRUE(client.activate("KEY-123").is_ok()); + auto validated = client.validate("KEY-123"); + ASSERT_TRUE(validated.is_ok()) << validated.error_message(); + ASSERT_TRUE(validated.value().valid); + ASSERT_TRUE(validated.value().activation.has_value()); + EXPECT_EQ(validated.value().activation->id(), id_.get()); + auto machine = client.checkout_machine_file("KEY-123"); + ASSERT_TRUE(machine.is_ok()) << machine.error_message(); + } + server_.stop(); + Client restarted(config_); + auto restored = restarted.restore_license(); + EXPECT_TRUE(restored.success) << restored.message; + EXPECT_EQ(restored.status, ClientStatus::OfflineValid); +} + +TEST_F(CustomerWorkflowReview, CurlCertificateOnlyVerifiesWithPinnedKeyAndRejectsTampering) { + config_.signing_public_key = public_key_; + Client client(config_); + MachineFile imported; + imported.certificate = machine_.certificate; + auto valid = client.verify_machine_file(imported, "", "KEY-123", config_.device_id); + ASSERT_TRUE(valid.is_ok()) << valid.error_message(); + ASSERT_TRUE(valid.value().valid); + ASSERT_TRUE(valid.value().payload.has_value()); + ASSERT_TRUE(valid.value().payload->license.has_value()); + EXPECT_EQ(valid.value().payload->license->key(), "KEY-123"); + auto wrong_device = client.verify_machine_file(imported, "", "KEY-123", "wrong-device"); + EXPECT_TRUE(wrong_device.is_error() || !wrong_device.value().valid); + auto wrong_license = client.verify_machine_file(imported, "", "WRONG-KEY", config_.device_id); + EXPECT_TRUE(wrong_license.is_error() || !wrong_license.value().valid); + imported.certificate[40] = imported.certificate[40] == 'A' ? 'B' : 'A'; + auto tampered = client.verify_machine_file(imported, "", "KEY-123", config_.device_id); + EXPECT_TRUE(tampered.is_error() || !tampered.value().valid); +} + +TEST_F(CustomerWorkflowReview, RejectNonpositiveNumericActivationIds) { + config_.offline_fallback_mode = OfflineFallbackMode::Disabled; + Client client(config_); + for (auto invalid : {0, -1}) { + id_ = invalid; + auto result = client.activate("KEY-123"); + EXPECT_TRUE(result.is_error()) << "Accepted id=" << invalid; + auto deactivation = client.deactivate("KEY-123", config_.device_id); + EXPECT_TRUE(deactivation.is_error()) << "Accepted activation_id=" << invalid; + } +} + +TEST_F(CustomerWorkflowReview, IdentifierNormalizationRejectsMalformedResponses) { + config_.offline_fallback_mode = OfflineFallbackMode::Disabled; + Client client(config_); + const std::vector invalid_ids = { + nullptr, true, false, 1.5, 1.0, "", " ", "bad id", "id\n", + std::string(256, 'x'), nlohmann::json::array({1}), nlohmann::json::object()}; + for (const auto& invalid : invalid_ids) { + id_ = invalid; + EXPECT_TRUE(client.activate("KEY-123").is_error()) << invalid.dump(); + EXPECT_TRUE(client.deactivate("KEY-123", config_.device_id).is_error()) << invalid.dump(); + EXPECT_TRUE(client.validate("KEY-123").is_error()) << invalid.dump(); + } +} + +TEST_F(CustomerWorkflowReview, IntegerIdentifiersRoundTripWithoutSignedOverflow) { + config_.offline_fallback_mode = OfflineFallbackMode::Disabled; + Client client(config_); + for (const uint64_t value : {uint64_t{1}, uint64_t{42}, + std::numeric_limits::max()}) { + id_ = value; + auto activated = client.activate("KEY-123"); + ASSERT_TRUE(activated.is_ok()) << activated.error_message(); + EXPECT_EQ(activated.value().id(), std::to_string(value)); + auto deactivated = client.deactivate("KEY-123", config_.device_id); + ASSERT_TRUE(deactivated.is_ok()); + EXPECT_EQ(deactivated.value().activation_id, std::to_string(value)); + } +} + +TEST_F(CustomerWorkflowReview, ActivationIdentityAloneDoesNotAuthorizeOffline) { + config_.signing_public_key = public_key_; + { + Client client(config_); + ASSERT_TRUE(client.activate("KEY-123").is_ok()); + FileStorage storage(path_.string()); + const auto identity = storage.get_license(); + ASSERT_TRUE(identity.has_value()); + EXPECT_FALSE(identity->validation.has_value()); + EXPECT_FALSE(identity->license_data.has_value()); + storage.clear_machine_file(); + } + server_.stop(); + Client restarted(config_); + EXPECT_FALSE(restarted.restore_license().success); +} + +TEST_F(CustomerWorkflowReview, CheckoutReportsUnwritableCacheInsteadOfReady) { + config_.signing_public_key = public_key_; + // A directory at the intended artifact filename cannot be atomically replaced. + std::filesystem::create_directories(path_ / "licenseseat_machine_file.json"); + Client client(config_); + std::atomic ready{0}; + client.on(events::MACHINE_FILE_READY, [&](const std::any&) { ++ready; }); + auto machine = client.checkout_machine_file("KEY-123"); + ASSERT_TRUE(machine.is_error()); + EXPECT_EQ(machine.error_code(), ErrorCode::FileError); + EXPECT_EQ(ready.load(), 0); +} + +TEST_F(CustomerWorkflowReview, ActivationReportsUnwritableSessionAndSameDeviceCanRetry) { + config_.offline_fallback_mode = OfflineFallbackMode::Disabled; + const auto blocked_path = path_ / "licenseseat_license.json"; + std::filesystem::create_directories(blocked_path); + Client client(config_); + auto activation = client.activate("KEY-123"); + ASSERT_TRUE(activation.is_error()); + EXPECT_EQ(activation.error_code(), ErrorCode::FileError); + EXPECT_NE(activation.error_message().find("Device activated"), std::string::npos); + std::filesystem::remove(blocked_path); // Empty directory made by this test. + auto retry = client.activate("KEY-123"); + ASSERT_TRUE(retry.is_ok()) << retry.error_message(); + FileStorage storage(path_.string()); + const auto identity = storage.get_license(); + ASSERT_TRUE(identity.has_value()); + EXPECT_EQ(identity->device_id, config_.device_id); + EXPECT_FALSE(identity->validation.has_value()); +} + // ==================== Construction Tests ==================== TEST_F(ClientTest, CanBeConstructed) { diff --git a/tests/test_json.cpp b/tests/test_json.cpp index 2fd767e..0f18adf 100644 --- a/tests/test_json.cpp +++ b/tests/test_json.cpp @@ -260,7 +260,7 @@ TEST(JsonActivationTest, ParseFullActivation) { auto activation = parse_activation(j); - EXPECT_EQ(activation.id(), 42); + EXPECT_EQ(activation.id(), "42"); EXPECT_EQ(activation.device_id(), "device-001"); EXPECT_EQ(activation.device_name(), "My MacBook"); EXPECT_EQ(activation.license_key(), "KEY-123"); @@ -296,6 +296,20 @@ TEST(JsonActivationTest, ParseFingerprintFieldAsDeviceId) { EXPECT_EQ(activation.fingerprint(), "fp-123"); } +TEST(JsonActivationTest, ParsesUuidActivationId) { + // Production issues UUID primary keys, so `id` arrives as a string. Parsing it as a + // number left the id empty and made every activate() call fail the identity check. + nlohmann::json j = {{"id", "9d063849-d144-49a5-bf91-2af06e700421"}, + {"fingerprint", "device-001"}, + {"license_key", "KEY-123"}, + {"activated_at", "2026-01-19T12:00:00Z"}}; + + auto activation = parse_activation(j); + + EXPECT_EQ(activation.id(), "9d063849-d144-49a5-bf91-2af06e700421"); + EXPECT_FALSE(activation.id().empty()); +} + TEST(JsonActivationTest, RejectsMalformedLifecycleDates) { EXPECT_THROW((void)parse_activation({{"id", 1}, {"activated_at", "invalid"}}), std::invalid_argument); @@ -314,7 +328,20 @@ TEST(JsonDeactivationTest, ParseDeactivation) { auto deactivation = parse_deactivation(j); - EXPECT_EQ(deactivation.activation_id, 42); + EXPECT_EQ(deactivation.activation_id, "42"); +} + +TEST(JsonDeactivationTest, ParsesUuidActivationId) { + // Production issues UUID primary keys, so `activation_id` arrives as a string. Parsing + // it as a number left the id empty and made every deactivate() call fail the + // completeness check before the local cache was cleared. + nlohmann::json j = {{"activation_id", "9d063849-d144-49a5-bf91-2af06e700421"}, + {"deactivated_at", "2026-01-20T12:00:00Z"}}; + + auto deactivation = parse_deactivation(j); + + EXPECT_EQ(deactivation.activation_id, "9d063849-d144-49a5-bf91-2af06e700421"); + EXPECT_FALSE(deactivation.activation_id.empty()); } // ==================== Validation Warning Tests ==================== @@ -390,7 +417,7 @@ TEST(JsonValidationResultTest, ParseWithActivation) { EXPECT_TRUE(result.valid); EXPECT_TRUE(result.activation.has_value()); - EXPECT_EQ(result.activation->id(), 42); + EXPECT_EQ(result.activation->id(), "42"); } // ==================== Offline Token Tests ==================== diff --git a/tests/test_license.cpp b/tests/test_license.cpp index 328371c..d94b4c2 100644 --- a/tests/test_license.cpp +++ b/tests/test_license.cpp @@ -197,7 +197,7 @@ TEST(LicenseModeTest, FromStringConversion) { TEST(ActivationTest, DefaultConstructor) { Activation activation; - EXPECT_EQ(activation.id(), 0); + EXPECT_TRUE(activation.id().empty()); EXPECT_TRUE(activation.device_id().empty()); EXPECT_TRUE(activation.device_name().empty()); EXPECT_TRUE(activation.license_key().empty()); @@ -210,10 +210,10 @@ TEST(ActivationTest, FullConstructor) { auto activated = std::chrono::system_clock::now(); Metadata meta{{"os", "macos"}}; - Activation activation(42, "device-123", "My MacBook", "KEY-ABC", activated, std::nullopt, + Activation activation("42", "device-123", "My MacBook", "KEY-ABC", activated, std::nullopt, "192.168.1.1", meta); - EXPECT_EQ(activation.id(), 42); + EXPECT_EQ(activation.id(), "42"); EXPECT_EQ(activation.device_id(), "device-123"); EXPECT_EQ(activation.device_name(), "My MacBook"); EXPECT_EQ(activation.license_key(), "KEY-ABC"); @@ -226,7 +226,7 @@ TEST(ActivationTest, DeactivatedActivation) { auto activated = std::chrono::system_clock::now() - std::chrono::hours(24); auto deactivated = std::chrono::system_clock::now(); - Activation activation(42, "device-123", "", "KEY-ABC", activated, deactivated, "192.168.1.1", + Activation activation("42", "device-123", "", "KEY-ABC", activated, deactivated, "192.168.1.1", {}); EXPECT_FALSE(activation.is_active()); @@ -337,7 +337,7 @@ TEST(DownloadTokenTest, DefaultState) { TEST(DeactivationTest, DefaultState) { Deactivation deactivation; - EXPECT_EQ(deactivation.activation_id, 0); + EXPECT_TRUE(deactivation.activation_id.empty()); } // ==================== Config Tests ==================== diff --git a/vcpkg.json b/vcpkg.json index cd7604b..fb8d951 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", "name": "licenseseat", - "version": "0.6.1", + "version": "0.7.0", "description": "C++ SDK for LicenseSeat licensing API", "homepage": "https://github.com/licenseseat/licenseseat-cpp", "license": "MIT",