Pourquoi offline-first ?

Un système de licensing qui nécessite une connexion permanente à un serveur de validation est inutilisable sur les sites industriels, dans les avions, dans les hôpitaux avec VLAN isolé ou lors d'une panne réseau. L'offline-first signifie que l'application fonctionne toujours localement, tout en synchronisant avec le serveur quand c'est possible.

Un système d'activation qui ne fonctionne pas sans internet est un système qui tombe en panne dès que l'internet tombe. L'offline-first est une exigence de fiabilité, pas un contournement de sécurité.

Architecture générale

# Flux offline-first
Première activation (réseau requis) :
  Client → POST /activate {hardware_id, licence_key}
  Serveur → vérifie, génère token_local signé ECDSA
  Client → stocke token_local (valable N jours)

Utilisations suivantes (offline) :
  Client → vérifie token_local en local (signature + expiry)
  ✓ Si valide → exécution
  ✗ Si expiré → tente renouvellement réseau
    ✓ Réseau OK → nouveau token_local
    ✗ Réseau KO → période de grâce (ex: 7 jours)

Flux d'activation

# Côté serveur (API Flask/FastAPI)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import json, base64
from datetime import datetime, timezone, timedelta

app = FastAPI()

class ActivationRequest(BaseModel):
    hardware_id: str
    licence_key: str
    product: str

@app.post("/activate")
async def activate(req: ActivationRequest):
    # Vérifier la clé de licence dans la base
    licence = db.get_licence(req.licence_key)
    if not licence:
        raise HTTPException(404, "Clé de licence inconnue")
    if licence['hardware_id'] and licence['hardware_id'] != req.hardware_id:
        raise HTTPException(403, "Machine non autorisée")

    # Générer le token local (valable 30 jours)
    token_data = {
        "hardware_id": req.hardware_id,
        "product": req.product,
        "expires_at": (datetime.now(timezone.utc) + timedelta(days=30)).isoformat(),
        "features": licence['features'],
        "issued_at": datetime.now(timezone.utc).isoformat(),
    }
    token_data["signature"] = sign_ecdsa(token_data, PRIVATE_KEY)
    return {"token": base64.b64encode(json.dumps(token_data).encode()).decode()}

Token temporaire signé

Le token local est un JSON signé ECDSA stocké chiffré sur le disque du client. Sa durée de validité est courte (7–30 jours) pour forcer des reconnexions régulières sans bloquer le travail quotidien :

# Côté client — vérification du token local
import json, base64
from pathlib import Path

TOKEN_PATH = Path.home() / ".monapp" / "token.enc"

def check_local_token(public_key, hw_id: str) -> dict | None:
    if not TOKEN_PATH.exists():
        return None
    try:
        raw   = decrypt_local(TOKEN_PATH.read_bytes())  # AES-256-GCM
        token = json.loads(base64.b64decode(raw))
        verify_ecdsa(token, public_key)  # lève si invalide
        if token["hardware_id"] != hw_id:
            return None
        expires = datetime.fromisoformat(token["expires_at"])
        if datetime.now(timezone.utc) > expires:
            return None  # expiré → tenter renouvellement
        return token
    except:
        return None

Reconnexion différée et période de grâce

GRACE_DAYS = 7

def startup_check(public_key, hw_id: str, activation_url: str):
    token = check_local_token(public_key, hw_id)

    if token:
        return token  # token valide → démarrage immédiat

    # Token expiré ou absent → tenter renouvellement
    try:
        new_token = renew_token_online(hw_id, activation_url)
        save_local_token(new_token)
        return new_token
    except Exception:
        # Réseau indisponible → vérifier la grâce
        grace_until = load_grace_timestamp()
        if grace_until and datetime.now(timezone.utc) < grace_until:
            log_warning(f"Mode grâce : {(grace_until - datetime.now(timezone.utc)).days}j restants")
            return load_last_valid_token()
        sys.exit("Licence expirée et serveur inaccessible")

Anti-clone réseau

Sans protection, un attaquant pourrait copier le token chiffré sur une autre machine. IronLock v2 lie le token au hardware_id : même si le fichier est copié, la vérification du hardware_id échoue sur la machine non autorisée. De plus, le token est chiffré avec une clé dérivée du fingerprint hardware — il est donc indéchiffrable sur une autre machine.

def encrypt_local(token_json: bytes, hw_id: str) -> bytes:
    # Clé dérivée du fingerprint → indéchiffrable sur autre machine
    key = derive_key_argon2id(hw_id.encode())
    return aesgcm_encrypt(key, token_json)

Implémentation complète

# Démarrage de l'application
if __name__ == "__main__":
    hw_id = get_hardware_fingerprint()
    token = startup_check(
        public_key=load_public_key("keys/public.pem"),
        hw_id=hw_id,
        activation_url="https://licences.monapp.fr/activate"
    )
    # Exposer les features au reste de l'application
    FEATURES = set(token.get("features", []))
    run_application(features=FEATURES)

Conclusion

L'architecture offline-first repose sur trois éléments : token local signé ECDSA (vérifiable sans réseau), période de grâce configurable (tolérance aux pannes réseau) et chiffrement du token lié au hardware (anti-clone). La durée du token (7–30 jours) est le seul paramètre à ajuster selon votre modèle commercial : courte pour forcer des reconnexions régulières, longue pour maximiser la disponibilité offline.

🔐
PRODUIT LIÉ
IronLock v2.0
← Article précédent Article suivant →