Les enjeux de l'expiration

Une licence à durée limitée est un mécanisme commercial fondamental — mais sa mise en œuvre technique est plus délicate qu'il n'y paraît. L'horloge système peut être manipulée, le timestamp d'expiration peut être falsifié, et une expiration brutale en plein travail crée une expérience utilisateur désastreuse.

Une bonne gestion d'expiration de licence est invisible quand tout va bien et gracieuse quand ça expire. L'utilisateur ne doit jamais perdre de données à cause d'une expiration surprise.

Protection contre le clock skew

Un utilisateur peut régler l'horloge système en arrière pour prolonger une licence expirée. Plusieurs contre-mesures :

from datetime import datetime, timezone, timedelta

def check_clock_integrity(licence_issued_at: datetime) -> bool:
    now = datetime.now(timezone.utc)

    # 1. L'heure actuelle ne peut pas être avant la date d'émission
    if now < licence_issued_at:
        return False  # Horloge reculée

    # 2. Vérifier contre un timestamp ancré (dernière exécution connue)
    last_run = load_last_run_timestamp()  # chiffré dans un cache local
    if last_run and now < last_run - timedelta(minutes=5):
        return False  # Horloge reculée depuis la dernière exécution

    # 3. Sauvegarder le timestamp courant pour la prochaine vérification
    save_last_run_timestamp(now)
    return True

NTP spoofing

Un attaquant sophistiqué peut configurer un faux serveur NTP local qui retourne une date passée. La mitigation : utiliser plusieurs sources NTP et vérifier la cohérence entre elles :

import ntplib

NTP_SERVERS = ['pool.ntp.org', 'time.cloudflare.com', 'time.google.com']

def get_ntp_consensus() -> datetime | None:
    times = []
    for srv in NTP_SERVERS:
        try:
            r = ntplib.NTPClient().request(srv, version=3, timeout=3)
            times.append(datetime.fromtimestamp(r.tx_time, timezone.utc))
        except: pass

    if len(times) < 2: return None  # offline

    # Vérifier la cohérence : toutes les réponses dans une fenêtre de 30s
    times.sort()
    if (times[-1] - times[0]).total_seconds() > 30:
        return None  # Incohérence → NTP spoofing probable
    return times[len(times) // 2]  # médiane

Timestamp ancré hardware

La technique la plus robuste : ancrer le timestamp de dernière exécution dans le fingerprint hardware. La valeur est chiffrée avec une clé dérivée du hardware_id — impossible à transférer sur une autre machine :

def save_last_run_timestamp(ts: datetime, hw_id: str):
    key  = derive_key_argon2id(hw_id.encode(), salt=b'LASTRUN')
    data = ts.isoformat().encode()
    enc  = aesgcm_encrypt(key, data)
    Path.home().joinpath(".monapp/.lastrun").write_bytes(enc)

def load_last_run_timestamp(hw_id: str) -> datetime | None:
    p = Path.home().joinpath(".monapp/.lastrun")
    if not p.exists(): return None
    key = derive_key_argon2id(hw_id.encode(), salt=b'LASTRUN')
    return datetime.fromisoformat(aesgcm_decrypt(key, p.read_bytes()).decode())

Période de grâce

La période de grâce évite les coupures brutales en plein travail. L'application continue à fonctionner N jours après l'expiration mais affiche un bandeau d'avertissement croissant :

def check_expiry_with_grace(licence: dict) -> tuple[str, int]:
    now     = datetime.now(timezone.utc)
    expires = datetime.fromisoformat(licence['expires_at'].replace('Z','+00:00'))
    grace   = timedelta(days=licence.get('grace_days', 7))

    if now <= expires:
        days_left = (expires - now).days
        return 'valid', days_left
    elif now <= expires + grace:
        grace_left = (expires + grace - now).days
        return 'grace', grace_left
    else:
        return 'expired', 0

Renouvellement automatique

def auto_renew_if_needed(licence_path: str, renewal_url: str, hw_id: str):
    lic = load_licence(licence_path)
    status, days = check_expiry_with_grace(lic)

    if status == 'valid' and days <= 30:
        # Tenter un renouvellement silencieux 30 jours avant expiration
        try:
            r = requests.post(renewal_url, json={
                "licence_id": lic["licence_id"],
                "hardware_id": hw_id
            }, timeout=10)
            if r.ok:
                new_lic = r.json()["licence"]
                save_licence(new_lic, licence_path)
        except: pass  # silencieux si offline

UX de l'expiration

  • J-30 : bandeau discret dans l'interface, lien vers renouvellement.
  • J-7 : bandeau orange, popup au démarrage (fermable).
  • J-0 à J+7 (grâce) : bandeau rouge persistant, compteur de jours restants.
  • J+8 (blocage) : dialogue modal non fermable avec instructions de renouvellement. Les données de l'utilisateur restent accessibles en lecture seule.

Conclusion

La gestion d'expiration robuste repose sur trois couches : timestamp ancré hardware (anti-retour d'horloge), NTP multi-sources (anti-spoofing) et période de grâce (UX). IronLock v2 implémente les deux premières nativement. La période de grâce se configure dans le champ grace_days du .lic. Le renouvellement automatique est optionnel et se configure avec l'URL de renouvellement dans le manifest de l'application.

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