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.
@@ -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)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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