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 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.
@@ -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,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,2 @@
1
+ cryptography>=41.0.0
2
+ requests>=2.28.0
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+