CertifiEd

Client SDK (CertifiEd.Client)

A lightweight .NET SDK you embed into your application. It owns the entire client-side protocol: loading the license file, verifying the Ed25519 signature offline, activating on the server, running background heartbeats with a cached offline marker, and deactivating on shutdown.

The SDK depends only on the token format and NSec.Cryptography — it drags in neither the server's Domain nor its Infrastructure.

Installation

The SDK ships as the NuGet package `CertifiEd.Client` 1.0.0 (MIT licence, authored by Ofarandagon). It carries XML documentation for IntelliSense and a .snupkg symbols package; the only dependency is NSec.Cryptography.

bash
dotnet add package CertifiEd.Client

ProjectReference

For a mono-repository or a source build:

XML
<ItemGroup>  <ProjectReference Include="path/to/src/CertifiEd.Client/CertifiEd.Client.csproj" /></ItemGroup>

Local NuGet feed

For shipping the SDK to customers separately from the server code:

bash
# build the packagedotnet pack src/CertifiEd.Client -c Release -o ./nupkg
# register the local source and installdotnet nuget add source ./nupkg --name certified-localdotnet add package CertifiEd.Client --source certified-local

Public API

MemberPurpose
new CertifiEdLicenseClient(CertifiEdClientOptions options, HttpClient? http = null)Constructor. When http is omitted the SDK creates its own HttpClient with BaseAddress = ServerUrl and owns it
Task<bool> InitializeAsync(ct)Loads the .ced file, verifies the signature offline against PublicKey, activates on the server (best-effort) and starts the heartbeat loop. false — file missing / signature invalid / token expired
CertifiEdLicenseStatus Status { get; }Invalid / Active / GracePeriod / Expired / Revoked — derived from the token exp and the marker
bool HasFeature(string name)Whether name is present in the features array of the signed cfg
T? GetConfig<T>(string path) where T : structThe value at a dotted path ("limits.maxSeats") inside cfg; null when the path is absent
ValueTask DisposeAsync()Best-effort deactivate plus stopping the heartbeat timer

CertifiEdClientOptions

PropertyRequiredPurpose
ServerUrlyesServer base URL, e.g. https://api.certified.example
LicenseFilePathyesPath to the local .ced file — envelope or bare token
PublicKeyyesRaw 32 Ed25519 bytes (Convert.FromHexString(hex))
HwFingerprintnoOverrides the fingerprint; defaults to HwFingerprint.Get()

Full integration example

