devkanan 0.2.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.
- devkanan-0.2.0/LICENSE +20 -0
- devkanan-0.2.0/PKG-INFO +120 -0
- devkanan-0.2.0/README.md +101 -0
- devkanan-0.2.0/devkanan/__init__.py +42 -0
- devkanan-0.2.0/devkanan/helpers.py +110 -0
- devkanan-0.2.0/devkanan/key.py +231 -0
- devkanan-0.2.0/devkanan/license_key.py +267 -0
- devkanan-0.2.0/devkanan.egg-info/PKG-INFO +120 -0
- devkanan-0.2.0/devkanan.egg-info/SOURCES.txt +12 -0
- devkanan-0.2.0/devkanan.egg-info/dependency_links.txt +1 -0
- devkanan-0.2.0/devkanan.egg-info/requires.txt +2 -0
- devkanan-0.2.0/devkanan.egg-info/top_level.txt +1 -0
- devkanan-0.2.0/pyproject.toml +32 -0
- devkanan-0.2.0/setup.cfg +4 -0
devkanan-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Vilchis
|
|
6
|
+
|
|
7
|
+
Portions derived from Cryptolens Python SDK (Apache-2.0):
|
|
8
|
+
Copyright (C) 2015-2023 Cryptolens AB
|
|
9
|
+
|
|
10
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
11
|
+
you may not use this file except in compliance with the License.
|
|
12
|
+
You may obtain a copy of the License at
|
|
13
|
+
|
|
14
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
15
|
+
|
|
16
|
+
Unless required by applicable law or agreed to in writing, software
|
|
17
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
18
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
19
|
+
See the License for the specific language governing permissions and
|
|
20
|
+
limitations under the License.
|
devkanan-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devkanan
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Cliente Python para DevKanan — validación local + API online de licencias firmadas RSA
|
|
5
|
+
Author-email: Vilchis <vilchislalo98@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://devkanan.dev
|
|
8
|
+
Keywords: license,licensing,rsa,drm,devkanan,kanan
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: cryptography>=41.0.0
|
|
17
|
+
Requires-Dist: requests>=2.28.0
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# qustomlicensing
|
|
21
|
+
|
|
22
|
+
Cliente Python ligero para validar archivos de licencia `.dat` firmados con RSA, emitidos por **LicensingServer** SaaS o QustomLicenseServer.
|
|
23
|
+
|
|
24
|
+
API estilo Cryptolens — fácil migración si vienes de su SDK.
|
|
25
|
+
|
|
26
|
+
## Instalación
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install qustomlicensing
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
O para desarrollo:
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Uso básico
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from qustomlicensing import LicenseKey, Helpers
|
|
41
|
+
|
|
42
|
+
# Clave pública RSA del tenant (XML o PEM)
|
|
43
|
+
RSA_PUB_KEY = """-----BEGIN PUBLIC KEY-----
|
|
44
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
|
45
|
+
-----END PUBLIC KEY-----"""
|
|
46
|
+
|
|
47
|
+
# Obtener machine code de esta PC
|
|
48
|
+
machine_code = Helpers.GetMachineCode(v=2)
|
|
49
|
+
|
|
50
|
+
# Leer y validar .dat
|
|
51
|
+
with open("license.dat", "r", encoding="utf-8-sig") as f:
|
|
52
|
+
license_key = LicenseKey.load_from_string(RSA_PUB_KEY, f.read(), max_age_days=1)
|
|
53
|
+
|
|
54
|
+
if license_key is None:
|
|
55
|
+
print("Licencia invalida o firma corrupta")
|
|
56
|
+
exit(1)
|
|
57
|
+
|
|
58
|
+
# Verificar esta máquina
|
|
59
|
+
if not license_key.is_on_right_machine(machine_code):
|
|
60
|
+
print("Esta maquina no esta autorizada")
|
|
61
|
+
exit(1)
|
|
62
|
+
|
|
63
|
+
# Verificar features
|
|
64
|
+
if license_key.F1:
|
|
65
|
+
print("Feature Pro activado")
|
|
66
|
+
|
|
67
|
+
print(f"Licencia valida hasta {license_key.expires}")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `Helpers.GetMachineCode(v=2)`
|
|
73
|
+
Genera un machine code único basado en propiedades del hardware. `v=2` es el algoritmo actual (SHA-256 sobre identificadores).
|
|
74
|
+
|
|
75
|
+
### `LicenseKey.load_from_string(rsa_pub_key, content, max_age_days=0)`
|
|
76
|
+
Parsea un `.dat` JSON, verifica firma RSA y freshness.
|
|
77
|
+
|
|
78
|
+
| Parámetro | Tipo | Descripción |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `rsa_pub_key` | str | XML (.NET) o PEM. Auto-detecta. |
|
|
81
|
+
| `content` | str | Contenido del archivo `.dat` |
|
|
82
|
+
| `max_age_days` | int | Máx. días desde `signDate`. 0 = ilimitado o usa el del `.dat`. |
|
|
83
|
+
|
|
84
|
+
Devuelve un objeto `LicenseKey` o `None` si la validación falla.
|
|
85
|
+
|
|
86
|
+
### Atributos de `LicenseKey`
|
|
87
|
+
| Atributo | Tipo |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `product_id`, `id`, `key`, `notes` | datos básicos |
|
|
90
|
+
| `created`, `expires`, `sign_date` | datetimes UTC |
|
|
91
|
+
| `period`, `max_no_of_machines`, `max_offline_days` | enteros |
|
|
92
|
+
| `F1`..`F8`, `block`, `trial_activation` | flags |
|
|
93
|
+
| `activated_machines` | List[ActivatedMachine] |
|
|
94
|
+
| `data_objects` | List[DataObject] |
|
|
95
|
+
| `customer` | Customer o None |
|
|
96
|
+
| `offline_mode` | str (FloatingLease/FloatingManual/Locked) |
|
|
97
|
+
|
|
98
|
+
### `license_key.is_on_right_machine(machine_code)`
|
|
99
|
+
Verifica que el machine code esté en `activatedMachines`.
|
|
100
|
+
|
|
101
|
+
### `license_key.has_not_expired()`
|
|
102
|
+
Verifica que `expires > now`.
|
|
103
|
+
|
|
104
|
+
### `license_key.has_feature(n)`
|
|
105
|
+
Atajo para `getattr(license_key, f'F{n}')`.
|
|
106
|
+
|
|
107
|
+
## Comparación con SDK Cryptolens
|
|
108
|
+
|
|
109
|
+
| | Cryptolens SDK | qustomlicensing |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| Activate online | ✅ | ❌ (usa requests directamente) |
|
|
112
|
+
| Validar .skm/.dat offline | ✅ | ✅ |
|
|
113
|
+
| `Helpers.GetMachineCode` | ✅ | ✅ (compatible) |
|
|
114
|
+
| `LicenseKey.load_from_string` | ✅ | ✅ (compatible) |
|
|
115
|
+
| Tamaño | ~2 MB con deps | ~50 KB |
|
|
116
|
+
| Dependencias | `pycryptodome`, `requests` | solo `cryptography` |
|
|
117
|
+
|
|
118
|
+
## Licencia
|
|
119
|
+
|
|
120
|
+
Apache-2.0. Basado en código del SDK de Cryptolens (también Apache-2.0).
|
devkanan-0.2.0/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# qustomlicensing
|
|
2
|
+
|
|
3
|
+
Cliente Python ligero para validar archivos de licencia `.dat` firmados con RSA, emitidos por **LicensingServer** SaaS o QustomLicenseServer.
|
|
4
|
+
|
|
5
|
+
API estilo Cryptolens — fácil migración si vienes de su SDK.
|
|
6
|
+
|
|
7
|
+
## Instalación
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install qustomlicensing
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
O para desarrollo:
|
|
14
|
+
```bash
|
|
15
|
+
pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Uso básico
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from qustomlicensing import LicenseKey, Helpers
|
|
22
|
+
|
|
23
|
+
# Clave pública RSA del tenant (XML o PEM)
|
|
24
|
+
RSA_PUB_KEY = """-----BEGIN PUBLIC KEY-----
|
|
25
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
|
26
|
+
-----END PUBLIC KEY-----"""
|
|
27
|
+
|
|
28
|
+
# Obtener machine code de esta PC
|
|
29
|
+
machine_code = Helpers.GetMachineCode(v=2)
|
|
30
|
+
|
|
31
|
+
# Leer y validar .dat
|
|
32
|
+
with open("license.dat", "r", encoding="utf-8-sig") as f:
|
|
33
|
+
license_key = LicenseKey.load_from_string(RSA_PUB_KEY, f.read(), max_age_days=1)
|
|
34
|
+
|
|
35
|
+
if license_key is None:
|
|
36
|
+
print("Licencia invalida o firma corrupta")
|
|
37
|
+
exit(1)
|
|
38
|
+
|
|
39
|
+
# Verificar esta máquina
|
|
40
|
+
if not license_key.is_on_right_machine(machine_code):
|
|
41
|
+
print("Esta maquina no esta autorizada")
|
|
42
|
+
exit(1)
|
|
43
|
+
|
|
44
|
+
# Verificar features
|
|
45
|
+
if license_key.F1:
|
|
46
|
+
print("Feature Pro activado")
|
|
47
|
+
|
|
48
|
+
print(f"Licencia valida hasta {license_key.expires}")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
### `Helpers.GetMachineCode(v=2)`
|
|
54
|
+
Genera un machine code único basado en propiedades del hardware. `v=2` es el algoritmo actual (SHA-256 sobre identificadores).
|
|
55
|
+
|
|
56
|
+
### `LicenseKey.load_from_string(rsa_pub_key, content, max_age_days=0)`
|
|
57
|
+
Parsea un `.dat` JSON, verifica firma RSA y freshness.
|
|
58
|
+
|
|
59
|
+
| Parámetro | Tipo | Descripción |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `rsa_pub_key` | str | XML (.NET) o PEM. Auto-detecta. |
|
|
62
|
+
| `content` | str | Contenido del archivo `.dat` |
|
|
63
|
+
| `max_age_days` | int | Máx. días desde `signDate`. 0 = ilimitado o usa el del `.dat`. |
|
|
64
|
+
|
|
65
|
+
Devuelve un objeto `LicenseKey` o `None` si la validación falla.
|
|
66
|
+
|
|
67
|
+
### Atributos de `LicenseKey`
|
|
68
|
+
| Atributo | Tipo |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `product_id`, `id`, `key`, `notes` | datos básicos |
|
|
71
|
+
| `created`, `expires`, `sign_date` | datetimes UTC |
|
|
72
|
+
| `period`, `max_no_of_machines`, `max_offline_days` | enteros |
|
|
73
|
+
| `F1`..`F8`, `block`, `trial_activation` | flags |
|
|
74
|
+
| `activated_machines` | List[ActivatedMachine] |
|
|
75
|
+
| `data_objects` | List[DataObject] |
|
|
76
|
+
| `customer` | Customer o None |
|
|
77
|
+
| `offline_mode` | str (FloatingLease/FloatingManual/Locked) |
|
|
78
|
+
|
|
79
|
+
### `license_key.is_on_right_machine(machine_code)`
|
|
80
|
+
Verifica que el machine code esté en `activatedMachines`.
|
|
81
|
+
|
|
82
|
+
### `license_key.has_not_expired()`
|
|
83
|
+
Verifica que `expires > now`.
|
|
84
|
+
|
|
85
|
+
### `license_key.has_feature(n)`
|
|
86
|
+
Atajo para `getattr(license_key, f'F{n}')`.
|
|
87
|
+
|
|
88
|
+
## Comparación con SDK Cryptolens
|
|
89
|
+
|
|
90
|
+
| | Cryptolens SDK | qustomlicensing |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| Activate online | ✅ | ❌ (usa requests directamente) |
|
|
93
|
+
| Validar .skm/.dat offline | ✅ | ✅ |
|
|
94
|
+
| `Helpers.GetMachineCode` | ✅ | ✅ (compatible) |
|
|
95
|
+
| `LicenseKey.load_from_string` | ✅ | ✅ (compatible) |
|
|
96
|
+
| Tamaño | ~2 MB con deps | ~50 KB |
|
|
97
|
+
| Dependencias | `pycryptodome`, `requests` | solo `cryptography` |
|
|
98
|
+
|
|
99
|
+
## Licencia
|
|
100
|
+
|
|
101
|
+
Apache-2.0. Basado en código del SDK de Cryptolens (también Apache-2.0).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DevKanan — cliente Python para validación y activación de licencias.
|
|
3
|
+
|
|
4
|
+
Uso offline (validar un .dat):
|
|
5
|
+
from devkanan import LicenseKey, Helpers
|
|
6
|
+
|
|
7
|
+
with open("license.dat", encoding="utf-8-sig") as f:
|
|
8
|
+
lk = LicenseKey.load_from_string(RSA_PUB_KEY, f.read(), max_age_days=1)
|
|
9
|
+
|
|
10
|
+
if lk and lk.is_on_right_machine(Helpers.GetMachineCode(v=2)):
|
|
11
|
+
print("OK")
|
|
12
|
+
|
|
13
|
+
Uso online (activar contra el SaaS):
|
|
14
|
+
from devkanan import Key, Helpers
|
|
15
|
+
|
|
16
|
+
license_key, msg = Key.activate(
|
|
17
|
+
token=TOKEN_ACT,
|
|
18
|
+
rsa_pub_key=RSA_PUBLIC_KEY,
|
|
19
|
+
product_id=PRODUCT_ID,
|
|
20
|
+
key=serial_key,
|
|
21
|
+
machine_code=Helpers.GetMachineCode(v=2),
|
|
22
|
+
server_url="https://devkanan.dev", # opcional
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if license_key:
|
|
26
|
+
print(f"Activated: {license_key.key}")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .helpers import Helpers
|
|
30
|
+
from .license_key import LicenseKey, ActivatedMachine, DataObject, Customer
|
|
31
|
+
from .key import Key, Credits
|
|
32
|
+
|
|
33
|
+
__version__ = "0.2.0"
|
|
34
|
+
__all__ = [
|
|
35
|
+
"LicenseKey",
|
|
36
|
+
"Helpers",
|
|
37
|
+
"Key",
|
|
38
|
+
"Credits",
|
|
39
|
+
"ActivatedMachine",
|
|
40
|
+
"DataObject",
|
|
41
|
+
"Customer",
|
|
42
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helpers — machine code generation compatible bit-a-bit con el SDK de Cryptolens.
|
|
3
|
+
|
|
4
|
+
Algoritmos disponibles:
|
|
5
|
+
v=1 → Windows: `wmic csproduct get uuid` (UUID de la BIOS)
|
|
6
|
+
v=2 → Windows: PowerShell `(Get-CimInstance Win32_ComputerSystemProduct).UUID`
|
|
7
|
+
(recomendado — más estable cross-instalación de Windows)
|
|
8
|
+
|
|
9
|
+
Linux/Mac usan el mismo algoritmo en ambas versiones.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import platform
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Helpers:
|
|
18
|
+
"""Wrapper estilo Cryptolens SDK."""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def GetMachineCode(v: int = 2) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Devuelve el SHA-256 hex de un identificador único de hardware.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
v: 1 = algoritmo wmic (legacy), 2 = PowerShell CIM (default, recomendado).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Hash hex de 64 caracteres. Cadena vacía si no se pudo obtener.
|
|
30
|
+
"""
|
|
31
|
+
if "windows" in platform.platform().lower():
|
|
32
|
+
if v == 2:
|
|
33
|
+
seed = HelperMethods.start_process_ps_v2()
|
|
34
|
+
else:
|
|
35
|
+
seed = HelperMethods.start_process(["cmd.exe", "/C", "wmic", "csproduct", "get", "uuid"], v)
|
|
36
|
+
|
|
37
|
+
if not seed:
|
|
38
|
+
machine_guid = HelperMethods.read_registry_value(
|
|
39
|
+
r"SOFTWARE\Microsoft\Cryptography", "MachineGuid"
|
|
40
|
+
)
|
|
41
|
+
if machine_guid:
|
|
42
|
+
return HelperMethods.get_SHA256(machine_guid)
|
|
43
|
+
return ""
|
|
44
|
+
return HelperMethods.get_SHA256(seed)
|
|
45
|
+
|
|
46
|
+
if "mac" in platform.platform().lower() or "darwin" in platform.platform().lower():
|
|
47
|
+
seed = HelperMethods.start_process(["system_profiler", "SPHardwareDataType"], v)
|
|
48
|
+
if not seed:
|
|
49
|
+
return ""
|
|
50
|
+
idx = seed.find("UUID")
|
|
51
|
+
if idx < 0:
|
|
52
|
+
return ""
|
|
53
|
+
return HelperMethods.get_SHA256(seed[idx:].strip())
|
|
54
|
+
|
|
55
|
+
# Linux / otros
|
|
56
|
+
for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
|
|
57
|
+
try:
|
|
58
|
+
with open(path) as f:
|
|
59
|
+
return HelperMethods.get_SHA256(f.read().strip())
|
|
60
|
+
except Exception:
|
|
61
|
+
continue
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HelperMethods:
|
|
66
|
+
"""Métodos internos de bajo nivel — mismas firmas que el SDK Cryptolens."""
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def start_process(cmd: list, v: int = 1) -> str:
|
|
70
|
+
"""Ejecuta un comando y devuelve la primera línea no-header de su output."""
|
|
71
|
+
try:
|
|
72
|
+
proc = subprocess.Popen(
|
|
73
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
|
|
74
|
+
)
|
|
75
|
+
out, _ = proc.communicate(timeout=30)
|
|
76
|
+
text = out.decode("utf-8", errors="replace").strip()
|
|
77
|
+
# Output típico de wmic: "UUID\n<valor>\n" — descartamos el header.
|
|
78
|
+
lines = [l.strip() for l in text.splitlines() if l.strip() and l.strip().upper() != "UUID"]
|
|
79
|
+
return lines[0] if lines else ""
|
|
80
|
+
except Exception:
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def start_process_ps_v2() -> str:
|
|
85
|
+
"""v=2: usa PowerShell para obtener Win32_ComputerSystemProduct.UUID."""
|
|
86
|
+
try:
|
|
87
|
+
proc = subprocess.Popen(
|
|
88
|
+
["powershell", "-Command", "(Get-CimInstance -Class Win32_ComputerSystemProduct).UUID"],
|
|
89
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True,
|
|
90
|
+
)
|
|
91
|
+
out, _ = proc.communicate(timeout=30)
|
|
92
|
+
return out.decode("utf-8", errors="replace").strip()
|
|
93
|
+
except Exception:
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def read_registry_value(subkey: str, name: str) -> str:
|
|
98
|
+
"""Lee un valor de HKEY_LOCAL_MACHINE\\<subkey>. Solo Windows."""
|
|
99
|
+
try:
|
|
100
|
+
import winreg # type: ignore
|
|
101
|
+
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, subkey) as key:
|
|
102
|
+
value, _ = winreg.QueryValueEx(key, name)
|
|
103
|
+
return str(value) if value else ""
|
|
104
|
+
except Exception:
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def get_SHA256(data: str) -> str:
|
|
109
|
+
"""SHA-256 hex en minúsculas."""
|
|
110
|
+
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Key — operaciones online contra el SaaS DevKanan.
|
|
3
|
+
API estilo Cryptolens SDK: Key.activate / Key.deactivate / Key.get_key.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Optional, Tuple
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from .license_key import LicenseKey, _from_dict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_SERVER_URL = "https://devkanan.dev"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Key:
|
|
20
|
+
"""Operaciones contra la API REST de DevKanan."""
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def activate(
|
|
24
|
+
token: str,
|
|
25
|
+
rsa_pub_key: str,
|
|
26
|
+
product_id: int,
|
|
27
|
+
key: str,
|
|
28
|
+
machine_code: str,
|
|
29
|
+
friendly_name: Optional[str] = None,
|
|
30
|
+
floating_time_interval: int = 0,
|
|
31
|
+
max_overdraft: int = 0,
|
|
32
|
+
server_url: str = DEFAULT_SERVER_URL,
|
|
33
|
+
timeout: int = 10,
|
|
34
|
+
) -> Tuple[Optional[LicenseKey], str]:
|
|
35
|
+
"""
|
|
36
|
+
Activa una licencia contra el servidor DevKanan.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
(LicenseKey, mensaje) si éxito.
|
|
40
|
+
(None, mensaje_error) si falla.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
data = {
|
|
44
|
+
"token": token,
|
|
45
|
+
"product_id": product_id,
|
|
46
|
+
"key": key,
|
|
47
|
+
"machine_code": machine_code,
|
|
48
|
+
"max_overdraft": max_overdraft,
|
|
49
|
+
}
|
|
50
|
+
if friendly_name:
|
|
51
|
+
data["friendly_name"] = friendly_name
|
|
52
|
+
if floating_time_interval > 0:
|
|
53
|
+
data["floating_time_interval"] = floating_time_interval
|
|
54
|
+
|
|
55
|
+
r = requests.post(f"{server_url.rstrip('/')}/api/key/activate", data=data, timeout=timeout)
|
|
56
|
+
response = r.json()
|
|
57
|
+
except requests.RequestException as ex:
|
|
58
|
+
return None, f"Network error: {ex}"
|
|
59
|
+
except Exception as ex:
|
|
60
|
+
return None, f"Error: {ex}"
|
|
61
|
+
|
|
62
|
+
if response.get("result") != 0:
|
|
63
|
+
return None, response.get("message", "Unknown error")
|
|
64
|
+
|
|
65
|
+
# Verificar firma del licenseKey devuelto
|
|
66
|
+
license_obj = response.get("licenseKey")
|
|
67
|
+
signature_b64 = response.get("signature", "")
|
|
68
|
+
if not license_obj or not signature_b64:
|
|
69
|
+
return None, "Invalid server response (missing licenseKey or signature)"
|
|
70
|
+
|
|
71
|
+
# Re-serializar como hace el server para validar la firma
|
|
72
|
+
from cryptography.exceptions import InvalidSignature
|
|
73
|
+
from cryptography.hazmat.primitives import hashes
|
|
74
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
75
|
+
from .license_key import _load_public_key
|
|
76
|
+
import base64
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
pub = _load_public_key(rsa_pub_key)
|
|
80
|
+
payload = json.dumps(license_obj, separators=(",", ":"), ensure_ascii=False)
|
|
81
|
+
signature = base64.b64decode(signature_b64)
|
|
82
|
+
pub.verify(signature, payload.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
|
|
83
|
+
except InvalidSignature:
|
|
84
|
+
return None, "Response signature invalid — possible MITM or wrong RSA key"
|
|
85
|
+
except Exception as ex:
|
|
86
|
+
return None, f"Signature verification failed: {ex}"
|
|
87
|
+
|
|
88
|
+
return _from_dict(license_obj), response.get("message", "")
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def deactivate(
|
|
92
|
+
token: str,
|
|
93
|
+
product_id: int,
|
|
94
|
+
key: str,
|
|
95
|
+
machine_code: str,
|
|
96
|
+
floating: bool = False,
|
|
97
|
+
server_url: str = DEFAULT_SERVER_URL,
|
|
98
|
+
timeout: int = 10,
|
|
99
|
+
) -> Tuple[bool, str]:
|
|
100
|
+
"""Libera la máquina actual de una licencia."""
|
|
101
|
+
try:
|
|
102
|
+
r = requests.post(
|
|
103
|
+
f"{server_url.rstrip('/')}/api/key/deactivate",
|
|
104
|
+
data={
|
|
105
|
+
"token": token,
|
|
106
|
+
"product_id": product_id,
|
|
107
|
+
"key": key,
|
|
108
|
+
"machine_code": machine_code,
|
|
109
|
+
"floating": str(floating).lower(),
|
|
110
|
+
},
|
|
111
|
+
timeout=timeout,
|
|
112
|
+
)
|
|
113
|
+
response = r.json()
|
|
114
|
+
except Exception as ex:
|
|
115
|
+
return False, f"Error: {ex}"
|
|
116
|
+
|
|
117
|
+
return response.get("result") == 0, response.get("message", "")
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def get_key(
|
|
121
|
+
token: str,
|
|
122
|
+
rsa_pub_key: str,
|
|
123
|
+
product_id: int,
|
|
124
|
+
key: str,
|
|
125
|
+
server_url: str = DEFAULT_SERVER_URL,
|
|
126
|
+
timeout: int = 10,
|
|
127
|
+
) -> Tuple[Optional[LicenseKey], str]:
|
|
128
|
+
"""Consulta info de una licencia sin activarla."""
|
|
129
|
+
try:
|
|
130
|
+
r = requests.post(
|
|
131
|
+
f"{server_url.rstrip('/')}/api/key/getkey",
|
|
132
|
+
data={
|
|
133
|
+
"token": token,
|
|
134
|
+
"product_id": product_id,
|
|
135
|
+
"key": key,
|
|
136
|
+
},
|
|
137
|
+
timeout=timeout,
|
|
138
|
+
)
|
|
139
|
+
response = r.json()
|
|
140
|
+
except Exception as ex:
|
|
141
|
+
return None, f"Error: {ex}"
|
|
142
|
+
|
|
143
|
+
if response.get("result") != 0:
|
|
144
|
+
return None, response.get("message", "Unknown error")
|
|
145
|
+
|
|
146
|
+
license_obj = response.get("licenseKey")
|
|
147
|
+
signature_b64 = response.get("signature", "")
|
|
148
|
+
if not license_obj or not signature_b64:
|
|
149
|
+
return None, "Invalid server response"
|
|
150
|
+
|
|
151
|
+
# Verificar firma
|
|
152
|
+
from cryptography.exceptions import InvalidSignature
|
|
153
|
+
from cryptography.hazmat.primitives import hashes
|
|
154
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
155
|
+
from .license_key import _load_public_key
|
|
156
|
+
import base64
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
pub = _load_public_key(rsa_pub_key)
|
|
160
|
+
payload = json.dumps(license_obj, separators=(",", ":"), ensure_ascii=False)
|
|
161
|
+
signature = base64.b64decode(signature_b64)
|
|
162
|
+
pub.verify(signature, payload.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
|
|
163
|
+
except InvalidSignature:
|
|
164
|
+
return None, "Response signature invalid"
|
|
165
|
+
except Exception as ex:
|
|
166
|
+
return None, f"Signature verification failed: {ex}"
|
|
167
|
+
|
|
168
|
+
return _from_dict(license_obj), response.get("message", "")
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def save_to_file(license_key_response: dict, file_path: str) -> None:
|
|
172
|
+
"""Guarda la respuesta completa de activate/get_key (con firma) en un .dat para uso offline posterior."""
|
|
173
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
174
|
+
json.dump(license_key_response, f, indent=2, ensure_ascii=False)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Credits:
|
|
178
|
+
"""Operaciones de créditos contra DevKanan."""
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def record(
|
|
182
|
+
token: str,
|
|
183
|
+
product_id: int,
|
|
184
|
+
key: str,
|
|
185
|
+
amount: int,
|
|
186
|
+
feature: Optional[str] = None,
|
|
187
|
+
machine_code: Optional[str] = None,
|
|
188
|
+
server_url: str = DEFAULT_SERVER_URL,
|
|
189
|
+
timeout: int = 10,
|
|
190
|
+
) -> Tuple[bool, int, str]:
|
|
191
|
+
"""
|
|
192
|
+
Descuenta créditos. Devuelve (success, balance_restante, mensaje).
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
data = {"token": token, "product_id": product_id, "key": key, "amount": amount}
|
|
196
|
+
if feature:
|
|
197
|
+
data["feature"] = feature
|
|
198
|
+
if machine_code:
|
|
199
|
+
data["machine_code"] = machine_code
|
|
200
|
+
|
|
201
|
+
r = requests.post(f"{server_url.rstrip('/')}/api/credits/record", data=data, timeout=timeout)
|
|
202
|
+
response = r.json()
|
|
203
|
+
except Exception as ex:
|
|
204
|
+
return False, 0, f"Error: {ex}"
|
|
205
|
+
|
|
206
|
+
ok = response.get("result") == 0
|
|
207
|
+
return ok, int(response.get("balance", 0) or 0), response.get("message", "")
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def balance(
|
|
211
|
+
token: str,
|
|
212
|
+
product_id: int,
|
|
213
|
+
key: str,
|
|
214
|
+
server_url: str = DEFAULT_SERVER_URL,
|
|
215
|
+
timeout: int = 10,
|
|
216
|
+
) -> Tuple[bool, int, str]:
|
|
217
|
+
"""Consulta saldo actual. Devuelve (enabled, balance, mensaje)."""
|
|
218
|
+
try:
|
|
219
|
+
r = requests.post(
|
|
220
|
+
f"{server_url.rstrip('/')}/api/credits/balance",
|
|
221
|
+
data={"token": token, "product_id": product_id, "key": key},
|
|
222
|
+
timeout=timeout,
|
|
223
|
+
)
|
|
224
|
+
response = r.json()
|
|
225
|
+
except Exception as ex:
|
|
226
|
+
return False, 0, f"Error: {ex}"
|
|
227
|
+
|
|
228
|
+
if response.get("result") != 0:
|
|
229
|
+
return False, 0, response.get("message", "")
|
|
230
|
+
|
|
231
|
+
return bool(response.get("enabled", False)), int(response.get("balance", 0) or 0), ""
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LicenseKey — clase para parsear, verificar y consultar un archivo .dat firmado.
|
|
3
|
+
API compatible con el SDK de Cryptolens: LicenseKey.load_from_string(pub_key, content, max_age_days).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from cryptography.exceptions import InvalidSignature
|
|
15
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
17
|
+
from cryptography.hazmat.backends import default_backend
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ActivatedMachine:
|
|
22
|
+
mid: str = ""
|
|
23
|
+
ip: str = ""
|
|
24
|
+
time: int = 0
|
|
25
|
+
friendly_name: str = ""
|
|
26
|
+
floating_expires: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DataObject:
|
|
31
|
+
id: int = 0
|
|
32
|
+
name: str = ""
|
|
33
|
+
string_value: str = ""
|
|
34
|
+
int_value: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Customer:
|
|
39
|
+
id: int = 0
|
|
40
|
+
name: str = ""
|
|
41
|
+
email: str = ""
|
|
42
|
+
company_name: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LicenseKey:
|
|
47
|
+
"""Representa una licencia firmada cargada desde un .dat."""
|
|
48
|
+
|
|
49
|
+
product_id: int = 0
|
|
50
|
+
id: int = 0
|
|
51
|
+
key: str = ""
|
|
52
|
+
created: int = 0
|
|
53
|
+
expires: int = 0
|
|
54
|
+
period: int = 0
|
|
55
|
+
f1: bool = False
|
|
56
|
+
f2: bool = False
|
|
57
|
+
f3: bool = False
|
|
58
|
+
f4: bool = False
|
|
59
|
+
f5: bool = False
|
|
60
|
+
f6: bool = False
|
|
61
|
+
f7: bool = False
|
|
62
|
+
f8: bool = False
|
|
63
|
+
notes: str = ""
|
|
64
|
+
block: bool = False
|
|
65
|
+
global_id: int = 0
|
|
66
|
+
customer: Optional[Customer] = None
|
|
67
|
+
activated_machines: List[ActivatedMachine] = field(default_factory=list)
|
|
68
|
+
trial_activation: bool = False
|
|
69
|
+
max_no_of_machines: int = 0
|
|
70
|
+
offline_mode: str = "FloatingLease"
|
|
71
|
+
allowed_machines: str = ""
|
|
72
|
+
data_objects: List[DataObject] = field(default_factory=list)
|
|
73
|
+
sign_date: int = 0
|
|
74
|
+
|
|
75
|
+
# Aliases PascalCase (compat SDK Cryptolens)
|
|
76
|
+
@property
|
|
77
|
+
def ProductId(self) -> int: return self.product_id
|
|
78
|
+
@property
|
|
79
|
+
def ID(self) -> int: return self.id
|
|
80
|
+
@property
|
|
81
|
+
def Key(self) -> str: return self.key
|
|
82
|
+
@property
|
|
83
|
+
def Expires(self) -> int: return self.expires
|
|
84
|
+
@property
|
|
85
|
+
def SignDate(self) -> int: return self.sign_date
|
|
86
|
+
@property
|
|
87
|
+
def F1(self) -> bool: return self.f1
|
|
88
|
+
@property
|
|
89
|
+
def F2(self) -> bool: return self.f2
|
|
90
|
+
@property
|
|
91
|
+
def F3(self) -> bool: return self.f3
|
|
92
|
+
@property
|
|
93
|
+
def F4(self) -> bool: return self.f4
|
|
94
|
+
@property
|
|
95
|
+
def F5(self) -> bool: return self.f5
|
|
96
|
+
@property
|
|
97
|
+
def F6(self) -> bool: return self.f6
|
|
98
|
+
@property
|
|
99
|
+
def F7(self) -> bool: return self.f7
|
|
100
|
+
@property
|
|
101
|
+
def F8(self) -> bool: return self.f8
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def load_from_string(rsa_pub_key: str, content: str, max_age_days: int = 0) -> Optional["LicenseKey"]:
|
|
105
|
+
"""
|
|
106
|
+
Parsea un .dat, verifica firma RSA y freshness.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
rsa_pub_key: Clave pública en XML (.NET) o PEM (estándar).
|
|
110
|
+
content: Contenido del archivo .dat (JSON).
|
|
111
|
+
max_age_days: Días máximos desde signDate como fallback.
|
|
112
|
+
Si el .dat trae `maxOfflineDays>0`, ese valor sobrescribe.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
LicenseKey si todo valida, None si falla cualquier check.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
content = content.lstrip("") # quita BOM si existe
|
|
119
|
+
data = json.loads(content)
|
|
120
|
+
except Exception:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
license_obj = data.get("licenseKey") or data.get("LicenseKey")
|
|
124
|
+
signature_b64 = data.get("signature") or data.get("Signature")
|
|
125
|
+
if license_obj is None or not signature_b64:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# Si licenseKey es base64 string (formato .skm legacy), decodificar
|
|
129
|
+
if isinstance(license_obj, str):
|
|
130
|
+
try:
|
|
131
|
+
license_obj = json.loads(base64.b64decode(license_obj).decode("utf-8"))
|
|
132
|
+
except Exception:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
# Verificar firma RSA
|
|
136
|
+
try:
|
|
137
|
+
pub = _load_public_key(rsa_pub_key)
|
|
138
|
+
payload = json.dumps(license_obj, separators=(",", ":"), ensure_ascii=False)
|
|
139
|
+
signature = base64.b64decode(signature_b64)
|
|
140
|
+
pub.verify(signature, payload.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
|
|
141
|
+
except InvalidSignature:
|
|
142
|
+
return None
|
|
143
|
+
except Exception:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
lk = _from_dict(license_obj)
|
|
147
|
+
|
|
148
|
+
# Freshness check — controlado únicamente por el cliente
|
|
149
|
+
if max_age_days > 0 and lk.sign_date > 0:
|
|
150
|
+
signed_at = datetime.fromtimestamp(lk.sign_date, tz=timezone.utc)
|
|
151
|
+
age_days = (datetime.now(tz=timezone.utc) - signed_at).total_seconds() / 86400
|
|
152
|
+
if age_days > max_age_days:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
if lk.block:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
return lk
|
|
159
|
+
|
|
160
|
+
def is_on_right_machine(self, machine_code: str) -> bool:
|
|
161
|
+
"""Verifica que el machine code esté entre las máquinas autorizadas."""
|
|
162
|
+
if not self.activated_machines:
|
|
163
|
+
return True # Sin máquinas = licencia flotante general
|
|
164
|
+
return any(
|
|
165
|
+
m.mid == machine_code or m.mid == f"floating:{machine_code}"
|
|
166
|
+
for m in self.activated_machines
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def has_not_expired(self) -> bool:
|
|
170
|
+
"""True si la licencia aún no ha llegado a su fecha de expiración."""
|
|
171
|
+
return self.expires > int(datetime.now(tz=timezone.utc).timestamp())
|
|
172
|
+
|
|
173
|
+
def has_feature(self, n: int) -> bool:
|
|
174
|
+
"""Atajo para F<n> (n=1..8)."""
|
|
175
|
+
if 1 <= n <= 8:
|
|
176
|
+
return getattr(self, f"f{n}", False)
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def __repr__(self) -> str:
|
|
180
|
+
exp = datetime.fromtimestamp(self.expires, tz=timezone.utc).strftime("%Y-%m-%d") if self.expires else "?"
|
|
181
|
+
return (
|
|
182
|
+
f"LicenseKey(key={self.key!r}, product_id={self.product_id}, "
|
|
183
|
+
f"expires={exp}, machines={len(self.activated_machines)}/{self.max_no_of_machines})"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _load_public_key(key_str: str):
|
|
188
|
+
"""Carga una clave pública RSA desde PEM o XML (.NET)."""
|
|
189
|
+
key_str = key_str.strip()
|
|
190
|
+
if "BEGIN PUBLIC KEY" in key_str:
|
|
191
|
+
return serialization.load_pem_public_key(key_str.encode(), backend=default_backend())
|
|
192
|
+
if "<RSAKeyValue>" in key_str:
|
|
193
|
+
import re
|
|
194
|
+
m = re.search(r"<Modulus>([^<]+)</Modulus>", key_str)
|
|
195
|
+
e = re.search(r"<Exponent>([^<]+)</Exponent>", key_str)
|
|
196
|
+
if not m or not e:
|
|
197
|
+
raise ValueError("Invalid XML RSA key")
|
|
198
|
+
modulus = int.from_bytes(base64.b64decode(m.group(1)), "big")
|
|
199
|
+
exponent = int.from_bytes(base64.b64decode(e.group(1)), "big")
|
|
200
|
+
return rsa.RSAPublicNumbers(exponent, modulus).public_key(default_backend())
|
|
201
|
+
raise ValueError("Unrecognized public key format (use PEM or XML)")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _from_dict(d: dict) -> LicenseKey:
|
|
205
|
+
"""Construye LicenseKey desde dict camelCase o PascalCase."""
|
|
206
|
+
def g(*keys, default=None):
|
|
207
|
+
for k in keys:
|
|
208
|
+
if k in d:
|
|
209
|
+
return d[k]
|
|
210
|
+
return default
|
|
211
|
+
|
|
212
|
+
customer = None
|
|
213
|
+
c = g("customer", "Customer")
|
|
214
|
+
if isinstance(c, dict):
|
|
215
|
+
customer = Customer(
|
|
216
|
+
id=c.get("id", c.get("Id", 0)),
|
|
217
|
+
name=c.get("name", c.get("Name", "")),
|
|
218
|
+
email=c.get("email", c.get("Email", "")),
|
|
219
|
+
company_name=c.get("companyName", c.get("CompanyName", "")),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
machines = []
|
|
223
|
+
for m in g("activatedMachines", "ActivatedMachines", default=[]) or []:
|
|
224
|
+
machines.append(ActivatedMachine(
|
|
225
|
+
mid=m.get("mid", m.get("Mid", "")),
|
|
226
|
+
ip=m.get("ip", m.get("IP", "")),
|
|
227
|
+
time=int(m.get("time", m.get("Time", 0)) or 0),
|
|
228
|
+
friendly_name=m.get("friendlyName", m.get("FriendlyName", "")),
|
|
229
|
+
floating_expires=int(m.get("floatingExpires", m.get("FloatingExpires", 0)) or 0),
|
|
230
|
+
))
|
|
231
|
+
|
|
232
|
+
data_objects = []
|
|
233
|
+
for o in g("dataObjects", "DataObjects", default=[]) or []:
|
|
234
|
+
data_objects.append(DataObject(
|
|
235
|
+
id=o.get("id", o.get("Id", 0)),
|
|
236
|
+
name=o.get("name", o.get("Name", "")),
|
|
237
|
+
string_value=o.get("stringValue", o.get("StringValue", "")),
|
|
238
|
+
int_value=int(o.get("intValue", o.get("IntValue", 0)) or 0),
|
|
239
|
+
))
|
|
240
|
+
|
|
241
|
+
return LicenseKey(
|
|
242
|
+
product_id=int(g("productId", "ProductId", default=0) or 0),
|
|
243
|
+
id=int(g("id", "ID", default=0) or 0),
|
|
244
|
+
key=g("key", "Key", default="") or "",
|
|
245
|
+
created=int(g("created", "Created", default=0) or 0),
|
|
246
|
+
expires=int(g("expires", "Expires", default=0) or 0),
|
|
247
|
+
period=int(g("period", "Period", default=0) or 0),
|
|
248
|
+
f1=bool(g("f1", "F1", default=False)),
|
|
249
|
+
f2=bool(g("f2", "F2", default=False)),
|
|
250
|
+
f3=bool(g("f3", "F3", default=False)),
|
|
251
|
+
f4=bool(g("f4", "F4", default=False)),
|
|
252
|
+
f5=bool(g("f5", "F5", default=False)),
|
|
253
|
+
f6=bool(g("f6", "F6", default=False)),
|
|
254
|
+
f7=bool(g("f7", "F7", default=False)),
|
|
255
|
+
f8=bool(g("f8", "F8", default=False)),
|
|
256
|
+
notes=g("notes", "Notes", default="") or "",
|
|
257
|
+
block=bool(g("block", "Block", default=False)),
|
|
258
|
+
global_id=int(g("globalId", "GlobalId", default=0) or 0),
|
|
259
|
+
customer=customer,
|
|
260
|
+
activated_machines=machines,
|
|
261
|
+
trial_activation=bool(g("trialActivation", "TrialActivation", default=False)),
|
|
262
|
+
max_no_of_machines=int(g("maxNoOfMachines", "MaxNoOfMachines", default=0) or 0),
|
|
263
|
+
offline_mode=g("offlineMode", "OfflineMode", default="FloatingLease") or "FloatingLease",
|
|
264
|
+
allowed_machines=g("allowedMachines", "AllowedMachines", default="") or "",
|
|
265
|
+
data_objects=data_objects,
|
|
266
|
+
sign_date=int(g("signDate", "SignDate", default=0) or 0),
|
|
267
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devkanan
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Cliente Python para DevKanan — validación local + API online de licencias firmadas RSA
|
|
5
|
+
Author-email: Vilchis <vilchislalo98@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://devkanan.dev
|
|
8
|
+
Keywords: license,licensing,rsa,drm,devkanan,kanan
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: cryptography>=41.0.0
|
|
17
|
+
Requires-Dist: requests>=2.28.0
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# qustomlicensing
|
|
21
|
+
|
|
22
|
+
Cliente Python ligero para validar archivos de licencia `.dat` firmados con RSA, emitidos por **LicensingServer** SaaS o QustomLicenseServer.
|
|
23
|
+
|
|
24
|
+
API estilo Cryptolens — fácil migración si vienes de su SDK.
|
|
25
|
+
|
|
26
|
+
## Instalación
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install qustomlicensing
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
O para desarrollo:
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Uso básico
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from qustomlicensing import LicenseKey, Helpers
|
|
41
|
+
|
|
42
|
+
# Clave pública RSA del tenant (XML o PEM)
|
|
43
|
+
RSA_PUB_KEY = """-----BEGIN PUBLIC KEY-----
|
|
44
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
|
45
|
+
-----END PUBLIC KEY-----"""
|
|
46
|
+
|
|
47
|
+
# Obtener machine code de esta PC
|
|
48
|
+
machine_code = Helpers.GetMachineCode(v=2)
|
|
49
|
+
|
|
50
|
+
# Leer y validar .dat
|
|
51
|
+
with open("license.dat", "r", encoding="utf-8-sig") as f:
|
|
52
|
+
license_key = LicenseKey.load_from_string(RSA_PUB_KEY, f.read(), max_age_days=1)
|
|
53
|
+
|
|
54
|
+
if license_key is None:
|
|
55
|
+
print("Licencia invalida o firma corrupta")
|
|
56
|
+
exit(1)
|
|
57
|
+
|
|
58
|
+
# Verificar esta máquina
|
|
59
|
+
if not license_key.is_on_right_machine(machine_code):
|
|
60
|
+
print("Esta maquina no esta autorizada")
|
|
61
|
+
exit(1)
|
|
62
|
+
|
|
63
|
+
# Verificar features
|
|
64
|
+
if license_key.F1:
|
|
65
|
+
print("Feature Pro activado")
|
|
66
|
+
|
|
67
|
+
print(f"Licencia valida hasta {license_key.expires}")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `Helpers.GetMachineCode(v=2)`
|
|
73
|
+
Genera un machine code único basado en propiedades del hardware. `v=2` es el algoritmo actual (SHA-256 sobre identificadores).
|
|
74
|
+
|
|
75
|
+
### `LicenseKey.load_from_string(rsa_pub_key, content, max_age_days=0)`
|
|
76
|
+
Parsea un `.dat` JSON, verifica firma RSA y freshness.
|
|
77
|
+
|
|
78
|
+
| Parámetro | Tipo | Descripción |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `rsa_pub_key` | str | XML (.NET) o PEM. Auto-detecta. |
|
|
81
|
+
| `content` | str | Contenido del archivo `.dat` |
|
|
82
|
+
| `max_age_days` | int | Máx. días desde `signDate`. 0 = ilimitado o usa el del `.dat`. |
|
|
83
|
+
|
|
84
|
+
Devuelve un objeto `LicenseKey` o `None` si la validación falla.
|
|
85
|
+
|
|
86
|
+
### Atributos de `LicenseKey`
|
|
87
|
+
| Atributo | Tipo |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `product_id`, `id`, `key`, `notes` | datos básicos |
|
|
90
|
+
| `created`, `expires`, `sign_date` | datetimes UTC |
|
|
91
|
+
| `period`, `max_no_of_machines`, `max_offline_days` | enteros |
|
|
92
|
+
| `F1`..`F8`, `block`, `trial_activation` | flags |
|
|
93
|
+
| `activated_machines` | List[ActivatedMachine] |
|
|
94
|
+
| `data_objects` | List[DataObject] |
|
|
95
|
+
| `customer` | Customer o None |
|
|
96
|
+
| `offline_mode` | str (FloatingLease/FloatingManual/Locked) |
|
|
97
|
+
|
|
98
|
+
### `license_key.is_on_right_machine(machine_code)`
|
|
99
|
+
Verifica que el machine code esté en `activatedMachines`.
|
|
100
|
+
|
|
101
|
+
### `license_key.has_not_expired()`
|
|
102
|
+
Verifica que `expires > now`.
|
|
103
|
+
|
|
104
|
+
### `license_key.has_feature(n)`
|
|
105
|
+
Atajo para `getattr(license_key, f'F{n}')`.
|
|
106
|
+
|
|
107
|
+
## Comparación con SDK Cryptolens
|
|
108
|
+
|
|
109
|
+
| | Cryptolens SDK | qustomlicensing |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| Activate online | ✅ | ❌ (usa requests directamente) |
|
|
112
|
+
| Validar .skm/.dat offline | ✅ | ✅ |
|
|
113
|
+
| `Helpers.GetMachineCode` | ✅ | ✅ (compatible) |
|
|
114
|
+
| `LicenseKey.load_from_string` | ✅ | ✅ (compatible) |
|
|
115
|
+
| Tamaño | ~2 MB con deps | ~50 KB |
|
|
116
|
+
| Dependencias | `pycryptodome`, `requests` | solo `cryptography` |
|
|
117
|
+
|
|
118
|
+
## Licencia
|
|
119
|
+
|
|
120
|
+
Apache-2.0. Basado en código del SDK de Cryptolens (también Apache-2.0).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
devkanan/__init__.py
|
|
5
|
+
devkanan/helpers.py
|
|
6
|
+
devkanan/key.py
|
|
7
|
+
devkanan/license_key.py
|
|
8
|
+
devkanan.egg-info/PKG-INFO
|
|
9
|
+
devkanan.egg-info/SOURCES.txt
|
|
10
|
+
devkanan.egg-info/dependency_links.txt
|
|
11
|
+
devkanan.egg-info/requires.txt
|
|
12
|
+
devkanan.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devkanan
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "devkanan"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Cliente Python para DevKanan — validación local + API online de licencias firmadas RSA"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Vilchis", email = "vilchislalo98@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["license", "licensing", "rsa", "drm", "devkanan", "kanan"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"cryptography>=41.0.0",
|
|
24
|
+
"requests>=2.28.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://devkanan.dev"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["."]
|
|
32
|
+
include = ["devkanan*"]
|
devkanan-0.2.0/setup.cfg
ADDED