49725ddad5
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.
526 lines
18 KiB
C#
526 lines
18 KiB
C#
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; } = "";
|
|
}
|