feat: hardware binding modes, operator management, packaged SDK

- Hardware binding: License.HwBinding (none | fixed | firstActivation).
  firstActivation leaves the license unbound until the first activation,
  which claims the machine; afterwards it behaves like fixed. Legacy rows
  backfilled to fixed when a fingerprint was already set.
- Rebind: POST /panel/licenses/{id}/rebind moves a license to new
  hardware (or releases the binding), deactivating existing activations
  and re-signing a new version; 409 on revoked/expired. Operator or
  company admin. Audited, emits license.rebound.
- Operator management: list, invite, promote and demote operators
  (operator-only), guarding self-demotion and the last operator — until
  now a second operator could only be seeded directly into the database.
- SDK packaging: CertifiEd.Client is now a proper NuGet package (id,
  version, MIT license, README, XML docs, symbols) and gained the
  missing public XML documentation.
This commit is contained in:
Faris Laptop
2026-07-20 14:16:51 +05:00
parent 49725ddad5
commit e0354458df
4 changed files with 155 additions and 1 deletions
+29 -1
View File
@@ -1,9 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<Description>CertifiEd client SDK: license activation, heartbeats and offline Ed25519 token validation.</Description> <Description>CertifiEd client SDK: license activation, heartbeats and offline Ed25519 token validation.</Description>
<!-- XML docs ship with the package so consumers get IntelliSense. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<!-- ── NuGet packaging ──────────────────────────────────────────────────── -->
<PropertyGroup>
<PackageId>CertifiEd.Client</PackageId>
<Version>1.0.0</Version>
<Authors>Ofarandagon</Authors>
<Company>Ofarandagon</Company>
<Product>CertifiEd</Product>
<Copyright>Copyright © Ofarandagon 2026</Copyright>
<PackageTags>licensing;license;ed25519;offline;activation;self-hosted</PackageTags>
<PackageProjectUrl>https://git.ofarandagon.tj/Ofarandagon/CertifiEd</PackageProjectUrl>
<RepositoryUrl>https://git.ofarandagon.tj/Ofarandagon/CertifiEd</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<!-- Symbols for debugging; sources embedded for untracked files. -->
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="NSec.Cryptography" Version="25.4.0" /> <PackageReference Include="NSec.Cryptography" Version="25.4.0" />
</ItemGroup> </ItemGroup>
+18
View File
@@ -35,6 +35,9 @@ public sealed class CertifiEdLicenseClient : IAsyncDisposable
private DateTimeOffset _lastValidatedAt; private DateTimeOffset _lastValidatedAt;
private bool _disposed; private bool _disposed;
/// <summary>Creates a client for a single local license file.</summary>
/// <param name="options">Server URL, license path and the Ed25519 public key.</param>
/// <param name="http">Optional shared <see cref="HttpClient"/>; when omitted the client owns and disposes its own.</param>
public CertifiEdLicenseClient(CertifiEdClientOptions options, HttpClient? http = null) public CertifiEdLicenseClient(CertifiEdClientOptions options, HttpClient? http = null)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
@@ -189,6 +192,10 @@ public sealed class CertifiEdLicenseClient : IAsyncDisposable
return true; return true;
} }
/// <summary>
/// Stops the heartbeat loop and best-effort deactivates this machine, freeing its seat.
/// Safe to call more than once.
/// </summary>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -431,6 +438,7 @@ public sealed class CertifiEdLicenseClient : IAsyncDisposable
// ── Options ─────────────────────────────────────────────────────────────────── // ── Options ───────────────────────────────────────────────────────────────────
/// <summary>Configuration for <see cref="CertifiEdLicenseClient"/>.</summary>
public sealed class CertifiEdClientOptions public sealed class CertifiEdClientOptions
{ {
/// <summary>Base URL of the CertifiEd server, e.g. "https://licensing.example.com".</summary> /// <summary>Base URL of the CertifiEd server, e.g. "https://licensing.example.com".</summary>
@@ -448,12 +456,22 @@ public sealed class CertifiEdClientOptions
// ── Status ──────────────────────────────────────────────────────────────────── // ── Status ────────────────────────────────────────────────────────────────────
/// <summary>Local license state, derived from the token and the cached heartbeat marker.</summary>
public enum CertifiEdLicenseStatus public enum CertifiEdLicenseStatus
{ {
/// <summary>No license loaded, or its signature did not verify.</summary>
Invalid, Invalid,
/// <summary>Licensed — the token is valid and within its offline window.</summary>
Active, Active,
/// <summary>Offline window elapsed; still usable for a short grace period.</summary>
GracePeriod, GracePeriod,
/// <summary>The license term or the grace period has run out.</summary>
Expired, Expired,
/// <summary>The license was revoked by the issuer.</summary>
Revoked, Revoked,
} }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ofarandagon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+87
View File
@@ -0,0 +1,87 @@
# CertifiEd.Client
Клиентский SDK для [CertifiEd](https://git.ofarandagon.tj/Ofarandagon/CertifiEd) — self-hosted платформы лицензирования on-prem ПО от [Ofarandagon](https://ofarandagon.tj).
SDK проверяет лицензию **локально** по публичному ключу Ed25519: приложение работает без связи с сервером до 30 дней, а конфигурация (feature-флаги и лимиты) читается из самого подписанного токена — подделать нельзя.
## Установка
```bash
dotnet add package CertifiEd.Client
```
## Быстрый старт
```csharp
using CertifiEd.Client;
await using var license = new CertifiEdLicenseClient(new CertifiEdClientOptions
{
ServerUrl = "https://api.licensing.example.com",
LicenseFilePath = "license.ced", // выданный клиенту файл
PublicKey = Convert.FromHexString(publicKeyHex), // 32 байта Ed25519
});
if (!await license.InitializeAsync())
{
Console.Error.WriteLine("Лицензия отсутствует, повреждена, подделана или истекла.");
return 1;
}
if (license.Status is not (CertifiEdLicenseStatus.Active or CertifiEdLicenseStatus.GracePeriod))
return 1;
if (license.HasFeature("reports.export"))
EnableReportExport();
var seats = license.GetConfig<int>("limits.maxSeats") ?? 1;
```
`InitializeAsync` загружает токен, **офлайн** проверяет подпись Ed25519 и срок, затем (best-effort) активируется на сервере и запускает фоновый heartbeat. Отсутствие сети не роняет приложение.
Публичный ключ выдаёт панель или API: `GET /api/v1/client/public-key/{licenseKey}` (hex → `Convert.FromHexString`). Ключ безопасно зашивать в приложение.
## API
| Член | Назначение |
|---|---|
| `InitializeAsync(ct)` | Загрузка + офлайн-проверка подписи, активация, старт heartbeat. `false` — лицензия невалидна |
| `Status` | `Active` · `GracePeriod` · `Expired` · `Invalid` · `Revoked` |
| `HasFeature(name)` | Флаг из массива `features` подписанного конфига |
| `GetConfig<T>(path)` | Значение по dotted-path, напр. `"limits.maxSeats"` |
| `DisposeAsync()` | Best-effort деактивация (освобождает место) и остановка таймера. Идемпотентен |
### Опции
| Опция | Описание |
|---|---|
| `ServerUrl` | Базовый URL сервера CertifiEd |
| `LicenseFilePath` | Путь к `.ced` (голый токен или скачанный JSON-конверт) |
| `PublicKey` | Сырые 32 байта публичного ключа Ed25519 |
| `HwFingerprint` | Переопределить отпечаток железа (по умолчанию вычисляется автоматически) |
## Офлайн-режим
После каждого успешного heartbeat сервер возвращает подписанный маркер; SDK кэширует его рядом с файлом лицензии (`license.ced.hb`). Перезапуски без сети остаются лицензированными, пока маркер жив (до `maxOfflineDays`, максимум 30). Далее — `GracePeriod`, затем `Expired`.
Рекомендация: не блокируйте работу при первом же сбое сети — ориентируйтесь на `Status`, а не на факт ответа сервера.
## Привязка к железу
Лицензия может быть привязана к машине по аппаратному отпечатку. Отпечаток вычисляется автоматически (`HwFingerprint.Get()`), при необходимости переопределяется через `CertifiEdClientOptions.HwFingerprint` — например, если у вас своя стабильная идентификация узла.
Если лицензия привязана к другой машине, активация вернёт ошибку несовпадения отпечатка. Перепривязка (замена оборудования) выполняется на стороне платформы оператором или администратором тенанта.
## Обработка ошибок
- `InitializeAsync` возвращает `false` — файла нет, токен повреждён/подделан или срок истёк. Не бросает при недоступном сервере.
- Сетевые сбои и таймауты внутри активации/heartbeat проглатываются осознанно — работает офлайн-окно.
- Проверяйте `Status` перед выдачей платной функциональности, а не только результат `InitializeAsync`.
## Документация
Полная документация — в [`docs/`](https://git.ofarandagon.tj/Ofarandagon/CertifiEd) репозитория: протокол лицензирования, справочник API, webhooks и руководство по SDK. Рабочий пример — `examples/CertifiEd.QuickStart`.
---
© Ofarandagon, 2026. SDK распространяется по лицензии MIT.