Skip to content
← Alle Beiträge
· Delta1 Labs KeyrightLizenzierungTiefenanalyse

Eine Lizenz deaktivieren und auf eine neue Maschine übertragen: Quittungen, Offline-Nachweise und Transferlimits

Einen Seat freizugeben klingt wie die einfache Hälfte des Node-Lockings — der Kunde klickt auf „dieses Gerät deaktivieren" und der Seat ist wieder verfügbar. Doch eine Deaktivierung ist eine Behauptung, die der Client über seinen eigenen Zustand aufstellt, und der Client ist die eine Partei, der Sie nicht vertrauen können. Online geht es um Reihenfolge und Wiederholungen: auf dem Server freigeben, bevor oder nachdem lokal gelöscht wird, und was passiert, wenn eines von beiden fehlschlägt. Offline geht es um den Nachweis: Eine Maschine ohne Netz muss dem Kunden etwas aushändigen, das den Server überzeugt, dass ein Seat wirklich aufgegeben wurde. Und bei toten Maschinen — dem Laptop, der in den See gefallen ist — geht es um Missbrauch: Jede erzwungene Freigabe ist eine Gelegenheit, Seats zu vervielfachen. Dieser Beitrag liest das Keyright-SDK und die Server-Richtlinie für alle drei Fälle: die idempotente Online-Freigabe, die HMAC-Deaktivierungsquittung, die an das Instanzgeheimnis der Aktivierung gebunden ist, warum die Lease-TTL begrenzt, was ein lügender Client gewinnen kann, und die Transfer-Abkühlphase und die rollierenden Limits, die verhindern, dass „ich habe meine Maschine verloren" zum Seat-Generator wird.

Die Aktivierung bekommt die ganze Aufmerksamkeit: Fingerabdrücke, Seats, signierte Leases. Die Deaktivierung — einen Seat zurückzugeben, damit er anderswo verwendet werden kann — wird als triviale Umkehrung behandelt. Das ist sie nicht, aus einem Grund: Eine Aktivierung ist etwas, das der Server gewährt, aber eine Deaktivierung ist etwas, das der Client behauptet. „Ich habe aufgehört, diese Lizenz auf dieser Maschine zu nutzen” ist eine Aussage der einen Partei im System, der Sie nicht vertrauen können, und das Design einer sicheren Deaktivierung dreht sich vollständig darum, wie viel dieser Aussage Sie verifizieren und wie viel Sie begrenzen können.

Es gibt drei Fälle, und sie brauchen drei verschiedene Antworten. Die Maschine ist online und kooperativ: Das Problem sind Reihenfolge und Wiederholungen. Die Maschine ist offline — vom Netz getrennt oder ein Feldlaptop ohne Empfang: Das Problem ist der Nachweis. Die Maschine ist weg — gestohlen, defekt, ohne Deaktivierung neu aufgesetzt: Das Problem ist Missbrauch, denn jede erzwungene Freigabe ist eine Gelegenheit, aus einer Lizenz zwei zu machen. Dieser Beitrag geht das Keyright-SDK und die Server-Richtlinie für jeden Fall durch.

online — kooperativ1. POST /v1/free-seat key = activation + nonce2. Server gibt Seat frei3. Client löscht Speichererst Server, dann lokal;Wiederholung sicher (idempotent);ein hängender Cache stirbt mit TTLSeat frei: sofortzählt gegen Limit: neinoffline — Quittung1. Client löscht Speicher2. Quittung = Nutzlast + HMAC(instanceSecret)3. Kunde fügt im Portal ein4. Server prüft, gibt freibeweist, dass die aktivierteInstanz aufgab; Cache-Leaseüberlebt ihre TTL nichtzählt gegen Limit: neintote Maschine — erzwungen1. Admin / Kunde: „verloren"2. Seat → Abkühlung (TTL)3. dann freidie alte Maschine läuft evtl.noch auf einer Cache-Lease,Seat also nie doppelt aktivSeat frei: nach TTLzählt gegen Limit: JA(3 pro 30 Tage, rollierend)

Online: auf dem Server freigeben, dann löschen, und das Ganze wiederholbar machen

Der kooperative Fall enthält immer noch einen Fehler, den die meisten ersten Implementierungen ausliefern: die Reihenfolge der beiden Seiteneffekte. Eine Deaktivierung muss zwei Dinge tun — den Seat auf dem Server freigeben und die Lizenz auf dem Client zerstören — und genau eines davon kann fehlschlagen, nachdem das andere gelungen ist.

