Creare un sistema di gestione delle licenze software con Python

Creare un sistema di gestione delle licenze software con Python

Un sistema di licenze software serve a rispondere a tre domande: chi è il cliente, che cosa ha acquistato e per quanto tempo può usarlo. Tutto il resto (attivazioni, posti disponibili, revoche, periodi di grazia) è infrastruttura costruita attorno a queste tre risposte.

In questo articolo costruiremo un sistema completo in Python basato su firme digitali asimmetriche Ed25519, con licenze autocontenute verificabili offline, un server di attivazione per il conteggio dei posti e un meccanismo di revoca. Il codice è pensato per essere usato in produzione, non come giocattolo didattico.

Modello di minaccia e obiettivi realistici

Prima di scrivere una riga di codice conviene essere onesti su cosa un sistema di licenze può e non può fare. Se il software gira sulla macchina dell'utente, l'utente ha il controllo completo del processo: può patchare il bytecode, intercettare le chiamate di rete, sostituire la chiave pubblica incorporata nel binario. Nessuna tecnica lo impedisce in modo definitivo.

Gli obiettivi realistici sono quindi:

  • Impedire la creazione di licenze valide da parte di terzi. Questo è un obiettivo crittograficamente raggiungibile: senza la chiave privata nessuno può forgiare una licenza.
  • Impedire la modifica di una licenza esistente. Cambiare la data di scadenza o il numero di posti deve invalidare la firma.
  • Rendere il conteggio dei posti verificabile. Una licenza da 5 postazioni non deve poter essere attivata su 500 macchine senza che l'azienda se ne accorga.
  • Alzare il costo dell'aggiramento al punto in cui, per un cliente aziendale, pagare la licenza costa meno che violarla.

Quello che non è un obiettivo: fermare un attaccante determinato con un disassemblatore. Progettare il sistema come se lo fosse porta a complessità inutile e a bug che colpiscono i clienti paganti.

Architettura del sistema

Il sistema si articola su tre componenti separati:

  • L'issuer (emittente): un tool a riga di comando che gira su una macchina controllata dal vendor e custodisce la chiave privata. Produce token di licenza firmati.
  • Il server di attivazione: un servizio HTTP che registra le attivazioni, applica il limite dei posti, emette lease a breve scadenza e pubblica la lista di revoca.
  • Il client: una libreria incorporata nell'applicazione che verifica la licenza offline con la chiave pubblica, gestisce la cache del lease e applica il periodo di grazia.

La scelta chiave è che la licenza è autocontenuta e verificabile offline. Il server serve solo per il conteggio dei posti e per la revoca: se il server è irraggiungibile, l'applicazione continua a funzionare fino alla scadenza del lease più il periodo di grazia. Questo elimina la peggiore modalità di guasto di un sistema di licenze, ovvero il blocco di tutti i clienti quando il servizio del vendor va offline.

Useremo Ed25519 invece di RSA per tre motivi: le chiavi pubbliche sono di 32 byte (facili da incorporare in una costante nel sorgente), le firme sono di 64 byte (il token resta corto), e l'implementazione non ha i parametri configurabili di RSA che si possono sbagliare.

Preparazione dell'ambiente

python -m venv .venv
source .venv/bin/activate
pip install cryptography fastapi "uvicorn[standard]" pydantic httpx pytest

La struttura del progetto:

licensing/
├── __init__.py
├── model.py          # dataclass della licenza e serializzazione canonica
├── keys.py           # generazione e caricamento delle chiavi
├── issuer.py         # firma dei token
├── verifier.py       # verifica offline
├── fingerprint.py    # identificazione della macchina
├── lease.py          # token di attivazione a breve scadenza
├── gate.py           # integrazione runtime nell'applicazione
└── cli.py            # tool di emissione
server/
├── __init__.py
├── db.py
└── app.py
tests/
└── test_licensing.py

Il modello dati della licenza

La licenza è una struttura immutabile. Ogni campo che finisce nella firma deve essere esplicito: quello che non viene firmato non è affidabile.

# licensing/model.py
from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import datetime, timezone

LICENSE_SCHEMA_VERSION = 1


def to_iso(value: datetime) -> str:
    # Rifiutiamo i datetime naive: un fuso orario implicito e' una fonte
    # inesauribile di bug nelle scadenze
    if value.tzinfo is None:
        raise ValueError("naive datetime is not allowed")
    normalized = value.astimezone(timezone.utc).replace(microsecond=0)
    return normalized.isoformat().replace("+00:00", "Z")


def from_iso(value: str) -> datetime:
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


@dataclass(frozen=True, slots=True)
class License:
    license_id: str
    product_id: str
    customer_id: str
    edition: str
    seats: int
    issued_at: datetime
    expires_at: datetime | None = None
    features: tuple[str, ...] = ()
    schema_version: int = LICENSE_SCHEMA_VERSION

    def to_payload(self) -> dict:
        # Chiavi corte: il payload finisce in base64 dentro una stringa
        # che il cliente dovra' copiare e incollare
        return {
            "v": self.schema_version,
            "lid": self.license_id,
            "pid": self.product_id,
            "cid": self.customer_id,
            "ed": self.edition,
            "seats": self.seats,
            "iat": to_iso(self.issued_at),
            "exp": to_iso(self.expires_at) if self.expires_at else None,
            "feat": sorted(self.features),
        }

    @classmethod
    def from_payload(cls, payload: dict) -> "License":
        expires_raw = payload.get("exp")
        return cls(
            license_id=payload["lid"],
            product_id=payload["pid"],
            customer_id=payload["cid"],
            edition=payload["ed"],
            seats=int(payload["seats"]),
            issued_at=from_iso(payload["iat"]),
            expires_at=from_iso(expires_raw) if expires_raw else None,
            features=tuple(payload.get("feat", ())),
            schema_version=int(payload["v"]),
        )

    def has_feature(self, name: str) -> bool:
        return name in self.features

    @property
    def is_perpetual(self) -> bool:
        return self.expires_at is None