C#
using CertifiEd.Client;
// The public key ships with your product (from the .ced file or GET .../public-key).// The API returns it as hex — unpack it into raw bytes on the client.byte[] publicKey = Convert.FromHexString(    "7bac1e3ce8578ad859bfe001a4f4d72702d00e3d1eebdd735ebdb7145d74f35b");
var options = new CertifiEdClientOptions{    ServerUrl       = "https://api.certified.example",    LicenseFilePath = "/etc/myapp/license.ced",    PublicKey       = publicKey,    // HwFingerprint = "..."   // optional; defaults to HwFingerprint.Get()};
await using var license = new CertifiEdLicenseClient(options);
if (!await license.InitializeAsync()){    Console.Error.WriteLine("License missing, corrupted or expired.");    return 1;}
// Gate on statusswitch (license.Status){    case CertifiEdLicenseStatus.Active:        break;                                   // all good
    case CertifiEdLicenseStatus.GracePeriod:        Console.WriteLine("License is in the grace period — restore connectivity.");        break;                                   // keep running, but warn
    case CertifiEdLicenseStatus.Expired:    case CertifiEdLicenseStatus.Revoked:    case CertifiEdLicenseStatus.Invalid:        Console.Error.WriteLine($"Access denied: {license.Status}.");        return 1;                                // block}
// Gate on features and limitsif (license.HasFeature("export"))    EnableExport();
int maxSeats = license.GetConfig<int>("limits.maxSeats") ?? 1;   // fallback defaultEnforceSeatLimit(maxSeats);
RunApplication();return 0;// await using calls DisposeAsync -> deactivate + stop the heartbeat timer.

GetConfig<T> is constrained to where T : structint, long, bool, double, DateTimeOffset and friends. For strings and arrays read cfg directly or use HasFeature.

What InitializeAsync does

  1. Reads the file (a .ced envelope or a bare token) and parses the three segments.
  2. Verifies the Ed25519 signature offline against PublicKey. On failure the token is dropped and the method returns false.
  3. Checks exp: an expired token → false.
  4. Loads the cached <license>.ced.hb marker if there is one.
  5. Best-effort activation via POST /api/v1/client/activate: on success it stores activationId, applies the fresh marker and starts the heartbeat timer.

Offline behaviour

  • While the app keeps fetching a fresh marker, the status stays Active.
  • Connectivity lost: the marker holds Active until its own exp (issue time + maxOfflineDays), then 24 hours of GracePeriod, then Expired.
  • Restart without network: the marker is read from disk and the license stays valid until the marker's exp.
  • The token's hard exp overrides everything: past it the status is always Expired, even online.

The exact status computation lives in the License protocol section.

Handling Expired and Revoked

  • Expired — the term has run out (token.exp passed) or the marker plus grace expired without connectivity. Usually this requires the operator to re-issue the license.
  • Revoked — the license was revoked on the server. The SDK learns about it indirectly: the server rejects activation and heartbeat (license.not_active), fresh markers stop arriving, and once the offline window ends the status degrades to Expired.

In practice: gate functionality on Status == Active || Status == GracePeriod, show a warning during GracePeriod, and block protected functionality on Expired / Revoked / Invalid.

Hardware binding

The SDK always sends hwFingerprint to POST /api/v1/client/activate. By default that is HwFingerprint.Get() — a stable SHA-256 over the machine name, the OS description, the first stable MAC address and the platform machine id, computed once per process. Your own identifier (a stable per-tenant one, say) goes into CertifiEdClientOptions.HwFingerprint.

License modeWhat the integrator sees
noneThe fingerprint is recorded on the activation but constrains nothing
fixedActivation only succeeds on the machine with the pinned fingerprint
firstActivationThe first run pins the machine; from then on it behaves like fixed

The modes and rebinding are covered in full in Hardware binding.

What the client sees on a mismatch

Activation answers 400:

JSON
{  "code": "activation.fingerprint_mismatch",  "message": "License is bound to different hardware.",  "status": 400}

This does not fail InitializeAsync: activation is best-effort and the access decision comes from Status, derived from the local token and marker. In practice:

  • Fresh offline markers stop arriving — a heartbeat is impossible without an activationId.
  • While the last marker is alive the status stays Active, then GracePeriod, then Expired.
  • On a clean machine that never had a marker the countdown starts from the last local validation, and the license degrades over the same maxOfflineDays.

Replacing the machine

The fingerprint cannot be “fixed” on the client side — rebinding happens on the platform:

  1. An operator or a company Admin calls POST /api/v1/panel/licenses/{id}/rebind with the new hwFingerprint (or null, so the license pins itself to the next machine).
  2. The platform deactivates the old activations and re-signs the license as a new version.
  3. The customer downloads the `.ced` again (GET /api/v1/panel/licenses/{id}/download or the portal) and places it at LicenseFilePath.
  4. The stale marker cache <license>.ced.hb can be deleted — a new one arrives with the first successful activation.

The public key does not change (unless the template key was rotated), so the PublicKey compiled into your application stays valid.

Lifecycle and Dispose

  • Call DisposeAsync exactly once — via await using or explicitly. The SDK owns the HttpClient it created itself and closes it; a second call would touch an already-disposed client.
  • If you passed your own HttpClient into the constructor, the SDK does not dispose it — that is your responsibility.
  • DisposeAsync sends a best-effort deactivate; an unreachable server does not prevent a clean shutdown.