Erst löschen, dann den Server aufrufen, und ein fehlgeschlagener Aufruf lässt den Kunden ohne Lizenz auf dieser Maschine zurück und mit einem weiterhin belegten Seat. Das ist das schlechteste Ergebnis: ein feststeckender Seat, eine unlizenzierte App und ein Ticket. Erst den Server aufrufen, dann löschen, und ein fehlgeschlagenes Löschen lässt die App mit einer zwischengespeicherten signierten Lease zurück — aber diese Lease hat eine TTL, der Server hat den Seat bereits freigegeben, und der nächste Heartbeat liefert ein Aktivierung-verloren-Ergebnis. Die Maschine fällt innerhalb einer Lease-Periode von selbst in den unlizenzierten Zustand. Der Fehler ist begrenzt und heilt sich selbst. Also: zuerst der Server.

public async Task<DeactivationOutcome> DeactivateAsync(CancellationToken ct)
{
    var current = await _store.ReadAsync(ct);
    if (current is null)
        return DeactivationOutcome.NothingToRelease;

    // The nonce is generated ONCE and persisted before the call, so a retry after
    // a crash or timeout replays the same idempotency key instead of minting a new one.
    var nonce = current.PendingDeactivationNonce ??= Guid.NewGuid().ToString("N");
    await _store.WriteAsync(current, ct);

    var result = await _client.FreeSeatAsync(new FreeSeatRequest(
        LicenseId:      current.LicenseId,
        ActivationId:   current.ActivationId,
        InstanceSecret: current.InstanceSecret,
        Nonce:          nonce), ct);                          // POST /v1/free-seat

    if (!result.Released && !result.AlreadyReleased)
        return DeactivationOutcome.ServerRefused(result.Reason); // store is intact; nothing lost

    await _store.WipeAsync(ct);          // only after the server has freed the seat
    _state.Clear();                      // drop the in-memory lease; app is now unlicensed
    return DeactivationOutcome.Released;
}

Zwei Details tragen die Last. Die Nonce wird vor dem Absenden der Anfrage in den Speicher geschrieben, sodass der nächste Versuch denselben (ActivationId, Nonce)-Schlüssel sendet, falls der Prozess mitten im Aufruf stirbt. Und der Server behandelt diesen Schlüssel idempotent: Eine zweite Anfrage mit demselben Schlüssel liefert das Ergebnis der ersten.

public async Task<FreeSeatResult> FreeSeatAsync(FreeSeatRequest req, CancellationToken ct)
{
    await using var _ = await _store.LockActivationAsync(req.ActivationId, ct);

    // Replay: same activation + same nonce → return the recorded outcome, do not error.
    if (await _store.FindReleaseAsync(req.ActivationId, req.Nonce, ct) is { } prior)
        return FreeSeatResult.AlreadyReleased(prior.ReleasedAt);

    var activation = await _store.FindActivationAsync(req.ActivationId, ct);
    if (activation is null || !activation.SecretMatches(req.InstanceSecret))
        return FreeSeatResult.Refused("unknown activation or bad instance secret");

    activation.Release(ReleaseReason.SelfOnline, _clock.GetUtcNow());
    await _store.RecordReleaseAsync(req.ActivationId, req.Nonce, activation.ReleasedAt!.Value, ct);
    await _store.UpsertActivationAsync(activation, ct);
    return FreeSeatResult.Released(activation.ReleasedAt.Value);
}

Beachten Sie, was verifiziert wird: das InstanceSecret. Eine Deaktivierungsanfrage wird nicht über den Lizenzschlüssel authentifiziert — sonst könnte jeder, der den Schlüssel hat, einen Seat freigeben, den er nicht hält —, sondern über das opake Geheimnis, das der Server dieser Aktivierung bei ihrer Erstellung ausgehändigt hat. Dasselbe Geheimnis macht den Offline-Fall überhaupt erst möglich.

Offline: eine Quittung, die der Server prüfen kann, ohne mit dem Client zu sprechen

Eine vom Netz getrennte Maschine kann /v1/free-seat nicht aufrufen. Was sie kann, ist etwas zu erzeugen, das der Kunde zum Portal trägt — eine kurze Zeichenfolge —, das den Server überzeugt, dass der Seat aufgegeben wurde. Die Frage ist, was diese Zeichenfolge ehrlicherweise beweisen kann.

