Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ jobs:
- name: Verify Package Build
run: python -m build

- name: Run Contract Tests
run: pytest -q tests/test_contracts.py
- name: Run Tests
run: pytest -q tests

publish:
name: Publish to PyPI
Expand Down
3 changes: 3 additions & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Matches python_requires in setup.py.
target-version = "py310"

[lint]
ignore = ["F401", "F403", "F405", "F541", "BLE001", "S110", "S112", "PLW1510", "UP035", "RUF013"]
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- Add `verify_offline_license`, which verifies a signed offline license (the `.lic` file from the dashboard or `sign_key`, or the bare token) locally against your Ed25519 public keys, with no network call. It checks the algorithm, key, signature, license type, product ID, machine code and expiry, returns an `OfflineLicense`, and raises `OfflineLicenseError` with a `code` (`malformed`, `unsupported_algorithm`, `unknown_key`, `bad_signature`, `wrong_type`, `wrong_product`, `wrong_machine`, `expired`) on failure.
- `product_id` is required: one signing key covers every product in an organization, so a verifier that skips the product check would let a license for one product unlock another.
- New required dependency: `cryptography>=41`, for Ed25519.
- CI now runs every test under `tests/`, including the shared offline-license test vectors.

## 2.6.0 - 2026-10-26

- Add `expirationDate` (ISO 8601 string, or `None` when the license never expires) to the `activate_key` and `floating_checkout` response types. On checkout it is separate from `expiresAt`, the seat lease. Additive; no other fields changed.
Expand Down
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ A professional, production-ready SDK for integrating with the Keymint API in Pyt
- **Type hints**: Full type hint support for better IDE integration and code safety.
- **Comprehensive**: Complete API coverage for all Keymint endpoints.
- **Consistent error handling**: All API errors are returned as structured objects or exceptions.
- **Offline licenses**: Verify signed `.lic` files locally with `verify_offline_license`.
- **Machine Identity**: Built-in utilities for hardware fingerprinting and stable installation IDs.

## Installation
Expand Down Expand Up @@ -66,6 +67,81 @@ else:

Store the last `expirationDate` you received. An expired license fails activation with the same generic invalid-key error as a wrong key, so the stored date is how your app can tell the user the license expired on that date instead of calling the key invalid.

## Offline Licenses

An offline license is a `.lic` file holding a token signed with your organization's Ed25519 key. Your app verifies it locally with `verify_offline_license`, with no network call.

1. **Get the `.lic` file** from the dashboard's Offline License dialog, or with `sign_key` from your own backend (it needs an admin API key, so never call it from the shipped app). The `hostId` you sign for becomes the license's machine code, so use the ID the customer's machine reports, e.g. `identity.get_or_create_installation_id()`.

```python
# On your server, with host_id sent by the customer's machine:
result = sdk.sign_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})
lic = result['file'] # the .lic contents; deliver this to the customer
```

2. **Bundle your public key** with the app: copy the PEM from **Developer → Public Keys** in the dashboard. It is a public key, so shipping it is safe.

3. **Verify** with the product ID and this machine's code:

```python
from keymint import OfflineLicenseError, identity, verify_offline_license

PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----
"""

with open('license.lic', encoding='utf-8') as f:
lic = f.read()

try:
offline = verify_offline_license(
lic,
public_keys=[PUBLIC_KEY],
product_id='prod_...', # this app's product ID
machine_code=identity.get_or_create_installation_id(),
)
print(f'Licensed until {offline.expires_at:%Y-%m-%d}')
except OfflineLicenseError as err:
if err.code == 'expired':
... # ask the customer to renew or reconnect
elif err.code in ('wrong_product', 'wrong_machine'):
... # a real license, but not for this product or this machine
else:
... # malformed, unsupported_algorithm, unknown_key, bad_signature, wrong_type: not a valid license
```

`verify_offline_license` accepts the `.lic` file contents or the bare token and returns an `OfflineLicense` with `license_key`, `product_id`, `machine_code`, `key_id`, `issued_at` (or `None`) and `expires_at` (timezone-aware UTC datetimes). On failure it raises `OfflineLicenseError`; `err.code` is one of:

| Code | Meaning |
|---|---|
| `malformed` | Not a license file or token, or required claims (`exp`, `sub`) are missing or invalid. |
| `unsupported_algorithm` | The token is not signed with EdDSA (e.g. `none`, `HS256`). |
| `unknown_key` | Signed by a key that isn't in `public_keys`. |
| `bad_signature` | The signature doesn't match: the file was altered or forged. |
| `wrong_type` | The token is not an offline license. |
| `wrong_product` | The license is for a different product. |
| `wrong_machine` | The license is for a different machine. |
| `expired` | Past its expiry, plus `clock_skew` (default 60 seconds, max 3600). |

Bad arguments (no public keys, a PEM that isn't an Ed25519 public key, an empty `product_id` or `machine_code`, `clock_skew` outside 0..3600, a naive `now`) raise `ValueError` or `TypeError`, not `OfflineLicenseError`.

**`product_id` is required.** One signing key covers all of an organization's products, so without the product check a license for a cheap product would unlock an expensive one.

**Clock rollback.** The SDK can't tell if the system clock was set back. To guard against it, store the latest time you have trusted (for example, after each successful run) and pass the later of the two as `now`. `now` must be timezone-aware:

```python
from datetime import datetime, timezone

now = max(datetime.now(timezone.utc), last_trusted_time)
offline = verify_offline_license(lic, public_keys=[PUBLIC_KEY], product_id=product_id,
machine_code=machine_code, now=now)
```

`public_keys` accepts several keys, so apps are ready for key rotation. An offline license stays valid until it expires; it can't be revoked before then.

Offline verification uses the [`cryptography`](https://pypi.org/project/cryptography/) package, which `pip install keymint` installs (`cryptography>=41`).

## Machine Identity
Keymint provides utilities to uniquely identify machines for node-locking:

Expand Down
16 changes: 15 additions & 1 deletion keymint/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import requests

from ._version import __version__
from .offline import (
OfflineLicense,
OfflineLicenseError,
OfflineLicenseErrorCode,
verify_offline_license,
)
from .types import *

__all__ = ['KeyMint', 'KeyMintApiError', '__version__']
__all__ = [
'KeyMint',
'KeyMintApiError',
'OfflineLicense',
'OfflineLicenseError',
'OfflineLicenseErrorCode',
'__version__',
'verify_offline_license',
]

class KeyMint:
def __init__(self, api_key: str, base_url: str = "https://api.keymint.dev"):
Expand Down
Loading
Loading