xenterprise 0.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.
- xenterprise/__init__.py +55 -0
- xenterprise/client.py +128 -0
- xenterprise/crypto.py +104 -0
- xenterprise/validate.py +255 -0
- xenterprise-0.1.0.dist-info/METADATA +141 -0
- xenterprise-0.1.0.dist-info/RECORD +9 -0
- xenterprise-0.1.0.dist-info/WHEEL +5 -0
- xenterprise-0.1.0.dist-info/licenses/LICENSE +27 -0
- xenterprise-0.1.0.dist-info/top_level.txt +1 -0
xenterprise/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""xenterprise: offline validation of Vauxoo RS256-JWT license tokens.
|
|
2
|
+
|
|
3
|
+
Pure-Python client library extracted from the Vauxoo ``base_license`` Odoo
|
|
4
|
+
module. A proprietary module gates itself by listing ``xenterprise`` in
|
|
5
|
+
``external_dependencies.python`` and calling :func:`validate` with the vendor
|
|
6
|
+
public key; the signing server (``license_manager``) is unchanged.
|
|
7
|
+
|
|
8
|
+
Public API::
|
|
9
|
+
|
|
10
|
+
import xenterprise
|
|
11
|
+
from xenterprise import (
|
|
12
|
+
validate, Verdict,
|
|
13
|
+
LicenseError, LicenseInvalid, LicenseRevoked,
|
|
14
|
+
obfuscate_token, deobfuscate_token, compute_hwid,
|
|
15
|
+
DEFAULT_ISSUER, DEFAULT_SERVER_URL,
|
|
16
|
+
)
|
|
17
|
+
from xenterprise import client # optional HTTP to the vendor
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from . import client
|
|
21
|
+
from .crypto import (
|
|
22
|
+
CONTACT_INFO,
|
|
23
|
+
DEFAULT_ISSUER,
|
|
24
|
+
DEFAULT_SERVER_URL,
|
|
25
|
+
JWT_LEEWAY,
|
|
26
|
+
PUBLIC_KEY_PEM_PLACEHOLDER,
|
|
27
|
+
LicenseError,
|
|
28
|
+
LicenseInvalid,
|
|
29
|
+
LicenseRevoked,
|
|
30
|
+
compute_hwid,
|
|
31
|
+
deobfuscate_token,
|
|
32
|
+
obfuscate_token,
|
|
33
|
+
)
|
|
34
|
+
from .validate import Verdict, decode_token, validate
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"__version__",
|
|
40
|
+
"validate",
|
|
41
|
+
"Verdict",
|
|
42
|
+
"decode_token",
|
|
43
|
+
"client",
|
|
44
|
+
"LicenseError",
|
|
45
|
+
"LicenseInvalid",
|
|
46
|
+
"LicenseRevoked",
|
|
47
|
+
"obfuscate_token",
|
|
48
|
+
"deobfuscate_token",
|
|
49
|
+
"compute_hwid",
|
|
50
|
+
"DEFAULT_ISSUER",
|
|
51
|
+
"DEFAULT_SERVER_URL",
|
|
52
|
+
"JWT_LEEWAY",
|
|
53
|
+
"CONTACT_INFO",
|
|
54
|
+
"PUBLIC_KEY_PEM_PLACEHOLDER",
|
|
55
|
+
]
|
xenterprise/client.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Optional HTTP client to the vendor license server (pure ``requests``).
|
|
2
|
+
|
|
3
|
+
This mirrors the HTTP side of ``base_license`` (``_call_server`` /
|
|
4
|
+
``_activate`` / ``_refresh``) with no Odoo. The functions return the RAW vendor
|
|
5
|
+
response body so the caller stays in control; :func:`interpret_response` maps
|
|
6
|
+
that body to the package exceptions exactly as ``base_license`` does.
|
|
7
|
+
|
|
8
|
+
The vendor API speaks one shape: HTTP 200 with a body of
|
|
9
|
+
``{"ok": true, "token": ...}`` or ``{"ok": false, "error": "<code>"}``. Codes
|
|
10
|
+
are ``invalid_request | forbidden | revoked | expired | rate_limited |
|
|
11
|
+
server_error``. ``revoked`` and ``expired`` are definitive; everything else is
|
|
12
|
+
soft (vendor unreachable -> stay in grace, never read as a revocation).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .crypto import (
|
|
18
|
+
DEFAULT_SERVER_URL,
|
|
19
|
+
LicenseInvalid,
|
|
20
|
+
LicenseRevoked,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Network timeout (seconds) for every vendor call. Kept short: a slow or down
|
|
24
|
+
# vendor must never hang a user action; grace handling takes over instead.
|
|
25
|
+
HTTP_TIMEOUT = 10
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _post(server_url, endpoint, payload, timeout, require_https):
|
|
29
|
+
"""POST ``{"params": payload}`` to ``server_url + endpoint`` and return the
|
|
30
|
+
parsed body, unwrapping Odoo's jsonrpc ``{"result": ...}`` envelope.
|
|
31
|
+
|
|
32
|
+
A non-200 status, a non-JSON or non-dict body is a transport failure and
|
|
33
|
+
raises ``requests.RequestException`` (vendor unreachable), never a business
|
|
34
|
+
verdict.
|
|
35
|
+
"""
|
|
36
|
+
url = server_url.rstrip("/") + endpoint
|
|
37
|
+
if require_https and not url.lower().startswith("https://"):
|
|
38
|
+
raise LicenseInvalid("Refusing to contact a non-https license server.")
|
|
39
|
+
resp = requests.post(url, json={"params": payload}, timeout=timeout)
|
|
40
|
+
if resp.status_code != 200:
|
|
41
|
+
raise requests.HTTPError(f"Vendor returned HTTP {resp.status_code}.")
|
|
42
|
+
try:
|
|
43
|
+
data = resp.json()
|
|
44
|
+
except ValueError as err:
|
|
45
|
+
raise requests.RequestException(
|
|
46
|
+
f"Vendor returned a non-JSON body: {err}"
|
|
47
|
+
) from err
|
|
48
|
+
if isinstance(data, dict) and "result" in data:
|
|
49
|
+
data = data["result"]
|
|
50
|
+
if not isinstance(data, dict):
|
|
51
|
+
raise requests.RequestException("Vendor returned an unexpected payload.")
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def activate(
|
|
56
|
+
server_url=DEFAULT_SERVER_URL,
|
|
57
|
+
key=None,
|
|
58
|
+
*,
|
|
59
|
+
database_uuid=None,
|
|
60
|
+
hwid=None,
|
|
61
|
+
timeout=HTTP_TIMEOUT,
|
|
62
|
+
require_https=True,
|
|
63
|
+
**ctx,
|
|
64
|
+
):
|
|
65
|
+
"""First contact: exchange the license ``key`` for a signed token.
|
|
66
|
+
|
|
67
|
+
Returns the raw vendor body, e.g. ``{"ok": True, "token": "<jwt>"}`` or
|
|
68
|
+
``{"ok": False, "error": "<code>"}``. Does not raise on a business error;
|
|
69
|
+
pass the body to :func:`interpret_response` to act on it.
|
|
70
|
+
"""
|
|
71
|
+
payload = {"key": key, "database_uuid": database_uuid, "hwid": hwid}
|
|
72
|
+
payload.update(ctx)
|
|
73
|
+
return _post(server_url, "/v1/activate", payload, timeout, require_https)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def refresh(
|
|
77
|
+
server_url=DEFAULT_SERVER_URL,
|
|
78
|
+
token=None,
|
|
79
|
+
*,
|
|
80
|
+
key=None,
|
|
81
|
+
database_uuid=None,
|
|
82
|
+
timeout=HTTP_TIMEOUT,
|
|
83
|
+
require_https=True,
|
|
84
|
+
**ctx,
|
|
85
|
+
):
|
|
86
|
+
"""Ask for a fresh token.
|
|
87
|
+
|
|
88
|
+
The vendor's ``/v1/refresh`` resolves the install from ``key`` +
|
|
89
|
+
``database_uuid`` (the current ``token`` is accepted for call-shape parity
|
|
90
|
+
and forwarded as ``token`` in the payload, harmless to the server). Returns
|
|
91
|
+
the raw vendor body, like :func:`activate`.
|
|
92
|
+
"""
|
|
93
|
+
payload = {"key": key, "database_uuid": database_uuid}
|
|
94
|
+
if token is not None:
|
|
95
|
+
payload["token"] = token
|
|
96
|
+
payload.update(ctx)
|
|
97
|
+
return _post(server_url, "/v1/refresh", payload, timeout, require_https)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def sandbox(
|
|
101
|
+
server_url=DEFAULT_SERVER_URL,
|
|
102
|
+
credential=None,
|
|
103
|
+
*,
|
|
104
|
+
timeout=HTTP_TIMEOUT,
|
|
105
|
+
require_https=True,
|
|
106
|
+
**ctx,
|
|
107
|
+
):
|
|
108
|
+
"""Self-service sandbox: exchange a sandbox ``credential`` for a sandbox
|
|
109
|
+
token. Returns the raw vendor body, like :func:`activate`."""
|
|
110
|
+
payload = {"credential": credential}
|
|
111
|
+
payload.update(ctx)
|
|
112
|
+
return _post(server_url, "/v1/sandbox", payload, timeout, require_https)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def interpret_response(body):
|
|
116
|
+
"""Map a raw vendor body to a token string, or raise.
|
|
117
|
+
|
|
118
|
+
- ``error in (revoked, expired)`` -> :class:`~xenterprise.LicenseRevoked`
|
|
119
|
+
(definitive, blocks even inside grace).
|
|
120
|
+
- any other ``error`` code -> :class:`~xenterprise.LicenseInvalid` (soft).
|
|
121
|
+
- otherwise return ``body.get("token")`` (possibly ``None``).
|
|
122
|
+
"""
|
|
123
|
+
error = body.get("error")
|
|
124
|
+
if error:
|
|
125
|
+
if error in ("revoked", "expired"):
|
|
126
|
+
raise LicenseRevoked(str(error))
|
|
127
|
+
raise LicenseInvalid(str(error))
|
|
128
|
+
return body.get("token")
|
xenterprise/crypto.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Crypto helpers, constants and exceptions for the xenterprise client.
|
|
2
|
+
|
|
3
|
+
This module is pure Python (no Odoo imports). It is the extraction of the
|
|
4
|
+
``license_crypto`` module that ships inside the Vauxoo ``base_license`` Odoo
|
|
5
|
+
module, so a proprietary module can validate a vendor-signed license token by
|
|
6
|
+
depending on this PyPI package instead of on the Odoo module.
|
|
7
|
+
|
|
8
|
+
Security note: on-premise the customer controls the runtime, so the token cache
|
|
9
|
+
obfuscation here is *obfuscation*, not real secrecy. The real protections are
|
|
10
|
+
(a) the RS256 signature verified offline with the vendor public key and (b) the
|
|
11
|
+
OPL-1 contract.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
|
|
17
|
+
# -----------------------------------------------------------------------------
|
|
18
|
+
# Vendor public key (RS256)
|
|
19
|
+
# -----------------------------------------------------------------------------
|
|
20
|
+
# The vendor (license_manager, running at enterprise.vauxoo.ai) signs tokens
|
|
21
|
+
# with its PRIVATE key. The client validates OFFLINE with the matching PUBLIC
|
|
22
|
+
# key. There is NO production key embedded in this package: the caller passes
|
|
23
|
+
# the vendor public key to :func:`xenterprise.validate` at runtime
|
|
24
|
+
# (``pubkey_pem=...``). The placeholder below is only a clearly-marked default
|
|
25
|
+
# so misconfiguration fails closed instead of trusting an attacker key.
|
|
26
|
+
#
|
|
27
|
+
# !!! PLACEHOLDER -- NOT A REAL KEY !!!
|
|
28
|
+
# Fetch the real public key from ``GET https://enterprise.vauxoo.ai/v1/pubkey``
|
|
29
|
+
# (or from the license_manager settings screen) and pass it at runtime. Never
|
|
30
|
+
# commit a private key here.
|
|
31
|
+
PUBLIC_KEY_PEM_PLACEHOLDER = """-----BEGIN PUBLIC KEY-----
|
|
32
|
+
REPLACE_WITH_VENDOR_PUBLIC_KEY_FROM_license_manager
|
|
33
|
+
-----END PUBLIC KEY-----"""
|
|
34
|
+
|
|
35
|
+
# Expected ``iss`` claim; tokens with any other issuer are rejected.
|
|
36
|
+
DEFAULT_ISSUER = "vauxoo-license"
|
|
37
|
+
|
|
38
|
+
# Default vendor endpoint.
|
|
39
|
+
DEFAULT_SERVER_URL = "https://enterprise.vauxoo.ai"
|
|
40
|
+
|
|
41
|
+
# Leeway (seconds) applied to every time comparison (iat floor, clock rollback,
|
|
42
|
+
# JWT decode) so a small, legitimate clock skew is not treated as an attack.
|
|
43
|
+
JWT_LEEWAY = 300
|
|
44
|
+
|
|
45
|
+
# Contact shown to the user when a license is invalid.
|
|
46
|
+
CONTACT_INFO = (
|
|
47
|
+
"Please contact Vauxoo to renew or activate your license: "
|
|
48
|
+
"ventas@vauxoo.com / https://www.vauxoo.com"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LicenseError(Exception):
|
|
53
|
+
"""Base error for license validation failures."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LicenseInvalid(LicenseError):
|
|
57
|
+
"""Token is missing, malformed, badly signed, bound to another install,
|
|
58
|
+
expired past its grace window, or the product is not granted."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LicenseRevoked(LicenseError):
|
|
62
|
+
"""The vendor explicitly reported the license as ``revoked`` or ``expired``
|
|
63
|
+
in the application-level response body (``{"ok": false, "error": ...}``),
|
|
64
|
+
independent of the HTTP status.
|
|
65
|
+
|
|
66
|
+
This verdict is definitive: it blocks immediately, even inside the grace
|
|
67
|
+
window, and should be persisted by the caller so it survives a restart.
|
|
68
|
+
A bare HTTP 403 (WAF/proxy) is NOT this: it is a transport failure.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fernet_key_from_secret(secret):
|
|
73
|
+
"""Derive a urlsafe-base64 32-byte Fernet key from an arbitrary secret."""
|
|
74
|
+
digest = hashlib.sha256((secret or "base_license").encode("utf-8")).digest()
|
|
75
|
+
return base64.urlsafe_b64encode(digest)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def obfuscate_token(token, secret):
|
|
79
|
+
"""Encrypt the cached JWT so it is not stored as clear text."""
|
|
80
|
+
from cryptography.fernet import Fernet
|
|
81
|
+
|
|
82
|
+
fernet = Fernet(_fernet_key_from_secret(secret))
|
|
83
|
+
return fernet.encrypt(token.encode("utf-8")).decode("ascii")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def deobfuscate_token(blob, secret):
|
|
87
|
+
"""Reverse of :func:`obfuscate_token`. Returns None on any failure."""
|
|
88
|
+
if not blob:
|
|
89
|
+
return None
|
|
90
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
91
|
+
|
|
92
|
+
fernet = Fernet(_fernet_key_from_secret(secret))
|
|
93
|
+
try:
|
|
94
|
+
return fernet.decrypt(blob.encode("ascii")).decode("utf-8")
|
|
95
|
+
except (InvalidToken, ValueError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compute_hwid(hwid_secret):
|
|
100
|
+
"""Install fingerprint: hash of a random secret persisted on first
|
|
101
|
+
activation by the caller. The real hardware id is unreliable in containers,
|
|
102
|
+
so the primary anchor stays the database uuid; this only ties the install
|
|
103
|
+
secret."""
|
|
104
|
+
return hashlib.sha256((hwid_secret or "").encode("utf-8")).hexdigest()
|
xenterprise/validate.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Pure, offline license-token validation core.
|
|
2
|
+
|
|
3
|
+
This is the extraction of the client-side validation that lives today inside
|
|
4
|
+
the ``license.entitlement`` Odoo model of ``base_license``
|
|
5
|
+
(``_check_license_or_raise`` and its helpers), with every Odoo-specific concern
|
|
6
|
+
removed: no ORM, no ``ir.config_parameter``, no network, no persistence. The
|
|
7
|
+
caller owns token caching, the persisted revoke block and the install
|
|
8
|
+
identifiers; this module only decides a verdict for a token it is handed.
|
|
9
|
+
|
|
10
|
+
The grace / expiry / revocation and anti-rollback semantics are kept identical
|
|
11
|
+
to ``base_license`` so a token issued by ``license_manager`` validates the same
|
|
12
|
+
way whether it is checked by the Odoo module or by this package.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import date, datetime, timezone
|
|
18
|
+
|
|
19
|
+
from .crypto import (
|
|
20
|
+
DEFAULT_ISSUER,
|
|
21
|
+
JWT_LEEWAY,
|
|
22
|
+
LicenseInvalid,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Claims the token MUST carry; a missing one fails closed (A4 in base_license).
|
|
26
|
+
_REQUIRED_CLAIMS = ["exp", "iat", "iss", "jti", "mode", "products"]
|
|
27
|
+
_VALID_MODES = ("production", "sandbox")
|
|
28
|
+
|
|
29
|
+
_OK_STATES = ("valid", "grace")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Verdict:
|
|
34
|
+
"""Outcome of :func:`validate`.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
state: one of ``valid``, ``grace``, ``expired``, ``revoked``,
|
|
38
|
+
``invalid``.
|
|
39
|
+
term_end: the product subscription end date, or ``None`` when the
|
|
40
|
+
product is perpetual or no date is known.
|
|
41
|
+
mode: ``production`` / ``sandbox`` from the token, or ``None`` when the
|
|
42
|
+
token could not be decoded.
|
|
43
|
+
claims: the decoded JWT claim set (empty dict when undecodable).
|
|
44
|
+
reason: a short human-readable explanation (empty when ``valid``).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
state: str
|
|
48
|
+
term_end: "date | None" = None
|
|
49
|
+
mode: "str | None" = None
|
|
50
|
+
claims: dict = field(default_factory=dict)
|
|
51
|
+
reason: str = ""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def ok(self):
|
|
55
|
+
"""True iff the license may run now (``valid`` or inside ``grace``)."""
|
|
56
|
+
return self.state in _OK_STATES
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def revoked(cls, reason="This license has been revoked.", claims=None):
|
|
60
|
+
"""Build a definitive ``revoked`` verdict.
|
|
61
|
+
|
|
62
|
+
Revocation is a vendor decision learned out of band (a
|
|
63
|
+
:class:`~xenterprise.LicenseRevoked` raised by
|
|
64
|
+
:mod:`xenterprise.client`, persisted by the caller). ``base_license``
|
|
65
|
+
checks that persisted block FIRST, before any decode; so does
|
|
66
|
+
:func:`validate` when called with ``blocked=True``.
|
|
67
|
+
"""
|
|
68
|
+
return cls(state="revoked", claims=claims or {}, reason=reason)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _to_epoch(moment):
|
|
72
|
+
"""Return a UTC epoch float for ``moment``.
|
|
73
|
+
|
|
74
|
+
``None`` means now. A naive datetime is read as UTC (never local time) to
|
|
75
|
+
match the server, which signs ``iat``/``exp`` as UTC epoch seconds.
|
|
76
|
+
"""
|
|
77
|
+
if moment is None:
|
|
78
|
+
return time.time()
|
|
79
|
+
if moment.tzinfo is None:
|
|
80
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
81
|
+
return moment.timestamp()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def decode_token(token, pubkey_pem, issuer=DEFAULT_ISSUER, leeway=JWT_LEEWAY):
|
|
85
|
+
"""Verify signature + issuer + required claims + mode, offline.
|
|
86
|
+
|
|
87
|
+
``exp``/``aud`` are intentionally NOT verified here: the grace window and
|
|
88
|
+
the sandbox mode are handled afterwards by :func:`validate`. Raises
|
|
89
|
+
:class:`~xenterprise.LicenseInvalid` on any failure (fail closed).
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
import jwt
|
|
93
|
+
except ImportError as err: # pragma: no cover - declared as a dependency
|
|
94
|
+
raise LicenseInvalid(f"PyJWT is not installed: {err}") from err
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
payload = jwt.decode(
|
|
98
|
+
token,
|
|
99
|
+
pubkey_pem,
|
|
100
|
+
algorithms=["RS256"],
|
|
101
|
+
leeway=leeway,
|
|
102
|
+
options={
|
|
103
|
+
"verify_exp": False,
|
|
104
|
+
"verify_aud": False,
|
|
105
|
+
# Fail closed if any security-relevant claim is missing.
|
|
106
|
+
"require": _REQUIRED_CLAIMS,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
except jwt.PyJWTError as err:
|
|
110
|
+
# Broad on purpose: a bad signature, an unexpected/none algorithm, a
|
|
111
|
+
# missing required claim or a bad key must all fail closed.
|
|
112
|
+
raise LicenseInvalid(f"Bad license token: {err}") from err
|
|
113
|
+
if payload.get("iss") != issuer:
|
|
114
|
+
raise LicenseInvalid("Unexpected license issuer.")
|
|
115
|
+
# An unknown mode must never be treated as production or sandbox.
|
|
116
|
+
if payload.get("mode") not in _VALID_MODES:
|
|
117
|
+
raise LicenseInvalid("Unknown license mode.")
|
|
118
|
+
return payload
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_clock_floor(payload, now_ts, last_seen_ts, leeway):
|
|
122
|
+
"""Hard floor against a rolled-back clock, with leeway.
|
|
123
|
+
|
|
124
|
+
Two anchors:
|
|
125
|
+
- the token's own ``iat``: ``now`` cannot be before it (minus leeway);
|
|
126
|
+
- the caller's high-water mark ``last_seen``: ``now`` cannot be before it
|
|
127
|
+
(minus leeway).
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
iat = float(payload.get("iat"))
|
|
131
|
+
except (TypeError, ValueError) as err:
|
|
132
|
+
raise LicenseInvalid("License has no issue time.") from err
|
|
133
|
+
if now_ts < iat - leeway:
|
|
134
|
+
raise LicenseInvalid("System clock is behind the license issue time.")
|
|
135
|
+
if last_seen_ts and now_ts < last_seen_ts - leeway:
|
|
136
|
+
raise LicenseInvalid("System clock moved backwards.")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_term_end(term_end):
|
|
140
|
+
"""Parse ``term_end`` tolerating a malformed value (fail closed)."""
|
|
141
|
+
if not term_end:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
return date.fromisoformat(term_end)
|
|
145
|
+
except (TypeError, ValueError) as err:
|
|
146
|
+
raise LicenseInvalid(
|
|
147
|
+
"License has a malformed subscription end date."
|
|
148
|
+
) from err
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _product_entry(payload, product_code):
|
|
152
|
+
for entry in payload.get("products") or []:
|
|
153
|
+
if entry.get("code") == product_code:
|
|
154
|
+
return entry
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate(
|
|
159
|
+
token,
|
|
160
|
+
*,
|
|
161
|
+
pubkey_pem,
|
|
162
|
+
product_code,
|
|
163
|
+
now=None,
|
|
164
|
+
last_seen=None,
|
|
165
|
+
issuer=DEFAULT_ISSUER,
|
|
166
|
+
leeway=JWT_LEEWAY,
|
|
167
|
+
blocked=False,
|
|
168
|
+
):
|
|
169
|
+
"""Validate ``token`` for ``product_code`` and return a :class:`Verdict`.
|
|
170
|
+
|
|
171
|
+
This is a pure, offline decision: it never performs network I/O and never
|
|
172
|
+
raises for a license outcome (every outcome is a :class:`Verdict` state).
|
|
173
|
+
It mirrors ``base_license._check_license_or_raise`` minus the ORM, the
|
|
174
|
+
network refresh and the persistence:
|
|
175
|
+
|
|
176
|
+
Order: persisted revoke block (``blocked``) -> decode (signature + issuer +
|
|
177
|
+
required claims + mode) -> clock floor (iat + ``last_seen``) ->
|
|
178
|
+
grace / expiry of the token -> product grant + ``term_end``.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
token: the RS256 JWT issued by ``license_manager``.
|
|
182
|
+
pubkey_pem: the vendor PUBLIC key (PEM) used to verify the signature.
|
|
183
|
+
product_code: the vendor catalog code to check, e.g. ``"CR_EDI"``.
|
|
184
|
+
now: the reference moment (tz-aware recommended); ``None`` means now.
|
|
185
|
+
A naive datetime is read as UTC.
|
|
186
|
+
last_seen: the anti-rollback high-water mark the caller persisted on the
|
|
187
|
+
last successful check; ``None`` disables that anchor.
|
|
188
|
+
issuer: the expected ``iss`` claim.
|
|
189
|
+
leeway: seconds of tolerance for every time comparison.
|
|
190
|
+
blocked: when True, the caller has a persisted revoke/expired verdict
|
|
191
|
+
from the vendor; this wins over everything and is checked FIRST,
|
|
192
|
+
before any decode (mirrors ``base_license`` A2).
|
|
193
|
+
|
|
194
|
+
Note on binding: the production ``aud``/``hwid`` binding is deliberately NOT
|
|
195
|
+
checked here, because it needs install-specific identifiers that are the
|
|
196
|
+
integrator's responsibility. The decoded ``claims`` are returned so the
|
|
197
|
+
caller can enforce binding when it wants to.
|
|
198
|
+
"""
|
|
199
|
+
# A persisted revoke/expired verdict wins over everything, checked FIRST.
|
|
200
|
+
if blocked:
|
|
201
|
+
return Verdict.revoked()
|
|
202
|
+
|
|
203
|
+
if not token:
|
|
204
|
+
return Verdict(state="invalid", reason="No active license was found.")
|
|
205
|
+
|
|
206
|
+
now_ts = _to_epoch(now)
|
|
207
|
+
now_date = datetime.fromtimestamp(now_ts, tz=timezone.utc).date()
|
|
208
|
+
last_seen_ts = _to_epoch(last_seen) if last_seen is not None else 0.0
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
payload = decode_token(token, pubkey_pem, issuer=issuer, leeway=leeway)
|
|
212
|
+
mode = payload.get("mode")
|
|
213
|
+
|
|
214
|
+
# Anti clock-rollback floor (iat + last_seen, with leeway).
|
|
215
|
+
_check_clock_floor(payload, now_ts, last_seen_ts, leeway)
|
|
216
|
+
|
|
217
|
+
exp = float(payload.get("exp") or 0)
|
|
218
|
+
grace_s = int(payload.get("grace") or 0) * 86400
|
|
219
|
+
final_state = "valid"
|
|
220
|
+
if now_ts > exp:
|
|
221
|
+
# Offline: no refresh is attempted here. Past the grace window the
|
|
222
|
+
# cached token can no longer carry the install (matches
|
|
223
|
+
# base_license, which treats this as invalid, not expired).
|
|
224
|
+
if now_ts > exp + grace_s:
|
|
225
|
+
raise LicenseInvalid("License expired past grace period.")
|
|
226
|
+
final_state = "grace"
|
|
227
|
+
|
|
228
|
+
# Product must be granted and not past its own term_end.
|
|
229
|
+
entry = _product_entry(payload, product_code)
|
|
230
|
+
if entry is None:
|
|
231
|
+
raise LicenseInvalid(
|
|
232
|
+
f"License does not grant product {product_code}."
|
|
233
|
+
)
|
|
234
|
+
term_date = _parse_term_end(entry.get("term_end"))
|
|
235
|
+
if term_date and term_date < now_date:
|
|
236
|
+
return Verdict(
|
|
237
|
+
state="expired",
|
|
238
|
+
term_end=term_date,
|
|
239
|
+
mode=mode,
|
|
240
|
+
claims=payload,
|
|
241
|
+
reason=(
|
|
242
|
+
f"Subscription for {product_code} ended on "
|
|
243
|
+
f"{term_date.isoformat()}."
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return Verdict(
|
|
248
|
+
state=final_state,
|
|
249
|
+
term_end=term_date,
|
|
250
|
+
mode=mode,
|
|
251
|
+
claims=payload,
|
|
252
|
+
reason="",
|
|
253
|
+
)
|
|
254
|
+
except LicenseInvalid as err:
|
|
255
|
+
return Verdict(state="invalid", reason=str(err))
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xenterprise
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline validation of Vauxoo RS256-JWT license tokens (base_license client, no Odoo).
|
|
5
|
+
Author-email: Vauxoo <info@vauxoo.com>
|
|
6
|
+
License-Expression: LicenseRef-OPL-1
|
|
7
|
+
Project-URL: Homepage, https://www.vauxoo.com
|
|
8
|
+
Project-URL: Source, https://git.vauxoo.com/vauxoo/xenterprise
|
|
9
|
+
Keywords: license,jwt,rs256,vauxoo,odoo
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pyjwt<3,>=2.8
|
|
21
|
+
Requires-Dist: cryptography
|
|
22
|
+
Requires-Dist: requests
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
25
|
+
Requires-Dist: ruff; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# xenterprise
|
|
29
|
+
|
|
30
|
+
Offline validation of Vauxoo RS256-JWT license tokens — the pure-Python client
|
|
31
|
+
extracted from the Vauxoo `base_license` Odoo module.
|
|
32
|
+
|
|
33
|
+
A proprietary module gates itself by listing `xenterprise` in its
|
|
34
|
+
`external_dependencies.python` (so `pip` installs it on deploy) and calling
|
|
35
|
+
`xenterprise.validate(...)` with the vendor public key. The signing server
|
|
36
|
+
(`license_manager`, running at `https://enterprise.vauxoo.ai`) is unchanged:
|
|
37
|
+
this package only **validates** tokens offline with the vendor **public** key —
|
|
38
|
+
it never signs. Grace / expiry / revocation and anti-rollback semantics are
|
|
39
|
+
identical to `base_license`, so a `license_manager` token validates the same way
|
|
40
|
+
whether it is checked by the Odoo module or by this package.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install xenterprise
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Public API
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import xenterprise
|
|
52
|
+
from xenterprise import (
|
|
53
|
+
validate, Verdict,
|
|
54
|
+
LicenseError, LicenseInvalid, LicenseRevoked,
|
|
55
|
+
obfuscate_token, deobfuscate_token, compute_hwid,
|
|
56
|
+
DEFAULT_ISSUER, DEFAULT_SERVER_URL,
|
|
57
|
+
)
|
|
58
|
+
from xenterprise import client # optional HTTP to the vendor
|
|
59
|
+
|
|
60
|
+
v = xenterprise.validate(
|
|
61
|
+
token, # the RS256 JWT issued by license_manager
|
|
62
|
+
pubkey_pem=vendor_pem, # vendor PUBLIC key (PEM)
|
|
63
|
+
product_code="CR_EDI", # vendor catalog code
|
|
64
|
+
now=None, # datetime | None (naive is read as UTC); None = now
|
|
65
|
+
last_seen=None, # datetime | None, anti-rollback high-water mark
|
|
66
|
+
issuer=DEFAULT_ISSUER, # "vauxoo-license"
|
|
67
|
+
leeway=300, # seconds of clock tolerance
|
|
68
|
+
blocked=False, # True if a vendor revoke/expired verdict is persisted
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if v.ok: # True iff v.state in {"valid", "grace"}
|
|
72
|
+
... # deliver the gated value
|
|
73
|
+
else:
|
|
74
|
+
raise SomeError(v.reason)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`validate` never raises for a license outcome and never does network I/O. It
|
|
78
|
+
returns a `Verdict`:
|
|
79
|
+
|
|
80
|
+
| field | type | notes |
|
|
81
|
+
|-------------|----------------------------------------------------|-----------------------------------------|
|
|
82
|
+
| `.state` | `valid` / `grace` / `expired` / `revoked` / `invalid` | |
|
|
83
|
+
| `.ok` | `bool` | `True` iff `state in {valid, grace}` |
|
|
84
|
+
| `.term_end` | `date \| None` | product subscription end (`None` = perpetual) |
|
|
85
|
+
| `.mode` | `production` / `sandbox` / `None` | `None` when the token did not decode |
|
|
86
|
+
| `.claims` | `dict` | the decoded JWT claims (empty if undecodable) |
|
|
87
|
+
| `.reason` | `str` | human-readable explanation |
|
|
88
|
+
|
|
89
|
+
### State semantics (identical to `base_license`)
|
|
90
|
+
|
|
91
|
+
- **valid** — signature, issuer, required claims and mode check out; the token
|
|
92
|
+
is not past its `exp`; the product is granted and its `term_end` is in the
|
|
93
|
+
future (or perpetual).
|
|
94
|
+
- **grace** — the token is past `exp` but still inside the `grace` window
|
|
95
|
+
(`grace` days from the token). Keep working; refresh when the vendor is
|
|
96
|
+
reachable.
|
|
97
|
+
- **expired** — the product subscription `term_end` is in the past.
|
|
98
|
+
- **revoked** — definitive. Produced from `blocked=True` (a persisted vendor
|
|
99
|
+
`revoked`/`expired` verdict) or `Verdict.revoked(...)`. Checked first, before
|
|
100
|
+
any decode.
|
|
101
|
+
- **invalid** — missing/malformed token, bad signature, wrong issuer, missing
|
|
102
|
+
required claim, unknown mode, product not granted, clock rolled back, or the
|
|
103
|
+
token is expired **past** its grace window.
|
|
104
|
+
|
|
105
|
+
### Binding (`aud` / `hwid`)
|
|
106
|
+
|
|
107
|
+
`validate` deliberately does **not** check the production `aud`/`hwid` binding,
|
|
108
|
+
because those identifiers are install-specific. The decoded `claims` are
|
|
109
|
+
returned so an integrator can enforce binding with the identifiers it owns
|
|
110
|
+
(e.g. compare `claims["aud"]` to the database uuid and `claims["hwid"]` to
|
|
111
|
+
`compute_hwid(secret)`).
|
|
112
|
+
|
|
113
|
+
### Optional HTTP client
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from xenterprise import client
|
|
117
|
+
|
|
118
|
+
body = client.activate(server_url, key, database_uuid=..., hwid=...)
|
|
119
|
+
body = client.refresh(server_url, key=..., database_uuid=...)
|
|
120
|
+
token = client.interpret_response(body) # raises LicenseRevoked / LicenseInvalid
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The client returns the raw vendor body (`{"ok": ..., "token"/"error": ...}`)
|
|
124
|
+
with a short timeout; `interpret_response` maps `revoked`/`expired` to
|
|
125
|
+
`LicenseRevoked` (definitive) and any other error code to `LicenseInvalid`
|
|
126
|
+
(soft — the vendor being unreachable must leave you in grace, never read as a
|
|
127
|
+
revocation). Plain `http://` is refused unless `require_https=False`.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python -m venv .venv && . .venv/bin/activate
|
|
133
|
+
pip install -e ".[test]"
|
|
134
|
+
ruff check .
|
|
135
|
+
pytest
|
|
136
|
+
python -m build # wheel + sdist in dist/
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
Odoo Proprietary License v1.0 (OPL-1). See `LICENSE`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
xenterprise/__init__.py,sha256=Y9VcO6lr0QNoOFtatMGE5IdU9jU3uuynzzjH_xe0XiQ,1395
|
|
2
|
+
xenterprise/client.py,sha256=H7ioJ4n2gRCn8wEkwi2_yTCKHo4D8N9609C01NiocrI,4530
|
|
3
|
+
xenterprise/crypto.py,sha256=y_PNpE73A1C4fb50qLdKQFysM15cMhF0qzv6QztNfbE,4205
|
|
4
|
+
xenterprise/validate.py,sha256=_W49luJcSLpZd568FAwoQ9AOhYWeigOfVzowYZucj2A,9659
|
|
5
|
+
xenterprise-0.1.0.dist-info/licenses/LICENSE,sha256=Au9MArOLyioBvHBNiByYSqUVTo84CF3wM-emHlvu-wI,1472
|
|
6
|
+
xenterprise-0.1.0.dist-info/METADATA,sha256=JrKtYUhahcemVd8anYSgwJ5lFrtPzXzJzUtdsfGwWYs,5892
|
|
7
|
+
xenterprise-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
xenterprise-0.1.0.dist-info/top_level.txt,sha256=0EbIEIUllt-MIvEc8J1-1_MG9ranVeR2WPdahr8xq5g,12
|
|
9
|
+
xenterprise-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Odoo Proprietary License v1.0
|
|
2
|
+
|
|
3
|
+
This software and associated files (the "Software") may only be used (executed,
|
|
4
|
+
modified, executed after modifications) if you have purchased a valid license
|
|
5
|
+
from the authors, typically via Odoo Apps, or if you have received a written
|
|
6
|
+
agreement from the authors of the Software (see the COPYRIGHT file).
|
|
7
|
+
|
|
8
|
+
You may develop Odoo modules that use the Software as a library (typically
|
|
9
|
+
by depending on it, importing it and using its resources), but without copying
|
|
10
|
+
any source code or material from the Software. You may distribute those
|
|
11
|
+
modules under the license of your choice, provided that this license is
|
|
12
|
+
compatible with the terms of the Odoo Proprietary License (For example:
|
|
13
|
+
LGPL, MIT, or proprietary licenses similar to this one).
|
|
14
|
+
|
|
15
|
+
It is forbidden to publish, distribute, sublicense, or sell copies of the Software
|
|
16
|
+
or modified copies of the Software.
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice must be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
24
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
25
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
26
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
27
|
+
DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xenterprise
|