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:
@@ -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; } = "";
|
||||
}
|
||||
Reference in New Issue
Block a user