DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

When a VM gets cloned: detecting duplicate activations in node-locked licensing

Node-locking rests on one assumption: a machine fingerprint identifies a machine. Clone a VM and that assumption is simply false. A snapshot restored onto a second host, a golden image rolled out to fifty instances, a VM template an autoscaler stamps out on demand — every one of them reproduces the hardware and OS attributes a fingerprint is built from, which means every copy computes the same fingerprint. Node-locking asks "is this the machine the license was activated on?" and for a clone the honest answer is yes.

So the usual instinct — hash more attributes, make the fingerprint stronger — is exactly wrong. The fingerprint is doing its job. It was designed to be stable across reboots, driver updates and a swapped disk, and that stability is precisely what survives cloning. A fingerprint that could tell two clones apart would be one that changed every time you rebooted, which would lock out your honest customers on Tuesday morning.

The thing that can tell two clones apart is not on the machine at all. It is identity the server mints and tracks: an activation record the server owns, plus a runtime instance id each running process generates fresh and reports on every heartbeat. This post walks the mechanism end to end — why the fingerprint is defeated, what an activation adds, how the heartbeat turns "same machine twice" into an observable event, and how to act on it without turning a DR drill into a support fire.

Why the fingerprint is defeated, concretely

A node-lock fingerprint is a hash over signals that are stable for a given install. A typical derivation on Windows pulls a handful of identifiers, hashes each one so the raw serials never leave the machine, and combines them:

// Simplified: each signal is salted + hashed locally; only the digest is sent.
public static string ComputeFingerprint()
{
    var signals = new[]
    {
        ReadMachineGuid(),          // HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid
        ReadSystemVolumeSerial(),   // volume serial of the OS drive
        ReadPrimaryMacAddress(),    // first non-virtual NIC
        ReadBaseboardSerial()       // WMI Win32_BaseBoard.SerialNumber
    };

    using var sha = SHA256.Create();
    var parts = signals.Select(s =>
        Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes("kr:" + s))));

    return Convert.ToHexString(
        sha.ComputeHash(Encoding.UTF8.GetBytes(string.Join('|', parts))))[..32];
}
Enter fullscreen mode Exit fullscreen mode

Now clone the VM. The MachineGuid lives in the registry hive, which is on the disk image — it copies. The volume serial is a property of the file system, written into the image — it copies. The baseboard serial is whatever the hypervisor reports, and a template reports the same value to every instance stamped from it. Even the MAC copies unless the platform is configured to randomize it, and plenty of snapshot-restore workflows keep it. The clone runs ComputeFingerprint() and gets f3a9…, the exact value the original produced at activation.

This is not a weak fingerprint. It is a correct one, defeated by the fact that cloning copies the inputs. The only signals that would differ between clones are the ones that also differ between reboots — process start time, an ephemeral boot id, a freshly generated GUID — and folding those into the fingerprint would break the stability that makes node-locking usable. The fingerprint and the clone-detector want opposite properties from the same value, so they cannot be the same value.

Activation identity: what the server adds

The server-owned half of the binding is the activation. When a client activates, it sends its fingerprint; the server records an activation against the license and returns two things the fingerprint alone never gave you: an ActivationId that the server assigned, and an opaque InstanceSecret the client persists locally.

public sealed record ActivationResult(
    string ActivationId,     // server-assigned, unique per accepted activation
    string InstanceSecret,   // opaque; the client stores this and replays it
    SignedLicense License);  // the usual signed payload for offline enforcement

var activation = await client.ActivateAsync(licenseKey, Computefingerprint(), ct);
await _store.PersistAsync(activation);   // written to the client's protected store
Enter fullscreen mode Exit fullscreen mode

So far a clone still wins: the ActivationId and the InstanceSecret are both written to disk, so the clone inherits them along with the fingerprint. That is fine — we are not trying to stop the clone from starting. We are building toward the one thing a clone cannot copy: the identity of a running process.

That identity is the runtime instance id. It is generated once, in memory, at startup, and it is deliberately not persisted:

public sealed class RuntimeIdentity
{
    // New value every process launch. Never written to disk, so a clone of the
    // disk image does not carry it — each copy generates its own on first run.
    public string RuntimeInstanceId { get; } = Guid.NewGuid().ToString("N");

    public DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow;
}
Enter fullscreen mode Exit fullscreen mode

The original VM, already running, holds R-1a in memory. The clone boots and generates R-9f. Both carry activation A-7721 on disk. Both compute fingerprint f3a9…. The difference that matters now exists only at runtime, and it is exactly the difference the heartbeat carries to the server.

The heartbeat turns "same machine" into an observable event

Keyright clients already heartbeat to keep a lease alive. The clone-detection change is small: the heartbeat carries the runtime instance id, and the server records which runtime ids it has seen recently against each activation.

public sealed record HeartbeatRequest(
    string LicenseId,
    string ActivationId,
    string InstanceSecret,       // proves the caller holds the activation
    string RuntimeInstanceId,    // identifies this specific running process
    DateTimeOffset StartedAt);
Enter fullscreen mode Exit fullscreen mode

On the server, the heartbeat handler looks up the activation, verifies the instance secret, and then asks a new question: how many distinct runtime ids have beaten against this activation inside the live window? One is normal. Two or more, overlapping in time, is a duplicate.

