keymint 2.6.0__tar.gz → 2.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {keymint-2.6.0/keymint.egg-info → keymint-2.7.0}/PKG-INFO +80 -2
- {keymint-2.6.0 → keymint-2.7.0}/README.md +78 -1
- {keymint-2.6.0 → keymint-2.7.0}/keymint/__init__.py +32 -4
- {keymint-2.6.0 → keymint-2.7.0}/keymint/_version.py +1 -1
- keymint-2.7.0/keymint/offline.py +298 -0
- {keymint-2.6.0 → keymint-2.7.0}/keymint/types.py +8 -0
- {keymint-2.6.0 → keymint-2.7.0/keymint.egg-info}/PKG-INFO +80 -2
- {keymint-2.6.0 → keymint-2.7.0}/keymint.egg-info/SOURCES.txt +3 -1
- {keymint-2.6.0 → keymint-2.7.0}/keymint.egg-info/requires.txt +1 -0
- {keymint-2.6.0 → keymint-2.7.0}/setup.py +2 -1
- {keymint-2.6.0 → keymint-2.7.0}/tests/test_contracts.py +41 -0
- keymint-2.7.0/tests/test_offline.py +252 -0
- {keymint-2.6.0 → keymint-2.7.0}/LICENSE +0 -0
- {keymint-2.6.0 → keymint-2.7.0}/MANIFEST.in +0 -0
- {keymint-2.6.0 → keymint-2.7.0}/keymint/identity.py +0 -0
- {keymint-2.6.0 → keymint-2.7.0}/keymint.egg-info/dependency_links.txt +0 -0
- {keymint-2.6.0 → keymint-2.7.0}/keymint.egg-info/top_level.txt +0 -0
- {keymint-2.6.0 → keymint-2.7.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: keymint
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: License key validation, activation, and management for Python. Supports node-locking, offline licensing, and hardware fingerprinting.
|
|
5
5
|
Home-page: https://github.com/keymint-dev/keymint-python
|
|
6
6
|
Author: KeyMint
|
|
@@ -24,6 +24,7 @@ Requires-Python: >=3.10
|
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE
|
|
26
26
|
Requires-Dist: requests>=2.32.4
|
|
27
|
+
Requires-Dist: cryptography>=41
|
|
27
28
|
Provides-Extra: dev
|
|
28
29
|
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
29
30
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -51,6 +52,7 @@ A professional, production-ready SDK for integrating with the Keymint API in Pyt
|
|
|
51
52
|
- **Type hints**: Full type hint support for better IDE integration and code safety.
|
|
52
53
|
- **Comprehensive**: Complete API coverage for all Keymint endpoints.
|
|
53
54
|
- **Consistent error handling**: All API errors are returned as structured objects or exceptions.
|
|
55
|
+
- **Offline licenses**: Verify signed `.lic` files locally with `verify_offline_license`.
|
|
54
56
|
- **Machine Identity**: Built-in utilities for hardware fingerprinting and stable installation IDs.
|
|
55
57
|
|
|
56
58
|
## Installation
|
|
@@ -111,6 +113,81 @@ else:
|
|
|
111
113
|
|
|
112
114
|
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.
|
|
113
115
|
|
|
116
|
+
## Offline Licenses
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
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()`.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# On your server, with host_id sent by the customer's machine:
|
|
124
|
+
result = sdk.sign_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})
|
|
125
|
+
lic = result['file'] # the .lic contents; deliver this to the customer
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
3. **Verify** with the product ID and this machine's code:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from keymint import OfflineLicenseError, identity, verify_offline_license
|
|
134
|
+
|
|
135
|
+
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
|
136
|
+
MCowBQYDK2VwAyEA...
|
|
137
|
+
-----END PUBLIC KEY-----
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
with open('license.lic', encoding='utf-8') as f:
|
|
141
|
+
lic = f.read()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
offline = verify_offline_license(
|
|
145
|
+
lic,
|
|
146
|
+
public_keys=[PUBLIC_KEY],
|
|
147
|
+
product_id='prod_...', # this app's product ID
|
|
148
|
+
machine_code=identity.get_or_create_installation_id(),
|
|
149
|
+
)
|
|
150
|
+
print(f'Licensed until {offline.expires_at:%Y-%m-%d}')
|
|
151
|
+
except OfflineLicenseError as err:
|
|
152
|
+
if err.code == 'expired':
|
|
153
|
+
... # ask the customer to renew or reconnect
|
|
154
|
+
elif err.code in ('wrong_product', 'wrong_machine'):
|
|
155
|
+
... # a real license, but not for this product or this machine
|
|
156
|
+
else:
|
|
157
|
+
... # malformed, unsupported_algorithm, unknown_key, bad_signature, wrong_type: not a valid license
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`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:
|
|
161
|
+
|
|
162
|
+
| Code | Meaning |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `malformed` | Not a license file or token, or required claims (`exp`, `sub`) are missing or invalid. |
|
|
165
|
+
| `unsupported_algorithm` | The token is not signed with EdDSA (e.g. `none`, `HS256`). |
|
|
166
|
+
| `unknown_key` | Signed by a key that isn't in `public_keys`. |
|
|
167
|
+
| `bad_signature` | The signature doesn't match: the file was altered or forged. |
|
|
168
|
+
| `wrong_type` | The token is not an offline license. |
|
|
169
|
+
| `wrong_product` | The license is for a different product. |
|
|
170
|
+
| `wrong_machine` | The license is for a different machine. |
|
|
171
|
+
| `expired` | Past its expiry, plus `clock_skew` (default 60 seconds, max 3600). |
|
|
172
|
+
|
|
173
|
+
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`.
|
|
174
|
+
|
|
175
|
+
**`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.
|
|
176
|
+
|
|
177
|
+
**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:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from datetime import datetime, timezone
|
|
181
|
+
|
|
182
|
+
now = max(datetime.now(timezone.utc), last_trusted_time)
|
|
183
|
+
offline = verify_offline_license(lic, public_keys=[PUBLIC_KEY], product_id=product_id,
|
|
184
|
+
machine_code=machine_code, now=now)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`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.
|
|
188
|
+
|
|
189
|
+
Offline verification uses the [`cryptography`](https://pypi.org/project/cryptography/) package, which `pip install keymint` installs (`cryptography>=41`).
|
|
190
|
+
|
|
114
191
|
## Machine Identity
|
|
115
192
|
Keymint provides utilities to uniquely identify machines for node-locking:
|
|
116
193
|
|
|
@@ -130,6 +207,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
|
|
|
130
207
|
| `update_key` | Updates an existing license key. |
|
|
131
208
|
| `block_key` | Blocks a license key. |
|
|
132
209
|
| `unblock_key` | Unblocks a previously blocked license key. |
|
|
210
|
+
| `delete_key` | Permanently deletes a license key. |
|
|
133
211
|
| `sign_key` | Signs a key for offline validation using an admin API key.|
|
|
134
212
|
| `floating_checkout` | Checks out a floating license seat. |
|
|
135
213
|
| `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
|
|
@@ -145,7 +223,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
|
|
|
145
223
|
| `get_customer_with_keys`| Gets a customer along with their license keys. |
|
|
146
224
|
| `update_customer` | Updates customer information. |
|
|
147
225
|
| `toggle_customer_status`| Toggles customer active status. |
|
|
148
|
-
| `delete_customer` |
|
|
226
|
+
| `delete_customer` | Deletes a customer. Their license keys are kept; use `delete_key` to remove them. |
|
|
149
227
|
|
|
150
228
|
### Webhook Verification
|
|
151
229
|
|
|
@@ -6,6 +6,7 @@ A professional, production-ready SDK for integrating with the Keymint API in Pyt
|
|
|
6
6
|
- **Type hints**: Full type hint support for better IDE integration and code safety.
|
|
7
7
|
- **Comprehensive**: Complete API coverage for all Keymint endpoints.
|
|
8
8
|
- **Consistent error handling**: All API errors are returned as structured objects or exceptions.
|
|
9
|
+
- **Offline licenses**: Verify signed `.lic` files locally with `verify_offline_license`.
|
|
9
10
|
- **Machine Identity**: Built-in utilities for hardware fingerprinting and stable installation IDs.
|
|
10
11
|
|
|
11
12
|
## Installation
|
|
@@ -66,6 +67,81 @@ else:
|
|
|
66
67
|
|
|
67
68
|
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.
|
|
68
69
|
|
|
70
|
+
## Offline Licenses
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
|
|
74
|
+
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()`.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# On your server, with host_id sent by the customer's machine:
|
|
78
|
+
result = sdk.sign_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})
|
|
79
|
+
lic = result['file'] # the .lic contents; deliver this to the customer
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
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.
|
|
83
|
+
|
|
84
|
+
3. **Verify** with the product ID and this machine's code:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from keymint import OfflineLicenseError, identity, verify_offline_license
|
|
88
|
+
|
|
89
|
+
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
|
90
|
+
MCowBQYDK2VwAyEA...
|
|
91
|
+
-----END PUBLIC KEY-----
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
with open('license.lic', encoding='utf-8') as f:
|
|
95
|
+
lic = f.read()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
offline = verify_offline_license(
|
|
99
|
+
lic,
|
|
100
|
+
public_keys=[PUBLIC_KEY],
|
|
101
|
+
product_id='prod_...', # this app's product ID
|
|
102
|
+
machine_code=identity.get_or_create_installation_id(),
|
|
103
|
+
)
|
|
104
|
+
print(f'Licensed until {offline.expires_at:%Y-%m-%d}')
|
|
105
|
+
except OfflineLicenseError as err:
|
|
106
|
+
if err.code == 'expired':
|
|
107
|
+
... # ask the customer to renew or reconnect
|
|
108
|
+
elif err.code in ('wrong_product', 'wrong_machine'):
|
|
109
|
+
... # a real license, but not for this product or this machine
|
|
110
|
+
else:
|
|
111
|
+
... # malformed, unsupported_algorithm, unknown_key, bad_signature, wrong_type: not a valid license
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`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:
|
|
115
|
+
|
|
116
|
+
| Code | Meaning |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `malformed` | Not a license file or token, or required claims (`exp`, `sub`) are missing or invalid. |
|
|
119
|
+
| `unsupported_algorithm` | The token is not signed with EdDSA (e.g. `none`, `HS256`). |
|
|
120
|
+
| `unknown_key` | Signed by a key that isn't in `public_keys`. |
|
|
121
|
+
| `bad_signature` | The signature doesn't match: the file was altered or forged. |
|
|
122
|
+
| `wrong_type` | The token is not an offline license. |
|
|
123
|
+
| `wrong_product` | The license is for a different product. |
|
|
124
|
+
| `wrong_machine` | The license is for a different machine. |
|
|
125
|
+
| `expired` | Past its expiry, plus `clock_skew` (default 60 seconds, max 3600). |
|
|
126
|
+
|
|
127
|
+
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`.
|
|
128
|
+
|
|
129
|
+
**`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.
|
|
130
|
+
|
|
131
|
+
**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:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from datetime import datetime, timezone
|
|
135
|
+
|
|
136
|
+
now = max(datetime.now(timezone.utc), last_trusted_time)
|
|
137
|
+
offline = verify_offline_license(lic, public_keys=[PUBLIC_KEY], product_id=product_id,
|
|
138
|
+
machine_code=machine_code, now=now)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`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.
|
|
142
|
+
|
|
143
|
+
Offline verification uses the [`cryptography`](https://pypi.org/project/cryptography/) package, which `pip install keymint` installs (`cryptography>=41`).
|
|
144
|
+
|
|
69
145
|
## Machine Identity
|
|
70
146
|
Keymint provides utilities to uniquely identify machines for node-locking:
|
|
71
147
|
|
|
@@ -85,6 +161,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
|
|
|
85
161
|
| `update_key` | Updates an existing license key. |
|
|
86
162
|
| `block_key` | Blocks a license key. |
|
|
87
163
|
| `unblock_key` | Unblocks a previously blocked license key. |
|
|
164
|
+
| `delete_key` | Permanently deletes a license key. |
|
|
88
165
|
| `sign_key` | Signs a key for offline validation using an admin API key.|
|
|
89
166
|
| `floating_checkout` | Checks out a floating license seat. |
|
|
90
167
|
| `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
|
|
@@ -100,7 +177,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
|
|
|
100
177
|
| `get_customer_with_keys`| Gets a customer along with their license keys. |
|
|
101
178
|
| `update_customer` | Updates customer information. |
|
|
102
179
|
| `toggle_customer_status`| Toggles customer active status. |
|
|
103
|
-
| `delete_customer` |
|
|
180
|
+
| `delete_customer` | Deletes a customer. Their license keys are kept; use `delete_key` to remove them. |
|
|
104
181
|
|
|
105
182
|
### Webhook Verification
|
|
106
183
|
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import requests
|
|
2
2
|
|
|
3
3
|
from ._version import __version__
|
|
4
|
+
from .offline import (
|
|
5
|
+
OfflineLicense,
|
|
6
|
+
OfflineLicenseError,
|
|
7
|
+
OfflineLicenseErrorCode,
|
|
8
|
+
verify_offline_license,
|
|
9
|
+
)
|
|
4
10
|
from .types import *
|
|
5
11
|
|
|
6
|
-
__all__ = [
|
|
12
|
+
__all__ = [
|
|
13
|
+
'KeyMint',
|
|
14
|
+
'KeyMintApiError',
|
|
15
|
+
'OfflineLicense',
|
|
16
|
+
'OfflineLicenseError',
|
|
17
|
+
'OfflineLicenseErrorCode',
|
|
18
|
+
'__version__',
|
|
19
|
+
'verify_offline_license',
|
|
20
|
+
]
|
|
7
21
|
|
|
8
22
|
class KeyMint:
|
|
9
23
|
def __init__(self, api_key: str, base_url: str = "https://api.keymint.dev"):
|
|
@@ -17,7 +31,8 @@ class KeyMint:
|
|
|
17
31
|
'Content-Type': 'application/json'
|
|
18
32
|
}
|
|
19
33
|
|
|
20
|
-
def _handle_request(self, method: str, endpoint: str, params: dict | None = None, query_params: dict | None = None, idempotency_key: str | None = None, extra_headers: dict | None = None):
|
|
34
|
+
def _handle_request(self, method: str, endpoint: str, params: dict | None = None, query_params: dict | None = None, idempotency_key: str | None = None, extra_headers: dict | None = None, delete_body: dict | None = None):
|
|
35
|
+
# DELETE sends a JSON body only when delete_body is given (delete_key).
|
|
21
36
|
url = f'{self.base_url}{endpoint}'
|
|
22
37
|
headers = self.headers.copy()
|
|
23
38
|
if idempotency_key:
|
|
@@ -35,7 +50,10 @@ class KeyMint:
|
|
|
35
50
|
elif method.upper() == 'PATCH':
|
|
36
51
|
response = requests.patch(url, json=params, params=query_params, headers=headers)
|
|
37
52
|
elif method.upper() == 'DELETE':
|
|
38
|
-
|
|
53
|
+
if delete_body is not None:
|
|
54
|
+
response = requests.delete(url, json=delete_body, params=query_params, headers=headers)
|
|
55
|
+
else:
|
|
56
|
+
response = requests.delete(url, params=query_params, headers=headers)
|
|
39
57
|
else:
|
|
40
58
|
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
41
59
|
|
|
@@ -156,6 +174,16 @@ class KeyMint:
|
|
|
156
174
|
"""
|
|
157
175
|
return self._handle_request('POST', '/key/unblock', params, idempotency_key=idempotency_key)
|
|
158
176
|
|
|
177
|
+
def delete_key(self, params: DeleteKeyParams, idempotency_key: str | None = None) -> DeleteKeyResponse:
|
|
178
|
+
"""
|
|
179
|
+
Permanently deletes a license key. Use block_key to stop a key reversibly.
|
|
180
|
+
:param params: Parameters for deleting the key.
|
|
181
|
+
:param idempotency_key: Optional unique identifier to ensure request idempotency.
|
|
182
|
+
:returns: The deletion confirmation.
|
|
183
|
+
"""
|
|
184
|
+
body = {'productId': params['productId'], 'licenseKey': params['licenseKey']}
|
|
185
|
+
return self._handle_request('DELETE', '/key', idempotency_key=idempotency_key, delete_body=body)
|
|
186
|
+
|
|
159
187
|
def update_key(self, params: 'UpdateKeyParams', idempotency_key: str | None = None) -> 'UpdateKeyResponse':
|
|
160
188
|
"""
|
|
161
189
|
Updates an existing license key.
|
|
@@ -215,7 +243,7 @@ class KeyMint:
|
|
|
215
243
|
|
|
216
244
|
def delete_customer(self, params: DeleteCustomerParams, idempotency_key: str | None = None) -> DeleteCustomerResponse:
|
|
217
245
|
"""
|
|
218
|
-
|
|
246
|
+
Deletes a customer. Their license keys are kept; use delete_key to remove them.
|
|
219
247
|
:param params: Parameters containing the customer ID.
|
|
220
248
|
:param idempotency_key: Optional unique identifier to ensure request idempotency.
|
|
221
249
|
:returns: The deletion confirmation.
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline license verification for the Keymint Python SDK.
|
|
3
|
+
|
|
4
|
+
Verifies a signed offline license (the ``.lic`` file from the dashboard or
|
|
5
|
+
``KeyMint.sign_key``) locally, with no network call. The license is an Ed25519
|
|
6
|
+
JWT signed by your organization's key; you bundle the public key PEM from
|
|
7
|
+
Developer -> Public Keys with your app.
|
|
8
|
+
|
|
9
|
+
One signing key covers every product in an organization, so the product ID is
|
|
10
|
+
a required argument: without it a license for one product would unlock another.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import binascii
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
|
|
28
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
29
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
30
|
+
Encoding,
|
|
31
|
+
PublicFormat,
|
|
32
|
+
load_pem_public_key,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
'OfflineLicense',
|
|
37
|
+
'OfflineLicenseError',
|
|
38
|
+
'OfflineLicenseErrorCode',
|
|
39
|
+
'verify_offline_license',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
DEFAULT_CLOCK_SKEW = 60
|
|
43
|
+
MAX_CLOCK_SKEW = 3600
|
|
44
|
+
|
|
45
|
+
# fullmatch, not match: '$' would also accept a trailing newline.
|
|
46
|
+
_SEGMENT = re.compile(r'[A-Za-z0-9_-]*')
|
|
47
|
+
# The characters JavaScript's String.prototype.trim removes, so every SDK
|
|
48
|
+
# accepts the same inputs as the reference implementation.
|
|
49
|
+
_WHITESPACE = (
|
|
50
|
+
' \t\n\v\f\r '
|
|
51
|
+
'
'
|
|
52
|
+
)
|
|
53
|
+
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OfflineLicenseErrorCode(str, Enum):
|
|
57
|
+
"""Why an offline license was rejected. Compares equal to its string value."""
|
|
58
|
+
|
|
59
|
+
MALFORMED = 'malformed'
|
|
60
|
+
UNSUPPORTED_ALGORITHM = 'unsupported_algorithm'
|
|
61
|
+
UNKNOWN_KEY = 'unknown_key'
|
|
62
|
+
BAD_SIGNATURE = 'bad_signature'
|
|
63
|
+
WRONG_TYPE = 'wrong_type'
|
|
64
|
+
WRONG_PRODUCT = 'wrong_product'
|
|
65
|
+
WRONG_MACHINE = 'wrong_machine'
|
|
66
|
+
EXPIRED = 'expired'
|
|
67
|
+
|
|
68
|
+
def __str__(self) -> str:
|
|
69
|
+
return self.value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_MESSAGES = {
|
|
73
|
+
OfflineLicenseErrorCode.MALFORMED: 'The offline license is not a valid license file or token.',
|
|
74
|
+
OfflineLicenseErrorCode.UNSUPPORTED_ALGORITHM: 'The offline license uses an unsupported signing algorithm.',
|
|
75
|
+
OfflineLicenseErrorCode.UNKNOWN_KEY: 'The offline license was not signed by a trusted public key.',
|
|
76
|
+
OfflineLicenseErrorCode.BAD_SIGNATURE: 'The offline license signature is invalid.',
|
|
77
|
+
OfflineLicenseErrorCode.WRONG_TYPE: 'The token is not an offline license.',
|
|
78
|
+
OfflineLicenseErrorCode.WRONG_PRODUCT: 'The offline license is for a different product.',
|
|
79
|
+
OfflineLicenseErrorCode.WRONG_MACHINE: 'The offline license is for a different machine.',
|
|
80
|
+
OfflineLicenseErrorCode.EXPIRED: 'The offline license has expired.',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OfflineLicenseError(Exception):
|
|
85
|
+
"""
|
|
86
|
+
Raised when an offline license fails verification.
|
|
87
|
+
|
|
88
|
+
``code`` is an :class:`OfflineLicenseErrorCode` (a ``str``), e.g. ``"expired"``.
|
|
89
|
+
The message never contains the license key.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, code: OfflineLicenseErrorCode, message: str | None = None):
|
|
93
|
+
self.code = code
|
|
94
|
+
self.message = message or _MESSAGES[code]
|
|
95
|
+
super().__init__(self.message)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class OfflineLicense:
|
|
100
|
+
"""A verified offline license. Times are timezone-aware UTC datetimes."""
|
|
101
|
+
|
|
102
|
+
license_key: str = field(repr=False)
|
|
103
|
+
product_id: str
|
|
104
|
+
machine_code: str
|
|
105
|
+
key_id: str
|
|
106
|
+
issued_at: datetime | None
|
|
107
|
+
expires_at: datetime
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _key_id(key: Ed25519PublicKey) -> str:
|
|
111
|
+
"""base64url (no padding) of SHA-256 over the key's SPKI DER, as the server computes it."""
|
|
112
|
+
der = key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
|
|
113
|
+
return base64.urlsafe_b64encode(hashlib.sha256(der).digest()).rstrip(b'=').decode('ascii')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_public_key(pem: str, index: int) -> Ed25519PublicKey:
|
|
117
|
+
if not isinstance(pem, str):
|
|
118
|
+
raise TypeError(f'public_keys[{index}] must be a PEM string')
|
|
119
|
+
try:
|
|
120
|
+
key = load_pem_public_key(pem.encode('utf-8'))
|
|
121
|
+
except (ValueError, TypeError, UnsupportedAlgorithm) as err:
|
|
122
|
+
raise ValueError(f'public_keys[{index}] is not a valid PEM public key') from err
|
|
123
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
124
|
+
# A valid PEM of the wrong key type is a bad value, not a bad type (spec).
|
|
125
|
+
raise ValueError(f'public_keys[{index}] is not an Ed25519 public key') # noqa: TRY004
|
|
126
|
+
return key
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _b64url_decode(segment: str) -> bytes | None:
|
|
130
|
+
try:
|
|
131
|
+
return base64.urlsafe_b64decode(segment + '=' * (-len(segment) % 4))
|
|
132
|
+
except (binascii.Error, ValueError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _reject_constant(name: str) -> Any:
|
|
137
|
+
# JSON has no NaN or Infinity; Python's json module accepts them by default.
|
|
138
|
+
raise ValueError(f'invalid JSON constant {name}')
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_json(text: str) -> Any:
|
|
142
|
+
"""json.loads, raising ValueError for anything JSON.parse would reject."""
|
|
143
|
+
try:
|
|
144
|
+
return json.loads(text, parse_constant=_reject_constant)
|
|
145
|
+
except RecursionError as err: # deeply nested input
|
|
146
|
+
raise ValueError('JSON nested too deeply') from err
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _decode_json_object(segment: str) -> dict[str, Any] | None:
|
|
150
|
+
raw = _b64url_decode(segment)
|
|
151
|
+
if raw is None:
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
value = _parse_json(raw.decode('utf-8', errors='replace'))
|
|
155
|
+
except ValueError:
|
|
156
|
+
return None
|
|
157
|
+
return value if isinstance(value, dict) else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_number(value: Any) -> bool:
|
|
161
|
+
# bool is a subclass of int in Python; JSON true/false are not numbers.
|
|
162
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _to_datetime(seconds: float) -> datetime | None:
|
|
166
|
+
try:
|
|
167
|
+
if not math.isfinite(seconds):
|
|
168
|
+
return None
|
|
169
|
+
return _EPOCH + timedelta(seconds=seconds)
|
|
170
|
+
except (OverflowError, ValueError):
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def verify_offline_license(
|
|
175
|
+
license: str,
|
|
176
|
+
*,
|
|
177
|
+
public_keys: Sequence[str],
|
|
178
|
+
product_id: str,
|
|
179
|
+
machine_code: str,
|
|
180
|
+
now: datetime | None = None,
|
|
181
|
+
clock_skew: float = DEFAULT_CLOCK_SKEW,
|
|
182
|
+
) -> OfflineLicense:
|
|
183
|
+
"""
|
|
184
|
+
Verifies an offline license locally and returns its claims.
|
|
185
|
+
|
|
186
|
+
:param license: The ``.lic`` file contents (JSON string) or the bare signed token.
|
|
187
|
+
:param public_keys: One or more trusted Ed25519 public keys (SPKI PEM strings)
|
|
188
|
+
from Developer -> Public Keys.
|
|
189
|
+
:param product_id: The product this app is. Must equal the license's product.
|
|
190
|
+
:param machine_code: This machine's code. Must equal the license's machine code.
|
|
191
|
+
:param now: The time to check expiry against. Must be timezone-aware; naive
|
|
192
|
+
datetimes are rejected. Defaults to the system clock. To guard against
|
|
193
|
+
clock rollback, pass ``max(system time, last trusted time)``.
|
|
194
|
+
:param clock_skew: Seconds of leeway on expiry, 0 to 3600. Defaults to 60.
|
|
195
|
+
:returns: The verified :class:`OfflineLicense`.
|
|
196
|
+
:raises OfflineLicenseError: The license failed verification; see ``.code``.
|
|
197
|
+
:raises ValueError: An argument is invalid (no keys, a key that is not an
|
|
198
|
+
Ed25519 public key PEM, an empty product or machine code, a naive ``now``,
|
|
199
|
+
or ``clock_skew`` out of range).
|
|
200
|
+
:raises TypeError: An argument has the wrong type.
|
|
201
|
+
"""
|
|
202
|
+
if not isinstance(license, str):
|
|
203
|
+
raise TypeError('license must be a string')
|
|
204
|
+
if isinstance(public_keys, (str, bytes)):
|
|
205
|
+
raise TypeError('public_keys must be a sequence of PEM strings, not a single string')
|
|
206
|
+
public_keys = list(public_keys)
|
|
207
|
+
if not public_keys:
|
|
208
|
+
raise ValueError('public_keys must contain at least one key')
|
|
209
|
+
if not isinstance(product_id, str):
|
|
210
|
+
raise TypeError('product_id must be a string')
|
|
211
|
+
if not product_id:
|
|
212
|
+
raise ValueError('product_id is required')
|
|
213
|
+
if not isinstance(machine_code, str):
|
|
214
|
+
raise TypeError('machine_code must be a string')
|
|
215
|
+
if not machine_code:
|
|
216
|
+
raise ValueError('machine_code is required')
|
|
217
|
+
if not _is_number(clock_skew):
|
|
218
|
+
raise TypeError('clock_skew must be a number of seconds')
|
|
219
|
+
if not 0 <= clock_skew <= MAX_CLOCK_SKEW:
|
|
220
|
+
raise ValueError(f'clock_skew must be between 0 and {MAX_CLOCK_SKEW} seconds')
|
|
221
|
+
if now is None:
|
|
222
|
+
now = datetime.now(timezone.utc)
|
|
223
|
+
elif not isinstance(now, datetime):
|
|
224
|
+
raise TypeError('now must be a datetime')
|
|
225
|
+
elif now.tzinfo is None or now.utcoffset() is None:
|
|
226
|
+
raise ValueError('now must be timezone-aware, e.g. datetime.now(timezone.utc)')
|
|
227
|
+
|
|
228
|
+
keys: dict[str, Ed25519PublicKey] = {}
|
|
229
|
+
for index, pem in enumerate(public_keys):
|
|
230
|
+
key = _load_public_key(pem, index)
|
|
231
|
+
keys[_key_id(key)] = key
|
|
232
|
+
|
|
233
|
+
# 1. Parse: a .lic file (JSON) or a bare token.
|
|
234
|
+
token = license.strip(_WHITESPACE)
|
|
235
|
+
if token.startswith('{'):
|
|
236
|
+
try:
|
|
237
|
+
file = _parse_json(token)
|
|
238
|
+
except ValueError:
|
|
239
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED) from None
|
|
240
|
+
if not isinstance(file, dict) or not isinstance(file.get('signedKey'), str):
|
|
241
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED)
|
|
242
|
+
token = file['signedKey']
|
|
243
|
+
parts = token.split('.')
|
|
244
|
+
if len(parts) != 3 or not all(_SEGMENT.fullmatch(part) for part in parts):
|
|
245
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED)
|
|
246
|
+
header_b64, payload_b64, signature_b64 = parts
|
|
247
|
+
header = _decode_json_object(header_b64)
|
|
248
|
+
payload = _decode_json_object(payload_b64)
|
|
249
|
+
if header is None or payload is None:
|
|
250
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED)
|
|
251
|
+
|
|
252
|
+
# 2. Algorithm: exactly EdDSA, no critical extensions.
|
|
253
|
+
if header.get('alg') != 'EdDSA' or 'crit' in header:
|
|
254
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.UNSUPPORTED_ALGORITHM)
|
|
255
|
+
|
|
256
|
+
# 3. Key: the kid must name a trusted key.
|
|
257
|
+
kid = header.get('kid')
|
|
258
|
+
key = keys.get(kid) if isinstance(kid, str) else None
|
|
259
|
+
if key is None:
|
|
260
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.UNKNOWN_KEY)
|
|
261
|
+
|
|
262
|
+
# 4. Signature over the segments exactly as received.
|
|
263
|
+
signature = _b64url_decode(signature_b64)
|
|
264
|
+
if signature is None or len(signature) != 64:
|
|
265
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.BAD_SIGNATURE)
|
|
266
|
+
try:
|
|
267
|
+
key.verify(signature, f'{header_b64}.{payload_b64}'.encode('ascii'))
|
|
268
|
+
except InvalidSignature:
|
|
269
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.BAD_SIGNATURE) from None
|
|
270
|
+
|
|
271
|
+
# 5-7. Claims, compared exactly (no trimming or case folding).
|
|
272
|
+
if payload.get('type') != 'offline':
|
|
273
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.WRONG_TYPE)
|
|
274
|
+
if payload.get('productId') != product_id:
|
|
275
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.WRONG_PRODUCT)
|
|
276
|
+
if payload.get('machineCode') != machine_code:
|
|
277
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.WRONG_MACHINE)
|
|
278
|
+
|
|
279
|
+
# 8. Shape of the remaining claims, then 9. expiry.
|
|
280
|
+
exp = payload.get('exp')
|
|
281
|
+
sub = payload.get('sub')
|
|
282
|
+
iat = payload.get('iat')
|
|
283
|
+
expires_at = _to_datetime(exp) if _is_number(exp) else None
|
|
284
|
+
if expires_at is None:
|
|
285
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED)
|
|
286
|
+
if not isinstance(sub, str) or not sub:
|
|
287
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.MALFORMED)
|
|
288
|
+
if not now.timestamp() < exp + clock_skew:
|
|
289
|
+
raise OfflineLicenseError(OfflineLicenseErrorCode.EXPIRED)
|
|
290
|
+
|
|
291
|
+
return OfflineLicense(
|
|
292
|
+
license_key=sub,
|
|
293
|
+
product_id=product_id,
|
|
294
|
+
machine_code=machine_code,
|
|
295
|
+
key_id=kid,
|
|
296
|
+
issued_at=_to_datetime(iat) if _is_number(iat) else None,
|
|
297
|
+
expires_at=expires_at,
|
|
298
|
+
)
|
|
@@ -123,6 +123,14 @@ class UnblockKeyResponse(TypedDict):
|
|
|
123
123
|
message: str
|
|
124
124
|
code: int
|
|
125
125
|
|
|
126
|
+
class DeleteKeyParams(TypedDict):
|
|
127
|
+
productId: str
|
|
128
|
+
licenseKey: str
|
|
129
|
+
|
|
130
|
+
class DeleteKeyResponse(TypedDict):
|
|
131
|
+
message: str
|
|
132
|
+
code: int
|
|
133
|
+
|
|
126
134
|
# Customer Management Types
|
|
127
135
|
|
|
128
136
|
class CreateCustomerParams(TypedDict):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: keymint
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: License key validation, activation, and management for Python. Supports node-locking, offline licensing, and hardware fingerprinting.
|
|
5
5
|
Home-page: https://github.com/keymint-dev/keymint-python
|
|
6
6
|
Author: KeyMint
|
|
@@ -24,6 +24,7 @@ Requires-Python: >=3.10
|
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE
|
|
26
26
|
Requires-Dist: requests>=2.32.4
|
|
27
|
+
Requires-Dist: cryptography>=41
|
|
27
28
|
Provides-Extra: dev
|
|
28
29
|
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
29
30
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -51,6 +52,7 @@ A professional, production-ready SDK for integrating with the Keymint API in Pyt
|
|
|
51
52
|
- **Type hints**: Full type hint support for better IDE integration and code safety.
|
|
52
53
|
- **Comprehensive**: Complete API coverage for all Keymint endpoints.
|
|
53
54
|
- **Consistent error handling**: All API errors are returned as structured objects or exceptions.
|
|
55
|
+
- **Offline licenses**: Verify signed `.lic` files locally with `verify_offline_license`.
|
|
54
56
|
- **Machine Identity**: Built-in utilities for hardware fingerprinting and stable installation IDs.
|
|
55
57
|
|
|
56
58
|
## Installation
|
|
@@ -111,6 +113,81 @@ else:
|
|
|
111
113
|
|
|
112
114
|
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.
|
|
113
115
|
|
|
116
|
+
## Offline Licenses
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
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()`.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# On your server, with host_id sent by the customer's machine:
|
|
124
|
+
result = sdk.sign_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})
|
|
125
|
+
lic = result['file'] # the .lic contents; deliver this to the customer
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
3. **Verify** with the product ID and this machine's code:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from keymint import OfflineLicenseError, identity, verify_offline_license
|
|
134
|
+
|
|
135
|
+
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
|
136
|
+
MCowBQYDK2VwAyEA...
|
|
137
|
+
-----END PUBLIC KEY-----
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
with open('license.lic', encoding='utf-8') as f:
|
|
141
|
+
lic = f.read()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
offline = verify_offline_license(
|
|
145
|
+
lic,
|
|
146
|
+
public_keys=[PUBLIC_KEY],
|
|
147
|
+
product_id='prod_...', # this app's product ID
|
|
148
|
+
machine_code=identity.get_or_create_installation_id(),
|
|
149
|
+
)
|
|
150
|
+
print(f'Licensed until {offline.expires_at:%Y-%m-%d}')
|
|
151
|
+
except OfflineLicenseError as err:
|
|
152
|
+
if err.code == 'expired':
|
|
153
|
+
... # ask the customer to renew or reconnect
|
|
154
|
+
elif err.code in ('wrong_product', 'wrong_machine'):
|
|
155
|
+
... # a real license, but not for this product or this machine
|
|
156
|
+
else:
|
|
157
|
+
... # malformed, unsupported_algorithm, unknown_key, bad_signature, wrong_type: not a valid license
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`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:
|
|
161
|
+
|
|
162
|
+
| Code | Meaning |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `malformed` | Not a license file or token, or required claims (`exp`, `sub`) are missing or invalid. |
|
|
165
|
+
| `unsupported_algorithm` | The token is not signed with EdDSA (e.g. `none`, `HS256`). |
|
|
166
|
+
| `unknown_key` | Signed by a key that isn't in `public_keys`. |
|
|
167
|
+
| `bad_signature` | The signature doesn't match: the file was altered or forged. |
|
|
168
|
+
| `wrong_type` | The token is not an offline license. |
|
|
169
|
+
| `wrong_product` | The license is for a different product. |
|
|
170
|
+
| `wrong_machine` | The license is for a different machine. |
|
|
171
|
+
| `expired` | Past its expiry, plus `clock_skew` (default 60 seconds, max 3600). |
|
|
172
|
+
|
|
173
|
+
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`.
|
|
174
|
+
|
|
175
|
+
**`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.
|
|
176
|
+
|
|
177
|
+
**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:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from datetime import datetime, timezone
|
|
181
|
+
|
|
182
|
+
now = max(datetime.now(timezone.utc), last_trusted_time)
|
|
183
|
+
offline = verify_offline_license(lic, public_keys=[PUBLIC_KEY], product_id=product_id,
|
|
184
|
+
machine_code=machine_code, now=now)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`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.
|
|
188
|
+
|
|
189
|
+
Offline verification uses the [`cryptography`](https://pypi.org/project/cryptography/) package, which `pip install keymint` installs (`cryptography>=41`).
|
|
190
|
+
|
|
114
191
|
## Machine Identity
|
|
115
192
|
Keymint provides utilities to uniquely identify machines for node-locking:
|
|
116
193
|
|
|
@@ -130,6 +207,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
|
|
|
130
207
|
| `update_key` | Updates an existing license key. |
|
|
131
208
|
| `block_key` | Blocks a license key. |
|
|
132
209
|
| `unblock_key` | Unblocks a previously blocked license key. |
|
|
210
|
+
| `delete_key` | Permanently deletes a license key. |
|
|
133
211
|
| `sign_key` | Signs a key for offline validation using an admin API key.|
|
|
134
212
|
| `floating_checkout` | Checks out a floating license seat. |
|
|
135
213
|
| `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
|
|
@@ -145,7 +223,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
|
|
|
145
223
|
| `get_customer_with_keys`| Gets a customer along with their license keys. |
|
|
146
224
|
| `update_customer` | Updates customer information. |
|
|
147
225
|
| `toggle_customer_status`| Toggles customer active status. |
|
|
148
|
-
| `delete_customer` |
|
|
226
|
+
| `delete_customer` | Deletes a customer. Their license keys are kept; use `delete_key` to remove them. |
|
|
149
227
|
|
|
150
228
|
### Webhook Verification
|
|
151
229
|
|
|
@@ -5,10 +5,12 @@ setup.py
|
|
|
5
5
|
keymint/__init__.py
|
|
6
6
|
keymint/_version.py
|
|
7
7
|
keymint/identity.py
|
|
8
|
+
keymint/offline.py
|
|
8
9
|
keymint/types.py
|
|
9
10
|
keymint.egg-info/PKG-INFO
|
|
10
11
|
keymint.egg-info/SOURCES.txt
|
|
11
12
|
keymint.egg-info/dependency_links.txt
|
|
12
13
|
keymint.egg-info/requires.txt
|
|
13
14
|
keymint.egg-info/top_level.txt
|
|
14
|
-
tests/test_contracts.py
|
|
15
|
+
tests/test_contracts.py
|
|
16
|
+
tests/test_offline.py
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
|
5
5
|
|
|
6
6
|
setup(
|
|
7
7
|
name="keymint",
|
|
8
|
-
version="2.
|
|
8
|
+
version="2.7.0",
|
|
9
9
|
author="KeyMint",
|
|
10
10
|
author_email="cliff@keymint.dev",
|
|
11
11
|
description="License key validation, activation, and management for Python. Supports node-locking, offline licensing, and hardware fingerprinting.",
|
|
@@ -35,6 +35,7 @@ setup(
|
|
|
35
35
|
python_requires='>=3.10',
|
|
36
36
|
install_requires=[
|
|
37
37
|
'requests>=2.32.4',
|
|
38
|
+
'cryptography>=41',
|
|
38
39
|
],
|
|
39
40
|
extras_require={
|
|
40
41
|
'dev': [
|
|
@@ -83,3 +83,44 @@ def test_floating_checkout_returns_expiration_date_separate_from_lease(mock_post
|
|
|
83
83
|
assert mock_post.call_args.args[0].endswith("/key/checkout")
|
|
84
84
|
assert result["expirationDate"] == expiration_date
|
|
85
85
|
assert result["expiresAt"] == "2026-10-26T12:05:00.000Z"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@patch("keymint.requests.delete")
|
|
89
|
+
def test_delete_key_sends_json_body_not_url(mock_delete):
|
|
90
|
+
mock_delete.return_value = response({"message": "Key deleted", "code": 0})
|
|
91
|
+
|
|
92
|
+
result = KeyMint("admin_test").delete_key({"productId": "product_123", "licenseKey": "secret/license+key"})
|
|
93
|
+
|
|
94
|
+
args, kwargs = mock_delete.call_args
|
|
95
|
+
url = args[0]
|
|
96
|
+
assert url == "https://api.keymint.dev/key"
|
|
97
|
+
assert kwargs["json"] == {"productId": "product_123", "licenseKey": "secret/license+key"}
|
|
98
|
+
assert kwargs["params"] is None
|
|
99
|
+
assert "secret" not in url and "license" not in url
|
|
100
|
+
assert "x-license-key" not in kwargs["headers"]
|
|
101
|
+
assert kwargs["headers"]["Authorization"] == "Bearer admin_test"
|
|
102
|
+
assert kwargs["headers"]["Content-Type"] == "application/json"
|
|
103
|
+
assert result == {"message": "Key deleted", "code": 0}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@patch("keymint.requests.delete")
|
|
107
|
+
def test_delete_key_raises_on_404(mock_delete):
|
|
108
|
+
mock_delete.return_value = response({"message": "Invalid license key", "code": 1}, 404)
|
|
109
|
+
|
|
110
|
+
with pytest.raises(KeyMintApiError, match="Invalid license key") as excinfo:
|
|
111
|
+
KeyMint("admin_test").delete_key({"productId": "product_123", "licenseKey": "license_123"})
|
|
112
|
+
|
|
113
|
+
assert excinfo.value.status == 404
|
|
114
|
+
assert excinfo.value.code == 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@patch("keymint.requests.delete")
|
|
118
|
+
def test_delete_customer_still_uses_query_params_and_no_body(mock_delete):
|
|
119
|
+
mock_delete.return_value = response({"action": "deleteCustomer", "status": True, "code": 0})
|
|
120
|
+
|
|
121
|
+
KeyMint("admin_test").delete_customer({"customerId": "cust_123"})
|
|
122
|
+
|
|
123
|
+
args, kwargs = mock_delete.call_args
|
|
124
|
+
assert args[0] == "https://api.keymint.dev/customer/by-id"
|
|
125
|
+
assert kwargs["params"] == {"customerId": "cust_123"}
|
|
126
|
+
assert set(kwargs) == {"params", "headers"}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
|
|
8
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
9
|
+
Encoding,
|
|
10
|
+
NoEncryption,
|
|
11
|
+
PrivateFormat,
|
|
12
|
+
PublicFormat,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from keymint import (
|
|
16
|
+
OfflineLicense,
|
|
17
|
+
OfflineLicenseError,
|
|
18
|
+
OfflineLicenseErrorCode,
|
|
19
|
+
verify_offline_license,
|
|
20
|
+
)
|
|
21
|
+
from keymint.offline import _key_id, _load_public_key
|
|
22
|
+
|
|
23
|
+
FIXTURES = json.loads(
|
|
24
|
+
(Path(__file__).parent / "fixtures" / "offline-license-fixtures.json").read_text(encoding="utf-8")
|
|
25
|
+
)
|
|
26
|
+
PUBLIC_KEYS = FIXTURES["publicKeys"]
|
|
27
|
+
VALID_CASE = FIXTURES["cases"][0]
|
|
28
|
+
PRODUCT_ID = VALID_CASE["options"]["productId"]
|
|
29
|
+
MACHINE_CODE = VALID_CASE["options"]["machineCode"]
|
|
30
|
+
NOW = datetime.fromtimestamp(VALID_CASE["options"]["now"], tz=timezone.utc)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def unix(value):
|
|
34
|
+
return None if value is None else value.timestamp()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verify_case(case):
|
|
38
|
+
options = case["options"]
|
|
39
|
+
kwargs = {
|
|
40
|
+
"public_keys": PUBLIC_KEYS,
|
|
41
|
+
"product_id": options["productId"],
|
|
42
|
+
"machine_code": options["machineCode"],
|
|
43
|
+
"now": datetime.fromtimestamp(options["now"], tz=timezone.utc),
|
|
44
|
+
}
|
|
45
|
+
if "clockSkew" in options:
|
|
46
|
+
kwargs["clock_skew"] = options["clockSkew"]
|
|
47
|
+
return verify_offline_license(case["input"], **kwargs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_fixture_file_has_all_cases():
|
|
51
|
+
assert len(FIXTURES["cases"]) == 34
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_key_ids_match_fixtures():
|
|
55
|
+
assert [_key_id(_load_public_key(pem, i)) for i, pem in enumerate(PUBLIC_KEYS)] == FIXTURES["keyIds"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize("case", FIXTURES["cases"], ids=[c["name"] for c in FIXTURES["cases"]])
|
|
59
|
+
def test_shared_fixture(case):
|
|
60
|
+
expect = case["expect"]
|
|
61
|
+
if expect["valid"]:
|
|
62
|
+
result = verify_case(case)
|
|
63
|
+
expected = expect["license"]
|
|
64
|
+
assert isinstance(result, OfflineLicense)
|
|
65
|
+
assert result.license_key == expected["licenseKey"]
|
|
66
|
+
assert result.product_id == expected["productId"]
|
|
67
|
+
assert result.machine_code == expected["machineCode"]
|
|
68
|
+
assert result.key_id == expected["keyId"]
|
|
69
|
+
assert unix(result.issued_at) == expected["issuedAt"]
|
|
70
|
+
assert unix(result.expires_at) == expected["expiresAt"]
|
|
71
|
+
assert result.expires_at.tzinfo is timezone.utc
|
|
72
|
+
else:
|
|
73
|
+
with pytest.raises(OfflineLicenseError) as excinfo:
|
|
74
|
+
verify_case(case)
|
|
75
|
+
assert excinfo.value.code == expect["error"]
|
|
76
|
+
assert isinstance(excinfo.value.code, OfflineLicenseErrorCode)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_errors_never_contain_the_license_key():
|
|
80
|
+
case = next(c for c in FIXTURES["cases"] if c["name"] == "wrong product")
|
|
81
|
+
with pytest.raises(OfflineLicenseError) as excinfo:
|
|
82
|
+
verify_case(case)
|
|
83
|
+
assert "KM-ABCD" not in str(excinfo.value)
|
|
84
|
+
assert "KM-ABCD" not in repr(excinfo.value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_repr_hides_the_license_key():
|
|
88
|
+
result = verify_case(VALID_CASE)
|
|
89
|
+
assert "KM-ABCD" not in repr(result)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_accepts_now_in_another_timezone():
|
|
93
|
+
from datetime import timedelta
|
|
94
|
+
|
|
95
|
+
tz = timezone(timedelta(hours=-5))
|
|
96
|
+
result = verify_offline_license(
|
|
97
|
+
VALID_CASE["input"], public_keys=PUBLIC_KEYS, product_id=PRODUCT_ID,
|
|
98
|
+
machine_code=MACHINE_CODE, now=NOW.astimezone(tz),
|
|
99
|
+
)
|
|
100
|
+
assert result.license_key == "KM-ABCD-1234-EFGH-5678"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─── Tokens signed here, for Python-specific edge cases ─────────────────
|
|
104
|
+
|
|
105
|
+
def b64url(data: bytes) -> str:
|
|
106
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sign(payload_json: str):
|
|
110
|
+
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
111
|
+
pem = private_key.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode()
|
|
112
|
+
header = json.dumps({"alg": "EdDSA", "kid": _key_id(private_key.public_key())})
|
|
113
|
+
signing_input = f"{b64url(header.encode())}.{b64url(payload_json.encode())}"
|
|
114
|
+
token = f"{signing_input}.{b64url(private_key.sign(signing_input.encode('ascii')))}"
|
|
115
|
+
return token, pem
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def claims(**overrides):
|
|
119
|
+
payload = {
|
|
120
|
+
"productId": PRODUCT_ID, "machineCode": MACHINE_CODE, "type": "offline",
|
|
121
|
+
"iat": 1799913600, "exp": 1802592000, "sub": "KM-TEST",
|
|
122
|
+
}
|
|
123
|
+
payload.update(overrides)
|
|
124
|
+
return json.dumps(payload)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def verify_signed(payload_json: str):
|
|
128
|
+
token, pem = sign(payload_json)
|
|
129
|
+
return verify_offline_license(
|
|
130
|
+
token, public_keys=[pem], product_id=PRODUCT_ID, machine_code=MACHINE_CODE, now=NOW,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.parametrize("payload_json", [
|
|
135
|
+
claims(exp=True),
|
|
136
|
+
claims(exp=None),
|
|
137
|
+
claims(exp="1802592000"),
|
|
138
|
+
claims(sub=""),
|
|
139
|
+
claims(sub=123),
|
|
140
|
+
claims().replace("1802592000", "NaN"),
|
|
141
|
+
claims().replace("1802592000", "Infinity"),
|
|
142
|
+
claims().replace("1802592000", "1e400"),
|
|
143
|
+
claims().replace("1802592000", "1" + "0" * 400),
|
|
144
|
+
], ids=["bool", "null", "string", "empty sub", "number sub", "NaN", "Infinity", "huge float", "huge int"])
|
|
145
|
+
def test_bad_exp_or_sub_is_malformed(payload_json):
|
|
146
|
+
with pytest.raises(OfflineLicenseError) as excinfo:
|
|
147
|
+
verify_signed(payload_json)
|
|
148
|
+
assert excinfo.value.code == "malformed"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_bool_iat_is_not_returned():
|
|
152
|
+
assert verify_signed(claims(iat=True)).issued_at is None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_missing_iat_is_none():
|
|
156
|
+
payload = json.loads(claims())
|
|
157
|
+
del payload["iat"]
|
|
158
|
+
assert verify_signed(json.dumps(payload)).issued_at is None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_float_exp_is_accepted():
|
|
162
|
+
result = verify_signed(claims(exp=1802592000.5))
|
|
163
|
+
assert unix(result.expires_at) == 1802592000.5
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_trailing_newline_in_a_segment_is_malformed():
|
|
167
|
+
token, pem = sign(claims())
|
|
168
|
+
header, payload, signature = token.split(".")
|
|
169
|
+
with pytest.raises(OfflineLicenseError) as excinfo:
|
|
170
|
+
verify_offline_license(
|
|
171
|
+
f"{header}\n.{payload}.{signature}", public_keys=[pem],
|
|
172
|
+
product_id=PRODUCT_ID, machine_code=MACHINE_CODE, now=NOW,
|
|
173
|
+
)
|
|
174
|
+
assert excinfo.value.code == "malformed"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pytest.mark.parametrize("value", [
|
|
178
|
+
'{"signedKey":' + "[" * 100_000,
|
|
179
|
+
b64url(b'{"a":' + b"[" * 100_000) + ".e30.",
|
|
180
|
+
], ids=[".lic", "token header"])
|
|
181
|
+
def test_deeply_nested_json_is_malformed(value):
|
|
182
|
+
with pytest.raises(OfflineLicenseError) as excinfo:
|
|
183
|
+
verify_offline_license(
|
|
184
|
+
value, public_keys=PUBLIC_KEYS, product_id=PRODUCT_ID, machine_code=MACHINE_CODE, now=NOW,
|
|
185
|
+
)
|
|
186
|
+
assert excinfo.value.code == "malformed"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ─── Argument errors ────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def rsa_public_pem():
|
|
192
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
193
|
+
return key.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def ed25519_private_pem():
|
|
197
|
+
key = ed25519.Ed25519PrivateKey.generate()
|
|
198
|
+
return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def call(**overrides):
|
|
202
|
+
kwargs = {
|
|
203
|
+
"public_keys": PUBLIC_KEYS, "product_id": PRODUCT_ID,
|
|
204
|
+
"machine_code": MACHINE_CODE, "now": NOW,
|
|
205
|
+
}
|
|
206
|
+
kwargs.update(overrides)
|
|
207
|
+
return verify_offline_license(VALID_CASE["input"], **kwargs)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@pytest.mark.parametrize("overrides", [
|
|
211
|
+
{"public_keys": []},
|
|
212
|
+
{"public_keys": ["not a pem"]},
|
|
213
|
+
{"public_keys": ["-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n"]},
|
|
214
|
+
{"public_keys": [rsa_public_pem()]},
|
|
215
|
+
{"public_keys": [ed25519_private_pem()]},
|
|
216
|
+
{"product_id": ""},
|
|
217
|
+
{"machine_code": ""},
|
|
218
|
+
{"clock_skew": -1},
|
|
219
|
+
{"clock_skew": 3601},
|
|
220
|
+
{"clock_skew": float("nan")},
|
|
221
|
+
{"now": datetime(2027, 1, 15, 8, 0, 0)},
|
|
222
|
+
], ids=[
|
|
223
|
+
"no keys", "garbage pem", "truncated pem", "rsa key", "private key",
|
|
224
|
+
"empty product", "empty machine", "negative skew", "skew over an hour", "nan skew", "naive now",
|
|
225
|
+
])
|
|
226
|
+
def test_bad_arguments_raise_value_error(overrides):
|
|
227
|
+
with pytest.raises(ValueError):
|
|
228
|
+
call(**overrides)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.parametrize("overrides", [
|
|
232
|
+
{"public_keys": PUBLIC_KEYS[0]},
|
|
233
|
+
{"public_keys": [b"bytes"]},
|
|
234
|
+
{"product_id": None},
|
|
235
|
+
{"machine_code": 1},
|
|
236
|
+
{"clock_skew": True},
|
|
237
|
+
{"clock_skew": "60"},
|
|
238
|
+
{"now": 1800000000},
|
|
239
|
+
], ids=["single string", "bytes key", "product none", "machine int", "bool skew", "string skew", "int now"])
|
|
240
|
+
def test_wrong_argument_types_raise_type_error(overrides):
|
|
241
|
+
with pytest.raises(TypeError):
|
|
242
|
+
call(**overrides)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_clock_skew_bounds_are_inclusive():
|
|
246
|
+
assert call(clock_skew=0).license_key
|
|
247
|
+
assert call(clock_skew=3600).license_key
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_rsa_key_error_names_ed25519():
|
|
251
|
+
with pytest.raises(ValueError, match="Ed25519"):
|
|
252
|
+
call(public_keys=[rsa_public_pem()])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|