def canonical_bytes(payload: dict) -> bytes:
    # Serializzazione canonica: chiavi ordinate e separatori compatti.
    # Lo stesso dizionario deve produrre sempre gli stessi byte, altrimenti
    # la firma calcolata dall'issuer non corrisponde a quella verificata
    # dal client.
    return json.dumps(
        payload,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    ).encode("utf-8")

Il punto delicato è canonical_bytes. Un errore classico consiste nel firmare il payload deserializzato e riserializzato con impostazioni diverse: basta uno spazio dopo i due punti perché la verifica fallisca su una licenza perfettamente valida. Nella verifica, come vedremo, firmeremo e verificheremo sempre i byte trasmessi, mai una loro ricostruzione.

Generazione e gestione delle chiavi

# licensing/keys.py
from __future__ import annotations

import base64
import hashlib
from pathlib import Path

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey,
    Ed25519PublicKey,
)


def generate_keypair(
    private_path: Path,
    public_path: Path,
    password: bytes | None = None,
) -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
    private_key = Ed25519PrivateKey.generate()
    public_key = private_key.public_key()

    encryption = (
        serialization.BestAvailableEncryption(password)
        if password
        else serialization.NoEncryption()
    )
    private_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=encryption,
    )
    public_pem = public_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    )

    private_path.write_bytes(private_pem)
    # La chiave privata non deve essere leggibile da altri utenti del sistema
    private_path.chmod(0o600)
    public_path.write_bytes(public_pem)
    return private_key, public_key


def load_private_key(path: Path, password: bytes | None = None) -> Ed25519PrivateKey:
    key = serialization.load_pem_private_key(path.read_bytes(), password=password)
    if not isinstance(key, Ed25519PrivateKey):
        raise TypeError("expected an Ed25519 private key")
    return key


def load_public_key(path: Path) -> Ed25519PublicKey:
    key = serialization.load_pem_public_key(path.read_bytes())
    if not isinstance(key, Ed25519PublicKey):
        raise TypeError("expected an Ed25519 public key")
    return key


def public_key_raw(public_key: Ed25519PublicKey) -> bytes:
    # 32 byte grezzi: la forma piu' compatta per incorporare la chiave
    # direttamente nel sorgente dell'applicazione
    return public_key.public_bytes(
        encoding=serialization.Encoding.Raw,
        format=serialization.PublicFormat.Raw,
    )


def public_key_from_raw(data: bytes) -> Ed25519PublicKey:
    return Ed25519PublicKey.from_public_bytes(data)


def key_id(public_key: Ed25519PublicKey) -> str:
    # Identificatore breve e stabile della chiave: serve per la rotazione,
    # perche' il client deve sapere quale chiave usare per la verifica
    digest = hashlib.sha256(public_key_raw(public_key)).digest()
    return base64.urlsafe_b64encode(digest[:6]).decode("ascii").rstrip("=")

Il key_id è il dettaglio che rende possibile la rotazione delle chiavi senza invalidare le licenze già emesse. Il client mantiene un dizionario di chiavi pubbliche indicizzate per kid: quando si introduce una nuova chiave, si aggiunge al dizionario nella versione successiva dell'applicazione e le licenze vecchie continuano a verificarsi.

L'issuer: firma dei token

Il formato del token è composto da tre parti separate da punto: un prefisso di versione, il payload in base64url e la firma in base64url.

# licensing/issuer.py
from __future__ import annotations

import base64

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

from .keys import key_id
from .model import License, canonical_bytes

TOKEN_PREFIX = "LIC1"


def b64encode(raw: bytes) -> str:
    # base64url senza padding: il token resta copiabile senza escaping
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def b64decode(value: str) -> bytes:
    padding = "=" * (-len(value) % 4)
    return base64.urlsafe_b64decode(value + padding)


class LicenseIssuer:
    def __init__(self, private_key: Ed25519PrivateKey) -> None:
        self._private_key = private_key
        self._key_id = key_id(private_key.public_key())

    def issue(self, license_data: License) -> str:
        payload = license_data.to_payload()
        # Il kid viene inserito nel payload firmato: se un attaccante lo
        # modificasse per puntare a un'altra chiave, la firma non tornerebbe
        payload["kid"] = self._key_id
        message = canonical_bytes(payload)
        signature = self._private_key.sign(message)
        return f"{TOKEN_PREFIX}.{b64encode(message)}.{b64encode(signature)}"


def format_for_humans(token: str, group_size: int = 48) -> str:
    # Spezza il token in righe leggibili per email e file di licenza
    return "\n".join(
        token[index : index + group_size]
        for index in range(0, len(token), group_size)
    )

Un token tipico ha una lunghezza di circa 300 caratteri. Non è una chiave da cinque gruppi di cinque lettere che l'utente digita a mano: è un blocco di testo da incollare o un file license.key da posizionare nella directory di configurazione. Se serve una chiave breve digitabile, il modello corretto è diverso — una stringa casuale opaca che il server risolve in una licenza vera — e lo tratteremo nella sezione sull'attivazione.

Il verifier: validazione offline

La verifica è il componente più importante del sistema, perché gira sulla macchina del cliente e determina se l'applicazione parte. Deve essere rigorosa e produrre errori distinguibili: un utente con una licenza scaduta merita un messaggio diverso da uno con una licenza corrotta.

# licensing/verifier.py
from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

from .issuer import TOKEN_PREFIX, b64decode
from .model import License


class LicenseError(Exception):
    """Base class for every licensing failure."""


class MalformedLicense(LicenseError):
    pass


class BadSignature(LicenseError):
    pass


class UnknownSigningKey(LicenseError):
    pass


