Metadata-Version: 2.4
Name: keyguard-sdk
Version: 0.1.0
Summary: Official Python SDK for KeyGuard license verification (verify, hardware fingerprint, offline license files)
Author: KeyGuard
License-Expression: MIT
Project-URL: Homepage, https://keyguard-dz.com
Keywords: licensing,license-verification,keyguard
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28
Provides-Extra: offline
Requires-Dist: cryptography>=41.0; extra == "offline"
Dynamic: license-file

# keyguard-sdk (Python)

The official Python SDK for [KeyGuard](https://keyguard-dz.com) — a license
verification service for people who sell software and don't want licensing
to become their second job. MIT licensed, Python 3.8+, one dependency
(`requests`).

## Quickstart

**1. Install**

```bash
pip install keyguard-sdk             # verify + hardware fingerprint
pip install "keyguard-sdk[offline]"  # adds offline license-file verification (ed25519)
```

**2. Verify a license**

```python
from keyguard import KeyGuardClient
from keyguard.fingerprint import get_hardware_id

client = KeyGuardClient(api_key="live_...")   # your product API key
hardware_id = get_hardware_id(product_key="live_...")

result = client.verify("PERS-ONA0-VALID-0001", hardware_id=hardware_id)

if result.valid:
    print("Licensed until", result.expires_at)
```

That's a real request: `POST {base_url}/api/v1/licenses/verify` with
`{"api_key", "license_key", "hardware_id"}` in the JSON body, and a flat
`{"valid", "environment", "expires_at", ...}` back. Two things trip people
up on day one:

- **`hardware_id` is required** for hardware-bound licenses. Omit it and
  you get `invalid_payload`, not a helpful error page.
- The API key goes in the **request body**, not a header. That is the API
  contract.

**3. React to the outcome**

```python
import time
from keyguard import VerifyStatus

if result.status is VerifyStatus.EXPIRED:
    # vendor_info carries the seller's contact details + renewal link
    show_renewal_screen(result.vendor_info["renewal_url"])
elif result.status is VerifyStatus.MACHINE_LIMIT:
    print(f"All {result.max_machines} machine slots are in use")
elif result.status is VerifyStatus.RATE_LIMITED:
    time.sleep(result.retry_after or 60)
```

A runnable end-to-end demo lives in `examples/demo.py`:

```bash
KEYGUARD_API_KEY=live_... KEYGUARD_LICENSE_KEY=KG-... python examples/demo.py
```

## Handling things that go wrong

API-level outcomes never raise — they come back as a `VerifyResult` with
`valid=False` and a `status` you can switch on. Transport problems (DNS,
TLS, timeouts, a proxy returning an HTML error page) raise
`keyguard.KeyGuardError`.

```python
import time
from keyguard import KeyGuardClient, KeyGuardError, VerifyStatus

client = KeyGuardClient(api_key="live_...")

try:
    result = client.verify("KG-XXXX-XXXX", hardware_id=hardware_id)
except KeyGuardError as exc:
    # Network is down, DNS is broken, server returned junk.
    # Your call whether this fails open or closed — pick deliberately.
    print(f"Could not reach the licensing server: {exc}")
else:
    if result.status is VerifyStatus.INVALID_KEY:
        print("That key doesn't belong to this product.")
    elif result.status is VerifyStatus.RATE_LIMITED:
        # The edge limiter is a composite of per-IP and per-license buckets.
        # Retry-After is only present when the limiter knows the cooldown —
        # fall back to your own backoff when it isn't.
        time.sleep(result.retry_after or 60)
    elif result.valid:
        run_app()
```

The full status taxonomy:

| Status | Meaning | Extra attributes |
|---|---|---|
| `VALID` | License active; machine activated implicitly on first verify | `environment`, `expires_at`, `custom_fields` |
| `EXPIRED` | License past expiration | `expired_at`, `vendor_info` (name, phone, email, renewal_url, whatsapp_url, telegram_url) |
| `MACHINE_LIMIT` / `DOMAIN_LIMIT` | Activation slots exhausted | `max_machines` |
| `RATE_LIMITED` | HTTP 429 from the edge limiter | `retry_after` (seconds, or `None`) |
| `BLOCKED` | License blocked by the vendor | — |
| `INVALID_KEY` | Key not found for this product | — |
| `INVALID_API_KEY` | Wrong or archived API key | — |
| `INVALID_PAYLOAD` | Missing required fields (did you forget `hardware_id`?) | — |
| `DOMAIN_REQUIRED` / `DOMAIN_NOT_SUPPORTED` / `INVALID_DOMAIN` | Web-product binding mismatch | — |
| `UNAVAILABLE` | Vendor account deleted | — |
| `UNKNOWN` | Unrecognized `reason`; the raw body is in `result.raw` | — |

### Web products

For products with `platform_type = "web"`, pass `domain` instead of
`hardware_id`:

```python
client.verify("KG-XXXX-XXXX", domain="mysite.com")
```

### Activate / deactivate

There are no standalone activate/deactivate endpoints — this is deliberate,
not a gap in the SDK. The first successful `verify()` for a new
`hardware_id` consumes a machine slot; slots are freed from the vendor
dashboard. `client.activate()` and `client.deactivate()` exist only to raise
`NotSupportedError` with that explanation, so you don't have to learn it
from a 404.

## Hardware fingerprints

`get_hardware_id(product_key)` reads the most stable OS identifier it can
find — Windows `MachineGuid` (with WMI UUID and disk serial as fallbacks),
Linux `/etc/machine-id` (dbus machine-id, dmidecode UUID), macOS
`IOPlatformUUID` — salts it with your product key, and returns a SHA-256 hex
digest. The raw identifier never leaves the device, and the salt means the
same machine produces a different fingerprint for every vendor, so
fingerprints can't be correlated across products.

We intentionally prefer *stable* identifiers over *unique* ones: a
fingerprint that changes after an OS update quietly eats a customer's
activation slot, which is a worse failure than two VMs colliding.

## Offline license files

For machines that will never touch the internet, vendors can issue signed
offline license files (envelope v2). Verification is fully local with the
product's **public** key:

```python
from keyguard.offline import verify_offline_file

payload = verify_offline_file("license.kglic", public_key_b64="<product public key>")
print(payload["license_key"], payload["expiration_date"])
```

Legacy HMAC envelopes are rejected with `LegacyEnvelopeError` — that format
was sunset because the symmetric secret had to ship inside the app to be
checked offline, which is to say it checked nothing. HMAC files are still
validated server-side.

## When NOT to use this

Save yourself the integration work if any of these describe you:

- **You need floating/concurrent-seat licenses.** KeyGuard counts activated
  machines, not simultaneous sessions. A checkout/checkin seat pool is a
  different product.
- **You want enforcement decided on your own server.** Verification happens
  against KeyGuard's API; if your compliance posture forbids a third-party
  dependency in the licensing path, run your own license server instead.
- **You only sell SaaS.** If users authenticate to your backend anyway, your
  existing auth and billing already answer "is this account allowed" — you
  don't need per-machine licensing at all.
- **Offline machines must be revocable.** Offline files are irrevocable by
  construction; cryptography can't fix physics.

## How it works under the hood

Online verify is a single signed-in-spirit JSON round trip: your API key in
the body authenticates the product, the server checks the license state, the
machine slot ledger, and the rate-limit buckets, and answers with a flat
`{"valid", ...}` payload.

Offline files are an ed25519 envelope: the vendor's private key signs the
canonical JSON of the license claims (UTF-8, sorted keys, compact
separators — byte-identical to the server's canonical form), and this SDK
verifies the signature with the product's public key. The hardware
fingerprint that binds a license to a machine is a salted SHA-256 of the
OS machine identifier; only the digest ever crosses the network.

## Development

```bash
cd sdks/python
PYTHONPATH=. python -m unittest discover -s tests -v
```

## License

MIT — see [LICENSE](LICENSE).
