using System.Buffers.Text; using System.Globalization; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace CertifiEd.Client; /// /// Client SDK for CertifiEd licenses. /// /// Workflow: /// 1. Call on startup — loads and offline-verifies the /// local ".ced" token against the embedded Ed25519 public key, activates with the /// server when reachable, and starts the background heartbeat loop. /// 2. Gate features on , and . /// 3. Call on shutdown — best-effort deactivate, stops the timer. /// /// Offline behaviour: after each successful heartbeat the server returns a signed /// marker valid for MaxOfflineDays (at most 30); the SDK caches it next to the license /// file, so restarts stay licensed without connectivity until the marker expires. /// public sealed class CertifiEdLicenseClient : IAsyncDisposable { private readonly CertifiEdClientOptions _options; private readonly HttpClient _http; private readonly bool _ownsHttpClient; private readonly SemaphoreSlim _gate = new(1, 1); private LicenseToken? _token; private HeartbeatMarkerPayload? _marker; private Guid _activationId; private Timer? _heartbeatTimer; private DateTimeOffset _lastValidatedAt; private bool _disposed; /// Creates a client for a single local license file. /// Server URL, license path and the Ed25519 public key. /// Optional shared ; when omitted the client owns and disposes its own. public CertifiEdLicenseClient(CertifiEdClientOptions options, HttpClient? http = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _ownsHttpClient = http is null; _http = http ?? new HttpClient { BaseAddress = new Uri(options.ServerUrl) }; } // ── Status ──────────────────────────────────────────────────────────────── /// Current license state derived from the local token and cached marker. public CertifiEdLicenseStatus Status { get { if (_token is null) return CertifiEdLicenseStatus.Invalid; var now = DateTimeOffset.UtcNow; if (_token.Payload.ExpiresAt < now) return CertifiEdLicenseStatus.Expired; if (_marker is not null) { var validUntil = DateTimeOffset.FromUnixTimeSeconds(_marker.Exp); if (now <= validUntil) return CertifiEdLicenseStatus.Active; if (now <= validUntil.AddHours(24)) return CertifiEdLicenseStatus.GracePeriod; return CertifiEdLicenseStatus.Expired; } // No marker yet — count the offline window from the last local validation. var graceUntil = _lastValidatedAt.AddDays(_token.Payload.MaxOfflineDays); if (now <= graceUntil) return CertifiEdLicenseStatus.Active; if (now <= graceUntil.AddDays(1)) return CertifiEdLicenseStatus.GracePeriod; return CertifiEdLicenseStatus.Expired; } } // ── Config helpers ──────────────────────────────────────────────────────── /// True when the signed config's "features" array contains the flag. public bool HasFeature(string featureName) { if (_token is null) return false; try { using var doc = JsonDocument.Parse(_token.Payload.Cfg); if (!doc.RootElement.TryGetProperty("features", out var features)) return false; foreach (var item in features.EnumerateArray()) { if (item.GetString() == featureName) return true; } } catch (JsonException) { // Malformed config — treat as feature absent. } return false; } /// Reads a value from the signed config by dotted path (e.g. "limits.maxSeats"). public T? GetConfig(string path) where T : struct { if (_token is null) return null; try { using var doc = JsonDocument.Parse(_token.Payload.Cfg); var element = doc.RootElement; foreach (var segment in path.Split('.')) { if (!element.TryGetProperty(segment, out element)) return null; } return JsonSerializer.Deserialize(element.GetRawText()); } catch (JsonException) { return null; } } // ── Lifecycle ───────────────────────────────────────────────────────────── /// /// Loads the token from disk, verifies its Ed25519 signature and expiry, then /// (best-effort) activates with the server and starts the heartbeat loop. /// Returns false when the license file is missing, corrupted, forged or expired. /// public async Task InitializeAsync(CancellationToken ct = default) { if (!File.Exists(_options.LicenseFilePath)) return false; var tokenString = (await ReadTokenAsync(ct).ConfigureAwait(false))?.Trim(); if (tokenString is null) return false; _token = ParseToken(tokenString); if (_token is null) return false; if (!VerifySignature(tokenString, _options.PublicKey)) { _token = null; return false; } if (_token.Payload.ExpiresAt < DateTimeOffset.UtcNow) return false; _lastValidatedAt = DateTimeOffset.UtcNow; _marker = LoadMarker(); try { var response = await ActivateAsync(ct).ConfigureAwait(false); if (response is not null && response.ActivationId != Guid.Empty) { _activationId = response.ActivationId; ApplyMarker(response.HeartbeatToken); var interval = TimeSpan.FromSeconds( Math.Max(60, response.HeartbeatIntervalSeconds + RandomNumberGenerator.GetInt32(-30, 31))); _heartbeatTimer = new Timer( _ => _ = SendHeartbeatAsync(CancellationToken.None), null, interval, interval); } } catch (HttpRequestException) { // Server unreachable — cached marker covers the offline window. } catch (TaskCanceledException) { // Timeout — same offline behaviour. } return true; } /// /// Stops the heartbeat loop and best-effort deactivates this machine, freeing its seat. /// Safe to call more than once. /// public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; if (_heartbeatTimer is not null) { await _heartbeatTimer.DisposeAsync().ConfigureAwait(false); _heartbeatTimer = null; } if (_activationId != Guid.Empty && _token is not null) { try { await DeactivateAsync(CancellationToken.None).ConfigureAwait(false); } catch (HttpRequestException) { // Best-effort. } catch (TaskCanceledException) { // Best-effort. } } _gate.Dispose(); if (_ownsHttpClient) _http.Dispose(); } // ── Server calls ────────────────────────────────────────────────────────── private async Task ActivateAsync(CancellationToken ct) { var request = new { licenseKey = _token!.Payload.Key, hwFingerprint = _options.HwFingerprint ?? HwFingerprint.Get(), machineName = Environment.MachineName, }; using var response = await _http.PostAsJsonAsync("/api/v1/client/activate", request, ct).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(ct).ConfigureAwait(false); } private async Task SendHeartbeatAsync(CancellationToken ct) { if (_token is null || _activationId == Guid.Empty) return; await _gate.WaitAsync(ct).ConfigureAwait(false); try { using var message = SignedRequest("/api/v1/client/heartbeat", new { licenseKey = _token.Payload.Key, activationId = _activationId }); using var response = await _http.SendAsync(message, ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) return; var heartbeat = await response.Content.ReadFromJsonAsync(ct).ConfigureAwait(false); if (heartbeat?.HeartbeatToken is { Length: > 0 } marker) ApplyMarker(marker); } catch (HttpRequestException) { // Network error — cached marker keeps us licensed. } catch (TaskCanceledException) { // Timeout — same. } finally { _gate.Release(); } } private async Task DeactivateAsync(CancellationToken ct) { using var message = SignedRequest("/api/v1/client/deactivate", new { licenseKey = _token!.Payload.Key, activationId = _activationId }); using var response = await _http.SendAsync(message, ct).ConfigureAwait(false); } /// Builds an HMAC-signed request (X-CertifiEd-Timestamp / X-CertifiEd-Signature). private HttpRequestMessage SignedRequest(string path, object body) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); var signature = ComputeHmac(_token!.Payload.Key, timestamp); var message = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body), }; message.Headers.Add("X-CertifiEd-Timestamp", timestamp); message.Headers.Add("X-CertifiEd-Signature", $"v1={signature}"); return message; } // ── Marker persistence ──────────────────────────────────────────────────── private string MarkerPath => _options.LicenseFilePath + ".hb"; private void ApplyMarker(string markerToken) { var payload = ParseMarkerPayload(markerToken); if (payload is null) return; _marker = payload; AtomicWrite(MarkerPath, markerToken); } private HeartbeatMarkerPayload? LoadMarker() { if (!File.Exists(MarkerPath)) return null; try { return ParseMarkerPayload(File.ReadAllText(MarkerPath)); } catch (IOException) { return null; } } private static void AtomicWrite(string path, string content) { var tmp = path + ".tmp"; File.WriteAllText(tmp, content); File.Move(tmp, path, overwrite: true); } private async Task ReadTokenAsync(CancellationToken ct) { var raw = await File.ReadAllTextAsync(_options.LicenseFilePath, ct).ConfigureAwait(false); // A ".ced" file may be either the bare token or the downloaded JSON envelope. var trimmed = raw.TrimStart(); if (!trimmed.StartsWith('{')) return raw; try { using var doc = JsonDocument.Parse(raw); return doc.RootElement.TryGetProperty("token", out var token) ? token.GetString() : null; } catch (JsonException) { return null; } } // ── Crypto and parsing ──────────────────────────────────────────────────── private static string ComputeHmac(string licenseKey, string timestamp) => Convert.ToHexStringLower(HMACSHA256.HashData( Encoding.UTF8.GetBytes(licenseKey), Encoding.UTF8.GetBytes(timestamp))); private static LicenseToken? ParseToken(string tokenString) { var parts = tokenString.Split('.'); if (parts.Length != 3) return null; try { var header = JsonSerializer.Deserialize(Base64Url.DecodeFromChars(parts[0]), Json.CamelCase); var payload = JsonSerializer.Deserialize(Base64Url.DecodeFromChars(parts[1]), Json.CamelCase); var signature = Base64Url.DecodeFromChars(parts[2]); return header is null || payload is null ? null : new LicenseToken(header, payload, signature); } catch (FormatException) { return null; } catch (JsonException) { return null; } } private static bool VerifySignature(string tokenString, byte[] publicKey) { try { var lastDot = tokenString.LastIndexOf('.'); if (lastDot <= 0) return false; var message = Encoding.ASCII.GetBytes(tokenString[..lastDot]); var signature = Base64Url.DecodeFromChars(tokenString.AsSpan(lastDot + 1)); var algorithm = NSec.Cryptography.SignatureAlgorithm.Ed25519; var key = NSec.Cryptography.PublicKey.Import( algorithm, publicKey, NSec.Cryptography.KeyBlobFormat.RawPublicKey); return algorithm.Verify(key, message, signature); } catch (FormatException) { return false; } catch (ArgumentException) { return false; } } private static HeartbeatMarkerPayload? ParseMarkerPayload(string marker) { var parts = marker.Trim().Split('.'); if (parts.Length != 2) return null; try { var payload = JsonSerializer.Deserialize( Base64Url.DecodeFromChars(parts[0]), Json.CamelCase); return payload?.Typ == "certified-heartbeat" ? payload : null; } catch (FormatException) { return null; } catch (JsonException) { return null; } } } // ── Options ─────────────────────────────────────────────────────────────────── /// Configuration for . public sealed class CertifiEdClientOptions { /// Base URL of the CertifiEd server, e.g. "https://licensing.example.com". public required string ServerUrl { get; init; } /// Path to the local ".ced" license file (bare token or downloaded JSON). public required string LicenseFilePath { get; init; } /// Raw 32-byte Ed25519 public key used for offline verification. public required byte[] PublicKey { get; init; } /// Overrides the hardware fingerprint; defaults to . public string? HwFingerprint { get; init; } } // ── Status ──────────────────────────────────────────────────────────────────── /// Local license state, derived from the token and the cached heartbeat marker. public enum CertifiEdLicenseStatus { /// No license loaded, or its signature did not verify. Invalid, /// Licensed — the token is valid and within its offline window. Active, /// Offline window elapsed; still usable for a short grace period. GracePeriod, /// The license term or the grace period has run out. Expired, /// The license was revoked by the issuer. Revoked, } // ── Internals ───────────────────────────────────────────────────────────────── internal static class Json { public static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; } internal sealed class TokenHeaderModel { public string Alg { get; set; } = "Ed25519"; public string Kid { get; set; } = ""; public string Typ { get; set; } = "certified-license"; public int Ver { get; set; } = 1; } internal sealed class TokenPayloadModel { public string Lic { get; set; } = ""; public string Key { get; set; } = ""; public string Tpl { get; set; } = ""; public int TplV { get; set; } public int CfgV { get; set; } public string Iss { get; set; } = ""; public string Sub { get; set; } = ""; public long Iat { get; set; } public long Nbf { get; set; } public long Exp { get; set; } public int MaxOfflineDays { get; set; } public string? Hwfp { get; set; } public string Cfg { get; set; } = "{}"; public DateTimeOffset ExpiresAt => DateTimeOffset.FromUnixTimeSeconds(Exp); } internal sealed class LicenseToken(TokenHeaderModel header, TokenPayloadModel payload, byte[] signature) { public TokenHeaderModel Header { get; } = header; public TokenPayloadModel Payload { get; } = payload; public byte[] Signature { get; } = signature; } internal sealed class HeartbeatMarkerPayload { public string Typ { get; set; } = ""; public Guid Lic { get; set; } public string Key { get; set; } = ""; public long Iat { get; set; } public long Exp { get; set; } public string? Mid { get; set; } } internal sealed class ActivateResponse { public Guid ActivationId { get; set; } public string HeartbeatToken { get; set; } = ""; public int HeartbeatIntervalSeconds { get; set; } public int MaxOfflineDays { get; set; } } internal sealed class HeartbeatResponse { public string HeartbeatToken { get; set; } = ""; }