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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keymint
3
- Version: 2.6.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
@@ -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` | Permanently deletes a customer and their keys. |
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` | Permanently deletes a customer and their keys. |
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__ = ['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
 
@@ -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
- Permanently deletes a customer and all associated license keys.
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.
@@ -1,6 +1,6 @@
1
1
  """KeyMint Python SDK version information."""
2
2
 
3
- __version__ = "2.6.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"
@@ -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.6.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
@@ -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` | Permanently deletes a customer and their keys. |
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
@@ -1,4 +1,5 @@
1
1
  requests>=2.32.4
2
+ cryptography>=41
2
3
 
3
4
  [dev]
4
5
  pytest>=6.0
@@ -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.6.0",
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