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.
- keyflint_verify-1.1.0/LICENSE +21 -0
- keyflint_verify-1.1.0/PKG-INFO +73 -0
- keyflint_verify-1.1.0/README.md +44 -0
- keyflint_verify-1.1.0/pyproject.toml +39 -0
- keyflint_verify-1.1.0/setup.cfg +4 -0
- keyflint_verify-1.1.0/src/keyflint_verify/__init__.py +119 -0
- keyflint_verify-1.1.0/src/keyflint_verify/_validator.py +369 -0
- keyflint_verify-1.1.0/src/keyflint_verify/fingerprint.py +34 -0
- keyflint_verify-1.1.0/src/keyflint_verify/mcp.py +394 -0
- keyflint_verify-1.1.0/src/keyflint_verify/py.typed +0 -0
- keyflint_verify-1.1.0/src/keyflint_verify.egg-info/PKG-INFO +73 -0
- keyflint_verify-1.1.0/src/keyflint_verify.egg-info/SOURCES.txt +15 -0
- keyflint_verify-1.1.0/src/keyflint_verify.egg-info/dependency_links.txt +1 -0
- keyflint_verify-1.1.0/src/keyflint_verify.egg-info/requires.txt +11 -0
- keyflint_verify-1.1.0/src/keyflint_verify.egg-info/top_level.txt +1 -0
- keyflint_verify-1.1.0/tests/test_mcp.py +271 -0
- keyflint_verify-1.1.0/tests/test_vectors.py +82 -0
|
@@ -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,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
|