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; } = "";
}