Metadata-Version: 2.4
Name: sekiwa-sdk
Version: 0.1.1
Summary: Sekiwa licensing SDK for Python: offline-verified, signed licences for desktop and server apps
License: Sekiwa SDK Licence
        Applies to the Sekiwa SDKs for JavaScript, Python, .NET and Java.
        Version 1.0 · Effective 20 October 2026
        
        Copyright (c) 2026 Xalo Software. All rights reserved.
        
        1. Grant
        Xalo Software of Kariba, Zimbabwe (“Xalo”) grants you a non-exclusive, non-transferable, royalty-
        free licence to use the Sekiwa SDK (“the SDK”) in software that you develop, to check software
        licences issued through a Sekiwa account that you, or your customer, hold in good standing under
        the Sekiwa Terms of Service.
        
        2. What you may do
        You may install the SDK on development, build and customer machines; link it into your
        applications; and distribute it unmodified, as part of your applications, to your own customers.
        You may make changes needed to fit the SDK into your build, for your own use, as long as you keep
        this licence and the copyright notices with it.
        
        3. What you may not do
        You may not sell, sub-license or publish the SDK, or any modified version of it, as a product or
        library in its own right; use it to build or operate a service that competes with Sekiwa; remove
        or alter copyright or licence notices; or use it to bypass, defeat or tamper with licence checks
        of software you do not own or are not authorised to license.
        
        4. Ownership
        Xalo owns the SDK and all rights in it. This licence gives you the right to use the SDK, not
        ownership of it. Third-party components that the SDK depends on or includes remain under their own
        licences.
        
        5. No warranty
        The SDK is provided “as is”. To the fullest extent permitted by law, Xalo excludes all implied
        warranties, including fitness for a particular purpose. Licence checks run on devices that you and
        your customers control; Xalo does not guarantee that they cannot be bypassed by someone with
        control of the device.
        
        6. Limit of liability
        To the fullest extent permitted by law, Xalo is not liable for indirect, incidental, special,
        punitive or consequential loss, or lost profits, revenue, data or goodwill, arising from use of
        the SDK. Xalo’s aggregate liability will not exceed the fees you paid for the Sekiwa service in
        the preceding 12 months, unless law prohibits that limit.
        
        7. Ending this licence
        This licence ends automatically if you break it. You may stop at any time by removing the SDK.
        Applications you have already distributed to customers under section 2 may keep working for those
        customers.
        
        This is the Sekiwa SDK Licence, also published at https://sekiwa.xalosoftware.com/sdk-license
        
Project-URL: Homepage, https://sekiwa.xalosoftware.com
Keywords: licensing,offline,activation,ed25519,sekiwa
Classifier: License :: Other/Proprietary License
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=41
Dynamic: license-file

# sekiwa-sdk (Python)

Sekiwa licensing SDK for Python. It verifies signed licences offline, for desktop and server applications.

The SDK checks the single signed authority snapshot that Sekiwa issued for one app, environment, device and licensee. Sekiwa decides licensing; your application supplies a durable device identity, calls `check()`, and gates features on the result.

For the behaviour every SDK shares, see [`../README.md`](../README.md). The TypeScript SDK in `cloud/packages/sdk` is the reference.

## Install

```sh
pip install sekiwa-sdk
```

Requires Python 3.9 or later. The only dependency is `cryptography` (41 or later), used for Ed25519 verification.

## Quick start

```python
from pathlib import Path
import json
import urllib.request
import uuid

from sekiwa import FileStorage, Licensee, Sekiwa

storage = FileStorage(Path.home() / ".myapp" / "licence")


def device_id(store):
    # Durable and installation-specific: create it once and keep it. See "Device identity".
    existing = store.get("myapp.device-id")
    if existing:
        return existing
    fresh = str(uuid.uuid4())
    store.set("myapp.device-id", fresh)
    return fresh


def fetch_assertion(request, session_token):
    # Your backend authenticates the user, derives the scope from its own session
    # and mints the assertion with the integration private key. The app never holds that key.
    http_request = urllib.request.Request(
        "https://api.example.com/sekiwa/assertion",  # your backend
        data=json.dumps({"operation": request.operation}).encode("utf-8"),
        headers={"content-type": "application/json", "authorization": f"Bearer {session_token}"},
        method="POST",
    )
    with urllib.request.urlopen(http_request, timeout=30) as response:
        return response.read().decode("utf-8")


session_token = "token-from-your-login-flow"

client = Sekiwa(
    app_id="your-sekiwa-app-id",
    environment="production",
    device_id=device_id(storage),
    licensee=Licensee(type="BUSINESS", external_id="your-licensee-id"),
    public_keys={"key-id": "BASE64_ED25519_PUBLIC_KEY"},  # public half only
    storage=storage,
    endpoint="https://your-deployment.convex.site",
    assertion_provider=lambda request: fetch_assertion(request, session_token),
)

report = client.check()
print(f"Licence: {report.status} (from {report.source})")

if report.allowed:
    enable_pro_features(report.features)   # your code
else:
    show_activation_screen(report.reason)  # your code
```