class ProductMismatch(LicenseError):
    pass


class LicenseExpired(LicenseError):
    def __init__(self, expired_at: datetime) -> None:
        super().__init__(f"license expired at {expired_at.isoformat()}")
        self.expired_at = expired_at


class LicenseVerifier:
    def __init__(
        self,
        public_keys: dict[str, Ed25519PublicKey],
        product_id: str,
        clock_skew: timedelta = timedelta(hours=24),
    ) -> None:
        self._public_keys = public_keys
        self._product_id = product_id
        self._clock_skew = clock_skew

    def verify(self, token: str, now: datetime | None = None) -> License:
        now = now or datetime.now(timezone.utc)
        payload_dict, message = self._decode_and_authenticate(token)

        license_data = License.from_payload(payload_dict)
        if license_data.product_id != self._product_id:
            raise ProductMismatch(
                f"license is for product {license_data.product_id!r}"
            )
        if license_data.expires_at is not None:
            # La tolleranza sull'orologio evita falsi negativi su macchine
            # con l'ora leggermente sfasata
            if now > license_data.expires_at + self._clock_skew:
                raise LicenseExpired(license_data.expires_at)
        return license_data

    def _decode_and_authenticate(self, token: str) -> tuple[dict, bytes]:
        cleaned = "".join(token.split())
        parts = cleaned.split(".")
        if len(parts) != 3 or parts[0] != TOKEN_PREFIX:
            raise MalformedLicense("unrecognized license format")

        try:
            message = b64decode(parts[1])
            signature = b64decode(parts[2])
        except (ValueError, TypeError) as exc:
            raise MalformedLicense("license is not valid base64") from exc

        try:
            payload_dict = json.loads(message.decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
            raise MalformedLicense("license payload is not valid JSON") from exc
        if not isinstance(payload_dict, dict):
            raise MalformedLicense("license payload must be an object")

        signing_key_id = payload_dict.get("kid")
        public_key = self._public_keys.get(signing_key_id)
        if public_key is None:
            raise UnknownSigningKey(f"unknown signing key {signing_key_id!r}")

        try:
            # Verifichiamo i byte trasmessi, non una loro riserializzazione:
            # e' l'unico modo per essere immuni alle differenze di formato
            public_key.verify(signature, message)
        except InvalidSignature as exc:
            raise BadSignature("license signature does not match") from exc

        return payload_dict, message

Da notare l'ordine delle operazioni: la firma viene verificata prima di interpretare qualunque campo del payload come dato affidabile. L'unico campo letto prima della verifica è kid, che serve a scegliere la chiave; ma poiché kid è a sua volta dentro il messaggio firmato, un attaccante non può alterarlo senza rompere la firma.

La chiave pubblica viene incorporata nel sorgente dell'applicazione:

# myapp/licensing_config.py
import base64

from licensing.keys import public_key_from_raw

# Chiave pubblica di produzione, 32 byte in base64.
# Sostituirla richiede una nuova release: e' esattamente cio' che vogliamo
TRUSTED_KEYS_RAW = {
    "Kx7dQmVs": "TDoTvKAyoY0tOmpfy2Ky1sHNXjHFTs6MOXXPh0MKGYE=",
}

TRUSTED_KEYS = {
    identifier: public_key_from_raw(base64.b64decode(encoded))
    for identifier, encoded in TRUSTED_KEYS_RAW.items()
}

PRODUCT_ID = "com.example.analytics"

Identificazione della macchina

Per legare un'attivazione a una postazione serve un identificatore stabile. Ogni sistema operativo ne espone uno: /etc/machine-id su Linux, MachineGuid nel registro su Windows, IOPlatformUUID su macOS. Nessuno di questi va inviato in chiaro al server: si trasmette un HMAC, così il server non accumula identificatori hardware reali dei clienti.

# licensing/fingerprint.py
from __future__ import annotations

import hashlib
import hmac
import platform
import re
import subprocess
import uuid
from pathlib import Path

# Il salt lega il fingerprint al prodotto: lo stesso computer produce
# identificatori diversi per applicazioni diverse
FINGERPRINT_SALT = b"com.example.analytics/fingerprint/v1"


def _read_first_existing(paths: tuple[str, ...]) -> str | None:
    for raw_path in paths:
        path = Path(raw_path)
        if path.is_file():
            content = path.read_text(encoding="utf-8", errors="ignore").strip()
            if content:
                return content
    return None


def _windows_machine_guid() -> str | None:
    try:
        import winreg
    except ImportError:
        return None
    try:
        access = winreg.KEY_READ | winreg.KEY_WOW64_64KEY
        with winreg.OpenKey(
            winreg.HKEY_LOCAL_MACHINE,
            r"SOFTWARE\Microsoft\Cryptography",
            0,
            access,
        ) as handle:
            value, _ = winreg.QueryValueEx(handle, "MachineGuid")
            return str(value)
    except OSError:
        return None


def _macos_platform_uuid() -> str | None:
    try:
        completed = subprocess.run(
            ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
            capture_output=True,
            text=True,
            timeout=5,
            check=True,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    match = re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"', completed.stdout)
    return match.group(1) if match else None


def raw_machine_id() -> str:
    system = platform.system()
    if system == "Linux":
        value = _read_first_existing(
            ("/etc/machine-id", "/var/lib/dbus/machine-id")
        )
    elif system == "Windows":
        value = _windows_machine_guid()
    elif system == "Darwin":
        value = _macos_platform_uuid()
    else:
        value = None
    # Ripiego sull'indirizzo MAC: meno stabile, ma sempre disponibile
    return value or f"mac-{uuid.getnode():012x}"


def machine_fingerprint() -> str:
    digest = hmac.new(
        FINGERPRINT_SALT,
        raw_machine_id().encode("utf-8"),
        hashlib.sha256,
    )
    return digest.hexdigest()[:32]

Attenzione a due trappole. La prima: nei container Docker /etc/machine-id è spesso identico su tutte le istanze costruite dalla stessa immagine, oppure cambia a ogni riavvio. Se il prodotto è destinato a girare in container, occorre prevedere una variabile d'ambiente che permetta di fissare il fingerprint esplicitamente. La seconda: non combinare troppi segnali hardware (CPU, dischi, schede di rete) sperando in maggiore robustezza, perché il risultato è che il fingerprint cambia quando l'utente aggiunge un disco e la licenza si "consuma" un posto senza motivo. Un solo identificatore stabile, con un ripiego, è la scelta corretta.

Il lease: token di attivazione a breve scadenza

Il server non risponde con un semplice "ok". Restituisce un lease: un token firmato, valido pochi giorni, che lega la licenza a una macchina specifica. Il client lo memorizza in cache e lo verifica offline a ogni avvio. Questo è ciò che rende il conteggio dei posti applicabile senza richiedere connettività continua.

# licensing/lease.py
from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey,
    Ed25519PublicKey,
)

from .issuer import b64decode, b64encode
from .keys import key_id
from .model import License, canonical_bytes, from_iso, to_iso
from .verifier import BadSignature, MalformedLicense, UnknownSigningKey

LEASE_PREFIX = "LEA1"


@dataclass(frozen=True, slots=True)
class Lease:
    license_id: str
    product_id: str
    machine_hash: str
    issued_at: datetime
    expires_at: datetime
    seats: int
    features: tuple[str, ...]

    def is_valid_at(self, moment: datetime) -> bool:
        return moment <= self.expires_at


def issue_lease(
    private_key: Ed25519PrivateKey,
    license_data: License,
    machine_hash: str,
    ttl: timedelta,
) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "kid": key_id(private_key.public_key()),
        "lid": license_data.license_id,
        "pid": license_data.product_id,
        "mh": machine_hash,
        "iat": to_iso(now),
        "exp": to_iso(now + ttl),
        "seats": license_data.seats,
        "feat": sorted(license_data.features),
    }
    message = canonical_bytes(payload)
    signature = private_key.sign(message)
    return f"{LEASE_PREFIX}.{b64encode(message)}.{b64encode(signature)}"


