Langages

Intégration C# / .NET

SDK .NET 8+ sans dépendance : signatures vérifiées, retries, heartbeat.

SDK officiel

Projet Keyline.Sdk (dossier sdks/csharp du dépôt), .NET 8+, aucune dépendance externe. Ajoutez-le en référence de projet ou générez le paquet avec dotnet pack.

csharp
using Keyline;

using var client = new LicenseClient(new LicenseClientOptions
{
    ApiUrl = new Uri("https://keyline.example.com/api/v1"),
    ApplicationId = "<application_id>",
    PublicKeys = new Dictionary<string, string> { ["<kid>"] = PublicKeyPem },
    AppVersion = "1.4.2",
});

var result = await client.ActivateAsync(licenseKey);
if (result.Success)
{
    // Conserver « heartbeat » jusqu'à la fermeture du logiciel : Dispose() arrête la boucle.
    var heartbeat = client.StartHeartbeat(onFailure: r => LockPremiumFeatures(r.Error!.Code));
}
else
{
    Console.WriteLine($"Refus : {result.Error!.Code}");
}

Sans SDK : le protocole avec HttpClient

csharp
// KeylineClient.cs — .NET 8+
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public sealed class LicenseException(string code, string message) : Exception(message)
{
    public string Code { get; } = code;
}

public sealed class KeylineClient(HttpClient http, string applicationId, IReadOnlyDictionary<string, string> publicKeysPem)
{
    // http.BaseAddress = new Uri("https://keyline.example.com/api/v1/"); http.Timeout = TimeSpan.FromSeconds(10);

    public Task<JsonElement> ActivateAsync(string licenseKey, string deviceId, string? appVersion = null, CancellationToken ct = default) =>
        CallAsync("activate", new Dictionary<string, object?> { ["license_key"] = licenseKey, ["device_id"] = deviceId, ["app_version"] = appVersion }, ct);

    public Task<JsonElement> HeartbeatAsync(string sessionToken, CancellationToken ct = default) =>
        CallAsync("heartbeat", new Dictionary<string, object?> { ["session_token"] = sessionToken }, ct);

    public Task<JsonElement> DeactivateAsync(string sessionToken, CancellationToken ct = default) =>
        CallAsync("deactivate", new Dictionary<string, object?> { ["session_token"] = sessionToken }, ct);

    private async Task<JsonElement> CallAsync(string endpoint, Dictionary<string, object?> payload, CancellationToken ct)
    {
        var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
        // Les champs facultatifs absents ne sont pas envoyés (null est refusé par l'API).
        foreach (var k in payload.Where(kv => kv.Value is null).Select(kv => kv.Key).ToList()) payload.Remove(k);
        payload["application_id"] = applicationId;
        payload["nonce"] = nonce;
        for (var attempt = 0; ; attempt++)
        {
            HttpResponseMessage res;
            try { res = await http.PostAsJsonAsync($"licenses/{endpoint}", payload, ct); }
            catch (HttpRequestException) when (attempt < 2) { await Task.Delay(500 * (1 << attempt), ct); continue; }

            using (res)
            {
                if ((int)res.StatusCode == 429 && attempt < 2)
                {
                    await Task.Delay(res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1), ct);
                    continue;
                }
                var body = await res.Content.ReadAsByteArrayAsync(ct);
                Verify(res, body);
                using var doc = JsonDocument.Parse(body);
                var root = doc.RootElement.Clone();
                if (!root.TryGetProperty("nonce", out var n) || n.GetString() != nonce) throw new LicenseException("REPLAY", "Nonce inattendu");
                if (!root.GetProperty("success").GetBoolean())
                {
                    var e = root.GetProperty("error");
                    throw new LicenseException(e.GetProperty("code").GetString()!, e.GetProperty("message").GetString()!);
                }
                return root;
            }
        }
    }

    /// <summary>ECDSA P-256 / SHA-256 (IEEE P1363) sur "&lt;ts&gt;.&lt;corps&gt;".</summary>
    private void Verify(HttpResponseMessage res, byte[] body)
    {
        if (!res.Headers.TryGetValues("Keyline-Signature", out var values)) throw new LicenseException("UNSIGNED", "Réponse non signée");
        var parts = values.First().Split(',').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1]);
        if (!publicKeysPem.TryGetValue(parts["kid"], out var pem)) throw new LicenseException("UNKNOWN_KEY", "Clé de signature inconnue");
        using var ecdsa = ECDsa.Create();
        ecdsa.ImportFromPem(pem);
        var message = Encoding.UTF8.GetBytes(parts["ts"] + ".").Concat(body).ToArray();
        // VerifyData utilise par défaut le format IEEE P1363 (r || s), celui de Keyline.
        if (!ecdsa.VerifyData(message, Base64UrlDecode(parts["sig"]), HashAlgorithmName.SHA256))
            throw new LicenseException("BAD_SIGNATURE", "Signature invalide");
    }

    private static byte[] Base64UrlDecode(string s)
    {
        s = s.Replace('-', '+').Replace('_', '/');
        return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
    }
}

Identifiant d’appareil

csharp
using System.Security.Cryptography;
using System.Text;

static string DeviceId(string salt = "mon-logiciel")
{
    string? raw = null;
    if (OperatingSystem.IsWindows())
        raw = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography")?.GetValue("MachineGuid") as string;
    else if (OperatingSystem.IsLinux() && File.Exists("/etc/machine-id"))
        raw = File.ReadAllText("/etc/machine-id").Trim();
    raw ??= Environment.MachineName;
    return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{salt}:{raw}"))).ToLowerInvariant();
}

Utilisation

csharp
var http = new HttpClient { BaseAddress = new Uri("https://keyline.example.com/api/v1/"), Timeout = TimeSpan.FromSeconds(10) };
var client = new KeylineClient(http, "<application_id>", new Dictionary<string, string> { ["<kid>"] = PublicKeyPem });
try
{
    var r = await client.ActivateAsync(licenseKey, DeviceId(), "1.4.2");
    var token = r.GetProperty("session").GetProperty("token").GetString();
}
catch (LicenseException e) when (e.Code == "DEVICE_LIMIT_REACHED")
{
    // inviter l'utilisateur à libérer un appareil
}