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.
@@ -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.
@@ -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,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ keywarden/__init__.py
5
+ keywarden.egg-info/PKG-INFO
6
+ keywarden.egg-info/SOURCES.txt
7
+ keywarden.egg-info/dependency_links.txt
8
+ keywarden.egg-info/requires.txt
9
+ keywarden.egg-info/top_level.txt
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+