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) } Find that branch, flip one instruction, and the check is gone. cryptship moves the most important part of that c
Most license checks look like this somewhere in the binary:
if !license.Valid() {
os.Exit(1)
}
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
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
onlineprovider (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…"
}
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
- 🐙 GitHub: github.com/AI2TH/cryptship (GPLv3)
- 📦 Release: v0.1.0
- 📚 Docs: HOWTO · Architecture · Diagrams
- 🌐 Website: ai2th.github.io/cryptship.html
- 💬 Discussion: AI2TH Google Group
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.