Creare un sistema di gestione delle licenze software con Go
Ogni prodotto software commerciale distribuito on-premise, o installato direttamente sulle macchine dei clienti, prima o poi si scontra con lo stesso problema: come stabilire, in modo affidabile e verificabile, che una determinata installazione ha diritto di essere eseguita. Un sistema di gestione delle licenze non è soltanto un controllo booleano all'avvio dell'applicazione: è un'infrastruttura completa che comprende emissione, firma crittografica, distribuzione, attivazione, conteggio delle postazioni, revoca e rinnovo.
Go è un linguaggio particolarmente adatto a questo scopo. La libreria standard offre primitive crittografiche moderne (Ed25519, HMAC-SHA256, TLS), un server HTTP di qualità produttiva, e la compilazione statica produce binari autonomi facili da distribuire sia come servizio di attivazione sia come libreria embedded nel prodotto da proteggere. In questo articolo costruiremo un sistema completo, partendo dal modello di minaccia e arrivando al deployment.
Definire il modello di minaccia
Prima di scrivere una sola riga di codice occorre essere onesti su ciò che un sistema di licenze può e non può fare. Un attaccante che possiede il binario dell'applicazione e i privilegi di amministrazione sulla macchina su cui gira può, con tempo sufficiente, rimuovere qualunque controllo di licenza. Nessuna crittografia lo impedisce: la chiave pubblica di verifica è nel binario, il punto di salto condizionale è nel binario, e il disassemblatore è a disposizione di chiunque.
L'obiettivo realistico è quindi un altro, ed è duplice:
- Impedire l'uso accidentale o casualmente scorretto. Il cliente che installa il prodotto su venti server quando ne ha pagati cinque, di solito, non lo fa per malizia ma perché nulla glielo impedisce. Un sistema di licenze ben fatto rende esplicito il limite.
- Rendere la contraffazione non ripudiabile. Una licenza firmata crittograficamente non può essere prodotta da nessuno che non possieda la chiave privata. Chi modifica il binario per aggirare il controllo commette un atto deliberato e dimostrabile, con conseguenze contrattuali e legali chiare.
Il corollario pratico è che non va investito tempo in offuscamento, anti-debugging o packing: sono misure fragili, costose da mantenere e facilmente aggirabili. Il tempo va investito in una crittografia corretta, in un protocollo di attivazione robusto e in un comportamento degradato che non punisca il cliente onesto quando la rete non funziona.
Architettura del sistema
Il sistema si compone di quattro elementi, ognuno con responsabilità nette.
La libreria condivisa definisce il formato del token di licenza, la firma e la verifica. È usata sia dal server (che emette) sia dal client (che verifica), garantendo per costruzione che le due parti parlino la stessa lingua.
Il servizio di emissione è uno strumento a riga di comando, eseguito in un ambiente controllato, che ha accesso alla chiave privata di firma. Non è esposto in rete.
Il server di attivazione è un servizio HTTP che riceve le richieste di attivazione dai client, conteggia le postazioni occupate, mantiene la lista di revoca e risponde agli heartbeat. Non possiede la chiave privata di firma delle licenze: al massimo possiede una chiave distinta per firmare le liste di revoca.
Il client embedded è il codice integrato nel prodotto: verifica la firma offline, gestisce la cache dell'attivazione, applica il periodo di grazia e decide quali funzionalità abilitare.
licensing/
├── cmd/
│ ├── keygen/ # generazione delle coppie di chiavi Ed25519
│ ├── licctl/ # CLI amministrativa: emissione, revoca, elenco
│ └── licensed/ # server di attivazione HTTP
├── internal/
│ ├── api/ # handler HTTP e middleware
│ ├── ratelimit/ # limitazione delle richieste per IP
│ └── store/ # persistenza SQLite
├── pkg/
│ ├── fingerprint/ # identificativo stabile della macchina
│ └── license/ # formato del token, firma, verifica
└── go.mod
La scelta di collocare license e fingerprint sotto pkg/ è deliberata: sono i due pacchetti che verranno importati dal prodotto da proteggere, che vive in un repository diverso. Tutto il resto sta sotto internal/ e non è importabile dall'esterno.
Il formato del token di licenza
Il token è una stringa autocontenuta, firmata, che il cliente riceve al momento dell'acquisto e incolla nel prodotto. La struttura ricalca quella di JWT ma è deliberatamente più rigida: un'unica versione di formato, un unico algoritmo di firma, nessuna negoziazione. L'algoritmo non è mai indicato nel token, perché l'attacco classico ai JWT consiste proprio nel manipolare quel campo.
Il formato è composto da quattro segmenti separati da punti:
LIC1.<key-id>.<payload-base64url>.<signature-base64url>
La firma copre i primi tre segmenti nella loro forma testuale. Includere versione e identificativo della chiave nell'input firmato è essenziale: impedisce a un attaccante di riciclare una firma valida cambiando l'intestazione, per esempio per dirottare la verifica su una chiave che controlla.
I claims
package license
import "time"
// Edition rappresenta il piano commerciale associato alla licenza.
type Edition string
const (
EditionCommunity Edition = "community"
EditionProfessional Edition = "professional"
EditionEnterprise Edition = "enterprise"
)
// Claims contiene i dati firmati della licenza. I nomi dei campi JSON sono
// abbreviati per contenere la lunghezza del token, che il cliente deve
// poter copiare e incollare senza difficoltà.
type Claims struct {
// ID è l'identificativo univoco della licenza, usato per la revoca.
ID string `json:"jti"`
// Product distingue le licenze di prodotti diversi firmati con la stessa chiave.
Product string `json:"prd"`
// Edition determina l'insieme di funzionalità di base.
Edition Edition `json:"edt"`
// CustomerID è il riferimento opaco al cliente nel sistema gestionale.
CustomerID string `json:"cid"`
// CustomerName viene mostrato nell'interfaccia: la sua presenza nel token
// firmato è di per sé un deterrente alla condivisione.
CustomerName string `json:"cnm,omitempty"`
IssuedAt time.Time `json:"iat"`
NotBefore time.Time `json:"nbf"`
// ExpiresAt vale time.Time{} per le licenze perpetue.
ExpiresAt time.Time `json:"exp,omitzero"`
// SupportUntil delimita il diritto agli aggiornamenti nelle licenze perpetue.
SupportUntil time.Time `json:"sup,omitzero"`
// MaxSeats è il numero massimo di installazioni attive contemporaneamente.
MaxSeats int `json:"sea"`
// Features elenca le funzionalità opzionali abilitate oltre a quelle
// incluse nell'edizione.
Features []string `json:"ftr,omitempty"`
// Fingerprint, se valorizzato, vincola la licenza a una macchina specifica
// e rende superflua l'attivazione online.
Fingerprint string `json:"fpr,omitempty"`
// Online indica se il prodotto deve contattare il server di attivazione.
Online bool `json:"onl"`
}
// IsPerpetual segnala l'assenza di una data di scadenza.
func (c Claims) IsPerpetual() bool {
return c.ExpiresAt.IsZero()
}
// HasFeature verifica l'abilitazione di una funzionalità opzionale.
func (c Claims) HasFeature(name string) bool {
for _, f := range c.Features {
if f == name {
return true
}
}
return false
}
Il tag omitzero, disponibile da Go 1.24, è preferibile a omitempty per i valori di tipo time.Time: omitempty non ha alcun effetto sulle struct, e produrrebbe il valore inutile "0001-01-01T00:00:00Z" nel token.
Firma e verifica
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
)
// TokenVersion identifica il formato del token e consente evoluzioni future
// senza ambiguità di interpretazione.
const TokenVersion = "LIC1"
const partSeparator = "."
var b64 = base64.RawURLEncoding
// Errori restituiti dalla verifica. Sono distinti perché il chiamante deve
// poter reagire in modo diverso: una licenza scaduta apre un percorso di
// rinnovo, una firma non valida no.
var (
ErrMalformed = errors.New("license: token malformato")
ErrUnknownKey = errors.New("license: chiave di firma sconosciuta")
ErrBadSignature = errors.New("license: firma non valida")
ErrExpired = errors.New("license: licenza scaduta")
ErrNotYetValid = errors.New("license: licenza non ancora valida")
ErrProductMismatch = errors.New("license: prodotto non corrispondente")
ErrFingerprint = errors.New("license: licenza vincolata a un'altra macchina")
ErrRevoked = errors.New("license: licenza revocata")
)
// signingInput costruisce la porzione di token effettivamente firmata.
// Includere versione e key ID impedisce la sostituzione dell'intestazione.
func signingInput(keyID string, payload []byte) string {
return TokenVersion + partSeparator + keyID + partSeparator + b64.EncodeToString(payload)
}
// Sign serializza i claims e produce il token firmato completo.
func Sign(c Claims, keyID string, priv ed25519.PrivateKey) (string, error) {
if len(priv) != ed25519.PrivateKeySize {
return "", errors.New("license: chiave privata di dimensione errata")
}
if keyID == "" || strings.Contains(keyID, partSeparator) {
return "", errors.New("license: key ID non valido")
}
payload, err := json.Marshal(c)
if err != nil {
return "", fmt.Errorf("license: serializzazione dei claims: %w", err)
}
input := signingInput(keyID, payload)
sig := ed25519.Sign(priv, []byte(input))
return input + partSeparator + b64.EncodeToString(sig), nil
}
// KeyRing associa ogni key ID alla relativa chiave pubblica. Mantenere più
// chiavi contemporaneamente è ciò che rende possibile la rotazione senza
// invalidare le licenze già emesse.
type KeyRing map[string]ed25519.PublicKey
// Parse verifica la firma del token e ne decodifica i claims. Non applica
// alcun controllo semantico: quelli sono compito di Verify.
func Parse(token string, ring KeyRing) (*Claims, error) {
parts := strings.Split(strings.TrimSpace(token), partSeparator)
if len(parts) != 4 || parts[0] != TokenVersion {
return nil, ErrMalformed
}
pub, ok := ring[parts[1]]
if !ok {
return nil, ErrUnknownKey
}
payload, err := b64.DecodeString(parts[2])
if err != nil {
return nil, ErrMalformed
}
sig, err := b64.DecodeString(parts[3])
if err != nil {
return nil, ErrMalformed
}
input := strings.Join(parts[:3], partSeparator)
if !ed25519.Verify(pub, []byte(input), sig) {
return nil, ErrBadSignature
}
var c Claims
dec := json.NewDecoder(strings.NewReader(string(payload)))
// I campi sconosciuti vengono rifiutati: un token prodotto da una versione
// più recente del sistema non deve essere interpretato parzialmente.
dec.DisallowUnknownFields()
if err := dec.Decode(&c); err != nil {
return nil, ErrMalformed
}
return &c, nil
}
Un dettaglio che vale la pena sottolineare: ed25519.Verify non richiede confronti a tempo costante da parte nostra, perché la verifica di una firma asimmetrica opera su dati pubblici. Al contrario, ogni volta che confronteremo segreti condivisi — token di amministrazione, chiavi API — useremo subtle.ConstantTimeCompare.
I controlli semantici
La separazione tra Parse e Verify non è pedanteria. Il server di attivazione deve poter leggere i claims di una licenza scaduta per rispondere al cliente in modo utile; il client deve invece applicare tutti i controlli. Tenere le due operazioni distinte evita di duplicare la logica di parsing con controlli diversi.
package license
import "time"
// VerifyOptions raccoglie i controlli semantici applicati dopo la firma.
type VerifyOptions struct {
// Product è l'identificativo atteso del prodotto: impedisce di usare
// la licenza di un prodotto per sbloccarne un altro.
Product string
// Now consente di iniettare il tempo nei test.
Now time.Time
// Skew tollera un disallineamento ragionevole degli orologi.
Skew time.Duration
// Fingerprint è l'identificativo della macchina corrente; viene
// confrontato solo se la licenza è vincolata.
Fingerprint string
// IsRevoked consulta la lista di revoca locale, se disponibile.
IsRevoked func(licenseID string) bool
}
// Verify applica i controlli semantici a claims già validati nella firma.
func Verify(c *Claims, opts VerifyOptions) error {
now := opts.Now
if now.IsZero() {
now = time.Now()
}
if opts.Product != "" && c.Product != opts.Product {
return ErrProductMismatch
}
if !c.NotBefore.IsZero() && now.Add(opts.Skew).Before(c.NotBefore) {
return ErrNotYetValid
}
if !c.IsPerpetual() && now.Add(-opts.Skew).After(c.ExpiresAt) {
return ErrExpired
}
if c.Fingerprint != "" && c.Fingerprint != opts.Fingerprint {
return ErrFingerprint
}
if opts.IsRevoked != nil && opts.IsRevoked(c.ID) {
return ErrRevoked
}
return nil
}
Generare e custodire le chiavi
La coppia di chiavi Ed25519 è il fulcro dell'intero sistema. La chiave privata non deve mai lasciare l'ambiente in cui vengono emesse le licenze, non deve stare nel repository del codice, e non deve essere presente su nessuna macchina raggiungibile dalla rete pubblica.
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
func main() {
outDir := flag.String("out", "keys", "directory di destinazione")
keyID := flag.String("kid", "", "identificativo della coppia (default: data corrente)")
flag.Parse()
id := *keyID
if id == "" {
// Un key ID basato sulla data rende immediatamente leggibile
// l'ordine di rotazione delle chiavi.
id = time.Now().UTC().Format("2006-01")
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Fatalf("generazione della chiave: %v", err)
}
if err := os.MkdirAll(*outDir, 0o700); err != nil {
log.Fatalf("creazione della directory: %v", err)
}
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
log.Fatalf("codifica della chiave privata: %v", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
log.Fatalf("codifica della chiave pubblica: %v", err)
}
privPath := filepath.Join(*outDir, id+".key.pem")
pubPath := filepath.Join(*outDir, id+".pub.pem")
// La chiave privata viene scritta con permessi restrittivi e con O_EXCL,
// in modo da non sovrascrivere silenziosamente una chiave esistente.
f, err := os.OpenFile(privPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
log.Fatalf("apertura del file della chiave privata: %v", err)
}
if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: privDER}); err != nil {
log.Fatalf("scrittura della chiave privata: %v", err)
}
if err := f.Close(); err != nil {
log.Fatalf("chiusura del file della chiave privata: %v", err)
}
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
if err := os.WriteFile(pubPath, pubPEM, 0o644); err != nil {
log.Fatalf("scrittura della chiave pubblica: %v", err)
}
fmt.Printf("coppia di chiavi %q generata in %s\n", id, *outDir)
}
La chiave pubblica va invece incorporata nel binario del prodotto. Il modo più semplice e robusto è la direttiva go:embed, che rende impossibile una discrepanza tra il file letto e quello distribuito.
package licensing
import (
"crypto/ed25519"
"crypto/x509"
"embed"
"encoding/pem"
"fmt"
"io/fs"
"path"
"strings"
"example.com/licensing/pkg/license"
)
//go:embed keys/*.pub.pem
var keyFS embed.FS
// LoadKeyRing costruisce il portachiavi a partire dalle chiavi pubbliche
// incorporate nel binario. Il nome del file determina il key ID.
func LoadKeyRing() (license.KeyRing, error) {
entries, err := fs.Glob(keyFS, "keys/*.pub.pem")
if err != nil {
return nil, err
}
ring := make(license.KeyRing, len(entries))
for _, name := range entries {
raw, err := keyFS.ReadFile(name)
if err != nil {
return nil, err
}
block, _ := pem.Decode(raw)
if block == nil || block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("chiave pubblica non valida in %s", name)
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing di %s: %w", name, err)
}
pub, ok := parsed.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("%s non contiene una chiave Ed25519", name)
}
keyID := strings.TrimSuffix(path.Base(name), ".pub.pem")
ring[keyID] = pub
}
if len(ring) == 0 {
return nil, fmt.Errorf("nessuna chiave pubblica incorporata")
}
return ring, nil
}
Chiavi di prodotto leggibili
Il token firmato è lungo diverse centinaia di caratteri: perfetto per essere incollato da un file, pessimo per essere dettato al telefono o stampato su una confezione. Per i canali in cui serve un codice breve conviene affiancare al token una product key di venticinque caratteri, che non contiene alcun dato ma funge da riferimento opaco alla licenza sul server.
L'alfabeto usato è quello di Crockford base32, privo dei caratteri ambigui I, L, O e U, con un gruppo finale di checksum che permette di individuare gli errori di trascrizione senza contattare il server.
package license
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"errors"
"strings"
)
// productKeyAlphabet esclude I, L, O e U per ridurre gli errori di trascrizione.
const productKeyAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
var productKeyEnc = base32.NewEncoding(productKeyAlphabet).WithPadding(base32.NoPadding)
// ErrBadProductKey segnala un codice sintatticamente errato o con checksum
// non corrispondente.
var ErrBadProductKey = errors.New("license: chiave di prodotto non valida")
// NewProductKey genera un codice del tipo XXXXX-XXXXX-XXXXX-XXXXX-CCCCC,
// dove l'ultimo gruppo è un checksum derivato dai precedenti.
func NewProductKey(secret []byte) (string, error) {
// 12 byte casuali producono esattamente 20 caratteri base32,
// ossia quattro gruppi da cinque.
raw := make([]byte, 12)
if _, err := rand.Read(raw); err != nil {
return "", err
}
body := productKeyEnc.EncodeToString(raw)
return group(body + checksum(body, secret)), nil
}
// checksum calcola cinque caratteri derivati dal corpo del codice tramite
// HMAC: senza il segreto non è possibile fabbricare codici sintatticamente
// validi, il che rende inutile il fuzzing di massa sull'endpoint pubblico.
func checksum(body string, secret []byte) string {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(body))
return productKeyEnc.EncodeToString(mac.Sum(nil))[:5]
}
// group inserisce un trattino ogni cinque caratteri.
func group(s string) string {
var sb strings.Builder
for i, r := range s {
if i > 0 && i%5 == 0 {
sb.WriteByte('-')
}
sb.WriteRune(r)
}
return sb.String()
}
// NormalizeProductKey riporta il codice in forma canonica, tollerando
// spazi, minuscole e le confusioni tipiche fra caratteri simili.
func NormalizeProductKey(s string) string {
replacer := strings.NewReplacer(
"-", "", " ", "",
"I", "1", "L", "1",
"O", "0", "U", "V",
)
return replacer.Replace(strings.ToUpper(strings.TrimSpace(s)))
}
// ValidateProductKey verifica localmente la correttezza del checksum.
func ValidateProductKey(s string, secret []byte) error {
norm := NormalizeProductKey(s)
if len(norm) != 25 {
return ErrBadProductKey
}
body, sum := norm[:20], norm[20:]
if !hmac.Equal([]byte(sum), []byte(checksum(body, secret))) {
return ErrBadProductKey
}
return nil
}
Si noti l'uso di hmac.Equal anche per confrontare due stringhe di checksum: è un confronto a tempo costante e, trattandosi di un valore derivato da un segreto, è la scelta corretta.
Identificare la macchina
Il conteggio delle postazioni richiede un identificativo stabile della macchina su cui il prodotto viene eseguito. Nessuna singola fonte è affidabile: gli indirizzi MAC cambiano con le interfacce virtuali, il nome host cambia dopo una riconfigurazione, il machine-id viene clonato insieme all'immagine della macchina virtuale. La strategia sensata è combinare più fonti e accettare che il valore possa cambiare, prevedendo un meccanismo di riassegnazione delle postazioni.
package fingerprint
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net"
"os"
"sort"
"strings"
)
// virtualPrefixes elenca i prefissi delle interfacce tipicamente effimere,
// che introdurrebbero instabilità nel fingerprint.
var virtualPrefixes = []string{"docker", "veth", "br-", "virbr", "tun", "tap", "vmnet", "utun"}
// Compute deriva un identificativo stabile della macchina corrente.
// Il salt è una costante specifica del prodotto: impedisce di correlare
// le installazioni fra prodotti diversi dello stesso fornitore.
func Compute(salt []byte) string {
mac := hmac.New(sha256.New, salt)
if host, err := os.Hostname(); err == nil {
mac.Write([]byte("host:" + strings.ToLower(host)))
}
if id := machineID(); id != "" {
mac.Write([]byte("machine:" + id))
}
for _, addr := range stableHardwareAddrs() {
mac.Write([]byte("mac:" + addr))
}
sum := mac.Sum(nil)
// Sedici byte sono ampiamente sufficienti a escludere collisioni
// e mantengono il valore leggibile nei log e nell'interfaccia.
return hex.EncodeToString(sum[:16])
}
// stableHardwareAddrs restituisce gli indirizzi hardware delle interfacce
// fisiche, ordinati per garantire determinismo fra esecuzioni successive.
func stableHardwareAddrs() []string {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
var addrs []string
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || len(iface.HardwareAddr) == 0 {
continue
}
if isVirtual(iface.Name) {
continue
}
addrs = append(addrs, iface.HardwareAddr.String())
}
sort.Strings(addrs)
return addrs
}
func isVirtual(name string) bool {
lower := strings.ToLower(name)
for _, prefix := range virtualPrefixes {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
// machineID legge l'identificativo persistente fornito dal sistema operativo.
// L'implementazione varia per piattaforma: qui la versione per Linux.
func machineID() string {
for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
if data, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(data))
}
}
return ""
}
Nei container il valore va trattato con cautela: un'immagine replicata su cinquanta pod produce cinquanta fingerprint identici oppure cinquanta fingerprint diversi a ogni riavvio, a seconda della configurazione. Per i deployment containerizzati conviene prevedere una variabile d'ambiente che imponga un identificativo di installazione fornito dal cliente, usando il fingerprint calcolato solo come fallback.
Persistenza: schema e operazioni
Il server di attivazione ha bisogno di uno stato durevole. SQLite, tramite un driver in Go puro come modernc.org/sqlite, è più che sufficiente per la maggior parte dei casi: le scritture sono poche e brevi, le letture sono banali, e l'assenza di un processo separato semplifica enormemente il deployment.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS licenses (
id TEXT PRIMARY KEY,
product_key TEXT NOT NULL UNIQUE,
product TEXT NOT NULL,
edition TEXT NOT NULL,
customer_id TEXT NOT NULL,
max_seats INTEGER NOT NULL DEFAULT 1,
issued_at INTEGER NOT NULL,
expires_at INTEGER,
revoked_at INTEGER,
revocation_reason TEXT,
token TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_licenses_customer ON licenses (customer_id);
CREATE TABLE IF NOT EXISTS activations (
id TEXT PRIMARY KEY,
license_id TEXT NOT NULL REFERENCES licenses (id) ON DELETE CASCADE,
fingerprint TEXT NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT '',
activated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
released_at INTEGER,
UNIQUE (license_id, fingerprint)
);
CREATE INDEX IF NOT EXISTS idx_activations_license
ON activations (license_id) WHERE released_at IS NULL;
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
license_id TEXT,
event TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
remote_ip TEXT NOT NULL DEFAULT ''
);
Il vincolo UNIQUE (license_id, fingerprint) è ciò che rende l'attivazione idempotente a livello di database: un client che ripete la richiesta per un timeout di rete non consuma una seconda postazione. L'indice parziale su released_at IS NULL accelera il conteggio delle postazioni attive, che è l'operazione più frequente.
L'operazione di attivazione
L'attivazione è l'unico punto in cui una corsa critica può causare un danno reale: due richieste simultanee sull'ultima postazione disponibile potrebbero entrambe superare il controllo. La soluzione è una transazione immediata, che acquisisce il lock di scrittura prima di leggere il conteggio.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
var (
ErrLicenseNotFound = errors.New("store: licenza inesistente")
ErrLicenseRevoked = errors.New("store: licenza revocata")
ErrNoSeatsLeft = errors.New("store: nessuna postazione disponibile")
)
// Activation descrive una postazione occupata.
type Activation struct {
ID string
LicenseID string
Fingerprint string
Hostname string
ActivatedAt time.Time
LastSeenAt time.Time
}
// Activate registra una postazione rispettando il limite della licenza.
// L'operazione è idempotente rispetto alla coppia (licenza, fingerprint).
func (s *Store) Activate(ctx context.Context, licenseID, fp, hostname, version string) (*Activation, error) {
// BEGIN IMMEDIATE acquisisce subito il lock di scrittura: senza di esso
// due attivazioni concorrenti potrebbero leggere lo stesso conteggio
// e superare entrambe il controllo sulle postazioni.
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil && !isNestedTxErr(err) {
return nil, err
}
var maxSeats int
var revokedAt sql.NullInt64
err = tx.QueryRowContext(ctx,
`SELECT max_seats, revoked_at FROM licenses WHERE id = ?`, licenseID,
).Scan(&maxSeats, &revokedAt)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrLicenseNotFound
case err != nil:
return nil, err
case revokedAt.Valid:
return nil, ErrLicenseRevoked
}
now := time.Now().UTC()
// Se il fingerprint è già registrato aggiorno i metadati e restituisco
// l'attivazione esistente, senza consumare una nuova postazione.
var existingID string
var activatedAt int64
err = tx.QueryRowContext(ctx,
`SELECT id, activated_at FROM activations
WHERE license_id = ? AND fingerprint = ? AND released_at IS NULL`,
licenseID, fp,
).Scan(&existingID, &activatedAt)
if err == nil {
if _, err := tx.ExecContext(ctx,
`UPDATE activations SET last_seen_at = ?, hostname = ?, version = ? WHERE id = ?`,
now.Unix(), hostname, version, existingID,
); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &Activation{
ID: existingID,
LicenseID: licenseID,
Fingerprint: fp,
Hostname: hostname,
ActivatedAt: time.Unix(activatedAt, 0).UTC(),
LastSeenAt: now,
}, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
var used int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM activations WHERE license_id = ? AND released_at IS NULL`,
licenseID,
).Scan(&used); err != nil {
return nil, err
}
if used >= maxSeats {
return nil, fmt.Errorf("%w: %d/%d occupate", ErrNoSeatsLeft, used, maxSeats)
}
id := newID()
if _, err := tx.ExecContext(ctx,
`INSERT INTO activations
(id, license_id, fingerprint, hostname, version, activated_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, licenseID, fp, hostname, version, now.Unix(), now.Unix(),
); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &Activation{
ID: id,
LicenseID: licenseID,
Fingerprint: fp,
Hostname: hostname,
ActivatedAt: now,
LastSeenAt: now,
}, nil
}
Le postazioni abbandonate — macchine dismesse, container distrutti — vengono liberate da un processo periodico che marca come rilasciate le attivazioni il cui ultimo heartbeat è troppo vecchio. Il valore della soglia va scelto con generosità: liberare troppo presto è innocuo, liberare troppo tardi blocca il cliente.
// ReapStaleActivations libera le postazioni il cui heartbeat è scaduto.
// Restituisce il numero di postazioni recuperate.
func (s *Store) ReapStaleActivations(ctx context.Context, maxAge time.Duration) (int64, error) {
cutoff := time.Now().UTC().Add(-maxAge).Unix()
res, err := s.db.ExecContext(ctx,
`UPDATE activations SET released_at = ?
WHERE released_at IS NULL AND last_seen_at < ?`,
time.Now().UTC().Unix(), cutoff,
)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
Il server di attivazione
Il router della libreria standard, dalla versione 1.22, supporta i pattern con metodo e segmenti variabili: per un servizio di questa dimensione non serve altro.
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"example.com/licensing/internal/ratelimit"
"example.com/licensing/internal/store"
"example.com/licensing/pkg/license"
)
// Server raccoglie le dipendenze degli handler.
type Server struct {
store *store.Store
ring license.KeyRing
limiter *ratelimit.Limiter
log *slog.Logger
// leaseDuration è la validità dell'attivazione prima che il client
// debba rinnovarla con un heartbeat.
leaseDuration time.Duration
// gracePeriod è il tempo aggiuntivo concesso al client quando il
// server non è raggiungibile.
gracePeriod time.Duration
}
// Routes costruisce il router del servizio.
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/activate", s.handleActivate)
mux.HandleFunc("POST /v1/heartbeat", s.handleHeartbeat)
mux.HandleFunc("POST /v1/deactivate", s.handleDeactivate)
mux.HandleFunc("GET /v1/revocations", s.handleRevocations)
mux.HandleFunc("GET /healthz", s.handleHealth)
// L'ordine dei middleware è significativo: il logging avvolge tutto,
// il rate limiting precede l'esecuzione degli handler.
return s.withLogging(s.limiter.Middleware(mux))
}
Decodifica difensiva del corpo della richiesta
// maxBodySize limita la dimensione dei corpi accettati: gli endpoint
// ricevono soltanto oggetti JSON di poche centinaia di byte.
const maxBodySize = 32 << 10
// decodeJSON legge il corpo della richiesta in modo difensivo.
func decodeJSON[T any](w http.ResponseWriter, r *http.Request, dst *T) error {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
// Un secondo oggetto JSON nel corpo indica quasi sempre un client
// malformato o un tentativo di confusione: viene rifiutato.
if dec.More() {
return errors.New("corpo della richiesta con contenuto eccedente")
}
return nil
}
// writeJSON serializza la risposta con il codice di stato indicato.
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
// errorResponse è il formato uniforme degli errori restituiti dall'API.
type errorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Code: code, Message: message})
}
L'handler di attivazione
type activateRequest struct {
Token string `json:"token"`
Fingerprint string `json:"fingerprint"`
Hostname string `json:"hostname"`
Version string `json:"version"`
}
type activateResponse struct {
ActivationID string `json:"activation_id"`
LicenseID string `json:"license_id"`
Edition string `json:"edition"`
Features []string `json:"features"`
SeatsUsed int `json:"seats_used"`
SeatsMax int `json:"seats_max"`
LeaseUntil time.Time `json:"lease_until"`
GraceUntil time.Time `json:"grace_until"`
ServerTime time.Time `json:"server_time"`
}
func (s *Server) handleActivate(w http.ResponseWriter, r *http.Request) {
var req activateRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "corpo della richiesta non valido")
return
}
if req.Fingerprint == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "fingerprint mancante")
return
}
// La firma viene verificata anche lato server: il token potrebbe essere
// stato manomesso da un client che tenta di dichiarare più postazioni.
claims, err := license.Parse(req.Token, s.ring)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_token", "licenza non valida")
return
}
if err := license.Verify(claims, license.VerifyOptions{
Now: time.Now(),
Skew: 5 * time.Minute,
}); err != nil {
if errors.Is(err, license.ErrExpired) {
writeError(w, http.StatusForbidden, "expired", "licenza scaduta")
return
}
writeError(w, http.StatusForbidden, "invalid_token", "licenza non utilizzabile")
return
}
act, err := s.store.Activate(r.Context(), claims.ID, req.Fingerprint, req.Hostname, req.Version)
switch {
case errors.Is(err, store.ErrNoSeatsLeft):
writeError(w, http.StatusConflict, "no_seats", "tutte le postazioni sono occupate")
return
case errors.Is(err, store.ErrLicenseRevoked):
writeError(w, http.StatusForbidden, "revoked", "licenza revocata")
return
case errors.Is(err, store.ErrLicenseNotFound):
// La licenza è firmata ma non registrata: è il sintomo di una
// compromissione della chiave privata e va tracciato con priorità.
s.log.Error("licenza firmata ma sconosciuta", "license_id", claims.ID)
writeError(w, http.StatusForbidden, "unknown_license", "licenza non riconosciuta")
return
case err != nil:
s.log.Error("attivazione fallita", "error", err, "license_id", claims.ID)
writeError(w, http.StatusInternalServerError, "internal", "errore interno")
return
}
used, max, err := s.store.SeatUsage(r.Context(), claims.ID)
if err != nil {
s.log.Error("conteggio delle postazioni fallito", "error", err)
}
now := time.Now().UTC()
writeJSON(w, http.StatusOK, activateResponse{
ActivationID: act.ID,
LicenseID: claims.ID,
Edition: string(claims.Edition),
Features: claims.Features,
SeatsUsed: used,
SeatsMax: max,
LeaseUntil: now.Add(s.leaseDuration),
GraceUntil: now.Add(s.leaseDuration + s.gracePeriod),
ServerTime: now,
})
}
Il campo server_time non è decorativo: consente al client di rilevare uno scostamento significativo del proprio orologio e, soprattutto, gli fornisce un riferimento temporale autorevole che non può essere manipolato spostando semplicemente l'ora di sistema.
Limitare le richieste
Gli endpoint di attivazione sono pubblici e vanno protetti da tentativi di enumerazione e da client difettosi che ripetono la richiesta in ciclo stretto.
package ratelimit
import (
"net"
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
// Limiter applica un budget di richieste per indirizzo IP.
type Limiter struct {
mu sync.Mutex
visitors map[string]*visitor
rate rate.Limit
burst int
ttl time.Duration
}
// New crea un limitatore e avvia la pulizia periodica delle voci scadute.
func New(perMinute float64, burst int) *Limiter {
l := &Limiter{
visitors: make(map[string]*visitor),
rate: rate.Limit(perMinute / 60),
burst: burst,
ttl: 10 * time.Minute,
}
go l.cleanupLoop()
return l
}
func (l *Limiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
v, ok := l.visitors[key]
if !ok {
v = &visitor{limiter: rate.NewLimiter(l.rate, l.burst)}
l.visitors[key] = v
}
v.lastSeen = time.Now()
return v.limiter.Allow()
}
// cleanupLoop rimuove le voci inattive per evitare una crescita illimitata
// della mappa, che sarebbe di per sé un vettore di attacco.
func (l *Limiter) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Add(-l.ttl)
l.mu.Lock()
for key, v := range l.visitors {
if v.lastSeen.Before(cutoff) {
delete(l.visitors, key)
}
}
l.mu.Unlock()
}
}
// Middleware rifiuta con 429 le richieste eccedenti il budget.
func (l *Limiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if !l.allow(host) {
w.Header().Set("Retry-After", "60")
http.Error(w, "troppe richieste", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
Se il servizio è esposto dietro un reverse proxy, l'indirizzo va estratto dall'intestazione X-Forwarded-For, ma soltanto dopo aver verificato che la richiesta provenga effettivamente da un proxy attendibile: fidarsi ciecamente di quell'intestazione equivale a disattivare il rate limiting, perché il valore è sotto il controllo del client.
Revoca e distribuzione della lista
Una licenza può dover essere invalidata prima della scadenza: pagamento non andato a buon fine, token pubblicato per errore su un repository, chiusura anticipata del contratto. Per i client che operano online la revoca è immediata, perché il server rifiuta il rinnovo del lease. Per i client offline serve una lista di revoca firmata, distribuita attraverso lo stesso canale degli aggiornamenti del prodotto.
package license
import (
"crypto/ed25519"
"encoding/json"
"errors"
"time"
)
// RevocationList è l'elenco firmato delle licenze revocate. La firma è
// indispensabile: senza di essa un attaccante potrebbe distribuire una
// lista vuota per riabilitare una licenza revocata.
type RevocationList struct {
IssuedAt time.Time `json:"issued_at"`
NextUpdate time.Time `json:"next_update"`
Serial uint64 `json:"serial"`
Revoked []string `json:"revoked"`
}
// SignedRevocationList incapsula la lista e la relativa firma.
type SignedRevocationList struct {
KeyID string `json:"kid"`
Payload json.RawMessage `json:"payload"`
Sig string `json:"sig"`
}
// SignRevocationList produce la busta firmata da distribuire ai client.
func SignRevocationList(list RevocationList, keyID string, priv ed25519.PrivateKey) (*SignedRevocationList, error) {
payload, err := json.Marshal(list)
if err != nil {
return nil, err
}
return &SignedRevocationList{
KeyID: keyID,
Payload: payload,
Sig: b64.EncodeToString(ed25519.Sign(priv, payload)),
}, nil
}
// VerifyRevocationList controlla la firma e restituisce la lista decodificata.
// Il chiamante deve inoltre rifiutare le liste con Serial inferiore a quello
// già memorizzato, per impedire attacchi di rollback.
func VerifyRevocationList(signed *SignedRevocationList, ring KeyRing) (*RevocationList, error) {
pub, ok := ring[signed.KeyID]
if !ok {
return nil, ErrUnknownKey
}
sig, err := b64.DecodeString(signed.Sig)
if err != nil {
return nil, ErrMalformed
}
if !ed25519.Verify(pub, signed.Payload, sig) {
return nil, ErrBadSignature
}
var list RevocationList
if err := json.Unmarshal(signed.Payload, &list); err != nil {
return nil, ErrMalformed
}
if list.NextUpdate.Before(time.Now()) {
return nil, errors.New("license: lista di revoca scaduta")
}
return &list, nil
}
Il campo Serial monotono crescente merita attenzione. Senza di esso, un attaccante in grado di intercettare il traffico può servire indefinitamente una vecchia lista, legittimamente firmata, che non contiene la licenza revocata di recente. Il client deve memorizzare l'ultimo seriale accettato e rifiutare qualunque lista con seriale inferiore.
Il client embedded
La parte integrata nel prodotto deve bilanciare due esigenze contrapposte: applicare i limiti della licenza e non rendere il prodotto inutilizzabile quando la rete è temporaneamente assente. Il meccanismo del lease con periodo di grazia risolve entrambe.
package licensing
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// Lease è lo stato di attivazione memorizzato localmente dal client.
type Lease struct {
Token string `json:"token"`
ActivationID string `json:"activation_id"`
Fingerprint string `json:"fingerprint"`
LeaseUntil time.Time `json:"lease_until"`
GraceUntil time.Time `json:"grace_until"`
// LastServerTime registra l'ora dichiarata dal server all'ultimo
// contatto: consente di rilevare un orologio locale spostato indietro.
LastServerTime time.Time `json:"last_server_time"`
}
// Status descrive lo stato corrente della licenza per l'interfaccia utente.
type Status int
const (
StatusValid Status = iota
// StatusGrace indica che il lease è scaduto ma il periodo di grazia
// è ancora attivo: il prodotto funziona e mostra un avviso.
StatusGrace
StatusExpired
StatusInvalid
)
// Evaluate determina lo stato della licenza senza contattare la rete.
func (l *Lease) Evaluate(now time.Time) Status {
switch {
case l == nil:
return StatusInvalid
// Un orologio spostato indietro rispetto all'ultimo contatto con il
// server è un tentativo di manomissione: la licenza non viene estesa.
case now.Before(l.LastServerTime):
return StatusExpired
case now.Before(l.LeaseUntil):
return StatusValid
case now.Before(l.GraceUntil):
return StatusGrace
default:
return StatusExpired
}
}
// leasePath restituisce il percorso del file di stato nella directory
// di configurazione dell'utente.
func leasePath(product string) (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, product, "lease.json"), nil
}
// Save scrive il lease in modo atomico, per non lasciare un file corrotto
// se il processo termina durante la scrittura.
func (l *Lease) Save(product string) error {
path, err := leasePath(product)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
Il file di lease non è cifrato né protetto da manomissione, ed è corretto così: il token che contiene è firmato, e la modifica delle date locali produce al massimo un'estensione del periodo di grazia, che verrà comunque azzerata al successivo contatto con il server. Cifrare quel file darebbe una falsa sensazione di sicurezza, dal momento che la chiave dovrebbe essere presente nel binario.
Il ciclo di controllo in background
// Client gestisce il ciclo di vita della licenza all'interno del prodotto.
type Client struct {
product string
serverURL string
ring license.KeyRing
http *http.Client
mu sync.RWMutex
lease *Lease
claims *license.Claims
}
// Run avvia il rinnovo periodico del lease. Va lanciato in una goroutine
// dedicata e terminato tramite la cancellazione del contesto.
func (c *Client) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
if err := c.refresh(ctx); err != nil {
// Un errore di rete non è fatale: il periodo di grazia copre
// le interruzioni temporanee e il tentativo viene ripetuto.
slog.Warn("rinnovo della licenza fallito", "error", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// Allowed verifica se una funzionalità è utilizzabile nello stato corrente.
func (c *Client) Allowed(feature string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
if c.claims == nil {
return false
}
switch c.lease.Evaluate(time.Now()) {
case StatusValid, StatusGrace:
return c.claims.HasFeature(feature)
default:
return false
}
}
Un principio da rispettare rigorosamente: il controllo della licenza non deve mai bloccare l'avvio dell'applicazione in attesa di una risposta di rete. Il prodotto parte con lo stato salvato su disco, e il primo rinnovo avviene in background. Nulla irrita un cliente più di un software che non si avvia perché il server di attivazione del fornitore è irraggiungibile.
Lo strumento di emissione
La CLI amministrativa è il solo componente che accede alla chiave privata. Vive su una macchina controllata, idealmente con la chiave su un token hardware o in un modulo di gestione delle chiavi che espone soltanto l'operazione di firma.
// issueCmd emette una nuova licenza e la registra nel database.
func issueCmd(args []string) error {
fs := flag.NewFlagSet("issue", flag.ExitOnError)
customer := fs.String("customer", "", "identificativo del cliente")
name := fs.String("name", "", "ragione sociale mostrata nel prodotto")
product := fs.String("product", "acme-suite", "identificativo del prodotto")
edition := fs.String("edition", "professional", "edizione commerciale")
seats := fs.Int("seats", 1, "numero massimo di postazioni")
days := fs.Int("days", 365, "durata in giorni; 0 per licenza perpetua")
features := fs.String("features", "", "funzionalità aggiuntive separate da virgola")
keyPath := fs.String("key", "", "percorso della chiave privata PEM")
keyID := fs.String("kid", "", "identificativo della chiave di firma")
if err := fs.Parse(args); err != nil {
return err
}
if *customer == "" || *keyPath == "" || *keyID == "" {
fs.Usage()
return errors.New("parametri obbligatori mancanti")
}
priv, err := loadPrivateKey(*keyPath)
if err != nil {
return err
}
now := time.Now().UTC().Truncate(time.Second)
claims := license.Claims{
ID: newLicenseID(),
Product: *product,
Edition: license.Edition(*edition),
CustomerID: *customer,
CustomerName: *name,
IssuedAt: now,
NotBefore: now,
MaxSeats: *seats,
Online: true,
}
if *days > 0 {
claims.ExpiresAt = now.AddDate(0, 0, *days)
}
if *features != "" {
claims.Features = strings.Split(*features, ",")
}
token, err := license.Sign(claims, *keyID, priv)
if err != nil {
return err
}
// La product key è il riferimento breve comunicato al cliente:
// viene generata qui e associata alla licenza nel database.
productKey, err := license.NewProductKey(checksumSecret())
if err != nil {
return err
}
if err := db.InsertLicense(context.Background(), claims, productKey, token); err != nil {
return err
}
fmt.Printf("licenza: %s\n", claims.ID)
fmt.Printf("product key: %s\n", productKey)
fmt.Printf("token:\n%s\n", token)
return nil
}
Testare il sistema
Il codice di licensing è per definizione difficile da correggere dopo il rilascio: un errore che invalida licenze legittime raggiunge simultaneamente tutti i clienti. La copertura dei test deve essere sistematica, e in particolare deve includere i casi negativi.
package license_test
import (
"crypto/ed25519"
"crypto/rand"
"strings"
"testing"
"time"
"example.com/licensing/pkg/license"
)
// newTestKey genera una coppia di chiavi e il relativo portachiavi.
func newTestKey(t *testing.T, keyID string) (ed25519.PrivateKey, license.KeyRing) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generazione della chiave: %v", err)
}
return priv, license.KeyRing{keyID: pub}
}
func TestSignAndParseRoundTrip(t *testing.T) {
priv, ring := newTestKey(t, "test-1")
now := time.Now().UTC().Truncate(time.Second)
original := license.Claims{
ID: "lic_001",
Product: "acme-suite",
Edition: license.EditionEnterprise,
IssuedAt: now,
NotBefore: now,
ExpiresAt: now.AddDate(1, 0, 0),
MaxSeats: 10,
Features: []string{"sso", "audit-log"},
}
token, err := license.Sign(original, "test-1", priv)
if err != nil {
t.Fatalf("firma: %v", err)
}
parsed, err := license.Parse(token, ring)
if err != nil {
t.Fatalf("parsing: %v", err)
}
if parsed.ID != original.ID || parsed.MaxSeats != original.MaxSeats {
t.Fatalf("claims alterati nel round trip: %+v", parsed)
}
}
func TestParseRejectsTamperedPayload(t *testing.T) {
priv, ring := newTestKey(t, "test-1")
token, err := license.Sign(license.Claims{ID: "lic_001", MaxSeats: 1}, "test-1", priv)
if err != nil {
t.Fatalf("firma: %v", err)
}
// Modifico un carattere del payload mantenendo la firma originale:
// è l'attacco più elementare e deve essere rifiutato.
parts := strings.Split(token, ".")
parts[2] = "A" + parts[2][1:]
if _, err := license.Parse(strings.Join(parts, "."), ring); err == nil {
t.Fatal("un payload manomesso è stato accettato")
}
}
func TestParseRejectsKeyIDSubstitution(t *testing.T) {
// Due chiavi distinte: la firma prodotta con la prima non deve essere
// accettata dichiarando il key ID della seconda.
privA, ringA := newTestKey(t, "key-a")
_, ringB := newTestKey(t, "key-b")
token, err := license.Sign(license.Claims{ID: "lic_001"}, "key-a", privA)
if err != nil {
t.Fatalf("firma: %v", err)
}
parts := strings.Split(token, ".")
parts[1] = "key-b"
ring := license.KeyRing{}
for k, v := range ringA {
ring[k] = v
}
for k, v := range ringB {
ring[k] = v
}
if _, err := license.Parse(strings.Join(parts, "."), ring); err == nil {
t.Fatal("la sostituzione del key ID è stata accettata")
}
}
func TestVerifyExpiration(t *testing.T) {
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
claims := &license.Claims{
Product: "acme-suite",
NotBefore: now.Add(-24 * time.Hour),
ExpiresAt: now.Add(-time.Hour),
}
err := license.Verify(claims, license.VerifyOptions{Product: "acme-suite", Now: now})
if err == nil {
t.Fatal("una licenza scaduta è stata accettata")
}
}
Per il server, i test di integrazione con httptest.Server e un database SQLite temporaneo verificano il comportamento complessivo. Il test più importante è quello della concorrenza sull'ultima postazione disponibile.
func TestActivateRespectsSeatLimitUnderConcurrency(t *testing.T) {
srv, cleanup := newTestServer(t, 3) // licenza con tre postazioni
defer cleanup()
const clients = 20
var wg sync.WaitGroup
results := make(chan int, clients)
for i := 0; i < clients; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
// Ogni goroutine usa un fingerprint diverso: nessuna richiesta
// può essere soddisfatta per idempotenza.
results <- activate(t, srv.URL, fmt.Sprintf("fp-%02d", n))
}(i)
}
wg.Wait()
close(results)
var granted int
for status := range results {
if status == http.StatusOK {
granted++
}
}
if granted != 3 {
t.Fatalf("postazioni concesse = %d, attese 3", granted)
}
}
Rotazione delle chiavi
Una chiave di firma va sostituita periodicamente, e va sostituita immediatamente se si sospetta una compromissione. Il portachiavi rende l'operazione praticabile senza invalidare le licenze in circolazione, purché la sequenza sia rispettata.
- Si genera la nuova coppia con un key ID distinto.
- Si distribuisce una versione del prodotto che incorpora entrambe le chiavi pubbliche, quella vecchia e quella nuova.
- Si attende che il parco installato sia sufficientemente aggiornato, monitorando le versioni riportate dagli heartbeat.
- Si comincia a emettere licenze con la nuova chiave.
- Si rinnovano le licenze ancora firmate con la vecchia chiave in occasione del loro rinnovo naturale.
- Si rimuove la vecchia chiave pubblica solo quando nessuna licenza attiva la utilizza più.
Invertire i passi due e quattro è l'errore classico: le licenze nuove non verrebbero riconosciute dalle installazioni esistenti, con il risultato di bloccare i clienti che hanno appena rinnovato. In caso di compromissione conclamata la sequenza cambia: si revocano in blocco tutte le licenze firmate con la chiave compromessa e si emettono sostituzioni, accettando il disagio.
Deployment e osservabilità
Il server di attivazione è un componente piccolo ma critico: se è irraggiungibile per giorni, tutti i clienti entrano nel periodo di grazia e, alla sua scadenza, il prodotto smette di funzionare. Alcune scelte riducono il rischio.
- Periodo di grazia generoso. Un lease di ventiquattro ore con quattordici giorni di grazia significa che un'interruzione del servizio, per quanto grave, non causa danni ai clienti.
- Nessuna dipendenza esterna nel percorso critico. Il server deve poter rispondere alle attivazioni anche se il gestionale, il sistema di fatturazione o l'invio delle email non funzionano.
- Backup verificati del database. Perdere la tabella delle attivazioni significa azzerare il conteggio delle postazioni. Con SQLite in modalità WAL il backup si esegue con il comando
VACUUM INTO, che produce una copia coerente senza interrompere il servizio. - Log di audit immutabile. Ogni emissione, attivazione, revoca e rilascio va registrato con timestamp, licenza e indirizzo di origine. È il materiale su cui si basa qualsiasi contestazione futura.
Sul fronte delle metriche, quattro serie temporali coprono la quasi totalità dei problemi operativi: il tasso di attivazioni riuscite, il tasso di rifiuti per esaurimento delle postazioni, il numero di licenze in scadenza nei trenta giorni successivi e la latenza dell'endpoint di heartbeat. Un picco anomalo di rifiuti per esaurimento postazioni indica, di norma, un cliente che ha appena avviato un deployment su scala superiore a quella contrattata: è un'informazione commerciale prima ancora che tecnica.
# Compilazione del server per il deployment su Linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags)" \
-o bin/licensed ./cmd/licensed
# Backup coerente del database senza fermare il servizio
sqlite3 /var/lib/licensed/licenses.db "VACUUM INTO '/backup/licenses-$(date +%F).db'"
Considerazioni finali
Il sistema descritto copre l'intero ciclo di vita di una licenza: emissione firmata con Ed25519, formato del token versionato e resistente alla manipolazione dell'intestazione, chiavi di prodotto leggibili con checksum verificabile offline, fingerprint della macchina resiliente alle interfacce virtuali, conteggio transazionale delle postazioni, revoca distribuita tramite liste firmate con protezione dal rollback, e un client che degrada con grazia quando la rete manca.
Restano alcune estensioni naturali, il cui costo va valutato caso per caso. La licenza floating, in cui le postazioni vengono prese in prestito per una sessione e restituite alla chiusura, si ottiene riducendo drasticamente la durata del lease e aggiungendo un endpoint di rilascio esplicito. La misurazione a consumo richiede di trasformare l'heartbeat in un canale di telemetria aggregata, con tutte le implicazioni contrattuali e di riservatezza che ne conseguono. L'installazione air-gapped, tipica delle infrastrutture critiche, si affronta vincolando la licenza al fingerprint direttamente nel token, eliminando del tutto la necessità di un'attivazione online.
La raccomandazione conclusiva riguarda l'atteggiamento progettuale più che il codice. Un sistema di licenze è un punto di contatto quotidiano fra il prodotto e chi lo ha pagato: ogni suo errore viene percepito come un sospetto ingiustificato. Vale la pena, in ogni decisione dubbia, scegliere l'opzione più permissiva. Il cliente che sfora di una postazione per una settimana costa infinitamente meno di quello che rinuncia al rinnovo perché il software si è bloccato durante una manutenzione di rete.