def verify_lease(
    token: str,
    public_keys: dict[str, Ed25519PublicKey],
    expected_machine_hash: str,
) -> Lease:
    parts = "".join(token.split()).split(".")
    if len(parts) != 3 or parts[0] != LEASE_PREFIX:
        raise MalformedLicense("unrecognized lease format")

    message = b64decode(parts[1])
    signature = b64decode(parts[2])
    payload = json.loads(message.decode("utf-8"))

    public_key = public_keys.get(payload.get("kid"))
    if public_key is None:
        raise UnknownSigningKey("unknown lease signing key")
    try:
        public_key.verify(signature, message)
    except InvalidSignature as exc:
        raise BadSignature("lease signature does not match") from exc

    # Un lease copiato su un'altra macchina non deve funzionare
    if payload["mh"] != expected_machine_hash:
        raise BadSignature("lease was issued for a different machine")

    return Lease(
        license_id=payload["lid"],
        product_id=payload["pid"],
        machine_hash=payload["mh"],
        issued_at=from_iso(payload["iat"]),
        expires_at=from_iso(payload["exp"]),
        seats=int(payload["seats"]),
        features=tuple(payload.get("feat", ())),
    )

Il server di attivazione

Il server ha una responsabilità circoscritta: sapere quante macchine hanno attivato ciascuna licenza e rifiutare le attivazioni oltre il limite. Non ha bisogno di conoscere le licenze in anticipo, perché il token è autocontenuto e firmato: la riga nel database viene creata alla prima attivazione.

Lo schema del database

# server/db.py
from __future__ import annotations

import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator

DB_PATH = Path("licensing.db")

SCHEMA = """
CREATE TABLE IF NOT EXISTS licenses (
    license_id   TEXT PRIMARY KEY,
    product_id   TEXT NOT NULL,
    customer_id  TEXT NOT NULL,
    seats        INTEGER NOT NULL,
    first_seen_at TEXT NOT NULL,
    revoked_at   TEXT,
    revoke_reason TEXT
);

CREATE TABLE IF NOT EXISTS activations (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    license_id   TEXT NOT NULL REFERENCES licenses(license_id),
    machine_hash TEXT NOT NULL,
    hostname     TEXT,
    activated_at TEXT NOT NULL,
    last_seen_at TEXT NOT NULL,
    released_at  TEXT,
    UNIQUE (license_id, machine_hash)
);

CREATE INDEX IF NOT EXISTS idx_activations_active
    ON activations (license_id) WHERE released_at IS NULL;
"""


@contextmanager
def connection() -> Iterator[sqlite3.Connection]:
    # isolation_level=None disattiva la gestione implicita delle transazioni:
    # le controlliamo esplicitamente con BEGIN IMMEDIATE
    conn = sqlite3.connect(DB_PATH, isolation_level=None, timeout=10.0)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA foreign_keys=ON")
    try:
        yield conn
    finally:
        conn.close()


def init_db() -> None:
    with connection() as conn:
        conn.executescript(SCHEMA)

Il vincolo UNIQUE (license_id, machine_hash) è la garanzia strutturale contro il doppio conteggio: una macchina che si riattiva aggiorna la riga esistente invece di crearne una nuova. La colonna released_at permette il rilascio del posto (disattivazione) senza cancellare lo storico.

Gli endpoint

# server/app.py
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from pathlib import Path

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

from licensing.keys import key_id, load_private_key, load_public_key
from licensing.lease import issue_lease
from licensing.model import to_iso
from licensing.verifier import LicenseError, LicenseVerifier

from .db import connection, init_db

