From 49725ddad5605cd8760b4460e97837b38243fca8 Mon Sep 17 00:00:00 2001 From: Faris Laptop Date: Thu, 16 Jul 2026 19:35:01 +0500 Subject: [PATCH] feat: initial CertifiEd licensing platform Self-hosted, multitenant licensing platform for on-prem software. - Backend: .NET 10 Clean Architecture (Domain/Application/Infrastructure/Api), PostgreSQL 16 with ltree company tree, Ed25519 signing (NSec), cookie auth with Argon2id, role enforcement (Operator/Owner/Admin/Viewer over subtree), webhooks, audit log, rate limiting. - Panel: Next.js 15 enterprise admin (RU/EN/TG), Ofarandagon light theme, command palette, license issue wizard, server-side pagination. - Landing: Next.js 15 static export, awwwards-grade, RU/EN/TG + full SEO. - Client SDK (CertifiEd.Client) with offline Ed25519 validation, heartbeat, feature gating; QuickStart example; usage docs. --- CertifiEd.Client.csproj | 11 + CertifiEdLicenseClient.cs | 525 ++++++++++++++++++++++++++++++++++++++ HwFingerprint.cs | 124 +++++++++ 3 files changed, 660 insertions(+) create mode 100644 CertifiEd.Client.csproj create mode 100644 CertifiEdLicenseClient.cs create mode 100644 HwFingerprint.cs diff --git a/CertifiEd.Client.csproj b/CertifiEd.Client.csproj new file mode 100644 index 0000000..4369227 --- /dev/null +++ b/CertifiEd.Client.csproj @@ -0,0 +1,11 @@ + + + + CertifiEd client SDK: license activation, heartbeats and offline Ed25519 token validation. + + + + + + + diff --git a/CertifiEdLicenseClient.cs b/CertifiEdLicenseClient.cs new file mode 100644 index 0000000..4b6950c --- /dev/null +++ b/CertifiEdLicenseClient.cs @@ -0,0 +1,525 @@ +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; + + 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; + } + + 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 ─────────────────────────────────────────────────────────────────── + +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 ──────────────────────────────────────────────────────────────────── + +public enum CertifiEdLicenseStatus +{ + Invalid, + Active, + GracePeriod, + Expired, + 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; } = ""; +} diff --git a/HwFingerprint.cs b/HwFingerprint.cs new file mode 100644 index 0000000..898a4e6 --- /dev/null +++ b/HwFingerprint.cs @@ -0,0 +1,124 @@ +using System.Net.NetworkInformation; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +namespace CertifiEd.Client; + +/// +/// Computes a stable hardware fingerprint for the current machine — a SHA-256 hex +/// digest over machine name, OS description, the first stable MAC address, and the +/// platform machine id when available. +/// +public static class HwFingerprint +{ + private static string? _cached; + + /// Returns the fingerprint, computed once per process. + public static string Get() + { + if (_cached is not null) + return _cached; + + var raw = new StringBuilder() + .Append(Environment.MachineName).Append('|') + .Append(RuntimeInformation.OSDescription).Append('|') + .Append(GetStableMac() ?? "no-mac"); + + var platformId = GetPlatformMachineId(); + if (platformId is not null) + raw.Append('|').Append(platformId); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw.ToString())); + _cached = Convert.ToHexStringLower(hash); + return _cached; + } + + private static string? GetStableMac() + { + try + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(n => n.NetworkInterfaceType != NetworkInterfaceType.Loopback + && n.OperationalStatus == OperationalStatus.Up) + .Select(n => n.GetPhysicalAddress().ToString()) + .Where(mac => mac.Length > 0) + .Order(StringComparer.Ordinal) + .FirstOrDefault(); + } + catch (NetworkInformationException) + { + return null; + } + } + + private static string? GetPlatformMachineId() + { + if (OperatingSystem.IsWindows()) + return GetWindowsMachineGuid(); + + if (OperatingSystem.IsLinux()) + return TryReadFile("/etc/machine-id") ?? TryReadFile("/var/lib/dbus/machine-id"); + + if (OperatingSystem.IsMacOS()) + return GetMacSerialNumber(); + + return null; + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static string? GetWindowsMachineGuid() + { + try + { + using var key = Microsoft.Win32.Registry.LocalMachine + .OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); + return key?.GetValue("MachineGuid")?.ToString(); + } + catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException) + { + return null; + } + } + + private static string? TryReadFile(string path) + { + try + { + return File.Exists(path) ? File.ReadAllText(path).Trim() : null; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + private static string? GetMacSerialNumber() + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo("ioreg", "-l") + { + RedirectStandardOutput = true, + UseShellExecute = false, + }; + using var process = System.Diagnostics.Process.Start(psi); + var output = process?.StandardOutput.ReadToEnd() ?? string.Empty; + var line = output.Split('\n').FirstOrDefault(l => l.Contains("IOPlatformSerialNumber", StringComparison.Ordinal)); + if (line is null) + return null; + + var start = line.IndexOf('"', line.IndexOf('=', StringComparison.Ordinal) + 1); + var end = start >= 0 ? line.IndexOf('"', start + 1) : -1; + return start >= 0 && end > start ? line[(start + 1)..end] : null; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) + { + return null; + } + } +}