Come creare un sistema di gestione delle licenze software con C++
Un sistema di gestione delle licenze software (licensing system) è il meccanismo con cui un vendor stabilisce chi può eseguire il proprio prodotto, per quanto tempo, su quali macchine e con quali funzionalità attive. Nel mondo C++ il problema ha una connotazione particolare: il codice viene distribuito come binario nativo, senza runtime intermedi, quindi la verifica della licenza avviene interamente sulla macchina dell'utente e deve resistere a un avversario che ha il pieno controllo dell'ambiente di esecuzione.
In questo articolo costruiamo, passo dopo passo, una libreria di licensing completa: modello dati, firma crittografica con Ed25519, generazione delle chiavi di licenza, fingerprint della macchina, validazione offline, attivazione online, revoca, persistenza autenticata dello stato e strategie di hardening. Il codice è scritto in C++20 e usa OpenSSL 3 e nlohmann/json come uniche dipendenze.
Requisiti e modello di minaccia
Prima di scrivere una riga di codice conviene fissare cosa il sistema deve garantire e, soprattutto, cosa non può garantire. Un sistema di licensing per software distribuito al cliente non è un sistema di sicurezza in senso stretto: non protegge un segreto da un attaccante, perché l'attaccante possiede il binario e può modificarlo. Protegge invece la correttezza commerciale dell'uso del prodotto.
I requisiti funzionali che implementiamo sono i seguenti:
- Autenticità: solo il vendor può emettere licenze valide. Nessun cliente, nemmeno disponendo del binario completo, deve poter generare una licenza accettata dal prodotto.
- Integrità: qualsiasi modifica al contenuto della licenza (data di scadenza, elenco delle funzionalità, numero di postazioni) deve invalidarla.
- Verifica offline: il prodotto deve funzionare anche senza connessione di rete, almeno per un periodo definito.
- Node locking opzionale: una licenza può essere vincolata a una macchina specifica tramite un fingerprint stabile.
- Scadenza e periodo di grazia: le licenze a tempo devono scadere, con una tolleranza configurabile per evitare interruzioni brusche del servizio.
- Revoca: il vendor deve poter invalidare una licenza già emessa, ad esempio dopo un rimborso o un abuso.
- Funzionalità granulari: la licenza trasporta l'elenco delle feature abilitate, così che lo stesso binario serva tutti i piani commerciali.
Il modello di minaccia distingue tre categorie di avversario. L'utente distratto, che semplicemente non si accorge della scadenza: contro di lui basta un controllo corretto. L'utente opportunista, che prova a modificare il file di licenza, a spostare l'orologio di sistema o a copiare la licenza su un'altra macchina: contro di lui servono firma crittografica, fingerprint e rilevamento della manomissione dell'orologio. Il reverse engineer esperto, che apre il binario in un disassemblatore e sostituisce l'istruzione di salto condizionato dopo la verifica: contro di lui nessuna tecnica puramente locale è risolutiva, e va detto con chiarezza fin dall'inizio. L'obiettivo realistico è alzare il costo dell'attacco sopra il prezzo della licenza, non renderlo impossibile.
Architettura del sistema
Il sistema si divide in due componenti che non condividono mai lo stesso materiale crittografico.
Il generatore vive sull'infrastruttura del vendor, tipicamente dietro il gestionale o l'e-commerce. Possiede la chiave privata Ed25519 e produce le chiavi di licenza. Non viene mai distribuito ai clienti.
Il validatore è una libreria statica linkata nel prodotto. Contiene solo la chiave pubblica, che può essere letta da chiunque apra il binario senza che questo comprometta il sistema: dalla chiave pubblica non si risale a quella privata, quindi nessuno può forgiare licenze.
Questa asimmetria è il motivo per cui una firma digitale è preferibile a un HMAC o a una cifratura simmetrica. Con un algoritmo simmetrico la stessa chiave che verifica la licenza sarebbe anche in grado di emetterla, e dovrebbe quindi risiedere nel binario del client: chiunque la estraesse potrebbe diventare un generatore di licenze, come è accaduto storicamente a moltissimi keygen.
La struttura del progetto è la seguente:
licensing/
├── CMakeLists.txt
├── include/
│ └── licensing/
│ ├── base64.hpp
│ ├── crypto.hpp
│ ├── fingerprint.hpp
│ ├── license.hpp
│ ├── manager.hpp
│ └── state.hpp
├── src/
│ ├── base64.cpp
│ ├── crypto.cpp
│ ├── fingerprint.cpp
│ ├── license.cpp
│ ├── manager.cpp
│ └── state.cpp
├── tools/
│ └── keygen_cli.cpp
└── tests/
└── test_license.cpp
Le dipendenze si installano con il gestore di pacchetti della distribuzione oppure con vcpkg o Conan:
sudo apt install build-essential cmake libssl-dev nlohmann-json3-dev
Il modello dati della licenza
La licenza è un documento strutturato che descrive un diritto d'uso. Conviene definirla come una struttura POD, senza logica di validazione al suo interno: le regole vivranno altrove, così che la stessa struttura serva sia al generatore sia al validatore.
#pragma once
#include <chrono>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace licensing {
// Tipologia commerciale: determina quali regole di validazione si applicano
enum class LicenseType { Trial, Subscription, Perpetual };
using TimePoint = std::chrono::system_clock::time_point;
struct License {
std::string id; // identificativo univoco (UUID v4)
std::string product_id; // es. "acme-editor"
std::uint32_t max_major_version = 0; // versione massima coperta, 0 = tutte
std::string customer_id;
std::string customer_email;
LicenseType type = LicenseType::Trial;
TimePoint issued_at{};
std::optional<TimePoint> expires_at; // assente = licenza perpetua
std::vector<std::string> features; // funzionalità abilitate
std::uint32_t max_seats = 1;
std::optional<std::string> machine_id; // node locking; assente = licenza mobile
};
std::string to_json(const License& license);
License from_json(const std::string& payload);
std::string to_string(LicenseType type);
LicenseType license_type_from_string(const std::string& value);
} // namespace licensing
Due scelte meritano una spiegazione. La scadenza è un std::optional anziché un valore sentinella come l'epoch zero o un timestamp lontanissimo: rende esplicita nel tipo la differenza tra licenza perpetua e licenza a termine, ed elimina un'intera classe di bug in cui una data di default viene interpretata come scadenza immediata. Il campo max_major_version vincola la licenza a una linea di versione: è il meccanismo con cui si vendono gli aggiornamenti maggiori come prodotto separato, molto più semplice da gestire di una data di fine manutenzione.
Serializzazione
Il payload viaggia in JSON. La scelta è pragmatica: è leggibile durante il debug, è estendibile senza rompere i client vecchi ed è supportato da qualunque linguaggio con cui il vendor potrebbe reimplementare il generatore lato server.
#include "licensing/license.hpp"
#include <nlohmann/json.hpp>
#include <stdexcept>
namespace licensing {
namespace {
// Il timestamp è espresso in secondi Unix: rappresentazione compatta,
// priva di fuso orario e identica su tutte le piattaforme
std::int64_t to_unix(const TimePoint& tp) {
return std::chrono::duration_cast<std::chrono::seconds>(
tp.time_since_epoch())
.count();
}
TimePoint from_unix(std::int64_t seconds) {
return TimePoint{std::chrono::seconds{seconds}};
}
} // namespace
std::string to_string(LicenseType type) {
switch (type) {
case LicenseType::Trial:
return "trial";
case LicenseType::Subscription:
return "subscription";
case LicenseType::Perpetual:
return "perpetual";
}
throw std::invalid_argument("unknown license type");
}
LicenseType license_type_from_string(const std::string& value) {
if (value == "trial") return LicenseType::Trial;
if (value == "subscription") return LicenseType::Subscription;
if (value == "perpetual") return LicenseType::Perpetual;
throw std::invalid_argument("unknown license type: " + value);
}
std::string to_json(const License& license) {
nlohmann::json payload;
payload["v"] = 1; // versione del formato: consente migrazioni future
payload["id"] = license.id;
payload["product_id"] = license.product_id;
payload["max_major_version"] = license.max_major_version;
payload["customer_id"] = license.customer_id;
payload["customer_email"] = license.customer_email;
payload["type"] = to_string(license.type);
payload["issued_at"] = to_unix(license.issued_at);
payload["features"] = license.features;
payload["max_seats"] = license.max_seats;
// I campi opzionali vengono omessi anziché serializzati come null:
// la chiave risulta più corta e il significato resta inequivocabile
if (license.expires_at) {
payload["expires_at"] = to_unix(*license.expires_at);
}
if (license.machine_id) {
payload["machine_id"] = *license.machine_id;
}
// dump() senza indentazione: la firma coprirà esattamente questi byte
return payload.dump();
}
License from_json(const std::string& payload) {
const auto json = nlohmann::json::parse(payload);
if (json.value("v", 0) != 1) {
throw std::invalid_argument("unsupported license format version");
}
License license;
license.id = json.at("id").get<std::string>();
license.product_id = json.at("product_id").get<std::string>();
license.max_major_version = json.value("max_major_version", 0u);
license.customer_id = json.value("customer_id", std::string{});
license.customer_email = json.value("customer_email", std::string{});
license.type = license_type_from_string(json.at("type").get<std::string>());
license.issued_at = from_unix(json.at("issued_at").get<std::int64_t>());
license.features =
json.value("features", std::vector<std::string>{});
license.max_seats = json.value("max_seats", 1u);
if (json.contains("expires_at")) {
license.expires_at = from_unix(json.at("expires_at").get<std::int64_t>());
}
if (json.contains("machine_id")) {
license.machine_id = json.at("machine_id").get<std::string>();
}
return license;
}
} // namespace licensing
Il campo v in testa al payload è un dettaglio che si rivela prezioso dopo il primo anno di vita del prodotto: quando il formato cambia, il client vecchio riconosce di non poter interpretare la licenza nuova e produce un messaggio d'errore comprensibile anziché un fallimento oscuro.
Codifica Base64 URL-safe
La chiave di licenza deve poter essere copiata in una email, incollata in un campo di testo e salvata in un file di configurazione. Serve quindi una codifica testuale del payload binario. Usiamo l'alfabeto URL-safe definito dalla sezione 5 dell'RFC 4648, senza caratteri di riempimento, così che la chiave sia utilizzabile anche in una query string o in un nome di file.
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace licensing {
using Bytes = std::vector<std::uint8_t>;
std::string base64url_encode(const Bytes& data);
Bytes base64url_decode(const std::string& text);
} // namespace licensing
L'implementazione lavora su blocchi di tre byte, che diventano quattro caratteri. La tabella inversa è costruita a tempo di compilazione, così che la decodifica sia una semplice indicizzazione anziché una ricerca lineare nell'alfabeto.
#include "licensing/base64.hpp"
#include <array>
#include <stdexcept>
namespace licensing {
namespace {
// Alfabeto URL-safe: '-' e '_' sostituiscono '+' e '/'
constexpr char kAlphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
// La tabella inversa è calcolata a compile time: -1 marca i caratteri illegali
constexpr std::array<int, 256> make_reverse_table() {
std::array<int, 256> table{};
for (int& entry : table) {
entry = -1;
}
for (int i = 0; i < 64; ++i) {
table[static_cast<unsigned char>(kAlphabet[i])] = i;
}
return table;
}
constexpr std::array<int, 256> kReverse = make_reverse_table();
} // namespace
std::string base64url_encode(const Bytes& data) {
std::string out;
out.reserve((data.size() + 2) / 3 * 4);
std::size_t i = 0;
while (i + 2 < data.size()) {
const std::uint32_t chunk = (static_cast<std::uint32_t>(data[i]) << 16) |
(static_cast<std::uint32_t>(data[i + 1]) << 8) |
static_cast<std::uint32_t>(data[i + 2]);
out.push_back(kAlphabet[(chunk >> 18) & 0x3F]);
out.push_back(kAlphabet[(chunk >> 12) & 0x3F]);
out.push_back(kAlphabet[(chunk >> 6) & 0x3F]);
out.push_back(kAlphabet[chunk & 0x3F]);
i += 3;
}
// Coda: senza padding, il numero di byte residui si deduce dalla lunghezza
const std::size_t remaining = data.size() - i;
if (remaining == 1) {
const std::uint32_t chunk = static_cast<std::uint32_t>(data[i]) << 16;
out.push_back(kAlphabet[(chunk >> 18) & 0x3F]);
out.push_back(kAlphabet[(chunk >> 12) & 0x3F]);
} else if (remaining == 2) {
const std::uint32_t chunk = (static_cast<std::uint32_t>(data[i]) << 16) |
(static_cast<std::uint32_t>(data[i + 1]) << 8);
out.push_back(kAlphabet[(chunk >> 18) & 0x3F]);
out.push_back(kAlphabet[(chunk >> 12) & 0x3F]);
out.push_back(kAlphabet[(chunk >> 6) & 0x3F]);
}
return out;
}
Bytes base64url_decode(const std::string& text) {
Bytes out;
out.reserve(text.size() * 3 / 4);
// Accumulatore a scorrimento: ogni carattere aggiunge 6 bit,
// ogni volta che se ne raccolgono almeno 8 si emette un byte
std::uint32_t buffer = 0;
int bits = 0;
for (const char character : text) {
const int value = kReverse[static_cast<unsigned char>(character)];
if (value < 0) {
throw std::invalid_argument("invalid base64url character");
}
buffer = (buffer << 6) | static_cast<std::uint32_t>(value);
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(static_cast<std::uint8_t>((buffer >> bits) & 0xFF));
}
}
return out;
}
} // namespace licensing
Firma digitale con Ed25519
Ed25519 è la scelta naturale per questo caso d'uso. Le chiavi sono di 32 byte e le firme di 64, contro i 256 byte di una firma RSA-2048: la differenza si vede direttamente nella lunghezza della chiave di licenza che il cliente deve maneggiare. L'algoritmo è deterministico, quindi non dipende dalla qualità del generatore di numeri casuali al momento della firma, ed è immune per costruzione a diverse classi di attacchi che affliggono ECDSA. OpenSSL 3 lo espone attraverso l'interfaccia EVP.
#pragma once
#include "licensing/base64.hpp"
#include <string>
namespace licensing {
struct KeyPair {
Bytes private_key; // 32 byte, non lascia mai l'infrastruttura del vendor
Bytes public_key; // 32 byte, incorporata nel binario distribuito
};
KeyPair generate_keypair();
Bytes sign(const Bytes& private_key, const Bytes& message);
bool verify(const Bytes& public_key, const Bytes& message, const Bytes& signature);
Bytes sha256(const Bytes& data);
Bytes hmac_sha256(const Bytes& key, const Bytes& data);
bool constant_time_equals(const Bytes& left, const Bytes& right);
} // namespace licensing
OpenSSL espone risorse C che vanno liberate esplicitamente. Invece di disseminare il codice di chiamate a EVP_PKEY_free, incapsuliamo ogni handle in uno std::unique_ptr con deleter dedicato: il codice diventa immune alle fughe di memoria anche lungo i percorsi di errore, che in una libreria crittografica sono la maggioranza.
#include "licensing/crypto.hpp"
#include <openssl/crypto.h>
#include <openssl/evp.h>
#include <openssl/hmac.h>
#include <memory>
#include <stdexcept>
namespace licensing {
namespace {
// Deleter dedicati: ogni handle OpenSSL viene chiuso dal distruttore
struct PkeyDeleter {
void operator()(EVP_PKEY* key) const noexcept { EVP_PKEY_free(key); }
};
struct PkeyCtxDeleter {
void operator()(EVP_PKEY_CTX* ctx) const noexcept { EVP_PKEY_CTX_free(ctx); }
};
struct MdCtxDeleter {
void operator()(EVP_MD_CTX* ctx) const noexcept { EVP_MD_CTX_free(ctx); }
};
using PkeyPtr = std::unique_ptr<EVP_PKEY, PkeyDeleter>;
using PkeyCtxPtr = std::unique_ptr<EVP_PKEY_CTX, PkeyCtxDeleter>;
using MdCtxPtr = std::unique_ptr<EVP_MD_CTX, MdCtxDeleter>;
constexpr std::size_t kRawKeySize = 32;
} // namespace
KeyPair generate_keypair() {
PkeyCtxPtr ctx{EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, nullptr)};
if (!ctx || EVP_PKEY_keygen_init(ctx.get()) <= 0) {
throw std::runtime_error("cannot initialise Ed25519 key generation");
}
EVP_PKEY* generated = nullptr;
if (EVP_PKEY_keygen(ctx.get(), &generated) <= 0) {
throw std::runtime_error("Ed25519 key generation failed");
}
PkeyPtr key{generated};
KeyPair pair;
pair.private_key.resize(kRawKeySize);
pair.public_key.resize(kRawKeySize);
std::size_t length = kRawKeySize;
if (EVP_PKEY_get_raw_private_key(key.get(), pair.private_key.data(),
&length) <= 0) {
throw std::runtime_error("cannot export private key");
}
length = kRawKeySize;
if (EVP_PKEY_get_raw_public_key(key.get(), pair.public_key.data(),
&length) <= 0) {
throw std::runtime_error("cannot export public key");
}
return pair;
}
Bytes sign(const Bytes& private_key, const Bytes& message) {
PkeyPtr key{EVP_PKEY_new_raw_private_key(
EVP_PKEY_ED25519, nullptr, private_key.data(), private_key.size())};
if (!key) {
throw std::runtime_error("invalid Ed25519 private key");
}
MdCtxPtr ctx{EVP_MD_CTX_new()};
if (!ctx || EVP_DigestSignInit(ctx.get(), nullptr, nullptr, nullptr,
key.get()) <= 0) {
throw std::runtime_error("cannot initialise signing context");
}
// Ed25519 non supporta l'aggiornamento incrementale: si usa
// l'API one-shot, invocata due volte per ricavare prima la dimensione
std::size_t signature_length = 0;
if (EVP_DigestSign(ctx.get(), nullptr, &signature_length, message.data(),
message.size()) <= 0) {
throw std::runtime_error("cannot compute signature length");
}
Bytes signature(signature_length);
if (EVP_DigestSign(ctx.get(), signature.data(), &signature_length,
message.data(), message.size()) <= 0) {
throw std::runtime_error("signing failed");
}
signature.resize(signature_length);
return signature;
}
bool verify(const Bytes& public_key, const Bytes& message,
const Bytes& signature) {
PkeyPtr key{EVP_PKEY_new_raw_public_key(
EVP_PKEY_ED25519, nullptr, public_key.data(), public_key.size())};
if (!key) {
return false;
}
MdCtxPtr ctx{EVP_MD_CTX_new()};
if (!ctx || EVP_DigestVerifyInit(ctx.get(), nullptr, nullptr, nullptr,
key.get()) <= 0) {
return false;
}
// Solo il valore 1 indica una firma valida: qualunque altro esito,
// compresi i codici negativi di errore interno, va trattato come rifiuto
return EVP_DigestVerify(ctx.get(), signature.data(), signature.size(),
message.data(), message.size()) == 1;
}
Bytes sha256(const Bytes& data) {
Bytes digest(EVP_MAX_MD_SIZE);
unsigned int length = 0;
if (EVP_Digest(data.data(), data.size(), digest.data(), &length,
EVP_sha256(), nullptr) != 1) {
throw std::runtime_error("SHA-256 computation failed");
}
digest.resize(length);
return digest;
}
Bytes hmac_sha256(const Bytes& key, const Bytes& data) {
Bytes mac(EVP_MAX_MD_SIZE);
unsigned int length = 0;
if (HMAC(EVP_sha256(), key.data(), static_cast<int>(key.size()),
data.data(), data.size(), mac.data(), &length) == nullptr) {
throw std::runtime_error("HMAC computation failed");
}
mac.resize(length);
return mac;
}
bool constant_time_equals(const Bytes& left, const Bytes& right) {
// Il confronto a tempo costante impedisce di ricostruire un MAC valido
// misurando quanto rapidamente il controllo fallisce
if (left.size() != right.size()) {
return false;
}
return CRYPTO_memcmp(left.data(), right.data(), left.size()) == 0;
}
} // namespace licensing
Formato della chiave di licenza
La chiave è composta da due segmenti separati da un punto, con una struttura ispirata a quella dei token JWT ma volutamente più semplice, perché l'algoritmo non è negoziabile: è fissato nel codice del validatore. Questa rigidità elimina in partenza la classe di vulnerabilità in cui un attaccante dichiara nell'intestazione un algoritmo debole o assente.
base64url(payload JSON) + "." + base64url(firma Ed25519)
Il punto centrale del progetto è che il payload viaggia in chiaro dentro la chiave. Non è un difetto: il contenuto di una licenza non è segreto, il cliente ha anzi diritto di leggere cosa ha comprato. Ciò che conta è che non sia modificabile, e la firma se ne occupa. Ne deriva una regola implementativa che va rispettata scrupolosamente: la verifica avviene sui byte ricevuti, mai su una riserializzazione del payload. Se il validatore decodificasse il JSON, lo riserializzasse e verificasse la firma sul risultato, qualunque differenza nell'ordine delle chiavi, negli spazi o nella rappresentazione dei numeri farebbe fallire licenze legittime.
#include "licensing/crypto.hpp"
#include "licensing/license.hpp"
namespace licensing {
std::string issue_license(const License& license, const Bytes& private_key) {
const std::string payload = to_json(license);
const Bytes payload_bytes(payload.begin(), payload.end());
const Bytes signature = sign(private_key, payload_bytes);
return base64url_encode(payload_bytes) + "." + base64url_encode(signature);
}
} // namespace licensing
Una chiave prodotta in questo modo è lunga alcune centinaia di caratteri: perfetta per essere consegnata come file license.key o incollata in un campo di testo, inadatta a essere dettata al telefono. Se il prodotto richiede un codice breve e digitabile, l'approccio corretto è diverso: si genera un codice opaco di venticinque caratteri, lo si registra nel database del vendor e lo si scambia con una licenza firmata attraverso un'attivazione online. Il codice breve non contiene informazioni, è solo un riferimento; la licenza vera nasce sul server. Vedremo questo flusso più avanti.
Il generatore da riga di comando
Il vendor ha bisogno di uno strumento minimo per creare la coppia di chiavi e per emettere licenze. Il seguente eseguibile stampa la chiave privata in Base64 e la chiave pubblica già formattata come array C++, pronta per essere incollata nel sorgente del prodotto.
#include "licensing/crypto.hpp"
#include <iomanip>
#include <iostream>
int main(int argc, char** argv) {
using namespace licensing;
if (argc < 2 || std::string(argv[1]) != "keygen") {
std::cerr << "usage: keygen_cli keygen\n";
return 1;
}
const KeyPair pair = generate_keypair();
std::cout << "private key (base64url, keep it secret):\n"
<< base64url_encode(pair.private_key) << "\n\n";
// La chiave pubblica viene emessa come array di byte: incollarla nel
// sorgente evita di distribuire un file separato, facilmente sostituibile
std::cout << "public key (embed this array in the product):\n"
<< "constexpr std::uint8_t kPublicKey[32] = {";
for (std::size_t i = 0; i < pair.public_key.size(); ++i) {
if (i % 8 == 0) std::cout << "\n ";
std::cout << "0x" << std::hex << std::setw(2) << std::setfill('0')
<< static_cast<int>(pair.public_key[i]) << ", ";
}
std::cout << "\n};\n";
return 0;
}
Distribuire la chiave pubblica come array compilato nel binario, anziché come file affiancato all'eseguibile, è una precauzione elementare ma efficace: un file esterno può essere sostituito con la chiave pubblica dell'attaccante in pochi secondi, mentre modificare una costante dentro un binario richiede almeno di individuarla.
Il fingerprint della macchina
Il node locking vincola una licenza a un computer specifico. La difficoltà non è tecnica ma di equilibrio: un identificativo troppo volatile (l'indirizzo MAC di un'interfaccia che cambia quando si collega una dock, il numero di serie di un disco che cambia dopo un ripristino) genera falsi negativi che si traducono in ticket di assistenza; un identificativo troppo debole rende la copia banale.
La soluzione più equilibrata sulle tre piattaforme principali è l'identificativo di installazione del sistema operativo, che sopravvive agli aggiornamenti hardware ma cambia con la reinstallazione.
#include "licensing/fingerprint.hpp"
#include "licensing/base64.hpp"
#include "licensing/crypto.hpp"
#include <array>
#include <cstdio>
#include <fstream>
#include <memory>
#include <string>
#if defined(_WIN32)
#include <windows.h>
#endif
namespace licensing {
namespace {
#if defined(__linux__)
// Su Linux /etc/machine-id resta stabile per tutta la vita dell'installazione
std::string raw_machine_id() {
std::string value;
std::ifstream primary("/etc/machine-id");
if (primary && std::getline(primary, value) && !value.empty()) {
return value;
}
// Percorso alternativo sui sistemi privi di systemd
std::ifstream fallback("/var/lib/dbus/machine-id");
if (fallback && std::getline(fallback, value)) {
return value;
}
return {};
}
#elif defined(__APPLE__)
// Su macOS l'UUID di piattaforma si legge dal registro IOKit
std::string raw_machine_id() {
constexpr const char* command =
R"(ioreg -rd1 -c IOPlatformExpertDevice | awk -F'"' )"
R"('/IOPlatformUUID/{print $4}')";
std::unique_ptr<FILE, int (*)(FILE*)> pipe(popen(command, "r"), pclose);
if (!pipe) {
return {};
}
std::array<char, 128> buffer{};
std::string value;
while (std::fgets(buffer.data(), static_cast<int>(buffer.size()),
pipe.get()) != nullptr) {
value += buffer.data();
}
while (!value.empty() && (value.back() == '\n' || value.back() == '\r')) {
value.pop_back();
}
return value;
}
#elif defined(_WIN32)
// Su Windows MachineGuid identifica univocamente l'installazione di sistema
std::string raw_machine_id() {
HKEY key = nullptr;
if (RegOpenKeyExA(HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Cryptography", 0,
KEY_READ | KEY_WOW64_64KEY, &key) != ERROR_SUCCESS) {
return {};
}
char buffer[128] = {};
DWORD size = sizeof(buffer);
const LSTATUS status =
RegQueryValueExA(key, "MachineGuid", nullptr, nullptr,
reinterpret_cast<LPBYTE>(buffer), &size);
RegCloseKey(key);
if (status != ERROR_SUCCESS) {
return {};
}
return std::string(buffer);
}
#else
std::string raw_machine_id() { return {}; }
#endif
} // namespace
std::string machine_fingerprint(const std::string& product_id) {
const std::string identifier = raw_machine_id();
if (identifier.empty()) {
return {};
}
// Il fingerprint è un digest, non l'identificativo grezzo: il prodotto
// non trasmette mai al vendor un dato che identifichi la macchina
// al di fuori del contesto di questo specifico prodotto
const std::string material = identifier + "|" + product_id;
const Bytes digest = sha256(Bytes(material.begin(), material.end()));
return base64url_encode(digest).substr(0, 32);
}
} // namespace licensing
Combinare l'identificativo con il product_id prima di calcolare il digest non è un dettaglio estetico. Impedisce che lo stesso fingerprint sia riutilizzabile fra prodotti diversi dello stesso vendor e, soprattutto, evita di trasmettere sulla rete un identificatore stabile del computer dell'utente, che sarebbe un dato personale a tutti gli effetti sotto il GDPR. Il digest è specifico del prodotto e non reversibile.
Quando machine_fingerprint restituisce una stringa vuota, ad esempio in un container privo di /etc/machine-id, la scelta progettuale corretta è non bloccare l'esecuzione ma trattare la licenza come non vincolabile e, se il vincolo è obbligatorio, richiedere l'attivazione online. Fallire in modo silenzioso su un ambiente legittimo è il modo più rapido per perdere un cliente.
Il validatore
La validazione è la parte del sistema che gira sulla macchina dell'utente ed è quindi quella su cui conviene concentrare l'attenzione progettuale. Definiamo prima l'esito, in modo che il chiamante possa distinguere i casi e mostrare messaggi utili.
#pragma once
#include "licensing/base64.hpp"
#include "licensing/license.hpp"
#include <optional>
#include <string>
namespace licensing {
enum class ValidationError {
None,
MalformedKey,
InvalidSignature,
ProductMismatch,
VersionNotCovered,
NotYetValid,
Expired,
MachineMismatch,
ClockTampered,
Revoked
};
struct ValidationResult {
bool valid = false;
bool in_grace_period = false;
ValidationError error = ValidationError::None;
std::optional<License> license;
std::int64_t days_remaining = 0;
};
struct ValidationOptions {
Bytes public_key;
std::string product_id;
std::uint32_t major_version = 1;
std::string machine_fingerprint;
std::int64_t grace_period_days = 7;
std::int64_t clock_tolerance_seconds = 86400;
TimePoint now{};
std::optional<TimePoint> last_seen;
std::vector<std::string> revoked_ids;
};
ValidationResult validate(const std::string& key,
const ValidationOptions& options);
std::string describe(ValidationError error);
} // namespace licensing
Il campo now nelle opzioni merita un commento: iniettare l'istante corrente anziché leggerlo dentro validate rende la funzione pura e quindi testabile. Poter simulare una licenza scaduta da tre giorni senza toccare l'orologio della macchina di build vale ampiamente il campo aggiuntivo.
#include "licensing/validator.hpp"
#include "licensing/base64.hpp"
#include "licensing/crypto.hpp"
#include <algorithm>
namespace licensing {
namespace {
ValidationResult failure(ValidationError error) {
ValidationResult result;
result.valid = false;
result.error = error;
return result;
}
std::int64_t seconds_between(const TimePoint& from, const TimePoint& to) {
return std::chrono::duration_cast<std::chrono::seconds>(to - from).count();
}
} // namespace
ValidationResult validate(const std::string& key,
const ValidationOptions& options) {
const std::size_t separator = key.find('.');
if (separator == std::string::npos || separator == 0 ||
separator + 1 >= key.size()) {
return failure(ValidationError::MalformedKey);
}
Bytes payload_bytes;
Bytes signature;
try {
payload_bytes = base64url_decode(key.substr(0, separator));
signature = base64url_decode(key.substr(separator + 1));
} catch (const std::exception&) {
return failure(ValidationError::MalformedKey);
}
// La firma viene verificata sui byte ricevuti, prima di qualunque
// interpretazione: il parser JSON non deve mai vedere dati non attendibili
if (!verify(options.public_key, payload_bytes, signature)) {
return failure(ValidationError::InvalidSignature);
}
License license;
try {
license = from_json(
std::string(payload_bytes.begin(), payload_bytes.end()));
} catch (const std::exception&) {
return failure(ValidationError::MalformedKey);
}
if (license.product_id != options.product_id) {
return failure(ValidationError::ProductMismatch);
}
if (license.max_major_version != 0 &&
options.major_version > license.max_major_version) {
return failure(ValidationError::VersionNotCovered);
}
if (!options.revoked_ids.empty() &&
std::find(options.revoked_ids.begin(), options.revoked_ids.end(),
license.id) != options.revoked_ids.end()) {
return failure(ValidationError::Revoked);
}
if (license.machine_id && *license.machine_id !=
options.machine_fingerprint) {
return failure(ValidationError::MachineMismatch);
}
// Orologio spostato all'indietro rispetto all'ultimo avvio registrato:
// il tentativo di prolungare una licenza scaduta è il caso tipico
if (options.last_seen &&
seconds_between(*options.last_seen, options.now) <
-options.clock_tolerance_seconds) {
return failure(ValidationError::ClockTampered);
}
// Tolleranza in avanti: una licenza emessa da un server con orologio
// leggermente sfasato non deve risultare non ancora valida
if (seconds_between(license.issued_at, options.now) <
-options.clock_tolerance_seconds) {
return failure(ValidationError::NotYetValid);
}
ValidationResult result;
result.license = license;
if (!license.expires_at) {
result.valid = true;
result.days_remaining = -1; // licenza perpetua
return result;
}
const std::int64_t seconds_left =
seconds_between(options.now, *license.expires_at);
result.days_remaining = seconds_left / 86400;
if (seconds_left >= 0) {
result.valid = true;
return result;
}
const std::int64_t grace_seconds = options.grace_period_days * 86400;
if (-seconds_left <= grace_seconds) {
// Scaduta ma entro il periodo di grazia: il prodotto continua a
// funzionare mostrando un avviso, così un rinnovo tardivo o un
// pagamento rifiutato non interrompono il lavoro dell'utente
result.valid = true;
result.in_grace_period = true;
return result;
}
return failure(ValidationError::Expired);
}
std::string describe(ValidationError error) {
switch (error) {
case ValidationError::None:
return "the license is valid";
case ValidationError::MalformedKey:
return "the license key is malformed";
case ValidationError::InvalidSignature:
return "the license signature does not match";
case ValidationError::ProductMismatch:
return "the license belongs to a different product";
case ValidationError::VersionNotCovered:
return "the license does not cover this product version";
case ValidationError::NotYetValid:
return "the license is not valid yet";
case ValidationError::Expired:
return "the license has expired";
case ValidationError::MachineMismatch:
return "the license is bound to a different machine";
case ValidationError::ClockTampered:
return "the system clock has been moved backwards";
case ValidationError::Revoked:
return "the license has been revoked";
}
return "unknown validation error";
}
} // namespace licensing
L'ordine dei controlli non è casuale. La firma viene verificata per prima, prima ancora del parsing del JSON: significa che il parser, che è il componente più complesso e quindi più esposto della catena, riceve solo dati la cui provenienza dal vendor è già dimostrata. È lo stesso principio del cryptographic doom principle applicato alla verifica prima della decodifica.
Rilevare la manomissione dell'orologio
Spostare la data di sistema indietro è il modo più semplice per prolungare una licenza a tempo, e non richiede alcuna competenza tecnica. La contromisura non ha bisogno di un server: è sufficiente che il prodotto ricordi l'istante più avanzato che ha osservato e si insospettisca quando l'orologio torna indietro in modo significativo.
La tolleranza di un giorno impostata come predefinita è un compromesso deliberato. Un utente che corregge un fuso orario sbagliato, un portatile la cui batteria tampone si è esaurita o una macchina virtuale ripristinata da uno snapshot possono legittimamente far arretrare l'orologio di alcune ore. Una tolleranza troppo stretta produrrebbe accuse di manomissione a utenti onesti, che è un danno reputazionale peggiore di qualche giorno di licenza guadagnato in modo scorretto.
Persistenza autenticata dello stato
Il rilevamento appena descritto ha bisogno di memoria fra un'esecuzione e l'altra, e questa memoria vive in un file sul disco dell'utente, cioè in un luogo che l'utente controlla. Un file di stato in chiaro verrebbe semplicemente cancellato. La protezione consiste nell'autenticarlo con un HMAC la cui chiave deriva dal fingerprint della macchina: il file non è segreto ma non è modificabile, e non è trasferibile su un altro computer.
#pragma once
#include "licensing/license.hpp"
#include <filesystem>
#include <optional>
#include <string>
namespace licensing {
struct RuntimeState {
std::string license_id;
TimePoint last_seen{}; // istante più avanzato mai osservato
std::uint32_t launch_count = 0;
};
bool save_state(const std::filesystem::path& path, const RuntimeState& state,
const std::string& fingerprint, const std::string& product_id);
std::optional<RuntimeState> load_state(const std::filesystem::path& path,
const std::string& fingerprint,
const std::string& product_id);
} // namespace licensing
#include "licensing/state.hpp"
#include "licensing/crypto.hpp"
#include <fstream>
#include <nlohmann/json.hpp>
#include <sstream>
namespace licensing {
namespace {
// La chiave HMAC nasce dal fingerprint della macchina: lo stato copiato
// su un altro computer non supera la verifica del MAC
Bytes derive_state_key(const std::string& fingerprint,
const std::string& product_id) {
const std::string material = "state-key|" + fingerprint + "|" + product_id;
return sha256(Bytes(material.begin(), material.end()));
}
} // namespace
bool save_state(const std::filesystem::path& path, const RuntimeState& state,
const std::string& fingerprint,
const std::string& product_id) {
nlohmann::json document;
document["license_id"] = state.license_id;
document["last_seen"] = std::chrono::duration_cast<std::chrono::seconds>(
state.last_seen.time_since_epoch())
.count();
document["launch_count"] = state.launch_count;
const std::string body = document.dump();
const Bytes key = derive_state_key(fingerprint, product_id);
const Bytes mac = hmac_sha256(key, Bytes(body.begin(), body.end()));
std::error_code error;
std::filesystem::create_directories(path.parent_path(), error);
// Scrittura atomica: si scrive su un file temporaneo e lo si rinomina,
// così un'interruzione non lascia mai uno stato parziale sul disco
const std::filesystem::path temporary = path.string() + ".tmp";
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) {
return false;
}
output << base64url_encode(Bytes(body.begin(), body.end())) << "."
<< base64url_encode(mac);
output.close();
std::filesystem::rename(temporary, path, error);
return !error;
}
std::optional<RuntimeState> load_state(const std::filesystem::path& path,
const std::string& fingerprint,
const std::string& product_id) {
std::ifstream input(path, std::ios::binary);
if (!input) {
return std::nullopt;
}
std::ostringstream reader;
reader << input.rdbuf();
const std::string content = reader.str();
const std::size_t separator = content.find('.');
if (separator == std::string::npos) {
return std::nullopt;
}
try {
const Bytes body = base64url_decode(content.substr(0, separator));
const Bytes mac = base64url_decode(content.substr(separator + 1));
const Bytes key = derive_state_key(fingerprint, product_id);
if (!constant_time_equals(mac, hmac_sha256(key, body))) {
// MAC non valido: lo stato è stato alterato o proviene
// da un'altra macchina, quindi va scartato senza esitazioni
return std::nullopt;
}
const auto document =
nlohmann::json::parse(std::string(body.begin(), body.end()));
RuntimeState state;
state.license_id = document.value("license_id", std::string{});
state.last_seen = TimePoint{
std::chrono::seconds{document.value("last_seen", std::int64_t{0})}};
state.launch_count = document.value("launch_count", 0u);
return state;
} catch (const std::exception&) {
return std::nullopt;
}
}
} // namespace licensing
Un dettaglio da non trascurare è il comportamento in caso di stato assente o invalido. La tentazione è di bloccare il prodotto, ma sarebbe sbagliato: lo stato manca legittimamente al primo avvio, dopo la pulizia della cartella di configurazione o su un profilo utente nuovo. La regola corretta è che l'assenza di stato non è un errore, mentre uno stato presente ma con MAC non valido viene semplicemente ignorato e riscritto. La licenza resta l'unica fonte di verità sull'autorizzazione; lo stato serve solo ad arricchire i controlli.
Attivazione online e revoca
Fino a questo punto il sistema è interamente offline. L'attivazione online aggiunge tre capacità che offline non sono ottenibili: il conteggio effettivo delle postazioni in uso, la revoca di licenze già consegnate e la conversione di un codice breve digitabile in una licenza firmata.
Il protocollo è volutamente minimale. Il client invia il codice di attivazione e il proprio fingerprint; il server verifica nel proprio database che il codice esista, non sia revocato e non abbia esaurito le postazioni disponibili, quindi emette una licenza firmata vincolata a quel fingerprint.
#pragma once
#include <string>
namespace licensing {
enum class ActivationStatus { Activated, Rejected, NetworkError };
struct ActivationRequest {
std::string activation_code; // codice breve consegnato al cliente
std::string machine_fingerprint;
std::string product_id;
std::string product_version;
std::string hostname; // solo a scopo diagnostico
};
struct ActivationResponse {
ActivationStatus status = ActivationStatus::Rejected;
std::string license_key; // licenza firmata emessa dal server
std::string message;
};
ActivationResponse activate_online(const ActivationRequest& request,
const std::string& endpoint);
} // namespace licensing
Il client, dal canto suo, si limita a due passaggi: invia la richiesta e, se la risposta contiene una licenza, la salva sul disco e la valida come qualunque altra licenza offline.
#include "licensing/activation.hpp"
#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <stdexcept>
namespace licensing {
namespace {
std::size_t append_body(char* data, std::size_t size, std::size_t count,
void* userdata) {
auto* target = static_cast<std::string*>(userdata);
target->append(data, size * count);
return size * count;
}
} // namespace
ActivationResponse activate_online(const ActivationRequest& request,
const std::string& endpoint) {
nlohmann::json body;
body["activation_code"] = request.activation_code;
body["machine_fingerprint"] = request.machine_fingerprint;
body["product_id"] = request.product_id;
body["product_version"] = request.product_version;
body["hostname"] = request.hostname;
const std::string payload = body.dump();
std::string response;
long status = 0;
CURL* handle = curl_easy_init();
if (handle == nullptr) {
throw std::runtime_error("cannot initialise HTTP client");
}
curl_slist* headers =
curl_slist_append(nullptr, "Content-Type: application/json");
curl_easy_setopt(handle, CURLOPT_URL, endpoint.c_str());
curl_easy_setopt(handle, CURLOPT_POSTFIELDS, payload.c_str());
curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, append_body);
curl_easy_setopt(handle, CURLOPT_WRITEDATA, &response);
// Timeout brevi: l'attivazione non deve mai bloccare l'avvio del prodotto
curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(handle, CURLOPT_TIMEOUT, 15L);
// Verifica del certificato sempre attiva: senza TLS autenticato
// un proxy potrebbe sostituire la risposta del server di attivazione
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L);
const CURLcode outcome = curl_easy_perform(handle);
curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status);
curl_slist_free_all(headers);
curl_easy_cleanup(handle);
ActivationResponse result;
if (outcome != CURLE_OK) {
result.status = ActivationStatus::NetworkError;
result.message = curl_easy_strerror(outcome);
return result;
}
try {
const auto document = nlohmann::json::parse(response);
if (status == 200 && document.contains("license_key")) {
result.status = ActivationStatus::Activated;
result.license_key = document.at("license_key").get<std::string>();
} else {
result.status = ActivationStatus::Rejected;
result.message = document.value("error", "activation refused");
}
} catch (const std::exception&) {
result.status = ActivationStatus::Rejected;
result.message = "malformed server response";
}
return result;
}
} // namespace licensing
La licenza restituita dal server è firmata, quindi il client non deve fidarsi del canale di trasporto: anche se un attaccante intercettasse la connessione, non potrebbe sostituire la licenza con una più generosa senza possedere la chiave privata. Il TLS resta comunque necessario per proteggere il codice di attivazione, che di per sé è un segreto commerciale.
Gestione della revoca
La revoca offline si realizza con una lista di identificativi revocati, firmata dal vendor esattamente come le licenze e distribuita insieme agli aggiornamenti del prodotto o scaricata periodicamente.
// La lista di revoca è un documento firmato con la stessa chiave delle
// licenze: il client la accetta solo se la firma è valida e se il numero
// di serie è superiore a quello della copia già memorizzata
struct RevocationList {
std::uint64_t serial = 0;
TimePoint issued_at{};
std::vector<std::string> revoked_ids;
};
std::optional<RevocationList> load_revocation_list(const std::string& document,
const Bytes& public_key,
std::uint64_t known_serial) {
const std::size_t separator = document.find('.');
if (separator == std::string::npos) {
return std::nullopt;
}
const Bytes body = base64url_decode(document.substr(0, separator));
const Bytes signature = base64url_decode(document.substr(separator + 1));
if (!verify(public_key, body, signature)) {
return std::nullopt;
}
const auto json =
nlohmann::json::parse(std::string(body.begin(), body.end()));
RevocationList list;
list.serial = json.at("serial").get<std::uint64_t>();
// Il controllo sul numero di serie impedisce l'attacco di rollback,
// in cui si ripropone al client una lista vecchia priva della revoca
if (list.serial < known_serial) {
return std::nullopt;
}
list.revoked_ids = json.value("revoked", std::vector<std::string>{});
return list;
}
Il numero di serie monotono è l'elemento che rende sicuro il meccanismo. Senza di esso, un utente la cui licenza sia stata revocata potrebbe conservare una copia della lista precedente e riproporla indefinitamente al prodotto, che non avrebbe modo di accorgersi di stare guardando un documento vecchio: la firma sarebbe perfettamente valida.
La facciata pubblica
Tutti i componenti visti finora sono di basso livello. Chi integra il licensing nel prodotto vuole invece un'interfaccia ridotta all'osso, che nasconda la crittografia e gestisca da sola la persistenza.
#pragma once
#include "licensing/validator.hpp"
#include <filesystem>
#include <string_view>
namespace licensing {
struct ManagerConfig {
Bytes public_key;
std::string product_id;
std::uint32_t major_version = 1;
std::filesystem::path storage_directory;
std::string activation_endpoint;
std::int64_t grace_period_days = 7;
};
class LicenseManager {
public:
explicit LicenseManager(ManagerConfig config);
// Verifica la licenza già installata; da chiamare all'avvio
ValidationResult check();
// Installa una chiave fornita dall'utente e la valida in un solo passo
ValidationResult install(const std::string& license_key);
// Scambia un codice breve con una licenza firmata dal server
ActivationResponse activate(const std::string& activation_code);
bool has_feature(std::string_view feature) const;
std::int64_t days_remaining() const;
const std::optional<License>& license() const noexcept;
private:
ManagerConfig config_;
std::string fingerprint_;
ValidationResult current_;
};
// Restituisce la cartella di configurazione dell'utente adatta alla
// piattaforma: %APPDATA% su Windows, ~/.config o ~/Library su Unix
std::filesystem::path default_storage_directory(
const std::string& application_name);
} // namespace licensing
L'implementazione del metodo di controllo mette insieme i pezzi: legge la chiave dal disco, carica lo stato precedente, valida e aggiorna lo stato.
ValidationResult LicenseManager::check() {
const std::filesystem::path key_path =
config_.storage_directory / "license.key";
std::ifstream input(key_path, std::ios::binary);
if (!input) {
current_ = ValidationResult{};
current_.error = ValidationError::MalformedKey;
return current_;
}
std::ostringstream reader;
reader << input.rdbuf();
std::string key = reader.str();
// Gli spazi e gli a capo introdotti dai client di posta vanno rimossi
// prima della decodifica, altrimenti una chiave valida verrebbe rifiutata
std::erase_if(key, [](unsigned char c) { return std::isspace(c) != 0; });
const auto state = load_state(config_.storage_directory / "state.bin",
fingerprint_, config_.product_id);
ValidationOptions options;
options.public_key = config_.public_key;
options.product_id = config_.product_id;
options.major_version = config_.major_version;
options.machine_fingerprint = fingerprint_;
options.grace_period_days = config_.grace_period_days;
options.now = std::chrono::system_clock::now();
if (state) {
options.last_seen = state->last_seen;
}
current_ = validate(key, options);
if (current_.valid && current_.license) {
RuntimeState updated;
updated.license_id = current_.license->id;
updated.launch_count = state ? state->launch_count + 1 : 1;
// Si registra l'istante più avanzato mai osservato, non l'ultimo:
// altrimenti un singolo avvio con orologio arretrato azzererebbe
// la protezione contro la manomissione della data
updated.last_seen = std::max(
options.now, state ? state->last_seen : options.now);
save_state(config_.storage_directory / "state.bin", updated,
fingerprint_, config_.product_id);
}
return current_;
}
bool LicenseManager::has_feature(std::string_view feature) const {
if (!current_.valid || !current_.license) {
return false;
}
const auto& features = current_.license->features;
return std::find(features.begin(), features.end(), feature) !=
features.end();
}
Integrazione nel prodotto
#include "licensing/manager.hpp"
#include <iostream>
namespace {
// Chiave pubblica generata con keygen_cli e incorporata nel binario
constexpr std::uint8_t kPublicKey[32] = {
0x3d, 0x40, 0x17, 0xc3, 0xe8, 0x43, 0x89, 0x5a,
0x0d, 0x3c, 0xa9, 0x1b, 0x21, 0x50, 0xa4, 0x93,
0x86, 0x33, 0x53, 0xbb, 0x84, 0x0c, 0x63, 0x0d,
0x2c, 0xb4, 0x69, 0x9b, 0x5f, 0x2b, 0x1b, 0x0a,
};
} // namespace
int main() {
using namespace licensing;
ManagerConfig config;
config.public_key = Bytes(std::begin(kPublicKey), std::end(kPublicKey));
config.product_id = "acme-editor";
config.major_version = 4;
config.storage_directory = default_storage_directory("AcmeEditor");
config.activation_endpoint = "https://api.acme.example/v1/activate";
LicenseManager manager(std::move(config));
const ValidationResult result = manager.check();
if (!result.valid) {
std::cerr << "License problem: " << describe(result.error) << "\n";
run_in_restricted_mode();
return 0;
}
if (result.in_grace_period) {
std::cout << "The license expired; renew it within the grace period.\n";
} else if (result.days_remaining >= 0 && result.days_remaining < 15) {
std::cout << "The license expires in " << result.days_remaining
<< " days.\n";
}
if (manager.has_feature("export-pdf")) {
enable_pdf_export();
}
return run_application();
}
Si noti che una licenza non valida non termina il programma: attiva una modalità ridotta. È una scelta di prodotto più che tecnica, ma quasi sempre la migliore. Un utente che si trova davanti a un'applicazione che rifiuta di aprirsi dopo una scadenza non capisce cosa sia successo e apre un ticket; un utente che vede i propri documenti in sola lettura con un messaggio chiaro capisce e rinnova.
Build con CMake
cmake_minimum_required(VERSION 3.20)
project(licensing LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_package(OpenSSL REQUIRED)
find_package(nlohmann_json 3.10 REQUIRED)
find_package(CURL REQUIRED)
add_library(licensing STATIC
src/base64.cpp
src/crypto.cpp
src/fingerprint.cpp
src/license.cpp
src/validator.cpp
src/activation.cpp
src/state.cpp
src/manager.cpp
)
target_include_directories(licensing PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
target_link_libraries(licensing
PUBLIC OpenSSL::Crypto nlohmann_json::nlohmann_json
PRIVATE CURL::libcurl
)
if(MSVC)
target_compile_options(licensing PRIVATE /W4 /permissive-)
else()
target_compile_options(licensing PRIVATE -Wall -Wextra -Wpedantic)
endif()
add_executable(keygen_cli tools/keygen_cli.cpp)
target_link_libraries(keygen_cli PRIVATE licensing)
enable_testing()
add_executable(test_license tests/test_license.cpp)
target_link_libraries(test_license PRIVATE licensing)
add_test(NAME license_suite COMMAND test_license)
Test
La logica di licensing è un candidato ideale per i test automatici: è pura, deterministica e i suoi casi limite sono esattamente quelli che in produzione generano ticket. Poiché validate riceve l'istante corrente come parametro, si possono simulare scadenze e manomissioni senza toccare l'orologio.
#include "licensing/crypto.hpp"
#include "licensing/validator.hpp"
#include <cassert>
#include <iostream>
using namespace licensing;
namespace {
License make_license(std::int64_t days_to_expiry) {
License license;
license.id = "11111111-2222-3333-4444-555555555555";
license.product_id = "acme-editor";
license.type = LicenseType::Subscription;
license.issued_at = std::chrono::system_clock::now();
license.expires_at =
license.issued_at + std::chrono::hours{24 * days_to_expiry};
license.features = {"export-pdf", "cloud-sync"};
return license;
}
ValidationOptions make_options(const KeyPair& keys) {
ValidationOptions options;
options.public_key = keys.public_key;
options.product_id = "acme-editor";
options.now = std::chrono::system_clock::now();
return options;
}
void test_valid_license_is_accepted(const KeyPair& keys) {
const std::string key = issue_license(make_license(30), keys.private_key);
const ValidationResult result = validate(key, make_options(keys));
assert(result.valid);
assert(result.days_remaining >= 29);
}
void test_tampered_payload_is_rejected(const KeyPair& keys) {
std::string key = issue_license(make_license(30), keys.private_key);
// Si altera un carattere del payload: la firma non deve più corrispondere
key[10] = (key[10] == 'A') ? 'B' : 'A';
const ValidationResult result = validate(key, make_options(keys));
assert(!result.valid);
assert(result.error == ValidationError::InvalidSignature);
}
void test_foreign_key_is_rejected(const KeyPair& keys) {
const KeyPair attacker = generate_keypair();
// Licenza firmata con la chiave privata di un terzo: è il caso del keygen
const std::string key =
issue_license(make_license(30), attacker.private_key);
assert(validate(key, make_options(keys)).error ==
ValidationError::InvalidSignature);
}
void test_grace_period(const KeyPair& keys) {
const std::string key = issue_license(make_license(-3), keys.private_key);
ValidationOptions options = make_options(keys);
options.grace_period_days = 7;
const ValidationResult result = validate(key, options);
assert(result.valid);
assert(result.in_grace_period);
// Oltre il periodo di grazia la stessa licenza va rifiutata
options.grace_period_days = 1;
assert(validate(key, options).error == ValidationError::Expired);
}
void test_clock_rollback(const KeyPair& keys) {
const std::string key = issue_license(make_license(30), keys.private_key);
ValidationOptions options = make_options(keys);
// Ultimo avvio registrato nel futuro rispetto a "adesso": l'orologio
// è stato spostato indietro di trenta giorni
options.last_seen = options.now + std::chrono::hours{24 * 30};
assert(validate(key, options).error == ValidationError::ClockTampered);
}
} // namespace
int main() {
const KeyPair keys = generate_keypair();
test_valid_license_is_accepted(keys);
test_tampered_payload_is_rejected(keys);
test_foreign_key_is_rejected(keys);
test_grace_period(keys);
test_clock_rollback(keys);
std::cout << "all licensing tests passed\n";
return 0;
}
Hardening e limiti reali
Il sistema costruito finora è crittograficamente solido: nessuno può produrre una licenza valida senza la chiave privata. Resta il punto debole strutturale di ogni protezione locale, ossia che la decisione finale è un salto condizionato dentro un binario che l'attaccante possiede. Alcune tecniche alzano il costo di questa manipolazione senza pretendere di impedirla.
Distribuire i punti di verifica. Un'unica funzione is_licensed() chiamata all'avvio è un singolo punto da neutralizzare. Verificare la licenza anche all'interno delle funzionalità premium, in momenti diversi e con codice non identico, moltiplica il lavoro necessario.
Legare i dati al risultato della verifica. La contromisura più efficace non è controllare di più ma fare in modo che il controllo non sia scavalcabile: se una chiave di cifratura del formato di file proprietario deriva da un campo della licenza, rimuovere il controllo non serve a nulla, perché senza licenza valida quel dato non esiste proprio.
Non lasciare stringhe parlanti. Un messaggio come "License expired" in chiaro nel binario è il primo punto di ingresso di chi cerca il codice di verifica. Cifrare le stringhe sensibili con un semplice XOR risolto a compile time non ferma nessuno a lungo, ma elimina la ricerca banale.
// Offusca una stringa a compile time: nel binario finiscono i byte cifrati,
// il testo in chiaro esiste solo per il tempo dell'utilizzo
template <std::size_t N>
class ObfuscatedString {
public:
consteval explicit ObfuscatedString(const char (&text)[N]) {
for (std::size_t i = 0; i < N; ++i) {
data_[i] = static_cast<char>(text[i] ^ static_cast<char>(kKey + i));
}
}
std::string reveal() const {
std::string result(N - 1, '\0');
for (std::size_t i = 0; i < N - 1; ++i) {
result[i] = static_cast<char>(data_[i] ^ static_cast<char>(kKey + i));
}
return result;
}
private:
static constexpr char kKey = 0x5A;
char data_[N]{};
};
Va detto con onestà: queste misure sono deterrenti, non barriere. Un professionista con un debugger arriva comunque al punto di decisione. La letteratura e l'esperienza commerciale concordano su un punto controintuitivo, e cioè che oltre una certa soglia investire in protezione rende meno che investire in valore erogato: aggiornamenti frequenti, servizi lato server realmente utili, contenuti che vivono nel cloud e supporto tecnico sono le uniche cose che una copia non autorizzata non può replicare. Una protezione eccessivamente aggressiva, inoltre, produce falsi positivi che colpiscono solo i clienti paganti, perché chi usa una versione manomessa quei controlli non li esegue affatto.
Aspetti legali e privacy
Il fingerprint e le chiamate di attivazione trattano dati che, nel contesto europeo, ricadono nel GDPR. Alcune regole pratiche riducono drasticamente l'esposizione. Trasmettere sempre un digest e mai l'identificativo grezzo della macchina, come fatto in questo articolo, evita di trattare un identificatore stabile riutilizzabile fra servizi. Dichiarare nell'informativa quali dati vengono inviati durante l'attivazione e con quale frequenza. Prevedere una procedura di disattivazione che liberi la postazione, altrimenti un cliente che cambia computer resta bloccato e il vincolo tecnico diventa un problema contrattuale. Conservare i log di attivazione per un periodo definito e non indefinito.
Conclusioni
Il sistema descritto copre l'intero ciclo di vita di una licenza: emissione firmata con Ed25519, distribuzione come stringa testuale autocontenuta, validazione offline con controllo di scadenza, versione, funzionalità e vincolo di macchina, periodo di grazia, rilevamento della manomissione dell'orologio, stato persistente autenticato con HMAC, attivazione online e revoca con protezione dal rollback.
Le decisioni progettuali che contano davvero sono poche e vale la pena ribadirle. La crittografia asimmetrica è obbligatoria, perché con un segreto condiviso il generatore di licenze è già nelle mani dell'utente. La firma va verificata sui byte ricevuti, prima di ogni interpretazione. Il codice deve essere generoso nei casi ambigui e severo solo su quelli inequivocabili, dato che il costo di un falso positivo ricade interamente su un cliente pagante. Infine, ogni parametro temporale deve essere iniettabile, altrimenti la logica più delicata del sistema resta priva di test.
Da qui l'estensione naturale è il lato server: un'API di emissione integrata con il gestionale, la gestione dei rinnovi automatici e una dashboard per il conteggio delle postazioni attive. La libreria C++ presentata è già pronta a interoperare con qualunque implementazione di quel lato, perché il formato della chiave è documentato e l'algoritmo di firma è uno standard consolidato.