keyflint-verify 1.1.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 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,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,44 @@
1
+ # keyflint-verify
2
+
3
+ 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]`).
4
+
5
+ ```python
6
+ from keyflint_verify import verify_license
7
+ r = verify_license("LIC-PRO-eyJ...", PUBLIC_KEY_HEX, machine_id="ws-node-01")
8
+ if not r: raise SystemExit(r.error) # e.g. ERR_LICENSE_EXPIRED
9
+ print(r.payload["tier"], r.payload["features"])
10
+ ```
11
+
12
+ ## MCP servers
13
+
14
+ ```python
15
+ from keyflint_verify.mcp import KeyFlint
16
+ kf = KeyFlint() # KEYFLINT_LICENSE, KEYFLINT_PUBLIC_KEYS, KEYFLINT_URL, KEYFLINT_CHECKOUT_URL
17
+
18
+ @mcp.tool()
19
+ @kf.require_license(features=["core"]) # offline only
20
+ def search(query: str) -> str: ...
21
+
22
+ @mcp.tool()
23
+ @kf.metered(credits=5) # offline check + POST /api/v1/quota/deduct
24
+ async def summarize(url: str) -> str: ...
25
+ ```
26
+
27
+ - 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.
28
+ - `@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.
29
+ - 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.
30
+ - For node-locked licenses the machine id is computed automatically with the same algorithm as the gateway (`keyflint_verify.fingerprint`).
31
+
32
+ ## Key rotation
33
+
34
+ `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.
35
+
36
+ ## Guarantees
37
+
38
+ - `validate_offline` is a byte-for-byte copy of the gateway's `offline_validator.py`. A test fails if the two drift apart.
39
+ - 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.
40
+ - The same 47 conformance vectors as the npm `keyflint-verify` package, run on both the OpenSSL path and the pure RFC 8032 path.
41
+
42
+ ```bash
43
+ python -m unittest discover -s tests # from sdks/python-verify, with src on PYTHONPATH or after pip install -e .
44
+ ```
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keyflint-verify"
7
+ version = "1.1.0"
8
+ description = "Zero-dependency offline verifier and MCP tool gating for KeyFlint Ed25519 license tokens"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "ArceeDev" }]
14
+ keywords = ["license", "licensing", "ed25519", "offline", "keyflint", "mcp"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Security :: Cryptography",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.optional-dependencies]
26
+ fast = ["cryptography>=43.0.0"]
27
+ mcp = ["mcp>=1.2"]
28
+ test = ["pytest>=8", "cryptography>=43.0.0", "mcp>=1.2"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://keyflint.dev/"
32
+ Source = "https://github.com/ArceeDev/keyflint/tree/main/sdks/python-verify"
33
+ Issues = "https://github.com/ArceeDev/keyflint/issues"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ keyflint_verify = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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