keymint 2.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keymint
3
- Version: 2.5.0
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
@@ -87,6 +89,105 @@ if result and 'key' in result:
87
89
  print(f"Created Key: {result['key']}")
88
90
  ```
89
91
 
92
+ ## License Expiry
93
+
94
+ `activate_key` and `floating_checkout` return `expirationDate`: the license expiry as an ISO 8601 string (e.g. `"2027-03-01T00:00:00.000Z"`), or `None` when the license never expires. On `floating_checkout`, `expiresAt` is the seat lease, not the license expiry.
95
+
96
+ ```python
97
+ from datetime import datetime, timezone
98
+ import math
99
+
100
+ result = sdk.activate_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})
101
+
102
+ expiration = result.get('expirationDate')
103
+ if expiration is None:
104
+ print('License never expires')
105
+ else:
106
+ expires_at = datetime.fromisoformat(expiration.replace('Z', '+00:00'))
107
+ seconds_left = (expires_at - datetime.now(timezone.utc)).total_seconds()
108
+ days_left = max(0, math.ceil(seconds_left / 86400))
109
+ print(f'License expires {expires_at:%Y-%m-%d} ({days_left} days left)')
110
+
111
+ # Store the last value you received (e.g. in your app's settings file).
112
+ ```
113
+
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.
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
+
90
191
  ## Machine Identity
91
192
  Keymint provides utilities to uniquely identify machines for node-locking:
92
193
 
@@ -106,6 +207,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
106
207
  | `update_key` | Updates an existing license key. |
107
208
  | `block_key` | Blocks a license key. |
108
209
  | `unblock_key` | Unblocks a previously blocked license key. |
210
+ | `delete_key` | Permanently deletes a license key. |
109
211
  | `sign_key` | Signs a key for offline validation using an admin API key.|
110
212
  | `floating_checkout` | Checks out a floating license seat. |
111
213
  | `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
@@ -121,7 +223,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
121
223
  | `get_customer_with_keys`| Gets a customer along with their license keys. |
122
224
  | `update_customer` | Updates customer information. |
123
225
  | `toggle_customer_status`| Toggles customer active status. |
124
- | `delete_customer` | Permanently deletes a customer and their keys. |
226
+ | `delete_customer` | Deletes a customer. Their license keys are kept; use `delete_key` to remove them. |
125
227
 
126
228
  ### Webhook Verification
127
229
 
