Metadata-Version: 2.4
Name: pylicensify
Version: 1.2.0
Summary: Verify software license keys in Python with tamper-proof Ed25519-signed responses — device binding (HWID), floating licenses, offline activation.
Author: Licers
License: MIT
Project-URL: Homepage, https://licers.com
Project-URL: Documentation, https://licers.com/docs
Project-URL: Blog & guides, https://licers.com/blog
Project-URL: Report an issue, https://licers.com/support
Keywords: license,licensing,license-key,license-key-validation,software-licensing,activation,product-key,anti-piracy,copy-protection,drm,hwid,device-binding,floating-license,offline-activation,ed25519,code-signing,sdk
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Security
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Software Distribution
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.25
Requires-Dist: cryptography>=41.0

# pylicensify

**License key validation for Python that a cracked build can't fake.**

Verify software license keys at runtime with Ed25519-signed responses, bind them
to devices (HWID), sell concurrent seats, and activate offline — in a few lines.

[![PyPI](https://img.shields.io/pypi/v/pylicensify.svg)](https://pypi.org/project/pylicensify/)
[![Python](https://img.shields.io/pypi/pyversions/pylicensify.svg)](https://pypi.org/project/pylicensify/)
[![License](https://img.shields.io/pypi/l/pylicensify.svg)](https://opensource.org/licenses/MIT)

```bash
pip install pylicensify
```

```python
from pylicensify import LicenseClient

client = LicenseClient(public_key="YOUR_PUBLIC_KEY")

if not client.validate("PY-XXXXXXXXXXXXXXXX"):
    raise SystemExit("Not licensed")

# your app starts here
```

---

## Why not just ask your own server?

Because the usual approach breaks in about five minutes.

Most licensing code sends the key somewhere and trusts whatever comes back. So an
attacker doesn't attack your key format or your crypto — they point your app at a
server of their own that answers `{"valid": true}` to everything. One line in
`/etc/hosts`, and every copy is licensed forever.

**The check was never the hard part. Making the answer impossible to forge is.**

Every response from the license server is signed with an Ed25519 private key that
never leaves the server. Your app embeds only the *public* key and verifies the
signature locally:

```
your app  ──── key + random nonce ───▶  license server
          ◀─── result + signature ────  (signed with the private key)
              │
              └─ verified against the public key baked into your build
                 wrong signature → SignatureError, not "licensed"
```

A fake server can return a valid-looking JSON body, but it cannot produce a
signature that verifies. Redirect the app anywhere you like — it fails closed.

The nonce and timestamp are signed too, so a genuine "yes" captured off the wire
can't be replayed back later.

---

## Quick start

Get your **public key** from the [dashboard](https://licers.com) → *Integration*.
It's safe to embed in a distributed app; the private key never leaves the server.

```python
from pylicensify import LicenseClient

client = LicenseClient(
    public_key="YOUR_PUBLIC_KEY",
    product_id="YOUR_PRODUCT_ID",   # see "Pin your product" below
)

result = client.validate("PY-XXXXXXXXXXXXXXXX")

if not result:
    print("Not licensed:", result.error)
    raise SystemExit(1)

print("Licensed")
```

`validate()` generates a nonce, verifies the Ed25519 signature, and rejects stale
or replayed responses automatically. It returns a falsy `ValidationResult` when
the key is refused, and raises `SignatureError` on tampering or `NetworkError`
when the server is unreachable.

Prefer exceptions to truthiness checks? Pass `raise_on_invalid=True` and a refused
key raises `InvalidLicense` instead of returning a falsy result.

---

## What you get

| | |
|---|---|
| **Signed responses** | Ed25519 on validation, seats, heartbeats and update checks |
| **Product pinning** | A key for one product will not unlock another |
| **Device binding** | Lock a key to *n* machines by hardware id |
| **Entitlements** | One key unlocks different tiers — signed, so they can't be flipped |
| **Floating licenses** | Sell *n* concurrent seats instead of *n* installs |
| **Offline activation** | Signed `.lic` files that verify with no network at all |
| **Update checks** | Ship new builds only to customers with a live license |

---

## Pin your product

**If your account sells more than one product, set `product_id`.**

One Ed25519 keypair signs for your whole account, not per product. Without a pin,
`validate()` returns the signed `product_id` but never checks it — so a key for
your $10 utility verifies perfectly inside your $500 app. No tampering, no
patched binary, no fake server: just a customer using a cheap key in the wrong
place.

```python
client = LicenseClient(
    public_key="YOUR_PUBLIC_KEY",
    product_id="YOUR_PRODUCT_ID",   # from the dashboard, on the product page
)
```

A response for any other product now raises `ProductMismatch`. It applies to
`validate()`, `lease()`, `session()` and `check_update()`, and it takes a
per-call override where you need one:

```python
client.validate(key, expected_product_id="prod_other")
```

Offline files take the same argument:

```python
verify_offline_license(PUBLIC_KEY, "license.lic", expected_product_id="YOUR_PRODUCT_ID")
```

`ProductMismatch` subclasses `InvalidLicense`, so existing handlers keep working.
Leaving `product_id` unset preserves the old behaviour — nothing breaks on
upgrade — but a single-product account is the only case where that is safe.

---

## Entitlements (feature gating)

Attach flags to a key — `{"pro": true, "seats": 5}` — and they arrive inside the
signed payload, so a patched client can't turn them on:

```python
result = client.validate(key)

if result.feature("pro"):
    enable_pro_features()

seats = result.feature("seats", 1)   # second arg is the default
```

A key with no entitlements returns an empty `result.features` dict — set them per
key when you generate it.

> **Upgrade to 1.0.4 or later.** In 1.0.3 and earlier the client read the
> entitlements from an unsigned copy of the response, so they could be edited in
> transit while the signature still verified. 1.0.4 reads them from the signed
> payload. The license check itself was never affected — only entitlement values.

---

## Device binding (HWID)

Each key can be limited to a number of machines. The SDK sends a stable
per-installation device id automatically:

```python
from pylicensify import get_hwid

client.validate(key)                       # uses get_hwid() for you
client.validate(key, hwid=my_own_id())     # or supply your own
```

The default id is the machine's MAC address where one is readable, otherwise a
random id persisted to disk and reused on every run. That matters more than it
sounds: a naive `uuid.getnode()` changes between runs on Android/Pydroid, VMs and
containers, which would burn through a customer's device limit on a single
machine.

The persisted id lives in a per-user data dir — `%LOCALAPPDATA%\pylicensify` on
Windows, `~/.local/share/pylicensify` elsewhere. Override the location with the
`PYLICENSIFY_HWID_DIR` environment variable.

---

## Floating (concurrent) licenses

For teams: cap how many copies run *at the same time* rather than how many are
installed. The SDK checks out a seat, heartbeats in the background, and releases
it on exit — including when the process dies, because the lease simply expires.

```python
from pylicensify import LicenseClient, SeatUnavailable

client = LicenseClient(public_key="YOUR_PUBLIC_KEY", product_id="YOUR_PRODUCT_ID")

try:
    with client.session("PY-XXXXXXXXXXXXXXXX") as sess:
        print("Seat acquired:", sess.seats)   # {'used': 1, 'max': 5}
        while work_remaining():
            sess.ensure_active()              # raises SeatUnavailable if lost
            do_a_chunk_of_work()
except SeatUnavailable:
    print("All seats are in use — try again later.")
```

**The seat can be lost while your code runs, and the SDK will not stop it for
you** — killing a customer's process mid-work is worse than letting it finish.
Entering the context manager is a checkout, not a guard. Check `sess.active`,
call `sess.ensure_active()` where stopping is safe, or pass a callback:

```python
with client.session(key, on_lost=lambda why: log.warning("seat lost: %s", why)):
    ...
```

Lease and heartbeat responses are Ed25519-signed and verified exactly like
`validate()` — a fake server can't hand out seats it has no right to, and a
forged heartbeat counts as "seat lost" rather than "still held".

A genuine network blip still counts as held, so a brief outage won't kill a
running app — but only until the last *signed* expiry runs out. An unreachable
server never extends a seat; if it stays unreachable past that expiry the
session gives up, because by then the server has handed the same seat to
somebody else. Pulling the network cable is not a way to get an unlimited
offline seat.

The lower-level `lease()`, `heartbeat()` and `release()` methods are available if
you need to drive the lifecycle yourself.

> **Signed floating licenses need server 2026-08-27 or later.** Against an older
> server, `lease()` raises `SignatureError` because the response carries no
> signature to check. Upgrade the server, or pin `pylicensify<1.0.4`.

---

## Offline activation (air-gapped)

Issue a signed `.lic` file, ship it with the app, and verify it locally with
**zero network calls** — for air-gapped machines, regulated environments, or
customers whose boxes are deliberately kept off the internet:

```python
from pylicensify import verify_offline_license

data = verify_offline_license("YOUR_PUBLIC_KEY", "license.lic")
print("Valid for:", data.get("customer"), data.get("features"))
```

Verifies the signature, expiry, device binding and OS lock. It raises
`InvalidLicense` if the file has expired or is bound to another machine, and
`SignatureError` if it has been edited.

Offline files trade revocation for availability — the client never calls home, so
you can't revoke one before it expires. Keep their lifetimes short.

---

## Update checks

Serve new builds only to customers with a live license:

```python
info = client.check_update("PY-XXXXXXXXXXXXXXXX", current_version="1.0.0")

if info["update_available"]:
    print("New version:", info["version"], info["notes"])
    # info["download_url"] only works while the key is active and unexpired
```

The answer is signed and verified — **including "no update available"**, so a
server in the middle can't quietly suppress a security release by claiming
there's nothing to install.

**What the signature covers:** `product_id`, `channel`, `update_available`,
`version`, `filename` and `size`. It does *not* cover `download_url` or `notes`,
and no hash of the artifact is signed — so a verified response tells you a new
version exists, but does not authenticate the bytes you then fetch. Check the
URL is HTTPS on a host you expect, and verify the download against a digest you
ship out of band before running it. Binding the artifact hash into the signature
is a protocol change that needs the server side too; it is planned, and this
note will go when it lands.

---

## Errors

| Exception | Meaning |
|---|---|
| `InvalidLicense` | Server refused the key — invalid, expired, revoked or blocked |
| `ProductMismatch` | Correctly signed, but for a different product (subclass of `InvalidLicense`) |
| `SignatureError` | Signature, nonce or timestamp failed — forged or replayed |
| `ProtocolError` | Reply was not a usable response body — an intercepting proxy, say |
| `NetworkError` | Couldn't reach the license server |
| `SeatUnavailable` | Floating license has no free seats, or a held seat was lost |

All inherit from `LicenseError`, so `except LicenseError:` catches everything.

---

## Requirements

Python 3.8+, plus `requests` and `cryptography` (installed automatically).

You'll need an account for the server side — [licers.com](https://licers.com) is
free: unlimited keys, devices, validations, offline files and floating licenses,
no card required.

---

## Self-hosting and custom domains

By default the SDK calls the managed API at `https://licers.com`.
That host is deliberately permanent — shipped apps depend on it — while your
dashboard and account live at [licers.com](https://licers.com). Point the client
somewhere else for a self-hosted deployment:

```python
client = LicenseClient(public_key="...", api_url="https://license.yourdomain.com")
```

Other options: `timeout` (seconds, default 5) and `max_skew` (off by default).

Replay protection comes from the nonce: every call sends a fresh random value
that the server must sign, so a captured response can never satisfy a later
call. `max_skew` additionally rejects responses whose signed timestamp is more
than that many seconds from the **local** clock. Before 1.1.0 it defaulted to
300s, which refused every license on machines with a wrong clock — dual-boot
PCs, restored VMs, dead clock batteries — and reported it as a forgery. Set it
only if you control the clocks your app runs on.

---

## Hardening

Signed validation stops a forged *answer*. It doesn't stop someone deleting the
*question* — patching the `if not client.validate(...)` line out of your bytecode.
Obfuscate the client so that's meaningfully harder, for example with
[PyObfuscate](https://pyobfuscate.com).

Layered, that's: signature verification (can't fake a yes) + device binding
(can't share one key everywhere) + obfuscation (can't trivially remove the check).

---

## Links

- **Documentation** — <https://licers.com/docs>
- **Guides** — <https://licers.com/blog>
- **Support** — <https://licers.com/support>

MIT licensed.
