DEV Community

Ai2th
Ai2th

Posted on Edited on

cryptship | Part 1 — Binding the Decryption Key to the License and the Machine

Most license checks look like this somewhere in the binary:

if !license.Valid() {
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Find that branch, flip one instruction, and the check is gone. cryptship moves the most important part of that check, which machine and which license, out of if statements and into the cryptography.


🔑 The key comes from the license

The content key that decrypts your app is never stored in the image. It is wrapped inside the license, and it can only be unwrapped with a key-encryption key (KEK) derived from the machine and the license's own claims:

KEK         = HKDF(machine_fingerprint, license_id, bound claims)
content_key = unwrap(license.wrapped_key, KEK)
plaintext   = aead_open(payload.enc, content_key)   # wrong key => AEAD tag fails
Enter fullscreen mode Exit fullscreen mode

Run on a machine the license is not bound to, or edit a bound claim (license and customer id, product, payload id, binding policy, issue or expiry date), and the unwrap produces a wrong key. AES-256-GCM's authentication tag then refuses to decrypt. Stretching the expiry date by editing the license does not work.


⚖️ What is still an ordinary check

Being precise matters here. Two checks are still normal code in the loader:

  • The signature check. Claims outside the KEK context (edition, entitlements, revocation_url) are protected only by the signature.
  • The expiry check. An unedited license past its date still unwraps on its machine if someone patches that check out or turns the clock back. If expiry must hold against a patched loader, use the online provider (Part 2).

And with binding none, the license itself carries everything needed to unwrap the key: treat that license like a secret.


📄 What a license looks like

Signed claims, canonically JSON-encoded, with a detached Ed25519 signature:

{
  "claims": {
    "v": 1,
    "license_id": "lic_ab12cd34",
    "customer_id": "acme-corp",
    "product": "widget-server",
    "edition": "enterprise",
    "payload_id": "f3a9…",            // binds the license to one build
    "entitlements": ["module.reporting"],
    "issued_at": 1762905600,
    "not_after": 1765497600,          // expiry drives offline revocation
    "binding": { "policy": "strict", "max": 1, "fingerprint_hashes": ["…"] },
    "wrapped_keys": ["base64…"],       // one per bound machine (pool), else one
    "revocation_url": "https://…",     // optional online check
    "signing_key_id": "v1"             // key rotation
  },
  "sig_alg": "ed25519",
  "sig": "base64…"
}
Enter fullscreen mode Exit fullscreen mode

The signature is over the exact stored claim bytes. The loader rejects unknown fields, so a license with newer fields needs a newer loader.


🖥️ Binding to machines

  • none: any machine; the key is unwrapped from a secret in the license
  • strict: one machine
  • pool: a set of machines, with one wrapped key per machine (that is how migrations work)

The machine fingerprint is derived from readable host identifiers such as /etc/machine-id and the DMI UUID. The hostname is deliberately excluded, because in a container it is the container ID and changes every run. Bind-mount /etc/machine-id:ro into the container for a stable identity.


Next, Part 2: the same loader for air-gapped, on-prem and cloud.

🔗 cryptship

Top comments (0)