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