public async Task<HeartbeatResult> HeartbeatAsync(HeartbeatRequest req, SeatPolicy policy, CancellationToken ct)
{
    await using var _ = await _store.LockActivationAsync(req.ActivationId, ct);

    var activation = await _store.FindActivationAsync(req.ActivationId, ct);
    if (activation is null || !activation.SecretMatches(req.InstanceSecret))
        return HeartbeatResult.ActivationLost();     // revoked, reaped, or forged secret

    var now = _clock.GetUtcNow();
    activation.RecordRuntimeBeat(req.RuntimeInstanceId, now);

    // Distinct runtime ids seen within the live window = concurrently running copies.
    var liveRuntimes = activation.LiveRuntimeIds(now, policy.Skew);

    if (liveRuntimes.Count > 1)
    {
        var decision = policy.OnDuplicate(activation, req.RuntimeInstanceId, liveRuntimes);
        if (decision == DuplicateAction.EvictCaller)
            return HeartbeatResult.ActivationLost();  // this caller is the loser; it must re-activate

        if (decision == DuplicateAction.FlagAndHold)
            activation.Flag(DuplicateReason.ConcurrentRuntimes, liveRuntimes);
        // FlagAndHold and KeepNewest both fall through and renew the winner below.
    }

    var renewed = _signer.Sign(activation.Lease with { IssuedAt = now, ExpiresAt = now + policy.Ttl });
    await _store.UpsertActivationAsync(activation, ct);
    return HeartbeatResult.Renewed(renewed);
}
Enter fullscreen mode Exit fullscreen mode

The crucial detail is LiveRuntimeIds: it counts runtime ids whose most recent beat is inside now - (Ttl + Skew). A runtime id that beat once and went silent ages out of the set, so a machine you shut down last week does not count against you. Two ids both beating now is the clone signature — the original never stopped, and the copy started. That overlap in time is the whole signal.

Migration is a hand-off; a clone is an overlap

This is where most naive clone-detectors create more pain than they prevent. A customer who legitimately moves a VM — decommissions the old host, restores onto a new one — produces a sequence that looks superficially like a clone: two machines, one activation, same fingerprint. The difference is timing. In a migration the old runtime id stops beating before the new one starts; the sets never overlap. In a clone both keep beating.

LiveRuntimeIds already encodes that distinction, because a runtime id that stopped beating ages out. So the policy can be honest about it:

public DuplicateAction OnDuplicate(
    Activation activation, string caller, IReadOnlyList<string> liveRuntimes)
{
    // A genuine migration never reaches here: the old runtime aged out before the
    // new one beat, so liveRuntimes.Count == 1. Reaching here means true overlap.

    // Newest-wins: the most recently started runtime keeps the activation, the
    // older one is evicted on its next heartbeat and must re-activate.
    var newest = activation.NewestRuntimeId(liveRuntimes);
    if (caller != newest)
        return DuplicateAction.EvictCaller;

    // The caller is the newest; keep it, but record the overlap for the dashboard.
    return activation.OverlapCount > HighWaterMark
        ? DuplicateAction.FlagAndHold     // repeated cloning: hold and surface loudly
        : DuplicateAction.KeepNewest;
}
Enter fullscreen mode Exit fullscreen mode

Newest-wins is the common default for interactive software: the person who just launched the app keeps working, and the abandoned copy quietly loses its activation. For server workloads you often invert it to oldest-wins, so a mis-fired autoscale does not evict the instance that is actually serving traffic. Keyright lets you pick per license, because the right answer depends on whether a human or a scheduler is doing the launching.

For the honest mover, the eviction is not the end — it is a prompt. When the evicted copy's next heartbeat returns ActivationLost, the client re-activates, which rebinds the activation to the new runtime id in one round trip:

var result = await client.HeartbeatAsync(request, ct);
if (!result.Renewed)
{
    // Lost the activation — either we were cloned and lost newest-wins, or the
    // activation was reaped. Re-activate: rebinds to THIS runtime id cleanly.
    var fresh = await client.ReactivateAsync(licenseKey, ComputeFingerprint(), ct);
    await _store.PersistAsync(fresh);
    _state.Replace(fresh.License);
}
Enter fullscreen mode Exit fullscreen mode

A customer who moved a VM sees, at worst, one reconnect. A script that stamps out ten clones from one image sees ten copies fighting over one activation, nine of them losing newest-wins on every heartbeat cycle and a FlagAndHold on the activation record the moment the overlap count crosses your high-water mark — which is the event you actually want on your dashboard.

What not to do

Do not kill the process on first detection. The first detection is also what a legitimate restore-from-backup looks like for the few seconds before the old instance ages out, and a hard kill turns a DR drill into a customer-facing outage. It also buys you nothing against a real attacker, who patches a hard exit in minutes; detection belongs on the server, where the attacker cannot reach it, not in a client-side Environment.Exit.

Do not try to make the fingerprint clone-proof. Every attribute stable enough to survive a reboot survives a clone, and every attribute that would differ between clones differs between reboots. The fingerprint's job is to bind a license to a machine; separating two copies of that machine is the activation's job, and the runtime instance id's.

And do not treat a single duplicate as proof of piracy. Cloning is how modern infrastructure works — images, snapshots, templates, DR. The signal you act on is not "a duplicate happened once"; it is "two copies are alive at the same time, repeatedly, more than any legitimate topology explains." That is a number you can set, surface, and defend — which is exactly what a node-lock that understands cloning gives you, and a fingerprint on its own never can.

Top comments (0)