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';
});
+41
View File
@@ -0,0 +1,41 @@
from app.gerador_chave import gerar_instalacao, gerar_reativacao
def test_gerar_instalacao_bate_com_exemplo_real_byte_dc():
assert gerar_instalacao("000001", "ALUGUEL", byte_inicial=0xDC) == "DC45092C"
def test_gerar_instalacao_bate_com_exemplo_real_byte_2e():
assert gerar_instalacao("000001", "ALUGUEL", byte_inicial=0x2E) == "2E35083D"
def test_gerar_instalacao_bate_com_exemplo_real_byte_3d():
assert gerar_instalacao("000001", "ALUGUEL", byte_inicial=0x3D) == "3D25090C"
def test_gerar_instalacao_compra_usa_modalidade_00():
# Mesmo byte inicial, modalidade diferente -> cifrado diferente do ALUGUEL.
aluguel = gerar_instalacao("000001", "ALUGUEL", byte_inicial=0xDC)
compra = gerar_instalacao("000001", "COMPRA", byte_inicial=0xDC)
assert aluguel != compra
assert compra.startswith("DC") # byte inicial some no começo, sem cifrar
def test_gerar_instalacao_sem_byte_inicial_gera_aleatorio_valido():
chave = gerar_instalacao("000001", "ALUGUEL")
assert len(chave) == 8
assert chave[3:5].isdigit() # valor_cliente embutido no meio
def test_gerar_reativacao_e_deterministico_com_byte_fixo():
chave1 = gerar_reativacao("000123", 8, 2026, byte_inicial=0x10)
chave2 = gerar_reativacao("000123", 8, 2026, byte_inicial=0x10)
assert chave1 == chave2
assert len(chave1) == 22 # 11 bytes (1 aleatorio + 10 do texto) em hex
def test_gerar_reativacao_sem_byte_inicial_varia():
# Estatisticamente, gerar várias vezes sem byte fixo não deve sempre
# bater igual (chance de colisão é 1/256).
chaves = {gerar_reativacao("000123", 8, 2026) for _ in range(20)}
assert len(chaves) > 1
+81 -4
View File
@@ -8,18 +8,95 @@ app_teste = FastAPI()
app_teste.include_router(router_chave_manual.router)
app_teste.dependency_overrides[exigir_login] = lambda: "teste"
client = TestClient(app_teste)
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
def test_pagina_gerador_carrega():
resposta = client.get("/gerador-chave")
assert resposta.status_code == 200
assert "gerarReativacao" in resposta.text
assert "gerarInstalacao" in resposta.text
assert "Gerador de Chave" in resposta.text
# O algoritmo NUNCA pode voltar a aparecer no HTML/JS da página — ele
# roda só no backend (app/gerador_chave.py) desde que essa exposição
# foi corrigida.
assert "CHAVE_INTERNA" not in resposta.text
assert "function gerarReativacao" not in resposta.text
assert "function gerarInstalacao" not in resposta.text
def test_pagina_gerador_nao_grava_nada():
# Rota é GET-only: não existe POST /gerador-chave, então não há como
# este router gravar em Clientes_Chaves.
# GET /gerador-chave não recebe csrf_token no cookie/form combinando
# com nada que grave — só há dois POSTs possíveis nesse router:
# /gerador-chave (não existe, 405) e /gerador-chave/gerar (não grava
# em Clientes_Chaves, só calcula e devolve).
resposta = client.post("/gerador-chave")
assert resposta.status_code == 405
def test_gerar_reativacao_com_sucesso():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "reativacao", "codigo": "123", "referencia": "2026-08"},
)
assert resposta.status_code == 200
corpo = resposta.json()
assert len(corpo["chave"]) == 22
assert all(c in "0123456789ABCDEF" for c in corpo["chave"])
def test_gerar_instalacao_com_sucesso_bate_com_exemplo_conhecido():
# Não dá pra fixar o byte aleatório via HTTP, mas confere que o valor do
# cliente (1*19+31=50) aparece embutido no meio da chave, como o
# algoritmo real exige.
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "instalacao", "codigo": "000001", "modalidade": "ALUGUEL"},
)
assert resposta.status_code == 200
corpo = resposta.json()
assert len(corpo["chave"]) == 8
assert corpo["chave"][3:5] == "50"
def test_gerar_codigo_invalido_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "instalacao", "codigo": "abc123", "modalidade": "ALUGUEL"},
)
assert resposta.status_code == 400
assert "6 dígitos" in resposta.json()["erro"]
def test_gerar_reativacao_sem_referencia_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "reativacao", "codigo": "123", "referencia": ""},
)
assert resposta.status_code == 400
assert "mês/ano" in resposta.json()["erro"]
def test_gerar_instalacao_modalidade_invalida_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "instalacao", "codigo": "123", "modalidade": "X"},
)
assert resposta.status_code == 400
assert "Modalidade" in resposta.json()["erro"]
def test_gerar_tipo_invalido_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"csrf_token": CSRF_TESTE, "tipo": "outracoisa", "codigo": "123"},
)
assert resposta.status_code == 400
assert "Tipo de chave" in resposta.json()["erro"]
def test_gerar_sem_csrf_bloqueia():
resposta = client.post(
"/gerador-chave/gerar",
data={"tipo": "instalacao", "codigo": "123", "modalidade": "ALUGUEL"},
)
assert resposta.status_code == 403