DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Rotating your license-signing key without bricking the field

License signing has a sharp edge that stays hidden until the day you need it. A signed license key carries its claims and a signature, and your application verifies that signature offline against a public key it ships — the design that makes a signed key self-proving. It works beautifully right up to the moment you have to change the key pair: a private key you think has leaked, a scheduled rotation your security policy mandates, or a jump from one algorithm to a stronger one. Now you have two populations that must agree — an issuing service and an entire installed base — and you cannot update both in the same instant.

Rotate naively and you break the field in one of two directions. Sign new licenses with the new key and the installed base, still carrying the old public key, rejects every fresh license. Push a build with the new public key and every license already out there, signed with the old key, stops verifying. Keyright treats a signing key as something you will rotate, not something you set once, by putting a key id in every signature and a trusted keyset in the SDK. This post is the full mechanism: the kid, the multi-key trust store, the overlap, the re-issue, and the safe retirement.

The single-key trap

Here is the verify path most licensing code starts with — one embedded public key, one signature check:

// The brittle version: one key, forever.
public LicenseClaims Verify(string licenseKey)
{
    var (payload, signature) = SplitBase32(licenseKey);
    if (!Ed25519.Verify(signature, payload, _publicKey))   // THE one key
        throw new InvalidLicenseException("signature invalid");
    return ParseClaims(payload);
}
Enter fullscreen mode Exit fullscreen mode

There is nowhere in this code to stand on the day the key changes. _publicKey is a single value baked into the build; the signature carries no hint of which key produced it. The only way forward is to replace _publicKey everywhere at once — which is exactly the thing you cannot do across a field of installs. Everything that follows exists to remove that single point of brittleness.

Step 1: put a kid in the signed payload

The payload that gets signed already carries the license claims; add one more field — a key id naming the key pair that will sign it. Because it lives inside the signed bytes, it is tamper-evident: change it and the signature fails.

// The claims that get canonicalised and signed. kid is just another claim.
var claims = new LicenseClaims
{
    Kid       = "k3",                       // which key pair signs this
    Product   = "acme",
    Tier      = "pro",
    Licensee  = "contoso",
    Expiry    = new DateOnly(2027, 10, 11),
};
byte[] payload   = CanonicalJson(claims);          // stable byte encoding
byte[] signature = Ed25519.Sign(payload, privateKeyForKid("k3"));
string key       = Base32(Concat(payload, signature));
Enter fullscreen mode Exit fullscreen mode

The kid is not a secret and not a security boundary — it is routing metadata. A good kid is short, stable, and unique per key pair: a monotonic label (k1, k2, k3) or the first bytes of a hash of the public key. What it must never be is reused: a kid identifies exactly one key pair for the life of your product, so a verifier can map it to one public key with no ambiguity.

Step 2: the SDK trusts a keyset, not a key

The verifier now holds a set of trusted public keys indexed by kid. It reads the kid from the payload, selects the matching key, and verifies once:

public sealed class KeySet
{
    private readonly Dictionary<string, byte[]> _keys;     // kid -> public key
    private readonly HashSet<string> _revokedIssuers;      // kids no longer allowed to issue

    public LicenseClaims Verify(string licenseKey)
    {
        var (payload, signature) = SplitBase32(licenseKey);
        var kid = ReadKidField(payload);                   // read BEFORE verifying — see note

        if (!_keys.TryGetValue(kid, out var publicKey))
            throw new InvalidLicenseException($"unknown signing key '{kid}'");

        if (!Ed25519.Verify(signature, payload, publicKey))
            throw new InvalidLicenseException("signature invalid");

        var claims = ParseClaims(payload);                 // now trusted
        if (_revokedIssuers.Contains(kid))
            throw new RevokedIssuerException(kid);          // authentic, but this issuer is retired
        return claims;
    }
}
Enter fullscreen mode Exit fullscreen mode

Two subtleties carry the whole design. First, you read the kid from unverified bytes to select a key — that is safe because selecting the wrong key only makes verification fail; you never trust the kid's value, only use it to route. Second, the revoked-issuer check comes after a successful signature check: you want to distinguish "this key is fake" from "this key is genuine but its issuing authority has been retired," and only a verified payload lets you say the latter honestly.

The keyset is configuration, not code, so you can add a key without a code change:

{
  "Keyright": {
    "TrustedKeys": {
      "k2": "MCowBQYDK2VwAyEA3b1f…",
      "k3": "MCowBQYDK2VwAyEA9c42…"
    },
    "RevokedIssuers": []
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: the overlap window

Rotation is not an event; it is a window during which both keys are valid. Three things run in parallel: the issuer signs everything new with k3, the SDK trusts both k2 and k3, and a background job re-issues the active base from k2 to k3. The field drains from old to new without a single rejected license.

The order within the window is what keeps the field alive. You add k3 to the SDK's trust store and ship that build before the issuer ever signs with k3 — so no install ever sees a k3 license it cannot verify. Reverse that and the first k3 license outruns the trust-store update and gets rejected.

Step 4: re-issue the active base

New sales and renewals pick up k3 for free. The licenses already in the field need a push. A re-issue reads a stored license's claims, re-signs the identical claims under the new kid, and hands back a drop-in replacement:

// Re-sign the SAME claims under the new key. No terms change — only the signature.
public string ReIssue(LicenseClaims existing)
{
    var claims = existing with { Kid = "k3" };          // only the kid field changes
    byte[] payload   = CanonicalJson(claims);
    byte[] signature = Ed25519.Sign(payload, privateKeyForKid("k3"));
    return Base32(Concat(payload, signature));
}
Enter fullscreen mode Exit fullscreen mode

Because nothing but the kid and the signature changes, a re-issued key is interchangeable with the original from the customer's point of view — same tier, same expiry, same seats. Delivery rides your existing refresh path: an app that already checks for a renewed lease simply receives one signed with k3 the next time it refreshes, and the overlap keyset verifies it with no customer action at all.

Step 5: retire the old key — last, and only last

A key leaves the trust store when, and only when, the population signed with it has drained to zero. Removing k2 is the one irreversible step, and doing it early is the classic way to brick paying customers whose re-issue had not yet reached them.

{
  "Keyright": {
    "TrustedKeys": { "k3": "MCowBQYDK2VwAyEA9c42…" },
    "RevokedIssuers": ["k2"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Note the two-stage retirement. Long before you delete k2 from TrustedKeys, you put it in RevokedIssuers the moment you stop issuing with it — especially if it leaked. A revoked-but-trusted key still verifies licenses you can prove predate the leak, but your build refuses to honour it as a live issuer, which closes the forgery window immediately while verification drains on its own schedule. Authority to issue and trust to verify are separate switches: rotation flips issuing off now and lets verification coast to zero.

What to take away

A signing key is not a constant; it is a value you will one day replace, and the time to make that cheap is before you ship. Put a kid inside the signed payload so every signature says which key made it, trust a keyset instead of a key so old and new verify side by side, and sequence the rotation so the SDK trusts the new key before the issuer ever uses it. Re-issue the active base over a renewal cycle, revoke a leaked issuer the instant you stop signing with it, and delete the old public key only after the field has drained. Done this way, rotating a compromised or ageing key is a scheduled maintenance window — not an outage, and never a bricked install.

Top comments (0)