keywarden 1.0.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.
- keywarden-1.0.0/LICENSE +21 -0
- keywarden-1.0.0/PKG-INFO +123 -0
- keywarden-1.0.0/README.md +100 -0
- keywarden-1.0.0/keywarden/__init__.py +232 -0
- keywarden-1.0.0/keywarden.egg-info/PKG-INFO +123 -0
- keywarden-1.0.0/keywarden.egg-info/SOURCES.txt +9 -0
- keywarden-1.0.0/keywarden.egg-info/dependency_links.txt +1 -0
- keywarden-1.0.0/keywarden.egg-info/requires.txt +1 -0
- keywarden-1.0.0/keywarden.egg-info/top_level.txt +1 -0
- keywarden-1.0.0/pyproject.toml +34 -0
- keywarden-1.0.0/setup.cfg +4 -0
keywarden-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Key-Warden
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
keywarden-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keywarden
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for Key-Warden - validate software licences online (seat- and revocation-aware) or verify signed tokens offline against your embedded public key.
|
|
5
|
+
Author: Key-Warden
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://key-warden.com
|
|
8
|
+
Project-URL: Documentation, https://key-warden.com/docs
|
|
9
|
+
Project-URL: Issues, https://key-warden.com/contact
|
|
10
|
+
Keywords: key-warden,keywarden,licence,license,licensing,activation,software-licensing,ed25519,offline-verification
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Security :: Cryptography
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: cryptography>=3.4
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# keywarden
|
|
25
|
+
|
|
26
|
+
The official Python client for [Key-Warden](https://key-warden.com). Validate a
|
|
27
|
+
software licence online — seat-aware, revocation-aware — or verify a signed token
|
|
28
|
+
offline against your embedded public key, with no network round-trip.
|
|
29
|
+
|
|
30
|
+
One dependency: [`cryptography`](https://pypi.org/project/cryptography/) (for
|
|
31
|
+
Ed25519). Networking is stdlib `urllib`. Python 3.8+.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install keywarden
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Validate online
|
|
38
|
+
|
|
39
|
+
The authoritative check. Ask the platform whether a licence is good *right now*.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os, socket
|
|
43
|
+
import keywarden as kw
|
|
44
|
+
|
|
45
|
+
res = kw.validate(
|
|
46
|
+
customer_licence_key,
|
|
47
|
+
apim_key=os.environ["KW_APIM_KEY"], # your APIM subscription key
|
|
48
|
+
client_key=os.environ["KW_CLIENT_KEY"], # your validation key
|
|
49
|
+
machine_id=kw.machine_id_from(socket.gethostname(), user_id), # stable, hashed your side
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if not res["valid"]:
|
|
53
|
+
raise SystemExit(f"licence not valid: {res.get('reason')}")
|
|
54
|
+
# res["token"] is a freshly signed proof — cache it for the offline path below.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A `valid == False` (e.g. `revoked`, `expired`, `seat_limit_exceeded`) is **data**,
|
|
58
|
+
not an error. A wrong `client_key` raises a `KeyWardenError` with
|
|
59
|
+
`code == "unauthorized_client"` — that's *your* auth failing, and your customer
|
|
60
|
+
should never see it as a licence problem.
|
|
61
|
+
|
|
62
|
+
## Verify offline
|
|
63
|
+
|
|
64
|
+
No connection? Verify a token you already hold against your **public** key —
|
|
65
|
+
the 32-byte raw key from your vendor console. Pure, no network.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
check = kw.verify_token(cached_token, os.environ["KW_PUBLIC_KEY"])
|
|
69
|
+
if not check["valid"]:
|
|
70
|
+
lock_features(check["reason"]) # "bad_signature" | "expired" | ...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The token is `header.body.signature` (compact JWT style) and the Ed25519
|
|
74
|
+
signature covers the exact bytes `header.body`. This client verifies over those
|
|
75
|
+
bytes — it never decodes-then-reverifies, which is the one mistake that silently
|
|
76
|
+
breaks offline checks. Expiry is honoured within the offline grace window you set
|
|
77
|
+
at mint time.
|
|
78
|
+
|
|
79
|
+
## Online, with an offline fallback
|
|
80
|
+
|
|
81
|
+
The pattern most desktop apps want: online is authoritative; if the network is
|
|
82
|
+
down, keep working within grace.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
res = kw.validate_or_verify(
|
|
86
|
+
customer_licence_key,
|
|
87
|
+
apim_key=apim_key, client_key=client_key, machine_id=machine_id,
|
|
88
|
+
cached_token=last_good_token, # from a previous validate()
|
|
89
|
+
public_key=os.environ["KW_PUBLIC_KEY"],
|
|
90
|
+
)
|
|
91
|
+
# res["source"] == "online" | "offline"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A rejected `client_key` (401) is never masked by the offline path — only a genuine
|
|
95
|
+
reachability failure falls back.
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
| Function | Purpose |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `validate(key, *, apim_key, client_key, ...)` | Online check. Returns `{"valid", "reason"?, "activeSeats"?, "token"?}`. |
|
|
102
|
+
| `verify_token(token, raw_pub_b64, *, now=None)` | Offline check. Returns `{"valid", "reason"?, "claims"?}`. |
|
|
103
|
+
| `validate_or_verify(key, *, cached_token, public_key, ...)` | Online, falling back to a cached token when unreachable. |
|
|
104
|
+
| `machine_id_from(*parts)` | A stable SHA-256 machine id; raw parts never leave the machine. |
|
|
105
|
+
|
|
106
|
+
Any real failure (bad credentials, unreachable gateway, server error) raises
|
|
107
|
+
`KeyWardenError`, which carries `.code` and `.status`.
|
|
108
|
+
|
|
109
|
+
## Security notes
|
|
110
|
+
|
|
111
|
+
- Your **private** signing key never leaves Key-Warden's Key Vault. You embed
|
|
112
|
+
only the 32-byte public half.
|
|
113
|
+
- `machine_id` is hashed by the platform, but send an opaque, stable id — not a
|
|
114
|
+
raw MAC address or a hostname you wouldn't want logged. `machine_id_from()`
|
|
115
|
+
hashes on your side too.
|
|
116
|
+
- Two independent credentials gate every online call: the APIM subscription key
|
|
117
|
+
gets you to the gateway, the validation key authenticates you as the vendor. A
|
|
118
|
+
leaked validation key can be rotated without reissuing a single customer
|
|
119
|
+
licence.
|
|
120
|
+
|
|
121
|
+
## Licence
|
|
122
|
+
|
|
123
|
+
MIT.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# keywarden
|
|
2
|
+
|
|
3
|
+
The official Python client for [Key-Warden](https://key-warden.com). Validate a
|
|
4
|
+
software licence online — seat-aware, revocation-aware — or verify a signed token
|
|
5
|
+
offline against your embedded public key, with no network round-trip.
|
|
6
|
+
|
|
7
|
+
One dependency: [`cryptography`](https://pypi.org/project/cryptography/) (for
|
|
8
|
+
Ed25519). Networking is stdlib `urllib`. Python 3.8+.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install keywarden
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Validate online
|
|
15
|
+
|
|
16
|
+
The authoritative check. Ask the platform whether a licence is good *right now*.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import os, socket
|
|
20
|
+
import keywarden as kw
|
|
21
|
+
|
|
22
|
+
res = kw.validate(
|
|
23
|
+
customer_licence_key,
|
|
24
|
+
apim_key=os.environ["KW_APIM_KEY"], # your APIM subscription key
|
|
25
|
+
client_key=os.environ["KW_CLIENT_KEY"], # your validation key
|
|
26
|
+
machine_id=kw.machine_id_from(socket.gethostname(), user_id), # stable, hashed your side
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if not res["valid"]:
|
|
30
|
+
raise SystemExit(f"licence not valid: {res.get('reason')}")
|
|
31
|
+
# res["token"] is a freshly signed proof — cache it for the offline path below.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A `valid == False` (e.g. `revoked`, `expired`, `seat_limit_exceeded`) is **data**,
|
|
35
|
+
not an error. A wrong `client_key` raises a `KeyWardenError` with
|
|
36
|
+
`code == "unauthorized_client"` — that's *your* auth failing, and your customer
|
|
37
|
+
should never see it as a licence problem.
|
|
38
|
+
|
|
39
|
+
## Verify offline
|
|
40
|
+
|
|
41
|
+
No connection? Verify a token you already hold against your **public** key —
|
|
42
|
+
the 32-byte raw key from your vendor console. Pure, no network.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
check = kw.verify_token(cached_token, os.environ["KW_PUBLIC_KEY"])
|
|
46
|
+
if not check["valid"]:
|
|
47
|
+
lock_features(check["reason"]) # "bad_signature" | "expired" | ...
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The token is `header.body.signature` (compact JWT style) and the Ed25519
|
|
51
|
+
signature covers the exact bytes `header.body`. This client verifies over those
|
|
52
|
+
bytes — it never decodes-then-reverifies, which is the one mistake that silently
|
|
53
|
+
breaks offline checks. Expiry is honoured within the offline grace window you set
|
|
54
|
+
at mint time.
|
|
55
|
+
|
|
56
|
+
## Online, with an offline fallback
|
|
57
|
+
|
|
58
|
+
The pattern most desktop apps want: online is authoritative; if the network is
|
|
59
|
+
down, keep working within grace.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
res = kw.validate_or_verify(
|
|
63
|
+
customer_licence_key,
|
|
64
|
+
apim_key=apim_key, client_key=client_key, machine_id=machine_id,
|
|
65
|
+
cached_token=last_good_token, # from a previous validate()
|
|
66
|
+
public_key=os.environ["KW_PUBLIC_KEY"],
|
|
67
|
+
)
|
|
68
|
+
# res["source"] == "online" | "offline"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A rejected `client_key` (401) is never masked by the offline path — only a genuine
|
|
72
|
+
reachability failure falls back.
|
|
73
|
+
|
|
74
|
+
## API
|
|
75
|
+
|
|
76
|
+
| Function | Purpose |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `validate(key, *, apim_key, client_key, ...)` | Online check. Returns `{"valid", "reason"?, "activeSeats"?, "token"?}`. |
|
|
79
|
+
| `verify_token(token, raw_pub_b64, *, now=None)` | Offline check. Returns `{"valid", "reason"?, "claims"?}`. |
|
|
80
|
+
| `validate_or_verify(key, *, cached_token, public_key, ...)` | Online, falling back to a cached token when unreachable. |
|
|
81
|
+
| `machine_id_from(*parts)` | A stable SHA-256 machine id; raw parts never leave the machine. |
|
|
82
|
+
|
|
83
|
+
Any real failure (bad credentials, unreachable gateway, server error) raises
|
|
84
|
+
`KeyWardenError`, which carries `.code` and `.status`.
|
|
85
|
+
|
|
86
|
+
## Security notes
|
|
87
|
+
|
|
88
|
+
- Your **private** signing key never leaves Key-Warden's Key Vault. You embed
|
|
89
|
+
only the 32-byte public half.
|
|
90
|
+
- `machine_id` is hashed by the platform, but send an opaque, stable id — not a
|
|
91
|
+
raw MAC address or a hostname you wouldn't want logged. `machine_id_from()`
|
|
92
|
+
hashes on your side too.
|
|
93
|
+
- Two independent credentials gate every online call: the APIM subscription key
|
|
94
|
+
gets you to the gateway, the validation key authenticates you as the vendor. A
|
|
95
|
+
leaked validation key can be rotated without reissuing a single customer
|
|
96
|
+
licence.
|
|
97
|
+
|
|
98
|
+
## Licence
|
|
99
|
+
|
|
100
|
+
MIT.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
keywarden - the official Python client for Key-Warden.
|
|
3
|
+
|
|
4
|
+
Two things, and only two, because that is all a vendor needs:
|
|
5
|
+
|
|
6
|
+
validate(key, ...) the ONLINE check. Asks the platform whether a
|
|
7
|
+
licence is good right now: seat-aware,
|
|
8
|
+
revocation-aware, expiry-aware. Returns a fresh
|
|
9
|
+
signed token you can cache.
|
|
10
|
+
|
|
11
|
+
verify_token(token, pub) the OFFLINE check. Verifies a token you already
|
|
12
|
+
hold against your embedded public key, with no
|
|
13
|
+
network round trip at all.
|
|
14
|
+
|
|
15
|
+
The token is a compact JWT-style envelope, ``header.body.signature``, each part
|
|
16
|
+
base64url, the Ed25519 signature computed over the exact bytes ``header.body``.
|
|
17
|
+
This client verifies over those exact bytes - it does NOT decode-then-reverify,
|
|
18
|
+
because the one thing everyone gets wrong is signing the decoded JSON instead of
|
|
19
|
+
the encoded string.
|
|
20
|
+
|
|
21
|
+
Only dependency: ``cryptography`` (for Ed25519). Networking is stdlib urllib.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import time
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
from typing import Any, Callable, Dict, Optional
|
|
33
|
+
|
|
34
|
+
from cryptography.exceptions import InvalidSignature
|
|
35
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"validate",
|
|
39
|
+
"verify_token",
|
|
40
|
+
"validate_or_verify",
|
|
41
|
+
"machine_id_from",
|
|
42
|
+
"KeyWardenError",
|
|
43
|
+
"DEFAULT_BASE",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
__version__ = "1.0.0"
|
|
47
|
+
|
|
48
|
+
DEFAULT_BASE = "https://api.key-warden.com"
|
|
49
|
+
_VALIDATE_PATH = "/keywarden/validate"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class KeyWardenError(Exception):
|
|
53
|
+
"""A problem with the request or the vendor's own auth - NOT a licence verdict.
|
|
54
|
+
|
|
55
|
+
A licence that is simply not valid comes back as ``{"valid": False, ...}``;
|
|
56
|
+
only real failures (bad credentials, unreachable gateway, server error) raise.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, code: str, status: Optional[int] = None):
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.code = code
|
|
62
|
+
self.status = status
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _b64url_to_bytes(s: str) -> bytes:
|
|
66
|
+
s = str(s).replace("-", "+").replace("_", "/")
|
|
67
|
+
return base64.b64decode(s + "=" * (-len(s) % 4))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def verify_token(token: str, raw_pub_b64: str, *, now: Optional[int] = None) -> Dict[str, Any]:
|
|
71
|
+
"""OFFLINE verification. Pure, no network.
|
|
72
|
+
|
|
73
|
+
:param token: ``header.body.signature`` as returned by :func:`validate`.
|
|
74
|
+
:param raw_pub_b64: your vendor public key, the 32 raw bytes, base64.
|
|
75
|
+
:param now: unix seconds; for testing. Defaults to real time.
|
|
76
|
+
:returns: ``{"valid": bool, "reason": str?, "claims": dict?}``
|
|
77
|
+
"""
|
|
78
|
+
parts = str(token or "").split(".")
|
|
79
|
+
if len(parts) != 3:
|
|
80
|
+
return {"valid": False, "reason": "malformed_token"}
|
|
81
|
+
header, body, sig = parts
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
raw = base64.b64decode(str(raw_pub_b64 or ""))
|
|
85
|
+
except Exception:
|
|
86
|
+
return {"valid": False, "reason": "bad_public_key"}
|
|
87
|
+
if len(raw) != 32:
|
|
88
|
+
# The vendor console hands out the raw 32-byte key; a PEM or an SPKI blob
|
|
89
|
+
# will be the wrong length, and that is a configuration mistake worth
|
|
90
|
+
# naming rather than a silent failure.
|
|
91
|
+
return {"valid": False, "reason": "bad_public_key"}
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
pub = Ed25519PublicKey.from_public_bytes(raw)
|
|
95
|
+
except Exception:
|
|
96
|
+
return {"valid": False, "reason": "bad_public_key"}
|
|
97
|
+
|
|
98
|
+
# The signature covers the STRING "header.body" exactly as transmitted -
|
|
99
|
+
# not the decoded claims. Verify over those bytes and nothing else.
|
|
100
|
+
try:
|
|
101
|
+
pub.verify(_b64url_to_bytes(sig), f"{header}.{body}".encode("ascii"))
|
|
102
|
+
except (InvalidSignature, Exception):
|
|
103
|
+
return {"valid": False, "reason": "bad_signature"}
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
claims = json.loads(_b64url_to_bytes(body).decode("utf-8"))
|
|
107
|
+
except Exception:
|
|
108
|
+
return {"valid": False, "reason": "bad_claims"}
|
|
109
|
+
|
|
110
|
+
# Expiry is checked WITHIN the offline grace window set at mint time.
|
|
111
|
+
at = int(now) if now is not None else int(time.time())
|
|
112
|
+
grace_days = float(claims.get("grace_seconds", 0)) / 86400.0
|
|
113
|
+
exp = claims.get("exp")
|
|
114
|
+
if exp and at > int(exp) + grace_days * 86400:
|
|
115
|
+
return {"valid": False, "reason": "expired", "claims": claims}
|
|
116
|
+
|
|
117
|
+
# A signed NEGATIVE (valid:false) is a proof of revocation and is honoured.
|
|
118
|
+
if claims.get("valid") is False:
|
|
119
|
+
return {"valid": False, "reason": claims.get("reason", "not_valid"), "claims": claims}
|
|
120
|
+
|
|
121
|
+
return {"valid": True, "claims": claims}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate(
|
|
125
|
+
key: str,
|
|
126
|
+
*,
|
|
127
|
+
apim_key: Optional[str] = None,
|
|
128
|
+
client_key: Optional[str] = None,
|
|
129
|
+
machine_id: Optional[str] = None,
|
|
130
|
+
base_url: str = DEFAULT_BASE,
|
|
131
|
+
timeout: float = 15.0,
|
|
132
|
+
opener: Optional[Callable[..., Any]] = None,
|
|
133
|
+
) -> Dict[str, Any]:
|
|
134
|
+
"""ONLINE validation against the gateway.
|
|
135
|
+
|
|
136
|
+
:param key: the customer's activation key.
|
|
137
|
+
:param apim_key: your APIM subscription key (gets you to the gateway).
|
|
138
|
+
:param client_key: your validation key (authenticates you as the vendor).
|
|
139
|
+
:param machine_id: a stable per-install id; required for node-locked
|
|
140
|
+
licences. Only a one-way hash ever leaves this machine.
|
|
141
|
+
:param opener: an injectable ``urlopen``-compatible callable, for tests.
|
|
142
|
+
:returns: the gateway JSON, e.g. ``{"valid": True, "activeSeats": 4, "token": "..."}``.
|
|
143
|
+
:raises KeyWardenError: on missing credentials, a rejected validation key
|
|
144
|
+
(401), an unreachable gateway, or a server error.
|
|
145
|
+
"""
|
|
146
|
+
if not key or not isinstance(key, str):
|
|
147
|
+
raise KeyWardenError("validate(key, ...): key must be a non-empty string", "missing_key")
|
|
148
|
+
if not apim_key:
|
|
149
|
+
raise KeyWardenError("apim_key is required (your APIM subscription key)", "missing_apim_key")
|
|
150
|
+
if not client_key:
|
|
151
|
+
raise KeyWardenError("client_key is required (your validation key)", "missing_client_key")
|
|
152
|
+
|
|
153
|
+
url = base_url.rstrip("/") + _VALIDATE_PATH
|
|
154
|
+
payload = {"key": key, "machineId": machine_id} if machine_id else {"key": key}
|
|
155
|
+
data = json.dumps(payload).encode("utf-8")
|
|
156
|
+
req = urllib.request.Request(url, data=data, method="POST")
|
|
157
|
+
req.add_header("Content-Type", "application/json")
|
|
158
|
+
req.add_header("Ocp-Apim-Subscription-Key", apim_key)
|
|
159
|
+
req.add_header("X-Client-Key", client_key)
|
|
160
|
+
# The raw machine id never leaves the caller untouched-but-unhashed by the
|
|
161
|
+
# platform; sending an opaque, stable id (not a MAC address) is the caller's
|
|
162
|
+
# job. machine_id_from() helps.
|
|
163
|
+
if machine_id:
|
|
164
|
+
req.add_header("X-Machine-Id", str(machine_id))
|
|
165
|
+
|
|
166
|
+
_open = opener or urllib.request.urlopen
|
|
167
|
+
try:
|
|
168
|
+
resp = _open(req, timeout=timeout)
|
|
169
|
+
status = getattr(resp, "status", None) or resp.getcode()
|
|
170
|
+
raw = resp.read()
|
|
171
|
+
except urllib.error.HTTPError as e: # noqa: PERF203 - explicit branches read better
|
|
172
|
+
status = e.code
|
|
173
|
+
raw = e.read()
|
|
174
|
+
except urllib.error.URLError as e:
|
|
175
|
+
raise KeyWardenError(f"could not reach the gateway: {e.reason}", "unreachable") from e
|
|
176
|
+
except TimeoutError as e:
|
|
177
|
+
raise KeyWardenError(f"validation timed out after {timeout}s", "timeout") from e
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
body = json.loads(raw.decode("utf-8")) if raw else None
|
|
181
|
+
except Exception:
|
|
182
|
+
body = None
|
|
183
|
+
|
|
184
|
+
if status == 401:
|
|
185
|
+
raise KeyWardenError(
|
|
186
|
+
(body or {}).get("reason", "your validation key was rejected"),
|
|
187
|
+
(body or {}).get("error", "unauthorized_client"),
|
|
188
|
+
401,
|
|
189
|
+
)
|
|
190
|
+
if status is None or status >= 500 or body is None:
|
|
191
|
+
raise KeyWardenError(
|
|
192
|
+
(body or {}).get("error", f"gateway error ({status})") if body else f"gateway error ({status})",
|
|
193
|
+
(body or {}).get("error", "validation_unavailable") if body else "validation_unavailable",
|
|
194
|
+
status,
|
|
195
|
+
)
|
|
196
|
+
# 200 with {valid} is the licence verdict - true or false, both normal.
|
|
197
|
+
return body
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def validate_or_verify(
|
|
201
|
+
key: str,
|
|
202
|
+
*,
|
|
203
|
+
cached_token: Optional[str] = None,
|
|
204
|
+
public_key: Optional[str] = None,
|
|
205
|
+
**kwargs: Any,
|
|
206
|
+
) -> Dict[str, Any]:
|
|
207
|
+
"""Validate online; on an unreachable gateway, fall back to a cached token.
|
|
208
|
+
|
|
209
|
+
A rejected validation key (401) is never masked by the offline path - only a
|
|
210
|
+
genuine reachability failure falls back.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
online = validate(key, **kwargs)
|
|
214
|
+
return {**online, "source": "online"}
|
|
215
|
+
except KeyWardenError as e:
|
|
216
|
+
if e.code not in ("unreachable", "timeout"):
|
|
217
|
+
raise
|
|
218
|
+
if not cached_token or not public_key:
|
|
219
|
+
raise
|
|
220
|
+
off = verify_token(cached_token, public_key)
|
|
221
|
+
return {**off, "source": "offline"}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def machine_id_from(*parts: str) -> str:
|
|
225
|
+
"""A stable, privacy-preserving machine id: SHA-256 over the parts you provide.
|
|
226
|
+
|
|
227
|
+
The raw parts never leave the machine - only their hash.
|
|
228
|
+
"""
|
|
229
|
+
joined = "|".join(str(p) for p in parts if p)
|
|
230
|
+
if not joined:
|
|
231
|
+
raise KeyWardenError("machine_id_from() needs at least one non-empty part", "empty")
|
|
232
|
+
return hashlib.sha256(joined.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keywarden
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for Key-Warden - validate software licences online (seat- and revocation-aware) or verify signed tokens offline against your embedded public key.
|
|
5
|
+
Author: Key-Warden
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://key-warden.com
|
|
8
|
+
Project-URL: Documentation, https://key-warden.com/docs
|
|
9
|
+
Project-URL: Issues, https://key-warden.com/contact
|
|
10
|
+
Keywords: key-warden,keywarden,licence,license,licensing,activation,software-licensing,ed25519,offline-verification
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Security :: Cryptography
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: cryptography>=3.4
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# keywarden
|
|
25
|
+
|
|
26
|
+
The official Python client for [Key-Warden](https://key-warden.com). Validate a
|
|
27
|
+
software licence online — seat-aware, revocation-aware — or verify a signed token
|
|
28
|
+
offline against your embedded public key, with no network round-trip.
|
|
29
|
+
|
|
30
|
+
One dependency: [`cryptography`](https://pypi.org/project/cryptography/) (for
|
|
31
|
+
Ed25519). Networking is stdlib `urllib`. Python 3.8+.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install keywarden
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Validate online
|
|
38
|
+
|
|
39
|
+
The authoritative check. Ask the platform whether a licence is good *right now*.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os, socket
|
|
43
|
+
import keywarden as kw
|
|
44
|
+
|
|
45
|
+
res = kw.validate(
|
|
46
|
+
customer_licence_key,
|
|
47
|
+
apim_key=os.environ["KW_APIM_KEY"], # your APIM subscription key
|
|
48
|
+
client_key=os.environ["KW_CLIENT_KEY"], # your validation key
|
|
49
|
+
machine_id=kw.machine_id_from(socket.gethostname(), user_id), # stable, hashed your side
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if not res["valid"]:
|
|
53
|
+
raise SystemExit(f"licence not valid: {res.get('reason')}")
|
|
54
|
+
# res["token"] is a freshly signed proof — cache it for the offline path below.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A `valid == False` (e.g. `revoked`, `expired`, `seat_limit_exceeded`) is **data**,
|
|
58
|
+
not an error. A wrong `client_key` raises a `KeyWardenError` with
|
|
59
|
+
`code == "unauthorized_client"` — that's *your* auth failing, and your customer
|
|
60
|
+
should never see it as a licence problem.
|
|
61
|
+
|
|
62
|
+
## Verify offline
|
|
63
|
+
|
|
64
|
+
No connection? Verify a token you already hold against your **public** key —
|
|
65
|
+
the 32-byte raw key from your vendor console. Pure, no network.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
check = kw.verify_token(cached_token, os.environ["KW_PUBLIC_KEY"])
|
|
69
|
+
if not check["valid"]:
|
|
70
|
+
lock_features(check["reason"]) # "bad_signature" | "expired" | ...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The token is `header.body.signature` (compact JWT style) and the Ed25519
|
|
74
|
+
signature covers the exact bytes `header.body`. This client verifies over those
|
|
75
|
+
bytes — it never decodes-then-reverifies, which is the one mistake that silently
|
|
76
|
+
breaks offline checks. Expiry is honoured within the offline grace window you set
|
|
77
|
+
at mint time.
|
|
78
|
+
|
|
79
|
+
## Online, with an offline fallback
|
|
80
|
+
|
|
81
|
+
The pattern most desktop apps want: online is authoritative; if the network is
|
|
82
|
+
down, keep working within grace.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
res = kw.validate_or_verify(
|
|
86
|
+
customer_licence_key,
|
|
87
|
+
apim_key=apim_key, client_key=client_key, machine_id=machine_id,
|
|
88
|
+
cached_token=last_good_token, # from a previous validate()
|
|
89
|
+
public_key=os.environ["KW_PUBLIC_KEY"],
|
|
90
|
+
)
|
|
91
|
+
# res["source"] == "online" | "offline"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A rejected `client_key` (401) is never masked by the offline path — only a genuine
|
|
95
|
+
reachability failure falls back.
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
| Function | Purpose |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `validate(key, *, apim_key, client_key, ...)` | Online check. Returns `{"valid", "reason"?, "activeSeats"?, "token"?}`. |
|
|
102
|
+
| `verify_token(token, raw_pub_b64, *, now=None)` | Offline check. Returns `{"valid", "reason"?, "claims"?}`. |
|
|
103
|
+
| `validate_or_verify(key, *, cached_token, public_key, ...)` | Online, falling back to a cached token when unreachable. |
|
|
104
|
+
| `machine_id_from(*parts)` | A stable SHA-256 machine id; raw parts never leave the machine. |
|
|
105
|
+
|
|
106
|
+
Any real failure (bad credentials, unreachable gateway, server error) raises
|
|
107
|
+
`KeyWardenError`, which carries `.code` and `.status`.
|
|
108
|
+
|
|
109
|
+
## Security notes
|
|
110
|
+
|
|
111
|
+
- Your **private** signing key never leaves Key-Warden's Key Vault. You embed
|
|
112
|
+
only the 32-byte public half.
|
|
113
|
+
- `machine_id` is hashed by the platform, but send an opaque, stable id — not a
|
|
114
|
+
raw MAC address or a hostname you wouldn't want logged. `machine_id_from()`
|
|
115
|
+
hashes on your side too.
|
|
116
|
+
- Two independent credentials gate every online call: the APIM subscription key
|
|
117
|
+
gets you to the gateway, the validation key authenticates you as the vendor. A
|
|
118
|
+
leaked validation key can be rotated without reissuing a single customer
|
|
119
|
+
licence.
|
|
120
|
+
|
|
121
|
+
## Licence
|
|
122
|
+
|
|
123
|
+
MIT.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography>=3.4
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keywarden
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keywarden"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python client for Key-Warden - validate software licences online (seat- and revocation-aware) or verify signed tokens offline against your embedded public key."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Key-Warden" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"key-warden", "keywarden", "licence", "license", "licensing",
|
|
15
|
+
"activation", "software-licensing", "ed25519", "offline-verification",
|
|
16
|
+
]
|
|
17
|
+
dependencies = ["cryptography>=3.4"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 5 - Production/Stable",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
"Topic :: Security :: Cryptography",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://key-warden.com"
|
|
30
|
+
Documentation = "https://key-warden.com/docs"
|
|
31
|
+
Issues = "https://key-warden.com/contact"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
packages = ["keywarden"]
|