Sie kann nicht beweisen, dass das Löschen stattgefunden hat. Der Client ist nicht vertrauenswürdiger Code auf einer Maschine, die Sie nicht kontrollieren; ein modifizierter Client könnte die Quittung ausgeben und die Lizenzdateien behalten. Entwerfen Sie also nicht um einen „Löschnachweis” herum. Entwerfen Sie um einen Aufgabenachweis herum: Die Quittung beweist, dass die aktivierte Instanz — die einzige Inhaberin des Instanzgeheimnisses — sich für die Freigabe entschieden hat. Alles Weitere folgt daraus, was der Server damit tut.

public sealed record DeactivationReceipt(
    string ActivationId,
    string LicenseId,
    string Fingerprint,
    DateTimeOffset IssuedAt,
    string Nonce,
    string Mac);                 // HMAC-SHA256 over the canonical fields, keyed by InstanceSecret

public async Task<string> DeactivateOfflineAsync(CancellationToken ct)
{
    var current = await _store.ReadAsync(ct) ?? throw new InvalidOperationException("not activated");

    var body = new DeactivationReceipt(
        current.ActivationId, current.LicenseId, ComputeFingerprint(),
        _clock.GetUtcNow(), Guid.NewGuid().ToString("N"), Mac: "");

    var canonical = $"{body.ActivationId}|{body.LicenseId}|{body.Fingerprint}|{body.IssuedAt:O}|{body.Nonce}";
    using var hmac = new HMACSHA256(Convert.FromBase64String(current.InstanceSecret));
    var mac = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)));

    // Wipe BEFORE the receipt is ever displayed. If the wipe throws, no receipt exists,
    // the machine is still licensed, and the customer can try again.
    await _store.WipeAsync(ct);
    _state.Clear();

    return Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(body with { Mac = mac }));
}

Die Reihenfolge ist gegenüber dem Online-Pfad absichtlich umgekehrt. Online handelt der Server zuerst, weil der Server die Quelle der Wahrheit ist und das lokale Löschen nur Aufräumen. Offline kann nichts auf dem Server wirken, bis der Kunde einen Browser erreicht, also kommt das lokale Löschen zuerst und die Quittung ist das Produkt eines erfolgreichen Löschens: Der Benutzer sieht niemals eine Quittung für eine Lizenz, die noch auf der Platte liegt. Schlägt das Löschen fehl, gibt es keine Quittung und keinen Schaden.

Der Server prüft die Quittung mit dem Instanzgeheimnis, das er bei der Aktivierung gespeichert hat:

public async Task<ReceiptResult> RedeemReceiptAsync(string encoded, CancellationToken ct)
{
    if (JsonSerializer.Deserialize<DeactivationReceipt>(Base64Url.Decode(encoded)) is not { } r)
        return ReceiptResult.Invalid("malformed");

    await using var _ = await _store.LockActivationAsync(r.ActivationId, ct);
    var activation = await _store.FindActivationAsync(r.ActivationId, ct);
    if (activation is null || activation.LicenseId != r.LicenseId)
        return ReceiptResult.Invalid("unknown activation");

    var canonical = $"{r.ActivationId}|{r.LicenseId}|{r.Fingerprint}|{r.IssuedAt:O}|{r.Nonce}";
    using var hmac = new HMACSHA256(activation.InstanceSecretBytes);
    var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
    if (!CryptographicOperations.FixedTimeEquals(expected, Convert.FromBase64String(r.Mac)))
        return ReceiptResult.Invalid("bad mac");

    if (await _store.NonceSeenAsync(r.ActivationId, r.Nonce, ct))
        return ReceiptResult.AlreadyRedeemed(activation.ReleasedAt);
    if (_clock.GetUtcNow() - r.IssuedAt > _policy.ReceiptMaxAge)      // e.g. 30 days
        return ReceiptResult.Invalid("expired");

    activation.Release(ReleaseReason.OfflineReceipt, _clock.GetUtcNow());
    await _store.RememberNonceAsync(r.ActivationId, r.Nonce, ct);
    await _store.UpsertActivationAsync(activation, ct);
    return ReceiptResult.Released;
}

