Metadata-Version: 2.4
Name: keywarden
Version: 1.3.0
Summary: Official Python client for Key-Warden - validate software licences online (seat- and revocation-aware) or verify signed tokens offline against your embedded public key.
Author: Key-Warden
License: MIT
Project-URL: Homepage, https://key-warden.com
Project-URL: Repository, https://github.com/myitandapps/key-warden
Project-URL: Documentation, https://key-warden.com/docs
Project-URL: Issues, https://key-warden.com/contact
Keywords: key-warden,keywarden,licence,license,licensing,activation,software-licensing,ed25519,offline-verification
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Security :: Cryptography
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=3.4
Dynamic: license-file

# keywarden

The official Python client for [Key-Warden](https://key-warden.com). Validate a
software licence online — seat-aware, revocation-aware — or verify a signed token
offline against your embedded public key, with no network round-trip.

One dependency: [`cryptography`](https://pypi.org/project/cryptography/) (for
Ed25519). Networking is stdlib `urllib`. Python 3.8+.

```bash
pip install keywarden
```

## Validate online

The authoritative check. Ask the platform whether a licence is good *right now*.

```python
import os, socket
import keywarden as kw

res = kw.validate(
    customer_licence_key,
    apim_key=os.environ["KW_APIM_KEY"],      # your APIM subscription key
    client_key=os.environ["KW_CLIENT_KEY"],  # your validation key
    machine_id=kw.machine_id_from(socket.gethostname(), user_id),  # stable, hashed your side
)

if not res["valid"]:
    raise SystemExit(f"licence not valid: {res.get('reason')}")
# res["token"] is a freshly signed proof — cache it for the offline path below.
```

A `valid == False` (e.g. `revoked`, `expired`, `seat_limit_exceeded`) is **data**,
not an error. A wrong `client_key` raises a `KeyWardenError` with
`code == "unauthorized_client"` — that's *your* auth failing, and your customer
should never see it as a licence problem.

## Verify offline

No connection? Verify a token you already hold against your **public** key —
the 32-byte raw key from your vendor console. Pure, no network.

```python
check = kw.verify_token(cached_token, os.environ["KW_PUBLIC_KEY"])
if not check["valid"]:
    lock_features(check["reason"])  # "bad_signature" | "expired" | ...
```

The token is `header.body.signature` (compact JWT style) and the Ed25519
signature covers the exact bytes `header.body`. This client verifies over those
bytes — it never decodes-then-reverifies, which is the one mistake that silently
breaks offline checks. Expiry is honoured within the offline grace window you set
at mint time.

## Online, with an offline fallback

The pattern most desktop apps want: online is authoritative; if the network is
down, keep working within grace.

```python
res = kw.validate_or_verify(
    customer_licence_key,
    apim_key=apim_key, client_key=client_key, machine_id=machine_id,
    cached_token=last_good_token,            # from a previous validate()
    public_key=os.environ["KW_PUBLIC_KEY"],
)
# res["source"] == "online" | "offline"
```

A rejected `client_key` (401) is never masked by the offline path — only a genuine
reachability failure falls back.

## Free trials

A trial licence is an ordinary Key-Warden key — validate it exactly like any
other. It just carries two extra claims: `trial: True` and an `exp` (unix
seconds). Once the trial ends, `verify_token()`/`validate()` refuse it as
`expired` on their own. The trial helpers are for **display** — showing
"N days left" and switching to an expired state:

```python
res = kw.verify_token(cached_token, os.environ["KW_PUBLIC_KEY"])

if res["valid"]:
    t = kw.trial_info(res)            # {"is_trial", "expired", "expires_at", "seconds_remaining", "days_remaining"}
    if t["is_trial"]:
        show_banner(f"Trial — {t['days_remaining']} day(s) left")
    run_app()
elif res.get("reason") == "expired":
    show_paywall("Your trial has ended. Enter a licence key to continue.")
```

`trial_info()` accepts a `verify_token()`/`validate()` result or a raw claims
dict. `is_trial(x)` and `days_remaining(x)` are shortcuts. `days_remaining` is
rounded up (the last partial day still reads "1 day left") and is `0` once
expired, `None` for a key with no `exp`. These helpers never grant access —
always gate on `verify_token()`/`validate()` first. Trial keys are node-locked
to one device, so pass the same `machine_id` you use for `validate()`.

## API

| Function | Purpose |
|---|---|
| `validate(key, *, apim_key, client_key, ...)` | Online check. Returns `{"valid", "reason"?, "activeSeats"?, "token"?}`. |
| `verify_token(token, raw_pub_b64, *, now=None)` | Offline check. Returns `{"valid", "reason"?, "claims"?}`. |
| `validate_or_verify(key, *, cached_token, public_key, ...)` | Online, falling back to a cached token when unreachable. |
| `machine_id_from(*parts)` | A stable SHA-256 machine id; raw parts never leave the machine. |
| `trial_info(x, *, now=None)` | Trial facts for display: `{"is_trial", "expired", "expires_at", "seconds_remaining", "days_remaining"}`. |
| `is_trial(x)` | `True` when the licence carries `trial: True`. |
| `days_remaining(x, *, now=None)` | Whole days left (rounded up); `0` once expired; `None` if no `exp`. |

Any real failure (bad credentials, unreachable gateway, server error) raises
`KeyWardenError`, which carries `.code` and `.status`.

## Security notes

- Your **private** signing key never leaves Key-Warden's Key Vault. You embed
  only the 32-byte public half.
- `machine_id` is hashed by the platform, but send an opaque, stable id — not a
  raw MAC address or a hostname you wouldn't want logged. `machine_id_from()`
  hashes on your side too.
- Two independent credentials gate every online call: the APIM subscription key
  gets you to the gateway, the validation key authenticates you as the vendor. A
  leaked validation key can be rotated without reissuing a single customer
  licence.

## Code protection (seal / unlock / unseal)

Lock part of your product so it only runs for a valid, activated licence. Get
your **content key** (base64) from the vendor console → **Protect your code**.

```python
import keywarden as kw

# Build time — seal a file once:
blob = kw.seal(open("secret_module.py", "rb").read(), MY_CONTENT_KEY_B64)
open("secret_module.sealed", "w").write(blob)

# Runtime — the key rides in the validate token as `ck`, machine-bound:
res = kw.validate(licence, apim_key=APIM, client_key=CK, machine_id=mid)
key = kw.unlock_from_token(res["token"], mid)     # bytes: content key
code = kw.unseal(sealed_blob, key)                # your decrypted file

# Or a live check every time (real-time revocation):
key = kw.unseal_online(licence, apim_key=APIM, machine_id=mid)
```

All AES-256-GCM (via the `cryptography` package). A revoked licence stops
getting the key. Unlock needs the SAME `machine_id` you validate with.

## Licence

MIT.
