Metadata-Version: 2.4
Name: pylicensify
Version: 1.0.5
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")

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 everything** | Ed25519 on validation, seats, heartbeats and update checks |
| **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 |

---

## 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")

try:
    with client.session("PY-XXXXXXXXXXXXXXXX") as sess:
        print("Seat acquired:", sess.seats)   # {'used': 1, 'max': 5}
        run_app()                             # released automatically on exit
except SeatUnavailable:
    print("All seats are in use — try again later.")
```

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;
the server-side lease expires on its own TTL regardless.

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.

---

## Errors

| Exception | Meaning |
|---|---|
| `InvalidLicense` | Server refused the key — invalid, expired, revoked or blocked |
| `SignatureError` | Signature, nonce or timestamp failed — forged or replayed |
| `NetworkError` | Couldn't reach the license server |
| `SeatUnavailable` | Floating license has no free seats |

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://license.pyobfuscate.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` (how old a signed
response may be before it's rejected as a replay, default 300s).

---

## 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.