Nun die ehrliche Bilanz. Angenommen, der Client hat gelogen — er hat eine gültige Quittung ausgegeben und seine Lizenzdateien behalten. Was hat er gewonnen? Der Server hat die Aktivierung als freigegeben markiert. Die zwischengespeicherte signierte Lease auf der Maschine ist kryptografisch weiterhin gültig, aber sie trägt ein Ablaufdatum, und die Maschine hat keine Möglichkeit, sie zu erneuern: Sie ist offline, und selbst wenn sie online käme, liefert ein Heartbeat gegen eine freigegebene Aktivierung Aktivierung-verloren. Die gestohlene Zeit ist also nach oben durch eine Lease-TTL begrenzt — die Restlaufzeit der Lease im Moment der Quittung. Für eine vom Netz getrennte Installation, in der Sie 30-Tage-Offline-Leases ausstellen, ist das der Preis der Offline-Deaktivierung, und Sie legen ihn fest, wenn Sie die TTL festlegen. Es gibt kein Design, das ihn ohne Netz auf null bringt, und jedes Produkt, das etwas anderes behauptet, vertraut dem Client.

Die Quittung enthält absichtlich den Fingerabdruck. Er wird für die Prüfung nicht gebraucht — der HMAC bindet die Aktivierung bereits —, aber er erlaubt dem Portal, dem Kunden zu zeigen, welche Maschine er freigibt, und erlaubt Ihrem Support, eine Quittung zu erkennen, die für eine Maschine eingelöst wird, die nicht zum Aktivierungsdatensatz passt.

Tote Maschinen: der erzwungene Pfad, und warum er ein Abkühlfenster hat

Der dritte Fall hat überhaupt keinen Client. Der Laptop wurde gestohlen; die VM wurde gelöscht; die Platte wurde von jemandem neu aufgesetzt, der nicht wusste, dass die Lizenz darauf lag. Der Kunde braucht den Seat zurück, und es gibt nichts aufzurufen, nichts zu signieren. Die einzige Option ist eine erzwungene Freigabe: Ein Administrator in Ihrem Dashboard oder der Kunde im Portal erklärt die Maschine für verloren.

Das ist der Pfad, der Lizenzierung in einen Seat-Generator verwandelt, wenn Sie nicht aufpassen. „Meine Maschine ist kaputt” kostet nichts zu sagen, und ein Kunde, der es einmal pro Woche sagt, hat faktisch eine unbegrenzte Lizenz. Zwei Mechanismen halten das ehrlich, und keiner beruht darauf, der Behauptung zu glauben.

Der erste ist das Abkühlfenster. Die tote Maschine ist vielleicht nicht tot. Sie ist vielleicht eine Maschine, die einfach offline ist und mit einer zwischengespeicherten Lease läuft, die der Server bereits ausgestellt hat — eine Lease, die bis zu ihrem Ablauf gültig ist, egal was der Server jetzt denkt. Würde die erzwungene Freigabe den Seat sofort freigeben, könnte der Kunde eine neue Maschine aktivieren, während die „tote” mit ihrem Cache weiterläuft, und der Seat wäre an zwei Orten aktiv. Also gibt eine erzwungene Freigabe den Seat nicht frei; sie versetzt ihn in Abkühlung, und der Seat wird erst verfügbar, wenn die maximale Restlaufzeit der Lease verstrichen ist.

public ForcedReleaseResult ForceRelease(Activation activation, ReleaseReason reason, DateTimeOffset now)
{
    // The last lease we issued to this activation is the longest anything on the
    // "dead" machine can still be running on. The seat is not free until it is gone.
    var coolingUntil = activation.LastIssuedLease.ExpiresAt;

    activation.Release(reason, now);
    activation.SeatAvailableAt = coolingUntil > now ? coolingUntil : now;

    return new ForcedReleaseResult(activation.SeatAvailableAt);
}

Für eine Desktop-App, die stündlich mit einer 24-Stunden-Lease heartbeatet, beträgt die Abkühlung höchstens einen Tag. Für eine 30-Tage-Offline-Lease sind es bis zu 30 Tage, was die richtige Antwort ist: Diese Lizenz könnte tatsächlich noch in Gebrauch sein. Ist die Maschine wirklich tot, wartet der Kunde; ist sie es nicht, ist das Abkühlfenster das, was Ihre Seat-Zählung wahr hält. Ihr Portal sollte den Countdown anzeigen, damit der Kunde nicht im Dunkeln tappt.

