Creare un sistema di gestione delle licenze software con Node.js
Ogni prodotto software distribuito fuori dal modello puramente SaaS prima o poi si scontra con lo stesso problema: come stabilire se una determinata copia in esecuzione su una determinata macchina ha il diritto di funzionare, e con quali funzionalità abilitate. La risposta ingenua è un controllo online a ogni avvio, ma questa soluzione rende il prodotto inutilizzabile in assenza di connettività e trasforma il server di licenze in un singolo punto di guasto che blocca l'intera base installata.
In questo articolo costruiamo, passo dopo passo, un sistema di gestione delle licenze completo in Node.js. Il sistema emette chiavi di licenza leggibili, le associa a un cliente e a un prodotto, gestisce l'attivazione su un numero limitato di postazioni, rilascia token firmati crittograficamente che il client può verificare offline, e prevede revoca, sospensione, heartbeat e rotazione delle chiavi di firma.
Cosa deve fare il sistema
Prima di scrivere codice conviene fissare i requisiti funzionali, perché ognuno di essi ha conseguenze precise sul modello dati e sul protocollo.
- Emissione: generare una chiave di licenza univoca, associata a un prodotto, a un cliente, a un piano commerciale e a un insieme di funzionalità.
- Attivazione: legare una licenza a una macchina specifica tramite un fingerprint, rispettando il numero massimo di postazioni previste dal contratto.
- Verifica offline: permettere all'applicazione di validare il proprio diritto di esecuzione senza rete, per un periodo di grazia configurabile.
- Revoca: invalidare una licenza in caso di rimborso, frode o cessazione del contratto, con propagazione entro un tempo massimo noto.
- Disattivazione: liberare una postazione quando il cliente cambia computer, senza costringerlo ad aprire un ticket.
- Osservabilità: registrare gli eventi rilevanti per poter rispondere a domande come «quando è stata attivata questa licenza e su quante macchine?».
Modello di minaccia e scelte crittografiche
È importante essere onesti su cosa un sistema di licenze può e non può fare. Il codice che gira sul computer del cliente è sotto il controllo del cliente: chiunque abbia le competenze e la motivazione sufficienti può disassemblare il binario e disattivare il controllo. Lo scopo di un sistema di licenze non è rendere impossibile la pirateria, ma rendere banale l'uso corretto e non banale l'uso scorretto, oltre a fornire all'azienda una fonte di verità su chi ha comprato cosa.
Le minacce che invece possiamo e dobbiamo neutralizzare del tutto sono altre:
- Falsificazione di licenze: un utente non deve poter generare da sé un token valido. Questo si ottiene con la firma asimmetrica: il server firma con la chiave privata, il client verifica con quella pubblica. Anche estraendo la chiave pubblica dal binario non si ottiene la capacità di emettere licenze.
- Manomissione del payload: modificare il piano da
standardaenterprisein un token firmato deve invalidare la firma. - Riuso illimitato di una chiave: la stessa chiave non deve poter essere attivata su un numero arbitrario di macchine.
Per la firma useremo Ed25519, disponibile nativamente nel modulo node:crypto a partire da Node.js 12. Rispetto a RSA produce firme molto più corte (64 byte), non richiede la scelta di un algoritmo di hash e di un padding, e ha una verifica rapida anche su hardware modesto. Le chiavi simmetriche (HMAC) sono da escludere per la verifica lato client, perché richiederebbero di distribuire ai client lo stesso segreto usato per firmare.
Architettura generale
Il sistema si compone di tre parti distinte:
- Un server di licenze: un servizio HTTP con un database relazionale, che espone API amministrative protette (emissione, revoca, consultazione) e API pubbliche destinate ai client installati (attivazione, heartbeat, disattivazione, lista di revoca).
- Un SDK client: un piccolo modulo, incorporato nell'applicazione distribuita, che calcola il fingerprint della macchina, contatta il server quando può e verifica offline il token firmato quando non può.
- Un artefatto crittografico: la coppia di chiavi Ed25519. La privata resta sul server, la pubblica viene incorporata nel binario distribuito.
Il flusso nominale è il seguente: il cliente acquista, il sistema di e-commerce chiama l'API amministrativa che emette una licenza e restituisce la chiave; il cliente inserisce la chiave nell'applicazione; l'applicazione chiama l'endpoint di attivazione inviando chiave e fingerprint; il server verifica i vincoli e restituisce un token firmato con scadenza breve; l'applicazione memorizza il token e lo verifica localmente a ogni avvio, rinnovandolo periodicamente quando c'è rete.
Struttura del progetto
license-server/
├── package.json
├── .env
├── keys/
│ ├── signing-private.pem
│ └── signing-public.pem
├── scripts/
│ └── generate-keys.js
├── src/
│ ├── config.js
│ ├── errors.js
│ ├── server.js
│ ├── db/
│ │ ├── index.js
│ │ └── schema.sql
│ ├── licensing/
│ │ ├── key-format.js
│ │ ├── fingerprint.js
│ │ └── token.js
│ ├── services/
│ │ ├── license-service.js
│ │ ├── activation-service.js
│ │ └── revocation-service.js
│ ├── routes/
│ │ ├── admin.js
│ │ └── public.js
│ └── client/
│ ├── machine.js
│ └── check-license.js
└── test/
├── key-format.test.js
└── token.test.js
Le dipendenze sono volutamente poche. Usiamo Fastify come framework HTTP, better-sqlite3 come database (sincrono, senza processo separato, più che sufficiente per centinaia di migliaia di licenze; la migrazione a PostgreSQL è discussa in fondo), zod per la validazione degli input e @fastify/rate-limit per la protezione dagli abusi.
{
"name": "license-server",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node src/server.js",
"keys:generate": "node scripts/generate-keys.js",
"test": "node --test test/"
},
"dependencies": {
"@fastify/rate-limit": "^10.1.1",
"better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5",
"fastify": "^5.0.0",
"zod": "^3.23.8"
}
}
Configurazione
La configurazione viene letta una sola volta all'avvio e fallisce immediatamente se manca un valore obbligatorio: è preferibile un processo che non parte a un processo che parte e firma token con una chiave sbagliata.
# .env
PORT=3000
HOST=127.0.0.1
DATABASE_FILE=./data/licenses.db
KEY_CHECKSUM_SECRET=cambiami-con-32-byte-casuali
SIGNING_PRIVATE_KEY_FILE=./keys/signing-private.pem
SIGNING_PUBLIC_KEY_FILE=./keys/signing-public.pem
ADMIN_API_KEY=cambiami-con-una-chiave-lunga
TOKEN_TTL_SECONDS=1209600
// src/config.js
import 'dotenv/config';
import { readFileSync } from 'node:fs';
// Fallisce subito all'avvio se una variabile obbligatoria non è impostata.
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const config = {
port: Number(process.env.PORT ?? 3000),
host: process.env.HOST ?? '127.0.0.1',
databaseFile: process.env.DATABASE_FILE ?? './data/licenses.db',
// Segreto usato solo per il checksum della chiave leggibile:
// serve a scartare i codici digitati male, non protegge dalla falsificazione.
keyChecksumSecret: requireEnv('KEY_CHECKSUM_SECRET'),
// La chiave privata non lascia mai il server: è l'unico segreto davvero critico.
signingPrivateKey: readFileSync(requireEnv('SIGNING_PRIVATE_KEY_FILE'), 'utf8'),
signingPublicKey: readFileSync(requireEnv('SIGNING_PUBLIC_KEY_FILE'), 'utf8'),
adminApiKey: requireEnv('ADMIN_API_KEY'),
// Durata del token di attivazione: definisce anche la latenza massima
// con cui una revoca raggiunge un client che resta offline.
tokenTtlSeconds: Number(process.env.TOKEN_TTL_SECONDS ?? 60 * 60 * 24 * 14)
};
Generazione della coppia di chiavi
La coppia Ed25519 si genera una sola volta, prima del primo avvio. Il file della chiave privata va scritto con permessi restrittivi e non deve mai finire nel repository.
// scripts/generate-keys.js
import { generateKeyPairSync } from 'node:crypto';
import { mkdirSync, writeFileSync } from 'node:fs';
const outputDir = process.argv[2] ?? './keys';
mkdirSync(outputDir, { recursive: true });
// Ed25519 non richiede parametri: nessuna dimensione da scegliere, nessun padding.
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
writeFileSync(
`${outputDir}/signing-private.pem`,
privateKey.export({ type: 'pkcs8', format: 'pem' }),
// Lettura e scrittura solo per il proprietario del processo.
{ mode: 0o600 }
);
writeFileSync(
`${outputDir}/signing-public.pem`,
publicKey.export({ type: 'spki', format: 'pem' })
);
console.log(`Key pair written to ${outputDir}`);
Il formato della chiave di licenza
La chiave di licenza è ciò che il cliente riceve via email e digita nell'applicazione. Deve quindi essere leggibile, trascrivibile senza ambiguità e verificabile localmente per intercettare gli errori di battitura prima di ogni chiamata di rete.
Usiamo l'alfabeto Crockford Base32, che esclude le lettere I, L, O e U: le prime tre perché confondibili con 1 e 0, l'ultima per evitare la formazione accidentale di parole sconvenienti. Il formato risultante è PRODOTTO-XXXXX-XXXXX-XXXXX-XXXXX, dove l'ultimo carattere del corpo è un checksum.
Va chiarito un punto: il checksum non è un meccanismo di sicurezza. È un filtro anti-errore. La sicurezza è tutta nel fatto che la chiave è casuale (96 bit di entropia) e deve esistere nel database del server.
// src/licensing/key-format.js
import { createHmac, randomBytes } from 'node:crypto';
// Alfabeto Crockford Base32: niente I, L, O, U per evitare ambiguità di lettura.
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const GROUP_SIZE = 5;
const BODY_LENGTH = 19;
const GROUP_PATTERN = new RegExp(`.{1,${GROUP_SIZE}}`, 'g');
// Codifica generica di un buffer in Base32 con l'alfabeto scelto.
function encodeBase32(buffer) {
let value = 0;
let bits = 0;
let output = '';
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
// Il checksum dipende anche dal codice prodotto: una chiave valida per un
// prodotto viene scartata se digitata sotto un prodotto diverso.
function checksumChar(productCode, body, secret) {
const digest = createHmac('sha256', secret)
.update(`${productCode}:${body}`)
.digest();
return ALPHABET[digest[0] % ALPHABET.length];
}
// Riporta i caratteri ambigui alla forma canonica dell'alfabeto.
function normalizeBody(raw) {
return raw
.toUpperCase()
.replace(/[\s-]/g, '')
.replace(/O/g, '0')
.replace(/[IL]/g, '1')
.replace(/U/g, 'V');
}
export function formatLicenseKey(productCode, payload) {
const groups = payload.match(GROUP_PATTERN) ?? [];
return [productCode, ...groups].join('-');
}
export function generateLicenseKey(productCode, secret) {
// 12 byte casuali producono 20 caratteri Base32: ne usiamo 19 come corpo
// e riserviamo l'ultima posizione al checksum.
const body = encodeBase32(randomBytes(12)).slice(0, BODY_LENGTH);
const payload = body + checksumChar(productCode, body, secret);
return formatLicenseKey(productCode, payload);
}
export function parseLicenseKey(input, secret) {
const parts = String(input ?? '').trim().toUpperCase().split('-');
if (parts.length < 2) {
return null;
}
const productCode = parts[0].replace(/[^A-Z0-9]/g, '');
const payload = normalizeBody(parts.slice(1).join(''));
if (payload.length !== BODY_LENGTH + 1) {
return null;
}
const body = payload.slice(0, BODY_LENGTH);
if (payload[BODY_LENGTH] !== checksumChar(productCode, body, secret)) {
return null;
}
// La forma canonica è quella che verrà cercata nel database.
return { productCode, payload, canonical: formatLicenseKey(productCode, payload) };
}
Il vantaggio pratico di questo formato è che il client può rifiutare una chiave malformata senza consumare una richiesta di rete, e il messaggio di errore mostrato all'utente («la chiave contiene un errore di battitura») è molto più utile di un generico «licenza non valida».
Il fingerprint della macchina
Il fingerprint identifica la postazione su cui la licenza viene attivata. La scelta dei componenti è un compromesso delicato: un fingerprint troppo specifico cambia a ogni aggiornamento hardware o a ogni rinomina dell'host, generando falsi negativi e ticket di supporto; uno troppo generico rende banale l'attivazione su macchine diverse.
Una regola pratica utile è includere solo elementi che cambiano quando cambia davvero il computer, e mai elementi volatili come l'indirizzo IP o la lista completa delle interfacce di rete (che varia con VPN e adattatori virtuali). Il fingerprint viene poi normalizzato lato server con SHA-256, così il database non contiene mai i dati grezzi della macchina del cliente.
// src/licensing/fingerprint.js
import { createHash } from 'node:crypto';
// Usata sia dal server sia dal client: il valore memorizzato e quello
// contenuto nel token devono essere calcolati esattamente allo stesso modo.
export function hashFingerprint(raw) {
return createHash('sha256').update(String(raw)).digest('hex');
}
// src/client/machine.js
import { arch, cpus, hostname, platform, totalmem } from 'node:os';
import { hashFingerprint } from '../licensing/fingerprint.js';
// Componenti stabili nel tempo: evitiamo indirizzi IP, MAC di interfacce
// virtuali e qualsiasi valore che cambi al riavvio.
export function computeMachineFingerprint(installationId = '') {
const cpuModel = cpus()[0]?.model ?? 'unknown-cpu';
const memoryGb = Math.round(totalmem() / (1024 ** 3));
const parts = [
platform(),
arch(),
hostname(),
cpuModel,
String(memoryGb),
// Identificativo generato alla prima installazione e salvato su disco:
// rende il fingerprint stabile anche se l'hardware viene aggiornato.
installationId
];
return parts.join('|');
}
export function machineFingerprintHash(installationId = '') {
return hashFingerprint(computeMachineFingerprint(installationId));
}
Il token di licenza firmato
Il token è l'artefatto che permette la verifica offline. La struttura è quella di un JWT semplificato: payload codificato in Base64 URL-safe, un punto, firma codificata allo stesso modo. Non usiamo un header separato con l'algoritmo, e non è una svista: accettare l'algoritmo dichiarato dal token è la causa storica di un'intera famiglia di vulnerabilità nelle implementazioni JWT. Qui l'algoritmo è fissato dal codice.
// src/licensing/token.js
import { createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
const TOKEN_VERSION = 1;
function toBase64Url(value) {
return Buffer.from(value).toString('base64url');
}
export function signLicenseToken(payload, privateKeyPem) {
const key = createPrivateKey(privateKeyPem);
const body = toBase64Url(JSON.stringify({ v: TOKEN_VERSION, ...payload }));
// Con Ed25519 l'algoritmo di digest è già definito dallo schema:
// il primo argomento deve essere null.
const signature = sign(null, Buffer.from(body), key);
return `${body}.${toBase64Url(signature)}`;
}
export function verifyLicenseToken(token, publicKeyPem) {
const [body, signature] = String(token ?? '').split('.');
if (!body || !signature) {
return { valid: false, reason: 'MALFORMED_TOKEN' };
}
let signatureIsValid = false;
try {
signatureIsValid = verify(
null,
Buffer.from(body),
createPublicKey(publicKeyPem),
Buffer.from(signature, 'base64url')
);
} catch {
// Una firma di lunghezza errata fa fallire la verifica a livello di libreria.
return { valid: false, reason: 'BAD_SIGNATURE' };
}
// La firma va verificata prima di interpretare il contenuto:
// il payload di un token non firmato non è dato attendibile.
if (!signatureIsValid) {
return { valid: false, reason: 'BAD_SIGNATURE' };
}
let payload;
try {
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
} catch {
return { valid: false, reason: 'MALFORMED_PAYLOAD' };
}
if (payload.v !== TOKEN_VERSION) {
return { valid: false, reason: 'UNSUPPORTED_VERSION' };
}
return { valid: true, payload };
}
Il payload che useremo contiene campi brevi, perché il token viene salvato su disco e talvolta mostrato all'utente:
{
"v": 1,
"lic": "ACME-3K7QW-9ZB2M-XR4TD-P8N5H",
"prod": "ACME",
"plan": "professional",
"feat": ["export-pdf", "api-access"],
"sub": "cus_00193",
"aid": "f0e0b7a6-4b6a-4f52-9a1f-2c0f2e2a6f11",
"fpr": "9f2c...b41e",
"iat": 1769212800,
"exp": 1770422400,
"lexp": 1798761600
}
Notare la distinzione tra exp (scadenza del token, breve) e lexp (scadenza della licenza, contrattuale). La prima determina la frequenza con cui il client deve ricontattare il server e quindi la latenza massima di propagazione di una revoca; la seconda è ciò che il cliente ha comprato. Nel payload mettiamo un riferimento cliente opaco (sub) e mai l'indirizzo email: il token finisce su disco in chiaro sulla macchina dell'utente.
Lo schema del database
Tre entità principali: prodotti, licenze e attivazioni. Le date sono salvate come stringhe ISO 8601 in UTC, in modo che Date.parse() le interpreti correttamente senza conversioni manuali.
-- src/db/schema.sql
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_key TEXT NOT NULL UNIQUE,
product_id INTEGER NOT NULL REFERENCES products(id),
customer_email TEXT NOT NULL,
customer_ref TEXT,
plan TEXT NOT NULL DEFAULT 'standard',
-- Array JSON di funzionalità: evita una tabella di join per un dato
-- che viene sempre letto e scritto per intero.
features TEXT NOT NULL DEFAULT '[]',
max_activations INTEGER NOT NULL DEFAULT 1,
-- Valori ammessi: active, suspended, revoked.
status TEXT NOT NULL DEFAULT 'active',
issued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
-- NULL significa licenza perpetua.
expires_at TEXT,
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_licenses_customer ON licenses(customer_email);
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status);
CREATE TABLE IF NOT EXISTS activations (
id TEXT PRIMARY KEY,
license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
-- Hash SHA-256: il server non conserva mai i dati grezzi della macchina.
fingerprint TEXT NOT NULL,
machine_name TEXT,
app_version TEXT,
status TEXT NOT NULL DEFAULT 'active',
activated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
deactivated_at TEXT,
-- Vincolo chiave: una macchina occupa al massimo una postazione per licenza.
UNIQUE (license_id, fingerprint)
);
CREATE INDEX IF NOT EXISTS idx_activations_license ON activations(license_id, status);
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
event TEXT NOT NULL,
detail TEXT,
ip_address TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_license ON audit_events(license_id, created_at);
Il vincolo UNIQUE (license_id, fingerprint) merita attenzione: è ciò che rende idempotente la riattivazione. Se un utente reinstalla l'applicazione sulla stessa macchina, il fingerprint coincide e il server restituisce l'attivazione esistente invece di consumare una nuova postazione.
// src/db/index.js
import Database from 'better-sqlite3';
import { mkdirSync, readFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from '../config.js';
mkdirSync(dirname(config.databaseFile), { recursive: true });
export const db = new Database(config.databaseFile);
// WAL: letture concorrenti mentre una scrittura è in corso.
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Attende invece di fallire subito quando il file è temporaneamente bloccato.
db.pragma('busy_timeout = 5000');
const schemaPath = fileURLToPath(new URL('./schema.sql', import.meta.url));
db.exec(readFileSync(schemaPath, 'utf8'));
Gli errori applicativi
Un sistema di licenze restituisce molti errori attesi, e ognuno deve essere distinguibile dal client per mostrare un messaggio sensato. Definiamo quindi una gerarchia di errori che porta con sé il codice HTTP e un codice applicativo stabile.
// src/errors.js
export class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
// Codice stabile: il client vi si affida, il messaggio può cambiare.
this.code = code;
}
}
export class ValidationError extends AppError {
constructor(message, code = 'VALIDATION_ERROR') {
super(message, 400, code);
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized', code = 'UNAUTHORIZED') {
super(message, 401, code);
}
}
export class ForbiddenError extends AppError {
constructor(message, code = 'FORBIDDEN') {
super(message, 403, code);
}
}
export class NotFoundError extends AppError {
constructor(message, code = 'NOT_FOUND') {
super(message, 404, code);
}
}
export class ConflictError extends AppError {
constructor(message, code = 'CONFLICT') {
super(message, 409, code);
}
}
Il servizio di emissione delle licenze
L'emissione è l'operazione amministrativa fondamentale: crea la riga nella tabella licenses e restituisce la chiave da consegnare al cliente. L'unico accorgimento non ovvio è la gestione della collisione sulla chiave generata: con 96 bit di entropia è un evento remoto, ma il ciclo di ritentativi costa poche righe e rimuove del tutto la classe di guasto.
// src/services/license-service.js
import { db } from '../db/index.js';
import { config } from '../config.js';
import { generateLicenseKey, parseLicenseKey } from '../licensing/key-format.js';
import { ConflictError, NotFoundError, ValidationError } from '../errors.js';
const selectProduct = db.prepare('SELECT * FROM products WHERE code = ?');
const selectLicenseByKey = db.prepare(`
SELECT l.*, p.code AS product_code, p.name AS product_name
FROM licenses l
JOIN products p ON p.id = l.product_id
WHERE l.license_key = ?
`);
const insertLicense = db.prepare(`
INSERT INTO licenses (
license_key, product_id, customer_email, customer_ref,
plan, features, max_activations, expires_at, notes
) VALUES (
@license_key, @product_id, @customer_email, @customer_ref,
@plan, @features, @max_activations, @expires_at, @notes
)
`);
const updateStatus = db.prepare(`
UPDATE licenses
SET status = @status,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = @id
`);
const revokeActivations = db.prepare(`
UPDATE activations
SET status = 'revoked',
deactivated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE license_id = ? AND status = 'active'
`);
const insertAuditEvent = db.prepare(`
INSERT INTO audit_events (license_id, event, detail, ip_address)
VALUES (@license_id, @event, @detail, @ip_address)
`);
export function recordEvent({ licenseId = null, event, detail = null, ip = null }) {
insertAuditEvent.run({
license_id: licenseId,
event,
detail: detail ? JSON.stringify(detail) : null,
ip_address: ip
});
}
export function getLicenseByKey(licenseKey) {
const parsed = parseLicenseKey(licenseKey, config.keyChecksumSecret);
if (!parsed) {
return null;
}
return selectLicenseByKey.get(parsed.canonical) ?? null;
}
export function issueLicense(input) {
const product = selectProduct.get(input.productCode);
if (!product) {
throw new NotFoundError('Unknown product code', 'PRODUCT_NOT_FOUND');
}
if (input.expiresAt && Number.isNaN(Date.parse(input.expiresAt))) {
throw new ValidationError('Invalid expiration date', 'INVALID_EXPIRATION');
}
// Ritenta in caso di collisione sul vincolo di unicità della chiave.
for (let attempt = 0; attempt < 5; attempt += 1) {
const licenseKey = generateLicenseKey(product.code, config.keyChecksumSecret);
try {
insertLicense.run({
license_key: licenseKey,
product_id: product.id,
customer_email: input.customerEmail,
customer_ref: input.customerRef ?? null,
plan: input.plan ?? 'standard',
features: JSON.stringify(input.features ?? []),
max_activations: input.maxActivations ?? 1,
expires_at: input.expiresAt ?? null,
notes: input.notes ?? null
});
const license = selectLicenseByKey.get(licenseKey);
recordEvent({
licenseId: license.id,
event: 'license.issued',
detail: { plan: license.plan, seats: license.max_activations }
});
return license;
} catch (error) {
const isCollision = String(error.message).includes('UNIQUE constraint failed');
if (!isCollision) {
throw error;
}
}
}
throw new ConflictError('Unable to generate a unique license key', 'KEY_GENERATION_FAILED');
}
// Revoca definitiva: la licenza non è più utilizzabile e tutte le postazioni
// attive vengono liberate nello stesso passaggio transazionale.
export const revokeLicense = db.transaction((licenseKey, reason) => {
const license = getLicenseByKey(licenseKey);
if (!license) {
throw new NotFoundError('License not found', 'LICENSE_NOT_FOUND');
}
updateStatus.run({ id: license.id, status: 'revoked' });
revokeActivations.run(license.id);
recordEvent({ licenseId: license.id, event: 'license.revoked', detail: { reason } });
return selectLicenseByKey.get(license.license_key);
});
// Sospensione reversibile, tipicamente per un pagamento non riuscito.
export function setLicenseStatus(licenseKey, status) {
if (!['active', 'suspended'].includes(status)) {
throw new ValidationError('Unsupported status transition', 'INVALID_STATUS');
}
const license = getLicenseByKey(licenseKey);
if (!license) {
throw new NotFoundError('License not found', 'LICENSE_NOT_FOUND');
}
if (license.status === 'revoked') {
throw new ConflictError('A revoked license cannot be reactivated', 'LICENSE_REVOKED');
}
updateStatus.run({ id: license.id, status });
recordEvent({ licenseId: license.id, event: `license.${status}` });
return selectLicenseByKey.get(license.license_key);
}
Il servizio di attivazione
Questo è il cuore del sistema. La funzione di attivazione deve essere transazionale: il controllo sul numero di postazioni occupate e l'inserimento della nuova attivazione devono avvenire nella stessa transazione, altrimenti due richieste simultanee possono entrambe leggere «una postazione libera» e occuparla entrambe. Con better-sqlite3 il metodo transaction() avvolge la funzione; la variante immediate acquisisce subito il lock di scrittura, che è esattamente ciò che serve quando la transazione inizia con una lettura da cui dipende una scrittura.
// src/services/activation-service.js
import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js';
import { config } from '../config.js';
import { parseLicenseKey } from '../licensing/key-format.js';
import { hashFingerprint } from '../licensing/fingerprint.js';
import { signLicenseToken } from '../licensing/token.js';
import { getLicenseByKey, recordEvent } from './license-service.js';
import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '../errors.js';
const selectActivation = db.prepare(
'SELECT * FROM activations WHERE license_id = ? AND fingerprint = ?'
);
const selectActivationById = db.prepare('SELECT * FROM activations WHERE id = ?');
const countActiveSeats = db.prepare(
"SELECT COUNT(*) AS total FROM activations WHERE license_id = ? AND status = 'active'"
);
const insertActivation = db.prepare(`
INSERT INTO activations (id, license_id, fingerprint, machine_name, app_version)
VALUES (@id, @license_id, @fingerprint, @machine_name, @app_version)
`);
const reactivateSeat = db.prepare(`
UPDATE activations
SET status = 'active',
deactivated_at = NULL,
last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
machine_name = @machine_name,
app_version = @app_version
WHERE id = @id
`);
const touchActivation = db.prepare(`
UPDATE activations
SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
app_version = COALESCE(@app_version, app_version)
WHERE id = @id
`);
const releaseSeat = db.prepare(`
UPDATE activations
SET status = 'released',
deactivated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = @id
`);
// Verifica che la licenza sia in uno stato che consente l'uso del prodotto.
function assertLicenseUsable(license) {
if (license.status === 'revoked') {
throw new ForbiddenError('License has been revoked', 'LICENSE_REVOKED');
}
if (license.status === 'suspended') {
throw new ForbiddenError('License is suspended', 'LICENSE_SUSPENDED');
}
if (license.expires_at && Date.parse(license.expires_at) <= Date.now()) {
throw new ForbiddenError('License has expired', 'LICENSE_EXPIRED');
}
}
function normalizeFingerprint(raw) {
const value = String(raw ?? '').trim();
if (value.length < 8) {
throw new ValidationError('Fingerprint is too short', 'INVALID_FINGERPRINT');
}
return hashFingerprint(value);
}
function buildToken(license, activation) {
const issuedAt = Math.floor(Date.now() / 1000);
const licenseExpiry = license.expires_at
? Math.floor(Date.parse(license.expires_at) / 1000)
: null;
// Il token scade sempre prima della licenza: la scadenza breve è
// ciò che permette a revoche e sospensioni di propagarsi.
const expiresAt = licenseExpiry
? Math.min(issuedAt + config.tokenTtlSeconds, licenseExpiry)
: issuedAt + config.tokenTtlSeconds;
return signLicenseToken(
{
lic: license.license_key,
prod: license.product_code,
plan: license.plan,
feat: JSON.parse(license.features),
// Riferimento opaco: nel token non finiscono dati personali.
sub: license.customer_ref ?? null,
aid: activation.id,
fpr: activation.fingerprint,
iat: issuedAt,
exp: expiresAt,
lexp: licenseExpiry
},
config.signingPrivateKey
);
}
function buildResult(license, activation) {
return {
activationId: activation.id,
licenseKey: license.license_key,
product: license.product_code,
plan: license.plan,
features: JSON.parse(license.features),
maxActivations: license.max_activations,
licenseExpiresAt: license.expires_at,
token: buildToken(license, activation)
};
}
// L'intera operazione è transazionale: il conteggio delle postazioni
// e l'inserimento devono essere atomici rispetto ad altre attivazioni.
const activateTransaction = db.transaction((license, fingerprint, meta) => {
const existing = selectActivation.get(license.id, fingerprint);
if (existing) {
if (existing.status === 'revoked') {
throw new ForbiddenError('This activation has been revoked', 'ACTIVATION_REVOKED');
}
if (existing.status === 'active') {
// Riattivazione idempotente sulla stessa macchina: nessuna postazione consumata.
touchActivation.run({ id: existing.id, app_version: meta.appVersion ?? null });
return selectActivationById.get(existing.id);
}
// La postazione era stata rilasciata: la riprendiamo se ce n'è ancora spazio.
const { total } = countActiveSeats.get(license.id);
if (total >= license.max_activations) {
throw new ConflictError('Activation limit reached', 'SEAT_LIMIT_REACHED');
}
reactivateSeat.run({
id: existing.id,
machine_name: meta.machineName ?? null,
app_version: meta.appVersion ?? null
});
return selectActivationById.get(existing.id);
}
const { total } = countActiveSeats.get(license.id);
if (total >= license.max_activations) {
throw new ConflictError('Activation limit reached', 'SEAT_LIMIT_REACHED');
}
const id = randomUUID();
insertActivation.run({
id,
license_id: license.id,
fingerprint,
machine_name: meta.machineName ?? null,
app_version: meta.appVersion ?? null
});
return selectActivationById.get(id);
});
export function activate({ licenseKey, fingerprint, machineName, appVersion, ip }) {
// Il controllo di formato precede l'accesso al database: una chiave
// digitata male non deve nemmeno generare una query.
if (!parseLicenseKey(licenseKey, config.keyChecksumSecret)) {
throw new ValidationError('Malformed license key', 'INVALID_KEY_FORMAT');
}
const license = getLicenseByKey(licenseKey);
if (!license) {
throw new NotFoundError('License not found', 'LICENSE_NOT_FOUND');
}
assertLicenseUsable(license);
const hashed = normalizeFingerprint(fingerprint);
const activation = activateTransaction.immediate(license, hashed, { machineName, appVersion });
recordEvent({
licenseId: license.id,
event: 'activation.created',
detail: { activationId: activation.id, machineName: machineName ?? null },
ip
});
return buildResult(license, activation);
}
// Rinnovo periodico del token: è anche il momento in cui il client
// scopre che la licenza è stata revocata o sospesa.
export function heartbeat({ licenseKey, activationId, appVersion, ip }) {
const license = getLicenseByKey(licenseKey);
if (!license) {
throw new NotFoundError('License not found', 'LICENSE_NOT_FOUND');
}
assertLicenseUsable(license);
const activation = selectActivationById.get(activationId);
if (!activation || activation.license_id !== license.id) {
throw new NotFoundError('Activation not found', 'ACTIVATION_NOT_FOUND');
}
if (activation.status !== 'active') {
throw new ForbiddenError('Activation is no longer active', 'ACTIVATION_INACTIVE');
}
touchActivation.run({ id: activation.id, app_version: appVersion ?? null });
recordEvent({ licenseId: license.id, event: 'activation.heartbeat', ip });
return buildResult(license, selectActivationById.get(activation.id));
}
// Disattivazione volontaria: libera la postazione per un'altra macchina.
export function deactivate({ licenseKey, activationId, ip }) {
const license = getLicenseByKey(licenseKey);
if (!license) {
throw new NotFoundError('License not found', 'LICENSE_NOT_FOUND');
}
const activation = selectActivationById.get(activationId);
if (!activation || activation.license_id !== license.id) {
throw new NotFoundError('Activation not found', 'ACTIVATION_NOT_FOUND');
}
releaseSeat.run({ id: activation.id });
recordEvent({
licenseId: license.id,
event: 'activation.released',
detail: { activationId: activation.id },
ip
});
return { released: true, activationId: activation.id };
}
Una scelta di progetto da sottolineare: la disattivazione porta lo stato a released e non elimina la riga. Conservare lo storico permette di rispondere a domande di supporto («quante volte questo cliente ha cambiato macchina nell'ultimo mese?») e di individuare pattern anomali, come una licenza singola che ruota tra decine di fingerprint diversi.
La lista di revoca
Il token con scadenza breve copre la maggior parte dei casi, ma un client che resta offline continua a funzionare fino alla scadenza. Per accelerare la propagazione pubblichiamo una lista di revoca firmata, che il client può scaricare quando ha rete e conservare localmente.
// src/services/revocation-service.js
import { db } from '../db/index.js';
import { config } from '../config.js';
import { signLicenseToken } from '../licensing/token.js';
const selectRevoked = db.prepare(`
SELECT license_key
FROM licenses
WHERE status IN ('revoked', 'suspended')
ORDER BY updated_at DESC
`);
// La lista è firmata come un token: un attaccante non può servire al client
// una lista vuota fabbricata da sé, ma può comunque bloccarne il download.
export function buildRevocationList() {
const issuedAt = Math.floor(Date.now() / 1000);
const keys = selectRevoked.all().map((row) => row.license_key);
return signLicenseToken(
{
typ: 'crl',
iat: issuedAt,
// Scadenza della lista: il client rifiuta una copia troppo vecchia,
// così non può essere congelato su uno stato precedente alla revoca.
exp: issuedAt + 60 * 60 * 24 * 7,
keys
},
config.signingPrivateKey
);
}
Le rotte HTTP
Separiamo nettamente le rotte amministrative (protette da API key, raggiungibili solo dai sistemi interni) dalle rotte pubbliche (esposte a Internet e usate dalle installazioni dei clienti). La validazione degli input usa zod, con limiti espliciti sulla lunghezza di ogni campo per non trasformare i log e il database in un vettore di abuso.
// src/routes/admin.js
import { timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import { config } from '../config.js';
import { UnauthorizedError } from '../errors.js';
import {
getLicenseByKey,
issueLicense,
revokeLicense,
setLicenseStatus
} from '../services/license-service.js';
// Confronto a tempo costante: un confronto ingenuo permette di ricostruire
// la chiave un carattere alla volta misurando i tempi di risposta.
function isValidApiKey(provided) {
const expected = Buffer.from(config.adminApiKey);
const received = Buffer.from(String(provided ?? ''));
if (expected.length !== received.length) {
return false;
}
return timingSafeEqual(expected, received);
}
const issueSchema = z.object({
productCode: z.string().min(2).max(16),
customerEmail: z.string().email().max(255),
customerRef: z.string().max(64).optional(),
plan: z.string().max(32).optional(),
features: z.array(z.string().max(64)).max(64).optional(),
maxActivations: z.number().int().min(1).max(1000).optional(),
expiresAt: z.string().datetime().optional(),
notes: z.string().max(1000).optional()
});
export default async function adminRoutes(fastify) {
fastify.addHook('onRequest', async (request) => {
if (!isValidApiKey(request.headers['x-api-key'])) {
throw new UnauthorizedError('Invalid administrative API key');
}
});
fastify.post('/admin/licenses', async (request, reply) => {
const payload = issueSchema.parse(request.body);
const license = issueLicense(payload);
reply.code(201);
return {
licenseKey: license.license_key,
product: license.product_code,
plan: license.plan,
maxActivations: license.max_activations,
expiresAt: license.expires_at
};
});
fastify.get('/admin/licenses/:key', async (request) => {
const license = getLicenseByKey(request.params.key);
if (!license) {
return { found: false };
}
return { found: true, license };
});
fastify.post('/admin/licenses/:key/revoke', async (request) => {
const body = z.object({ reason: z.string().max(255).optional() }).parse(request.body ?? {});
const license = revokeLicense(request.params.key, body.reason ?? null);
return { licenseKey: license.license_key, status: license.status };
});
fastify.post('/admin/licenses/:key/status', async (request) => {
const body = z.object({ status: z.enum(['active', 'suspended']) }).parse(request.body);
const license = setLicenseStatus(request.params.key, body.status);
return { licenseKey: license.license_key, status: license.status };
});
}
// src/routes/public.js
import { z } from 'zod';
import { activate, deactivate, heartbeat } from '../services/activation-service.js';
import { buildRevocationList } from '../services/revocation-service.js';
const activateSchema = z.object({
licenseKey: z.string().min(8).max(64),
fingerprint: z.string().min(8).max(512),
machineName: z.string().max(120).optional(),
appVersion: z.string().max(40).optional()
});
const activationRefSchema = z.object({
licenseKey: z.string().min(8).max(64),
activationId: z.string().uuid(),
appVersion: z.string().max(40).optional()
});
export default async function publicRoutes(fastify) {
fastify.post('/v1/activations', {
config: {
// Limite più severo: è l'endpoint che consuma postazioni.
rateLimit: { max: 10, timeWindow: '1 minute' }
}
}, async (request, reply) => {
const payload = activateSchema.parse(request.body);
const result = activate({ ...payload, ip: request.ip });
reply.code(201);
return result;
});
fastify.post('/v1/activations/heartbeat', async (request) => {
const payload = activationRefSchema.parse(request.body);
return heartbeat({ ...payload, ip: request.ip });
});
fastify.post('/v1/activations/release', async (request) => {
const payload = activationRefSchema.parse(request.body);
return deactivate({ ...payload, ip: request.ip });
});
fastify.get('/v1/revocations', async (request, reply) => {
// La lista cambia raramente: una cache breve riduce il carico
// senza ritardare in modo significativo la propagazione.
reply.header('cache-control', 'public, max-age=900');
return { list: buildRevocationList() };
});
}
Il server
Il gestore di errori centralizzato è la parte più importante di questo file: traduce le eccezioni applicative in risposte coerenti e, soprattutto, evita che un errore imprevisto trapeli dettagli interni al client.
// src/server.js
import Fastify from 'fastify';
import rateLimit from '@fastify/rate-limit';
import { ZodError } from 'zod';
import { config } from './config.js';
import { AppError } from './errors.js';
import adminRoutes from './routes/admin.js';
import publicRoutes from './routes/public.js';
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? 'info',
// Le chiavi di licenza non finiscono nei log: sono credenziali a tutti gli effetti.
redact: ['req.headers["x-api-key"]', 'req.body.licenseKey']
},
trustProxy: true,
bodyLimit: 32 * 1024
});
await app.register(rateLimit, {
max: 60,
timeWindow: '1 minute'
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof ZodError) {
return reply.code(400).send({
error: 'VALIDATION_ERROR',
details: error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message
}))
});
}
if (error instanceof AppError) {
return reply.code(error.statusCode).send({
error: error.code,
message: error.message
});
}
if (error.statusCode === 429) {
return reply.code(429).send({ error: 'RATE_LIMITED', message: 'Too many requests' });
}
// Errore non previsto: si registra tutto e si restituisce il minimo indispensabile.
request.log.error({ err: error }, 'unhandled error');
return reply.code(500).send({ error: 'INTERNAL_ERROR', message: 'Unexpected server error' });
});
await app.register(publicRoutes);
await app.register(adminRoutes);
app.get('/health', async () => ({ status: 'ok' }));
await app.listen({ port: config.port, host: config.host });
Il controllo lato client
Sul client la logica deve rispondere a una sola domanda: l'applicazione può partire? La risposta si costruisce dalla verifica del token, senza rete. La chiamata al server è un'ottimizzazione, non un prerequisito.
// src/client/check-license.js
import { verifyLicenseToken } from '../licensing/token.js';
import { hashFingerprint } from '../licensing/fingerprint.js';
export function checkLicense({
token,
publicKeyPem,
fingerprint,
revokedKeys = [],
// Periodo di tolleranza dopo la scadenza del token: copre i clienti
// che restano temporaneamente senza connettività.
gracePeriodSeconds = 60 * 60 * 24 * 7,
now = Date.now()
}) {
const verified = verifyLicenseToken(token, publicKeyPem);
if (!verified.valid) {
return { ok: false, reason: verified.reason };
}
const payload = verified.payload;
const seconds = Math.floor(now / 1000);
// La scadenza contrattuale non ammette tolleranza.
if (payload.lexp && payload.lexp <= seconds) {
return { ok: false, reason: 'LICENSE_EXPIRED' };
}
if (new Set(revokedKeys).has(payload.lic)) {
return { ok: false, reason: 'LICENSE_REVOKED' };
}
// Il token è legato alla macchina: copiarlo su un altro computer non basta.
if (payload.fpr !== hashFingerprint(fingerprint)) {
return { ok: false, reason: 'FINGERPRINT_MISMATCH' };
}
if (payload.exp <= seconds) {
if (payload.exp + gracePeriodSeconds <= seconds) {
return { ok: false, reason: 'RENEWAL_REQUIRED' };
}
// Funzionamento consentito, ma l'interfaccia deve avvisare l'utente.
return {
ok: true,
degraded: true,
reason: 'GRACE_PERIOD',
plan: payload.plan,
features: payload.feat ?? []
};
}
return {
ok: true,
degraded: false,
plan: payload.plan,
features: payload.feat ?? [],
licenseExpiresAt: payload.lexp
};
}
export function hasFeature(result, feature) {
return result.ok && (result.features ?? []).includes(feature);
}
L'integrazione nell'avvio dell'applicazione segue questo schema: si legge il token dal disco, si verifica, e solo se il token è vicino alla scadenza si tenta il rinnovo online. Il rinnovo non deve mai bloccare l'avvio.
// Esempio di integrazione all'avvio dell'applicazione
import { readFile, writeFile } from 'node:fs/promises';
import { checkLicense } from './client/check-license.js';
import { machineFingerprintHash } from './client/machine.js';
const PUBLIC_KEY = process.env.LICENSE_PUBLIC_KEY;
const TOKEN_PATH = './license.token';
const RENEW_BEFORE_SECONDS = 60 * 60 * 24 * 3;
async function renewToken(licenseKey, activationId, appVersion) {
const response = await fetch('https://licenses.example.com/v1/activations/heartbeat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ licenseKey, activationId, appVersion }),
// Un server lento non deve ritardare l'avvio dell'applicazione.
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
return null;
}
const data = await response.json();
await writeFile(TOKEN_PATH, data.token, 'utf8');
return data.token;
}
export async function bootstrapLicensing({ licenseKey, activationId, appVersion }) {
const fingerprint = machineFingerprintHash(await readInstallationId());
const token = await readFile(TOKEN_PATH, 'utf8');
let result = checkLicense({ token, publicKeyPem: PUBLIC_KEY, fingerprint });
// Il rinnovo è opportunistico: se fallisce si continua con il token esistente.
if (result.ok && result.degraded) {
const renewed = await renewToken(licenseKey, activationId, appVersion).catch(() => null);
if (renewed) {
result = checkLicense({ token: renewed, publicKeyPem: PUBLIC_KEY, fingerprint });
}
}
return result;
}
Codici di errore restituiti
Una tabella dei codici applicativi è utile sia alla documentazione per gli sviluppatori integratori, sia al team di supporto.
| Codice | HTTP | Significato | Azione suggerita al client |
|---|---|---|---|
INVALID_KEY_FORMAT |
400 | Checksum non valido, quasi sempre un errore di battitura | Chiedere di ricontrollare la chiave |
LICENSE_NOT_FOUND |
404 | Chiave formalmente valida ma inesistente | Invitare a contattare l'assistenza |
LICENSE_EXPIRED |
403 | Scadenza contrattuale superata | Proporre il rinnovo |
LICENSE_SUSPENDED |
403 | Sospensione temporanea, tipicamente amministrativa | Rimandare all'area clienti |
LICENSE_REVOKED |
403 | Revoca definitiva | Bloccare l'esecuzione |
SEAT_LIMIT_REACHED |
409 | Tutte le postazioni sono occupate | Offrire la disattivazione di una macchina |
ACTIVATION_NOT_FOUND |
404 | Attivazione inesistente o appartenente ad altra licenza | Rieseguire l'attivazione |
RATE_LIMITED |
429 | Troppe richieste dalla stessa origine | Ritentare con attesa esponenziale |
Rotazione delle chiavi di firma
Una chiave privata di firma vive per anni e prima o poi va sostituita, per compromissione o per policy. Il problema è che i client già distribuiti conoscono solo la vecchia chiave pubblica. La soluzione standard è includere nel payload un identificativo della chiave e far sì che il client mantenga un registro di chiavi pubbliche.
// Estensione del token con identificativo di chiave
export function signLicenseToken(payload, { keyId, privateKeyPem }) {
const body = toBase64Url(JSON.stringify({ v: TOKEN_VERSION, kid: keyId, ...payload }));
const signature = sign(null, Buffer.from(body), createPrivateKey(privateKeyPem));
return `${body}.${toBase64Url(signature)}`;
}
export function verifyLicenseToken(token, keyRegistry) {
const [body, signature] = String(token ?? '').split('.');
if (!body || !signature) {
return { valid: false, reason: 'MALFORMED_TOKEN' };
}
let header;
try {
// Lettura del solo identificativo di chiave: il contenuto resta
// non attendibile finché la firma non è verificata.
header = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
} catch {
return { valid: false, reason: 'MALFORMED_PAYLOAD' };
}
const publicKeyPem = keyRegistry[header.kid];
if (!publicKeyPem) {
return { valid: false, reason: 'UNKNOWN_KEY_ID' };
}
// Da qui in poi la logica è identica alla versione a chiave singola.
// ...
}
La procedura operativa è: generare la nuova coppia, distribuire un aggiornamento dell'applicazione che contiene entrambe le chiavi pubbliche, attendere che la base installata si aggiorni, quindi passare il server a firmare con la nuova chiave. Solo dopo che tutti i token firmati con la vecchia chiave sono scaduti si può rimuovere la vecchia pubblica dal registro.
Test
I test più preziosi in un sistema di licenze sono quelli che verificano le proprietà crittografiche e i vincoli di conteggio, perché sono le parti in cui un errore silenzioso costa denaro. Usiamo il runner integrato di Node.js, senza dipendenze aggiuntive.
// test/key-format.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateLicenseKey, parseLicenseKey } from '../src/licensing/key-format.js';
const SECRET = 'test-secret-value';
test('a generated key survives a parse round trip', () => {
const key = generateLicenseKey('ACME', SECRET);
const parsed = parseLicenseKey(key, SECRET);
assert.ok(parsed);
assert.equal(parsed.productCode, 'ACME');
assert.equal(parsed.canonical, key);
});
test('ambiguous characters typed by the user are normalised', () => {
const key = generateLicenseKey('ACME', SECRET);
// L'utente ha scritto O al posto di zero e tutto in minuscolo.
const typed = key.toLowerCase().replace(/0/g, 'O');
assert.equal(parseLicenseKey(typed, SECRET)?.canonical, key);
});
test('a single altered character breaks the checksum', () => {
const key = generateLicenseKey('ACME', SECRET);
const tampered = `${key.slice(0, -1)}${key.endsWith('Z') ? 'Y' : 'Z'}`;
assert.equal(parseLicenseKey(tampered, SECRET), null);
});
test('a key is not valid under a different product code', () => {
const key = generateLicenseKey('ACME', SECRET);
const moved = key.replace('ACME', 'OTHER');
assert.equal(parseLicenseKey(moved, SECRET), null);
});
// test/token.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync } from 'node:crypto';
import { signLicenseToken, verifyLicenseToken } from '../src/licensing/token.js';
function createKeyPair() {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
return {
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }),
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' })
};
}
test('a valid token is verified and its payload is readable', () => {
const { privateKeyPem, publicKeyPem } = createKeyPair();
const token = signLicenseToken({ lic: 'ACME-TEST', plan: 'professional' }, privateKeyPem);
const result = verifyLicenseToken(token, publicKeyPem);
assert.equal(result.valid, true);
assert.equal(result.payload.plan, 'professional');
});
test('a tampered payload invalidates the signature', () => {
const { privateKeyPem, publicKeyPem } = createKeyPair();
const token = signLicenseToken({ lic: 'ACME-TEST', plan: 'standard' }, privateKeyPem);
const [, signature] = token.split('.');
// Tentativo di promozione del piano riutilizzando la firma originale.
const forgedBody = Buffer
.from(JSON.stringify({ v: 1, lic: 'ACME-TEST', plan: 'enterprise' }))
.toString('base64url');
const result = verifyLicenseToken(`${forgedBody}.${signature}`, publicKeyPem);
assert.equal(result.valid, false);
assert.equal(result.reason, 'BAD_SIGNATURE');
});
test('a token signed with another key is rejected', () => {
const issuer = createKeyPair();
const attacker = createKeyPair();
const token = signLicenseToken({ lic: 'ACME-TEST' }, attacker.privateKeyPem);
assert.equal(verifyLicenseToken(token, issuer.publicKeyPem).valid, false);
});
Uso pratico delle API
Emissione di una licenza a tre postazioni con scadenza annuale:
curl -X POST https://licenses.example.com/admin/licenses \
-H "x-api-key: $ADMIN_API_KEY" \
-H "content-type: application/json" \
-d '{
"productCode": "ACME",
"customerEmail": "cliente@example.com",
"customerRef": "cus_00193",
"plan": "professional",
"features": ["export-pdf", "api-access"],
"maxActivations": 3,
"expiresAt": "2027-07-24T00:00:00.000Z"
}'
curl -X POST https://licenses.example.com/v1/activations \
-H "content-type: application/json" \
-d '{
"licenseKey": "ACME-3K7QW-9ZB2M-XR4TD-P8N5H",
"fingerprint": "linux|x64|workstation-01|...",
"machineName": "Workstation ufficio",
"appVersion": "4.2.0"
}'
Messa in produzione
Alcune considerazioni operative che fanno la differenza tra un prototipo e un servizio su cui poggia il fatturato.
- Backup: il database delle licenze è irreproducibile. Con SQLite si usa
VACUUM INTOper ottenere una copia consistente a caldo, da inviare poi a un archivio remoto con cadenza almeno oraria e con verifica periodica del ripristino. - Custodia della chiave privata: va conservata fuori dal repository, con permessi
0600, e va replicata in un archivio sicuro separato. Perderla significa non poter più emettere token verificabili dalla base installata esistente. - Terminazione TLS e reverse proxy: il servizio ascolta su
127.0.0.1e viene esposto tramite nginx o Caddy. ContrustProxyattivo, il proxy deve impostare correttamenteX-Forwarded-For, altrimenti il rate limiting conta tutte le richieste come provenienti da un solo indirizzo. - Rete amministrativa separata: le rotte
/adminnon hanno ragione di essere raggiungibili da Internet. Un blocco a livello di proxy, oltre all'API key, è una seconda linea di difesa che costa tre righe di configurazione. - Monitoraggio applicativo: gli indicatori utili non sono solo tecnici. Un'impennata di
SEAT_LIMIT_REACHEDpuò segnalare una chiave condivisa pubblicamente; un crollo delle attivazioni può segnalare un bug nel client appena rilasciato. - Migrazione a PostgreSQL: quando servono più istanze del server o repliche geografiche, SQLite non basta più. La transizione tocca solo il livello di accesso ai dati, purché si mantenga la logica di conteggio delle postazioni dentro una transazione con isolamento adeguato: con PostgreSQL è opportuno un
SELECT ... FOR UPDATEsulla riga della licenza prima di contare le attivazioni. - Pulizia periodica: gli eventi di audit crescono senza limite. Una politica di conservazione (per esempio ventiquattro mesi) va definita fin dall'inizio, insieme a un processo schedulato che la applichi.
Conclusioni
Il sistema descritto tiene insieme due esigenze che sembrano in conflitto: il controllo centralizzato, necessario all'azienda per gestire il ciclo di vita commerciale della licenza, e l'autonomia del client, necessaria all'utente per lavorare anche senza rete. Il punto di equilibrio è il token firmato con scadenza breve: sposta la verifica sul client senza cedergli la capacità di emettere diritti, e mantiene una finestra di propagazione nota e regolabile agendo su un solo parametro di configurazione.
Le direzioni naturali di estensione sono tre. La prima è l'integrazione con la piattaforma di pagamento, tramite webhook che emettono la licenza alla conferma dell'ordine e la sospendono al fallimento di un rinnovo. La seconda è un portale self-service dove il cliente vede le proprie attivazioni e ne libera una senza aprire un ticket, che è il singolo intervento con il maggiore impatto sul carico dell'assistenza. La terza è la licenza a consumo, in cui il token porta un budget di operazioni e il client segnala periodicamente il consumo: una variante che richiede di ripensare la logica di riconciliazione, ma che poggia sulla stessa infrastruttura crittografica costruita qui.