Skip to content

Commit f7869ff

Browse files
committed
chore: bump version to 2.2.0 and add verify_webhook_signature & floating licensing support
1 parent 09356d0 commit f7869ff

7 files changed

Lines changed: 197 additions & 4 deletions

File tree

.github/workflows/ci-cd.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Python SDK CI/CD
2+
3+
on:
4+
pull_request:
5+
branches: [ main, master ]
6+
push:
7+
tags:
8+
- 'v*'
9+
10+
jobs:
11+
validate:
12+
name: Build & Validate Python SDK
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
18+
- name: Setup Python
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: '3.12'
22+
23+
- name: Install dependencies & tools
24+
run: |
25+
python -m pip install --upgrade pip
26+
pip install ruff build
27+
28+
- name: Run Ruff (Linter & Formatter)
29+
run: ruff check .
30+
31+
- name: Verify Package Build
32+
run: python -m build
33+
34+
publish:
35+
name: Publish to PyPI
36+
needs: validate
37+
if: startsWith(github.ref, 'refs/tags/v')
38+
runs-on: ubuntu-latest
39+
environment: release
40+
permissions:
41+
id-token: write # Required for Trusted Publishing OIDC
42+
steps:
43+
- name: Checkout repository
44+
uses: actions/checkout@v4
45+
46+
- name: Setup Python
47+
uses: actions/setup-python@v5
48+
with:
49+
python-version: '3.12'
50+
51+
- name: Build distributions
52+
run: |
53+
python -m pip install --upgrade pip
54+
pip install build
55+
python -m build
56+
57+
- name: Publish package to PyPI
58+
uses: pypa/gh-action-pypi-publish@release/v1

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ Keymint provides utilities to uniquely identify machines for node-locking:
6060
| `get_key` | Retrieves detailed information about a key. |
6161
| `block_key` | Blocks a license key. |
6262
| `unblock_key` | Unblocks a previously blocked license key. |
63+
| `floating_checkout` | Checks out a floating license seat. |
64+
| `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
65+
| `floating_checkin` | Checks in a session, releasing the seat. |
6366

6467
### Customer Management
6568

@@ -73,6 +76,12 @@ Keymint provides utilities to uniquely identify machines for node-locking:
7376
| `toggle_customer_status`| Toggles customer active status. |
7477
| `delete_customer` | Permanently deletes a customer and their keys. |
7578

79+
### Webhook Verification
80+
81+
| Method | Description |
82+
|-------------------------|--------------------------------------------------|
83+
| `verify_webhook_signature`| Verifies the signature of a webhook request payload. |
84+
7685
## License
7786
MIT
7887

keymint/__init__.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,60 @@ def toggle_customer_status(self, params: ToggleCustomerStatusParams) -> ToggleCu
193193
query_params = {'customerId': params['customerId']}
194194
return self._handle_request('POST', '/customer/disable', params=None, query_params=query_params)
195195

196+
@staticmethod
197+
def verify_webhook_signature(payload: str, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
198+
"""
199+
Verifies a webhook payload signature received from Keymint.
200+
:param payload: The raw request body as a string.
201+
:param header: The value of the "Keymint-Signature" header.
202+
:param secret: The webhook endpoint's signing secret.
203+
:param tolerance_seconds: Time tolerance in seconds to prevent replay attacks. Defaults to 300 (5 minutes).
204+
:returns: True if the signature is valid, False otherwise.
205+
"""
206+
import hmac
207+
import hashlib
208+
import time
209+
210+
if not header or not secret:
211+
return False
212+
213+
try:
214+
# Parse header (e.g. t=1719374021,v1=signature)
215+
timestamp_str = ""
216+
signature = ""
217+
parts = header.split(",")
218+
for part in parts:
219+
kv = part.strip().split("=", 1)
220+
if len(kv) == 2:
221+
if kv[0] == "t":
222+
timestamp_str = kv[1]
223+
elif kv[0] == "v1":
224+
signature = kv[1]
225+
226+
if not timestamp_str or not signature:
227+
return False
228+
229+
# Check timestamp validity
230+
try:
231+
timestamp_int = int(timestamp_str)
232+
except ValueError:
233+
return False
234+
235+
now = int(time.time())
236+
if abs(now - timestamp_int) > tolerance_seconds:
237+
return False
238+
239+
# Verify HMAC signature
240+
signable_content = f"{timestamp_str}.{payload}".encode("utf-8")
241+
expected_signature = hmac.new(
242+
secret.encode("utf-8"),
243+
signable_content,
244+
hashlib.sha256
245+
).hexdigest()
246+
247+
# Constant-time comparison to prevent timing attacks
248+
return hmac.compare_digest(expected_signature, signature)
249+
except Exception:
250+
return False
251+
252+

keymint/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""KeyMint Python SDK version information."""
22

3-
__version__ = "2.1.1"
3+
__version__ = "2.2.0"
44
__author__ = "KeyMint"
55
__email__ = "cliff@keymint.dev"
66
__url__ = "https://github.com/keymint-dev/keymint-python"

keymint/identity.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import hashlib
10+
import hmac
1011
import os
1112
import platform
1213
import subprocess
@@ -195,7 +196,27 @@ def get_or_create_installation_id(storage_path: Optional[str] = None) -> str:
195196
composite_id = f'{new_uuid}:{hardware_anchor}:{int(time.time() * 1000)}'
196197

197198
# 3. Persist it
198-
file_path.parent.mkdir(parents=True, exist_ok=True)
199-
file_path.write_text(composite_id, encoding='utf-8')
199+
try:
200+
file_path.parent.mkdir(parents=True, exist_ok=True)
201+
file_path.write_text(composite_id, encoding='utf-8')
202+
except Exception:
203+
pass # Silently fallback to in-memory ID if filesystem is read-only
200204

201205
return hashlib.sha256(composite_id.encode('utf-8')).hexdigest()
206+
207+
def generate_session_signature(session_id: str, nonce: str, session_secret: str) -> str:
208+
"""
209+
Generates a cryptographic signature for a heartbeat or checkin request
210+
using the session_secret and the rotating nextNonce (passed as the timestamp).
211+
212+
Args:
213+
session_id: The 22-character unique session ID.
214+
nonce: The rotating nonce string (nextNonce) received from the previous response.
215+
session_secret: The temporary session secret key received during checkout.
216+
217+
Returns:
218+
A 64-character hexadecimal signature string.
219+
"""
220+
key_bytes = session_secret.encode('utf-8')
221+
msg_bytes = f"{session_id}:{nonce}".encode('utf-8')
222+
return hmac.new(key_bytes, msg_bytes, hashlib.sha256).hexdigest()

keymint/types.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,51 @@ class GetCustomerWithKeysResponse(TypedDict):
187187
status: bool
188188
data: Dict[str, Any] # Contains customer and licenseKeys
189189
code: int
190+
191+
class FloatingCheckoutParams(TypedDict):
192+
productId: str
193+
licenseKey: str
194+
hostId: str
195+
deviceTag: Optional[str]
196+
userIdentifier: Optional[str]
197+
apiKey: Optional[str]
198+
199+
class FloatingCheckoutResponse(TypedDict):
200+
code: int
201+
message: str
202+
sessionId: str
203+
sessionSecret: str
204+
nextNonce: str
205+
expiresAt: str
206+
heartbeatInterval: int
207+
metadata: Optional[Dict[str, Any]]
208+
currentSessions: Optional[int]
209+
maxSessions: Optional[int]
210+
licenseeName: Optional[str]
211+
licenseeEmail: Optional[str]
212+
213+
class FloatingHeartbeatParams(TypedDict):
214+
productId: str
215+
licenseKey: str
216+
sessionId: str
217+
timestamp: Any # rotating nonce (nextNonce) received from previous response
218+
signature: str
219+
apiKey: Optional[str]
220+
221+
class FloatingHeartbeatResponse(TypedDict):
222+
code: int
223+
message: str
224+
expiresAt: str
225+
nextNonce: str
226+
227+
class FloatingCheckinParams(TypedDict):
228+
productId: str
229+
licenseKey: str
230+
sessionId: str
231+
timestamp: Any # rotating nonce (nextNonce) received from previous response
232+
signature: str
233+
apiKey: Optional[str]
234+
235+
class FloatingCheckinResponse(TypedDict):
236+
code: int
237+
message: str

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setup(
77
name="keymint",
8-
version="2.1.1",
8+
version="2.2.0",
99
author="KeyMint",
1010
author_email="cliff@keymint.dev",
1111
description="Official Python SDK for KeyMint license management with comprehensive API coverage.",

0 commit comments

Comments
 (0)