PRODUCT_ID = "com.example.analytics"
LEASE_TTL = timedelta(days=7)

private_key = load_private_key(Path("/etc/licensing/private.pem"))
public_key = load_public_key(Path("/etc/licensing/public.pem"))
verifier = LicenseVerifier({key_id(public_key): public_key}, PRODUCT_ID)

app = FastAPI(title="License Activation Service")


@app.on_event("startup")
def on_startup() -> None:
    init_db()


class ActivationRequest(BaseModel):
    license_token: str = Field(min_length=32, max_length=8192)
    machine_hash: str = Field(min_length=16, max_length=64, pattern=r"^[0-9a-f]+$")
    hostname: str | None = Field(default=None, max_length=255)


class ActivationResponse(BaseModel):
    lease_token: str
    seats_used: int
    seats_total: int
    expires_at: str


def now_iso() -> str:
    return to_iso(datetime.now(timezone.utc))


@app.post("/v1/activate", response_model=ActivationResponse)
def activate(request: ActivationRequest) -> ActivationResponse:
    try:
        license_data = verifier.verify(request.license_token)
    except LicenseError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    with connection() as conn:
        # BEGIN IMMEDIATE acquisisce subito il lock di scrittura: senza,
        # due attivazioni concorrenti potrebbero superare il limite di posti
        conn.execute("BEGIN IMMEDIATE")
        try:
            row = conn.execute(
                "SELECT revoked_at FROM licenses WHERE license_id = ?",
                (license_data.license_id,),
            ).fetchone()

            if row is None:
                conn.execute(
                    "INSERT INTO licenses "
                    "(license_id, product_id, customer_id, seats, first_seen_at) "
                    "VALUES (?, ?, ?, ?, ?)",
                    (
                        license_data.license_id,
                        license_data.product_id,
                        license_data.customer_id,
                        license_data.seats,
                        now_iso(),
                    ),
                )
            elif row["revoked_at"]:
                raise HTTPException(status_code=403, detail="license revoked")

            existing = conn.execute(
                "SELECT id FROM activations "
                "WHERE license_id = ? AND machine_hash = ?",
                (license_data.license_id, request.machine_hash),
            ).fetchone()

            if existing is None:
                used = conn.execute(
                    "SELECT COUNT(*) AS total FROM activations "
                    "WHERE license_id = ? AND released_at IS NULL",
                    (license_data.license_id,),
                ).fetchone()["total"]
                if used >= license_data.seats:
                    raise HTTPException(
                        status_code=409,
                        detail=f"all {license_data.seats} seats are in use",
                    )
                conn.execute(
                    "INSERT INTO activations "
                    "(license_id, machine_hash, hostname, activated_at, last_seen_at) "
                    "VALUES (?, ?, ?, ?, ?)",
                    (
                        license_data.license_id,
                        request.machine_hash,
                        request.hostname,
                        now_iso(),
                        now_iso(),
                    ),
                )
            else:
                # Riattivazione della stessa macchina: non consuma un posto
                conn.execute(
                    "UPDATE activations "
                    "SET released_at = NULL, last_seen_at = ?, hostname = ? "
                    "WHERE id = ?",
                    (now_iso(), request.hostname, existing["id"]),
                )

            seats_used = conn.execute(
                "SELECT COUNT(*) AS total FROM activations "
                "WHERE license_id = ? AND released_at IS NULL",
                (license_data.license_id,),
            ).fetchone()["total"]
            conn.execute("COMMIT")
        except Exception:
            conn.execute("ROLLBACK")
            raise

    lease_token = issue_lease(
        private_key, license_data, request.machine_hash, LEASE_TTL
    )
    return ActivationResponse(
        lease_token=lease_token,
        seats_used=seats_used,
        seats_total=license_data.seats,
        expires_at=to_iso(datetime.now(timezone.utc) + LEASE_TTL),
    )


class ReleaseRequest(BaseModel):
    license_token: str
    machine_hash: str = Field(pattern=r"^[0-9a-f]+$")


@app.post("/v1/release", status_code=204)
def release(request: ReleaseRequest) -> None:
    try:
        license_data = verifier.verify(request.license_token)
    except LicenseError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    with connection() as conn:
        conn.execute(
            "UPDATE activations SET released_at = ? "
            "WHERE license_id = ? AND machine_hash = ? AND released_at IS NULL",
            (now_iso(), license_data.license_id, request.machine_hash),
        )

La chiamata a BEGIN IMMEDIATE merita attenzione. Con SQLite in modalità di default, la sequenza "conta i posti usati, poi inserisci" è soggetta a una race condition: due richieste simultanee leggono entrambe 4 posti su 5 e inseriscono entrambe, arrivando a 6. Acquisire il lock di scrittura all'inizio della transazione serializza le attivazioni. Su PostgreSQL l'equivalente è SELECT ... FOR UPDATE sulla riga della licenza.

Revoca delle licenze

Una licenza può dover essere invalidata prima della scadenza: rimborso, chargeback, violazione contrattuale. Poiché il token è autocontenuto e verificabile offline, la revoca non può essere immediata: si propaga alla scadenza del lease. È un compromesso accettabile con lease di durata breve.

Il server pubblica una lista di revoca firmata:

# server/app.py (continua)

class RevocationList(BaseModel):
    issued_at: str
    sequence: int
    revoked_ids: list[str]
    signature: str


@app.get("/v1/revocations", response_model=RevocationList)
def revocations() -> RevocationList:
    from licensing.issuer import b64encode
    from licensing.model import canonical_bytes

    with connection() as conn:
        rows = conn.execute(
            "SELECT license_id FROM licenses WHERE revoked_at IS NOT NULL "
            "ORDER BY license_id"
        ).fetchall()

    payload = {
        "iat": now_iso(),
        # Il numero di sequenza monotono impedisce a un attaccante di
        # rimettere in circolo una lista vecchia in cui la licenza revocata
        # non era ancora presente
        "seq": int(datetime.now(timezone.utc).timestamp()),
        "ids": [row["license_id"] for row in rows],
    }
    signature = private_key.sign(canonical_bytes(payload))
    return RevocationList(
        issued_at=payload["iat"],
        sequence=payload["seq"],
        revoked_ids=payload["ids"],
        signature=b64encode(signature),
    )

