Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Build directories
build/
build-tests/
build-review/
build_*/
cmake-build-*/
out/
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
66 changes: 52 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -864,14 +892,19 @@ 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";
}
}
```

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
Expand All @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion conanfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
88 changes: 88 additions & 0 deletions docs/offline-integration.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 27 additions & 7 deletions include/licenseseat/json.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>();
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<uint64_t>();
return id > 0 ? std::to_string(id) : std::string{};
}
if (value.is_number_integer()) {
const auto id = value.get<int64_t>();
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<int64_t>();
}
const auto id = j.contains("id") ? parse_activation_identifier(j["id"]) : std::string{};

std::string device_id;
if (j.contains("fingerprint")) {
Expand Down Expand Up @@ -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<int64_t>();
}
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<std::string>());
Expand Down
32 changes: 20 additions & 12 deletions include/licenseseat/licenseseat.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string, std::string>;
Expand Down Expand Up @@ -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<Timestamp> 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<Timestamp> 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_; }
Expand Down Expand Up @@ -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_;
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion integrations/unreal/LicenseSeat/LicenseSeat.uplugin
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading