fix(seguranca): move algoritmo de geracao de chave do JS pro backend

O algoritmo (CHAVE_INTERNA + funcoes gerarReativacao/gerarInstalacao)
ficava 100% legivel no codigo-fonte da pagina — qualquer pessoa com
acesso ao painel conseguia abrir o DevTools e extrair a logica pra
gerar chaves de licenca fora do sistema.

Move tudo pra app/gerador_chave.py (roda so no servidor) e expoe via
POST /gerador-chave/gerar (autenticado, protegido por CSRF). A pagina
agora so manda os dados do formulario e mostra o resultado que volta
do backend. Algoritmo verificado byte-a-byte contra os 3 exemplos
reais conhecidos (cliente 000001, ALUGUEL). Bonus: o byte inicial
agora usa secrets.randbelow (CSPRNG) em vez de Math.random() do JS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-14 10:03:19 -03:00
co-authored by Claude Sonnet 5
parent a3a84abd37
commit 5c577a644a
5 changed files with 253 additions and 76 deletions
+56
View File
@@ -0,0 +1,56 @@
"""
Algoritmo de geração de chave (reativação e instalação), reverso-projetado
do sistema legado (Delphi). Fica só aqui no backend — NUNCA deve voltar a
ser exposto em JavaScript de página, porque isso permite qualquer pessoa
com acesso ao painel extrair o algoritmo e gerar chaves fora do sistema.
Verificado byte-a-byte contra 3 exemplos reais de chave de instalação
(cliente 000001, ALUGUEL): byte inicial 0xDC -> "DC45092C",
0x2E -> "2E35083D", 0x3D -> "3D25090C" (ver testes).
"""
import secrets
CHAVE_INTERNA = (
"YUQL23KL23DF90WI5E1JAS467NMCXXL6JAOAUWWMCL0AOMM4A4VZYW9KHJUI2347"
"EJHJKDF3424SKL K3LAKDJSL9RTIKJ"
)
def _cifrar(texto: str, byte_inicial: int) -> list[int]:
"""Cifra por feedback de byte anterior + XOR com CHAVE_INTERNA rotativa.
Retorna a lista de bytes cifrados (sem o byte_inicial na frente)."""
anterior = byte_inicial
cifrados = []
for i, ch in enumerate(texto):
valor = (ord(ch) + anterior) % 255
cifrado = valor ^ ord(CHAVE_INTERNA[i % len(CHAVE_INTERNA)])
cifrados.append(cifrado)
anterior = cifrado
return cifrados
def gerar_reativacao(codigo_cliente: str, mes: int, ano: int, byte_inicial: int | None = None) -> str:
"""Chave de reativação — mês/cliente/ano cifrados por inteiro (22 caracteres hex)."""
cliente = str(codigo_cliente).zfill(6)
texto = str(mes).zfill(2) + cliente + str(ano)[-2:]
aleatorio = byte_inicial if byte_inicial is not None else secrets.randbelow(256)
cifrados = _cifrar(texto, aleatorio)
return "".join(f"{v:02X}" for v in [aleatorio, *cifrados])
def gerar_instalacao(codigo_cliente: str, modalidade: str, byte_inicial: int | None = None) -> str:
"""Chave de instalação — só a modalidade ("30"=ALUGUEL / "00"=COMPRA) é
cifrada; o valor derivado do código do cliente (cliente*19+31) entra sem
cifrar, no meio dos 6 caracteres cifrados (3 antes + 3 depois)."""
try:
cliente_num = int(str(codigo_cliente))
except ValueError:
cliente_num = 0
valor_cliente = cliente_num * 19 + 31
texto_modalidade = "30" if modalidade == "ALUGUEL" else "00"
aleatorio = byte_inicial if byte_inicial is not None else secrets.randbelow(256)
cifrados = _cifrar(texto_modalidade, aleatorio)
criptografado = "".join(f"{v:02X}" for v in [aleatorio, *cifrados])
parte1, parte2 = criptografado[0:3], criptografado[3:6]
return f"{parte1}{valor_cliente}{parte2}"
+48 -7
View File
@@ -1,19 +1,60 @@
"""
Calculadora de chave de reativação, para uso manual (reenvio/correção
pontual). 100% client-side (mesmo algoritmo gerarReativacao já usado no
workflow n8n e em Gerar chave WEB/index.html) — esta rota só serve a
página; NUNCA grava em Clientes_Chaves.
Calculadora de chave de reativação/instalação, para uso manual (reenvio/
correção pontual). O algoritmo em si (app/gerador_chave.py) roda só aqui
no backend — a página não recebe mais nada além do resultado já pronto,
pra ninguém conseguir extrair o algoritmo pelo código-fonte da página.
NUNCA grava em Clientes_Chaves.
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
import re
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from ..auth import exigir_login
from ..csrf import exigir_csrf, token_do_contexto
from ..gerador_chave import gerar_instalacao, gerar_reativacao
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
_RE_CODIGO = re.compile(r"^[0-9]{1,6}$")
_RE_REFERENCIA = re.compile(r"^\d{4}-\d{2}$")
@router.get("/gerador-chave", response_class=HTMLResponse)
def pagina(request: Request, usuario: str = Depends(exigir_login)):
return templates.TemplateResponse(request, "gerador_chave.html", {})
return templates.TemplateResponse(request, "gerador_chave.html", {"csrf_token": token_do_contexto(request)})
@router.post("/gerador-chave/gerar", dependencies=[Depends(exigir_csrf)])
def gerar(
tipo: str = Form(...),
codigo: str = Form(...),
referencia: str = Form(""),
modalidade: str = Form(""),
usuario: str = Depends(exigir_login),
):
codigo = codigo.strip()
if not _RE_CODIGO.match(codigo):
return JSONResponse({"erro": "Código do cliente deve ter até 6 dígitos numéricos."}, status_code=400)
codigo_formatado = codigo.zfill(6)
if tipo == "reativacao":
if not _RE_REFERENCIA.match(referencia):
return JSONResponse({"erro": "Selecione o mês/ano de referência."}, status_code=400)
ano_str, mes_str = referencia.split("-")
mes, ano = int(mes_str), int(ano_str)
if not (1 <= mes <= 12):
return JSONResponse({"erro": "Mês fora do intervalo 1-12."}, status_code=400)
if not (2000 <= ano <= 2100):
return JSONResponse({"erro": "Ano inválido (use entre 2000 e 2100)."}, status_code=400)
chave = gerar_reativacao(codigo_formatado, mes, ano)
elif tipo == "instalacao":
if modalidade not in ("ALUGUEL", "COMPRA"):
return JSONResponse({"erro": "Modalidade inválida."}, status_code=400)
chave = gerar_instalacao(codigo_formatado, modalidade)
else:
return JSONResponse({"erro": "Tipo de chave inválido."}, status_code=400)
return JSONResponse({"chave": chave})
+27 -65
View File
@@ -34,56 +34,11 @@
</div>
<script>
const CHAVE_INTERNA =
"YUQL23KL23DF90WI5E1JAS467NMCXXL6JAOAUWWMCL0AOMM4A4VZYW9KHJUI2347" +
"EJHJKDF3424SKL K3LAKDJSL9RTIKJ";
// ==========================
// REATIVAÇÃO — mês/cliente/ano cifrados por inteiro (22 caracteres)
// ==========================
function gerarReativacao(codigoCliente, mes, ano, byteInicial = null) {
const cliente = String(codigoCliente).padStart(6, '0');
const texto = String(mes).padStart(2, '0') + cliente + String(ano).slice(-2);
const aleatorio = byteInicial !== null ? byteInicial : Math.floor(Math.random() * 256);
let anterior = aleatorio;
const resultado = [aleatorio];
const bytes = [];
for (let i = 0; i < texto.length; i++) bytes.push(texto.charCodeAt(i) & 0xff);
for (let i = 0; i < bytes.length; i++) {
const valor = (bytes[i] + anterior) % 255;
const cifrado = valor ^ CHAVE_INTERNA.charCodeAt(i % CHAVE_INTERNA.length);
resultado.push(cifrado);
anterior = cifrado;
}
return resultado.map(v => v.toString(16).padStart(2, '0').toUpperCase()).join('');
}
// ==========================
// INSTALAÇÃO — só a modalidade ("30"=ALUGUEL / "00"=COMPRA) é cifrada
// (mesmo algoritmo de cifra da reativação, texto diferente); o valor
// derivado do código do cliente (cliente×19+31) entra sem cifrar, no
// meio dos 6 caracteres cifrados (3 antes + 3 depois).
// ==========================
function gerarInstalacao(codigoCliente, modalidade, byteInicial = null) {
const clienteNum = parseInt(String(codigoCliente), 10) || 0;
const valorCliente = clienteNum * 19 + 31;
const textoModalidade = modalidade === 'ALUGUEL' ? '30' : '00';
const aleatorio = byteInicial !== null ? byteInicial : Math.floor(Math.random() * 256);
let anterior = aleatorio;
const bytes = [aleatorio];
for (let i = 0; i < textoModalidade.length; i++) {
const valor = (textoModalidade.charCodeAt(i) + anterior) % 255;
const cifrado = valor ^ CHAVE_INTERNA.charCodeAt(i % CHAVE_INTERNA.length);
bytes.push(cifrado);
anterior = cifrado;
}
const criptografado = bytes.map(v => v.toString(16).padStart(2, '0').toUpperCase()).join('');
const parte1 = criptografado.substring(0, 3);
const parte2 = criptografado.substring(3, 6);
return `${parte1}${valorCliente}${parte2}`;
}
// O algoritmo de geração de chave roda só no backend (app/gerador_chave.py)
// — esta página só manda os dados do formulário pro endpoint e mostra o
// resultado. Nunca reintroduza a lógica de cifra aqui: qualquer pessoa com
// acesso à página consegue ler o código-fonte JS pelo navegador.
const CSRF_TOKEN = "{{ csrf_token }}";
const $tipo = document.getElementById('tipo');
const $codigo = document.getElementById('codigo');
@@ -117,25 +72,32 @@ function mostrarErro(msg) {
$copiar.style.display = 'none';
}
document.getElementById('gerar').addEventListener('click', () => {
document.getElementById('gerar').addEventListener('click', async () => {
$erro.style.display = 'none';
const codigoCliente = String($codigo.value || '').trim();
if (!/^[0-9]{1,6}$/.test(codigoCliente)) return mostrarErro('Código do cliente deve ter até 6 dígitos numéricos.');
const codigoFormatado = codigoCliente.padStart(6, '0');
let chave;
if ($tipo.value === 'reativacao') {
if (!/^\d{4}-\d{2}$/.test($referencia.value)) return mostrarErro('Selecione o mês/ano de referência.');
const [anoStr, mesStr] = $referencia.value.split('-');
const mesNum = Number(mesStr), anoNum = Number(anoStr);
if (!(mesNum >= 1 && mesNum <= 12)) return mostrarErro('Mês fora do intervalo 1-12.');
if (!(anoNum >= 2000 && anoNum <= 2100)) return mostrarErro('Ano inválido (use entre 2000 e 2100).');
chave = gerarReativacao(codigoFormatado, mesNum, anoNum);
} else {
chave = gerarInstalacao(codigoFormatado, $modalidade.value);
const corpo = new URLSearchParams({
csrf_token: CSRF_TOKEN,
tipo: $tipo.value,
codigo: $codigo.value,
referencia: $referencia.value,
modalidade: $modalidade.value,
});
let resposta, dados;
try {
resposta = await fetch('/gerador-chave/gerar', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: corpo,
});
dados = await resposta.json();
} catch (e) {
return mostrarErro('Não foi possível falar com o servidor — tente de novo.');
}
$resultado.textContent = chave;
if (!resposta.ok) return mostrarErro(dados.erro || 'Não foi possível gerar a chave.');
$resultado.textContent = dados.chave;
$resultado.style.display = 'block';
$copiar.style.display = 'inline-block';
});