What it is
zpwr-license is self-hosted, vendor-agnostic licensing for
paid native apps and plugins. Ed25519-signed license keys are verified
offline inside the binary; an issuer CLI mints and
manages them; an optional activation server node-locks keys to machines
and enforces seats. No third-party licensing SaaS, no mandatory
phone-home, and it runs at $0 in pure-offline mode.
The positioning is offline-first, not "biggest feature list." The SaaS incumbents are online-first, so the wedge here is a fully self-hosted, embeddable, zero-runtime-dependency licenser: arbitrary signed claims/metadata, key rotation, per-product / per-major-version keys, and both Rust and C/C++ embedding. The honest limits below are part of the design, not hidden by it.
- Version
0.1.0· MIT · workspace of three Rust crates. - Crypto: Ed25519 (
ed25519-dalek), SHA-256 fingerprints, base64url envelopes. - Offline core has no async runtime and no network; the online layer is opt-in behind a feature flag.
Threat model (honest)
| Threat | Stopped? | By what |
|---|---|---|
| Keygen | Yes, cryptographically | Ed25519 signatures. The binary embeds only the public key; forging a token needs the private key, which never ships. No symmetric secret to extract, so no valid generator can exist. |
| Key sharing | Yes, even offline | A license can be node-locked to a machine fingerprint at issue time (no server), or the activation server can bind + seat-limit it online. |
| Binary crack | No — not on client-side native code | Whoever controls the binary can NOP any check. Defeated only by server-gated value (content not in the binary) plus OS code-signing / notarization. The anti-tamper layer raises cost; it is not a guarantee. |
Components
| Crate | Artifacts | Role |
|---|---|---|
| license-core | rlib + cdylib + staticlib | Ed25519 sign/verify of typed tokens, key codec, node-lock, anti-tamper, optional online pull client. Usable from Rust and from C/C++ via include/zpwr_license.h / .hpp. Offline, no async, unit-tested. |
| license-cli | zpwr-license | Issuer CLI — keygen, issue, verify, revoke, manifest, blacklist/unblock, anti-tamper helpers. Holds the private key; runs offline. |
| license-server | zpwr-license-server | Activation — node-lock, seat enforcement, signed entitlements, CRL. Synchronous tiny_http, no tokio. |
Token model
A token is <typ>.<base64url(payload)>.<base64url(signature)>;
the signature covers "<typ>.<payload>", so the
type tag is part of the signed bytes and a license can
never be replayed where an entitlement is expected. There
are five signed token types:
| typ | Issued by | Payload |
|---|---|---|
| license | issuer (offline) | id, product, major, customer, issued, optional expires/features/seats/fingerprint, arbitrary metadata. Verified inside the product binary. |
| entitlement | server | license_id, product, major, machine fingerprint, features, issued, expires. Machine-bound and short-lived (default 30 days); the client caches it, verifies offline each launch, and honors a grace window past expiry. |
| crl | issuer | issued, revoked[] — a signed revocation list publishable as a static file. |
| unblock | issuer | license_id, issued — the vendor's authorization to clear a tamper blacklist. Only a vendor-signed unblock can lift a blacklist. |
| manifest | issuer | issued, optional min_version, revoked[], disabled_features[], message — the $0 remote-control channel. |
Two-key design: the issuer key signs licenses and stays offline; the server holds a separate entitlement key plus the license public key. A server breach leaks only the entitlement key — which mints short-lived, machine-bound entitlements, never licenses.
Mint a license (offline, no server)
cargo build --release
./target/release/zpwr-license keygen --out issuer
./target/release/zpwr-license issue \
--product audio-haxor --major 2 \
--customer buyer@example.com \
--features pro,vst3 --seats 3 \
--key issuer.key
./target/release/zpwr-license verify --token "<token>" --pubkey issuer.pub
That alone kills keygens and works air-gapped. The activation server is opt-in.
Issuer CLI reference
zpwr-license <command>. The signing (private) key never
leaves the machine the CLI runs on. Every command runs offline; the
fetch-* commands pull a signed file over HTTP and verify
the signature before trusting a byte.
| Command | Key flags | Does |
|---|---|---|
| keygen | --out (default zpwr-license) | Generate an Ed25519 keypair → <name>.key (private, 0600 on Unix) + <name>.pub (embed in products). |
| pubkey | --key | Derive and print the public key from a private key file. |
| issue | --product --major --customer [--expires-days --features --seats --id --fingerprint --meta KEY=VALUE] --key | Mint a signed license token to stdout. --meta is repeatable for arbitrary signed claims; --fingerprint node-locks the license offline. |
| verify | --token --pubkey [--fingerprint] | Verify a license, print its JSON, exit non-zero on failure. With --fingerprint, also enforce the node-lock against that raw machine id. |
| machine-id | — | Print this machine's raw hardware id for offline node-locking. |
| sign-binary | --file --key | Sign a release binary's SHA-256 so the product can self-verify integrity (antitamper::verify_self). Prints the base64url signature to embed. |
| tamper-scan | — | Run the local anti-tamper scan and print the JSON report. |
| blacklist | --id | Permanently blacklist a license id on this machine (tamper response). |
| blacklisted | --id | Check whether a license id is blacklisted on this machine; exits 0 if blacklisted, 1 if clean. |
| unblock | --id --key | Mint a signed unblock token (issuer side) to email a customer for false-positive recovery. |
| clear | --token --pubkey | Apply a signed unblock token on this machine: verify it and clear that id from the local blacklist. |
| fetch-crl | --url --pubkey | Pull + verify a signed CRL from a free static host and print the revoked ids. |
| manifest | [--min-version --revoke --disable --message --out] --key | Build + sign the $0 remote-control manifest (default out manifest.sig). |
| fetch-manifest | --url --pubkey [--current-version] | Pull + verify a signed manifest; with --current-version, report whether an upgrade is forced. |
| revoke | [--store --add --out] --key | Add ids to the revocation list and emit a freshly signed CRL token. Plaintext --store (default crl.json) is the editable source of truth; signed --out (default crl.sig) is what you publish. |
Offline node-locking
Bind a key to a machine with no server — the classic machine-id → keyfile activation:
- The customer runs
zpwr-license machine-id(or your app shows it) and sends you the value. - You mint a license locked to it:
issue --fingerprint <machine-id>. - The binary verifies the signature and that the bound fingerprint matches the current machine (
verify_license_locked/zpwr_license_verify_license_locked).
The machine id is the real hardware identity — macOS
IOPlatformUUID (direct IOKit FFI, no ioreg
subprocess to shadow), Windows MachineGuid, Linux
/etc/machine-id — not a mutable hostname. It is stored
hashed (salted SHA-256, salt = product name), so the
customer's raw id never travels in cleartext or is recoverable from the
license.
Tamper detection (cost layer)
A cost layer, embedded via license-core::antitamper. Every
check runs inside the binary, so a determined attacker can patch any
single one out. Use many, from several code paths, and fail late and
subtly. This raises the cost and filters casual piracy; it is not a
guarantee. The hard defense remains server-gated value.
| Check | Detects |
|---|---|
| debugger_present() | An attached debugger (Linux TracerPid, macOS P_TRACED via sysctl). |
| injection_detected() | LD_PRELOAD / LD_AUDIT / DYLD_INSERT_LIBRARIES library injection. |
| env_suspicious() | Loader/allocator debug env (LD_DEBUG, DYLD_PRINT_*, MallocStackLogging, …). |
| timing_anomaly() | A tight loop running far too slow — single-stepping. |
| verify_self(sig, vk) | On-disk patching of the shipped binary (Ed25519 over its hash; sign with sign-binary). |
| self_test(vk, token) | Public-key substitution — a baked-in known-good token stops verifying if the embedded key was swapped. |
| clock_rolled_back(last, now) | A clock rollback used to revive an expired entitlement. |
| arm_anti_debug() | Actively blocks debugger attach (macOS PT_DENY_ATTACH, Linux PTRACE_TRACEME). Call once, early. |
| binding_seed(token) | Woven check — a value only a valid license reproduces. Weave it into real computation so NOP-ing the gate isn't enough. |
| guarded_seed(token) | Tamper-coupled woven check — returns a wrong value under debugger/injection, so analysis silently corrupts results instead of raising a catchable failure. |
| should_recheck(last, now, interval) | Whether a periodic re-verification is due, so there is no single launch-time gate to neutralize. |
| tamper_tripwire(id) | Detect-and-brick — on any tamper signal, blacklists the license id permanently (hashed, to several durable locations) and returns true. |
| scan() | Runs the environmental checks and returns a TamperReport. |
Obfuscation: the telltale string literals are
XOR-encoded and decoded at runtime, so strings on the
stripped release binary shows nothing that flags a license check. The
C-ABI exports are named innocuously — zpwr_rt_state
(debugger), zpwr_rt_metrics (scan),
zpwr_rt_check (key-substitution self-test) — so
nm doesn't advertise the anti-tamper layer either. This is
source-level obfuscation: it defeats casual static triage, not a
disassembler that reads the syscalls.
False-positive risk is real: a legitimate debugger
session can brick the license via tamper_tripwire, so
reserve it for high-value products and ship a support unblock path
(unblock → clear).
Activation server
zpwr-license-server is the opt-in stateful layer. It holds
the license public key and a separate entitlement
signing key — never the license private key — so a full
server compromise leaks only short-lived, machine-bound entitlements.
./target/release/zpwr-license keygen --out entitlement
ZPWR_LICENSE_LICENSE_PUBKEY="$(cat issuer.pub)" \
ZPWR_LICENSE_ENTITLEMENT_KEY="$(cat entitlement.key)" \
./target/release/zpwr-license-server # 127.0.0.1:8787
curl -X POST localhost:8787/activate \
-d '{"token":"<license token>","fingerprint":"<machine hash>"}'
# -> { "entitlement": "<entitlement token>", "expires": <unix> }
| Endpoint | Does |
|---|---|
| GET /health | {"status":"ok"} |
| GET /crl | Serve the configured CRL (issued + revoked); 404 if none configured. |
| POST /activate | Verify the license, enforce expiry + revocation + the seat count, bind the fingerprint, return a signed entitlement. |
| POST /validate | Verify an entitlement and report revocation status. |
| Env var | Default | Meaning |
|---|---|---|
| ZPWR_LICENSE_ADDR | 127.0.0.1:8787 | Listen address. |
| ZPWR_LICENSE_LICENSE_PUBKEY | required | base64url license public key. |
| ZPWR_LICENSE_ENTITLEMENT_KEY | required | base64url entitlement signing key. |
| ZPWR_LICENSE_SEATS_FILE | seats.json | Seat-store path. |
| ZPWR_LICENSE_CRL | — | Path to a signed CRL token (verified with the license pubkey). |
| ZPWR_LICENSE_TTL_DAYS | 30 | Entitlement lifetime. |
Integrating a product
The core builds as an rlib (Rust), a
staticlib (libzpwr_license.a) and a
cdylib (libzpwr_license.{dylib,so}), so it
drops into both stacks.
Rust apps (Tauri backends)
use zpwr_license::{decode_verifying_key, verify_license_locked, machine_id, antitamper};
const PUBKEY: &str = "…base64url public key…"; // baked in, not secret
let vk = decode_verifying_key(PUBKEY).unwrap();
match verify_license_locked(&token, &vk, &machine_id()) {
Ok(lic) => unlock(&lic.features),
Err(_) => run_free_tier(),
}
if antitamper::scan().any() { /* fail late + subtly */ }
Full example:
crates/license-core/examples/app_gate.rs.
Use verify_license_any / verify_license_any_locked
with a keyring of public keys to support key rotation —
the first key that validates wins, so old licenses keep verifying after
you issue under a new key.
C++ plugins (JUCE)
#include "zpwr_license.hpp"
static const char* kPubKey = "…base64url public key…"; // baked in, not secret
if (!zpwr::license::ok(token, kPubKey, zpwr::license::machine_id()))
runFreeTier(); // node-locked, offline
if (zpwr::license::tamper::debugger()) { /* fail late */ }
target_include_directories(MyPlugin PRIVATE ${ZPWR}/include)
target_link_libraries(MyPlugin PRIVATE ${ZPWR}/target/release/libzpwr_license.a)
# macOS: also -framework IOKit -framework CoreFoundation -framework Security
# Linux: pthread dl m
The raw C ABI (include/zpwr_license.h) exposes
zpwr_license_ok,
zpwr_license_verify_license[_locked],
zpwr_license_machine_id,
zpwr_license_verify_manifest,
zpwr_license_unblock,
zpwr_license_free_cstring, and the innocuously named
anti-tamper calls zpwr_rt_state / zpwr_rt_metrics
/ zpwr_rt_check / zpwr_rt_arm /
zpwr_rt_seed / zpwr_rt_gseed /
zpwr_rt_tripwire / zpwr_rt_blacklisted.
Online layer at $0 (no paid hosting)
Global revocation/blacklist without paying for a server. The online
layer (Cargo feature online, blocking ureq)
is pull-based from a free static host — the file is Ed25519-signed, so
the host is untrusted.
zpwr-license revoke --add lic-bad --key issuer.key --out crl.sig
# commit crl.sig to a public repo / gist -> served free by GitHub raw,
# jsDelivr, Cloudflare Pages, Netlify, an S3 bucket, …
use zpwr_license::online::is_revoked_online;
// fail-open (keep working) on fetch error: pass `false`
if is_revoked_online(CRL_URL, &lic.id, &vk, false) { run_free_tier(); }
Signed manifest — $0 remote control. One
signed file is a vendor control channel. min_version is a
force-upgrade kill-switch (Manifest::requires_upgrade): bump
it and every older copy — including cracked releases — must refuse to
run. Plus revoked, disabled_features, and a
broadcast message, all in one pull.
Anti-replay: clients keep the newest issued
and reject a stale manifest (fetch_manifest_fresh), so a
hostile host can't replay a pre-revocation copy.
Only live seat-counting genuinely needs a stateful
endpoint — and that ships too, on the free tier:
serverless/cloudflare/
is a Cloudflare Workers activation function (100k req/day free + free KV),
wire-compatible with the Rust core (WebCrypto Ed25519). It serves
/health, /activate, /validate,
/checkout, /heartbeat, /checkin,
/blacklist, and /unblock — the
/checkout / /heartbeat / /checkin
trio implements floating/concurrent licensing via TTL leases (a crashed
client's seat frees when its lease expires).
Build & test
The repo is private and does not run GitHub Actions; CI is local only.
scripts/ci.sh is the source of truth — run it before every
push.
make ci # fmt + clippy(-D warnings) + check + test + doc + build
make build # cargo build
make test # cargo test --workspace
make clippy # cargo clippy --all-targets -- -D warnings
Status & honest limits
Generic and shipping today: arbitrary signed claims (metadata), key rotation via keyring, multi-product / multi-key / per-major-version, offline node-locking to the real hardware id, CRL, Rust + C/C++ embedding, obfuscated anti-tamper with active anti-debug and woven-check seeds, free-tier floating-license activation.
Roadmap (not yet built): a trial model, more SDKs over the C ABI (C#/.NET, Python, Node, Swift), token-format versioning + migration, and a semver/release/support commitment before another company depends on it.
Honest limits: true floating/concurrent licensing needs the online server — offline can't count across machines. Client-side checks are patchable; server-gated value is the only hard stop. Offline-first, not uncrackable.
Catalog
Licenses the paid app-store catalog — per product, per major version:
See the engineering report for the cryptographic design rationale.