Tamga.Sdk
2.1.3
dotnet add package Tamga.Sdk --version 2.1.3
NuGet\Install-Package Tamga.Sdk -Version 2.1.3
<PackageReference Include="Tamga.Sdk" Version="2.1.3" />
<PackageVersion Include="Tamga.Sdk" Version="2.1.3" />
<PackageReference Include="Tamga.Sdk" />
paket add Tamga.Sdk --version 2.1.3
#r "nuget: Tamga.Sdk, 2.1.3"
#:package Tamga.Sdk@2.1.3
#addin nuget:?package=Tamga.Sdk&version=2.1.3
#tool nuget:?package=Tamga.Sdk&version=2.1.3
Tamga.Sdk
Official .NET SDK for Tamga. Integrate license activation, offline verification, and machine management into your .NET applications.
Install
dotnet add package Tamga.Sdk
Targets net8.0 only. Ed25519 has no BCL implementation before .NET 9, so
the package takes a single non-BCL dependency, NSec.Cryptography
(src/Tamga.Sdk/Crypto/Ed25519.cs). There is no netstandard2.0 target, so
the package will not install into a .NET Framework project.
Quickstart
using Tamga.Sdk;
using Tamga.Sdk.Models;
using var client = new TamgaClient(new TamgaClientOptions
{
AccountId = "your-account-id",
BaseUrl = "https://api.tamga.sh",
Auth = new AuthTransport.License("YOUR-LICENSE-KEY"),
});
ValidationResult result = await client.ValidateByKeyAsync("YOUR-LICENSE-KEY");
if (result.Code == ValidationCode.Valid)
{
Console.WriteLine($"Valid. Machines: {result.License.MachinesCount}/{result.License.MaxMachines?.ToString() ?? "unlimited"}.");
}
else
{
Console.WriteLine($"Not valid: {result.Code} — {result.Detail}");
}
Activating a machine against a license, then keeping it alive:
using Tamga.Sdk;
using Tamga.Sdk.Models;
var (machine, validation) = await client.ActivateMachineAsync(new CreateMachineRequest
{
Fingerprint = "a-stable-machine-fingerprint",
LicenseId = licenseId,
Hostname = Environment.MachineName,
});
if (!validation.Valid)
{
// Over-limit activations are rolled back for you — and then THROWN, as
// MachineOverLimitException (a TamgaLimitExceededException carrying the
// ValidationResult and the deleted machine id), so you never hold a machine
// whose row is gone. This branch is for the other invalid codes (Expired,
// Suspended, …), where the machine is real. Pass deleteOnOverLimit: false
// to keep the old tuple return instead.
Console.WriteLine($"Activation rejected: {validation.Code}");
return;
}
// Size the ping interval from the policy that actually governs the machine.
// The 600s figure DefaultInterval is derived from is only the server's
// fallback; a policy that sets heartbeat_duration to 120 needs a ~40s ping,
// and nothing detects that for you. One round trip at activation time.
TimeSpan interval = await client.GetHeartbeatIntervalAsync(licenseId);
await using var heartbeat = new HeartbeatScheduler(client, machine.Id, interval);
heartbeat.Pinged += m => Console.WriteLine($"heartbeat ok: {m.HeartbeatStatus}");
// No `heartbeat.Dead` handler on purpose: a ping response can never say DEAD
// (it writes last_heartbeat_at = NOW(), then reports on that), so such a
// handler is dead code. No heartbeat status stops the loop — only a 404 does.
heartbeat.Faulted += ex =>
{
// A 404 from the ping is the one signal that the machine really is gone.
if (ex is TamgaNotFoundException)
{
Console.WriteLine("machine deleted server-side — re-activate");
return;
}
Console.WriteLine($"ping failed: {ex.Message}");
};
heartbeat.Start();
samples/ holds five runnable console programs covering
validation, offline license checkout and verification, machine activation
with heartbeats, offline proofs, and entitlements.
Activation that can run twice
ActivateMachineAsync reports a repeat activation of the same fingerprint as
FingerprintTakenException, because that is what the server returns. If your
activation step can run more than once — a reinstall, a crash before the machine
id was persisted, a user clicking the button again — use the idempotent form,
which adopts the machine that already holds the fingerprint:
MachineActivation activation = await client.ActivateMachineIdempotentAsync(
new CreateMachineRequest
{
Fingerprint = "a-stable-machine-fingerprint",
LicenseId = licenseId,
});
if (activation.AlreadyActivated)
{
Console.WriteLine($"already activated as {activation.Machine.Id}");
}
The result also says how the machine was found and what became of it:
RolledBack is true when this call created the machine, validation came back
over-limit and the row was deleted again — Machine is then a tombstone. Since
2.1.2 a same-license conflict is resolved by the id the server names in the
409's meta.machineId (one GET /machines/{id}, fingerprint re-checked);
without it the license-scoped search runs as before.
Two things it deliberately will not do. It never deletes an adopted machine, even
when validation comes back over-limit — that seat belongs to something this call
did not create. And it never adopts a machine from a different license. When
the server names the conflicting machine via meta.machineId (fast path), the
server's own contract ensures it is on the requested license, not verified
client-side. When no meta.machineId is present or that machine 404s or its
fingerprint doesn't match (fallback), the lookup is client-scoped to LicenseId
via filter[license] — so under a policy whose MachineUniquenessStrategy is
UNIQUE_PER_POLICY or UNIQUE_PER_ACCOUNT — the scopes where a conflict can
come from another license — the scoped search finds nothing and the original
FingerprintTakenException surfaces.
That is the correct outcome, not a gap. Why this matters: the machine resource
carries no license_id (removed in 2.1.0), so if another license's machine were
returned, a caller could never detect it and would heartbeat and check out a
machine its own license does not own while its own machines_count stayed at
zero — seat-sharing across licenses, the exact harm the wider uniqueness scopes
exist to prevent. The server's contract on the fast path prevents this; the
client's filter[license] scoping on the fallback prevents it. All three
strategies' duplicate checks do include the caller's own license rows, so a
genuine re-activation conflicts — and is found — under every one of them.
AlreadyActivated therefore means "this license already has this machine", the
strong reading.
Reads
| Call | Route |
|---|---|
GetLicenseAsync(id) |
GET /licenses/{id} |
GetPolicyAsync(id) |
GET /policies/{id} — 403s on a license key |
GetLicensePolicyAsync(licenseId) |
GET /licenses/{id}/policy — use this one |
GetHeartbeatIntervalAsync(licenseId) |
the above, divided by three |
GetMachineAsync(id) |
GET /machines/{id} |
UpdateMachineAsync(id, request) |
PATCH /machines/{id} |
ListMachinesAsync(...) |
GET /machines — offset-paginated |
FindMachineByFingerprintAsync(licenseId, fp) |
the above, license-scoped and exact-matched client-side |
ListMachineProcessesAsync(machineId, ...) |
GET /machines/{id}/processes — keyset-paginated |
DeleteProcessAsync(id) |
DELETE /processes/{id} |
CheckForUpgradeAsync(request) |
GET /releases/actions/upgrade |
GetHealthAsync() |
GET /v1/health |
ListMachinesAsync returns an OffsetPage<T>, not the Page<T> the entitlement
and component listings use. The two paginate on different mechanisms: the machine
collection sends meta.page{number,size,total,totalPages} built from a real
count, so you walk it with HasMore; the component listing sends no pagination
metadata at all, so its cursor has to be synthesized from a full page. Reaching
for a cursor on one or a total on the other is the same mistake in opposite
directions, and both directions silently drop rows.
GetMachineAsync and the machine listing are also the only network calls in this
SDK whose response is built off a read, which is what makes
HeartbeatStatus.Dead reachable from them — see Known gaps.
Entitlements and meters
An entitlement's Kind is Flag (a boolean grant — the only kind that
existed before the server's entitlement-metering migration) or Meter (a
named, per-license counter). Only a meter carries a meaningful
MaxValue/CurrentValue, and only on the license-scoped listing —
CurrentValue is null on any other scope, never 0:
Page<Entitlement> entitlements = await client.ListEntitlementsAsync(licenseId);
foreach (var e in entitlements.Items)
{
if (e.Kind == EntitlementKind.Meter)
{
Console.WriteLine($"{e.Code}: {e.CurrentValue}/{e.MaxValue?.ToString() ?? "unlimited"}");
}
}
Incrementing, decrementing and resetting a meter mirror the machine
heartbeat pair (PingHeartbeatAsync/ResetHeartbeatAsync) exactly — one
action route per verb, the full fresh resource returned so you see the new
CurrentValue without a second round trip:
try
{
Entitlement e = await client.IncrementEntitlementUsageAsync(licenseId, entitlementId);
Console.WriteLine($"now at {e.CurrentValue}/{e.MaxValue}");
}
catch (MeterLimitExceededException ex)
{
// ex.EntitlementId names which meter hit its cap, from meta.entitlement_id —
// useful when a license has several meters and you need to tell them apart
// without re-parsing the request that triggered this.
Console.WriteLine($"meter {ex.EntitlementId} is at its limit");
}
await client.DecrementEntitlementUsageAsync(licenseId, entitlementId, decrement: 3);
await client.ResetEntitlementUsageAsync(licenseId, entitlementId);
All three require the entitlement to be directly attached to the
license — one only inherited via the policy (no license_entitlements row)
answers 404 on all three, because there is no counter to increment until
it is attached directly.
Auth transports
TamgaClientOptions.Auth accepts any one of the eight transports below
(src/Tamga.Sdk/Transport.cs::AuthTransport, applied by
TamgaTransport.ApplyAuth). License is the expected default for this
SDK's typical embedded/client use case; null sends no credentials at all.
| Transport | Constructor | Sent as |
|---|---|---|
| Bearer token | new AuthTransport.Bearer(token) |
Authorization: Bearer <token> |
| Basic (email/password) | new AuthTransport.BasicEmailPassword(email, password) |
Authorization: Basic base64(email:password) |
| Basic (token) | new AuthTransport.BasicToken(token) |
Authorization: Basic base64(token:) |
| Basic (license) | new AuthTransport.BasicLicense(key) |
Authorization: Basic base64(license:key) |
| License key | new AuthTransport.License(key) |
Authorization: License <key> |
| Session cookie | new AuthTransport.Cookie(sessionId, origin) |
Cookie: Tamga-Session=<uuid> + Origin (browser/portal only) |
| Query token | new AuthTransport.QueryToken(token) |
?token=<token> |
| Query auth | new AuthTransport.QueryAuth(token) |
?auth=<token> |
Tokens are opaque strings. Every issued token carries a tok- prefix
regardless of its documented intent (tok-/prod-/env-/activ-/lic-),
so this SDK never parses a prefix to infer a token's type.
A TOTP code can be attached to every authenticated request with
TamgaClientOptions.Otp, which is sent as the Tamga-OTP header.
Offline verification
Compatibility warning — v1 offline license files are rejected. A
.licfile must be format v2: itsalghas to end in+v2and its payload has to carry the signedmetaclaims. Pre-v2 files are rejected outright with no fallback path (src/Tamga.Sdk/Checkout/LicenseFile.cs::LicenseFile.VerifyWithClaims), so a caller holding a v1-issued file must check the license out again against a current server.
Check out a license file, then verify and decrypt it with no network access:
using Tamga.Sdk;
using Tamga.Sdk.Checkout;
using Tamga.Sdk.Models;
LicenseFile file = await client.CheckOutLicenseAsync(licenseId, encrypt: true, ttl: 3600);
// Persist file.Certificate somewhere; verify it later, offline.
byte[] accountPublicKey = Convert.FromBase64String(accountEd25519PublicKeyBase64);
try
{
License license = file.VerifyAndDecrypt(accountPublicKey, "YOUR-LICENSE-KEY");
Console.WriteLine($"Verified license {license.Id}, suspended={license.Suspended}.");
}
catch (LicenseFileExpiredException ex)
{
Console.WriteLine($"File expired at {ex.ExpiresAt} — check out a fresh one.");
}
catch (SignatureVerificationException)
{
Console.WriteLine("File failed verification — treat as untrusted.");
}
VerifyWithClaims returns the signed claims alongside the license, for
jti replay detection or kid key-rotation bookkeeping, and lets you supply
the current time rather than trusting a user-controlled system clock:
(License license, LicenseFileClaims claims) = file.VerifyWithClaims(
accountPublicKey,
"YOUR-LICENSE-KEY",
serverSuppliedUnixSeconds);
Console.WriteLine($"jti={claims.Id} kid={claims.KeyId} exp={claims.ExpiresAt}");
Machine files work the same way, except that they are bound to one machine and that the signature scheme comes from the license's own policy rather than being fixed:
using Tamga.Sdk.Checkout;
using Tamga.Sdk.Models;
MachineFile machineFile = await client.CheckOutMachineAsync(machineId, encrypt: true, ttl: 3600);
Machine machine = machineFile.VerifyAndDecrypt(
LicenseScheme.Ed25519Sign,
accountPublicKey,
"YOUR-LICENSE-KEY",
"a-stable-machine-fingerprint");
ttl is validated client-side before the request is sent, mirroring the
server's > 0 && <= 31536000 range check
(src/Tamga.Sdk/Checkout/MachineFile.cs::MachineFile.ValidateTtl).
Security notes
- Both offline file formats derive their AES-256-GCM key with
HKDF-SHA256. A license file uses
salt = "tamga:license-file-key-v1",ikm = <license key>,info = "license-file"(src/Tamga.Sdk/Crypto/Hkdf.cs::Hkdf.DeriveLicenseFileKey); a machine file usessalt = "tamga:machine-file-key-v1",ikm = <license key>,info = <machine fingerprint>(src/Tamga.Sdk/Crypto/Hkdf.cs::Hkdf.DeriveMachineFileKey), which is why a machine file cannot be decrypted anywhere but on the machine it was issued for. The pre-v2 license-file transform — the license key's raw UTF-8 bytes zero-padded to 32 — was removed rather than deprecated; no code path can produce or consume it. - Expiry is enforced, not advisory — on both file formats.
iat/exp/jti/kidare carried inside the signed bytes (src/Tamga.Sdk/Models/License.cs::LicenseFileClaims) and checked on every verify, with one shared 60-second clock-skew tolerance (src/Tamga.Sdk/Checkout/LicenseFile.cs::LicenseFile.VerifyWithClaims,src/Tamga.Sdk/Checkout/MachineFile.cs::MachineFile.VerifyWithClaims). An expired-but-authentic file raisesLicenseFileExpiredException, distinct from theSignatureVerificationExceptiona forged one raises, so "fetch a fresh file" and "someone tampered with this" are not the same outcome. A checkout made without attllegitimately carries noexpand never expires. EachVerifyAndDecrypt/VerifyWithClaimshas an overload takingnowUnixSeconds, so an application holding a server-supplied timestamp can use it instead of the local clock, which on an offline client is under the attacker's control. Thettl/expiryfields returned in the checkout response envelope remain metadata only — they are not signed. - A wrong license key is not a forgery. After a verified signature an
AES-256-GCM failure can only mean the wrong key material —
LicenseKeyMismatchException, aSignatureVerificationExceptionsubclass — on both file formats and on both the single-key and key-set paths. The key-set paths verify the signature against every held key before decoding a byte ofenc; thekidonly labels a failure (UnknownSigningKeyException/UnpublishedSigningKeyException/SignatureVerificationException). algis parsed, never sniffed, and format v2 is mandatory. A machine file'salgis<encoding>+<signing-suffix>+v2; the encoding runs to the first+, the version marker follows the last+, and the suffix is what lies between (MachineFile.VerifyWithClaims). A file without+v2is refused — a v1 file carried noexpinside its signature and derived its AES key without HKDF.algsits outside the signature and is therefore attacker-malleable, which is why it is gated rather than trusted.- An encrypted machine file's
encis two base64 halves, not one blob. It is<nonce_b64>.<ciphertext_b64>, decoded independently, with the GCM tag already inside the second half — unlike a license file, whose encryptedencreally is a singlebase64(nonce || ciphertext || tag). The signature is checked over the wholeencstring before either half is decoded. - Verification fails closed. License files are Ed25519-only
(
src/Tamga.Sdk/Checkout/LicenseFile.cs::LicenseFile.Verify). Machine files dispatch on theLicenseSchemeyou pass in, never on the file's own self-declaredalg(src/Tamga.Sdk/Checkout/MachineFile.cs::MachineFile.Verify) — two distinct RSA schemes share onealgsuffix on the wire, so trusting that string would be an algorithm-confusion hole. The file'salgsuffix is a cross-check only: a file that contradicts the scheme you passed is refused, but it can never widen it. ECDSA verification pins the P-256 curve (src/Tamga.Sdk/Crypto/Ecdsa.cs::Ecdsa.Verify). - Public keys are accepted in the encodings the server actually emits.
Ed25519 is a raw 32-byte key; ECDSA P-256 is a raw 65-byte SEC1 uncompressed
point (
0x04 || X || Y), not SPKI DER; RSA is accepted as either PKCS#1RSAPublicKeyDER or X.509SubjectPublicKeyInfoDER, because the server produces both for the same key depending on which code path you got it from (src/Tamga.Sdk/Crypto/Ecdsa.cs::Ecdsa.TryImportPublicKey,src/Tamga.Sdk/Crypto/Rsa.cs::Rsa.TryImportPublicKey). - Signatures cover the base64 string, not the decoded bytes. Both file
formats sign the UTF-8 bytes of the
encbase64 string itself (LicenseFile.Verify,MachineFile.Verify). Any reimplementation that hashes the decoded payload will reject every genuine file. - Offline proofs are always RSA-2048 PKCS#1 v1.5 / SHA-256, over a
recursively alphabetically key-sorted canonical JSON payload
(
src/Tamga.Sdk/Proof.cs::MachineProof.BuildSignedPayload,MachineProof.Verify) — the ordering the server's own serializer produces. - HTTP 429 is retried with backoff.
src/Tamga.Sdk/Transport.cs::TamgaTransport.SendWithRetryAsyncretries a rate-limited request using jittered exponential backoff (TamgaTransport.RetryDelay), preferring a parsedRetry-Aftercapped at 60 seconds (TamgaTransport.ParseRetryAfter). Auto-retry is scoped toGETplus five safePOSTactions —validate,validate-key,check-in,check-out,ping,ping-heartbeat,reset-heartbeat(TamgaTransport.IsRetryable). Creates are deliberately excluded, because a repeatedPOST /machinescan burn a second seat. SetTamgaClientOptions.MaxRetriesto0to handle429yourself.
Vulnerability reporting and the full threat model live in SECURITY.md.
Known gaps
Behaviors of the current server that a consumer of this SDK needs to plan around:
- License-key auth is off by default. The server accepts an
Authorization: License <key>credential only when the license's policy hasauthentication_strategyset toLICENSEorMIXED— and that column defaults toTOKEN. Against a default policy every call answers401 LICENSE_NOT_ALLOWED(LicenseNotAllowedException). That is a configuration precondition, not a transient failure: retrying or re-issuing the key will not help, the policy has to be changed. Suspended licenses (401 LICENSE_SUSPENDED) and expired licenses under aREVOKE_ACCESSpolicy (401 LICENSE_EXPIRED) are refused at the same front door, before any per-endpoint check runs. - 18 of the 23
ValidationCodevalues are reachable.NotFoundis modeled but never emitted (the server returns HTTP 404 directly instead), andBanned,ComponentsScopeMismatch,ChecksumScopeMismatchandVersionScopeMismatchexist for forward-compatibility only.TooManyUsers(all three validate endpoints,policy.max_users),HeartbeatDeadandHeartbeatNotStarted(aScope.Fingerprinton arequire_heartbeatpolicy) are reachable as of the API's audit patch, as areEntitlementsMissingandFingerprintScopeMismatch— see the next entry.TooManyUseswas removed entirely (not just unreachable) by the entitlement-metering migration — the global per-licenseuses/max_usescounter it reported on no longer exists on the wire, replaced by named per-entitlement meters (Kind,MaxValue,CurrentValue— see Entitlements and meters) and the422 METER_LIMIT_EXCEEDEDerror, mapped toMeterLimitExceededException. Scope: six fields enforced, two rejected.Product,Policy,User,Environment,EntitlementsandFingerprintall constrain validation.Entitlementstakes entitlement codes (not the UUIDs the attach/detach bodies use), matched case-insensitively against the union of directly-attached and policy-inherited entitlements;Fingerprintmatches any machine on the license regardless of heartbeat status.VersionandChecksumare no longer ignored — sending either makes the server fail the entire validate call with422 SCOPE_NOT_SUPPORTED, so this SDK marks them[Obsolete]and never puts them on the wire. They are scheduled for removal in the next major — not the 2.1.0 minor that removed the phantom relationship ids, because their obsolete messages carried no removal notice through any shipped release. 2.1.0 adds that notice; the removal follows it.- Machine
MemoryandDiskare MEGABYTES, not bytes. The server stores and quota-checks these columns in megabytes. Reporting 16 GB as17179869184instead of16384inflates the license's running total by a factor of 1,048,576 and tripsMEMORY_LIMIT_EXCEEDEDon the next activation against that license. - Activation limits are enforced at creation time too, not only by a later
validation.
POST /machinescan fail with422 MACHINE_LIMIT_EXCEEDED/CORE_/MEMORY_/DISK_LIMIT_EXCEEDED, surfaced asTamgaLimitExceededExceptionsubclasses whoseEquivalentValidationCodegives the matchingValidationCode. The create-time check runs through the policy's overage strategy, so underALLOW_ACCESS/ALLOW_1_25X_OVERAGEthe create still succeeds and the limit surfaces only at validate — which is whyActivateMachineAsynckeeps its create→validate→rollback path as well. GET /licenses/{id}/entitlementscannot be paginated. It returns a union of direct and policy-inherited rows, so the server ignorespage[after]on this route;limit(max 100) is the only bound.Page.NextCursoris alwaysnullhere, and a license with more than 100 effective entitlements cannot be enumerated in full — so afalsefromHasEntitlementAsyncis authoritative only below that ceiling. Component listings are unaffected: their cursor genuinely works.- Quick-validate skips its
last_validated_atwrite when the request carries anOriginheader, and the response is byte-identical either way.AuthTransport.Cookieis the one transport this SDK sendsOriginon, soQuickValidateAsynctransparently usesPOST .../actions/validateinstead when it is configured. A proxy that addsOriginto another transport defeats that; useValidateByIdAsyncif that is a risk. - A fresh policy can report enum strings that are not real variants
(
overage_strategy: "DENY_ACCESS",heartbeat_resurrection_strategy: "NO_RESURRECTION"). The server treats both as the no-restriction case, and so do this SDK's decoders — neither is surfaced as a distinct C# member, because that would imply a restriction the server does not apply. Policy.HeartbeatDurationDOES drive the heartbeat window, andHeartbeatSchedulerstill does not adapt to it on its own. The server usespolicy.heartbeat_durationwhen it is set and falls back to 600 seconds only when it is null (Policy::effective_heartbeat_duration_secs; the culler measures againstCOALESCE(p.heartbeat_duration, 600)). Earlier releases of this SDK documented the window as a hardcoded 600s that ignored the policy — that was wrong.DefaultIntervalis still ~1/3 of the 600s fallback, and on a policy with a shorterheartbeat_durationit pings too slowly and the machine lapses toDEADbetween pings. What has changed is that you no longer have to find the right number yourself:GetHeartbeatIntervalAsync(licenseId)reads the governing policy and returns the matching interval, andPolicy.EffectiveHeartbeatDurationSecondsapplies the same 600s fallback the server does. Pass an interval to the constructor — the scheduler takes the value once and keeps it, so a policy changed later needs a new scheduler. A zero or negative interval falls back toDefaultIntervalrather than throwing (policy.heartbeat_durationhas noCHECKconstraint server-side, so a hand-rolled window/3 can genuinely produce one);Timeout.InfiniteTimeSpanstill means "never tick", as it always did.- You can also obtain the window without a policy read. A checked-out
.machinefile, and nowGetMachineAsync, both carry a read-backedNextHeartbeatAt, soNextHeartbeatAt - LastHeartbeatAtrecovers the effective window. Two caveats:next_heartbeat_atislast_heartbeat_at + window, so it isnulland the window unrecoverable until the machine has pinged at least once, and a value read out of a.machinefile is a snapshot from the moment the file was issued, so a later policy change is not reflected in a file you already hold. - Do NOT derive the window from a ping response.
CreateMachineAsync,PingHeartbeatAsync,ResetHeartbeatAsyncandUpdateMachineAsyncreturn rows from statements that do not joinpolicies, so theirNextHeartbeatAtis computed against the 600s fallback whatever the policy says. Two responses for the same machine seconds apart can disagree, and the endpoint a scheduler naturally calls is the one that is wrong.GetLicensePolicyAsyncand the read-backed machines above are the trustworthy sources. - "A write-backed response can never say
DEAD" has one exception:UpdateMachineAsync. The rule holds for the ping, the create and the reset because each of those writeslast_heartbeat_at(or nulls it) and the status is then derived from the timestamp it just set.PATCH /machines/{id}touches no heartbeat column, so the status it reports is judged against a timestamp as old as it ever was, andDEADis reachable from it. The discriminator is which columns the write touched, not the HTTP verb. - Whether
Machine.NextHeartbeatAtreflects the real window depends on the route. The value is derived from a window carried on the row, populated only when the loading query joinedpolicies.CreateMachineAsync,PingHeartbeatAsyncandResetHeartbeatAsyncreturnINSERT/UPDATE … RETURNINGrows with no join, so theirNextHeartbeatAt(andHeartbeatStatus) use the 600s fallback. The machine inside a.machinefile fromCheckOutMachineAsyncis resolved through a joining query, so it carries the policy-derived value — readingNextHeartbeatAt - LastHeartbeatAtoff a checked-out machine is the one way this SDK can observe the effective window. - No heartbeat-route response can report
HeartbeatStatus.Dead, and no status ever stops the scheduler. The durable rule, which survives new endpoints in a way a route list does not: a response the server builds off a write it just performed can never sayDEAD, because the status is derived from the timestamp that write set.ping-heartbeatsetslast_heartbeat_at = NOW()and so answersALIVEorRESURRECTED;CreateMachineAsyncleaves the column unset andResetHeartbeatAsyncnulls it, both givingNOT_STARTED; validation emitsHEARTBEAT_DEADonly for aScope.Fingerprinton arequire_heartbeatpolicy, which is a read, not a heartbeat route. So anif (status == Dead)branch written againstHeartbeatScheduleris unreachable code, and re-activation placed inside one never runs. The rule covers every status, not justDEAD: the loop ends on cancellation, on disposal, or on404 NOT_FOUNDfrom the ping — surfaced onFaultedasTamgaNotFoundException. Hang re-activation off that and nothing else. TheDeadevent is kept (a machine-read method would make it live) but no heartbeat route can currently raise it. - A response built off a read can report
DEAD— and three now reach you.CheckOutMachineAsyncyields a.machinefile whose embedded machine is resolved server-side through a read query;MachineFile.VerifyAndDecryptreturns aMachinewhoseHeartbeatStatusis bound from that payload. So doGetMachineAsyncandListMachinesAsync, whose query joinspolicies. Even there it means only that the last ping is older than the window: the cull job early-returns unlesspolicy.require_heartbeatis set, and that column defaults tofalse, so on a default policy nothing is ever culled and a machine can sit atDEADindefinitely with its row and its seat still there. A later ping revives it. - No response carries a
relationshipsobject. Every serializer emits{ type, id, attributes }only, on licenses and machines alike, so nothing on a licence or machine read links it to its product, policy, owner or environment. Five properties used to claim otherwise —License.ProductId/PolicyId/UserId/EnvironmentIdandMachine.LicenseId— and always answerednull. They were[Obsolete]from 2.0.0 and removed in 2.1.0. Track the ids you activated with yourself, or use the dedicatedGET /licenses/{id}/product·/policy·/ownerroutes.CreateMachineRequest.LicenseIdis a live request field and is unaffected. ResetHeartbeatAsyncandGenerateOfflineProofAsyncalways403on a license key. Both are role-gated (admin / developer / product token / environment token, plus sales/support agents for proofs) rather than permission-gated, and the license-key role is not on either list — even though it holdsmachine.proofs.generate.PingHeartbeatAsyncis permission-gated and works fine. This matters most for reset: it is the only server-side way to unstick a wedged heartbeat job, so an embedded client cannot self-recover.Policy.MaxMemoryandPolicy.MaxDiskare absent fromGETresponses even though both are enforced during validation, so they cannot be introspected client-side — only observed asTooMuchMemory/TooMuchDiskon a failed validation. They are modelled anyway and were deliberately kept when the phantom relationship ids were removed in 2.1.0: unlike those, these are real wire bindings on a type deserialized straight fromattributes, so they start working with no SDK change the day the server's policy serializer projects the two columns it already has. Every one of the other 30 policy attributes the serializer emits is modelled; 14 of them were silently missing before.GetPolicyAsyncalways403s on a license key;GetLicensePolicyAsyncdoes not.GET /policies/{id}is gated on thepolicy.readpermission, which is not in theLicenseTokenrole's set — no policy setting turns it on.GET /licenses/{id}/policyreturns the identical resource and is gated onlicense.read, which a license key does hold. Embedded clients want that one;GetPolicyAsyncis for admin / developer / product-token / environment-token credentials, or when you hold a policy id and no license id.- The read routes are not scoped to the caller's own license, and neither are
the machine writes.
GET /licenses/{id},GET /policies/{id}andGET /licenses/{id}/policycheck a permission plus the account on the verified credential, but not — unlike validate and check-out — that the id being read is the credential's own. A client holding one license key can therefore read every license in the account including each one's plaintextkey. The same omission covers machines:LicenseTokenholdsmachine.read,machine.updateandmachine.delete, and no machine route applies the per-license scope check, so a license key canPATCHorDELETEany machine in the account. This SDK cannot fix any of that; it is reported upstream. Do not expose these routes to an untrusted client, and do not build a UI that assumes a license key can only reach its own rows. policy.check_in_intervalis stored in the adverbial form (daily/weekly/monthly/yearly), not the noun form this SDK's documentation previously claimed. The decoder accepts both, and an unknown value falls back to the shortest interval so a policy it cannot read is over-served rather than under-served. Read it together withPolicy.CheckInIntervalCount— the period iscount × unit.- There is no exact-match fingerprint filter on the machine collection. The
only fingerprint-aware query parameter is
filter[q], a case-insensitive substring search that also coversnameandhostname.FindMachineByFingerprintAsyncnarrows withfilter[license]plus the fingerprint as a search term and then re-checks equality client-side; anything that trusted the server's result set directly could return a machine whose hostname merely contained the fingerprint. - The unfiltered machine listing is account-wide, not license-scoped, because
no machine route applies the per-license scope check and
LicenseTokenholdsmachine.read. PasslicenseIdtoListMachinesAsyncwhenever the answer is meant to be about one license — the server will not narrow it for you, and the machine resource carries nolicense_idto narrow it afterwards. This is whyFindMachineByFingerprintAsyncrequires a license id rather than offering an account-wide convenience overload. - Nothing on the server deletes process rows. The process reaper is not
wired up, so a process row outlives the process it represents until a client
removes it — and those rows count against
policy.max_processes. CallDeleteProcessAsync, or setProcessHeartbeatScheduler.DeleteOnDispose. Machines are different: they do get culled, but only whenpolicy.require_heartbeatis set, which is not the default. - Checkout
includesis always empty, and each checkout mints a fresh certificate: the call is not idempotent. x-ratelimit-*response headers ARE set, and are read back — this README said the opposite until 2.1.0. The rate-limit middleware attaches all four (limit,remaining,reset,window) to the response it returns, on the request it lets through as well as on the429it refuses. Read them withTamgaTransport.ReadRateLimitInfo(response). Two traps worth knowing:resetis an absolute Unix time in seconds, not a delay, so useResetAt; and absent is not exhausted — a server with no rate limiter configured sets no headers at all, so checkIsPresentbefore readingRemainingas a budget rather than concluding you have none left. This is independent of surviving a429, which the transport already handles on its own.GetHealthAsyncis a differential diagnostic, not just a ping.GET /v1/healthis exempt from two gates every other request passes: it is on the server's public-route list, so it needs no credential, and it skips theHost-header check. So if every ordinary call is failing with403and "The Host header does not match any configured host" while this one succeeds, the problem is the deployment's allowed-hosts configuration — not your token, not your account id, and not anything re-issuing credentials will fix. Note it is a liveness probe: the handler never touches the database, so a healthy answer does not promise licensing calls will work. Its body is a plain{status, version, uptime_secs}object, not a JSON:API document.- The machine
groupandownersub-resources are not exposed. Both needgroupsandusersresource models that a licensing client has no use for./machines/{id}/componentsand/machines/{id}/processesare both exposed. - Component and process REQUEST bodies are flat; their RESPONSES are not.
POST /componentsandPOST /processestake{machine_id, fingerprint, name, metadata}at the root, unlike the envelopedPOST /machines— that asymmetry is real and deliberate. It is request-only: every response on these routes is an ordinary JSON:API{type, id, attributes}document. Releases up to and including 2.0.x decoded those responses flat, soCreateComponentAsync,CreateProcessAsyncandPingProcessAsyncreturned objects with empty ids and empty strings, andListComponentsAsyncreturned the right number of blank components. Fixed; if you were working around it by re-fetching, you can stop.
Documentation
- tamga.sh — product documentation and the API reference.
samples/— five runnable end-to-end console programs.- CONTRIBUTING.md — local dev setup, build/test commands, coding standards.
- SECURITY.md — threat model and vulnerability reporting.
- XML doc comments ship in the package, so IntelliSense carries the per-endpoint notes inline.
License
MIT — see LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- NSec.Cryptography (>= 24.4.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.