La firma sulla lista è indispensabile: senza, chiunque controlli la rete potrebbe consegnare al client una lista vuota, oppure una lista che revoca le licenze dei concorrenti. Il numero di sequenza monotono protegge dagli attacchi di replay: il client rifiuta una lista con sequenza inferiore all'ultima memorizzata.

Il gate di licenza nell'applicazione

Ecco il componente che l'applicazione chiama all'avvio. Incapsula tutta la logica di cache, rinnovo e periodo di grazia.

# licensing/gate.py
from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path

import httpx
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

from .fingerprint import machine_fingerprint
from .lease import Lease, verify_lease
from .model import License, from_iso, to_iso
from .verifier import LicenseError, LicenseVerifier


@dataclass(frozen=True, slots=True)
class Entitlement:
    license_data: License
    lease: Lease | None
    offline: bool
    grace_days_left: int | None


class ActivationRequired(LicenseError):
    pass


class GracePeriodExpired(LicenseError):
    pass


class ClockTampering(LicenseError):
    pass


class LicenseGate:
    def __init__(
        self,
        license_path: Path,
        state_dir: Path,
        verifier: LicenseVerifier,
        public_keys: dict[str, Ed25519PublicKey],
        api_url: str,
        grace_period: timedelta = timedelta(days=14),
    ) -> None:
        self._license_path = license_path
        self._state_dir = state_dir
        self._verifier = verifier
        self._public_keys = public_keys
        self._api_url = api_url.rstrip("/")
        self._grace_period = grace_period
        self._lease_path = state_dir / "lease.token"
        self._clock_path = state_dir / "clock.json"

    def check(self) -> Entitlement:
        now = self._checked_now()
        license_data = self._load_license(now)
        machine_hash = machine_fingerprint()

        lease = self._load_cached_lease(machine_hash)
        if lease is not None and lease.is_valid_at(now):
            return Entitlement(license_data, lease, offline=False, grace_days_left=None)

        # Lease assente o scaduto: proviamo il rinnovo online
        try:
            lease = self._activate_online(license_data, machine_hash)
        except (httpx.HTTPError, OSError):
            return self._fall_back_to_grace(license_data, lease, now)

        return Entitlement(license_data, lease, offline=False, grace_days_left=None)

    def _load_license(self, now: datetime) -> License:
        if not self._license_path.is_file():
            raise ActivationRequired("no license file found")
        token = self._license_path.read_text(encoding="utf-8")
        return self._verifier.verify(token, now=now)

    def _load_cached_lease(self, machine_hash: str) -> Lease | None:
        if not self._lease_path.is_file():
            return None
        try:
            return verify_lease(
                self._lease_path.read_text(encoding="utf-8"),
                self._public_keys,
                machine_hash,
            )
        except LicenseError:
            # Un lease corrotto o appartenente ad altra macchina viene
            # semplicemente ignorato: si ripiega sull'attivazione online
            return None

    def _activate_online(self, license_data: License, machine_hash: str) -> Lease:
        import socket

        response = httpx.post(
            f"{self._api_url}/v1/activate",
            json={
                "license_token": self._license_path.read_text(encoding="utf-8"),
                "machine_hash": machine_hash,
                "hostname": socket.gethostname()[:255],
            },
            timeout=10.0,
        )
        if response.status_code == 409:
            raise ActivationRequired("no seats available for this license")
        if response.status_code == 403:
            raise ActivationRequired("this license has been revoked")
        response.raise_for_status()

        lease_token = response.json()["lease_token"]
        self._state_dir.mkdir(parents=True, exist_ok=True)
        self._lease_path.write_text(lease_token, encoding="utf-8")
        return verify_lease(lease_token, self._public_keys, machine_hash)

    def _fall_back_to_grace(
        self, license_data: License, lease: Lease | None, now: datetime
    ) -> Entitlement:
        if lease is None:
            raise ActivationRequired("first activation requires connectivity")
        deadline = lease.expires_at + self._grace_period
        if now > deadline:
            raise GracePeriodExpired("offline grace period is over")
        days_left = max(0, (deadline - now).days)
        return Entitlement(license_data, lease, offline=True, grace_days_left=days_left)

    def _checked_now(self) -> datetime:
        # Difesa contro lo spostamento indietro dell'orologio di sistema:
        # memorizziamo il timestamp massimo osservato finora
        now = datetime.now(timezone.utc)
        if self._clock_path.is_file():
            try:
                stored = from_iso(json.loads(self._clock_path.read_text())["max_seen"])
            except (ValueError, KeyError, json.JSONDecodeError):
                stored = None
            if stored is not None and now < stored - timedelta(hours=48):
                raise ClockTampering("system clock moved backwards")
        self._state_dir.mkdir(parents=True, exist_ok=True)
        self._clock_path.write_text(json.dumps({"max_seen": to_iso(now)}))
        return now

L'uso nell'applicazione resta banale:

# myapp/main.py
import sys
from datetime import timedelta
from pathlib import Path

from licensing.gate import (
    ActivationRequired,
    GracePeriodExpired,
    LicenseGate,
)
from licensing.verifier import LicenseError, LicenseExpired, LicenseVerifier

from .licensing_config import PRODUCT_ID, TRUSTED_KEYS


