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