@@ -0,0 +1,202 @@
1
+ # Keymint Python
2
+
3
+ A professional, production-ready SDK for integrating with the Keymint API in Python. Provides robust access to all major Keymint features, with type hints and modern error handling.
4
+
5
+ ## Features
6
+ - **Type hints**: Full type hint support for better IDE integration and code safety.
7
+ - **Comprehensive**: Complete API coverage for all Keymint endpoints.
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`.
10
+ - **Machine Identity**: Built-in utilities for hardware fingerprinting and stable installation IDs.
11
+
12
+ ## Installation
13
+ Add the SDK to your project:
14
+
15
+ ```bash
16
+ pip install keymint
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ import os
23
+ from keymint import KeyMint, identity
24
+
25
+ api_key = os.environ.get('KEYMINT_API_KEY')
26
+ product_id = os.environ.get('KEYMINT_PRODUCT_ID')
27
+
28
+ if not api_key or not product_id:
29
+ raise ValueError('Please set the KEYMINT_API_KEY and KEYMINT_PRODUCT_ID environment variables.')
30
+
31
+ sdk = KeyMint(api_key)
32
+
33
+ # 1. Get a stable, unique ID for this machine
34
+ host_id = identity.get_or_create_installation_id()
35
+
36
+ # 2. Create a key authorized only for this machine
37
+ result = sdk.create_key({
38
+ 'productId': product_id,
39
+ 'allowedHosts': [host_id]
40
+ })
41
+
42
+ if result and 'key' in result:
43
+ print(f"Created Key: {result['key']}")
44
+ ```
45
+
46
+ ## License Expiry
47
+
48
+ `activate_key` and `floating_checkout` return `expirationDate`: the license expiry as an ISO 8601 string (e.g. `"2027-03-01T00:00:00.000Z"`), or `None` when the license never expires. On `floating_checkout`, `expiresAt` is the seat lease, not the license expiry.
49
+
50
+ ```python
51
+ from datetime import datetime, timezone
52
+ import math
53
+
54
+ result = sdk.activate_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})
55
+
56
+ expiration = result.get('expirationDate')
57
+ if expiration is None:
58
+ print('License never expires')
59
+ else:
60
+ expires_at = datetime.fromisoformat(expiration.replace('Z', '+00:00'))
61
+ seconds_left = (expires_at - datetime.now(timezone.utc)).total_seconds()
62
+ days_left = max(0, math.ceil(seconds_left / 86400))
63
+ print(f'License expires {expires_at:%Y-%m-%d} ({days_left} days left)')
64
+
65
+ # Store the last value you received (e.g. in your app's settings file).
66
+ ```
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.
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
+
145
+ ## Machine Identity
146
+ Keymint provides utilities to uniquely identify machines for node-locking:
147
+
148
+ - `identity.get_or_create_installation_id()`: **Recommended.** Generates a stable UUID anchored to hardware and persists it to `~/.keymint/installation-id`.
149
+ - `identity.get_machine_id()`: Generates a SHA-256 fingerprint based on BIOS UUID, OS machine ID, and MAC address.
150
+
151
+ ## API Methods
152
+
153
+ ### License Key Management
154
+
155
+ | Method | Description |
156
+ |------------------|-------------------------------------------------|
157
+ | `create_key` | Creates a new license key. |
158
+ | `activate_key` | Activates a license key for a device. |
159
+ | `deactivate_key` | Deactivates a device from a license key. |
160
+ | `get_key` | Retrieves detailed information about a key. |
161
+ | `update_key` | Updates an existing license key. |
162
+ | `block_key` | Blocks a license key. |
163
+ | `unblock_key` | Unblocks a previously blocked license key. |
164
+ | `delete_key` | Permanently deletes a license key. |
165
+ | `sign_key` | Signs a key for offline validation using an admin API key.|
166
+ | `floating_checkout` | Checks out a floating license seat. |
167
+ | `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
168
+ | `floating_checkin` | Checks in a session, releasing the seat. |
169
+
170
+ ### Customer Management
171
+
172
+ | Method | Description |
173
+ |-------------------------|--------------------------------------------------|
174
+ | `create_customer` | Creates a new customer. |
175
+ | `get_all_customers` | Retrieves all customers. |
176
+ | `get_customer_by_id` | Gets a specific customer by ID. |
177
+ | `get_customer_with_keys`| Gets a customer along with their license keys. |
178
+ | `update_customer` | Updates customer information. |
179
+ | `toggle_customer_status`| Toggles customer active status. |
180
+ | `delete_customer` | Deletes a customer. Their license keys are kept; use `delete_key` to remove them. |
181
+
182
+ ### Webhook Verification
183
+
184
+ | Method | Description |
185
+ |-------------------------|--------------------------------------------------|
186
+ | `verify_webhook_signature`| Verifies the signature of a webhook request payload. |
187
+
188
+ ## Idempotency
189
+
190
+ All mutating SDK methods support idempotency keys to safely retry requests in case of network drops. Pass a unique string (recommended: UUID v4) as the optional `idempotency_key` keyword argument:
191
+
192
+ ```python
193
+ result = sdk.create_key({
194
+ 'productId': product_id,
195
+ }, idempotency_key='9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d')
196
+ ```
197
+
198
+ ## License
199
+ MIT
200
+
201
+ ## Support
202
+ For help, see [Keymint API docs](https://docs.keymint.dev) or open an issue.
@@ -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__ = ['KeyMint', 'KeyMintApiError', '__version__']
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
- response = requests.delete(url, params=query_params, headers=headers)
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
 
@@ -79,7 +97,10 @@ class KeyMint:
79
97
 
80
98
  :param params: Activation parameters including productId, licenseKey, and optional hostId.
81
99
  :param idempotency_key: Optional unique identifier to ensure request idempotency.
82
- :return: Activation success message and licensee information.
100
+ :return: Activation success message and licensee information. Includes
101
+ ``expirationDate``: the license expiry as an ISO 8601 string, or ``None``
102
+ when the license never expires. An expired license fails activation with
103
+ the same generic invalid-key error, so store the last value you received.
83
104
  """
84
105
  return self._handle_request('POST', '/key/activate', params, idempotency_key=idempotency_key)
85
106
 
@@ -98,6 +119,8 @@ class KeyMint:
98
119
  :param params: Parameters for checking out the license.
99
120
  :param idempotency_key: Optional unique identifier to ensure request idempotency.
100
121
  :returns: The checkout response containing sessionId and sessionSecret.
122
+ ``expiresAt`` is the seat lease; ``expirationDate`` is the license expiry
123
+ as an ISO 8601 string, or ``None`` when the license never expires.
101
124
  """
102
125
  return self._handle_request('POST', '/key/checkout', params, idempotency_key=idempotency_key)
103
126
 
@@ -151,6 +174,16 @@ class KeyMint:
151
174
  """
152
175
  return self._handle_request('POST', '/key/unblock', params, idempotency_key=idempotency_key)
153
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
+
154
187
  def update_key(self, params: 'UpdateKeyParams', idempotency_key: str | None = None) -> 'UpdateKeyResponse':
155
188
  """
156
189
  Updates an existing license key.
@@ -210,7 +243,7 @@ class KeyMint:
210
243
 
211
244
  def delete_customer(self, params: DeleteCustomerParams, idempotency_key: str | None = None) -> DeleteCustomerResponse:
212
245
  """
213
- Permanently deletes a customer and all associated license keys.
246
+ Deletes a customer. Their license keys are kept; use delete_key to remove them.
214
247
  :param params: Parameters containing the customer ID.
215
248
  :param idempotency_key: Optional unique identifier to ensure request idempotency.
216
249
  :returns: The deletion confirmation.
@@ -1,6 +1,6 @@
1
1
  """KeyMint Python SDK version information."""
2
2
 
3
- __version__ = "2.5.0"
3
+ __version__ = "2.7.0"
4
4
  __author__ = "KeyMint"
5
5
  __email__ = "cliff@keymint.dev"
6
6
  __url__ = "https://github.com/keymint-dev/keymint-python"