Metadata-Version: 2.4
Name: keywarden
Version: 1.5.2
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+.

Current version: **1.5.2**.

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

## Activation keys and grants

A Key-Warden key is **opaque** — `KW-XXXX-XXXX-XXXX-XXXX`. It carries no plan, no
seat count and no term. Those live on the licence record, so a renewal, an
upgrade, a seat top-up or a revocation lands at the customer's next check with
nothing for them to paste.

Every check returns a signed **grant** bound to that key, that machine and that
request. Verify it against the key **set** from your vendor console — a set, not
a single key, so a signing-key rotation never breaks installs that have not
updated yet:

```python
import keywarden

KEYS = [
    {"kid": "mitaa-k1", "pub": "BASE64_32_BYTE_KEY"},
    {"kid": "mitaa-k2", "pub": "BASE64_32_BYTE_KEY"},   # the incoming one
]

res = keywarden.validate(
    licence_key,
    apim_key=APIM, client_key=CLIENT, machine_id=machine_id,
    keys=KEYS,
    product="acme-maps",
    user_count=active_users,      # for banded plans
    env_type="production",        # or let KW_ENV_TYPE decide
)

# res["grantVerdict"]: "accept" | "deny" | "fallback"
# res["expiresAt"]   : the licence term (NOT res["claims"]["exp"])
```

Offline, the same check without a network:

```python
g = keywarden.verify_grant(
    cached_token,
    keys=KEYS,
    activation_key=licence_key,
    machine_id=machine_id,
    nonce=nonce,                  # only if you still hold the one you sent
)
# g == {"verdict": ..., "reason": ..., "claims": {...}}
if g["verdict"] == "accept":
    run_app()
elif g["verdict"] == "deny":
    lock(g["reason"])
else:                              # "fallback"
    keep_last_known_good()         # and re-check online
```

`offline_allowed` is **opt-in and omitted** when you have not enabled it in the
vendor console, so a cached grant returns `deny` / `offline_not_allowed` until
you do. That is the offline path only — a verdict that just came back live from
`validate()` is applied as-is.

### Three verdicts, and why `fallback` is not a denial

| Verdict | When | What you do |
|---|---|---|
| `accept` | good for this key and this machine | licence the product |
| `deny` | wrong key, wrong machine, forged, expired past grace | lock it |
| `fallback` | unknown `kid`, no keys baked in, unparseable | **keep your previous state** and re-check online |

`fallback` means the SDK could not judge the grant, not that the grant is bad.
Treating it as a denial turns a routine signing-key rotation into an outage. A
**known** kid whose signature fails is a different thing entirely — that is
forgery, and it denies.

### `expires_at` is the term; `exp` is the refresh window

The single most misread pair in the model.

- `expires_at` — when the **licence** ends. Gate on this.
- `exp` — when the **grant** goes stale and should be refreshed. It is
  `max(base TTL, grace + offline buffer)`, so an offline-enabled licence gets a
  grant that deliberately outlives its own grace window. Gating access on `exp`
  locks out paying customers.

`needs_refresh(claims)` and `in_grace(claims)` answer those two questions
directly. All three helpers take and return **unix seconds** (floats).

### What `validate()` now sends

Three headers you get for free, and should not strip:

- `X-Kw-Nonce` — a fresh nonce per call, echoed inside the signed grant. Without
  it a captured answer replays.
- `X-Kw-Env-Type` — `production` unless you say otherwise (or `KW_ENV_TYPE` says
  so). An undeclared staging box burns a **paid production seat**. Anything
  unrecognised reads as production — never the cheaper pool by accident.
- `X-Site-Url` — the site label, for the vendor console.

A grant that fails verification sets `grantVerdict` / `grantReason` and
`trustworthy: False`. It does **not** flip `valid` to `False` — a verification
fault is ours, not the customer's, and must never downgrade a paying licence.

## 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. |
| `verify_grant(token, *, keys, activation_key=None, machine_id=None, nonce=None, now=None)` | Offline grant check. Returns `{"verdict", "reason", "claims"?}`. |
| `expires_at(claims)` | The licence term as unix seconds — `expires_at`, never `exp`. `None` for perpetual. |
| `needs_refresh(claims, now=None)` | `True` once the grant's `exp` has passed and it should be re-fetched. |
| `in_grace(claims, now=None)` | `True` when the licence has lapsed but is still inside its offline grace window. |
| `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`.

## Verify the build yourself

```bash
python run_tests.py
#   == legacy surface ==     24 passed, 0 failed
#   == grant conformance ==  23 passed, 0 failed
#   == client contract ==    19 passed, 0 failed
#   ALL SUITES PASSED
```

`grant-vectors.json` is minted by the platform's **own** signer, not a lookalike,
and all four SDKs run the same vectors — so they cannot drift apart.

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