def start_application() -> int:
    gate = LicenseGate(
        license_path=Path.home() / ".config" / "analytics" / "license.key",
        state_dir=Path.home() / ".local" / "state" / "analytics",
        verifier=LicenseVerifier(TRUSTED_KEYS, PRODUCT_ID),
        public_keys=TRUSTED_KEYS,
        api_url="https://licensing.example.com",
        grace_period=timedelta(days=14),
    )

    try:
        entitlement = gate.check()
    except LicenseExpired as exc:
        print(f"License expired on {exc.expired_at:%d/%m/%Y}.", file=sys.stderr)
        return 2
    except ActivationRequired as exc:
        print(f"Activation required: {exc}", file=sys.stderr)
        return 3
    except GracePeriodExpired:
        print("Offline grace period is over. Connect to the internet.", file=sys.stderr)
        return 4
    except LicenseError as exc:
        print(f"License check failed: {exc}", file=sys.stderr)
        return 5

    if entitlement.offline and entitlement.grace_days_left is not None:
        print(f"Offline mode: {entitlement.grace_days_left} days left.")

    # Le funzionalita' opzionali sono attivate dal campo firmato "feat"
    if entitlement.license_data.has_feature("advanced-reporting"):
        enable_advanced_reporting()

    return run_main_loop()

Il tool di emissione

# licensing/cli.py
from __future__ import annotations

import argparse
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path

from .issuer import LicenseIssuer, format_for_humans
from .keys import generate_keypair, key_id, load_private_key, load_public_key
from .model import License
from .verifier import LicenseVerifier


def cmd_keygen(args: argparse.Namespace) -> int:
    password = args.password.encode("utf-8") if args.password else None
    _, public_key = generate_keypair(
        Path(args.private_out), Path(args.public_out), password
    )
    print(f"key id: {key_id(public_key)}")
    return 0


def cmd_issue(args: argparse.Namespace) -> int:
    private_key = load_private_key(
        Path(args.private_key),
        args.password.encode("utf-8") if args.password else None,
    )
    now = datetime.now(timezone.utc)
    license_data = License(
        license_id=str(uuid.uuid4()),
        product_id=args.product,
        customer_id=args.customer,
        edition=args.edition,
        seats=args.seats,
        issued_at=now,
        expires_at=None if args.perpetual else now + timedelta(days=args.days),
        features=tuple(args.feature or ()),
    )
    token = LicenseIssuer(private_key).issue(license_data)
    output = format_for_humans(token)
    if args.output:
        Path(args.output).write_text(output, encoding="utf-8")
    else:
        print(output)
    return 0


def cmd_inspect(args: argparse.Namespace) -> int:
    public_key = load_public_key(Path(args.public_key))
    verifier = LicenseVerifier({key_id(public_key): public_key}, args.product)
    license_data = verifier.verify(Path(args.license).read_text(encoding="utf-8"))
    print(f"id        : {license_data.license_id}")
    print(f"customer  : {license_data.customer_id}")
    print(f"edition   : {license_data.edition}")
    print(f"seats     : {license_data.seats}")
    print(f"expires   : {license_data.expires_at or 'never'}")
    print(f"features  : {', '.join(license_data.features) or 'none'}")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="licensetool")
    subparsers = parser.add_subparsers(required=True)

    keygen = subparsers.add_parser("keygen")
    keygen.add_argument("--private-out", default="private.pem")
    keygen.add_argument("--public-out", default="public.pem")
    keygen.add_argument("--password")
    keygen.set_defaults(func=cmd_keygen)

    issue = subparsers.add_parser("issue")
    issue.add_argument("--private-key", required=True)
    issue.add_argument("--password")
    issue.add_argument("--product", required=True)
    issue.add_argument("--customer", required=True)
    issue.add_argument("--edition", default="standard")
    issue.add_argument("--seats", type=int, default=1)
    issue.add_argument("--days", type=int, default=365)
    issue.add_argument("--perpetual", action="store_true")
    issue.add_argument("--feature", action="append")
    issue.add_argument("--output")
    issue.set_defaults(func=cmd_issue)

    inspect = subparsers.add_parser("inspect")
    inspect.add_argument("--public-key", required=True)
    inspect.add_argument("--product", required=True)
    inspect.add_argument("--license", required=True)
    inspect.set_defaults(func=cmd_inspect)

    return parser


def main() -> int:
    args = build_parser().parse_args()
    return args.func(args)

L'emissione di una licenza diventa quindi:

python -m licensing.cli keygen --private-out private.pem --public-out public.pem

python -m licensing.cli issue \
  --private-key private.pem \
  --product com.example.analytics \
  --customer acme-corp \
  --edition enterprise \
  --seats 25 \
  --days 365 \
  --feature advanced-reporting \
  --feature sso \
  --output acme.key

Test

I test di un sistema di licenze devono coprire soprattutto i casi negativi: è lì che si nascondono le vulnerabilità.

# tests/test_licensing.py
from datetime import datetime, timedelta, timezone

import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

from licensing.issuer import LicenseIssuer, b64decode, b64encode
from licensing.keys import key_id
from licensing.model import License
from licensing.verifier import (
    BadSignature,
    LicenseExpired,
    LicenseVerifier,
    MalformedLicense,
    ProductMismatch,
    UnknownSigningKey,
)

PRODUCT = "com.example.analytics"


@pytest.fixture
def keypair():
    private_key = Ed25519PrivateKey.generate()
    return private_key, private_key.public_key()


@pytest.fixture
def verifier(keypair):
    _, public_key = keypair
    return LicenseVerifier({key_id(public_key): public_key}, PRODUCT)


def make_license(**overrides) -> License:
    now = datetime.now(timezone.utc)
    defaults = {
        "license_id": "11111111-2222-3333-4444-555555555555",
        "product_id": PRODUCT,
        "customer_id": "acme-corp",
        "edition": "enterprise",
        "seats": 25,
        "issued_at": now,
        "expires_at": now + timedelta(days=365),
        "features": ("sso",),
    }
    defaults.update(overrides)
    return License(**defaults)


