#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Generador de claves de licencia SKEEN (herramienta del vendedor). Vive FUERA del producto: no se despliega en las instancias de las clínicas. Solo el vendedor corre este script para emitir/verificar una clave. La clave se firma con HMAC-SHA256 usando la clave del vendedor; la instancia valida la firma con la misma clave (symmetric HMAC), por lo que la clave del vendedor debe estar configurada en cada instancia que aplique licencias: 1) ir.config_parameter 'skeen.licencia.vendor_key' 2) variable de entorno SKEEN_LICENCIA_VENDOR_KEY 3) archivo /root/.skeen-licencias/vendor.key (permisos 600) Formato de la clave (texto, una sola línea): SKN1.. `payload_json` = JSON con claves ordenadas y separadores mínimos: {\"cliente\":...,\"limites\":{...},\"modulos\":[...],\"plan\":...,\"vence\":...} Uso: generar_licencia.py generar --cliente "Spa Bella" --plan profesional \ --vence 2027-12-31 [--modulos whatsapp,recursos] \ [--usuarios-max N --sucursales-max N --citas-mes-max N --almacenamiento-mb N] generar_licencia.py verificar --clave "SKN1...." El plan define los módulos por default; --modulos los pisa. En los planes `basico`/`profesional`/`enterprise` la lista de módulos es explícita; `custom` usa exactamente la lista indicada (vacía = todos los módulos). """ import argparse import base64 import hashlib import hmac import json import os import sys from datetime import date PREFIJO = 'SKN1' VENDOR_KEY_ENV = 'SKEEN_LICENCIA_VENDOR_KEY' VENDOR_KEY_FILE = '/root/.skeen-licencias/vendor.key' MODULOS_BASE = ['agenda', 'pacientes', 'pos', 'inventario', 'reportes'] MODULOS_GATEABLES = ['whatsapp', 'notificaciones', 'reservas_online', 'recursos', 'membresias', 'compras', 'comisiones', 'formularios'] MODULOS_VALIDOS = MODULOS_BASE + MODULOS_GATEABLES PLANES = { 'basico': MODULOS_BASE, 'profesional': MODULOS_BASE + ['whatsapp', 'notificaciones', 'reservas_online', 'recursos'], 'enterprise': MODULOS_BASE + MODULOS_GATEABLES, 'custom': [], # lo que diga --modulos; vacío = todos } def cargar_vendor(secreto=None, archivo=None): if secreto: return secreto.strip() env = (os.environ.get(VENDOR_KEY_ENV) or '').strip() if env: return env ruta = archivo or VENDOR_KEY_FILE try: with open(ruta, 'r', encoding='utf-8') as fh: return (fh.read() or '').strip() except OSError: return '' def _b64url(raw): return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') def _b64url_decode(texto): return base64.urlsafe_b64decode(texto + '=' * (-len(texto) % 4)) def _payload_canonico(payload): return json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8') def firmar(payload, vendor): cuerpo = _b64url(_payload_canonico(payload)) firma = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'), hashlib.sha256).digest() return '%s.%s.%s' % (PREFIJO, cuerpo, _b64url(firma)) def verificar(clave, vendor): partes = (clave or '').strip().split('.') if len(partes) != 3 or partes[0] != PREFIJO: return None, 'Formato de clave inválido.' _, cuerpo, firma = partes esperada = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'), hashlib.sha256).digest() try: recibida = _b64url_decode(firma) except Exception: return None, 'Firma corrupta.' if not hmac.compare_digest(esperada, recibida): return None, 'La firma no es válida (clave manipulada).' try: payload = json.loads(_b64url_decode(cuerpo).decode('utf-8')) except Exception: return None, 'Contenido corrupto.' vence = str(payload.get('vence') or '').strip() if vence: try: if date.fromisoformat(vence) < date.today(): return payload, 'La licencia está vencida (venció el %s).' % vence except ValueError: return payload, 'Fecha de vencimiento inválida.' return payload, None def cmd_generar(args): vendor = cargar_vendor(args.secreto, args.secreto_archivo) if not vendor: print('ERROR: no hay clave de vendedor. Configura %s o el archivo %s.' % (VENDOR_KEY_ENV, VENDOR_KEY_FILE), file=sys.stderr) return 2 plan = (args.plan or 'custom').strip().lower() if plan not in PLANES: print('ERROR: plan inválido (basico|profesional|enterprise|custom).', file=sys.stderr) return 2 if args.modulos: modulos = [] for m in args.modulos.split(','): m = m.strip().lower() if m not in MODULOS_VALIDOS: print('ERROR: módulo desconocido: %s' % m, file=sys.stderr) return 2 if m not in modulos: modulos.append(m) else: modulos = list(PLANES[plan]) limites = { 'usuarios_max': args.usuarios_max or 0, 'sucursales_max': args.sucursales_max or 0, 'citas_mes_max': args.citas_mes_max or 0, 'almacenamiento_mb': args.almacenamiento_mb or 0, } payload = { 'v': 1, 'cliente': args.cliente or '', 'plan': plan, 'vence': (args.vence or '').strip(), 'emitida': date.today().isoformat(), 'modulos': modulos, 'limites': limites, } clave = firmar(payload, vendor) if args.json: print(json.dumps({'clave': clave, 'payload': payload}, ensure_ascii=False, indent=2)) else: print(clave) print('', file=sys.stderr) print('Cliente: %s | Plan: %s | Vence: %s' % (payload['cliente'] or '-', plan, payload['vence'] or 'sin vencimiento'), file=sys.stderr) print('Módulos: %s' % (', '.join(modulos) or 'todos'), file=sys.stderr) print('Límites: %s' % limites, file=sys.stderr) return 0 def cmd_verificar(args): vendor = cargar_vendor(args.secreto, args.secreto_archivo) if not vendor: print('ERROR: no hay clave de vendedor.', file=sys.stderr) return 2 payload, error = verificar(args.clave, vendor) if args.json: print(json.dumps({'valida': error is None, 'error': error, 'payload': payload}, ensure_ascii=False, indent=2)) else: print('VÁLIDA' if error is None else 'INVÁLIDA: %s' % error) if payload: print(json.dumps(payload, ensure_ascii=False, indent=2)) return 0 if error is None else 1 def main(argv=None): parser = argparse.ArgumentParser(description='Generador de licencias SKEEN') parser.add_argument('--secreto', help='clave del vendedor (o env/archivo)') parser.add_argument('--secreto-archivo', dest='secreto_archivo', help='ruta alterna del archivo con la clave del vendedor') sub = parser.add_subparsers(dest='cmd', required=True) g = sub.add_parser('generar', help='emite una clave de licencia') g.add_argument('--cliente', default='') g.add_argument('--plan', default='custom', help='basico | profesional | enterprise | custom') g.add_argument('--vence', default='', help='YYYY-MM-DD (vacío = sin vencimiento)') g.add_argument('--modulos', default='', help='lista separada por comas') g.add_argument('--usuarios-max', dest='usuarios_max', type=int, default=0) g.add_argument('--sucursales-max', dest='sucursales_max', type=int, default=0) g.add_argument('--citas-mes-max', dest='citas_mes_max', type=int, default=0) g.add_argument('--almacenamiento-mb', dest='almacenamiento_mb', type=int, default=0) g.add_argument('--json', action='store_true') g.set_defaults(func=cmd_generar) v = sub.add_parser('verificar', help='verifica una clave de licencia') v.add_argument('--clave', required=True) v.add_argument('--json', action='store_true') v.set_defaults(func=cmd_verificar) args = parser.parse_args(argv) return args.func(args) if __name__ == '__main__': sys.exit(main())