xenterprise 0.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.
- xenterprise-0.1.0/LICENSE +27 -0
- xenterprise-0.1.0/PKG-INFO +141 -0
- xenterprise-0.1.0/README.md +114 -0
- xenterprise-0.1.0/pyproject.toml +49 -0
- xenterprise-0.1.0/setup.cfg +4 -0
- xenterprise-0.1.0/src/xenterprise/__init__.py +55 -0
- xenterprise-0.1.0/src/xenterprise/client.py +128 -0
- xenterprise-0.1.0/src/xenterprise/crypto.py +104 -0
- xenterprise-0.1.0/src/xenterprise/validate.py +255 -0
- xenterprise-0.1.0/src/xenterprise.egg-info/PKG-INFO +141 -0
- xenterprise-0.1.0/src/xenterprise.egg-info/SOURCES.txt +15 -0
- xenterprise-0.1.0/src/xenterprise.egg-info/dependency_links.txt +1 -0
- xenterprise-0.1.0/src/xenterprise.egg-info/requires.txt +7 -0
- xenterprise-0.1.0/src/xenterprise.egg-info/top_level.txt +1 -0
- xenterprise-0.1.0/tests/test_client.py +113 -0
- xenterprise-0.1.0/tests/test_crypto.py +40 -0
- xenterprise-0.1.0/tests/test_validate.py +197 -0
|
@@ -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,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,114 @@
|
|
|
1
|
+
# xenterprise
|
|
2
|
+
|
|
3
|
+
Offline validation of Vauxoo RS256-JWT license tokens — the pure-Python client
|
|
4
|
+
extracted from the Vauxoo `base_license` Odoo module.
|
|
5
|
+
|
|
6
|
+
A proprietary module gates itself by listing `xenterprise` in its
|
|
7
|
+
`external_dependencies.python` (so `pip` installs it on deploy) and calling
|
|
8
|
+
`xenterprise.validate(...)` with the vendor public key. The signing server
|
|
9
|
+
(`license_manager`, running at `https://enterprise.vauxoo.ai`) is unchanged:
|
|
10
|
+
this package only **validates** tokens offline with the vendor **public** key —
|
|
11
|
+
it never signs. Grace / expiry / revocation and anti-rollback semantics are
|
|
12
|
+
identical to `base_license`, so a `license_manager` token validates the same way
|
|
13
|
+
whether it is checked by the Odoo module or by this package.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install xenterprise
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Public API
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import xenterprise
|
|
25
|
+
from xenterprise import (
|
|
26
|
+
validate, Verdict,
|
|
27
|
+
LicenseError, LicenseInvalid, LicenseRevoked,
|
|
28
|
+
obfuscate_token, deobfuscate_token, compute_hwid,
|
|
29
|
+
DEFAULT_ISSUER, DEFAULT_SERVER_URL,
|
|
30
|
+
)
|
|
31
|
+
from xenterprise import client # optional HTTP to the vendor
|
|
32
|
+
|
|
33
|
+
v = xenterprise.validate(
|
|
34
|
+
token, # the RS256 JWT issued by license_manager
|
|
35
|
+
pubkey_pem=vendor_pem, # vendor PUBLIC key (PEM)
|
|
36
|
+
product_code="CR_EDI", # vendor catalog code
|
|
37
|
+
now=None, # datetime | None (naive is read as UTC); None = now
|
|
38
|
+
last_seen=None, # datetime | None, anti-rollback high-water mark
|
|
39
|
+
issuer=DEFAULT_ISSUER, # "vauxoo-license"
|
|
40
|
+
leeway=300, # seconds of clock tolerance
|
|
41
|
+
blocked=False, # True if a vendor revoke/expired verdict is persisted
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if v.ok: # True iff v.state in {"valid", "grace"}
|
|
45
|
+
... # deliver the gated value
|
|
46
|
+
else:
|
|
47
|
+
raise SomeError(v.reason)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`validate` never raises for a license outcome and never does network I/O. It
|
|
51
|
+
returns a `Verdict`:
|
|
52
|
+
|
|
53
|
+
| field | type | notes |
|
|
54
|
+
|-------------|----------------------------------------------------|-----------------------------------------|
|
|
55
|
+
| `.state` | `valid` / `grace` / `expired` / `revoked` / `invalid` | |
|
|
56
|
+
| `.ok` | `bool` | `True` iff `state in {valid, grace}` |
|
|
57
|
+
| `.term_end` | `date \| None` | product subscription end (`None` = perpetual) |
|
|
58
|
+
| `.mode` | `production` / `sandbox` / `None` | `None` when the token did not decode |
|
|
59
|
+
| `.claims` | `dict` | the decoded JWT claims (empty if undecodable) |
|
|
60
|
+
| `.reason` | `str` | human-readable explanation |
|
|
61
|
+
|
|
62
|
+
### State semantics (identical to `base_license`)
|
|
63
|
+
|
|
64
|
+
- **valid** — signature, issuer, required claims and mode check out; the token
|
|
65
|
+
is not past its `exp`; the product is granted and its `term_end` is in the
|
|
66
|
+
future (or perpetual).
|
|
67
|
+
- **grace** — the token is past `exp` but still inside the `grace` window
|
|
68
|
+
(`grace` days from the token). Keep working; refresh when the vendor is
|
|
69
|
+
reachable.
|
|
70
|
+
- **expired** — the product subscription `term_end` is in the past.
|
|
71
|
+
- **revoked** — definitive. Produced from `blocked=True` (a persisted vendor
|
|
72
|
+
`revoked`/`expired` verdict) or `Verdict.revoked(...)`. Checked first, before
|
|
73
|
+
any decode.
|
|
74
|
+
- **invalid** — missing/malformed token, bad signature, wrong issuer, missing
|
|
75
|
+
required claim, unknown mode, product not granted, clock rolled back, or the
|
|
76
|
+
token is expired **past** its grace window.
|
|
77
|
+
|
|
78
|
+
### Binding (`aud` / `hwid`)
|
|
79
|
+
|
|
80
|
+
`validate` deliberately does **not** check the production `aud`/`hwid` binding,
|
|
81
|
+
because those identifiers are install-specific. The decoded `claims` are
|
|
82
|
+
returned so an integrator can enforce binding with the identifiers it owns
|
|
83
|
+
(e.g. compare `claims["aud"]` to the database uuid and `claims["hwid"]` to
|
|
84
|
+
`compute_hwid(secret)`).
|
|
85
|
+
|
|
86
|
+
### Optional HTTP client
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from xenterprise import client
|
|
90
|
+
|
|
91
|
+
body = client.activate(server_url, key, database_uuid=..., hwid=...)
|
|
92
|
+
body = client.refresh(server_url, key=..., database_uuid=...)
|
|
93
|
+
token = client.interpret_response(body) # raises LicenseRevoked / LicenseInvalid
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The client returns the raw vendor body (`{"ok": ..., "token"/"error": ...}`)
|
|
97
|
+
with a short timeout; `interpret_response` maps `revoked`/`expired` to
|
|
98
|
+
`LicenseRevoked` (definitive) and any other error code to `LicenseInvalid`
|
|
99
|
+
(soft — the vendor being unreachable must leave you in grace, never read as a
|
|
100
|
+
revocation). Plain `http://` is refused unless `require_https=False`.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
python -m venv .venv && . .venv/bin/activate
|
|
106
|
+
pip install -e ".[test]"
|
|
107
|
+
ruff check .
|
|
108
|
+
pytest
|
|
109
|
+
python -m build # wheel + sdist in dist/
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
Odoo Proprietary License v1.0 (OPL-1). See `LICENSE`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xenterprise"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Offline validation of Vauxoo RS256-JWT license tokens (base_license client, no Odoo)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "LicenseRef-OPL-1"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Vauxoo", email = "info@vauxoo.com" }]
|
|
14
|
+
keywords = ["license", "jwt", "rs256", "vauxoo", "odoo"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"pyjwt>=2.8,<3",
|
|
26
|
+
"cryptography",
|
|
27
|
+
"requests",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://www.vauxoo.com"
|
|
32
|
+
Source = "https://git.vauxoo.com/vauxoo/xenterprise"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
test = ["pytest>=7", "ruff"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 88
|
|
42
|
+
target-version = "py310"
|
|
43
|
+
src = ["src", "tests"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "W", "I", "UP", "B"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
]
|
|
@@ -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")
|
|
@@ -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()
|