keyflint-verify 1.1.0__py3-none-any.whl
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.
- keyflint_verify/__init__.py +119 -0
- keyflint_verify/_validator.py +369 -0
- keyflint_verify/fingerprint.py +34 -0
- keyflint_verify/mcp.py +394 -0
- keyflint_verify/py.typed +0 -0
- keyflint_verify-1.1.0.dist-info/METADATA +73 -0
- keyflint_verify-1.1.0.dist-info/RECORD +10 -0
- keyflint_verify-1.1.0.dist-info/WHEEL +5 -0
- keyflint_verify-1.1.0.dist-info/licenses/LICENSE +21 -0
- keyflint_verify-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""keyflint-verify: offline verification of KeyFlint license tokens.
|
|
2
|
+
|
|
3
|
+
``validate_offline`` is a verbatim copy of the KeyFlint gateway's
|
|
4
|
+
``app/infrastructure/security/offline_validator.py``. ``verify_license`` adds
|
|
5
|
+
key-ring support (single key, list of historical keys, or ``{kid: hex}``),
|
|
6
|
+
matching the ``keyflint-verify`` npm package.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Mapping, Optional, Sequence, Union
|
|
15
|
+
|
|
16
|
+
from ._validator import validate_offline
|
|
17
|
+
|
|
18
|
+
__all__ = ["validate_offline", "verify_license", "decode_payload", "VerifyResult", "PublicKeys"]
|
|
19
|
+
__version__ = "1.1.0"
|
|
20
|
+
|
|
21
|
+
PublicKeys = Union[str, Sequence[str], Mapping[str, str]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class VerifyResult:
|
|
26
|
+
valid: bool
|
|
27
|
+
payload: Optional[dict[str, Any]]
|
|
28
|
+
error: Optional[str]
|
|
29
|
+
key_id: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
def __bool__(self) -> bool:
|
|
32
|
+
return self.valid
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_keys(keys: PublicKeys) -> Optional[list[tuple[Optional[str], str]]]:
|
|
36
|
+
if isinstance(keys, str):
|
|
37
|
+
pairs: list[tuple[Optional[str], Any]] = [(None, keys)]
|
|
38
|
+
elif isinstance(keys, Mapping):
|
|
39
|
+
pairs = [(str(k), v) for k, v in keys.items()]
|
|
40
|
+
elif isinstance(keys, Sequence):
|
|
41
|
+
pairs = [(str(i), v) for i, v in enumerate(keys)]
|
|
42
|
+
else:
|
|
43
|
+
return None
|
|
44
|
+
if not pairs:
|
|
45
|
+
return None
|
|
46
|
+
out: list[tuple[Optional[str], str]] = []
|
|
47
|
+
for kid, hex_key in pairs:
|
|
48
|
+
try:
|
|
49
|
+
if not isinstance(hex_key, str) or len(bytes.fromhex(hex_key)) != 32:
|
|
50
|
+
return None
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
out.append((kid, hex_key))
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def decode_payload(token: str) -> Optional[dict[str, Any]]:
|
|
58
|
+
"""Returns the token payload WITHOUT verifying it (display / routing only)."""
|
|
59
|
+
try:
|
|
60
|
+
payload_b64 = token.split(".")[0].split("-", 2)[2]
|
|
61
|
+
raw = base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4))
|
|
62
|
+
payload = json.loads(raw)
|
|
63
|
+
return payload if isinstance(payload, dict) else None
|
|
64
|
+
except Exception:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_PRE_SIGNATURE_ERRORS = frozenset({
|
|
69
|
+
"ERR_EMPTY_TOKEN", "ERR_INVALID_TOKEN_FORMAT", "ERR_INVALID_TOKEN_PREFIX", "ERR_INVALID_PUBLIC_KEY",
|
|
70
|
+
"ERR_MALFORMED_BASE64_PAYLOAD", "ERR_MALFORMED_BASE64_SIGNATURE", "ERR_INVALID_SIGNATURE_LENGTH",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _peek_kid(token: str) -> Optional[str]:
|
|
75
|
+
kid = (decode_payload(token) or {}).get("kid")
|
|
76
|
+
return kid if isinstance(kid, str) else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def verify_license(
|
|
80
|
+
token: str,
|
|
81
|
+
public_keys: PublicKeys,
|
|
82
|
+
*,
|
|
83
|
+
now: Optional[int] = None,
|
|
84
|
+
machine_id: Optional[str] = None,
|
|
85
|
+
domain: Optional[str] = None,
|
|
86
|
+
force_pure_python: bool = False,
|
|
87
|
+
) -> VerifyResult:
|
|
88
|
+
"""Verifies a token against one or more trusted public keys.
|
|
89
|
+
|
|
90
|
+
Tokens without ``kid`` (all KeyFlint v1.0 tokens) are tried against every key.
|
|
91
|
+
With a ``{kid: hex}`` mapping, a token with a signed ``kid`` is checked only against
|
|
92
|
+
that key (``ERR_UNKNOWN_KID`` when the mapping has no such kid). A single key or a
|
|
93
|
+
list has no names, so every key is tried.
|
|
94
|
+
"""
|
|
95
|
+
ring = _normalize_keys(public_keys)
|
|
96
|
+
if ring is None:
|
|
97
|
+
# Structural token errors take precedence, exactly like validate_offline.
|
|
98
|
+
_, _, err = validate_offline(token, "", now, machine_id, domain)
|
|
99
|
+
return VerifyResult(False, None, err)
|
|
100
|
+
|
|
101
|
+
kid = _peek_kid(token) if isinstance(token, str) else None
|
|
102
|
+
if kid is not None and isinstance(public_keys, Mapping):
|
|
103
|
+
matching = [e for e in ring if e[0] == kid]
|
|
104
|
+
if not matching:
|
|
105
|
+
# Report structural errors first (same order as the npm package), then the kid.
|
|
106
|
+
_, _, err = validate_offline(token, ring[0][1], now, machine_id, domain,
|
|
107
|
+
force_pure_python=force_pure_python)
|
|
108
|
+
return VerifyResult(False, None, err if err in _PRE_SIGNATURE_ERRORS else "ERR_UNKNOWN_KID")
|
|
109
|
+
ring = matching
|
|
110
|
+
|
|
111
|
+
last = VerifyResult(False, None, "ERR_SIGNATURE_TAMPERED")
|
|
112
|
+
for key_id, hex_key in ring:
|
|
113
|
+
ok, payload, err = validate_offline(
|
|
114
|
+
token, hex_key, now, machine_id, domain, force_pure_python=force_pure_python
|
|
115
|
+
)
|
|
116
|
+
if err == "ERR_SIGNATURE_TAMPERED":
|
|
117
|
+
continue
|
|
118
|
+
return VerifyResult(ok, payload, err, key_id if ok else None)
|
|
119
|
+
return last
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""KeyFlint Standalone Offline Validator.
|
|
2
|
+
|
|
3
|
+
Zero external dependencies. Can be packaged and distributed independently.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
# RFC 8032 Curve Parameters for standalone zero-dependency verification
|
|
16
|
+
_Q = 2**255 - 19
|
|
17
|
+
_L = 2**252 + 27742317777372353535851937790883648493
|
|
18
|
+
_D = (-121665 * pow(121666, _Q - 2, _Q)) % _Q
|
|
19
|
+
_D2 = (2 * _D) % _Q
|
|
20
|
+
_I = pow(2, (_Q - 1) // 4, _Q)
|
|
21
|
+
_BY = (4 * pow(5, _Q - 2, _Q)) % _Q
|
|
22
|
+
|
|
23
|
+
_ExtPt = tuple[int, int, int, int]
|
|
24
|
+
_EXT_IDENTITY: _ExtPt = (0, 1, 1, 0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _xrec(y: int) -> int:
|
|
28
|
+
u = (y * y - 1) % _Q
|
|
29
|
+
v = (_D * y * y + 1) % _Q
|
|
30
|
+
xx = (u * pow(v, _Q - 2, _Q)) % _Q
|
|
31
|
+
x = pow(xx, (_Q + 3) // 8, _Q)
|
|
32
|
+
if (x * x - xx) % _Q != 0:
|
|
33
|
+
x = (x * _I) % _Q
|
|
34
|
+
if (x * x - xx) % _Q != 0:
|
|
35
|
+
raise ValueError("Invalid Ed25519 point: no square root")
|
|
36
|
+
if x & 1:
|
|
37
|
+
x = _Q - x
|
|
38
|
+
return x
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_BX = _xrec(_BY)
|
|
42
|
+
_BP_EXT: _ExtPt = (_BX, _BY, 1, (_BX * _BY) % _Q)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ext_add(p1: _ExtPt, p2: _ExtPt) -> _ExtPt:
|
|
46
|
+
x1, y1, z1, t1 = p1
|
|
47
|
+
x2, y2, z2, t2 = p2
|
|
48
|
+
a = ((y1 - x1) * (y2 - x2)) % _Q
|
|
49
|
+
b = ((y1 + x1) * (y2 + x2)) % _Q
|
|
50
|
+
c = (t1 * _D2 * t2) % _Q
|
|
51
|
+
d = (2 * z1 * z2) % _Q
|
|
52
|
+
e = (b - a) % _Q
|
|
53
|
+
f = (d - c) % _Q
|
|
54
|
+
g = (d + c) % _Q
|
|
55
|
+
h = (b + a) % _Q
|
|
56
|
+
return ((e * f) % _Q, (g * h) % _Q, (f * g) % _Q, (e * h) % _Q)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ext_dbl(p: _ExtPt) -> _ExtPt:
|
|
60
|
+
x1, y1, z1, _ = p
|
|
61
|
+
a = (x1 * x1) % _Q
|
|
62
|
+
b = (y1 * y1) % _Q
|
|
63
|
+
c = (2 * z1 * z1) % _Q
|
|
64
|
+
h = (a + b) % _Q
|
|
65
|
+
xy = x1 + y1
|
|
66
|
+
e = (h - xy * xy) % _Q
|
|
67
|
+
g = (a - b) % _Q
|
|
68
|
+
f = (c + g) % _Q
|
|
69
|
+
return ((e * f) % _Q, (g * h) % _Q, (f * g) % _Q, (e * h) % _Q)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _smult_ext(pt: _ExtPt, e: int) -> _ExtPt:
|
|
73
|
+
res = _EXT_IDENTITY
|
|
74
|
+
addend = pt
|
|
75
|
+
scalar = e
|
|
76
|
+
while scalar > 0:
|
|
77
|
+
if scalar & 1:
|
|
78
|
+
res = _ext_add(res, addend)
|
|
79
|
+
addend = _ext_dbl(addend)
|
|
80
|
+
scalar >>= 1
|
|
81
|
+
return res
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _dpt_ext(s: bytes) -> _ExtPt:
|
|
85
|
+
if len(s) != 32:
|
|
86
|
+
raise ValueError("Invalid point length")
|
|
87
|
+
raw = int.from_bytes(s, "little")
|
|
88
|
+
y = raw & ((1 << 255) - 1)
|
|
89
|
+
sign_bit = (raw >> 255) & 1
|
|
90
|
+
if y >= _Q:
|
|
91
|
+
raise ValueError("Non-canonical y >= p")
|
|
92
|
+
x = _xrec(y)
|
|
93
|
+
if x == 0 and sign_bit == 1:
|
|
94
|
+
raise ValueError("Invalid point: x=0 with sign bit 1")
|
|
95
|
+
if (x & 1) != sign_bit:
|
|
96
|
+
x = _Q - x
|
|
97
|
+
if (-x * x + y * y - 1 - _D * x * x * y * y) % _Q != 0:
|
|
98
|
+
raise ValueError("Point off Ed25519 curve")
|
|
99
|
+
return (x, y, 1, (x * y) % _Q)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pure_ed25519_verify(sig: bytes, m: bytes, pk: bytes) -> bool:
|
|
103
|
+
if len(sig) != 64 or len(pk) != 32:
|
|
104
|
+
return False
|
|
105
|
+
s_val = int.from_bytes(sig[32:], "little")
|
|
106
|
+
if s_val >= _L:
|
|
107
|
+
return False
|
|
108
|
+
try:
|
|
109
|
+
r_ext = _dpt_ext(sig[:32])
|
|
110
|
+
a_ext = _dpt_ext(pk)
|
|
111
|
+
except ValueError:
|
|
112
|
+
return False
|
|
113
|
+
h_val = int.from_bytes(hashlib.sha512(sig[:32] + pk + m).digest(), "little") % _L
|
|
114
|
+
x1, y1, z1, _ = _smult_ext(_BP_EXT, s_val)
|
|
115
|
+
x2, y2, z2, _ = _ext_add(r_ext, _smult_ext(a_ext, h_val))
|
|
116
|
+
return (x1 * z2 - x2 * z1) % _Q == 0 and (y1 * z2 - y2 * z1) % _Q == 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Tolerated difference between the issuer's clock and the local clock for `issued_at`.
|
|
120
|
+
CLOCK_SKEW_LEEWAY_SECONDS = 300
|
|
121
|
+
|
|
122
|
+
_B64URL_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _b64url_decode(s: str) -> bytes:
|
|
126
|
+
"""Decode unpadded URL-safe base64 string to bytes with strict character set validation."""
|
|
127
|
+
if not isinstance(s, str) or not _B64URL_PATTERN.match(s):
|
|
128
|
+
raise ValueError("Invalid Base64URL string: contains illegal characters or whitespace.")
|
|
129
|
+
pad_len = (4 - len(s) % 4) % 4
|
|
130
|
+
return base64.b64decode((s + "=" * pad_len).encode("ascii"), altchars=b"-_", validate=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _normalize_domain_host(raw: Optional[str], *, allow_wildcard: bool = False) -> Optional[str]:
|
|
134
|
+
"""Normalizes a hostname (optional :port, trailing FQDN dot, and IDNA punycode) using stdlib only."""
|
|
135
|
+
if not raw or not isinstance(raw, str):
|
|
136
|
+
return None
|
|
137
|
+
s = raw.strip()
|
|
138
|
+
if not s or any(ch in s for ch in ("/", "\\", "@", "?", "#", " ", "\t", "\r", "\n")):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
wildcard = False
|
|
142
|
+
if s.startswith("*."):
|
|
143
|
+
if not allow_wildcard:
|
|
144
|
+
return None
|
|
145
|
+
wildcard = True
|
|
146
|
+
s = s[2:]
|
|
147
|
+
elif "*" in s:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
if ":" in s:
|
|
151
|
+
host_part, port_part = s.rsplit(":", 1)
|
|
152
|
+
if not port_part.isdigit():
|
|
153
|
+
return None
|
|
154
|
+
port_num = int(port_part)
|
|
155
|
+
if port_num < 1 or port_num > 65535:
|
|
156
|
+
return None
|
|
157
|
+
s = host_part
|
|
158
|
+
|
|
159
|
+
s = s.rstrip(".")
|
|
160
|
+
if not s or s.startswith(".") or ".." in s:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
labels = s.split(".")
|
|
164
|
+
if wildcard and len(labels) < 2:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
ascii_labels: list[str] = []
|
|
168
|
+
for label in labels:
|
|
169
|
+
if not label:
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
enc = label.encode("idna").decode("ascii").lower()
|
|
173
|
+
except UnicodeError:
|
|
174
|
+
return None
|
|
175
|
+
if (
|
|
176
|
+
not enc
|
|
177
|
+
or any(not (ch.isalnum() or ch == "-") for ch in enc)
|
|
178
|
+
or enc.startswith("-")
|
|
179
|
+
or enc.endswith("-")
|
|
180
|
+
):
|
|
181
|
+
return None
|
|
182
|
+
ascii_labels.append(enc)
|
|
183
|
+
|
|
184
|
+
normalized = ".".join(ascii_labels)
|
|
185
|
+
return f"*.{normalized}" if wildcard else normalized
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _matches_allowed_domains(allowed: list[str], domain: Optional[str]) -> bool:
|
|
189
|
+
if not allowed:
|
|
190
|
+
return True
|
|
191
|
+
candidate = _normalize_domain_host(domain, allow_wildcard=False)
|
|
192
|
+
if not candidate:
|
|
193
|
+
return False
|
|
194
|
+
for pattern in allowed:
|
|
195
|
+
if not isinstance(pattern, str):
|
|
196
|
+
continue
|
|
197
|
+
pat = _normalize_domain_host(pattern, allow_wildcard=True)
|
|
198
|
+
if not pat:
|
|
199
|
+
continue
|
|
200
|
+
if pat == candidate:
|
|
201
|
+
return True
|
|
202
|
+
if pat.startswith("*."):
|
|
203
|
+
suffix = pat[2:]
|
|
204
|
+
if suffix and "." in suffix and (
|
|
205
|
+
candidate == suffix or candidate.endswith("." + suffix)
|
|
206
|
+
):
|
|
207
|
+
return True
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_offline(
|
|
212
|
+
license_token: Optional[str] = None,
|
|
213
|
+
public_key_hex: str = "",
|
|
214
|
+
current_time: Optional[int] = None,
|
|
215
|
+
expected_machine_id: Optional[str] = None,
|
|
216
|
+
expected_domain: Optional[str] = None,
|
|
217
|
+
*,
|
|
218
|
+
license_key: Optional[str] = None,
|
|
219
|
+
force_pure_python: bool = False,
|
|
220
|
+
) -> tuple[bool, Optional[dict[str, Any]], Optional[str]]:
|
|
221
|
+
"""Validates a KeyFlint license token offline.
|
|
222
|
+
|
|
223
|
+
Parameters:
|
|
224
|
+
license_token: Complete token string (LIC-<TIER>-<PAYLOAD>.<SIG>)
|
|
225
|
+
public_key_hex: 64-character hex string of 32-byte Ed25519 public key
|
|
226
|
+
current_time: Optional Unix epoch seconds for expiration test (defaults to time.time())
|
|
227
|
+
expected_machine_id: Optional local machine ID to enforce node-locking
|
|
228
|
+
expected_domain: Optional caller hostname/domain to enforce allowed_domains lock
|
|
229
|
+
license_key: Alias for license_token
|
|
230
|
+
force_pure_python: Force pure-stdlib RFC 8032 curve math even if cryptography is installed
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
(is_valid: bool, payload: Optional[dict], error_code: Optional[str])
|
|
234
|
+
"""
|
|
235
|
+
token = license_token if license_token is not None else license_key
|
|
236
|
+
|
|
237
|
+
# 1. Structure validation
|
|
238
|
+
if not token or not isinstance(token, str):
|
|
239
|
+
return False, None, "ERR_EMPTY_TOKEN"
|
|
240
|
+
|
|
241
|
+
if "." not in token:
|
|
242
|
+
return False, None, "ERR_INVALID_TOKEN_FORMAT"
|
|
243
|
+
|
|
244
|
+
parts = token.split(".")
|
|
245
|
+
if len(parts) != 2:
|
|
246
|
+
return False, None, "ERR_INVALID_TOKEN_FORMAT"
|
|
247
|
+
|
|
248
|
+
header_part, sig_b64 = parts
|
|
249
|
+
if not header_part.startswith("LIC-"):
|
|
250
|
+
return False, None, "ERR_INVALID_TOKEN_PREFIX"
|
|
251
|
+
|
|
252
|
+
subparts = header_part.split("-", 2)
|
|
253
|
+
if len(subparts) != 3:
|
|
254
|
+
return False, None, "ERR_INVALID_TOKEN_FORMAT"
|
|
255
|
+
|
|
256
|
+
_, token_tier, payload_b64 = subparts
|
|
257
|
+
if not token_tier or not payload_b64:
|
|
258
|
+
return False, None, "ERR_INVALID_TOKEN_FORMAT"
|
|
259
|
+
|
|
260
|
+
# 2. Public key decode
|
|
261
|
+
try:
|
|
262
|
+
if not isinstance(public_key_hex, str):
|
|
263
|
+
return False, None, "ERR_INVALID_PUBLIC_KEY"
|
|
264
|
+
pub_bytes = bytes.fromhex(public_key_hex.strip())
|
|
265
|
+
if len(pub_bytes) != 32:
|
|
266
|
+
return False, None, "ERR_INVALID_PUBLIC_KEY"
|
|
267
|
+
except Exception:
|
|
268
|
+
return False, None, "ERR_INVALID_PUBLIC_KEY"
|
|
269
|
+
|
|
270
|
+
# 3. Base64url decodings
|
|
271
|
+
try:
|
|
272
|
+
payload_bytes = _b64url_decode(payload_b64)
|
|
273
|
+
except Exception:
|
|
274
|
+
return False, None, "ERR_MALFORMED_BASE64_PAYLOAD"
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
sig_bytes = _b64url_decode(sig_b64)
|
|
278
|
+
if len(sig_bytes) != 64:
|
|
279
|
+
return False, None, "ERR_INVALID_SIGNATURE_LENGTH"
|
|
280
|
+
except Exception:
|
|
281
|
+
return False, None, "ERR_MALFORMED_BASE64_SIGNATURE"
|
|
282
|
+
|
|
283
|
+
# 4. Cryptographic signature verification (OpenSSL fast path with pure-python RFC 8032 fallback)
|
|
284
|
+
sig_valid = False
|
|
285
|
+
if force_pure_python:
|
|
286
|
+
sig_valid = _pure_ed25519_verify(sig_bytes, payload_bytes, pub_bytes)
|
|
287
|
+
else:
|
|
288
|
+
try:
|
|
289
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
290
|
+
|
|
291
|
+
c_pk = ed25519.Ed25519PublicKey.from_public_bytes(pub_bytes)
|
|
292
|
+
c_pk.verify(sig_bytes, payload_bytes)
|
|
293
|
+
sig_valid = True
|
|
294
|
+
except ImportError:
|
|
295
|
+
sig_valid = _pure_ed25519_verify(sig_bytes, payload_bytes, pub_bytes)
|
|
296
|
+
except Exception:
|
|
297
|
+
sig_valid = False
|
|
298
|
+
|
|
299
|
+
if not sig_valid:
|
|
300
|
+
return False, None, "ERR_SIGNATURE_TAMPERED"
|
|
301
|
+
|
|
302
|
+
# 5. JSON payload deserialization
|
|
303
|
+
try:
|
|
304
|
+
payload = json.loads(payload_bytes.decode("utf-8"))
|
|
305
|
+
except Exception:
|
|
306
|
+
return False, None, "ERR_MALFORMED_JSON_PAYLOAD"
|
|
307
|
+
|
|
308
|
+
if not isinstance(payload, dict):
|
|
309
|
+
return False, None, "ERR_INVALID_PAYLOAD_STRUCTURE"
|
|
310
|
+
|
|
311
|
+
# 6. Tier consistency
|
|
312
|
+
tier_val = payload.get("tier")
|
|
313
|
+
if not isinstance(tier_val, str) or tier_val.lower() != token_tier.lower():
|
|
314
|
+
return False, payload, "ERR_TIER_MISMATCH"
|
|
315
|
+
|
|
316
|
+
# 7. Expiration boundary check
|
|
317
|
+
now = current_time if current_time is not None else int(time.time())
|
|
318
|
+
expires_at = payload.get("expires_at")
|
|
319
|
+
if expires_at is not None:
|
|
320
|
+
if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)):
|
|
321
|
+
return False, payload, "ERR_MALFORMED_EXPIRATION"
|
|
322
|
+
if now > expires_at:
|
|
323
|
+
return False, payload, "ERR_LICENSE_EXPIRED"
|
|
324
|
+
|
|
325
|
+
# 7b. Clock sanity: a local clock earlier than the signed issuance time (beyond the
|
|
326
|
+
# tolerated skew) means the clock was rolled back or is badly wrong.
|
|
327
|
+
issued_at = payload.get("issued_at")
|
|
328
|
+
if isinstance(issued_at, (int, float)) and not isinstance(issued_at, bool):
|
|
329
|
+
if now + CLOCK_SKEW_LEEWAY_SECONDS < issued_at:
|
|
330
|
+
return False, payload, "ERR_CLOCK_BEFORE_ISSUANCE"
|
|
331
|
+
|
|
332
|
+
# 8. Node lock / Machine ID enforcement
|
|
333
|
+
locked_machine = payload.get("machine_id")
|
|
334
|
+
if locked_machine is not None and not isinstance(locked_machine, str):
|
|
335
|
+
return False, payload, "ERR_INVALID_PAYLOAD_STRUCTURE"
|
|
336
|
+
if expected_machine_id is not None and not isinstance(expected_machine_id, str):
|
|
337
|
+
return False, payload, "ERR_NODE_LOCK_VIOLATION"
|
|
338
|
+
|
|
339
|
+
norm_locked = (
|
|
340
|
+
locked_machine.strip().lower()
|
|
341
|
+
if isinstance(locked_machine, str) and locked_machine.strip()
|
|
342
|
+
else None
|
|
343
|
+
)
|
|
344
|
+
norm_expected_hw = (
|
|
345
|
+
expected_machine_id.strip().lower()
|
|
346
|
+
if isinstance(expected_machine_id, str) and expected_machine_id.strip()
|
|
347
|
+
else None
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if norm_locked is not None:
|
|
351
|
+
if norm_expected_hw is None or norm_locked != norm_expected_hw:
|
|
352
|
+
return False, payload, "ERR_NODE_LOCK_VIOLATION"
|
|
353
|
+
elif norm_expected_hw is not None:
|
|
354
|
+
return False, payload, "ERR_NODE_LOCK_VIOLATION"
|
|
355
|
+
|
|
356
|
+
# 9. Domain lock enforcement
|
|
357
|
+
if expected_domain is not None and not isinstance(expected_domain, str):
|
|
358
|
+
return False, payload, "ERR_DOMAIN_LOCK_VIOLATION"
|
|
359
|
+
|
|
360
|
+
allowed_domains = payload.get("allowed_domains")
|
|
361
|
+
if allowed_domains is not None:
|
|
362
|
+
if not isinstance(allowed_domains, list) or any(
|
|
363
|
+
not isinstance(d, str) for d in allowed_domains
|
|
364
|
+
):
|
|
365
|
+
return False, payload, "ERR_INVALID_PAYLOAD_STRUCTURE"
|
|
366
|
+
if allowed_domains and not _matches_allowed_domains(allowed_domains, expected_domain):
|
|
367
|
+
return False, payload, "ERR_DOMAIN_LOCK_VIOLATION"
|
|
368
|
+
|
|
369
|
+
return True, payload, None
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Hardware Machine ID Fingerprinting for Node-Locked Licenses.
|
|
2
|
+
|
|
3
|
+
Zero external dependencies. Operates deterministically across Windows, Linux, and macOS.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import platform
|
|
10
|
+
import socket
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compute_machine_id() -> str:
|
|
15
|
+
"""Computes a deterministic, cross-platform hardware fingerprint for node locking.
|
|
16
|
+
|
|
17
|
+
Formula: sha256(hostname:cpu_info:mac_address)
|
|
18
|
+
"""
|
|
19
|
+
# 1. Hostname normalization
|
|
20
|
+
hostname = socket.gethostname().strip().lower()
|
|
21
|
+
|
|
22
|
+
# 2. Resilient CPU identification
|
|
23
|
+
proc = platform.processor().strip().lower()
|
|
24
|
+
mach = platform.machine().strip().lower()
|
|
25
|
+
sys_name = platform.system().strip().lower()
|
|
26
|
+
cpu_info = f"{proc}|{mach}|{sys_name}" if proc else f"{mach}|{sys_name}"
|
|
27
|
+
|
|
28
|
+
# 3. MAC Address extraction
|
|
29
|
+
mac_node = uuid.getnode()
|
|
30
|
+
mac_address = f"{mac_node:012x}".lower()
|
|
31
|
+
|
|
32
|
+
# 4. Canonical combination and SHA-256 digest
|
|
33
|
+
raw_identifier = f"{hostname}:{cpu_info}:{mac_address}"
|
|
34
|
+
return hashlib.sha256(raw_identifier.encode("utf-8")).hexdigest()
|
keyflint_verify/mcp.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""KeyFlint licensing for MCP servers (FastMCP, MCPServer and the low-level ``mcp`` SDK).
|
|
2
|
+
|
|
3
|
+
from keyflint_verify.mcp import KeyFlint
|
|
4
|
+
|
|
5
|
+
kf = KeyFlint() # reads KEYFLINT_* environment variables
|
|
6
|
+
|
|
7
|
+
@mcp.tool()
|
|
8
|
+
@kf.require_license # offline Ed25519 check, no network
|
|
9
|
+
def search(query: str) -> str: ...
|
|
10
|
+
|
|
11
|
+
@mcp.tool()
|
|
12
|
+
@kf.metered(credits=5) # offline check + POST /api/v1/quota/deduct
|
|
13
|
+
async def summarize(url: str) -> str: ...
|
|
14
|
+
|
|
15
|
+
Put the KeyFlint decorator *below* ``@mcp.tool()`` so the tool sees the wrapped function.
|
|
16
|
+
Both decorators work on sync and async functions and keep the original signature, so
|
|
17
|
+
the generated tool schema is unchanged.
|
|
18
|
+
|
|
19
|
+
Failures raise :class:`LicenseError`. When the ``mcp`` package is installed it subclasses
|
|
20
|
+
the SDK's standard MCP error (``MCPError`` in v2, ``McpError`` in v1), so the client
|
|
21
|
+
receives a JSON-RPC error with ``code``, ``message`` and ``data``. Without ``mcp`` it is a
|
|
22
|
+
plain ``Exception``.
|
|
23
|
+
|
|
24
|
+
Environment variables:
|
|
25
|
+
KEYFLINT_LICENSE license token (LIC-...)
|
|
26
|
+
KEYFLINT_PUBLIC_KEYS hex key, comma-separated keys, or kid=hex pairs
|
|
27
|
+
KEYFLINT_URL gateway base URL (required for @metered)
|
|
28
|
+
KEYFLINT_MACHINE_ID machine id for node-locked licenses (default: auto)
|
|
29
|
+
KEYFLINT_DOMAIN caller domain for domain-locked licenses
|
|
30
|
+
KEYFLINT_CHECKOUT_URL upgrade link shown when credits run out
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import functools
|
|
37
|
+
import inspect
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import time
|
|
41
|
+
import urllib.error
|
|
42
|
+
import urllib.request
|
|
43
|
+
import uuid
|
|
44
|
+
from typing import Any, Callable, Mapping, Optional, Sequence, TypeVar, Union, cast
|
|
45
|
+
|
|
46
|
+
from . import PublicKeys, VerifyResult, decode_payload, verify_license
|
|
47
|
+
from .fingerprint import compute_machine_id
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"KeyFlint",
|
|
51
|
+
"LicenseError",
|
|
52
|
+
"LICENSE_INVALID",
|
|
53
|
+
"FEATURE_NOT_ENTITLED",
|
|
54
|
+
"QUOTA_EXHAUSTED",
|
|
55
|
+
"GATEWAY_ERROR",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# JSON-RPC server error range (-32000..-32099), stable across SDKs. -32042 is avoided because
|
|
59
|
+
# MCP itself uses it for URLElicitationRequired.
|
|
60
|
+
LICENSE_INVALID = -32060
|
|
61
|
+
FEATURE_NOT_ENTITLED = -32061
|
|
62
|
+
QUOTA_EXHAUSTED = -32062
|
|
63
|
+
GATEWAY_ERROR = -32063
|
|
64
|
+
|
|
65
|
+
_LICENSE_MESSAGES = {
|
|
66
|
+
"ERR_EMPTY_TOKEN": "No KeyFlint license is configured. Set KEYFLINT_LICENSE.",
|
|
67
|
+
"ERR_SIGNATURE_TAMPERED": "This license was not issued for this server (signature check failed). "
|
|
68
|
+
"Check KEYFLINT_LICENSE and KEYFLINT_PUBLIC_KEYS.",
|
|
69
|
+
"ERR_UNKNOWN_KID": "This license was signed with a key this server does not trust. "
|
|
70
|
+
"Check KEYFLINT_PUBLIC_KEYS.",
|
|
71
|
+
"ERR_NODE_LOCK_VIOLATION": "This license is locked to another machine.",
|
|
72
|
+
"ERR_DOMAIN_LOCK_VIOLATION": "This license is not valid for this domain.",
|
|
73
|
+
"ERR_CLOCK_BEFORE_ISSUANCE": "The system clock is earlier than the license issue date. "
|
|
74
|
+
"Correct the clock and retry.",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _license_message(error: Optional[str], payload: Optional[Mapping[str, Any]], checkout_url: Optional[str]) -> str:
|
|
79
|
+
"""Plain-language reason plus the next step; this text is what MCP clients show."""
|
|
80
|
+
code = error or "ERR_LICENSE_INVALID"
|
|
81
|
+
if code == "ERR_LICENSE_EXPIRED":
|
|
82
|
+
exp = (payload or {}).get("expires_at")
|
|
83
|
+
when = time.strftime("%Y-%m-%d", time.gmtime(exp)) if isinstance(exp, (int, float)) else None
|
|
84
|
+
text = f"Your license expired on {when}." if when else "Your license has expired."
|
|
85
|
+
else:
|
|
86
|
+
text = _LICENSE_MESSAGES.get(code, f"The KeyFlint license is invalid ({code}).")
|
|
87
|
+
if checkout_url and code not in ("ERR_CLOCK_BEFORE_ISSUANCE", "ERR_UNKNOWN_KID", "ERR_SIGNATURE_TAMPERED"):
|
|
88
|
+
text += f" Get or renew a license: {checkout_url}"
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
92
|
+
|
|
93
|
+
try: # mcp >= 2
|
|
94
|
+
from mcp.shared.exceptions import MCPError as _MCPBase # type: ignore[import-not-found]
|
|
95
|
+
|
|
96
|
+
_MCP_FLAVOR = 2
|
|
97
|
+
except ImportError: # pragma: no cover - depends on installed SDK
|
|
98
|
+
try: # mcp 1.x
|
|
99
|
+
from mcp.shared.exceptions import McpError as _MCPBase # type: ignore[import-not-found,no-redef]
|
|
100
|
+
from mcp.types import ErrorData as _ErrorData # type: ignore[import-not-found]
|
|
101
|
+
|
|
102
|
+
_MCP_FLAVOR = 1
|
|
103
|
+
except ImportError:
|
|
104
|
+
_MCPBase = Exception # type: ignore[misc,assignment]
|
|
105
|
+
_MCP_FLAVOR = 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LicenseError(_MCPBase): # type: ignore[misc,valid-type]
|
|
109
|
+
"""Standard MCP error carrying KeyFlint details in ``data``.
|
|
110
|
+
|
|
111
|
+
``data["reason"]`` is the KeyFlint error code (``ERR_LICENSE_EXPIRED``,
|
|
112
|
+
``QUOTA_EXHAUSTED``, ...). ``data["checkout_url"]`` is set when an upgrade link is configured.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, code: int, message: str, data: Mapping[str, Any]) -> None:
|
|
116
|
+
payload = dict(data)
|
|
117
|
+
self._text = message
|
|
118
|
+
if _MCP_FLAVOR == 2:
|
|
119
|
+
super().__init__(code, message, payload)
|
|
120
|
+
elif _MCP_FLAVOR == 1: # pragma: no cover
|
|
121
|
+
super().__init__(_ErrorData(code=code, message=message, data=payload))
|
|
122
|
+
else:
|
|
123
|
+
super().__init__(message)
|
|
124
|
+
self.code = code # type: ignore[misc]
|
|
125
|
+
self.message = message # type: ignore[misc]
|
|
126
|
+
self.data = payload # type: ignore[misc]
|
|
127
|
+
self.reason: str = str(payload.get("reason", ""))
|
|
128
|
+
self.details: dict[str, Any] = payload
|
|
129
|
+
|
|
130
|
+
def __str__(self) -> str:
|
|
131
|
+
return self._text
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _error(code: int, reason: str, message: str, **extra: Any) -> LicenseError:
|
|
135
|
+
data = {"reason": reason, **{k: v for k, v in extra.items() if v is not None}}
|
|
136
|
+
return LicenseError(code, message, data)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_keys_env(raw: str) -> PublicKeys:
|
|
140
|
+
"""Parses ``hex``, ``hex,hex`` or ``kid=hex`` entries; bare keys in a named list become ``legacy-<i>``."""
|
|
141
|
+
items = [p.strip() for p in raw.split(",") if p.strip()]
|
|
142
|
+
if any("=" in p for p in items):
|
|
143
|
+
ring: dict[str, str] = {}
|
|
144
|
+
for i, p in enumerate(items):
|
|
145
|
+
kid, _, hex_key = p.rpartition("=")
|
|
146
|
+
ring[kid.strip() or f"legacy-{i}"] = hex_key.strip()
|
|
147
|
+
return ring
|
|
148
|
+
return items[0] if len(items) == 1 else items
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class KeyFlint:
|
|
152
|
+
"""Holds the license configuration and exposes the MCP decorators."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
license_key: Optional[str] = None,
|
|
158
|
+
public_keys: Optional[PublicKeys] = None,
|
|
159
|
+
gateway_url: Optional[str] = None,
|
|
160
|
+
machine_id: Optional[str] = "auto",
|
|
161
|
+
domain: Optional[str] = None,
|
|
162
|
+
checkout_url: Optional[str] = None,
|
|
163
|
+
timeout: float = 5.0,
|
|
164
|
+
event_type: str = "mcp_tool_call",
|
|
165
|
+
) -> None:
|
|
166
|
+
# KEYFORGE_* (pre-rename) is accepted as a fallback for every KEYFLINT_* variable.
|
|
167
|
+
env = {**{"KEYFLINT_" + k[9:]: v for k, v in os.environ.items() if k.startswith("KEYFORGE_")}, **os.environ}
|
|
168
|
+
self.license_key = license_key or env.get("KEYFLINT_LICENSE", "")
|
|
169
|
+
keys_env = env.get("KEYFLINT_PUBLIC_KEYS") or env.get("KEYFLINT_PUBLIC_KEY", "")
|
|
170
|
+
self.public_keys: PublicKeys = public_keys if public_keys is not None else _parse_keys_env(keys_env)
|
|
171
|
+
self.gateway_url = (gateway_url or env.get("KEYFLINT_URL", "")).rstrip("/")
|
|
172
|
+
self.machine_id = env.get("KEYFLINT_MACHINE_ID", machine_id) if machine_id == "auto" else machine_id
|
|
173
|
+
self.domain = domain or env.get("KEYFLINT_DOMAIN") or None
|
|
174
|
+
self.checkout_url = checkout_url or env.get("KEYFLINT_CHECKOUT_URL") or None
|
|
175
|
+
self.timeout = timeout
|
|
176
|
+
self.event_type = event_type
|
|
177
|
+
self._cached: Optional[VerifyResult] = None
|
|
178
|
+
self._cached_until: float = 0.0
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------ offline
|
|
181
|
+
|
|
182
|
+
def _resolved_machine_id(self) -> Optional[str]:
|
|
183
|
+
if self.machine_id != "auto":
|
|
184
|
+
return self.machine_id
|
|
185
|
+
payload = decode_payload(self.license_key)
|
|
186
|
+
locked = payload.get("machine_id") if isinstance(payload, dict) else None
|
|
187
|
+
return compute_machine_id() if locked else None
|
|
188
|
+
|
|
189
|
+
def check(self, features: Optional[Sequence[str]] = None) -> VerifyResult:
|
|
190
|
+
"""Verifies the configured license offline (cached until expiry, re-checked every 60 s)."""
|
|
191
|
+
now = time.time()
|
|
192
|
+
if self._cached is None or now >= self._cached_until:
|
|
193
|
+
result = verify_license(
|
|
194
|
+
self.license_key,
|
|
195
|
+
self.public_keys,
|
|
196
|
+
machine_id=self._resolved_machine_id(),
|
|
197
|
+
domain=self.domain,
|
|
198
|
+
)
|
|
199
|
+
if not result.valid:
|
|
200
|
+
raise _error(
|
|
201
|
+
LICENSE_INVALID,
|
|
202
|
+
result.error or "ERR_LICENSE_INVALID",
|
|
203
|
+
_license_message(result.error, result.payload, self.checkout_url),
|
|
204
|
+
checkout_url=self.checkout_url,
|
|
205
|
+
)
|
|
206
|
+
exp = (result.payload or {}).get("expires_at")
|
|
207
|
+
ttl = 60.0 if exp is None else max(0.0, min(60.0, float(exp) - now))
|
|
208
|
+
self._cached, self._cached_until = result, now + ttl
|
|
209
|
+
result = self._cached
|
|
210
|
+
if features:
|
|
211
|
+
have = set((result.payload or {}).get("features") or [])
|
|
212
|
+
missing = [f for f in features if f not in have]
|
|
213
|
+
if missing:
|
|
214
|
+
tier = (result.payload or {}).get("tier") or "current"
|
|
215
|
+
text = f"This tool requires the feature(s) {', '.join(missing)}, which the {tier} plan does not include."
|
|
216
|
+
if self.checkout_url:
|
|
217
|
+
text += f" Upgrade: {self.checkout_url}"
|
|
218
|
+
raise _error(
|
|
219
|
+
FEATURE_NOT_ENTITLED,
|
|
220
|
+
"FEATURE_NOT_ENTITLED",
|
|
221
|
+
text,
|
|
222
|
+
missing_features=missing,
|
|
223
|
+
tier=(result.payload or {}).get("tier"),
|
|
224
|
+
checkout_url=self.checkout_url,
|
|
225
|
+
)
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------- online
|
|
229
|
+
|
|
230
|
+
def deduct(
|
|
231
|
+
self,
|
|
232
|
+
credits: int = 1,
|
|
233
|
+
*,
|
|
234
|
+
idempotency_key: Optional[str] = None,
|
|
235
|
+
event_type: Optional[str] = None,
|
|
236
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
"""POST /api/v1/quota/deduct. Retries once on network errors with the same idempotency key."""
|
|
239
|
+
if not self.gateway_url:
|
|
240
|
+
raise _error(GATEWAY_ERROR, "GATEWAY_NOT_CONFIGURED", "KEYFLINT_URL is not set; metered tools need the gateway.")
|
|
241
|
+
key = idempotency_key or f"mcp-{uuid.uuid4().hex}"
|
|
242
|
+
body = {
|
|
243
|
+
"license_key": self.license_key,
|
|
244
|
+
"credits": int(credits),
|
|
245
|
+
"idempotency_key": key,
|
|
246
|
+
"event_type": event_type or self.event_type,
|
|
247
|
+
}
|
|
248
|
+
if metadata:
|
|
249
|
+
body["metadata"] = dict(metadata)
|
|
250
|
+
req = urllib.request.Request(
|
|
251
|
+
f"{self.gateway_url}/api/v1/quota/deduct",
|
|
252
|
+
data=json.dumps(body).encode("utf-8"),
|
|
253
|
+
headers={"Content-Type": "application/json", "Idempotency-Key": key, "Accept": "application/json"},
|
|
254
|
+
method="POST",
|
|
255
|
+
)
|
|
256
|
+
last_exc: Optional[Exception] = None
|
|
257
|
+
for _attempt in range(2):
|
|
258
|
+
try:
|
|
259
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310 - configured URL
|
|
260
|
+
data = json.loads(resp.read().decode("utf-8") or "{}")
|
|
261
|
+
return cast("dict[str, Any]", data.get("data", data))
|
|
262
|
+
except urllib.error.HTTPError as exc:
|
|
263
|
+
raise self._http_error(exc.code, exc.read()) from None
|
|
264
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
265
|
+
last_exc = exc
|
|
266
|
+
raise _error(GATEWAY_ERROR, "GATEWAY_UNREACHABLE",
|
|
267
|
+
f"Could not reach the KeyFlint license server at {self.gateway_url} ({last_exc}). "
|
|
268
|
+
"The tool did not run.")
|
|
269
|
+
|
|
270
|
+
def refund(self, idempotency_key: str, reason: Optional[str] = None) -> bool:
|
|
271
|
+
"""Best-effort POST /api/v1/quota/refund for a previous deduct. Never raises; True on success."""
|
|
272
|
+
if not self.gateway_url:
|
|
273
|
+
return False
|
|
274
|
+
body: dict[str, Any] = {"license_key": self.license_key, "idempotency_key": idempotency_key}
|
|
275
|
+
if reason:
|
|
276
|
+
body["reason"] = reason
|
|
277
|
+
req = urllib.request.Request(
|
|
278
|
+
f"{self.gateway_url}/api/v1/quota/refund",
|
|
279
|
+
data=json.dumps(body).encode("utf-8"),
|
|
280
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
281
|
+
method="POST",
|
|
282
|
+
)
|
|
283
|
+
try:
|
|
284
|
+
with urllib.request.urlopen(req, timeout=self.timeout): # noqa: S310 - configured URL
|
|
285
|
+
return True
|
|
286
|
+
except Exception:
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
def _http_error(self, status: int, raw: bytes) -> LicenseError:
|
|
290
|
+
try:
|
|
291
|
+
err = json.loads(raw.decode("utf-8")).get("error", {})
|
|
292
|
+
except Exception:
|
|
293
|
+
err = {}
|
|
294
|
+
code = str(err.get("code") or f"HTTP_{status}")
|
|
295
|
+
message = str(err.get("message") or f"Gateway returned HTTP {status}.")
|
|
296
|
+
if status == 402 or code == "QUOTA_EXHAUSTED":
|
|
297
|
+
det = err.get("details") or {}
|
|
298
|
+
need, left = det.get("requested_credits"), det.get("remaining_credits")
|
|
299
|
+
if isinstance(need, int) and isinstance(left, int):
|
|
300
|
+
text = f"Credits exhausted: this tool needs {need} credits and the license has {left} left."
|
|
301
|
+
else:
|
|
302
|
+
text = "Credits exhausted for this license."
|
|
303
|
+
if self.checkout_url:
|
|
304
|
+
text += f" Top up: {self.checkout_url}"
|
|
305
|
+
text += " The tool did not run and no credits were charged."
|
|
306
|
+
return _error(QUOTA_EXHAUSTED, "QUOTA_EXHAUSTED", text, status=402, checkout_url=self.checkout_url,
|
|
307
|
+
gateway_details=err.get("details") or None)
|
|
308
|
+
if status in (401, 403, 404):
|
|
309
|
+
return _error(LICENSE_INVALID, code, f"KeyFlint license rejected: {message}", status=status,
|
|
310
|
+
checkout_url=self.checkout_url)
|
|
311
|
+
return _error(GATEWAY_ERROR, code, f"KeyFlint gateway error: {message} The tool did not run.", status=status)
|
|
312
|
+
|
|
313
|
+
# --------------------------------------------------------------- decorators
|
|
314
|
+
|
|
315
|
+
def require_license(
|
|
316
|
+
self, fn: Optional[F] = None, *, features: Optional[Sequence[str]] = None
|
|
317
|
+
) -> Union[F, Callable[[F], F]]:
|
|
318
|
+
"""Decorator: the tool runs only with a valid license (offline check)."""
|
|
319
|
+
|
|
320
|
+
def decorate(func: F) -> F:
|
|
321
|
+
return _wrap(func, lambda: self.check(features), None)
|
|
322
|
+
|
|
323
|
+
return decorate(fn) if fn is not None else decorate
|
|
324
|
+
|
|
325
|
+
def metered(
|
|
326
|
+
self,
|
|
327
|
+
credits: int = 1,
|
|
328
|
+
*,
|
|
329
|
+
features: Optional[Sequence[str]] = None,
|
|
330
|
+
event_type: Optional[str] = None,
|
|
331
|
+
refund_on_error: bool = True,
|
|
332
|
+
) -> Callable[[F], F]:
|
|
333
|
+
"""Decorator: offline license check, then debit ``credits`` before the tool runs.
|
|
334
|
+
|
|
335
|
+
With ``refund_on_error`` (default) the credits are given back via /quota/refund if the
|
|
336
|
+
tool raises or returns an MCP error result (``isError: True``).
|
|
337
|
+
"""
|
|
338
|
+
if credits <= 0:
|
|
339
|
+
raise ValueError("credits must be a positive integer")
|
|
340
|
+
|
|
341
|
+
def decorate(func: F) -> F:
|
|
342
|
+
name = event_type or f"mcp:{func.__name__}"
|
|
343
|
+
|
|
344
|
+
def charge() -> Optional[Callable[[str], Any]]:
|
|
345
|
+
key = f"mcp-{uuid.uuid4().hex}"
|
|
346
|
+
self.deduct(credits, idempotency_key=key, event_type=name)
|
|
347
|
+
return (lambda reason: self.refund(key, reason)) if refund_on_error else None
|
|
348
|
+
|
|
349
|
+
return _wrap(func, lambda: self.check(features), charge)
|
|
350
|
+
|
|
351
|
+
return decorate
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _is_error_result(out: Any) -> bool:
|
|
355
|
+
if isinstance(out, Mapping):
|
|
356
|
+
return out.get("isError") is True
|
|
357
|
+
return getattr(out, "isError", None) is True
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _wrap(func: F, check: Callable[[], Any], charge: Optional[Callable[[], Any]]) -> F:
|
|
361
|
+
"""``charge`` debits and may return a ``refund(reason)`` callback used if the tool fails."""
|
|
362
|
+
if inspect.iscoroutinefunction(func):
|
|
363
|
+
|
|
364
|
+
@functools.wraps(func)
|
|
365
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
366
|
+
check()
|
|
367
|
+
undo = await asyncio.to_thread(charge) if charge is not None else None
|
|
368
|
+
try:
|
|
369
|
+
out = await func(*args, **kwargs)
|
|
370
|
+
except BaseException:
|
|
371
|
+
if undo is not None:
|
|
372
|
+
await asyncio.to_thread(undo, "tool_error")
|
|
373
|
+
raise
|
|
374
|
+
if undo is not None and _is_error_result(out):
|
|
375
|
+
await asyncio.to_thread(undo, "tool_error_result")
|
|
376
|
+
return out
|
|
377
|
+
|
|
378
|
+
return cast(F, async_wrapper)
|
|
379
|
+
|
|
380
|
+
@functools.wraps(func)
|
|
381
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
382
|
+
check()
|
|
383
|
+
undo = charge() if charge is not None else None
|
|
384
|
+
try:
|
|
385
|
+
out = func(*args, **kwargs)
|
|
386
|
+
except BaseException:
|
|
387
|
+
if undo is not None:
|
|
388
|
+
undo("tool_error")
|
|
389
|
+
raise
|
|
390
|
+
if undo is not None and _is_error_result(out):
|
|
391
|
+
undo("tool_error_result")
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
return cast(F, sync_wrapper)
|
keyflint_verify/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keyflint-verify
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Zero-dependency offline verifier and MCP tool gating for KeyFlint Ed25519 license tokens
|
|
5
|
+
Author: ArceeDev
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://keyflint.dev/
|
|
8
|
+
Project-URL: Source, https://github.com/ArceeDev/keyflint/tree/main/sdks/python-verify
|
|
9
|
+
Project-URL: Issues, https://github.com/ArceeDev/keyflint/issues
|
|
10
|
+
Keywords: license,licensing,ed25519,offline,keyflint,mcp
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: fast
|
|
21
|
+
Requires-Dist: cryptography>=43.0.0; extra == "fast"
|
|
22
|
+
Provides-Extra: mcp
|
|
23
|
+
Requires-Dist: mcp>=1.2; extra == "mcp"
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
26
|
+
Requires-Dist: cryptography>=43.0.0; extra == "test"
|
|
27
|
+
Requires-Dist: mcp>=1.2; extra == "test"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# keyflint-verify
|
|
31
|
+
|
|
32
|
+
Offline verifier for KeyFlint license tokens (`LIC-<TIER>-<PAYLOAD>.<SIGNATURE>`). It is pure Python standard library: no C compiler and no dependencies. If `cryptography` is installed it is used automatically (`pip install keyflint-verify[fast]`).
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from keyflint_verify import verify_license
|
|
36
|
+
r = verify_license("LIC-PRO-eyJ...", PUBLIC_KEY_HEX, machine_id="ws-node-01")
|
|
37
|
+
if not r: raise SystemExit(r.error) # e.g. ERR_LICENSE_EXPIRED
|
|
38
|
+
print(r.payload["tier"], r.payload["features"])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## MCP servers
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from keyflint_verify.mcp import KeyFlint
|
|
45
|
+
kf = KeyFlint() # KEYFLINT_LICENSE, KEYFLINT_PUBLIC_KEYS, KEYFLINT_URL, KEYFLINT_CHECKOUT_URL
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
@kf.require_license(features=["core"]) # offline only
|
|
49
|
+
def search(query: str) -> str: ...
|
|
50
|
+
|
|
51
|
+
@mcp.tool()
|
|
52
|
+
@kf.metered(credits=5) # offline check + POST /api/v1/quota/deduct
|
|
53
|
+
async def summarize(url: str) -> str: ...
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- Works with sync and async tools and with MCPServer / FastMCP (`mcp` v1 and v2). Put the KeyFlint decorator **below** `@mcp.tool()`. The signature is preserved, so the tool schema does not change.
|
|
57
|
+
- `@metered` charges **before** the tool runs. It sends an automatic `Idempotency-Key` and reuses it on its single network retry, so a retry never charges twice.
|
|
58
|
+
- Failures raise `LicenseError`, a subclass of the SDK's standard MCP error. The client receives a JSON-RPC error: `-32060` invalid license, `-32061` feature not in plan, `-32062` credits exhausted (HTTP 402, with `checkout_url` in `data`), `-32063` gateway error (`-32042` is reserved by MCP for URL elicitation). Each message states the cause, whether credits were charged, and the next step.
|
|
59
|
+
- For node-locked licenses the machine id is computed automatically with the same algorithm as the gateway (`keyflint_verify.fingerprint`).
|
|
60
|
+
|
|
61
|
+
## Key rotation
|
|
62
|
+
|
|
63
|
+
`public_keys` can be a single hex key, a list of historical keys, or a `{kid: hex}` mapping. Tokens without `kid` (every token issued by KeyFlint v1.0) are tried against all the keys. With a `{kid: hex}` mapping, a token with a signed `kid` is checked only against that key (`ERR_UNKNOWN_KID` if absent); strings and lists are tried key by key. `KEYFLINT_PUBLIC_KEYS` accepts the gateway's published keyring and mixed lists (`oldhex,k2=newhex`). To contain a leaked key, remove it from the ring. `r.key_id` tells you which key verified the token.
|
|
64
|
+
|
|
65
|
+
## Guarantees
|
|
66
|
+
|
|
67
|
+
- `validate_offline` is a byte-for-byte copy of the gateway's `offline_validator.py`. A test fails if the two drift apart.
|
|
68
|
+
- Tokens whose signed `issued_at` is more than 300 s ahead of the local clock fail with `ERR_CLOCK_BEFORE_ISSUANCE` (rolled-back clock). Expiry has no extra tolerance. Offline checks cannot stop a determined clock rollback; use short expirations with online renewal when that matters.
|
|
69
|
+
- The same 47 conformance vectors as the npm `keyflint-verify` package, run on both the OpenSSL path and the pure RFC 8032 path.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m unittest discover -s tests # from sdks/python-verify, with src on PYTHONPATH or after pip install -e .
|
|
73
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
keyflint_verify/__init__.py,sha256=K6XkiZlW8PYWfxsLlfwfUwLk6VXdd1EoZiZeFGf3fZo,4349
|
|
2
|
+
keyflint_verify/_validator.py,sha256=FyG1vX-becWOhwriCY1OJmbimeW_Gl7Qscp7ogjJWl8,12331
|
|
3
|
+
keyflint_verify/fingerprint.py,sha256=W26cZvfdx1kvVNn2tpGeYDBupAQapjYYwx6P8LblegE,1066
|
|
4
|
+
keyflint_verify/mcp.py,sha256=vpOkow6LpjWEe34Reg--NS5Rr8IIHD22StzhB0G2_Dk,17069
|
|
5
|
+
keyflint_verify/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
keyflint_verify-1.1.0.dist-info/licenses/LICENSE,sha256=qET3uc7Ij0PfRpNxL8KxYA8_saqGlAv7RlcpnU2P92w,1078
|
|
7
|
+
keyflint_verify-1.1.0.dist-info/METADATA,sha256=SRRPTYjiZIjDxa91CIGavsnLyxO9vDvtguJivqJmRmo,4187
|
|
8
|
+
keyflint_verify-1.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
keyflint_verify-1.1.0.dist-info/top_level.txt,sha256=_p5jRNdpaVaWUfSwGNz7U17-91wMc44WtabItE__0jg,16
|
|
10
|
+
keyflint_verify-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KeyFlint Contributors
|
|
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 @@
|
|
|
1
|
+
keyflint_verify
|