def test_valid_license_round_trip(keypair, verifier):
    private_key, _ = keypair
    token = LicenseIssuer(private_key).issue(make_license())
    result = verifier.verify(token)
    assert result.customer_id == "acme-corp"
    assert result.has_feature("sso")


def test_tampered_payload_is_rejected(keypair, verifier):
    private_key, _ = keypair
    token = LicenseIssuer(private_key).issue(make_license(seats=5))

    prefix, payload_b64, signature_b64 = token.split(".")
    # Proviamo a portare i posti da 5 a 500 lasciando la firma originale
    forged = b64decode(payload_b64).replace(b'"seats":5', b'"seats":500')
    forged_token = f"{prefix}.{b64encode(forged)}.{signature_b64}"

    with pytest.raises(BadSignature):
        verifier.verify(forged_token)


def test_license_signed_by_another_key_is_rejected(verifier):
    attacker_key = Ed25519PrivateKey.generate()
    token = LicenseIssuer(attacker_key).issue(make_license())
    with pytest.raises(UnknownSigningKey):
        verifier.verify(token)


def test_expired_license_is_rejected(keypair, verifier):
    private_key, _ = keypair
    expired_at = datetime.now(timezone.utc) - timedelta(days=10)
    token = LicenseIssuer(private_key).issue(
        make_license(expires_at=expired_at)
    )
    with pytest.raises(LicenseExpired):
        verifier.verify(token)


def test_clock_skew_tolerance(keypair, verifier):
    private_key, _ = keypair
    # Scaduta da un'ora: la tolleranza di 24 ore deve accettarla
    expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
    token = LicenseIssuer(private_key).issue(make_license(expires_at=expires_at))
    assert verifier.verify(token).seats == 25


def test_perpetual_license_never_expires(keypair, verifier):
    private_key, _ = keypair
    token = LicenseIssuer(private_key).issue(make_license(expires_at=None))
    far_future = datetime.now(timezone.utc) + timedelta(days=20000)
    assert verifier.verify(token, now=far_future).is_perpetual


def test_wrong_product_is_rejected(keypair, verifier):
    private_key, _ = keypair
    token = LicenseIssuer(private_key).issue(
        make_license(product_id="com.example.other")
    )
    with pytest.raises(ProductMismatch):
        verifier.verify(token)


@pytest.mark.parametrize(
    "token",
    ["", "garbage", "LIC1.only-two-parts", "LIC9.aaaa.bbbb", "LIC1.!!!.???"],
)
def test_malformed_tokens_are_rejected(verifier, token):
    with pytest.raises(MalformedLicense):
        verifier.verify(token)

Il test sulla manomissione del payload è il più importante di tutti: verifica che il meccanismo faccia esattamente ciò per cui esiste. Vale la pena aggiungerne uno analogo che sposti la data di scadenza.

Rotazione delle chiavi

Prima o poi una chiave privata va sostituita, per compromissione o per policy. La procedura, con l'architettura descritta, è indolore:

  1. Si genera la nuova coppia e si annota il nuovo key_id.
  2. Si aggiunge la nuova chiave pubblica al dizionario TRUSTED_KEYS dell'applicazione, mantenendo la vecchia, e si rilascia la nuova versione.
  3. Si attende che il parco installato aggiorni (mesi, tipicamente).
  4. Si iniziano a emettere licenze con la nuova chiave.
  5. Si rimuove la vecchia chiave solo quando tutte le licenze da essa firmate sono scadute.

In caso di compromissione della chiave privata l'ordine cambia: si emette immediatamente una nuova versione che rimuove la chiave compromessa e si riemettono le licenze dei clienti legittimi. È doloroso, ed è il motivo per cui la chiave privata deve stare su una macchina isolata o in un HSM, mai in un repository e mai su un server web.

Considerazioni finali e limiti

Alcune scelte pragmatiche che vale la pena esplicitare.

Non offuscare il codice sperando che basti. Strumenti come PyArmor o Nuitka alzano il costo dell'analisi, ma un attaccante può sempre sostituire la funzione check con una che restituisce sempre successo. L'offuscamento è ragionevole come deterrente marginale, dannoso se induce a trascurare la parte crittografica.

Distribuire il controllo nel codice. Un singolo if not gate.check(): sys.exit() all'avvio è un unico punto da patchare. Verificare l'Entitlement in più punti, dove serve davvero (all'attivazione di una funzionalità premium, nel percorso di export dei dati), rende l'aggiramento più laborioso. Non renderlo però così invasivo da diventare un problema di manutenzione.

Fallire in modo gentile. Ogni percorso di errore va progettato pensando al cliente pagante che lo incontrerà per un problema di rete o un orologio sbagliato. Messaggi chiari, codici di uscita distinti, un periodo di grazia generoso e un modo per contattare l'assistenza valgono più di qualsiasi restrizione aggiuntiva.

Conservare i dati minimi. Il server riceve un HMAC del machine id e un hostname. Non servono indirizzo IP persistente, elenco dell'hardware o telemetria d'uso: raccoglierli crea obblighi GDPR sproporzionati rispetto al beneficio.

Prevedere la disattivazione self-service. Un cliente che riformatta il computer deve poter liberare il posto da solo, dall'applicazione o da un portale. Senza questa possibilità, l'assistenza si riempie di ticket e i clienti percepiscono il sistema di licenze come un ostacolo anziché come un dettaglio amministrativo.

Il sistema descritto — licenze Ed25519 autocontenute, lease a scadenza breve, conteggio dei posti lato server e revoca firmata — copre le esigenze della grande maggioranza dei prodotti commerciali, resta comprensibile in poche centinaia di righe di Python e, soprattutto, non si trasforma in un ostacolo per chi ha pagato.