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.