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.
This commit is contained in:
Faris Laptop
2026-07-16 19:35:01 +05:00
commit 49725ddad5
3 changed files with 660 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>CertifiEd client SDK: license activation, heartbeats and offline Ed25519 token validation.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
</ItemGroup>
</Project>
+525
View File
@@ -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;
/// <summary>
/// Client SDK for CertifiEd licenses.
///
/// Workflow:
/// 1. Call <see cref="InitializeAsync"/> 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 <see cref="Status"/>, <see cref="HasFeature"/> and <see cref="GetConfig{T}"/>.
/// 3. Call <see cref="DisposeAsync"/> 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.
/// </summary>
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 ────────────────────────────────────────────────────────────────
/// <summary>Current license state derived from the local token and cached marker.</summary>
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 ────────────────────────────────────────────────────────
/// <summary>True when the signed config's "features" array contains the flag.</summary>
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;
}
/// <summary>Reads a value from the signed config by dotted path (e.g. "limits.maxSeats").</summary>
public T? GetConfig<T>(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<T>(element.GetRawText());
}
catch (JsonException)
{
return null;
}
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
public async Task<bool> 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<ActivateResponse?> 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<ActivateResponse>(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<HeartbeatResponse>(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);
}
/// <summary>Builds an HMAC-signed request (X-CertifiEd-Timestamp / X-CertifiEd-Signature).</summary>
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<string?> 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<TokenHeaderModel>(Base64Url.DecodeFromChars(parts[0]), Json.CamelCase);
var payload = JsonSerializer.Deserialize<TokenPayloadModel>(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<HeartbeatMarkerPayload>(
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
{
/// <summary>Base URL of the CertifiEd server, e.g. "https://licensing.example.com".</summary>
public required string ServerUrl { get; init; }
/// <summary>Path to the local ".ced" license file (bare token or downloaded JSON).</summary>
public required string LicenseFilePath { get; init; }
/// <summary>Raw 32-byte Ed25519 public key used for offline verification.</summary>
public required byte[] PublicKey { get; init; }
/// <summary>Overrides the hardware fingerprint; defaults to <see cref="HwFingerprint.Get"/>.</summary>
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; } = "";
}
+124
View File
@@ -0,0 +1,124 @@
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
namespace CertifiEd.Client;
/// <summary>
/// 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.
/// </summary>
public static class HwFingerprint
{
private static string? _cached;
/// <summary>Returns the fingerprint, computed once per process.</summary>
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;
}
}
}