`check()` never raises for network or assertion failures. If Sekiwa cannot be reached, it verifies the cached snapshot instead and puts the reason in `report.online_error`. Without `endpoint` and `assertion_provider` the client runs offline only.

Call `check()` on startup, on resume and periodically. Run it on a worker thread in GUI applications, because it blocks while it talks to Sekiwa.

## The report

`check()` returns a `LicenseReport`:

| Field | Meaning |
| --- | --- |
| `status` | One of `active`, `grace`, `expired`, `revoked`, `invalid`, `missing` (see below). |
| `allowed` | True only when the verified decision is ALLOW and the status is `active` or `grace`. Gate on this. |
| `source` | `"online"` when this call refreshed from Sekiwa, `"cache"` when it used the stored snapshot. |
| `online_error` | Why the refresh was skipped or failed. Free text, for logs. Do not parse it. |
| `verification` | Result of local verification, for example `VERIFIED`, `STALE_REVALIDATION`, `SCOPE_MISMATCH`. Use it for support references. |
| `reason` | Plain-language summary of the state. |
| `features`, `plan_code`, `entitlement_id` | From the verified snapshot. `features` is empty unless `allowed` is true. |
| `expires_at`, `grace_ends_at`, `revalidate_by`, `next_transition_at` | Signed time boundaries, in epoch milliseconds, for scheduling. |
| `last_online_check_at` | When this device last refreshed from Sekiwa. |

## Status values

| Status | Meaning | `allowed` | What the application does |
| --- | --- | --- | --- |
| `active` | Verified ALLOW snapshot inside its term. | yes | Enable paid features. |
| `grace` | Term has ended but the grace window has not. | yes | Keep features on, show a renewal reminder, prompt a reconnect. |
| `expired` | Grace window has ended, or the lifecycle is EXPIRED. | no | Lock paid features and show renewal or activation. |
| `revoked` | Sekiwa has blocked access: licence revoked or suspended, or this device was removed or replaced. | no | Lock paid features and explain. Do not retry silently. Contact the licence holder. |
| `invalid` | The cached snapshot cannot be trusted or used: bad signature, wrong device or app, corrupt cache, clock anomaly, not yet valid, or the revalidation window has passed (`verification` is `STALE_REVALIDATION`). | no | Lock paid features. Go online so `check()` can refresh, and show `verification` if it persists. |
| `missing` | No licence is stored on this device. | no | Show activation. This needs online access and an assertion provider. |

`sync_online_status()` returns the same values. A signed BLOCK is applied and returned as a status. An unsigned BLOCK resolution maps as follows: `NO_ENTITLEMENT` to `missing`, `DEVICE_REPLACED`, `SNAPSHOT_INVALID` and `ENTITLEMENT_BLOCKED` to `revoked`, `AUTHORITY_MISCONFIGURED` and `DEVICE_ACTION_REQUIRED` to `invalid`.

## Lower-level calls

`check()` is built from these. Use them only when you need finer control.

```python
client.status()                       # local only, never touches the network
client.current()                      # the verified snapshot dict, or None
client.remove_local_license()         # deletes the cached licence on this device
client.sync_online_status(assertion)  # raises on transport failure; an authoritative BLOCK is returned
```

## Online sync

1. The app calls `check()`, or `sync_online_status()`, which needs a fresh assertion.
2. The SDK calls `assertion_provider(ResolveRequest)`. The app calls its own backend.
3. The backend mints a short-lived RESOLVE assertion with the integration private key (see below) and returns it as a string.
4. The SDK posts `{"operation": "resolve", "assertion": ...}` to `{endpoint}/licensing/bridge`.
5. Sekiwa returns a signed snapshot, or an unsigned resolution. The SDK verifies the signature, scope, device and dates, then caches the snapshot.

Redirects are refused, and response bodies are capped at 64 KiB.

## Server side: minting an assertion

Run this on a server you control. Never ship the integration private key in the application.