Der zweite Mechanismus ist das rollierende Transferlimit. Erzwungene Freigaben werden gezählt, Selbst- und Quittungsfreigaben nicht — denn diese beiden Pfade haben die alte Instanz tatsächlich gestoppt, während der erzwungene Pfad das nur behauptet hat.

public sealed record TransferPolicy(int MaxForcedReleases, TimeSpan Window);   // e.g. 3 per 30 days

public bool CanForceRelease(License license, TransferPolicy policy, DateTimeOffset now)
{
    var recent = license.Releases.Count(r =>
        r.Reason is ReleaseReason.AdminForced or ReleaseReason.CustomerForced &&
        r.ReleasedAt > now - policy.Window);

    return recent < policy.MaxForcedReleases;
}

Drei pro dreißig Tage ist großzügig für ehrliche Kunden — Hardware fällt aus, aber nicht wöchentlich — und ruinös für jeden, der über den „Maschine verloren”-Knopf eine Seat-Farm betreiben will. Wird das Limit erreicht, sagt das Portal es, und der Kunde spricht mit Ihnen, was genau das Gespräch ist, das Sie an diesem Punkt führen wollen. Keyright lässt Sie Fenster und Anzahl pro Produkt festlegen, denn eine Flotte robuster Feldgeräte stirbt legitimerweise öfter als ein Schreibtisch voller Workstations.

Jede Freigabe ist ein Buchungseintrag

Alle drei Pfade enden am selben Ort: activation.Release(reason, now). Der Grund ist keine Dekoration. Er ist das, was die Geschichte einer Lizenz sechs Monate später lesbar macht, wenn ein Kunde mit einer 5-Seat-Lizenz irgendwie vierzig Maschinen aktiviert hat und Sie wissen müssen, ob das vierzig ehrliche Ersetzungen waren oder etwas anderes.

public enum ReleaseReason
{
    SelfOnline,       // client called /v1/free-seat with its instance secret — seat really stopped
    OfflineReceipt,   // HMAC receipt redeemed — activated instance surrendered; bounded by TTL
    CustomerForced,   // customer declared the machine lost in the portal — counts toward limit
    AdminForced,      // you did it from the dashboard — counts toward limit, with a note
    Reaped            // lease expired with no heartbeat; the seat aged out on its own
}

Die Aktivierungsansicht pro Lizenz im Dashboard zeigt diese Gründe neben den Gerätemetadaten, sodass sich die ehrliche Geschichte — eine ersetzte Maschine, eine aus dem Feld eingelöste Quittung, eine erzwungene Freigabe im März — auf einen Blick liest, und die unehrliche — nichts als erzwungene Freigaben, alle neun Tage — genauso deutlich.

Was man nicht tun sollte

Geben Sie den Seat nicht aus einem Deinstallationsprogramm frei. Deinstallationsprogramme laufen ohne Netz, ohne Zustimmungsabfragen, die Sie kontrollieren, und oft als Teil einer Neuinstallation, die der Kunde nicht als Deaktivierung gemeint hat. Ein Seat, der von einem Deinstallations-Hook freigegeben wird, ist ein versehentlich freigegebener Seat, und eine Neuinstallation belegt dann bei der Reaktivierung einen zweiten Seat. Machen Sie die Deaktivierung zu einer expliziten Aktion mit einem expliziten Ergebnis.

Akzeptieren Sie „ich habe es gelöscht” nicht ohne das Instanzgeheimnis. Eine Deaktivierungsanfrage, die nur über den Lizenzschlüssel authentifiziert wird, erlaubt jedem, der den Schlüssel gesehen hat — ein geteilter Schlüssel, ein geleakter Schlüssel, ein ehemaliger Mitarbeiter —, Seats freizugeben, die er nicht hält. Das Instanzgeheimnis ist das Einzige, was nur der aktivierte Client hat, und es ist das einzige Credential, das eine Freigabe akzeptieren sollte.

Und lassen Sie den erzwungenen Pfad nicht kostenlos sein. Das Abkühlfenster und das rollierende Limit sind nicht da, weil Sie Ihren Kunden misstrauen; sie sind da, weil ohne sie die Seat-Zählung eine Meinung ist. Mit ihnen ist ein Seat frei, wenn der Server beweisen kann, dass nichts darauf läuft — und das ist der Unterschied zwischen einem Node-Lock, der zählt, und einem, der nur hofft.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.