Langages

Intégration Python

SDK Python 3.9+ : signatures vérifiées, retries, heartbeat, identifiant d’appareil.

SDK officiel

Paquet keyline-sdk (dossier sdks/python du dépôt), dépendance unique : cryptography. Client thread-safe, résultats typés, aucune exception pour un refus de licence.

bash
pip install ./sdks/python
python
from keyline import LicenseClient

client = LicenseClient(
    application_id="<application_id>",
    api_url="https://keyline.example.com/api/v1",
    public_keys={"<kid>": PUBLIC_KEY_PEM},
    app_version="1.4.2",
)

result = client.activate(license_key="LIC-XXXX-XXXX-XXXX-XXXX")  # identifiant d'appareil par défaut
if result.success:
    print("Licence active jusqu'au", result.license.expires_at or "à vie")
    stop = client.start_heartbeat(on_failure=lambda r: lock_premium_features(r.error.code))
else:
    print("Refus :", result.error.code, result.error.message)

Sans device_id, le SDK dérive un identifiant stable de l’identifiant machine, haché localement. result.verified indique si une erreur provient d’une réponse signée.

Sans SDK : le protocole en 60 lignes

Référence pour un environnement où le SDK ne convient pas (même contrat, mêmes vérifications).

python
# keyline_client.py — Python 3.10+ : pip install requests cryptography
import base64
import secrets
import time

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature

API = "https://keyline.example.com/api/v1"
APPLICATION_ID = "<application_id>"
PUBLIC_KEYS = {  # dashboard → Applications → Clés de signature
    "<kid>": serialization.load_pem_public_key(b"""-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----"""),
}


class LicenseError(Exception):
    def __init__(self, code: str, message: str):
        super().__init__(message)
        self.code = code


def _b64url(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


def _verify(resp: requests.Response) -> None:
    header = resp.headers.get("Keyline-Signature")
    if not header:
        raise LicenseError("UNSIGNED", "Réponse non signée")
    parts = dict(p.split("=", 1) for p in header.split(","))
    key = PUBLIC_KEYS.get(parts["kid"])
    if key is None:
        raise LicenseError("UNKNOWN_KEY", "Clé de signature inconnue")
    raw = _b64url(parts["sig"])  # format IEEE P1363 : r || s (2 × 32 octets)
    der = encode_dss_signature(int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big"))
    # Lève cryptography.exceptions.InvalidSignature si la réponse a été altérée.
    key.verify(der, parts["ts"].encode() + b"." + resp.content, ec.ECDSA(hashes.SHA256()))


def _call(endpoint: str, **payload) -> dict:
    nonce = secrets.token_urlsafe(16)
    # Les champs facultatifs absents ne sont pas envoyés (null est refusé par l'API).
    body = {"application_id": APPLICATION_ID, "nonce": nonce, **{k: v for k, v in payload.items() if v is not None}}
    for attempt in range(3):
        try:
            resp = requests.post(f"{API}/licenses/{endpoint}", json=body, timeout=10)  # TLS vérifié par défaut
        except requests.RequestException:
            if attempt == 2:
                raise
            time.sleep(0.5 * 2**attempt)
            continue
        if resp.status_code == 429 and attempt < 2:
            time.sleep(int(resp.headers.get("Retry-After", "1")))
            continue
        _verify(resp)
        data = resp.json()
        if data.get("nonce") != nonce:
            raise LicenseError("REPLAY", "Nonce inattendu")
        if not data["success"]:
            raise LicenseError(data["error"]["code"], data["error"]["message"])
        return data
    raise LicenseError("RATE_LIMITED", "Trop de requêtes")


def activate(license_key: str, device_id: str, app_version: str | None = None) -> dict:
    return _call("activate", license_key=license_key, device_id=device_id, app_version=app_version)


def heartbeat(session_token: str) -> dict:
    return _call("heartbeat", session_token=session_token)


def deactivate(session_token: str) -> dict:
    return _call("deactivate", session_token=session_token)
python
import hashlib
import platform
import subprocess
import uuid


def device_id(salt: str = "mon-logiciel") -> str:
    """Identifiant stable, haché localement : la valeur brute ne quitte jamais la machine."""
    raw = None
    system = platform.system()
    try:
        if system == "Windows":
            import winreg
            with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography") as k:
                raw = winreg.QueryValueEx(k, "MachineGuid")[0]
        elif system == "Linux":
            raw = open("/etc/machine-id").read().strip()
        elif system == "Darwin":
            out = subprocess.check_output(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], text=True)
            raw = out.split('"IOPlatformUUID" = "')[1].split('"')[0]
    except Exception:
        raw = None
    raw = raw or str(uuid.getnode())
    return hashlib.sha256(f"{salt}:{raw}".encode()).hexdigest()
python
from keyline_client import LicenseError, activate, heartbeat

try:
    result = activate("LIC-8F3A-29BC-74DE-6A12", device_id(), app_version="1.4.2")
    print("Licence active jusqu'au", result["license"]["expires_at"] or "jamais")
    token = result["session"]["token"]
except LicenseError as e:
    if e.code == "DEVICE_LIMIT_REACHED":
        print("Nombre maximal d'appareils atteint : libérez un poste depuis votre espace client.")
    else:
        print(f"Licence refusée : {e.code}")