```python
import os
import uuid

from sekiwa import create_trusted_integration_assertion, serialize_assertion


def mint_resolve_assertion(actor, device_id: str) -> str:
    # `actor` comes from your authenticated session, never from the request body.
    assertion = create_trusted_integration_assertion(
        app_id=os.environ["SEKIWA_APP_ID"],
        environment="production",
        business_id=actor.business_id,
        licensee_external_id=actor.business_id,  # must equal the client's licensee.external_id
        actor_user_id=actor.user_id,
        actor_role=actor.role,                   # OWNER, MANAGER, CASHIER, VENDOR_OPERATOR or SYSTEM
        actor_session_id=actor.session_id,
        device_id=device_id,
        operation="RESOLVE",
        nonce=str(uuid.uuid4()),                 # a fresh nonce for every assertion
        key_id=os.environ["SEKIWA_INTEGRATION_KEY_ID"],
        private_key=os.environ["SEKIWA_INTEGRATION_PRIVATE_KEY"],  # 32-byte seed, Base64
    )
    return serialize_assertion(assertion)
```

Assertions are valid for five minutes by default (`expires_at` can be set). Create one per request. The `environment`, `licensee_external_id` and `device_id` in the assertion must match the device's scope.

## Device identity

`device_id` must be durable and installation-specific. Sekiwa binds a licence to a device ID, and every snapshot carries the device ID it was issued for. The SDK rejects a snapshot whose device ID does not match (`SCOPE_MISMATCH`).

- Create the ID once and reuse it on every start. A new ID makes the device look new to Sekiwa, which needs its own activation and seat.
- Do not derive it from anything that changes (hostname, IP, user name), and do not reuse one ID across installations.

## Storage

| Class | Use |
| --- | --- |
| `FileStorage(directory)` | Desktop use. One JSON file per key, written to a temporary file, fsynced and renamed into place, so a crash cannot leave a half-written licence. |
| `MemoryStorage()` | Tests, and servers that re-sync on every start. |
| Your own | Any object with `get`, `set` and `remove` (the `Storage` protocol). |

Separate processes must coordinate writes through the storage layer themselves.

## Requesting an entitlement

For a trial or plan request, call `client.create_request(plan_code="trial")`, then `client.request_entitlement(request, assertion_string)` with an assertion minted for `REQUEST_ENTITLEMENT`. It raises `RuntimeError` on a non-2xx response.

## Errors

- Constructor: `ValueError` for an invalid `endpoint` (must be HTTPS, or loopback HTTP) or a non-positive `operation_timeout_ms`.
- `check()`: never raises for online failures. Read `online_error`.
- `sync_online_status()`: raises transport errors (`urllib.error.URLError`, `OSError`), `RuntimeError` for a non-2xx resolve, `ValueError` for an invalid response, and `OnlineOperationError` with `code` `"timeout"` or `"obsolete"` when a newer operation supersedes it.
- `verify()`: raises `ContractValidationError`, whose `code` is stable (for example `invalid-contract`, `signature-invalid`, `unknown-key-id`, `file-too-large`). `scope=True` means the artifact is well formed but belongs to another app, environment, device or licensee.

## Threading

- All calls block the calling thread. There is no asyncio API.
- Clients that share a storage object and scope coordinate writes and resolve generations within one process.
- `subscribe_status(listener)` callbacks run on the committing thread. Their exceptions go to `on_subscriber_error`, if set, and never undo the committed state.

## Security notes

- Only public keys go in the application. `public_keys` takes Ed25519 public keys by key ID. Integration private keys stay on your server.
- Access comes only from a verified snapshot. `check()` never grants access by itself, and `allowed` is the only value to gate on.
- Offline revocation is not immediate. A revoked or removed device keeps its cached BLOCK, but a previously valid snapshot keeps working until the next online check, `revalidate_by`, or its expiry and grace end. Run `check()` on startup, on resume and periodically.
- Restoring an older cache after a block does not reactivate the licence. Reactivation needs a fresh online ALLOW.
- Clock rollback is detected. The SDK keeps a persisted trusted-time floor and uses the monotonic clock within a process. This is rollback detection, not tamper-proof enforcement: someone who controls the machine and its storage can bypass local checks. Protect the storage location with OS permissions, and synchronise online often.
- HTTPS is required for the endpoint, except for loopback hosts. The endpoint must not contain credentials, a query or a fragment. Redirects are refused, and response bodies are capped at 64 KiB.

## Tests

```sh
cd sdks/python && python3 -I -m unittest discover -s tests
```

The suite runs the shared conformance vectors in `sdks/conformance/vectors.json` and HTTP behaviour tests. See [`../conformance/README.md`](../conformance/README.md).
