commit ccfa358f97076e010d1e029ce2ddbd8c53f3cc81 Author: Claudecio Martins Date: Sun Oct 11 08:20:24 2026 -0300 feat: initial commit - cybercore signer desktop agent diff --git a/.agents/rules/main-rule.md b/.agents/rules/main-rule.md new file mode 100644 index 0000000..9697813 --- /dev/null +++ b/.agents/rules/main-rule.md @@ -0,0 +1,250 @@ +--- +trigger: always_on +--- + +PROMPT MESTRE — CYBERCORE SIGNER + +Você é responsável pelo desenvolvimento do CyberCore Signer, um agente desktop Java para Windows que permite aplicações web autorizadas solicitar assinaturas digitais usando certificados instalados/localmente disponíveis na máquina do usuário. + +1. REGRA DE DESENVOLVIMENTO + +Siga sempre: ANALISAR → PLANEJAR → AUTORIZAR → IMPLEMENTAR → VALIDAR. + +Antes de alterar qualquer código, analise o repositório real, sua estrutura, dependências, configurações, padrões, documentação e testes. Não invente arquivos, classes ou arquitetura existentes. + +Na fase de análise, não altere o projeto. Depois apresente o plano e aguarde autorização explícita para implementar. + +Se durante a implementação surgir mudança relevante de escopo, pare e peça nova autorização. + +Ao finalizar, informe arquivos alterados/criados, dependências, decisões arquiteturais, testes executados, limitações e pendências. + +2. TECNOLOGIA + +Use Java 21+ LTS, Maven ou Gradle conforme o projeto, JavaFX para a interface e JCA/JCE para criptografia. + +Use X.509, KeyStore/PKCS12 e PKCS#11 conforme necessário. Use SunPKCS11 quando apropriado e Bouncy Castle somente quando houver necessidade real. + +O aplicativo deve ser empacotado com runtime próprio, preferencialmente via jpackage, sem exigir Java instalado pelo usuário. + +Windows 10/11 é o alvo inicial; mantenha a arquitetura preparada para macOS/Linux. + +3. ARQUITETURA + +Separe responsabilidades em módulos/componentes coerentes, sem criar módulos artificiais. A arquitetura deve permitir evolução e testes independentes. + +Crie abstrações para descoberta e uso de certificados, por exemplo CertificateProvider, com implementações para A1, Windows Certificate Store e PKCS#11 quando aplicável. + +CertificateManager deve centralizar descoberta, validade, subject, issuer, algoritmo, disponibilidade da chave privada e seleção do provider. + +SigningService deve executar a assinatura por JCA/JCE e providers; nunca implemente criptografia própria. + +Mantenha o núcleo independente de fabricantes. + +4. A1 + +Suporte certificados .PFX/.P12. + +A chave privada permanece exclusivamente na máquina. A senha nunca deve ser enviada ao servidor nem aparecer em logs. + +Trate certificado válido, expirado, inválido, senha incorreta, ausência de chave privada e algoritmos não suportados. + +Se houver persistência de credenciais, use armazenamento seguro do sistema operacional. + +5. A3 / PKCS#11 + +Suporte tokens USB, smart cards, leitores e middleware compatíveis com PKCS#11, além de cenários Windows CSP/CNG/KSP quando necessários. + +Detecte slots, tokens, certificados e chaves disponíveis. + +Trate token ausente/removido, middleware/provider indisponível, PIN requerido/incorreto, timeout e falhas do dispositivo. + +Nunca armazene ou envie PIN. Nunca implemente sua própria camada PKCS#11: utilize APIs/providers existentes. + +Não assuma que todos os A3 funcionam da mesma forma; use adapters/providers sem acoplar o núcleo a um fabricante. + +6. ASSINATURA + +Priorize o modelo em que a aplicação web calcula o digest e o CyberCore assina localmente apenas esse digest. + +Exemplo: documento → SHA-256 → digest → CyberCore → certificado A1/A3 → assinatura. + +A chave privada nunca sai da máquina. + +Valide algoritmo, tamanho/formato do digest, certificado e capacidade do provider antes da operação. + +7. PROTOCOLO CCSP + +Defina o CyberCore Signing Protocol (CCSP), versão inicial CCSP/1. + +Toda requisição deve possuir request_id UUID, application_id, operação, algoritmo, digest, indicação opcional do certificado e expiração. + +Exemplo: + +{ + "version":"1","request_id":"uuid","application_id":"uuid", + "operation":"sign","algorithm":"SHA256withRSA", + "digest":"base64","certificate_hint":"uuid","expires_at":"..." +} + +Resposta: + +{ + "version":"1","request_id":"uuid","status":"signed", + "signature":"base64","certificate":"base64","algorithm":"SHA256withRSA" +} + +Especifique em documentação autenticação, autorização, validação, timeouts, nonce, replay protection, erros e compatibilidade de versões. + +8. COMUNICAÇÃO LOCAL + +A comunicação deve ocorrer somente localmente por padrão. Avalie HTTPS localhost, WebSocket ou outro mecanismo adequado. + +Nunca exponha uma API de assinatura local sem autenticação/autorização. + +Não permita exposição involuntária à rede LAN. + +Use registro/autorização de aplicações, origem permitida, desafio/nonce, timestamp, expiração e proteção contra replay. + +Não confie exclusivamente em Origin ou User-Agent. + +9. SEGURANÇA + +A assinatura deve exigir confirmação explícita do usuário por padrão. + +Mostre aplicação solicitante, certificado, tipo A1/A3, algoritmo e informações relevantes antes de assinar. + +Nunca registre PIN, senha, chave privada ou conteúdo desnecessário do documento. + +Minimize dados trafegados e armazenados. + +Use UUIDs, correlação por request_id e validação rigorosa de entrada. + +10. INTERFACE + +Use JavaFX com aparência profissional e consistente. + +Inclua, conforme o escopo: Dashboard, Certificados, Solicitação de Assinatura, Configurações, Diagnóstico e Sobre. + +Suporte System Tray. + +A interface deve informar claramente estado do agente, certificados disponíveis, token conectado e erros acionáveis. + +11. DIAGNÓSTICO + +Disponibilize diagnóstico de Java/runtime, engine criptográfica, Windows Certificate Store, A1, A3, PKCS#11, comunicação local e SDK. + +Permita diagnóstico completo e teste de assinatura. + +Diferencie claramente falha de configuração, middleware, token, certificado, comunicação e aplicação solicitante. + +12. ERROS + +Utilize erros estruturados e estáveis, incluindo: + +CERTIFICATE_NOT_FOUND, CERTIFICATE_EXPIRED, CERTIFICATE_INVALID, +PRIVATE_KEY_NOT_AVAILABLE, TOKEN_NOT_FOUND, TOKEN_REMOVED, +PIN_REQUIRED, PIN_INCORRECT, PKCS11_ERROR, SIGNATURE_FAILED, +APPLICATION_NOT_AUTHORIZED, REQUEST_EXPIRED, REPLAY_DETECTED, +INVALID_ORIGIN, UNSUPPORTED_ALGORITHM, USER_CANCELLED, +PROVIDER_NOT_AVAILABLE e COMMUNICATION_ERROR. + +Mensagens para o usuário devem ser claras, sem expor segredos ou detalhes internos desnecessários. + +13. LOGS E AUDITORIA + +Registre eventos relevantes: inicialização, descoberta de certificados, provider utilizado, aplicação, request_id, operação, sucesso/falha e remoção de token. + +Nunca registre PIN, senha, chave privada ou dados sensíveis desnecessários. + +Use níveis de log adequados e correlação por request_id. + +14. SDK WEB + +Planeje o pacote JavaScript @cybercore/signer-sdk. + +API mínima: + +connect(), isInstalled(), getVersion(), getStatus(), +getCertificates(), getCapabilities(), sign(), verify(). + +O SDK deve abstrair o CCSP e fornecer erros previsíveis para aplicações web. + +15. MULTIAPLICAÇÃO E CONCORRÊNCIA + +Uma única instalação deve atender múltiplas aplicações web autorizadas. + +Controle concorrência e fila de operações, especialmente em A3, pois tokens/middleware podem não suportar operações simultâneas. + +Nunca misture contexto ou autorização de uma aplicação com outra. + +16. PDF/PAdES + +Não misture a operação criptográfica básica com a montagem/finalização de PDF. + +Para PAdES, avalie bibliotecas maduras como Apache PDFBox, Bouncy Castle ou DSS. + +Nunca implemente manualmente mecanismos criptográficos ou estruturas complexas de assinatura PDF sem necessidade. + +17. TESTES + +Crie testes unitários, integração, segurança, protocolo, providers, comunicação local, SDK e concorrência. + +Cubra A1 válido/expirado/inválido/senha incorreta; A3 sem token, token removido, PIN incorreto, middleware indisponível e cancelamento; assinatura válida; algoritmo incompatível; requisição expirada/repetida/não autorizada; origem inválida; payload malformado e concorrência. + +Testes dependentes de hardware só podem ser declarados executados quando o hardware realmente estiver disponível. + +18. MODO DESENVOLVIMENTO + +Implemente mock quando útil para desenvolvimento e testes, simulando A1/A3, token removido, PIN incorreto, certificado expirado, provider indisponível, timeout e cancelamento. + +O modo deve ser claramente identificado como DESENVOLVIMENTO. + +Nunca apresente assinatura simulada como assinatura real. + +19. INSTALADOR E ATUALIZAÇÃO + +Crie instalador Windows com runtime próprio, instalação/desinstalação, atalhos e opções de inicialização quando aplicável. + +Atualizações devem ser autenticadas e seguras. + +Executáveis e instaladores devem ser preparados para code signing. + +20. PRIVACIDADE + +A chave privada, PIN e senha A1 permanecem na máquina. + +Quando apenas o digest for necessário, o documento não deve ser enviado ao CyberCore. + +Colete, armazene e registre somente o necessário para operação, diagnóstico e auditoria. + +21. DOCUMENTAÇÃO + +Mantenha documentação técnica em docs/. + +Documente especialmente docs/CCSP.md, arquitetura, instalação, certificados A1/A3, PKCS#11, segurança, SDK, erros, diagnóstico, desenvolvimento e limitações. + +Toda mudança relevante de protocolo ou arquitetura deve atualizar a documentação correspondente. + +22. IMPLEMENTAÇÃO + +Ao receber uma tarefa: + +1) ANALISE o projeto real e identifique impacto, dependências e riscos. +2) PLANEJE a solução, apresentando arquivos/componentes afetados e estratégia de testes. +3) PEÇA AUTORIZAÇÃO explícita antes de modificar o código. +4) IMPLEMENTE seguindo os padrões existentes e evitando alterações fora do escopo. +5) VALIDE com build, testes, análise de erros e, quando possível, execução real. + +Não marque uma tarefa como concluída apenas porque o código compila. + +23. REGRA FINAL + +Priorize segurança, interoperabilidade, rastreabilidade, manutenção e compatibilidade. + +Não invente suporte a certificados, tokens, providers, algoritmos ou hardware que não tenham sido realmente implementados/testados. + +Quando algo depender de fabricante, middleware, Windows ou hardware físico, declare a dependência. + +Princípio obrigatório: + +ANALISAR → PLANEJAR → AUTORIZAR → IMPLEMENTAR → VALIDAR. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83424a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# ============================================================================== +# CyberCore Signer - .gitignore +# ============================================================================== + +# Compilação e Artefatos Maven +target/ +**/target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Binários gerados e Distribuições Locais +*.jar +*.war +*.ear +*.zip +dist/ +build/ +bin/ + +# Certificados, Chaves e Credenciais Locais (Privacidade e Segurança) +*.pfx +*.p12 +*.jks +*.keystore +*.key +*.pem +*.cer +*.crt +*.der +!assets/ +!docs/ + +# Ambientes e Variáveis Locais +.env +.env.* +!.env.example + +# IDEs e Editores +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +*.code-workspace +.project +.classpath +.settings/ +.factorypath +bin/ + +# Logs e Auditorias de Runtime Locais +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Node / SDK de Testes +node_modules/ +sdk/node_modules/ +coverage/ +.nyc_output/ + +# Sistema Operacional +.DS_Store +Thumbs.db +desktop.ini +$RECYCLE.BIN/ +*.tmp +*~ + +# Arquivos temporários de Scratch locais +scratch/temp/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c0bcafe --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..bae019f --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# CyberCore Signer + +> **Agente Desktop de Assinatura Digital e Autenticação Criptográfica (A1 / A3 / PKCS#11) para Windows e Linux.** + +O **CyberCore Signer** é uma solução desktop em Java 21 LTS com JavaFX que permite que aplicações web autorizadas solicitem assinaturas digitais, autenticação de sessão e carimbos do tempo utilizando certificados digitais ICP-Brasil (A1 em arquivo e A3 em tokens/smartcards físicos) instalados ou conectados na estação de trabalho do usuário. + +A comunicação local entre a página web e o agente utiliza o protocolo **CCSP (CyberCore Signing Protocol)** via HTTP/WebSocket localhost autenticado e seguro. + +--- + +## 🏛 Arquitetura do Projeto + +O projeto é estruturado em módulos Maven: + +* **[modules/cybercore-core](modules/cybercore-core)**: Núcleo de regras criptográficas (JCA/JCE, SunPKCS11, X.509, PAdES, XMLDSig, carimbo de tempo TSA, LTV e validação de cadeias). Não possui dependência de interface gráfica. +* **[modules/cybercore-ui](modules/cybercore-ui)**: Interface gráfica com o usuário construída em JavaFX (Dashboard, Gerenciador de Certificados, Diagnóstico Criptográfico, Diálogo de Confirmação de Assinatura, PIN dialog e System Tray). +* **[modules/cybercore-app](modules/cybercore-app)**: Ponto de entrada da aplicação (`CyberCoreApp`), orquestrador dos serviços de ciclo de vida e servidor CCSP local. +* **[sdk/](sdk)**: SDK JavaScript oficial (`@cybercore/signer-sdk`) para integração de aplicações web com o agente desktop. +* **[docs/](docs)**: Documentação técnica detalhada de arquitetura, protocolo, segurança e casos de uso. + +--- + +## 🚀 Requisitos + +* **Java Development Kit (JDK):** Versão 21+ LTS (OpenJDK, Temurin, Corretto, etc.). +* **Maven:** 3.9+ (ou utilizar o wrapper `./mvnw` incluído no projeto). +* **Sistema Operacional Alvo:** Windows 10/11 ou Linux (x86_64). + +--- + +## 🛠 Compilação e Empacotamento + +### 1. Compilação do JAR Executável + +Para compilar e gerar o JAR consolidado (com todas as dependências sombreadas e assets): + +```bash +./build-jar.sh +``` + +Ou diretamente via Maven: + +```bash +./mvnw clean package -DskipTests +``` + +O artefato consolidado será gerado em: +`modules/cybercore-app/target/cybercore-signer.jar` + +### 2. Execução Local + +Para iniciar o agente em ambiente de desenvolvimento: + +```bash +# No Linux / macOS: +./run.sh + +# No Windows: +run.bat +``` + +### 3. Empacotamento com Runtime Nativo (`jpackage`) + +Para criar a distribuição com Java Runtime embutido (sem exigir JDK pré-instalado pelo usuário final): + +```bash +./scripts/package-jpackage.sh +``` + +--- + +## 🔒 Princípios de Segurança e Privacidade + +1. **Chave Privada Preservada:** A chave privada e os segredos (PIN de token A3 e senha de certificado A1) nunca saem da máquina do usuário nem trafegam pela rede. +2. **Assinatura por Digest:** Por padrão, a aplicação web calcula o hash/digest (ex: SHA-256) do documento e o CyberCore assina localmente apenas o digest, minimizando o tráfego de dados sensíveis. +3. **Confirmação Explícita:** Toda operação de assinatura exige consentimento e autorização visual do usuário antes da aplicação da chave. +4. **Isolamento de Origem e CCSP:** O agente atende apenas origens web autorizadas, protegendo contra abusos via CSRF ou requisições locais não autenticadas. + +--- + +## 📚 Documentação Técnica + +Consulte a pasta [docs/](docs) para especificações completas: + +* [Protocolo CCSP (CyberCore Signing Protocol)](docs/CCSP.md) +* [Arquitetura e Fluxo do Sistema](docs/architecture.md) +* [Certificados A1 e A3 (PKCS#11 / MSCAPI)](docs/a1-a3-certificates.md) +* [Assinatura de Documentos PDF (PAdES / LTV / TSA)](docs/pades.md) +* [Assinatura de Documentos XML (XMLDSig)](docs/xml-xmldsig.md) +* [Autenticação e Sessão Criptográfica](docs/authentication-login.md) +* [Diagnóstico e Resolução de Problemas](docs/diagnostics.md) +* [Segurança e Tratamento de Erros](docs/security.md) +* [SDK Web JavaScript](docs/sdk.md) +* [Guia de Instalação](docs/installation.md) + +--- + +## 📄 Licença + +Propriedade de **CyberCore Systems**. Todos os direitos reservados. diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..d77f68b --- /dev/null +++ b/assets/README.md @@ -0,0 +1,75 @@ +# Pasta de Ícones (SVG) e Imagens (PNG) — CyberCore Signer + +Esta pasta é o repositório oficial onde você pode visualizar, customizar e substituir livremente os **ícones em SVG** e as **imagens em PNG** do **CyberCore Signer**. + +--- + +## 📁 Estrutura de Arquivos Criados + +``` +assets/ +├── icons/ # Ícones vetoriais SVG reais e editáveis (W3C padrão) +│ ├── pdf.svg # Ícone Assinar PDF (documento com tag vermelha PDF) +│ ├── xml.svg # Ícone Assinar XML (documento com tag azul ) +│ ├── file.svg # Ícone Assinar Arquivo (documento com caneta) +│ ├── batch.svg # Ícone Assinatura em Lote (pilha de documentos com check) +│ ├── attached.svg # Ícone Assinatura Atachada (documento com clipe de papel) +│ ├── co-sign.svg # Ícone Co-Assinar (documentos duplos com usuários) +│ ├── timestamp.svg # Ícone Carimbo do Tempo (documento com relógio TSA) +│ ├── ltv.svg # Ícone Assinatura LTV (documento com selo perpétuo) +│ ├── validate.svg # Ícone Validar Assinatura (documento com checkmark verde) +│ ├── validate-timestamp.svg # Ícone Validar Carimbo (relógio com checkmark verde) +│ ├── exit.svg # Ícone Sair do Agente (porta com seta vermelha de saída) +│ ├── logs.svg # Ícone Terminal de Logs (janela com prompt ) +│ ├── settings.svg # Ícone Configurações (engrenagem de precisão de 8 dentes) +│ ├── tutorial.svg # Ícone Tutorial e Ajuda (monitor com vídeo/play) +│ ├── cert.svg # Ícone Certificados Digitais (pasta com cadeado seguro) +│ ├── about.svg # Ícone Sobre o Sistema (círculo azul com 'i') +│ ├── sun.svg # Ícone Tema Claro (sol dourado com raios vetoriais) +│ ├── moon.svg # Ícone Tema Escuro (lua crescente azul/índigo) +│ ├── shield.svg # Ícone Brasão de Segurança (CyberShield com chave interna) +│ ├── seal.svg # Ícone Designer e Gerenciador de Selos Notariais +│ ├── check.svg # Ícone Checkmark de sucesso e validação +│ ├── error.svg # Ícone de Erro / Alerta em vermelho +│ └── star.svg # Ícone de Estrela notarial / favorita +│ +└── images/ # Imagens de alta fidelidade em PNG com canal alfa (transparência) + ├── app-icon.png # Ícone oficial do app (256x256 px - Diamante 3D azul Serpro) + ├── app-logo.png # Logotipo horizontal oficial (480x120 px - CyberCore SIGNER) + ├── brand-banner.png # Banner de cabeçalho e marca (600x160 px - Serpro Blue) + └── default-seal.png # Selo de assinatura padrão ICP-Brasil (640x164 px) +``` + +--- + +## 🎨 Como Customizar Qualquer Ícone ou Imagem + +1. **Ícones:** + - Estão no formato `.svg` padrão. + - Podem ser editados diretamente no seu editor de código (VS Code, etc.) ou em editores vetoriais como **Inkscape, Figma ou Illustrator**. + - Se preferir colocar uma imagem bitmap para um ícone específico, você também pode colocar um `.png` (ex: `pdf.png`). O sistema prioriza `.svg` e aceita `.png` com fallback transparente. + +2. **Imagens do Sistema:** + - Estão no formato `.png` com canal alfa de 32 bits (fundo transparente). + - Você pode substituir `app-logo.png`, `app-icon.png`, `brand-banner.png` ou `default-seal.png` pelo logotipo e identidade visual da sua instituição ou empresa. + +3. **Ordem de Prioridade e Hot-Reloading:** + Quando a aplicação busca um ícone ou imagem, a resolução ocorre na ordem: + 1. **Pasta Física Local (`./assets/icons/` e `./assets/images/`)**: Qualquer alteração feita aqui entra em vigor imediatamente ao abrir a janela ou reiniciar a aplicação, sem necessidade de recompilar! + 2. **Pasta do Usuário (`~/.cybercore/assets/`)**: Configurações específicas por usuário do sistema operacional. + 3. **Resources Empacotados no JAR (`/assets/`)**: Cópia empacotada internamente no `.jar`. + 4. **Fallback Vetorial Nativo (`SvgIcons`)**: Garantia de que a interface nunca fique em branco caso algum arquivo seja excluído. + +--- + +## 🚀 Como Testar Suas Modificações + +Para compilar e gerar o pacote `.jar` completo com todos os novos assets embutidos: +```bash +./build-jar.sh +``` + +Para rodar a aplicação: +```bash +java -jar ./cybercore-signer.jar +``` diff --git a/assets/icons/about.svg b/assets/icons/about.svg new file mode 100644 index 0000000..f7cc7f1 --- /dev/null +++ b/assets/icons/about.svg @@ -0,0 +1 @@ +info-circle-fill \ No newline at end of file diff --git a/assets/icons/attached.svg b/assets/icons/attached.svg new file mode 100644 index 0000000..7d894e7 --- /dev/null +++ b/assets/icons/attached.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/assets/icons/batch.svg b/assets/icons/batch.svg new file mode 100644 index 0000000..5ced311 --- /dev/null +++ b/assets/icons/batch.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/icons/cert.svg b/assets/icons/cert.svg new file mode 100644 index 0000000..14cc2b4 --- /dev/null +++ b/assets/icons/cert.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 0000000..c379af0 --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/co-sign.svg b/assets/icons/co-sign.svg new file mode 100644 index 0000000..283c24f --- /dev/null +++ b/assets/icons/co-sign.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/error.svg b/assets/icons/error.svg new file mode 100644 index 0000000..5a36ff3 --- /dev/null +++ b/assets/icons/error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/exit.svg b/assets/icons/exit.svg new file mode 100644 index 0000000..d209a6b --- /dev/null +++ b/assets/icons/exit.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/assets/icons/file.svg b/assets/icons/file.svg new file mode 100644 index 0000000..023885f --- /dev/null +++ b/assets/icons/file.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/icons/logs.svg b/assets/icons/logs.svg new file mode 100644 index 0000000..0dd89e3 --- /dev/null +++ b/assets/icons/logs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/icons/ltv.svg b/assets/icons/ltv.svg new file mode 100644 index 0000000..dbc162a --- /dev/null +++ b/assets/icons/ltv.svg @@ -0,0 +1,11 @@ + + + + + + + + + + LTV + diff --git a/assets/icons/moon.svg b/assets/icons/moon.svg new file mode 100644 index 0000000..fd47105 --- /dev/null +++ b/assets/icons/moon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/pdf.svg b/assets/icons/pdf.svg new file mode 100644 index 0000000..3755ded --- /dev/null +++ b/assets/icons/pdf.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + PDF + diff --git a/assets/icons/seal.svg b/assets/icons/seal.svg new file mode 100644 index 0000000..1aa3a5f --- /dev/null +++ b/assets/icons/seal.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg new file mode 100644 index 0000000..e0f4042 --- /dev/null +++ b/assets/icons/settings.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/shield.svg b/assets/icons/shield.svg new file mode 100644 index 0000000..1bc2539 --- /dev/null +++ b/assets/icons/shield.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/star.svg b/assets/icons/star.svg new file mode 100644 index 0000000..ac2213a --- /dev/null +++ b/assets/icons/star.svg @@ -0,0 +1,4 @@ + + + diff --git a/assets/icons/sun.svg b/assets/icons/sun.svg new file mode 100644 index 0000000..0327476 --- /dev/null +++ b/assets/icons/sun.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/icons/timestamp.svg b/assets/icons/timestamp.svg new file mode 100644 index 0000000..16238f4 --- /dev/null +++ b/assets/icons/timestamp.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/icons/tutorial.svg b/assets/icons/tutorial.svg new file mode 100644 index 0000000..32d13d2 --- /dev/null +++ b/assets/icons/tutorial.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/validate-timestamp.svg b/assets/icons/validate-timestamp.svg new file mode 100644 index 0000000..dc0f7c8 --- /dev/null +++ b/assets/icons/validate-timestamp.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/validate.svg b/assets/icons/validate.svg new file mode 100644 index 0000000..f654ce8 --- /dev/null +++ b/assets/icons/validate.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/xml.svg b/assets/icons/xml.svg new file mode 100644 index 0000000..edbf6a9 --- /dev/null +++ b/assets/icons/xml.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + <XML> + diff --git a/assets/images/app-icon.png b/assets/images/app-icon.png new file mode 100644 index 0000000..e9e231b Binary files /dev/null and b/assets/images/app-icon.png differ diff --git a/assets/images/app-logo.png b/assets/images/app-logo.png new file mode 100644 index 0000000..0cea4b9 Binary files /dev/null and b/assets/images/app-logo.png differ diff --git a/assets/images/brand-banner.png b/assets/images/brand-banner.png new file mode 100644 index 0000000..6bd57c9 Binary files /dev/null and b/assets/images/brand-banner.png differ diff --git a/assets/images/default-seal.png b/assets/images/default-seal.png new file mode 100644 index 0000000..d12d737 Binary files /dev/null and b/assets/images/default-seal.png differ diff --git a/build-jar.sh b/build-jar.sh new file mode 100755 index 0000000..f42ecd9 --- /dev/null +++ b/build-jar.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# ============================================================================== +# CyberCore Signer - Script de Automação de Build do Fat JAR Executável +# ============================================================================== +set -euo pipefail + +# Garante execução no diretório raiz do projeto +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +COLOR_RESET="\033[0m" +COLOR_BOLD="\033[1m" +COLOR_CYAN="\033[36m" +COLOR_GREEN="\033[32m" +COLOR_YELLOW="\033[33m" +COLOR_RED="\033[31m" + +RUN_AFTER_BUILD=false +RUN_TESTS=false + +# Interpretação de argumentos +for arg in "$@"; do + case "${arg}" in + --with-tests|-t) + RUN_TESTS=true + ;; + --run|-r) + RUN_AFTER_BUILD=true + ;; + --help|-h) + echo -e "${COLOR_BOLD}Uso: ./build-jar.sh [opções]${COLOR_RESET}" + echo "" + echo "Opções disponíveis:" + echo " --with-tests, -t Executa a suíte completa de testes unitários antes de empacotar." + echo " --run, -r Inicia automaticamente o CyberCore Signer após a compilação." + echo " --help, -h Exibe esta mensagem de ajuda." + echo "" + echo "Exemplo:" + echo " ./build-jar.sh # Compilação e empacotamento rápido do JAR" + echo " ./build-jar.sh --run # Compila e já abre a aplicação" + exit 0 + ;; + *) + echo -e "${COLOR_YELLOW}Aviso: Opção desconhecida: ${arg}. Use --help para detalhes.${COLOR_RESET}" + ;; + esac +done + +echo -e "${COLOR_CYAN}${COLOR_BOLD}" +echo "============================================================" +echo " CYBERCORE SIGNER - AUTOMAÇÃO DE BUILD DO JAR" +echo "============================================================" +echo -e "${COLOR_RESET}" + +# 1. Verificação e Auto-Detecção do Java (JDK 21+) +if ! command -v java &>/dev/null; then + POSSIBLE_JDKS=( + "/home/claudecio/.jdks/jdk-25.0.4.1+1" + "/usr/lib/jvm/java-25-openjdk-amd64" + "/usr/lib/jvm/java-21-openjdk-amd64" + "/usr/lib/jvm/default-java" + ) + for candidate in "${POSSIBLE_JDKS[@]}"; do + if [ -x "${candidate}/bin/java" ]; then + export JAVA_HOME="${candidate}" + export PATH="${JAVA_HOME}/bin:${PATH}" + break + fi + done +fi + +if ! command -v java &>/dev/null; then + echo -e "${COLOR_RED}Erro: Java não foi encontrado no PATH nem nos diretórios padrão de JDK.${COLOR_RESET}" + echo -e "${COLOR_YELLOW}Dica: Não é necessário executar com 'sudo'. Tente executar normalmente: ./build-jar.sh${COLOR_RESET}" + exit 1 +fi + +JAVA_VERSION=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | head -n 1) +echo -e "Ambiente Java detectado: ${COLOR_BOLD}${JAVA_VERSION}${COLOR_RESET} (${JAVA_HOME:-$(which java)})" + +# 2. Verificação do Maven Wrapper +if [[ ! -f "./mvnw" ]]; then + echo -e "${COLOR_RED}Erro: Maven Wrapper (./mvnw) não encontrado na raiz do projeto.${COLOR_RESET}" + exit 1 +fi +chmod +x ./mvnw + +# 3. Execução do Build +MVN_ARGS=("clean" "package") +if [[ "${RUN_TESTS}" == "false" ]]; then + echo -e "${COLOR_YELLOW}Modo rápido ativado (pulando testes unitários). Para testar use: ./build-jar.sh --with-tests${COLOR_RESET}" + MVN_ARGS+=("-DskipTests") +else + echo -e "${COLOR_CYAN}Executando testes unitários e de integração...${COLOR_RESET}" +fi + +echo -e "Iniciando compilação e geração do Fat JAR via Maven..." +./mvnw "${MVN_ARGS[@]}" + +SOURCE_JAR="modules/cybercore-app/target/cybercore-signer.jar" +DEST_JAR="./cybercore-signer.jar" + +if [[ ! -f "${SOURCE_JAR}" ]]; then + echo -e "${COLOR_RED}Erro: O artefato ${SOURCE_JAR} não foi gerado pelo build.${COLOR_RESET}" + exit 1 +fi + +# 4. Copia para a raiz para facilitar o acesso +cp -f "${SOURCE_JAR}" "${DEST_JAR}" + +JAR_SIZE=$(ls -lh "${DEST_JAR}" | awk '{print $5}') + +echo "" +echo -e "${COLOR_GREEN}${COLOR_BOLD}============================================================${COLOR_RESET}" +echo -e "${COLOR_GREEN}${COLOR_BOLD}✓ JAR GERADO COM SUCESSO!${COLOR_RESET}" +echo -e "Arquivo: ${COLOR_BOLD}${DEST_JAR}${COLOR_RESET} (${JAR_SIZE})" +echo -e "Local de origem: ${SOURCE_JAR}" +echo -e "${COLOR_GREEN}${COLOR_BOLD}============================================================${COLOR_RESET}" +echo "" +echo -e "Para testar manualmente, execute:" +echo -e " ${COLOR_CYAN}java -jar ${DEST_JAR}${COLOR_RESET} # Interface gráfica desktop" +echo -e " ${COLOR_CYAN}java -jar ${DEST_JAR} --dev${COLOR_RESET} # Modo desenvolvimento (logs detalhados)" +echo -e " ${COLOR_CYAN}java -jar ${DEST_JAR} --headless${COLOR_RESET} # Modo console de segundo plano (porta 21325)" +echo "" + +# 5. Execução automática opcional +if [[ "${RUN_AFTER_BUILD}" == "true" ]]; then + echo -e "${COLOR_BOLD}Iniciando aplicação agora...${COLOR_RESET}" + java -jar "${DEST_JAR}" +fi diff --git a/config/spotbugs/exclude.xml b/config/spotbugs/exclude.xml new file mode 100644 index 0000000..e3949df --- /dev/null +++ b/config/spotbugs/exclude.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/docs/CCSP.md b/docs/CCSP.md new file mode 100644 index 0000000..ae4aec0 --- /dev/null +++ b/docs/CCSP.md @@ -0,0 +1,208 @@ +# CyberCore Signing Protocol (CCSP/1) + +O **CyberCore Signing Protocol (CCSP)** é o protocolo padrão para comunicação local entre aplicações web rodando no navegador e o agente desktop **CyberCore Signer**. + +A versão inicial descrita neste documento é a **CCSP/1**. + +--- + +## 1. Princípios de Segurança + +1. **Isolamento de Rede:** O servidor local escuta exclusivamente no endereço de loopback `127.0.0.1` (porta padrão `21325`), impedindo qualquer acesso a partir da rede local (LAN) ou externa (WAN). +2. **Controle Estrito de Origem (CORS):** O agente valida cabeçalhos `Origin` e responde apenas a pré-voos `OPTIONS` com origens autorizadas. +3. **Registro e Autorização de Aplicações:** Nenhuma aplicação desconhecida tem acesso a certificados ou assinaturas. Ao tentar conectar pela primeira vez, uma caixa de diálogo nativa solicita aprovação explícita do usuário. +4. **Confirmação Visual da Assinatura:** Toda operação de assinatura exige confirmação manual na interface gráfica, exibindo a aplicação solicitante, o hash do documento e o certificado selecionado. +5. **Proteção Contra Ataques de Replay:** Toda requisição deve conter um `nonce` UUID único com validação temporal (`expires_at` ou `timestamp`). Requisições com nonces repetidos são sumariamente rejeitadas (`REPLAY_DETECTED`). +6. **Privacidade Criptográfica:** Apenas o **digest (hash)** do documento é enviado ao agente. O documento completo nunca sai da aplicação web e a chave privada nunca sai do dispositivo/computador do usuário. + +--- + +## 2. Endpoints do Protocolo (CCSP/1) + +### 2.1 `GET /ccsp/v1/status` +Verifica se o agente está instalado, em execução e pronto para operações. + +#### Resposta de Sucesso (200 OK) +```json +{ + "version": "1", + "status": "ready", + "agent": "CyberCore Signer", + "agent_version": "0.1.0", + "os": "Windows 11", + "timestamp": "2026-10-08T19:40:00Z" +} +``` + +--- + +### 2.2 `GET /ccsp/v1/certificates` +Retorna a lista de certificados digitais atualmente disponíveis e aptos para assinatura. + +#### Headers / Parâmetros +- `X-CyberCore-AppId`: UUID da aplicação autorizada (ou query param `?application_id=uuid`). +- `Origin`: Origem web autorizada. + +#### Resposta de Sucesso (200 OK) +```json +{ + "version": "1", + "status": "success", + "certificates": [ + { + "id": "win-4340656a", + "alias": "CLAUDIO SILVA:12345678900", + "common_name": "CLAUDIO SILVA:12345678900", + "subject_dn": "CN=CLAUDIO SILVA:12345678900, OU=Autenticado por AR, O=ICP-Brasil, C=BR", + "issuer_dn": "CN=AC SERPRO v5, O=ICP-Brasil, C=BR", + "type": "A3", + "provider_id": "provider-win-store", + "device_name": "Token / Smart Card (Windows CAPI/CNG)", + "valid_from": "2024-05-10T12:00:00Z", + "valid_to": "2027-05-10T12:00:00Z", + "serial_number": "72910482910482019482", + "can_sign": true, + "status": "AVAILABLE" + } + ] +} +``` + +--- + +### 2.3 `POST /ccsp/v1/authorize` +Handshake inicial de uma nova aplicação web para solicitar autorização ao usuário. + +#### Requisição +```json +{ + "version": "1", + "application_id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Portal Assinaturas Jurídicas" +} +``` + +#### Resposta (200 OK) +```json +{ + "version": "1", + "status": "authorized", + "application_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +--- + +### 2.4 `POST /ccsp/v1/sign` +Executa a assinatura digital do digest informado (PDF, XML, documentos ou arquivos gerais). + +#### Requisição +```json +{ + "version": "1", + "request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "application_id": "550e8400-e29b-41d4-a716-446655440000", + "operation": "sign", + "algorithm": "SHA256withRSA", + "digest": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", + "certificate_hint": "win-4340656a", + "document_name": "contrato_locacao.pdf", + "document_type": "PDF", + "nonce": "c4ca4238a0b923820dcc509a6f75849b", + "expires_at": "2026-10-08T19:45:00Z" +} +``` + +#### Resposta de Sucesso (200 OK) +```json +{ + "version": "1", + "request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "status": "signed", + "signature": "MEUCIQDq...base64...", + "certificate": "MIIFazC...base64...", + "certificate_chain": [ + "MIIFazC...base64...", + "MIIEazC...base64..." + ], + "algorithm": "SHA256withRSA", + "subject_name": "CLAUDIO SILVA", + "subject_cpf_cnpj": "12345678900", + "document_name": "contrato_locacao.pdf", + "document_type": "PDF" +} +``` + +--- + +### 2.5 `POST /ccsp/v1/authenticate` +Executa o login / autenticação digital segura de um usuário em sistemas web via desafio criptográfico (Challenge-Response ICP-Brasil). + +#### Requisição +```json +{ + "version": "1", + "request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "application_id": "550e8400-e29b-41d4-a716-446655440000", + "operation": "authenticate", + "challenge": "server-session-challenge-nonce-2026", + "certificate_hint": "win-4340656a", + "purpose": "AUTHENTICATION", + "nonce": "c4ca4238a0b923820dcc509a6f75849b", + "expires_at": "2026-10-08T19:45:00Z" +} +``` + +#### Resposta de Sucesso (200 OK) +```json +{ + "version": "1", + "request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "status": "authenticated", + "signature": "MEUCIQDq...base64...", + "certificate": "MIIFazC...base64...", + "certificate_chain": [ + "MIIFazC...base64..." + ], + "algorithm": "SHA256withRSA", + "subject_name": "CLAUDIO SILVA", + "subject_cpf_cnpj": "12345678900", + "document_type": "AUTH_CHALLENGE" +} +``` + +#### Resposta de Erro (400/403/404) +```json +{ + "version": "1", + "request_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "status": "error", + "error_code": "USER_CANCELLED", + "message": "Operação de assinatura cancelada pelo usuário." +} +``` + +--- + +## 3. Tabela de Códigos de Erro Estruturados + +| Código | Significado | +|---|---| +| `CERTIFICATE_NOT_FOUND` | Certificado indicado no hint não foi encontrado. | +| `CERTIFICATE_EXPIRED` | O certificado selecionado está fora do período de vigência. | +| `CERTIFICATE_INVALID` | O certificado é inválido ou corrompido. | +| `PRIVATE_KEY_NOT_AVAILABLE` | Chave privada inacessível ou ausente. | +| `TOKEN_NOT_FOUND` | Nenhum dispositivo ou leitor compatível foi localizado. | +| `TOKEN_REMOVED` | Dispositivo de hardware foi removido durante a assinatura. | +| `PIN_REQUIRED` | PIN obrigatório para liberar chave privada em PKCS#11. | +| `PIN_INCORRECT` | PIN digitado está incorreto. | +| `PKCS11_ERROR` | Falha no middleware PKCS#11 do fabricante. | +| `SIGNATURE_FAILED` | Erro durante a geração da assinatura digital. | +| `APPLICATION_NOT_AUTHORIZED` | Aplicação não autorizada ou recusada pelo usuário. | +| `REQUEST_EXPIRED` | A data/hora de validade da requisição expirou. | +| `REPLAY_DETECTED` | O nonce informado já foi processado anteriormente. | +| `INVALID_ORIGIN` | O cabeçalho Origin não corresponde ao cadastro da aplicação. | +| `UNSUPPORTED_ALGORITHM` | Algoritmo não suportado (ex: diferente de SHA256withRSA). | +| `USER_CANCELLED` | O usuário clicou em Cancelar ou fechou a janela de confirmação. | +| `PROVIDER_NOT_AVAILABLE` | O provedor criptográfico necessário está inativo no SO. | +| `COMMUNICATION_ERROR` | Erro interno no agente de comunicação local. | diff --git a/docs/a1-a3-certificates.md b/docs/a1-a3-certificates.md new file mode 100644 index 0000000..be529d3 --- /dev/null +++ b/docs/a1-a3-certificates.md @@ -0,0 +1,46 @@ +# Certificados Digitais A1, A3 e PKCS#11 + +O **CyberCore Signer** oferece suporte abrangente aos formatos e repositórios de certificados digitais padrão ICP-Brasil utilizados no Brasil. + +--- + +## 1. Certificados Digitais A1 (Software / PKCS#12) + +Os certificados A1 são arquivos com extensão `.pfx` ou `.p12` protegidos por senha. + +### Fluxo de Uso +1. O usuário seleciona o arquivo via diálogo nativo (`FileChooser`) na interface ou importa via tela de Dashboard. +2. É solicitada a senha em um diálogo seguro (`A1PasswordDialog`), mantida em `char[]` temporário e sobrescrita com zeros logo após a leitura da chave. +3. O provedor `Pkcs12CertificateProvider` instancia um `KeyStore.getInstance("PKCS12")` e extrai os metadados do certificado X.509 (`subject`, `issuer`, `validade`, `algoritmo`, `tamanho da chave`). +4. Durante a assinatura, a chave privada em memória gera a assinatura digital via `Signature.getInstance("SHA256withRSA")`. + +--- + +## 2. Certificados Digitais A3 no Windows (Windows Certificate Store) + +No Windows 10/11, a maioria dos drivers de tokens USB e Smart Cards (ex: SafeSign, SafeNet Authentication Client, Serpro, Certisign) registra seus certificados diretamente no repositório de certificados do usuário (`Windows-MY`). + +### Vantagens do Provider `WindowsCertificateStoreProvider` +- Acessa certificados físicos via CryptoAPI (CAPI) e Cryptography Next Generation (CNG) através do provedor JDK `SunMSCAPI`. +- A chave privada permanece dentro do chip criptográfico do token. +- Quando o token exige autenticação por PIN, a janela nativa do próprio driver do fabricante é invocada com segurança pelo sistema operacional Windows. + +--- + +## 3. Hardware PKCS#11 Direto (Tokens USB e Smartcards) + +Para ambientes onde se deseja acesso direto ao middleware ou no Linux: +- `Pkcs11CertificateProvider` pesquisa bibliotecas DLL / SO padrão: + - `eps2003csp11.dll` (SafeNet / eToken) + - `aetpkss1.dll` (SafeSign AET) + - `acospkcs11.dll` (ACOS / G&D) + - `opensc-pkcs11.dll` / `opensc-pkcs11.so` (OpenSC) +- Utiliza o provider SunPKCS11 do JDK (`jdk.crypto.cryptoki`). + +--- + +## 4. Monitoramento Dinâmico de Hardware (`SmartCardWatcher`) + +O componente `SmartCardWatcher` monitora os leitores PC/SC em segundo plano: +- **Ao inserir um token:** Atualiza a lista de certificados automaticamente e notifica a interface gráfica. +- **Ao remover o token:** Registra evento de auditoria (`AUDITORIA [TOKEN_REMOVED]`), limpa o cache de PIN de sessão em `PinSessionManager` e força um warm reset via `SmartCardSessionManager`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..150052c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,79 @@ +# Arquitetura do CyberCore Signer + +O **CyberCore Signer** é um agente desktop nativo desenvolvido em Java 21+ LTS (Java 25 LTS) com interface JavaFX e segurança criptográfica JCA/JCE pura, projetado para permitir que aplicações web autorizadas solicitem assinaturas digitais ICP-Brasil padrão **A1** e **A3** diretamente da máquina do usuário. + +--- + +## 1. Visão Geral da Arquitetura em Módulos + +O projeto adota uma arquitetura desacoplada em múltiplos módulos Maven independentes: + +``` +cybercore-signer/ +├── modules/ +│ ├── cybercore-core/ # Núcleo de criptografia, provedores, protocolo e segurança +│ ├── cybercore-ui/ # Interface gráfica JavaFX, diálogos nativos e System Tray +│ └── cybercore-app/ # Ponto de entrada (Main), bootstrap e empacotamento nativo +├── sdk/ # SDK Web em JavaScript (@cybercore/signer-sdk) +├── scripts/ # Scripts de build nativo jpackage para Windows e Linux +└── docs/ # Especificações técnicas e manuais de integração +``` + +### 1.1 `cybercore-core` +- **Domínio:** Modelos imutáveis como `ManagedCertificate`, `SigningContext`, `SigningResult`, `CertificateType` e `CertificateStatus`. +- **Criptografia:** `SigningService` e `DigestAlgorithm` utilizando exclusivamente os provedores padrão JCA/JCE (sem reimplementação manual de algoritmos). +- **Provedores:** Abstração `CertificateProvider` com implementações: + - `Pkcs12CertificateProvider`: leitura de arquivos `.pfx`/`.p12` em memória protegida. + - `WindowsCertificateStoreProvider`: integração nativa com o repositório de chaves do Windows (`Windows-MY` via SunMSCAPI/CNG). + - `Pkcs11CertificateProvider`: integração com drivers de middleware e tokens USB/Smartcards via `SunPKCS11`. +- **Protocolo & Rede Local:** `CcspServer` escuta estritamente em `127.0.0.1:21325`, fornecendo a API REST CCSP/1. +- **Segurança & Configurações:** `ApplicationRegistry`, `NonceManager` (proteção anti-replay), `OriginValidator`, `SmartCardSessionManager` e `AgentConfigurationService` (persistência em `~/.cybercore/config.json`). +- **Validação Criptográfica:** `SignatureValidationService` com suporte a Assinaturas Incorporadas (PAdES PDF, CMS/P7M com conteúdo embutido, XMLDSig) e Assinaturas Destacadas (Detached CMS/P7S). +- **Selos Visuais Personalizados:** `CustomSealStorage` (persistência em `~/.cybercore/seals/`), `PdfSealThemeRegistry` dinâmico e `CpfDisplayMode` (privacidade LGPD). + +### 1.2 `cybercore-ui` +- Interface desenvolvida com JavaFX 25, empregando layout moderno e simétrico de grade 3x3 de operações e coluna lateral de utilitários: + - `MainAppView`: grade 3x3 otimizada: + - Linha 0: Assinar PDF, Assinar XML, Assinatura Atachada + - Linha 1: Co-Assinar Documento, Carimbo do Tempo, Gerenciar Selos + - Linha 2: Validar Assinatura, Validar Carimbo, Sair do Agente + - `SealDesignerDialog`: criador e instalador de selos digitais personalizados com pré-visualização ao vivo (Live Preview), seletores de cores, estilos de emblema, obrigatoriedade de nome completo, controle de mascaramento de CPF e estampa de data/hora. + - `SignatureValidationDialog`: diálogo de validação criptográfica real com alternância entre Assinatura Incorporada e Assinatura Destacada, extração de documento embutido e relatório de signatários. + - `PdfSignDialog`: modal de assinatura PAdES com posicionamento por clique na folha, atalho direto para criar/instalar selos e opções dinâmicas de CPF/data-hora. + - `SettingsView`: configurações gerais persistentes, política de PIN e gerenciamento de selos instalados. + +### 1.3 `cybercore-app` +- Main bootstrap (`CyberCoreSignerApplication`). +- Tratamento de parâmetros de inicialização (`--dev`, `--headless`). +- Execução de diagnósticos preliminares de ambiente e engine criptográfica antes do início das threads de escuta. +- Geração de Fat JAR autocontido via `maven-shade-plugin` e preparação para distribuição `jpackage`. + +--- + +## 2. Fluxo de Dados e Princípio de Menor Privilégio + +O CyberCore Signer opera sob o **modelo de assinatura descentralizada por digest**: +1. A aplicação web calcula o hash criptográfico SHA-256 no próprio navegador utilizando a Web Crypto API nativa. +2. Apenas o hash Base64 de 32 bytes trafega localmente para o CyberCore Signer. +3. O documento completo nunca sai do navegador e a chave privada nunca sai do computador ou do hardware do usuário. + +```mermaid +sequenceDiagram + autonumber + actor Usuario as Usuário + participant Web as Aplicação Web (SDK) + participant CCSP as CcspServer (127.0.0.1) + participant UI as CyberCore JavaFX + participant Crypto as SigningService & Providers + + Web->>CCSP: POST /ccsp/v1/sign (digest, appId, certHint, nonce) + CCSP->>CCSP: Valida autorização, nonce anti-replay e digest + CCSP->>UI: Solicita confirmação visual (requestSignatureConfirmation) + UI->>Usuario: Exibe Diálogo (Hash, Certificado, Aplicação) + Usuario->>UI: Aprova assinatura (e insere PIN se A3) + UI-->>CCSP: Confirmação concedida + CCSP->>Crypto: sign(SigningContext) [Trava FIFO] + Crypto->>Crypto: JCA/JCE Signature.update(digest) + sign() + Crypto-->>CCSP: SigningResult (signatureBytes, certChain) + CCSP-->>Web: JSON { status: "signed", signature: "...", certificate: "..." } +``` diff --git a/docs/authentication-login.md b/docs/authentication-login.md new file mode 100644 index 0000000..ee7a4b5 --- /dev/null +++ b/docs/authentication-login.md @@ -0,0 +1,71 @@ +# Autenticação e Login Digital via CyberCore Signer + +Este documento descreve o fluxo de **Login e Autenticação de Usuários com Certificado Digital ICP-Brasil (A1 e A3)** através do agente desktop **CyberCore Signer** e o protocolo **CCSP/1**. + +--- + +## 1. Visão Geral do Modelo de Autenticação + +O login por certificado digital no CyberCore Signer adota o padrão de mercado **Challenge-Response Criptográfico**: +1. O backend da sua aplicação web gera um **desafio único (challenge / nonce)** para a sessão atual. +2. A aplicação web solicita a assinatura desse desafio ao **CyberCore Signer** através do SDK JavaScript. +3. O CyberCore Signer apresenta ao usuário na tela nativa do computador uma solicitação visual com destaque de **LOGIN / AUTENTICAÇÃO**: + - Identifica a aplicação solicitante e a origem; + - Mostra o certificado selecionado e o titular; + - Solicita aprovação expressa do usuário (e a digitação do PIN caso seja token ou smart card A3). +4. O CyberCore assina o desafio com a chave privada do certificado local e devolve: + - A **assinatura digital do desafio**; + - O **certificado X.509 completo** (DER Base64) e cadeia; + - O **nome e CPF/CNPJ** do titular extraídos do certificado. +5. O frontend web envia a resposta de volta ao backend, que: + - Valida criptograficamente a assinatura com a chave pública do certificado recebido; + - Confirma a validade e cadeia do certificado junto à ICP-Brasil; + - Localiza o usuário correspondente no banco de dados pelo CPF/CNPJ; + - Cria a sessão autenticada do usuário no sistema. + +--- + +## 2. Exemplo de Integração Frontend com o SDK + +```javascript +import { CyberCoreSigner } from '@cybercore/signer-sdk'; + +const signer = new CyberCoreSigner({ + applicationName: 'Portal Corporativo' +}); + +async function realizarLoginComCertificado(certificateId) { + // 1. Obter desafio único gerado pelo seu backend + const { challenge } = await fetch('/api/auth/challenge').then(r => r.json()); + + // 2. Solicitar autenticação ao CyberCore Signer + const authResult = await signer.authenticate({ + certificateId: certificateId, + challenge: challenge + }); + + // 3. Enviar assinatura e certificado ao backend para validação e abertura de sessão + const sessionResponse = await fetch('/api/auth/login-certificate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: authResult.challenge, + signature: authResult.signature, + certificate: authResult.certificate + }) + }); + + if (sessionResponse.ok) { + console.log('Usuário autenticado com sucesso:', authResult.subjectName); + window.location.href = '/dashboard'; + } +} +``` + +--- + +## 3. Segurança e Prevenção de Ataques + +- **Prevenção de Ataques de Replay:** Cada desafio deve possuir tempo de expiração curto (ex: 60 a 90 segundos) e ser descartado no servidor após uma única tentativa. +- **Proteção da Chave Privada:** A chave privada e o PIN do usuário permanecem exclusivamente sob custódia do hardware/computador local do usuário. +- **Consentimento Explícito:** O CyberCore Signer nunca assina desafios em segundo plano sem a exibição do diálogo e autorização do usuário na tela nativa. diff --git a/docs/development-mode.md b/docs/development-mode.md new file mode 100644 index 0000000..71d3ce9 --- /dev/null +++ b/docs/development-mode.md @@ -0,0 +1,48 @@ +# Modo de Desenvolvimento e Diagnóstico Detalhado + +O **CyberCore Signer** opera exclusivamente com **certificados digitais e provedores criptográficos reais** (A1 via arquivos `.pfx`/`.p12`, repositório nativo do Windows `Windows-MY` e tokens/smart cards físicos PKCS#11). Em conformidade com a exigência de segurança de produção, **não existem mocks ou certificados sintéticos no agente**. + +--- + +## 1. Ativação do Modo de Desenvolvimento + +Para iniciar o agente em modo de desenvolvimento (com logs detalhados e diagnósticos ampliados), utilize o argumento `--dev`: + +```bash +java -jar cybercore-signer.jar --dev +``` + +Ao iniciar, uma mensagem informativa é registrada no log: +``` +========================================================= + MODO DE DESENVOLVIMENTO ATIVADO (--dev) + Logs detalhados ativos. Operação com certificados reais. +========================================================= +``` + +--- + +## 2. Testando em Ambiente de Desenvolvimento + +Para desenvolvedores testarem fluxos completos de assinatura de PDF, XML, arquivos em geral e login digital: + +1. **Certificados A1 de Teste/Homologação:** + - Adicione um certificado A1 `.pfx` ou `.p12` emitido por uma autoridade de teste ou autoassinado. + - Pela interface desktop, clique no botão **"Importar A1 (.pfx/.p12)"** no Dashboard e informe o arquivo e a senha. + - O certificado ficará disponível imediatamente para todas as operações CCSP/1. + +2. **Repositório Nativo do Windows (Windows-MY):** + - Instale o certificado no repositório de certificados pessoais do Windows (`certmgr.msc`). + - O provedor `WindowsCertificateStoreProvider` detectará o certificado automaticamente. + +3. **Tokens USB / Smart Cards (PKCS#11):** + - Conecte o dispositivo físico com middleware homologado (SafeSign, GD Burti, SafeNet, OpenSC, etc.). + - O provedor `Pkcs11CertificateProvider` detectará o token e seus slots. + +--- + +## 3. Garantia de Operação Real + +1. Toda assinatura é gerada criptograficamente através dos provedores JCA/JCE reais. +2. A resposta do protocolo CCSP/1 contém assinaturas válidas e certificados X.509 autênticos. +3. Não há certificados simulados ou flags artificiais nas respostas do agente. diff --git a/docs/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 0000000..8aeb6f7 --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,34 @@ +# Diagnóstico de Ambiente e Auto-Teste + +O **CyberCore Signer** possui um subsistema nativo de diagnóstico integrado (`DiagnosticService`), permitindo auditoria detalhada de saúde e integridade técnica da máquina do usuário. + +--- + +## 1. Itens Inspecionados no Diagnóstico + +Ao abrir a aba **Diagnóstico** na interface ou invocar `diagnosticService.runDiagnostic()`, o agente avalia: + +1. **Ambiente Java / Runtime:** + - Versão do Java (requer Java 21+ LTS, homologado em Java 25 LTS); + - Vendor do JDK (Temurin, Oracle, etc.); + - Arquitetura do processador e sistema operacional. +2. **Engine Criptográfica JCA/JCE:** + - Disponibilidade dos provedores de segurança registrados na JVM (`SUN`, `SunRsaSign`, `SunMSCAPI`, etc.); + - Suporte ao algoritmo de hash `SHA-256`; + - Suporte ao algoritmo de assinatura `SHA256withRSA`. +3. **Repositório de Chaves do Sistema Operacional:** + - Detecção do repositório `Windows-MY` (Windows CryptoAPI / CNG). +4. **Middlewares PKCS#11 e SmartCard:** + - Verificação de bibliotecas de smartcard registradas no sistema (`eps2003csp11.dll`, `aetpkss1.dll`, `opensc-pkcs11.dll`, etc.); + - Disponibilidade do subsistema PC/SC `javax.smartcardio`. + +--- + +## 2. Auto-Teste de Assinatura Local (Self-Test) + +A aba de diagnóstico disponibiliza o botão **"Executar Auto-teste de Assinatura"**: +1. Seleciona o primeiro certificado operacional disponível na máquina. +2. Gera um hash SHA-256 interno de teste: `"Documento_Teste_CyberCore_"`. +3. Abre o diálogo de confirmação visual para validação do usuário. +4. Executa a assinatura real JCA/JCE e afere os bytes gerados. +5. Emite relatório de sucesso com tempo decorrido e tamanho da assinatura. diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..7f2fb9d --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,28 @@ +# Catálogo Estruturado de Erros + +O **CyberCore Signer** adota um sistema unificado e determinístico de códigos de erro (`CcspErrorCode`) compartilhados entre o agente Java desktop, o protocolo CCSP/1 e o SDK Web em JavaScript. + +--- + +## 1. Tabela de Erros Estruturados + +| Código de Erro | Descrição Detalhada | Ação Recomendada para a Aplicação Web | +|---|---|---| +| `CERTIFICATE_NOT_FOUND` | O certificado indicado pelo `certificate_hint` não foi localizado. | Solicite ao usuário selecionar novamente o certificado via `getCertificates()`. | +| `CERTIFICATE_EXPIRED` | O certificado selecionado expirou. | Oriente o usuário a renovar o certificado digital junto à autoridade certificadora. | +| `CERTIFICATE_INVALID` | O certificado possui estrutura corrompida ou ainda não é válido. | Verificar a data e integridade do arquivo/dispositivo. | +| `PRIVATE_KEY_NOT_AVAILABLE` | O certificado está presente mas a chave privada não está acessível. | Em tokens A3, verifique se o middleware/driver está instalado corretamente. | +| `TOKEN_NOT_FOUND` | Nenhum dispositivo ou leitor compatível foi localizado. | Solicite ao usuário conectar o token USB ou inserir o smartcard no leitor. | +| `TOKEN_REMOVED` | O token USB ou cartão foi removido fisicamente durante a assinatura. | Peça ao usuário reconectar o token e reiniciar a operação de assinatura. | +| `PIN_REQUIRED` | O token exige PIN para liberar a chave privada e nenhum PIN foi fornecido. | Exibir campo de solicitação de PIN na tela. | +| `PIN_INCORRECT` | O PIN digitado está incorreto. | Informar o usuário e alertar sobre o risco de bloqueio de PUK após 3 tentativas. | +| `PKCS11_ERROR` | Falha no driver ou biblioteca PKCS#11 do fabricante. | Verificar a instalação do SafeSign, SafeNet ou OpenSC. | +| `SIGNATURE_FAILED` | Erro durante a geração dos bytes da assinatura digital. | Tentar novamente ou reiniciar o agente desktop. | +| `APPLICATION_NOT_AUTHORIZED` | A aplicação web teve autorização recusada pelo usuário. | Informar o usuário que é necessário aceitar a notificação na tela do agente. | +| `REQUEST_EXPIRED` | O timestamp ou `expires_at` da requisição expirou. | Gerar nova requisição de assinatura com timestamp atualizado. | +| `REPLAY_DETECTED` | O nonce informado já foi processado anteriormente. | Sempre gerar um UUID único para cada chamada de assinatura. | +| `INVALID_ORIGIN` | O cabeçalho `Origin` não confere com o cadastro da aplicação. | Conectar-se com a URL e protocolo autorizados. | +| `UNSUPPORTED_ALGORITHM` | Algoritmo solicitado não é suportado pelo agente ou certificado. | Utilizar algoritmo padrão homologado (`SHA256withRSA`). | +| `USER_CANCELLED` | O usuário clicou em Cancelar ou fechou a janela do CyberCore. | Tratar como cancelamento voluntário sem mensagem de erro alarmante. | +| `PROVIDER_NOT_AVAILABLE` | O provedor criptográfico necessário não está ativo no sistema operacional. | Verificar a integridade do Windows Certificate Store ou Java Runtime. | +| `COMMUNICATION_ERROR` | Erro interno de socket ou timeout de rede local. | Verificar se a porta `21325` não está bloqueada por antivírus local. | diff --git a/docs/image.png b/docs/image.png new file mode 100644 index 0000000..b87ee6a Binary files /dev/null and b/docs/image.png differ diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..a7559a6 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,43 @@ +# Instalação e Empacotamento Nativo + +O **CyberCore Signer** foi projetado para distribuição sem exigir que o usuário final tenha o Java ou JRE instalado em seu computador. + +--- + +## 1. Requisitos de Sistema + +- **Sistema Operacional Homologado:** Windows 10 (64-bit) ou Windows 11 (64-bit). +- **Compatibilidade Adicional:** Linux x86_64 e macOS (arquitetura preparada). +- **Hardware:** Mínimo de 4 GB RAM, 150 MB de espaço em disco. +- **Porta de Rede Local:** A porta TCP `21325` deve estar livre em `127.0.0.1`. + +--- + +## 2. Geração do Instalador Windows (MSI / EXE) com `jpackage` + +O empacotamento utiliza a ferramenta nativa `jpackage` incluída no JDK 21/25. + +### No Windows: +Execute o script em lote localizado na raiz: +```cmd +scripts\package-windows.bat +``` + +### O que o script realiza: +1. Compila o projeto em modo release: + ```cmd + mvnw clean package -DskipTests + ``` +2. Invoca o `jpackage` criando o instalador Windows: + - Extrai um runtime mínimo customizado contendo apenas os módulos necessários (`java.base`, `java.desktop`, `java.smartcardio`, `jdk.crypto.cryptoki`, etc.). + - Cria atalhos na Área de Trabalho e no Menu Iniciar. + - Gera um instalador padrão MSI ou EXE em `dist/`. + +--- + +## 3. Instalação e Inicialização com o Sistema + +1. Execute o instalador `CyberCore-Signer-0.1.0.msi`. +2. Siga as instruções do assistente de instalação. +3. Ao concluir, o ícone do **CyberCore Signer** aparecerá na Área de Notificação (System Tray) ao lado do relógio do Windows. +4. O serviço local `http://127.0.0.1:21325` estará imediatamente disponível para atender as aplicações web autorizadas. diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..a48553f --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,25 @@ +# Limitações Técnicas e Dependências Declaradas + +Conforme estabelecido na **Regra 23** do projeto (*"Não invente suporte a certificados, tokens, providers, algoritmos ou hardware que não tenham sido realmente implementados/testados. Quando algo depender de fabricante, middleware, Windows ou hardware físico, declare a dependência"*), as seguintes limitações são formalmente declaradas: + +--- + +## 1. Dependências de Sistema Operacional e Hardware + +1. **Windows Certificate Store (Windows-MY):** + - Disponível apenas em sistemas operacionais Microsoft Windows (Windows 10/11). + - Em Linux e macOS, o provedor `WindowsCertificateStoreProvider` declara-se automaticamente indisponível (`isAvailable() == false`), operando via arquivos A1 ou PKCS#11 direto. +2. **Tokens A3 e Middlewares Proprietários:** + - O reconhecimento de smartcards e tokens USB depende da instalação prévia dos drivers do fabricante do dispositivo físico (ex: SafeSign Identity Client, SafeNet Authentication Client, drivers de leitoras Gemalto/Omnikey). + - O CyberCore Signer não instala drivers de hardware nem substitui o middleware do fabricante. +3. **Bloqueio de Cartão por Tentativas de PIN:** + - A política de bloqueio por tentativas consecutivas de PIN incorreto (geralmente 3 tentativas) é governada estritamente pelo chip físico do dispositivo A3 e pelo driver do fabricante, estando fora do controle do software do agente. + +--- + +## 2. Limitações de Rede e Concorrência + +1. **Escuta Exclusiva em Loopback:** + - O agente não oferece nem oferece suporte para escuta em endereços de rede externa (IPs locais de LAN ou WAN). Essa restrição é um elemento fundamental do modelo de segurança. +2. **Operações Concorrentes no Mesmo Token Físico:** + - Drivers PKCS#11 e smartcards ICP-Brasil frequentemente operam em modo mono-thread ou canal serial exclusivo. Por essa razão, o CyberCore Signer serializa as assinaturas em fila FIFO (`signLock`) para impedir corrupção de sessão no chip do cartão. diff --git a/docs/pades.md b/docs/pades.md new file mode 100644 index 0000000..b299fba --- /dev/null +++ b/docs/pades.md @@ -0,0 +1,92 @@ +![alt text](image.png)# Assinaturas PDF (PAdES) e Selos Visuais em SVG + +Este documento descreve a arquitetura e o funcionamento da assinatura digital de arquivos PDF no padrão **PAdES (PDF Advanced Electronic Signatures)** no CyberCore Signer, incluindo o subsistema de **selos visuais temáticos baseados em SVG**. + +--- + +## 1. Visão Geral da Arquitetura + +O CyberCore Signer implementa assinatura PAdES em dois modos complementares: + +1. **Modo Desacoplado via Protocolo CCSP/1 (Web):** A aplicação web prepara a estrutura `/ByteRange`, envia apenas o digest SHA-256 e o CyberCore assina localmente com o certificado A1/A3 sem expor a chave privada. +2. **Modo Desktop Completo (Desktop UI):** O usuário seleciona um arquivo PDF localmente, escolhe o certificado ICP-Brasil, customiza o selo visual e o `PdfSigningService` gera o PDF assinado completo com envelope CMS/PKCS#7 e estampa gráfica. + +```mermaid +graph TD + A[Arquivo PDF Original] --> B[PdfSigningService] + C[Tema de Selo SVG] --> D[PdfSealRenderer Java2D] + E[Metadados Signatário] --> D + D -->|Estampa Visual 300 DPI| B + F[Chave Privada A1/A3] --> G[Bouncy Castle CMS Generator] + B -->|PAdES Incremental| H[PDF Assinado Digitalmente] + G --> H +``` + +--- + +## 2. Subsistema de Selos Visuais em SVG e Temas + +O selo visual é uma representação gráfica da assinatura estipulada em conformidade com as recomendações do ITI e MP nº 2.200-2/2001. + +### 2.1 Temas Pré-configurados e Ícones Vetoriais (`PdfSealThemeRegistry` & `PdfSealRenderer`) + +Os brasões e ícones padrão do sistema ficam centralizados em: +- **Catálogo de Temas:** `modules/cybercore-core/.../pdf/seal/PdfSealThemeRegistry.java` (definição de cores, dimensões, textos padrão e tipo de ícone). +- **Desenho Vetorial Java2D:** `modules/cybercore-core/.../pdf/seal/PdfSealRenderer.drawThemeIcon()` (renderização matemática em Java2D dos brasões padrão: `shield`, `star`, `crystal`, `notary`, etc.). + +| ID do Tema | Nome | Descrição | Ícone Vetorial | +| :--- | :--- | :--- | :--- | +| `icp-brasil` | **ICP-Brasil Oficial** | Padrão clássico com brasão e cadeado verde/azul | Escudo e cadeado | +| `cybercore-modern` | **CyberCore Modern** | Estilo tecnológico neon com cristal multifacetado | Cristal vetorial | +| `minimal-clean` | **Minimalista Clean** | Monocromático para relatórios executivos sóbrios | Carimbo notarial com check | +| `gov-br` | **Gov.br Oficial** | Identidade visual Verde-Oliva conforme MP 2.200-2/2001 | Brasão estelar estilizado | + +### 2.2 Renderização Gráfica (`PdfSealRenderer`) + +- O núcleo `cybercore-core` **não depende de JavaFX**, atendendo às regras estritas de arquitetura (`ArchitectureTest`). +- A renderização é efetuada através da API **Java2D (AWT)** nativa com anti-aliasing duplo (`RenderingHints.VALUE_ANTIALIAS_ON`, subpixel text, escala 2x / 300 DPI). +- Exporta tanto `BufferedImage` quanto `byte[]` PNG para inclusão no PDFBox ou pré-visualização dinâmica na interface com `ImageView`. + +### 2.3 Criação, Instalação e Privacidade de Selos Personalizados + +- **Foco Exclusivo no Titular (Sem Emissor/AC no Gráfico):** O selo foi desenhado para ser limpo e elegante. Dados técnicos do certificado e o emissor da Autoridade Certificadora não são exibidos no gráfico do selo, evitando poluição visual. +- **Obrigatoriedade do Nome Completo:** O renderizador calcula dinamicamente a métrica de fontes via `drawFittedText()` para garantir que o nome completo do signatário seja sempre exibido na íntegra, com destaque tipográfico (13.5pt a 14.5pt em negrito), sem cortes ou truncamentos. +- **Upload de Logotipo / Emblema Próprio:** Suporte a upload de imagens de logotipos (PNG, JPG, BMP) armazenadas de forma portável em Base64 dentro do tema, com renderização redimensionada e anti-aliasing de 300 DPI. +- **Campos Customizáveis (Matrícula, Cargo, OAB, etc.):** Permite definir rótulo e valor padrão no tema (ex: `Matrícula: 104829/SP` ou `Cargo: Auditor Fiscal`) ou informar pontualmente durante a assinatura via `customExtraInfo`. A informação é posicionada de forma harmônica na linha de identificação do signatário. +- **Privacidade do CPF (LGPD):** O modelo `CpfDisplayMode` suporta três modalidades: + - `FULL`: exibe o documento completo (`CPF: 123.456.789-00`). + - `MASKED`: mascara dígitos sensíveis (`CPF: ***.456.789-**`). + - `NONE`: oculta totalmente o CPF do gráfico do selo. +- **Data e Hora Opcional:** Parâmetro booleano para incluir ou suprimir data/hora no gráfico. +- **Instalação e Persistência Local:** Através de `CustomSealStorage`, novos selos criados ou importados são salvos em `~/.cybercore/seals/*.json` e registrados dinamicamente no `PdfSealThemeRegistry`. + +### 2.4 Gerenciamento Centralizado de Selos (`SealManagementDialog`) + +- **Lista Completa:** Exibição unificada de selos embutidos do sistema e selos personalizados instalados pelo usuário. +- **Selo Padrão Ativo:** Permite definir qual selo é o padrão ativo para todas as assinaturas do usuário, gravado em `AgentConfiguration` (`defaultSealThemeId`). +- **Pré-visualização em Tempo Real:** Renderização fiel do selo com dados de exemplo ao navegar pela lista. +- **Clonagem e Edição:** Permite duplicar qualquer selo (inclusive os embutidos) e abri-lo no `SealDesignerDialog` para customização. +- **Importação e Exportação:** Exportação de temas em arquivos `.json` e importação com um clique para compartilhamento entre máquinas e equipes. + +--- + +## 3. Posicionamento e Pré-Visualização Interativa + +A posição e a página da estampa são parametrizáveis via `PdfSignatureOptions`: + +- **Posições Automáticas:** `BOTTOM_RIGHT`, `BOTTOM_LEFT`, `TOP_RIGHT`, `TOP_LEFT`. +- **Posição Interativa Personalizada (`CUSTOM`):** O usuário clica ou arrasta o selo diretamente sobre a página do PDF na interface gráfica. As coordenadas na tela são matematicamente mapeadas para as coordenadas de mídia do PDFBox: + $$X_{pdf} = \frac{X_{clique}}{Largura_{view}} \times Largura_{pdf}$$ + $$Y_{pdf} = Altura_{pdf} - \left(\frac{Y_{clique}}{Altura_{view}} \times Altura_{pdf}\right) - Altura_{selo}$$ +- **Páginas:** `LAST_PAGE` (recomendado), `FIRST_PAGE`, `ALL_PAGES`, ou página específica numérica (`targetPageNumber`). +- **Dimensões padrão:** 200 pt de largura por 55 pt de altura, com margens de segurança e clamping contra extravasamento. +- **Renderização da Folha:** O `PdfSigningService` fornece `renderPagePngBytes(pdfBytes, pageIndex, dpi)` via PDFBox `PDFRenderer`, permitindo que o diálogo da UI JavaFX renderize a folha de papel em background thread com paginação e sobreposição visual do selo. + +--- + +## 4. Estrutura Criptográfica PAdES + +- **Motor:** Apache PDFBox 3.0.4 + Bouncy Castle 1.80 (`bcpkix-jdk18on`). +- **Filtro:** `/Filter /Adobe.PPKLite` e `/SubFilter /adbe.pkcs7.detached`. +- **Algoritmo:** `SHA256withRSA` / PKCS#1 v1.5 com digest SHA-256. +- **Salva Incremental:** Preserva a integridade de assinaturas anteriores e do documento original sem quebrar certificações prévias. diff --git a/docs/screenshots/01_dashboard.png b/docs/screenshots/01_dashboard.png new file mode 100644 index 0000000..a22786b Binary files /dev/null and b/docs/screenshots/01_dashboard.png differ diff --git a/docs/screenshots/01_layout_claro.png b/docs/screenshots/01_layout_claro.png new file mode 100644 index 0000000..3e2c1e9 Binary files /dev/null and b/docs/screenshots/01_layout_claro.png differ diff --git a/docs/screenshots/01_layout_serpro.png b/docs/screenshots/01_layout_serpro.png new file mode 100644 index 0000000..531f2be Binary files /dev/null and b/docs/screenshots/01_layout_serpro.png differ diff --git a/docs/screenshots/02_diagnostico.png b/docs/screenshots/02_diagnostico.png new file mode 100644 index 0000000..8233ae7 Binary files /dev/null and b/docs/screenshots/02_diagnostico.png differ diff --git a/docs/screenshots/02_layout_escuro.png b/docs/screenshots/02_layout_escuro.png new file mode 100644 index 0000000..583fa6e Binary files /dev/null and b/docs/screenshots/02_layout_escuro.png differ diff --git a/docs/screenshots/03_configuracoes.png b/docs/screenshots/03_configuracoes.png new file mode 100644 index 0000000..ecd7053 Binary files /dev/null and b/docs/screenshots/03_configuracoes.png differ diff --git a/docs/sdk.md b/docs/sdk.md new file mode 100644 index 0000000..ee2a17c --- /dev/null +++ b/docs/sdk.md @@ -0,0 +1,117 @@ +# SDK Web JavaScript (@cybercore/signer-sdk) + +O `@cybercore/signer-sdk` é a biblioteca oficial cliente em JavaScript para integrar aplicações web modernas com o agente desktop **CyberCore Signer** via protocolo **CCSP/1**. + +--- + +## 1. Instalação e Importação + +### Via Módulo ES6 (Browser ou Bundler Vite/Webpack/Next.js) +```javascript +import { CyberCoreSigner } from '@cybercore/signer-sdk'; + +const signer = new CyberCoreSigner({ + applicationName: 'Portal Meu Negócio' +}); +``` + +--- + +## 2. Métodos da API + +### `isInstalled(timeoutMs = 2000): Promise` +Testa rapidamente se o agente desktop está ativo na porta local `21325`. + +### `connect(): Promise` +Realiza o handshake inicial de autorização. Se for a primeira vez que a aplicação tenta se conectar, abre uma caixa de diálogo nativa no desktop do usuário para concessão de acesso. + +### `getCertificates(): Promise>` +Retorna todos os certificados A1 e A3 disponíveis e operacionais na máquina do usuário. + +```javascript +const certs = await signer.getCertificates(); +certs.forEach(cert => { + console.log(`[${cert.type}] ${cert.common_name} (Validade: ${cert.valid_to})`); +}); +``` + +### `sign(options): Promise` +Solicita a assinatura digital local de um digest ou conteúdo bruto. + +### `signPdf(pdfFile, options): Promise` +Assina um documento PDF (PAdES / Assinatura desacoplada). Recebe `File`, `Blob`, `ArrayBuffer` ou `Uint8Array`. +Calcula o SHA-256 no próprio navegador e transmite metadados do documento (`PDF`) ao agente para exibição clara na janela de confirmação. + +```javascript +const pdfResult = await signer.signPdf(fileInput.files[0], { + certificateId: certs[0].id +}); +``` + +### `signXml(xmlContent, options): Promise` +Assina um documento XML (XMLDSig / NF-e). Aceita string com o XML formatado ou objeto de arquivo `File`/`Blob`. + +```javascript +const xmlResult = await signer.signXml(xmlString, { + certificateId: certs[0].id, + documentName: 'nfe-3524.xml' +}); +``` + +### `signFile(file, options): Promise` +Assina qualquer arquivo (ZIP, DOCX, imagens, binários, executáveis) gerando pacote de assinatura detached / CAdES. + +```javascript +const fileResult = await signer.signFile(fileInput.files[0], { + certificateId: certs[0].id +}); +``` + +### `authenticate(options): Promise` (ou `login(options)`) +Executa login seguro com certificado digital via desafio criptográfico (Challenge-Response). + +```javascript +const auth = await signer.authenticate({ + certificateId: certs[0].id, + challenge: 'server-nonce-12345' +}); + +console.log('Bem-vindo,', auth.subjectName); +console.log('CPF/CNPJ:', auth.subjectCpfCnpj); +console.log('Assinatura do Desafio:', auth.signature); +``` + +### `extractSubjectInfo(certificateBase64): { name, cpfCnpj }` +Decodifica o certificado X.509 e extrai os dados do titular ICP-Brasil (Nome, CPF ou CNPJ). + +### `verify(options): Promise` +Validação sintática e criptográfica da integridade da assinatura gerada. + +--- + +## 3. Tratamento Estruturado de Erros + +O SDK expõe classes de erro dedicadas que herdam de `CyberCoreError`: + +```javascript +import { + UserCancelledError, + CertificateNotFoundError, + PinIncorrectError, + TokenNotFoundError +} from '@cybercore/signer-sdk/errors'; + +try { + await signer.sign({ certificateId: 'cert-1', data: 'Contrato' }); +} catch (err) { + if (err instanceof UserCancelledError) { + alert('Você cancelou a assinatura no CyberCore Signer.'); + } else if (err instanceof PinIncorrectError) { + alert('O PIN do token informado está incorreto.'); + } else if (err instanceof TokenNotFoundError) { + alert('Conecte o seu token USB na máquina para assinar.'); + } else { + console.error('Erro na assinatura:', err); + } +} +``` diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..adb637a --- /dev/null +++ b/docs/security.md @@ -0,0 +1,46 @@ +# Modelo de Segurança e Privacidade + +O **CyberCore Signer** foi projetado seguindo as diretrizes rigorosas da ICP-Brasil e os princípios de *Security by Design* e *Least Privilege*. + +--- + +## 1. Isolamento de Rede + +1. **Loopback Estrito:** O socket do servidor HTTP local vincula-se exclusivamente ao endereço IP `127.0.0.1`. Nenhuma interface de rede externa (LAN ou WAN) escuta a porta de serviço `21325`. +2. **Prevenção contra Exposição Acidental:** Testes unitários e de arquitetura garantem que conexões fora de `127.0.0.1` sejam bloqueadas imediatamente no nível de bind do socket. + +--- + +## 2. Autenticação e Autorização de Aplicações + +Nenhuma aplicação desconhecida tem autorização para obter certificados ou assinar documentos sem consentimento expresso: + +1. **Handshake de Autorização (`POST /ccsp/v1/authorize`):** + - A aplicação web identifica-se enviando seu `application_id` (UUID estável) e seu nome amigável. + - O CyberCore Signer suspende a requisição e abre uma janela modal nativa na tela principal do sistema operacional perguntando se o usuário autoriza aquele domínio web. +2. **Armazenamento Seguro das Autorizações:** + - As aplicações autorizadas são gravadas em `~/.cybercore/authorized-apps.json`. + - O usuário pode visualizar e **revogar a qualquer momento** qualquer permissão através da aba **Configurações** na interface gráfica. + +--- + +## 3. Proteção Contra Ataques de Replay + +Cada solicitação de assinatura enviada pelo SDK deve incluir: +- Um `nonce` (UUID criptograficamente aleatório); +- Um campo `expires_at` (ou `timestamp`). + +O componente `NonceManager`: +- Armazena os nonces recentes em memória protegida com expiração temporal (TTL); +- Rejeita com código estruturado `REPLAY_DETECTED` caso o mesmo nonce seja submetido mais de uma vez; +- Rejeita com código `REQUEST_EXPIRED` requisições cuja data/hora de validade já tenha sido ultrapassada. + +--- + +## 4. Confirmação Visual Obrigatória e Proteção de PIN/Chaves + +1. **Confirmação Manual do Usuário:** Toda e qualquer assinatura exige intervenção física e visual do usuário. Não é possível assinar "silenciosamente" por chamadas de API em segundo plano. +2. **Inviolabilidade da Chave Privada:** A chave privada nunca é exportada nem manipulada em forma de bytes puros. Todas as operações utilizam handles JCA/JCE ou comandos de hardware nativo. +3. **Proteção de PIN e Senhas:** + - O PIN de smartcards e a senha de arquivos A1 nunca são transmitidos para a aplicação web e nunca aparecem em logs de auditoria. + - Em caso de remoção do hardware, o `SmartCardWatcher` limpa e zera os buffers de memória de PIN imediatamente. diff --git a/docs/serpro-layout-and-batch.md b/docs/serpro-layout-and-batch.md new file mode 100644 index 0000000..cd5cb43 --- /dev/null +++ b/docs/serpro-layout-and-batch.md @@ -0,0 +1,82 @@ +# Layout CyberCore Signer — Design Dual-Theme & Assinatura em Lote + +O **CyberCore Signer** adota uma interface gráfica que une a ergonomia de hub consolidada no mercado nacional com uma identidade visual moderna, autoral e com suporte dinâmico a **Tema Claro (Light)** e **Tema Escuro (Dark)**. + +--- + +## 1. Identidade Visual e Filosofia de Design + +Em conformidade com os requisitos do projeto, a interface **não é uma cópia datada**, mas sim uma evolução: +1. **Identidade CyberCore:** Logotipo central em cristal tridimensional e escudo criptográfico vetorial (`SvgIcons`), subtítulo "Agente Criptográfico Local • ICP-Brasil" e indicador de segurança em tempo real. +2. **Badge de Status Ativo:** Exibe o status da porta local (`● Agente Ativo • 127.0.0.1:21325`) com verde esmeralda no modo claro e verde neon no modo escuro. +3. **Alternador Dinâmico de Temas:** Botão pílula localizado no cabeçalho superior que permite ao usuário alternar instantaneamente entre **Modo Claro** e **Modo Escuro**: + - Ícone do Sol ☀️ para comutar para o tema Claro. + - Ícone da Lua 🌙 para comutar para o tema Escuro. + - Classe gerenciadora centralizada: [`ThemeManager.java`](file:///var/www/cybercore/cybercore-signer/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/theme/ThemeManager.java), que replica a escolha de tema para todos os diálogos secundários. + +--- + +## 2. Paleta de Cores dos Temas + +| Componente | Tema Claro (Light) | Tema Escuro (Dark) | +|---|---|---| +| **Fundo da Janela** | `#f1f5f9` (Slate suave) | `#0b0f17` (Dark profundo) | +| **Header Superior** | `#ffffff`, borda `#e2e8f0` | `#121824`, borda `#1e293b` | +| **Cartões de Ação** | `#ffffff`, borda `#e2e8f0`, hover `#0284c7` | `#131c2e`, borda `#1e293b`, hover `#38bdf8` | +| **Texto dos Botões** | `#1e293b` (Cinza escuro de alto contraste) | `#f8fafc` (Branco gelo nítido) | +| **Painel Lateral** | `#ffffff`, borda `#e2e8f0` | `#121824`, borda `#1e293b` | +| **Badge de Status** | Fundo `#ecfdf5`, texto `#047857`, borda `#10b981` | Fundo `rgba(16, 185, 129, 0.15)`, texto `#34d399` | +| **Alternador de Tema** | Fundo `#ffffff`, borda `#cbd5e1`, hover `#0284c7` | Fundo `#1e293b`, borda `#334155`, hover `#38bdf8` | + +--- + +## 3. Grade Central de Ações (Hub 4x3) + +A interface principal organiza as operações em 12 cartões modernos com cantos arredondados (8px), sombras suaves e ícones vetoriais SVG nítidos: + +| Posição | Operação | Descrição | +|---|---|---| +| (0, 0) | **Assinar PDF** | Assinatura digital de documentos PDF com cálculo de hash SHA-256 e gravação de assinatura. | +| (1, 0) | **Assinar XML** | Assinatura de documentos XML (estruturas fiscais, NF-e, XMLDSig). | +| (2, 0) | **Assinar Arquivo** | Assinatura genérica de qualquer arquivo ou binário do sistema. | +| (3, 0) | **Assinar em Lote** | Processamento simultâneo de múltiplos arquivos com barra de progresso em tempo real. | +| (0, 1) | **Assinatura Atachada** | Assinatura embutida/anexada ao documento original (CAdES enveloped). | +| (1, 1) | **Co-Assinar Documento** | Adição de nova assinatura a um documento previamente assinado por outro titular. | +| (2, 1) | **Carimbo do Tempo** | Integração com protocolo RFC 3161 TSP para prova de anterioridade temporal. | +| (3, 1) | **Assinatura LTV** | Perfil de Longa Duração com inclusão de cadeia de certificados e carimbos para validade permanente. | +| (0, 2) | **Validar Assinatura** | Verificação de integridade criptográfica e conformidade do certificado digital. | +| (1, 2) | **Validar Carimbo** | Verificação da autenticidade e validade temporal de tokens TSR/TSP. | +| (2, 2) | *(Reservado)* | Espaço reservado para expansões futuras do agente. | +| (3, 2) | **Sair do Agente** | Encerramento seguro da sessão do agente. | + +--- + +## 4. Painel Lateral de Utilitários + +Na coluna lateral direita, o usuário dispõe de atalhos rápidos para operações do sistema: + +1. **` Logs`:** Visualizador de auditoria em tempo real, monitorando requisições locais CCSP/1 e eventos criptográficos. +2. **`⚙ Configurações`:** Gerenciador de origens web autorizadas, revogação de acessos e opções de PIN. +3. **`💻 Tutorial`:** Guia passo a passo interativo de uso do assinador e integração web. +4. **`🗂 Certificados`:** Lista de certificados ICP-Brasil instalados (Windows-MY, A1 e A3) e ferramenta para importação de certificados A1 (`.pfx` / `.p12`). +5. **`ℹ Sobre`:** Informações de versão, engine JCA/JCE e garantias de privacidade local. + +--- + +## 5. Assinatura Digital em Lote (`BatchSigningDialog`) + +A funcionalidade de assinatura em lote permite: +- Seleção de múltiplos arquivos simultâneos via diálogo de arquivos ou pastas. +- Visualização de tabela com nome, tamanho e status de cada item em tempo real. +- Seleção do certificado digital ICP-Brasil desejado (com exibição de titular, CPF/CNPJ, tipo A1/A3 e validade). +- Escolha da pasta de destino para os arquivos assinados (salvando arquivos `.p7s` correspondentes). +- Execução assíncrona em thread isolada para manter a interface responsiva durante todo o lote. +- Compatibilidade automática com o tema ativo (Claro ou Escuro). + +--- + +## 6. Ícones Vetoriais SVG Nativos (`SvgIcons`) + +Todos os ícones e o logotipo central em cristal foram construídos via `SVGPath` e primitivas vetoriais JavaFX nativas: +- **Resolução Infinita:** Perfeita nitidez em monitores Full HD, 2K, 4K e configurações de escala de DPI do Windows (100%, 125%, 150%, 200%). +- **Sem Dependência de Imagens Rasterizadas:** Sem risco de imagens ausentes ou corrompidas no runtime empacotado. diff --git a/docs/xml-xmldsig.md b/docs/xml-xmldsig.md new file mode 100644 index 0000000..2b3c005 --- /dev/null +++ b/docs/xml-xmldsig.md @@ -0,0 +1,56 @@ +# Assinaturas XML (XMLDSig e ICP-Brasil) + +Este documento descreve a especificação e o funcionamento da assinatura digital de arquivos XML no padrão **W3C XMLDSig (XML Digital Signatures)** e **ICP-Brasil** no CyberCore Signer. + +--- + +## 1. Visão Geral + +O CyberCore Signer fornece suporte nativo a assinaturas digitais XML utilizando a engine padronizada e segura da plataforma Java (`javax.xml.crypto.dsig`). + +### 1.1 Casos de Uso Suportados + +- **Documento Inteiro Envelopado (`URI=""`):** Contratos, despachos, declarações e relatórios em formato XML. +- **Assinatura de Elementos Específicos por ID:** + - **NF-e (Nota Fiscal Eletrônica):** Assinatura da tag `` + - **CT-e (Conhecimento de Transporte Eletrônico):** Assinatura da tag `` + - **MDF-e (Manifesto Eletrônico de Documentos Fiscais):** Assinatura da tag `` + - **eSocial / EFD-Reinf:** Assinatura de eventos com tags `` + +--- + +## 2. Padrões Criptográficos + +- **Canonicalização (C14N):** `http://www.w3.org/TR/2001/REC-xml-c14n-20010315` (C14N inclusiva sem comentários). +- **Transformações:** + - `http://www.w3.org/2000/09/xmldsig#enveloped-signature` (remove a assinatura do cálculo do digest). + - C14N padrão. +- **Algoritmo de Digest:** SHA-256 (`http://www.w3.org/2001/04/xmlenc#sha256`). +- **Algoritmo de Assinatura:** RSA-SHA256 (`http://www.w3.org/2001/04/xmldsig-more#rsa-sha256`). +- **KeyInfo:** Informações do certificado X.509 embutidas em ``. + +--- + +## 3. Segurança e Mitigação contra XXE + +O parser XML é configurado com blindagem estrita contra ataques **XML External Entity (XXE)** e expansões recursivas de entidades: + +```java +DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); +dbf.setNamespaceAware(true); +dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); +dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); +dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); +dbf.setXIncludeAware(false); +dbf.setExpandEntityReferences(false); +``` + +--- + +## 4. Validação Pós-Assinatura + +O serviço `XmlSigningService.validateXmlSignature(byte[] xmlBytes)` permite auditar a assinatura imediatamente após sua emissão, verificando: + +1. A integridade criptográfica da assinatura (`SignedInfo`). +2. A integridade dos digests de todas as referências apontadas (`Reference.validate()`). +3. O certificado X.509 público extraído da chave. diff --git a/modules/cybercore-app/pom.xml b/modules/cybercore-app/pom.xml new file mode 100644 index 0000000..5cb16cd --- /dev/null +++ b/modules/cybercore-app/pom.xml @@ -0,0 +1,162 @@ + + + 4.0.0 + + + br.com.cybercore.signer + cybercore-signer-parent + 0.1.0-SNAPSHOT + ../../pom.xml + + + cybercore-app + jar + + CyberCore Signer :: Application + Módulo executável desktop, ciclo de vida da aplicação, shutdown hooks e empacotamento nativo. + + + + br.com.cybercore.signer + cybercore-core + + + br.com.cybercore.signer + cybercore-ui + + + + + org.openjfx + javafx-controls + + + org.openjfx + javafx-graphics + + + org.openjfx + javafx-base + + + + + org.openjfx + javafx-controls + ${javafx.version} + win + + + org.openjfx + javafx-graphics + ${javafx.version} + win + + + org.openjfx + javafx-base + ${javafx.version} + win + + + + + org.openjfx + javafx-controls + ${javafx.version} + linux + + + org.openjfx + javafx-graphics + ${javafx.version} + linux + + + org.openjfx + javafx-base + ${javafx.version} + linux + + + + org.slf4j + slf4j-api + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-core + + + + + com.tngtech.archunit + archunit-junit5 + test + + + org.mockito + mockito-core + test + + + + + + + org.openjfx + javafx-maven-plugin + + br.com.cybercore.signer.app.CyberCoreSignerApplication + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + cybercore-signer + false + + + br.com.cybercore.signer.app.CyberCoreSignerApplication + + CyberCore Signer + ${project.version} + CyberCore + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + META-INF/versions/**/module-info.class + + + + + + + + + + diff --git a/modules/cybercore-app/src/main/java/br/com/cybercore/signer/app/CyberCoreSignerApplication.java b/modules/cybercore-app/src/main/java/br/com/cybercore/signer/app/CyberCoreSignerApplication.java new file mode 100644 index 0000000..8817b83 --- /dev/null +++ b/modules/cybercore-app/src/main/java/br/com/cybercore/signer/app/CyberCoreSignerApplication.java @@ -0,0 +1,144 @@ +package br.com.cybercore.signer.app; + +import br.com.cybercore.signer.core.diagnostics.DiagnosticReport; +import br.com.cybercore.signer.core.diagnostics.DiagnosticService; +import br.com.cybercore.signer.core.crypto.SigningService; +import br.com.cybercore.signer.core.protocol.CcspServer; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.ui.CyberCoreDesktopApplication; +import javafx.application.Application; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.GraphicsEnvironment; + +/** + * Ponto de entrada (Main Class) e bootstrap do agente CyberCore Signer. + */ +public class CyberCoreSignerApplication { + + private static final Logger logger = LoggerFactory.getLogger(CyberCoreSignerApplication.class); + + private static boolean developmentMode = false; + private static boolean headlessMode = false; + private static boolean noWaitMode = false; + private static volatile CcspServer runningServer = null; + + public static CcspServer getRunningServer() { + return runningServer; + } + + public static void stopServer() { + if (runningServer != null) { + runningServer.stop(); + runningServer = null; + } + } + + public static void main(String[] args) { + parseArguments(args); + printBanner(); + + logger.info("Inicializando CyberCore Signer v0.1.0..."); + if (developmentMode) { + logger.warn("========================================================="); + logger.warn(" MODO DE DESENVOLVIMENTO ATIVADO (--dev)"); + logger.warn(" Logs detalhados ativos. Operação com certificados reais."); + logger.warn("========================================================="); + } + + DiagnosticService diagnosticService = new DiagnosticService(); + DiagnosticReport report = diagnosticService.runDiagnostic(); + logger.info("Ambiente verificado: OS={} {} ({}), Java={} ({})", + report.osName(), report.osVersion(), report.osArch(), + report.javaVersion(), report.javaVendor()); + + if (!report.cryptoEngineOk()) { + logger.error("ALERTA CRÍTICO: Engine criptográfica JCA/JCE não está totalmente operacional!"); + } + + // Se o ambiente tiver suporte gráfico e não for headless forçado, abre a interface JavaFX + boolean isHeadless = headlessMode || GraphicsEnvironment.isHeadless(); + + if (isHeadless) { + logger.info("Executando em modo console / headless (sem interface gráfica)."); + startHeadlessServer(); + } else { + try { + logger.info("Iniciando interface gráfica desktop..."); + CyberCoreDesktopApplication.setDevelopmentMode(developmentMode); + Application.launch(CyberCoreDesktopApplication.class, args); + } catch (Throwable t) { + logger.warn("Não foi possível inicializar ambiente gráfico ({}). Continuando em modo console.", t.getMessage()); + startHeadlessServer(); + } + } + + logger.info("CyberCore Signer finalizado."); + } + + private static void startHeadlessServer() { + try { + CertificateManager certManager = CertificateManager.createDefault(); + if (developmentMode) { + logger.info("Modo de desenvolvimento ativo (com certificados reais do sistema)."); + } + SigningService signingService = new SigningService(certManager); + runningServer = new CcspServer(certManager, signingService, null); + runningServer.start(); + logger.info("Servidor local CCSP/1 pronto em http://127.0.0.1:{}", runningServer.getPort()); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + logger.info("Encerrando servidor local CCSP/1..."); + stopServer(); + })); + + // Mantém processo vivo para atender requisições web, a menos que em modo no-wait + if (!noWaitMode) { + Thread.currentThread().join(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + logger.error("Falha ao inicializar servidor local CCSP/1 no modo console: {}", e.getMessage(), e); + } + } + + private static void parseArguments(String[] args) { + if (args != null) { + for (String arg : args) { + if ("--dev".equalsIgnoreCase(arg) || "-dev".equalsIgnoreCase(arg)) { + developmentMode = true; + } else if ("--headless".equalsIgnoreCase(arg) || "-headless".equalsIgnoreCase(arg) + || "--cli".equalsIgnoreCase(arg)) { + headlessMode = true; + } else if ("--no-wait".equalsIgnoreCase(arg) || "-no-wait".equalsIgnoreCase(arg) + || "--test".equalsIgnoreCase(arg)) { + noWaitMode = true; + } + } + } + } + + public static boolean isDevelopmentMode() { + return developmentMode; + } + + public static boolean isHeadlessMode() { + return headlessMode; + } + + private static void printBanner() { + System.out.println(""" + ============================================================ + ____ _ ____ + / ___| _ _ __| | ___ _ __ / ___|___ _ __ ___ + | | | | | |/ _` | / _ \\| '__| | | / _ \\ | '__|/ _ \\ + | |___ | |_| | (_| || __/| | | |__| (_) || | | __/ + \\____| \\__, |\\__,_| \\___||_| \\____\\___/ |_| \\___| + |___/ + SIGNER AGENT v0.1.0 + ============================================================ + """); + } +} diff --git a/modules/cybercore-app/src/main/resources/logback.xml b/modules/cybercore-app/src/main/resources/logback.xml new file mode 100644 index 0000000..3626ce9 --- /dev/null +++ b/modules/cybercore-app/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/modules/cybercore-app/src/test/java/br/com/cybercore/signer/app/ArchitectureTest.java b/modules/cybercore-app/src/test/java/br/com/cybercore/signer/app/ArchitectureTest.java new file mode 100644 index 0000000..4f6be7d --- /dev/null +++ b/modules/cybercore-app/src/test/java/br/com/cybercore/signer/app/ArchitectureTest.java @@ -0,0 +1,37 @@ +package br.com.cybercore.signer.app; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class ArchitectureTest { + + private final JavaClasses importedClasses = new ClassFileImporter() + .importPackages("br.com.cybercore.signer"); + + @Test + @DisplayName("O módulo Core não deve depender de classes dos pacotes UI ou App") + void coreShouldNotDependOnUiOrApp() { + ArchRule rule = noClasses() + .that().resideInAPackage("br.com.cybercore.signer.core..") + .should().dependOnClassesThat() + .resideInAnyPackage("br.com.cybercore.signer.ui..", "br.com.cybercore.signer.app.."); + + rule.check(importedClasses); + } + + @Test + @DisplayName("O módulo Core não deve conter dependências de bibliotecas JavaFX") + void coreShouldNotDependOnJavaFx() { + ArchRule rule = noClasses() + .that().resideInAPackage("br.com.cybercore.signer.core..") + .should().dependOnClassesThat() + .resideInAPackage("javafx.."); + + rule.check(importedClasses); + } +} diff --git a/modules/cybercore-app/src/test/java/br/com/cybercore/signer/app/CyberCoreSignerApplicationTest.java b/modules/cybercore-app/src/test/java/br/com/cybercore/signer/app/CyberCoreSignerApplicationTest.java new file mode 100644 index 0000000..0398c33 --- /dev/null +++ b/modules/cybercore-app/src/test/java/br/com/cybercore/signer/app/CyberCoreSignerApplicationTest.java @@ -0,0 +1,20 @@ +package br.com.cybercore.signer.app; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatCode; + +class CyberCoreSignerApplicationTest { + + @Test + @DisplayName("Deve inicializar a aplicação com argumentos sem erros") + void shouldBootstrapWithoutErrors() { + try { + assertThatCode(() -> CyberCoreSignerApplication.main(new String[]{"--dev", "--headless", "--no-wait"})) + .doesNotThrowAnyException(); + } finally { + CyberCoreSignerApplication.stopServer(); + } + } +} diff --git a/modules/cybercore-core/pom.xml b/modules/cybercore-core/pom.xml new file mode 100644 index 0000000..b4388fc --- /dev/null +++ b/modules/cybercore-core/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + br.com.cybercore.signer + cybercore-signer-parent + 0.1.0-SNAPSHOT + ../../pom.xml + + + cybercore-core + jar + + CyberCore Signer :: Core + Núcleo de domínio, abstrações criptográficas, provedores A1/A3, protocolo CCSP e servidor local. + + + + org.slf4j + slf4j-api + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + org.apache.pdfbox + pdfbox + + + org.bouncycastle + bcpkix-jdk18on + + + org.bouncycastle + bcprov-jdk18on + + + org.bouncycastle + bcutil-jdk18on + + + + + org.mockito + mockito-core + test + + + ch.qos.logback + logback-classic + test + + + diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/config/AgentConfiguration.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/config/AgentConfiguration.java new file mode 100644 index 0000000..c8494b5 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/config/AgentConfiguration.java @@ -0,0 +1,43 @@ +package br.com.cybercore.signer.core.config; + +import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Modelo de configurações gerais e de segurança do CyberCore Signer. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record AgentConfiguration( + boolean requireConfirmation, + boolean alwaysPromptPin, + boolean autoStart, + String defaultSealThemeId, + CpfDisplayMode defaultCpfDisplayMode, + boolean defaultIncludeDateTime, + int serverPort +) { + + public AgentConfiguration { + if (defaultSealThemeId == null || defaultSealThemeId.isBlank()) { + defaultSealThemeId = "icp-brasil"; + } + if (defaultCpfDisplayMode == null) { + defaultCpfDisplayMode = CpfDisplayMode.FULL; + } + if (serverPort <= 0) { + serverPort = 18420; + } + } + + public static AgentConfiguration defaultConfiguration() { + return new AgentConfiguration( + true, + false, + false, + "icp-brasil", + CpfDisplayMode.FULL, + true, + 18420 + ); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/config/AgentConfigurationService.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/config/AgentConfigurationService.java new file mode 100644 index 0000000..ef1b8f0 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/config/AgentConfigurationService.java @@ -0,0 +1,94 @@ +package br.com.cybercore.signer.core.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +/** + * Serviço de gerenciamento e persistência das configurações do CyberCore Signer. + * Salva e recupera as preferências do usuário no arquivo ~/.cybercore/config.json. + */ +public final class AgentConfigurationService { + + private static final Logger logger = LoggerFactory.getLogger(AgentConfigurationService.class); + private static final String CONFIG_FILE_NAME = "config.json"; + + private static final AgentConfigurationService INSTANCE = new AgentConfigurationService(); + + private final Path configPath; + private final ObjectMapper objectMapper; + private AgentConfiguration currentConfig; + + public static AgentConfigurationService getInstance() { + return INSTANCE; + } + + public AgentConfigurationService() { + this(resolveDefaultConfigPath()); + } + + public AgentConfigurationService(Path configPath) { + this.configPath = Objects.requireNonNull(configPath); + this.objectMapper = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); + this.currentConfig = load(); + } + + private static Path resolveDefaultConfigPath() { + String userHome = System.getProperty("user.home", "."); + return Path.of(userHome, ".cybercore", CONFIG_FILE_NAME); + } + + public synchronized AgentConfiguration getConfig() { + return currentConfig; + } + + public synchronized AgentConfiguration loadConfiguration() { + return getConfig(); + } + + public synchronized void updateConfig(AgentConfiguration newConfig) { + Objects.requireNonNull(newConfig, "Configuração não pode ser nula."); + this.currentConfig = newConfig; + save(); + } + + public synchronized void saveConfiguration(AgentConfiguration newConfig) { + updateConfig(newConfig); + } + + private AgentConfiguration load() { + try { + if (Files.exists(configPath)) { + AgentConfiguration cfg = objectMapper.readValue(configPath.toFile(), AgentConfiguration.class); + logger.info("Configurações do agente carregadas de: {}", configPath); + return cfg; + } + } catch (Exception e) { + logger.warn("Falha ao ler {}. Usando configurações padrão: {}", configPath, e.getMessage()); + } + return AgentConfiguration.defaultConfiguration(); + } + + private void save() { + try { + if (configPath.getParent() != null && !Files.exists(configPath.getParent())) { + Files.createDirectories(configPath.getParent()); + } + objectMapper.writeValue(configPath.toFile(), currentConfig); + logger.info("Configurações do agente salvas com sucesso em: {}", configPath); + } catch (IOException e) { + logger.error("Erro ao salvar configurações em {}: {}", configPath, e.getMessage(), e); + } + } + + public Path getConfigPath() { + return configPath; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/DigestAlgorithm.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/DigestAlgorithm.java new file mode 100644 index 0000000..522a1ed --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/DigestAlgorithm.java @@ -0,0 +1,66 @@ +package br.com.cybercore.signer.core.crypto; + +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import br.com.cybercore.signer.core.provider.CertificateProviderException; + +/** + * Algoritmos criptográficos de assinatura suportados pelo CyberCore Signer. + */ +public enum DigestAlgorithm { + SHA256_WITH_RSA("SHA256withRSA", "SHA-256", 32), + SHA384_WITH_RSA("SHA384withRSA", "SHA-384", 48), + SHA512_WITH_RSA("SHA512withRSA", "SHA-512", 64), + SHA256_WITH_ECDSA("SHA256withECDSA", "SHA-256", 32), + SHA384_WITH_ECDSA("SHA384withECDSA", "SHA-384", 48), + SHA512_WITH_ECDSA("SHA512withECDSA", "SHA-512", 64); + + private final String jcaName; + private final String digestName; + private final int expectedDigestLengthBytes; + + DigestAlgorithm(String jcaName, String digestName, int expectedDigestLengthBytes) { + this.jcaName = jcaName; + this.digestName = digestName; + this.expectedDigestLengthBytes = expectedDigestLengthBytes; + } + + public String getJcaName() { + return jcaName; + } + + public String getDigestName() { + return digestName; + } + + public int getExpectedDigestLengthBytes() { + return expectedDigestLengthBytes; + } + + public static DigestAlgorithm fromName(String name) throws CertificateProviderException { + if (name == null || name.isBlank()) { + throw new CertificateProviderException(CcspErrorCode.UNSUPPORTED_ALGORITHM, "Algoritmo não especificado."); + } + for (DigestAlgorithm alg : values()) { + if (alg.jcaName.equalsIgnoreCase(name.trim())) { + return alg; + } + } + throw new CertificateProviderException( + CcspErrorCode.UNSUPPORTED_ALGORITHM, + "Algoritmo não suportado pelo CyberCore Signer: " + name + ); + } + + public void validateDigestLength(byte[] digest) throws CertificateProviderException { + if (digest == null) { + throw new CertificateProviderException(CcspErrorCode.SIGNATURE_FAILED, "Digest não pode ser nulo."); + } + if (digest.length != expectedDigestLengthBytes) { + throw new CertificateProviderException( + CcspErrorCode.SIGNATURE_FAILED, + String.format("Tamanho inválido para digest %s: esperado %d bytes, recebido %d bytes.", + digestName, expectedDigestLengthBytes, digest.length) + ); + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/SigningService.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/SigningService.java new file mode 100644 index 0000000..73b6496 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/SigningService.java @@ -0,0 +1,169 @@ +package br.com.cybercore.signer.core.crypto; + +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.CertificateProvider; +import br.com.cybercore.signer.core.provider.CertificateProviderException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Serviço central de assinatura digital do CyberCore Signer. + * Orquestra validações rigorosas de protocolo e algoritmos, resolução de certificados + * e delegação aos provedores criptográficos JCA/JCE (Windows-MY, A1, PKCS#11). + * Nunca implementa algoritmos criptográficos manuais e nunca expõe a chave privada. + */ +public class SigningService { + + private static final Logger logger = LoggerFactory.getLogger(SigningService.class); + + private final CertificateManager certificateManager; + + public SigningService(CertificateManager certificateManager) { + this.certificateManager = Objects.requireNonNull(certificateManager, "certificateManager must not be null"); + } + + /** + * Executa a assinatura digital local de um digest fornecido por uma aplicação autorizada. + * + * @param context Contexto da requisição contendo digest, algoritmo, ID da requisição e certificado indicado. + * @return Resultado imutável da assinatura com os bytes assinados e certificados em Base64. + * @throws CertificateProviderException Se qualquer validação, dispositivo ou operação criptográfica falhar. + */ + public SigningResult sign(SigningContext context) throws CertificateProviderException { + Objects.requireNonNull(context, "Contexto de assinatura não pode ser nulo."); + + if (context.requestId() == null || context.requestId().isBlank()) { + throw new CertificateProviderException( + CcspErrorCode.SIGNATURE_FAILED, + "O campo 'request_id' é obrigatório para correlação de assinatura." + ); + } + + // 1. Validação de algoritmo suportado e tamanho do digest + DigestAlgorithm algorithm = DigestAlgorithm.fromName(context.algorithm()); + algorithm.validateDigestLength(context.digest()); + + // 2. Resolução do certificado alvo + ManagedCertificate targetCertificate = resolveCertificate(context.certificateHint()); + + // 3. Validação do ciclo de vida e disponibilidade de chave privada + validateCertificateState(targetCertificate); + + // 4. Obtenção do provedor responsável + CertificateProvider provider = certificateManager.getProviderForCertificate(targetCertificate.id()); + if (provider == null || !provider.isAvailable()) { + throw new CertificateProviderException( + CcspErrorCode.PROVIDER_NOT_AVAILABLE, + "O provedor para o certificado '" + targetCertificate.alias() + "' não está disponível no sistema." + ); + } + + logger.info("Assinando digest para requestId='{}', appId='{}', cert='{}', provider='{}', alg='{}'", + context.requestId(), context.applicationId(), targetCertificate.alias(), + provider.getProviderId(), algorithm.getJcaName()); + + // Se o contexto não tiver hint explícito, utiliza o ID do certificado resolvido + SigningContext effectiveContext = (context.certificateHint() == null || context.certificateHint().isBlank()) + ? new SigningContext(context.requestId(), context.applicationId(), context.digest(), + context.algorithm(), targetCertificate.id(), context.requestedAt()) + : context; + + // 5. Execução no provedor JCA/JCE + SigningResult result = provider.sign(effectiveContext); + + if (result == null || result.signatureBytes() == null || result.signatureBytes().length == 0) { + throw new CertificateProviderException( + CcspErrorCode.SIGNATURE_FAILED, + "O provedor retornou uma assinatura nula ou vazia." + ); + } + + logger.info("Assinatura digital concluída com sucesso para requestId='{}'. Tamanho assinatura: {} bytes", + context.requestId(), result.signatureBytes().length); + + return result; + } + + private ManagedCertificate resolveCertificate(String hint) throws CertificateProviderException { + if (hint != null && !hint.isBlank()) { + Optional found = certificateManager.findByHint(hint); + if (found.isEmpty()) { + // Tenta rediscovery caso um token tenha acabado de ser conectado + certificateManager.discoverCertificates(); + found = certificateManager.findByHint(hint); + } + if (found.isPresent()) { + return found.get(); + } + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Nenhum certificado encontrado para o identificador ou hint: " + hint + ); + } + + // Sem hint: seleciona automaticamente se houver apenas um certificado operacional disponível + List available = certificateManager.getAvailableCertificates(); + if (available.isEmpty()) { + // Tenta redescobrir + certificateManager.discoverCertificates(); + available = certificateManager.getAvailableCertificates(); + } + + if (available.isEmpty()) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Nenhum certificado com chave privada disponível foi encontrado na máquina." + ); + } + + if (available.size() == 1) { + return available.get(0); + } + + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Existem múltiplos certificados disponíveis (" + available.size() + + "). Especifique o 'certificate_hint' para selecionar o certificado desejado." + ); + } + + private void validateCertificateState(ManagedCertificate cert) throws CertificateProviderException { + if (cert.status() == CertificateStatus.EXPIRED) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_EXPIRED, + "O certificado '" + cert.alias() + "' está expirado desde " + cert.notAfter() + ); + } + if (cert.status() == CertificateStatus.NOT_YET_VALID) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_INVALID, + "O certificado '" + cert.alias() + "' ainda não é válido. Início da validade: " + cert.notBefore() + ); + } + if (cert.status() == CertificateStatus.NO_PRIVATE_KEY) { + throw new CertificateProviderException( + CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE, + "O certificado '" + cert.alias() + "' não possui chave privada acessível para assinatura." + ); + } + if (cert.status() == CertificateStatus.REVOKED) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_INVALID, + "O certificado '" + cert.alias() + "' consta como revogado." + ); + } + } + + public CertificateManager getCertificateManager() { + return certificateManager; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/X509CertificateUtils.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/X509CertificateUtils.java new file mode 100644 index 0000000..d0d90ee --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/crypto/X509CertificateUtils.java @@ -0,0 +1,230 @@ +package br.com.cybercore.signer.core.crypto; + +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Collection; +import java.util.HexFormat; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utilitários para parsing, inspeção e extração de metadados de certificados X.509 + * com suporte nativo a extensões da Infraestrutura de Chaves Públicas Brasileira (ICP-Brasil). + */ +public final class X509CertificateUtils { + + private static final Logger logger = LoggerFactory.getLogger(X509CertificateUtils.class); + + private static final Pattern CN_PATTERN = Pattern.compile("CN=([^,]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern CPF_PATTERN = Pattern.compile("(\\d{11})"); + private static final Pattern CNPJ_PATTERN = Pattern.compile("(\\d{14})"); + + private X509CertificateUtils() { + // Utilitário estático + } + + /** + * Decodifica um certificado X.509 a partir de bytes codificados (DER ou PEM). + */ + public static X509Certificate parseCertificate(byte[] encoded) throws CertificateException { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(encoded)); + } + + /** + * Converte um X509Certificate em um ManagedCertificate imutável de domínio. + */ + public static ManagedCertificate toManagedCertificate( + String id, + String alias, + X509Certificate cert, + CertificateType type, + boolean hasPrivateKey, + String providerId, + String hardwareDeviceName + ) { + String subject = cert.getSubjectX500Principal().getName(); + String issuer = cert.getIssuerX500Principal().getName(); + String serial = cert.getSerialNumber().toString(16).toUpperCase(); + + Instant notBefore = cert.getNotBefore().toInstant(); + Instant notAfter = cert.getNotAfter().toInstant(); + Instant now = Instant.now(); + + CertificateStatus status; + if (!hasPrivateKey) { + status = CertificateStatus.NO_PRIVATE_KEY; + } else if (now.isBefore(notBefore)) { + status = CertificateStatus.NOT_YET_VALID; + } else if (now.isAfter(notAfter)) { + status = CertificateStatus.EXPIRED; + } else { + status = CertificateStatus.AVAILABLE; + } + + String cn = extractCommonName(subject); + String sha1 = calculateFingerprint(cert, "SHA-1"); + String sha256 = calculateFingerprint(cert, "SHA-256"); + + String cpf = extractCpf(cert, cn); + String cnpj = extractCnpj(cert, cn); + + int keySize = extractKeySize(cert); + String alg = cert.getPublicKey().getAlgorithm(); + + return new ManagedCertificate( + id, + alias, + type, + status, + subject, + issuer, + serial, + sha1, + sha256, + notBefore, + notAfter, + alg, + keySize, + cn, + cpf, + cnpj, + providerId, + hardwareDeviceName + ); + } + + public static String extractCommonName(String dn) { + if (dn == null) return "Desconhecido"; + Matcher matcher = CN_PATTERN.matcher(dn); + if (matcher.find()) { + return matcher.group(1).trim(); + } + return dn; + } + + public static String calculateFingerprint(X509Certificate cert, String algorithm) { + try { + MessageDigest md = MessageDigest.getInstance(algorithm); + byte[] digest = md.digest(cert.getEncoded()); + return HexFormat.ofDelimiter(":").formatHex(digest).toUpperCase(); + } catch (NoSuchAlgorithmException | CertificateEncodingException e) { + logger.debug("Não foi possível calcular fingerprint {} do certificado: {}", algorithm, e.getMessage()); + return ""; + } + } + + private static int extractKeySize(X509Certificate cert) { + try { + String alg = cert.getPublicKey().getAlgorithm(); + if ("RSA".equalsIgnoreCase(alg)) { + java.security.interfaces.RSAPublicKey rsa = (java.security.interfaces.RSAPublicKey) cert.getPublicKey(); + return rsa.getModulus().bitLength(); + } else if ("EC".equalsIgnoreCase(alg) || "ECDSA".equalsIgnoreCase(alg)) { + java.security.interfaces.ECPublicKey ec = (java.security.interfaces.ECPublicKey) cert.getPublicKey(); + return ec.getParams().getCurve().getField().getFieldSize(); + } + } catch (Exception ignored) { + // Se falhar em extrair tamanho específico da chave, retorna 0 + } + return 0; + } + + /** + * Tenta extrair CPF do certificado (via ICP-Brasil SAN ou padrão comum no CN: "NOME:12345678901"). + */ + public static String extractCpf(X509Certificate cert, String commonName) { + // Tenta padrão CN ICP-Brasil comum ("FULANO DE TAL:00011122233") + if (commonName != null && commonName.contains(":")) { + String[] parts = commonName.split(":"); + if (parts.length > 1) { + String candidate = parts[parts.length - 1].trim(); + if (CPF_PATTERN.matcher(candidate).matches()) { + return candidate; + } + } + } + + // Tenta SubjectAlternativeNames + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans != null) { + for (List item : sans) { + if (item.size() >= 2 && item.get(1) instanceof byte[] bytes) { + String content = new String(bytes, StandardCharsets.ISO_8859_1); + Matcher m = CPF_PATTERN.matcher(content); + if (m.find()) { + return m.group(1); + } + } + } + } + } catch (Exception ignored) { + // SAN não disponível ou inacessível + } + return null; + } + + /** + * Tenta extrair CNPJ do certificado para pessoas jurídicas. + */ + public static String extractCnpj(X509Certificate cert, String commonName) { + if (commonName != null && commonName.contains(":")) { + String[] parts = commonName.split(":"); + if (parts.length > 1) { + String candidate = parts[parts.length - 1].trim(); + if (CNPJ_PATTERN.matcher(candidate).matches()) { + return candidate; + } + } + } + + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans != null) { + for (List item : sans) { + if (item.size() >= 2 && item.get(1) instanceof byte[] bytes) { + String content = new String(bytes, StandardCharsets.ISO_8859_1); + Matcher m = CNPJ_PATTERN.matcher(content); + if (m.find()) { + return m.group(1); + } + } + } + } + } catch (Exception ignored) { + // SAN não disponível ou inacessível + } + return null; + } + + /** + * Extrai o nome amigável do titular (removendo sufixo :CPF ou :CNPJ comum em ICP-Brasil). + */ + public static String extractHolderName(String commonName) { + if (commonName == null || commonName.isBlank()) { + return "Titular do Certificado"; + } + if (commonName.contains(":")) { + String[] parts = commonName.split(":"); + if (parts.length > 0 && !parts[0].isBlank()) { + return parts[0].trim(); + } + } + return commonName.trim(); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/diagnostics/DiagnosticReport.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/diagnostics/DiagnosticReport.java new file mode 100644 index 0000000..63e60ab --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/diagnostics/DiagnosticReport.java @@ -0,0 +1,51 @@ +package br.com.cybercore.signer.core.diagnostics; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Relatório consolidado de integridade e diagnósticos do CyberCore Signer. + */ +public record DiagnosticReport( + Instant generatedAt, + String osName, + String osVersion, + String osArch, + String javaVersion, + String javaVendor, + String javaRuntime, + boolean isWindows, + boolean cryptoEngineOk, + List registeredSecurityProviders, + boolean windowsStoreSupported, + boolean pkcs11ModuleAvailable, + int totalCertificatesFound, + int a1CertificatesCount, + int a3CertificatesCount, + boolean localServerRunning, + int localServerPort, + Map additionalDetails +) implements Serializable { + + public DiagnosticReport { + registeredSecurityProviders = registeredSecurityProviders != null + ? List.copyOf(registeredSecurityProviders) + : Collections.emptyList(); + additionalDetails = additionalDetails != null + ? Map.copyOf(additionalDetails) + : Collections.emptyMap(); + } + + @Override + public List registeredSecurityProviders() { + return Collections.unmodifiableList(registeredSecurityProviders); + } + + @Override + public Map additionalDetails() { + return Collections.unmodifiableMap(additionalDetails); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/diagnostics/DiagnosticService.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/diagnostics/DiagnosticService.java new file mode 100644 index 0000000..48a6934 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/diagnostics/DiagnosticService.java @@ -0,0 +1,87 @@ +package br.com.cybercore.signer.core.diagnostics; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.Provider; +import java.security.Security; +import java.security.Signature; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Serviço responsável por verificar as capacidades do sistema, JVM e engines criptográficas. + */ +public class DiagnosticService { + + private static final Logger logger = LoggerFactory.getLogger(DiagnosticService.class); + + public DiagnosticReport runDiagnostic() { + logger.info("Iniciando diagnóstico do ambiente e engine criptográfica..."); + + String osName = System.getProperty("os.name", "Desconhecido"); + String osVersion = System.getProperty("os.version", "Desconhecido"); + String osArch = System.getProperty("os.arch", "Desconhecido"); + boolean isWindows = osName.toLowerCase().contains("win"); + + String javaVersion = System.getProperty("java.version", "Desconhecido"); + String javaVendor = System.getProperty("java.vendor", "Desconhecido"); + String javaRuntime = System.getProperty("java.runtime.name", "Desconhecido"); + + List providersList = new ArrayList<>(); + boolean windowsStoreSupported = false; + boolean pkcs11ModuleAvailable = false; + + for (Provider p : Security.getProviders()) { + providersList.add(p.getName() + " v" + p.getVersionStr()); + if ("SunMSCAPI".equalsIgnoreCase(p.getName())) { + windowsStoreSupported = true; + } + if ("SunPKCS11".equalsIgnoreCase(p.getName())) { + pkcs11ModuleAvailable = true; + } + } + + boolean cryptoEngineOk = verifyCryptoAlgorithms(); + + Map details = new HashMap<>(); + details.put("VirtualThreadsSupported", "true"); + details.put("DefaultEncoding", System.getProperty("file.encoding", "UTF-8")); + + return new DiagnosticReport( + Instant.now(), + osName, + osVersion, + osArch, + javaVersion, + javaVendor, + javaRuntime, + isWindows, + cryptoEngineOk, + providersList, + windowsStoreSupported, + pkcs11ModuleAvailable, + 0, + 0, + 0, + false, + 39741, + details + ); + } + + private boolean verifyCryptoAlgorithms() { + try { + Signature.getInstance("SHA256withRSA"); + Signature.getInstance("SHA384withRSA"); + Signature.getInstance("SHA512withRSA"); + return true; + } catch (Exception e) { + logger.error("Falha ao inicializar algoritmos padrão JCA/JCE: {}", e.getMessage()); + return false; + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/CertificateStatus.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/CertificateStatus.java new file mode 100644 index 0000000..3d5f83f --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/CertificateStatus.java @@ -0,0 +1,30 @@ +package br.com.cybercore.signer.core.domain; + +/** + * Estado de validade e disponibilidade de um certificado. + */ +public enum CertificateStatus { + AVAILABLE("Disponível", true), + EXPIRED("Expirado", false), + NOT_YET_VALID("Ainda não válido", false), + NO_PRIVATE_KEY("Sem chave privada", false), + TOKEN_DISCONNECTED("Token desconectado", false), + REVOKED("Revogado", false), + UNKNOWN("Desconhecido", false); + + private final String description; + private final boolean usableForSigning; + + CertificateStatus(String description, boolean usableForSigning) { + this.description = description; + this.usableForSigning = usableForSigning; + } + + public String getDescription() { + return description; + } + + public boolean isUsableForSigning() { + return usableForSigning; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/CertificateType.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/CertificateType.java new file mode 100644 index 0000000..6e6f1b2 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/CertificateType.java @@ -0,0 +1,32 @@ +package br.com.cybercore.signer.core.domain; + +/** + * Tipo de certificado digital suportado pelo CyberCore Signer. + */ +public enum CertificateType { + /** + * Certificado em software (.pfx / .p12). + */ + A1("A1", "Certificado Local"), + + /** + * Certificado em hardware (Smart Card / Token USB / PKCS#11 / Windows Store). + */ + A3("A3", "Token / Smart Card"); + + private final String code; + private final String label; + + CertificateType(String code, String label) { + this.code = code; + this.label = label; + } + + public String getCode() { + return code; + } + + public String getLabel() { + return label; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/ManagedCertificate.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/ManagedCertificate.java new file mode 100644 index 0000000..303d158 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/ManagedCertificate.java @@ -0,0 +1,56 @@ +package br.com.cybercore.signer.core.domain; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * Representação imutável de um certificado digital gerenciado pelo CyberCore Signer. + * Contém apenas metadados públicos e nunca expõe referências diretas de chave privada. + */ +public record ManagedCertificate( + String id, + String alias, + CertificateType type, + CertificateStatus status, + String subjectDn, + String issuerDn, + String serialNumber, + String sha1Fingerprint, + String sha256Fingerprint, + Instant notBefore, + Instant notAfter, + String algorithm, + int keySize, + String commonName, + String cpf, + String cnpj, + String providerId, + String hardwareDeviceName +) implements Serializable { + + public ManagedCertificate { + Objects.requireNonNull(id, "id must not be null"); + Objects.requireNonNull(alias, "alias must not be null"); + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(status, "status must not be null"); + Objects.requireNonNull(subjectDn, "subjectDn must not be null"); + Objects.requireNonNull(issuerDn, "issuerDn must not be null"); + } + + /** + * Verifica se o certificado está atualmente dentro do período de validade temporal. + */ + public boolean isCurrentlyValid() { + Instant now = Instant.now(); + return (notBefore == null || !now.isBefore(notBefore)) + && (notAfter == null || !now.isAfter(notAfter)); + } + + /** + * Verifica se o certificado está apto a assinar. + */ + public boolean canSign() { + return status.isUsableForSigning() && isCurrentlyValid(); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/SigningContext.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/SigningContext.java new file mode 100644 index 0000000..0ced061 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/SigningContext.java @@ -0,0 +1,55 @@ +package br.com.cybercore.signer.core.domain; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Contexto da operação de assinatura a ser processada pelo SigningService / CertificateProvider. + */ +public record SigningContext( + String requestId, + String applicationId, + byte[] digest, + String algorithm, + String certificateHint, + Instant requestedAt +) { + public SigningContext { + Objects.requireNonNull(requestId, "requestId must not be null"); + Objects.requireNonNull(applicationId, "applicationId must not be null"); + Objects.requireNonNull(digest, "digest must not be null"); + Objects.requireNonNull(algorithm, "algorithm must not be null"); + digest = digest.clone(); + if (requestedAt == null) { + requestedAt = Instant.now(); + } + } + + @Override + public byte[] digest() { + return digest.clone(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SigningContext that = (SigningContext) o; + return Objects.equals(requestId, that.requestId) + && Objects.equals(applicationId, that.applicationId) + && Arrays.equals(digest, that.digest) + && Objects.equals(algorithm, that.algorithm) + && Objects.equals(certificateHint, that.certificateHint) + && Objects.equals(requestedAt, that.requestedAt); + } + + @Override + public int hashCode() { + int result = Objects.hash(requestId, applicationId, algorithm, certificateHint, requestedAt); + result = 31 * result + Arrays.hashCode(digest); + return result; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/SigningKeyMaterial.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/SigningKeyMaterial.java new file mode 100644 index 0000000..66a0496 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/SigningKeyMaterial.java @@ -0,0 +1,38 @@ +package br.com.cybercore.signer.core.domain; + +import java.security.PrivateKey; +import java.security.Provider; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Objects; + +/** + * Contêiner seguro para material criptográfico de assinatura (chave privada e cadeia X.509). + * Permite que serviços de alto nível (PDF/PAdES e XMLDSig) realizem assinaturas estruturadas. + * A chave privada e a cadeia permanecem estritamente na máquina local do usuário. + */ +public record SigningKeyMaterial( + PrivateKey privateKey, + X509Certificate[] certificateChain, + Provider jcaProvider +) { + + public SigningKeyMaterial { + Objects.requireNonNull(privateKey, "Chave privada não pode ser nula."); + Objects.requireNonNull(certificateChain, "Cadeia de certificados não pode ser nula."); + if (certificateChain.length == 0) { + throw new IllegalArgumentException("A cadeia de certificados deve conter ao menos um certificado."); + } + // Cópia defensiva + certificateChain = Arrays.copyOf(certificateChain, certificateChain.length); + } + + public X509Certificate signingCertificate() { + return certificateChain[0]; + } + + @Override + public X509Certificate[] certificateChain() { + return Arrays.copyOf(certificateChain, certificateChain.length); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/SigningResult.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/SigningResult.java new file mode 100644 index 0000000..4ff73c3 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/domain/SigningResult.java @@ -0,0 +1,63 @@ +package br.com.cybercore.signer.core.domain; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Resultado da operação de assinatura digital. + */ +public record SigningResult( + String requestId, + byte[] signatureBytes, + String certificateBase64, + List certificateChainBase64, + String algorithm, + Instant signedAt +) { + public SigningResult { + Objects.requireNonNull(requestId, "requestId must not be null"); + Objects.requireNonNull(signatureBytes, "signatureBytes must not be null"); + Objects.requireNonNull(certificateBase64, "certificateBase64 must not be null"); + Objects.requireNonNull(algorithm, "algorithm must not be null"); + signatureBytes = signatureBytes.clone(); + certificateChainBase64 = certificateChainBase64 != null + ? List.copyOf(certificateChainBase64) + : Collections.singletonList(certificateBase64); + if (signedAt == null) { + signedAt = Instant.now(); + } + } + + @Override + public byte[] signatureBytes() { + return signatureBytes.clone(); + } + + @Override + public List certificateChainBase64() { + return Collections.unmodifiableList(certificateChainBase64); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SigningResult that = (SigningResult) o; + return Objects.equals(requestId, that.requestId) + && Arrays.equals(signatureBytes, that.signatureBytes) + && Objects.equals(certificateBase64, that.certificateBase64) + && Objects.equals(certificateChainBase64, that.certificateChainBase64) + && Objects.equals(algorithm, that.algorithm) + && Objects.equals(signedAt, that.signedAt); + } + + @Override + public int hashCode() { + int result = Objects.hash(requestId, certificateBase64, certificateChainBase64, algorithm, signedAt); + result = 31 * result + Arrays.hashCode(signatureBytes); + return result; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSealPageChoice.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSealPageChoice.java new file mode 100644 index 0000000..930803a --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSealPageChoice.java @@ -0,0 +1,20 @@ +package br.com.cybercore.signer.core.pdf; + +/** + * Seleção da página do PDF na qual o selo visual da assinatura digital será carimbado. + */ +public enum PdfSealPageChoice { + LAST_PAGE("Última Página (Recomendado)"), + FIRST_PAGE("Primeira Página"), + ALL_PAGES("Todas as Páginas"); + + private final String description; + + PdfSealPageChoice(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSealPosition.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSealPosition.java new file mode 100644 index 0000000..15f70d8 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSealPosition.java @@ -0,0 +1,22 @@ +package br.com.cybercore.signer.core.pdf; + +/** + * Posição do selo visual de assinatura na página do documento PDF. + */ +public enum PdfSealPosition { + BOTTOM_RIGHT("Canto Inferior Direito"), + BOTTOM_LEFT("Canto Inferior Esquerdo"), + TOP_RIGHT("Canto Superior Direito"), + TOP_LEFT("Canto Superior Esquerdo"), + CUSTOM("Posição Personalizada por Clique na Página"); + + private final String description; + + PdfSealPosition(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSignatureOptions.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSignatureOptions.java new file mode 100644 index 0000000..a133c18 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSignatureOptions.java @@ -0,0 +1,186 @@ +package br.com.cybercore.signer.core.pdf; + +import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode; +import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme; +import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry; + +import java.util.Objects; + +/** + * Parâmetros de configuração para a operação de assinatura digital PAdES com selo visual em PDF, + * incluindo suporte a posicionamento predefinido ou coordenadas personalizadas na página, + * e preferências granulares para exibição de CPF e data/hora. + */ +public record PdfSignatureOptions( + String certificateId, + char[] pinOrPassword, + boolean visibleSeal, + PdfSealTheme sealTheme, + PdfSealPosition position, + PdfSealPageChoice pageChoice, + String reason, + String location, + Float customX, + Float customY, + Integer targetPageNumber, + CpfDisplayMode cpfDisplayMode, + Boolean includeDateTime, + String customExtraInfo +) { + + public PdfSignatureOptions { + Objects.requireNonNull(certificateId, "ID do certificado não pode ser nulo."); + if (sealTheme == null) { + sealTheme = PdfSealThemeRegistry.getDefaultTheme(); + } + if (position == null) { + position = (customX != null && customY != null) ? PdfSealPosition.CUSTOM : PdfSealPosition.BOTTOM_RIGHT; + } + if (pageChoice == null) { + pageChoice = PdfSealPageChoice.LAST_PAGE; + } + if (reason == null || reason.isBlank()) { + reason = "Assinatura Digital de Documento"; + } + if (location == null || location.isBlank()) { + location = "Brasil"; + } + if (cpfDisplayMode == null) { + cpfDisplayMode = (sealTheme.defaultCpfMode() != null) ? sealTheme.defaultCpfMode() : CpfDisplayMode.FULL; + } + if (includeDateTime == null) { + includeDateTime = sealTheme.defaultIncludeDateTime(); + } + } + + /** + * Construtor para compatibilidade com assinaturas anteriores a customExtraInfo. + */ + public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal, + PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice, + String reason, String location, Float customX, Float customY, + Integer targetPageNumber, CpfDisplayMode cpfDisplayMode, Boolean includeDateTime) { + this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, + customX, customY, targetPageNumber, cpfDisplayMode, includeDateTime, null); + } + + /** + * Construtor de compatibilidade para assinatura com posições pré-fixadas por enum. + */ + public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal, + PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice, + String reason, String location) { + this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, null, null, null, null, null, null); + } + + public PdfSignatureOptions(String certificateId, char[] pinOrPassword, boolean visibleSeal, + PdfSealTheme sealTheme, PdfSealPosition position, PdfSealPageChoice pageChoice, + String reason, String location, Float customX, Float customY, Integer targetPageNumber) { + this(certificateId, pinOrPassword, visibleSeal, sealTheme, position, pageChoice, reason, location, customX, customY, targetPageNumber, null, null, null); + } + + public static Builder builder(String certificateId) { + return new Builder(certificateId); + } + + public static final class Builder { + private final String certificateId; + private char[] pinOrPassword; + private boolean visibleSeal = true; + private PdfSealTheme sealTheme; + private PdfSealPosition position = PdfSealPosition.BOTTOM_RIGHT; + private PdfSealPageChoice pageChoice = PdfSealPageChoice.LAST_PAGE; + private String reason = "Assinatura Digital ICP-Brasil"; + private String location = "Brasil"; + private Float customX; + private Float customY; + private Integer targetPageNumber; + private CpfDisplayMode cpfDisplayMode; + private Boolean includeDateTime; + + public Builder(String certificateId) { + this.certificateId = Objects.requireNonNull(certificateId); + } + + public Builder pinOrPassword(char[] pin) { + this.pinOrPassword = pin; + return this; + } + + public Builder visibleSeal(boolean visible) { + this.visibleSeal = visible; + return this; + } + + public Builder sealTheme(PdfSealTheme theme) { + this.sealTheme = theme; + return this; + } + + public Builder position(PdfSealPosition pos) { + this.position = pos; + return this; + } + + public Builder pageChoice(PdfSealPageChoice choice) { + this.pageChoice = choice; + return this; + } + + public Builder reason(String reason) { + this.reason = reason; + return this; + } + + public Builder location(String location) { + this.location = location; + return this; + } + + public Builder cpfDisplayMode(CpfDisplayMode mode) { + this.cpfDisplayMode = mode; + return this; + } + + private String customExtraInfo; + + public Builder includeDateTime(boolean include) { + this.includeDateTime = include; + return this; + } + + public Builder customExtraInfo(String info) { + this.customExtraInfo = info; + return this; + } + + public Builder customPosition(Float x, Float y, Integer pageNumber) { + this.customX = x; + this.customY = y; + this.targetPageNumber = pageNumber; + if (x != null && y != null) { + this.position = PdfSealPosition.CUSTOM; + } + return this; + } + + public PdfSignatureOptions build() { + return new PdfSignatureOptions( + certificateId, + pinOrPassword, + visibleSeal, + sealTheme, + position, + pageChoice, + reason, + location, + customX, + customY, + targetPageNumber, + cpfDisplayMode, + includeDateTime, + customExtraInfo + ); + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java new file mode 100644 index 0000000..d895faa --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java @@ -0,0 +1,351 @@ +package br.com.cybercore.signer.core.pdf; + +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningKeyMaterial; +import br.com.cybercore.signer.core.pdf.seal.PdfSealData; +import br.com.cybercore.signer.core.pdf.seal.PdfSealRenderer; +import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.CertificateProviderException; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.bouncycastle.cert.jcajce.JcaCertStore; +import org.bouncycastle.cms.CMSProcessableByteArray; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.CMSSignedDataGenerator; +import org.bouncycastle.cms.CMSTypedData; +import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Calendar; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Serviço de alto nível para Assinatura Digital de Documentos PDF no padrão PAdES (PDF Advanced Electronic Signatures), + * com suporte à aplicação de Selos Visuais estilizados por Temas SVG, posicionamento livre por clique na página e renderização de previews. + */ +public class PdfSigningService { + + private static final Logger logger = LoggerFactory.getLogger(PdfSigningService.class); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss") + .withZone(ZoneId.systemDefault()); + + private final CertificateManager certificateManager; + + public PdfSigningService(CertificateManager certificateManager) { + this.certificateManager = Objects.requireNonNull(certificateManager, "CertificateManager não pode ser nulo."); + } + + /** + * Retorna a quantidade total de páginas contidas no documento PDF. + */ + public static int getPageCount(byte[] pdfBytes) throws IOException { + Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + return doc.getNumberOfPages(); + } + } + + /** + * Renderiza uma página específica do PDF para BufferedImage na resolução DPI informada. + */ + public static BufferedImage renderPageImage(byte[] pdfBytes, int pageIndex0Based, float dpi) throws IOException { + Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) { + throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages()); + } + PDFRenderer renderer = new PDFRenderer(doc); + return renderer.renderImageWithDPI(pageIndex0Based, dpi); + } + } + + /** + * Renderiza uma página específica do PDF diretamente para bytes de imagem PNG. + */ + public static byte[] renderPagePngBytes(byte[] pdfBytes, int pageIndex0Based, float dpi) throws IOException { + BufferedImage image = renderPageImage(pdfBytes, pageIndex0Based, dpi); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "PNG", baos); + return baos.toByteArray(); + } + + /** + * Assina digitalmente um arquivo PDF do sistema de arquivos e grava o resultado no arquivo de destino. + */ + public void signPdfFile(File sourcePdf, File targetPdf, PdfSignatureOptions options) throws Exception { + Objects.requireNonNull(sourcePdf, "Arquivo PDF de origem não pode ser nulo."); + Objects.requireNonNull(targetPdf, "Arquivo PDF de destino não pode ser nulo."); + Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas."); + + byte[] inputBytes = Files.readAllBytes(sourcePdf.toPath()); + byte[] signedBytes = signPdf(inputBytes, options); + + if (targetPdf.getParentFile() != null) { + targetPdf.getParentFile().mkdirs(); + } + try (FileOutputStream fos = new FileOutputStream(targetPdf)) { + fos.write(signedBytes); + } + logger.info("PDF assinado com sucesso e salvo em: {}", targetPdf.getAbsolutePath()); + } + + /** + * Assina digitalmente os bytes de um documento PDF, aplicando o selo visual configurado + * e retornando o PDF assinado completo com envelope PAdES. + */ + public byte[] signPdf(byte[] pdfBytes, PdfSignatureOptions options) throws Exception { + Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); + Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas."); + + // 1. Localiza o certificado e o material criptográfico (chave e cadeia) + Optional certOpt = certificateManager.findById(options.certificateId()); + if (certOpt.isEmpty()) { + certOpt = certificateManager.findByHint(options.certificateId()); + } + if (certOpt.isEmpty()) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Certificado não encontrado no gerenciador para ID: " + options.certificateId() + ); + } + + ManagedCertificate cert = certOpt.get(); + Optional keyMaterialOpt = certificateManager.getKeyMaterial(cert.id(), options.pinOrPassword()); + if (keyMaterialOpt.isEmpty()) { + throw new CertificateProviderException( + CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE, + "Chave privada inacessível para o certificado '" + cert.alias() + "'." + ); + } + SigningKeyMaterial keyMaterial = keyMaterialOpt.get(); + + // 2. Prepara dados do signatário e resumo para o selo visual + String signerDoc = cert.cpf() != null ? "CPF: " + cert.cpf() : (cert.cnpj() != null ? "CNPJ: " + cert.cnpj() : ""); + String issuerCn = extractIssuerCn(cert.issuerDn()); + String signingTimeStr = DATE_TIME_FORMATTER.format(Instant.now()); + + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + byte[] pdfDigest = sha256.digest(pdfBytes); + String hashShort = bytesToHex(pdfDigest).substring(0, 16).toUpperCase(); + + PdfSealTheme theme = options.sealTheme(); + String effectiveExtraInfo = (options.customExtraInfo() != null && !options.customExtraInfo().isBlank()) + ? options.customExtraInfo().trim() + : theme.formatCustomField(); + + // 3. Monta dados do selo visual + PdfSealData sealData = new PdfSealData( + cert.commonName() != null ? cert.commonName() : cert.alias(), + signerDoc, + signingTimeStr, + issuerCn, + hashShort, + options.reason(), + options.location(), + options.cpfDisplayMode(), + options.includeDateTime(), + effectiveExtraInfo + ); + + // 4. Carrega o PDF e estampa o selo se solicitado + byte[] intermediatePdf; + if (options.visibleSeal()) { + byte[] sealPng = PdfSealRenderer.renderToPngBytes(theme, sealData); + intermediatePdf = applyVisualSealToPdf(pdfBytes, sealPng, theme, options); + } else { + intermediatePdf = pdfBytes; + } + + // 5. Aplica a assinatura PAdES criptográfica via PDFBox e Bouncy Castle + ByteArrayOutputStream finalOut = new ByteArrayOutputStream(); + try (PDDocument doc = Loader.loadPDF(intermediatePdf)) { + PDSignature signature = new PDSignature(); + signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE); + signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED); + signature.setName(cert.commonName()); + signature.setLocation(options.location()); + signature.setReason(options.reason()); + signature.setSignDate(Calendar.getInstance()); + + SignatureInterface signer = new PadesBouncyCastleSigner(keyMaterial); + doc.addSignature(signature, signer); + doc.saveIncremental(finalOut); + } + + logger.info("Assinatura PAdES concluída com sucesso. Tamanho final: {} bytes", finalOut.size()); + return finalOut.toByteArray(); + } + + private byte[] applyVisualSealToPdf(byte[] pdfBytes, byte[] sealPng, PdfSealTheme theme, + PdfSignatureOptions options) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + int totalPages = doc.getNumberOfPages(); + if (totalPages == 0) { + return pdfBytes; + } + + PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, sealPng, "cybercore_seal"); + + float sealWidth = theme.width() * 0.72f; + float sealHeight = theme.height() * 0.72f; + float margin = 20f; + + // Se coordenadas customizadas foram informadas (clique ou drag & drop na página) + if (options.customX() != null && options.customY() != null) { + int targetPageIdx = totalPages - 1; + if (options.targetPageNumber() != null && options.targetPageNumber() >= 1 && options.targetPageNumber() <= totalPages) { + targetPageIdx = options.targetPageNumber() - 1; + } + + PDPage page = doc.getPage(targetPageIdx); + PDRectangle mediaBox = page.getMediaBox(); + float pageW = mediaBox.getWidth(); + float pageH = mediaBox.getHeight(); + + float x = Math.max(0, Math.min(options.customX(), pageW - sealWidth)); + float y = Math.max(0, Math.min(options.customY(), pageH - sealHeight)); + + try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { + cs.drawImage(pdImage, x, y, sealWidth, sealHeight); + } + + doc.save(baos); + return baos.toByteArray(); + } + + // Caso contrário, usa posicionamento por enum (BOTTOM_RIGHT, etc.) + int startPage = 0; + int endPage = totalPages - 1; + + if (options.pageChoice() == PdfSealPageChoice.FIRST_PAGE) { + endPage = 0; + } else if (options.pageChoice() == PdfSealPageChoice.LAST_PAGE) { + startPage = totalPages - 1; + } + + for (int i = startPage; i <= endPage; i++) { + PDPage page = doc.getPage(i); + PDRectangle mediaBox = page.getMediaBox(); + float pageW = mediaBox.getWidth(); + float pageH = mediaBox.getHeight(); + + float x; + float y; + + switch (options.position()) { + case BOTTOM_LEFT -> { + x = margin; + y = margin; + } + case TOP_RIGHT -> { + x = pageW - sealWidth - margin; + y = pageH - sealHeight - margin; + } + case TOP_LEFT -> { + x = margin; + y = pageH - sealHeight - margin; + } + default -> { // BOTTOM_RIGHT + x = pageW - sealWidth - margin; + y = margin; + } + } + + try (PDPageContentStream cs = new PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { + cs.drawImage(pdImage, x, y, sealWidth, sealHeight); + } + } + + doc.save(baos); + } + return baos.toByteArray(); + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static String extractIssuerCn(String dn) { + if (dn == null) return "ICP-Brasil"; + int idx = dn.indexOf("CN="); + if (idx >= 0) { + int end = dn.indexOf(',', idx); + return end > idx ? dn.substring(idx + 3, end).trim() : dn.substring(idx + 3).trim(); + } + return dn; + } + + /** + * Assinador PAdES CMS/PKCS#7 desacoplado via Bouncy Castle. + */ + private static class PadesBouncyCastleSigner implements SignatureInterface { + private final SigningKeyMaterial keyMaterial; + + public PadesBouncyCastleSigner(SigningKeyMaterial keyMaterial) { + this.keyMaterial = Objects.requireNonNull(keyMaterial); + } + + @Override + public byte[] sign(InputStream content) throws IOException { + try { + byte[] messageBytes = content.readAllBytes(); + + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256withRSA") + .build(keyMaterial.privateKey()); + + X509Certificate userCert = keyMaterial.certificateChain()[0]; + + gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder( + new JcaDigestCalculatorProviderBuilder().build()) + .build(contentSigner, userCert)); + + gen.addCertificates(new JcaCertStore(Arrays.asList(keyMaterial.certificateChain()))); + + CMSTypedData msg = new CMSProcessableByteArray(messageBytes); + CMSSignedData signedData = gen.generate(msg, false); + + return signedData.getEncoded(); + } catch (Exception e) { + logger.error("Falha ao gerar assinatura CMS/PKCS#7 para PDF", e); + throw new IOException("Falha criptográfica no Bouncy Castle: " + e.getMessage(), e); + } + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/CpfDisplayMode.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/CpfDisplayMode.java new file mode 100644 index 0000000..4a0ac64 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/CpfDisplayMode.java @@ -0,0 +1,64 @@ +package br.com.cybercore.signer.core.pdf.seal; + +/** + * Modo de exibição do CPF do signatário no selo visual de assinatura digital. + * Permite balancear autenticidade documental e privacidade de dados (LGPD). + */ +public enum CpfDisplayMode { + + /** + * O CPF não é exibido no selo visual. + */ + NONE("Ocultar CPF"), + + /** + * O CPF é exibido parcialmente mascarado (ex: ***.456.789-**). + */ + MASKED("Mascarado (***.456.789-**)"), + + /** + * O CPF é exibido integralmente (ex: 123.456.789-00). + */ + FULL("Completo (123.456.789-00)"); + + private final String description; + + CpfDisplayMode(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + /** + * Formata um CPF bruto ou previamente formatado de acordo com este modo. + * + * @param rawCpf CPF a ser formatado (com ou sem pontuação) + * @return String formatada conforme o modo escolhido, ou vazio se não houver CPF + */ + public String formatCpf(String rawCpf) { + if (this == NONE || rawCpf == null || rawCpf.isBlank()) { + return ""; + } + + String digits = rawCpf.replaceAll("\\D", ""); + if (digits.length() != 11) { + // Se não for um CPF de 11 dígitos, retorna mascarado ou o próprio texto + return this == MASKED ? "***.***.***-**" : rawCpf; + } + + if (this == MASKED) { + // Mascara os 3 primeiros e os 2 dígitos verificadores: ***.456.789-** + String part2 = digits.substring(3, 6); + String part3 = digits.substring(6, 9); + return "***." + part2 + "." + part3 + "-**"; + } + + // FULL: 123.456.789-00 + return digits.substring(0, 3) + "." + + digits.substring(3, 6) + "." + + digits.substring(6, 9) + "-" + + digits.substring(9, 11); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/CustomSealStorage.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/CustomSealStorage.java new file mode 100644 index 0000000..29cbfaa --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/CustomSealStorage.java @@ -0,0 +1,175 @@ +package br.com.cybercore.signer.core.pdf.seal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Gerenciador de armazenamento em disco para Selos Visuais Personalizados criados ou instalados pelo usuário. + * Persiste cada selo como um arquivo JSON no diretório ~/.cybercore/seals/. + */ +public final class CustomSealStorage { + + private static final Logger logger = LoggerFactory.getLogger(CustomSealStorage.class); + private static final String SEALS_DIR_NAME = "seals"; + + private final Path storageDir; + private final ObjectMapper objectMapper; + + public CustomSealStorage() { + this(resolveDefaultStorageDir()); + } + + public CustomSealStorage(Path storageDir) { + this.storageDir = Objects.requireNonNull(storageDir); + this.objectMapper = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); + ensureDirectoryExists(); + } + + private static Path resolveDefaultStorageDir() { + String userHome = System.getProperty("user.home", "."); + return Path.of(userHome, ".cybercore", SEALS_DIR_NAME); + } + + private void ensureDirectoryExists() { + try { + if (!Files.exists(storageDir)) { + Files.createDirectories(storageDir); + } + } catch (IOException e) { + logger.error("Falha ao criar diretório de selos personalizados: {}", storageDir, e); + } + } + + public Path getStorageDir() { + return storageDir; + } + + /** + * Salva ou atualiza um selo personalizado em disco. + */ + public void saveSeal(PdfSealTheme seal) throws IOException { + Objects.requireNonNull(seal, "Selo não pode ser nulo."); + ensureDirectoryExists(); + + String safeFileName = sanitizeFileName(seal.id()) + ".json"; + Path targetPath = storageDir.resolve(safeFileName); + + // Marca como personalizado + PdfSealTheme customSeal = new PdfSealTheme( + seal.id(), + seal.name(), + seal.description(), + seal.width(), + seal.height(), + seal.primaryColor(), + seal.secondaryColor(), + seal.backgroundColor(), + seal.borderColor(), + seal.textColor(), + seal.svgTemplate(), + seal.customHeader(), + seal.customFooter(), + seal.iconStyle(), + seal.defaultCpfMode(), + seal.defaultIncludeDateTime(), + true, + seal.customLogoBase64(), + seal.customFieldLabel(), + seal.customFieldValue() + ); + + objectMapper.writeValue(targetPath.toFile(), customSeal); + logger.info("Selo personalizado '{}' instalado com sucesso em: {}", seal.id(), targetPath); + } + + /** + * Carrega todos os selos personalizados instalados no diretório do usuário. + */ + public List loadAllCustomSeals() { + List seals = new ArrayList<>(); + if (!Files.exists(storageDir)) { + return seals; + } + + try (Stream stream = Files.list(storageDir)) { + stream.filter(p -> p.toString().toLowerCase().endsWith(".json")) + .forEach(file -> { + try { + PdfSealTheme seal = objectMapper.readValue(file.toFile(), PdfSealTheme.class); + seals.add(seal); + } catch (Exception e) { + logger.warn("Falha ao carregar arquivo de selo {}: {}", file.getFileName(), e.getMessage()); + } + }); + } catch (IOException e) { + logger.error("Erro ao listar diretório de selos {}", storageDir, e); + } + + return seals; + } + + /** + * Remove um selo personalizado do disco. + */ + public boolean deleteSeal(String sealId) { + if (sealId == null || sealId.isBlank()) return false; + String safeFileName = sanitizeFileName(sealId) + ".json"; + Path targetPath = storageDir.resolve(safeFileName); + + try { + boolean deleted = Files.deleteIfExists(targetPath); + if (deleted) { + logger.info("Selo personalizado '{}' removido do disco.", sealId); + } + return deleted; + } catch (IOException e) { + logger.error("Erro ao excluir selo {}: {}", sealId, e.getMessage()); + return false; + } + } + + /** + * Importa um selo a partir de um arquivo externo (.json) e o instala no diretório do usuário. + */ + public PdfSealTheme importSeal(Path externalFile) throws IOException { + Objects.requireNonNull(externalFile, "Arquivo de importação não pode ser nulo."); + if (!Files.exists(externalFile)) { + throw new IllegalArgumentException("O arquivo de selo especificado não existe: " + externalFile); + } + + PdfSealTheme seal = objectMapper.readValue(externalFile.toFile(), PdfSealTheme.class); + saveSeal(seal); + return seal; + } + + /** + * Exporta um selo para um arquivo externo de destino. + */ + public void exportSeal(PdfSealTheme seal, Path destinationFile) throws IOException { + Objects.requireNonNull(seal, "Selo não pode ser nulo."); + Objects.requireNonNull(destinationFile, "Destino não pode ser nulo."); + + if (destinationFile.getParent() != null && !Files.exists(destinationFile.getParent())) { + Files.createDirectories(destinationFile.getParent()); + } + + objectMapper.writeValue(destinationFile.toFile(), seal); + logger.info("Selo '{}' exportado com sucesso para: {}", seal.id(), destinationFile); + } + + private static String sanitizeFileName(String id) { + return id.replaceAll("[^a-zA-Z0-9._-]", "_"); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealData.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealData.java new file mode 100644 index 0000000..cbb20ad --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealData.java @@ -0,0 +1,64 @@ +package br.com.cybercore.signer.core.pdf.seal; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * Metadados e dados do signatário a serem gravados e representados no selo visual da assinatura PDF. + */ +public record PdfSealData( + String signerName, + String identifier, + String signingDate, + String issuer, + String hashSummary, + String reason, + String location, + CpfDisplayMode cpfDisplayMode, + boolean includeDateTime, + String customExtraInfo +) { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss (z)") + .withZone(ZoneId.systemDefault()); + + public PdfSealData { + signerName = (signerName != null && !signerName.isBlank()) ? signerName : "Signatário ICP-Brasil"; + identifier = (identifier != null && !identifier.isBlank()) ? identifier : ""; + signingDate = (signingDate != null && !signingDate.isBlank()) ? signingDate : FORMATTER.format(Instant.now()); + issuer = (issuer != null && !issuer.isBlank()) ? issuer : "Autoridade Certificadora ICP-Brasil"; + hashSummary = (hashSummary != null && !hashSummary.isBlank()) ? hashSummary : ""; + reason = (reason != null && !reason.isBlank()) ? reason : "Assinatura Digital de Documento"; + location = (location != null && !location.isBlank()) ? location : "Brasil"; + if (cpfDisplayMode == null) { + cpfDisplayMode = CpfDisplayMode.FULL; + } + } + + /** + * Construtor para compatibilidade com assinaturas anteriores a customExtraInfo. + */ + public PdfSealData(String signerName, String identifier, String signingDate, String issuer, + String hashSummary, String reason, String location, + CpfDisplayMode cpfDisplayMode, boolean includeDateTime) { + this(signerName, identifier, signingDate, issuer, hashSummary, reason, location, cpfDisplayMode, includeDateTime, null); + } + + /** + * Construtor de compatibilidade para código que não especifica cpfDisplayMode e includeDateTime. + */ + public PdfSealData(String signerName, String identifier, String signingDate, String issuer, + String hashSummary, String reason, String location) { + this(signerName, identifier, signingDate, issuer, hashSummary, reason, location, CpfDisplayMode.FULL, true, null); + } + + public static PdfSealData of(String signerName, String identifier, String issuer, String hashSummary) { + return new PdfSealData(signerName, identifier, null, issuer, hashSummary, null, null, CpfDisplayMode.FULL, true); + } + + public static PdfSealData of(String signerName, String identifier, String issuer, String hashSummary, + CpfDisplayMode cpfDisplayMode, boolean includeDateTime) { + return new PdfSealData(signerName, identifier, null, issuer, hashSummary, null, null, cpfDisplayMode, includeDateTime); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealRenderer.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealRenderer.java new file mode 100644 index 0000000..baf143d --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealRenderer.java @@ -0,0 +1,411 @@ +package br.com.cybercore.signer.core.pdf.seal; + +import javax.imageio.ImageIO; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Path2D; +import java.awt.geom.RoundRectangle2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Objects; + +/** + * Renderizador de alta fidelidade para Selos Visuais de Assinatura PDF. + * Gera imagens nítidas (300 DPI equivalente) baseadas nos Temas e vetores SVG, + * utilizando exclusivamente a API gráfica padrão Java2D (AWT), sem dependência de JavaFX. + * + * Garante que o nome completo do signatário seja sempre exibido na íntegra, + * além de respeitar as opções de CPF (completo, mascarado ou omitido) e data/hora. + */ +public final class PdfSealRenderer { + + private PdfSealRenderer() { + } + + /** + * Renderiza o selo visual baseado no tema e dados fornecidos para um BufferedImage em escala 2x (alta nitidez). + */ + public static BufferedImage renderToImage(PdfSealTheme theme, PdfSealData data) { + Objects.requireNonNull(theme, "Tema não pode ser nulo."); + Objects.requireNonNull(data, "Dados da assinatura não podem ser nulos."); + + int baseWidth = theme.width(); + int baseHeight = theme.height(); + // Escala 2x para renderização super-nítida no documento PDF e na interface + int scale = 2; + int imgWidth = baseWidth * scale; + int imgHeight = baseHeight * scale; + + BufferedImage image = new BufferedImage(imgWidth, imgHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = image.createGraphics(); + + try { + // Configuração de anti-aliasing e renderização de alta qualidade + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + + g2d.scale(scale, scale); + + Color primaryColor = parseColor(theme.primaryColor(), new Color(13, 71, 161)); + Color secondaryColor = parseColor(theme.secondaryColor(), new Color(2, 132, 199)); + Color bgColor = parseColor(theme.backgroundColor(), Color.WHITE); + Color borderColor = parseColor(theme.borderColor(), new Color(203, 213, 225)); + Color textColor = parseColor(theme.textColor(), new Color(15, 23, 42)); + + // 1. Fundo do Selo com cantos arredondados + g2d.setColor(bgColor); + g2d.fill(new RoundRectangle2D.Float(1, 1, baseWidth - 2, baseHeight - 2, 8, 8)); + + // 2. Borda externa + g2d.setColor(borderColor); + g2d.setStroke(new BasicStroke(1.2f)); + g2d.draw(new RoundRectangle2D.Float(1, 1, baseWidth - 2, baseHeight - 2, 8, 8)); + + // 3. Tarja/Acento lateral do tema + g2d.setColor(primaryColor); + g2d.fill(new RoundRectangle2D.Float(1, 1, 5, baseHeight - 2, 3, 3)); + + if ("gov-br".equalsIgnoreCase(theme.id()) || "star".equalsIgnoreCase(theme.iconStyle())) { + g2d.setColor(secondaryColor); + g2d.fillRect(6, 1, 3, baseHeight - 2); + } + + // 4. Emblema à Esquerda (Logo Customizada em Imagem ou Ícone Vetorial) + if (theme.customLogoBase64() != null && !theme.customLogoBase64().isBlank()) { + drawCustomLogo(g2d, theme.customLogoBase64(), 10, 14, 36, baseHeight - 28); + } else { + String effectiveIcon = (theme.iconStyle() != null && !theme.iconStyle().isBlank()) ? theme.iconStyle() : theme.id(); + drawThemeIcon(g2d, effectiveIcon, primaryColor, secondaryColor); + } + + // 5. Informações Textuais da Assinatura (Focado no Titular, sem dados técnicos/emissor) + int textX = 52; + int textWidthAvailable = baseWidth - textX - 12; + + String formattedDoc = formatSignerDoc(data); + String customInfo = (data.customExtraInfo() != null && !data.customExtraInfo().isBlank()) + ? data.customExtraInfo().trim() + : theme.formatCustomField(); + + String identityLine; + if (!formattedDoc.isBlank() && !customInfo.isBlank()) { + identityLine = formattedDoc + " • " + customInfo; + } else if (!formattedDoc.isBlank()) { + identityLine = formattedDoc; + } else { + identityLine = customInfo; + } + + boolean hasIdentity = !identityLine.isBlank(); + boolean hasDate = data.includeDateTime(); + + String fullName = data.signerName() != null ? data.signerName().trim() : "Signatário ICP-Brasil"; + String headerText = resolveHeaderText(theme); + String footerText = resolveFooterText(theme, null); + + if (hasIdentity && hasDate) { + // Layout com 5 linhas: Cabeçalho, Nome, Identificação (CPF/Matrícula), Data, Rodapé + g2d.setColor(primaryColor); + g2d.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 9)); + g2d.drawString(headerText, textX, 16); + + g2d.setColor(textColor); + drawFullSignerName(g2d, fullName, textX, 33, textWidthAvailable, 13.0f); + + g2d.setColor(new Color(51, 65, 85)); + g2d.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 10)); + drawFittedText(g2d, identityLine, textX, 47, textWidthAvailable, 10.0f); + + g2d.drawString("Data: " + data.signingDate(), textX, 60); + + g2d.setColor(secondaryColor); + g2d.setFont(new Font(Font.SANS_SERIF, Font.ITALIC, 8)); + g2d.drawString(footerText, textX, 73); + + } else if (hasIdentity || hasDate) { + // Layout com 4 linhas: Cabeçalho, Nome, (Identificação ou Data), Rodapé + g2d.setColor(primaryColor); + g2d.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 10)); + g2d.drawString(headerText, textX, 17); + + g2d.setColor(textColor); + drawFullSignerName(g2d, fullName, textX, 36, textWidthAvailable, 13.5f); + + g2d.setColor(new Color(51, 65, 85)); + g2d.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 10)); + String singleLineInfo = hasIdentity ? identityLine : ("Data: " + data.signingDate()); + drawFittedText(g2d, singleLineInfo, textX, 53, textWidthAvailable, 10.0f); + + g2d.setColor(secondaryColor); + g2d.setFont(new Font(Font.SANS_SERIF, Font.ITALIC, 8)); + g2d.drawString(footerText, textX, 71); + + } else { + // Layout com 3 linhas: Cabeçalho, Nome em Destaque Máximo, Rodapé + g2d.setColor(primaryColor); + g2d.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 10)); + g2d.drawString(headerText, textX, 19); + + g2d.setColor(textColor); + drawFullSignerName(g2d, fullName, textX, 44, textWidthAvailable, 14.5f); + + g2d.setColor(secondaryColor); + g2d.setFont(new Font(Font.SANS_SERIF, Font.ITALIC, 9)); + g2d.drawString(footerText, textX, 68); + } + + } finally { + g2d.dispose(); + } + + return image; + } + + /** + * Desenha o logotipo personalizado fornecido em Base64, preservando o aspecto proporcional + * e aplicando anti-aliasing e interpolação bilinear de alta fidelidade. + */ + private static void drawCustomLogo(Graphics2D g2d, String base64Data, int x, int y, int maxWidth, int maxHeight) { + try { + String cleanBase64 = base64Data; + int commaIdx = cleanBase64.indexOf(","); + if (commaIdx >= 0 && cleanBase64.substring(0, commaIdx).contains("base64")) { + cleanBase64 = cleanBase64.substring(commaIdx + 1); + } + byte[] bytes = java.util.Base64.getDecoder().decode(cleanBase64.trim()); + BufferedImage logoImg = ImageIO.read(new java.io.ByteArrayInputStream(bytes)); + if (logoImg != null) { + int originalW = logoImg.getWidth(); + int originalH = logoImg.getHeight(); + if (originalW > 0 && originalH > 0) { + double scaleW = (double) maxWidth / originalW; + double scaleH = (double) maxHeight / originalH; + double scale = Math.min(scaleW, scaleH); + int targetW = Math.max(1, (int) Math.round(originalW * scale)); + int targetH = Math.max(1, (int) Math.round(originalH * scale)); + + int posX = x + (maxWidth - targetW) / 2; + int posY = y + (maxHeight - targetH) / 2; + + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2d.drawImage(logoImg, posX, posY, targetW, targetH, null); + return; + } + } + } catch (Exception ignored) { + // Se houver falha na leitura do logo, recorre ao escudo padrão + } + drawThemeIcon(g2d, "shield", new Color(13, 71, 161), new Color(2, 132, 199)); + } + + /** + * Desenha o nome completo do signatário sem cortes ou truncamentos. + * Ajusta a fonte gradativamente para caber perfeitamente na largura do selo. + */ + private static void drawFullSignerName(Graphics2D g2d, String fullName, int x, int y, int maxWidth, float preferredSize) { + float fontSize = preferredSize; + Font font = new Font(Font.SANS_SERIF, Font.BOLD, Math.round(fontSize)); + FontMetrics fm = g2d.getFontMetrics(font); + + // Se o nome for longo, reduz o tamanho da fonte até caber perfeitamente + while (fm.stringWidth(fullName) > maxWidth && fontSize > 8.0f) { + fontSize -= 0.5f; + font = new Font(Font.SANS_SERIF, Font.BOLD, Math.round(fontSize)); + fm = g2d.getFontMetrics(font); + } + + g2d.setFont(font); + g2d.drawString(fullName, x, y); + } + + /** + * Desenha texto informativo secundário ajustando suavemente a fonte caso exceda a largura. + */ + private static void drawFittedText(Graphics2D g2d, String text, int x, int y, int maxWidth, float baseSize) { + if (text == null || text.isBlank()) return; + float fontSize = baseSize; + Font font = new Font(Font.SANS_SERIF, Font.PLAIN, Math.round(fontSize)); + FontMetrics fm = g2d.getFontMetrics(font); + + while (fm.stringWidth(text) > maxWidth && fontSize > 7.5f) { + fontSize -= 0.5f; + font = new Font(Font.SANS_SERIF, Font.PLAIN, Math.round(fontSize)); + fm = g2d.getFontMetrics(font); + } + + g2d.setFont(font); + g2d.drawString(text, x, y); + } + + /** + * Formata os dados de documento (CPF/CNPJ) de acordo com o modo de exibição configurado. + */ + private static String formatSignerDoc(PdfSealData data) { + CpfDisplayMode mode = data.cpfDisplayMode() != null ? data.cpfDisplayMode() : CpfDisplayMode.FULL; + String rawDoc = data.identifier() != null ? data.identifier() : ""; + + if (mode == CpfDisplayMode.NONE) { + // Se for CPF, oculta. Se for CNPJ, pode manter ou formatar. + if (rawDoc.toUpperCase().contains("CPF")) { + return ""; + } + return rawDoc; + } + + if (rawDoc.toUpperCase().contains("CPF")) { + String digits = rawDoc.replaceAll("\\D", ""); + String formatted = mode.formatCpf(digits); + return formatted.isBlank() ? "" : "CPF: " + formatted; + } + + return rawDoc; + } + + /** + * Renderiza o selo visual e o exporta diretamente como array de bytes PNG. + */ + public static byte[] renderToPngBytes(PdfSealTheme theme, PdfSealData data) throws IOException { + BufferedImage img = renderToImage(theme, data); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "PNG", baos); + return baos.toByteArray(); + } + + private static void drawThemeIcon(Graphics2D g2d, String iconStyleOrId, Color primary, Color secondary) { + g2d.translate(14, 18); + + String style = (iconStyleOrId != null) ? iconStyleOrId.toLowerCase() : "shield"; + + if ("cybercore-modern".equals(style) || "crystal".equals(style)) { + // Cristal CyberCore Vetorial + Path2D.Float crystal = new Path2D.Float(); + crystal.moveTo(16, 2); + crystal.lineTo(28, 9); + crystal.lineTo(28, 25); + crystal.lineTo(16, 32); + crystal.lineTo(4, 25); + crystal.lineTo(4, 9); + crystal.closePath(); + + g2d.setColor(new Color(primary.getRed(), primary.getGreen(), primary.getBlue(), 35)); + g2d.fill(crystal); + g2d.setColor(primary); + g2d.setStroke(new BasicStroke(1.5f)); + g2d.draw(crystal); + + g2d.setColor(secondary); + g2d.drawLine(16, 2, 16, 32); + g2d.setColor(primary); + g2d.drawLine(4, 17, 28, 17); + + } else if ("minimal-clean".equals(style) || "notary".equals(style) || "cartorio".equals(style)) { + // Carimbo notarial sóbrio + g2d.setColor(primary); + g2d.setStroke(new BasicStroke(1.5f)); + g2d.draw(new Ellipse2D.Float(2, 2, 28, 28)); + g2d.setStroke(new BasicStroke(0.8f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, new float[]{2.0f, 2.0f}, 0.0f)); + g2d.draw(new Ellipse2D.Float(5, 5, 22, 22)); + + // Checkmark + g2d.setStroke(new BasicStroke(1.8f)); + g2d.drawLine(10, 16, 14, 20); + g2d.drawLine(14, 20, 22, 12); + + } else if ("gov-br".equals(style) || "star".equals(style) || "governo".equals(style)) { + // Brasão estelar estilizado + g2d.setColor(new Color(primary.getRed(), primary.getGreen(), primary.getBlue(), 30)); + g2d.fill(new Ellipse2D.Float(2, 2, 28, 28)); + g2d.setColor(primary); + g2d.setStroke(new BasicStroke(1.4f)); + g2d.draw(new Ellipse2D.Float(2, 2, 28, 28)); + + // Estrela interna + Path2D.Float star = new Path2D.Float(); + star.moveTo(16, 6); + star.lineTo(19, 13); + star.lineTo(26, 13); + star.lineTo(20, 17); + star.lineTo(22, 24); + star.lineTo(16, 20); + star.lineTo(10, 24); + star.lineTo(12, 17); + star.lineTo(6, 13); + star.lineTo(13, 13); + star.closePath(); + g2d.setColor(secondary); + g2d.fill(star); + + } else { + // Padrão: Escudo Criptográfico Oficial com Cadeado + Path2D.Float shield = new Path2D.Float(); + shield.moveTo(16, 2); + shield.lineTo(28, 6); + shield.lineTo(28, 16); + shield.curveTo(28, 25, 22, 31, 16, 34); + shield.curveTo(10, 31, 4, 25, 4, 16); + shield.lineTo(4, 6); + shield.closePath(); + + g2d.setColor(new Color(primary.getRed(), primary.getGreen(), primary.getBlue(), 35)); + g2d.fill(shield); + g2d.setColor(primary); + g2d.setStroke(new BasicStroke(1.5f)); + g2d.draw(shield); + + // Cadeado + g2d.setColor(primary); + g2d.fillRect(11, 15, 10, 8); + g2d.setStroke(new BasicStroke(1.3f)); + g2d.drawArc(12, 9, 8, 8, 0, 180); + g2d.setColor(Color.WHITE); + g2d.fillOval(15, 17, 2, 2); + } + + g2d.translate(-14, -18); + } + + private static String resolveHeaderText(PdfSealTheme theme) { + if (theme.customHeader() != null && !theme.customHeader().isBlank()) { + return theme.customHeader().toUpperCase(); + } + return switch (theme.id().toLowerCase()) { + case "cybercore-modern" -> "CYBERCORE SIGNER • AUTENTICAÇÃO DIGITAL"; + case "minimal-clean" -> "DOCUMENTO ASSINADO DIGITALMENTE"; + case "gov-br" -> "GOV.BR • CONFORMIDADE MP Nº 2.200-2/2001"; + default -> "ASSINADO DIGITALMENTE • ICP-BRASIL"; + }; + } + + private static String resolveFooterText(PdfSealTheme theme, String hash) { + if (theme.customFooter() != null && !theme.customFooter().isBlank()) { + return theme.customFooter(); + } + if (hash != null && !hash.isBlank()) { + return "Hash Criptográfico: " + hash; + } + return switch (theme.id().toLowerCase()) { + case "cybercore-modern" -> "Autenticidade assegurada por chave criptográfica local"; + case "minimal-clean" -> "Assinatura qualificada padrão ICP-Brasil"; + case "gov-br" -> "Validade jurídica plena conforme MP nº 2.200-2/2001"; + default -> "Conformidade ICP-Brasil e validade jurídica plena"; + }; + } + + private static Color parseColor(String hex, Color def) { + if (hex == null || !hex.startsWith("#")) return def; + try { + return Color.decode(hex); + } catch (Exception e) { + return def; + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealTheme.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealTheme.java new file mode 100644 index 0000000..9911a25 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealTheme.java @@ -0,0 +1,135 @@ +package br.com.cybercore.signer.core.pdf.seal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.Objects; + +/** + * Modelo de configuração e identidade visual de um Tema de Selo de Assinatura PDF. + * Permite definir paleta de cores, dimensões, templates, textos personalizados e preferências de exibição. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PdfSealTheme( + String id, + String name, + String description, + int width, + int height, + String primaryColor, + String secondaryColor, + String backgroundColor, + String borderColor, + String textColor, + String svgTemplate, + String customHeader, + String customFooter, + String iconStyle, + CpfDisplayMode defaultCpfMode, + boolean defaultIncludeDateTime, + boolean isCustom, + String customLogoBase64, + String customFieldLabel, + String customFieldValue +) { + + public PdfSealTheme { + Objects.requireNonNull(id, "ID do tema não pode ser nulo."); + Objects.requireNonNull(name, "Nome do tema não pode ser nulo."); + if (width <= 0) width = 320; + if (height <= 0) height = 82; + if (primaryColor == null) primaryColor = "#0d47a1"; + if (secondaryColor == null) secondaryColor = "#0284c7"; + if (backgroundColor == null) backgroundColor = "#ffffff"; + if (borderColor == null) borderColor = "#cbd5e1"; + if (textColor == null) textColor = "#0f172a"; + if (iconStyle == null || iconStyle.isBlank()) iconStyle = "shield"; + if (defaultCpfMode == null) defaultCpfMode = CpfDisplayMode.FULL; + } + + /** + * Construtor para temas customizados com logotipo Base64, sem campos extras. + */ + public PdfSealTheme(String id, String name, String description, int width, int height, + String primaryColor, String secondaryColor, String backgroundColor, + String borderColor, String textColor, String svgTemplate, + String customHeader, String customFooter, String iconStyle, + CpfDisplayMode defaultCpfMode, boolean defaultIncludeDateTime, boolean isCustom, + String customLogoBase64) { + this(id, name, description, width, height, primaryColor, secondaryColor, backgroundColor, + borderColor, textColor, svgTemplate, customHeader, customFooter, iconStyle, + defaultCpfMode, defaultIncludeDateTime, isCustom, customLogoBase64, null, null); + } + + /** + * Construtor para temas customizados sem logo personalizada em imagem. + */ + public PdfSealTheme(String id, String name, String description, int width, int height, + String primaryColor, String secondaryColor, String backgroundColor, + String borderColor, String textColor, String svgTemplate, + String customHeader, String customFooter, String iconStyle, + CpfDisplayMode defaultCpfMode, boolean defaultIncludeDateTime, boolean isCustom) { + this(id, name, description, width, height, primaryColor, secondaryColor, backgroundColor, + borderColor, textColor, svgTemplate, customHeader, customFooter, iconStyle, + defaultCpfMode, defaultIncludeDateTime, isCustom, null, null, null); + } + + /** + * Construtor legado para os temas embutidos do sistema. + */ + public PdfSealTheme(String id, String name, String description, int width, int height, + String primaryColor, String secondaryColor, String backgroundColor, + String borderColor, String textColor, String svgTemplate) { + this(id, name, description, width, height, primaryColor, secondaryColor, backgroundColor, + borderColor, textColor, svgTemplate, null, null, "shield", CpfDisplayMode.FULL, true, false, null, null, null); + } + + /** + * Formata o campo customizado (ex: "Matrícula: 104829" ou "Cargo: Auditor Fiscal"). + */ + public String formatCustomField() { + boolean hasLabel = customFieldLabel != null && !customFieldLabel.isBlank(); + boolean hasValue = customFieldValue != null && !customFieldValue.isBlank(); + if (hasLabel && hasValue) { + String label = customFieldLabel.trim(); + if (!label.endsWith(":")) label += ":"; + return label + " " + customFieldValue.trim(); + } else if (hasValue) { + return customFieldValue.trim(); + } else if (hasLabel) { + return customFieldLabel.trim(); + } + return ""; + } + + /** + * Gera o código SVG final substituindo os marcadores pelos dados reais da assinatura. + */ + public String renderSvg(PdfSealData data) { + if (svgTemplate == null || svgTemplate.isBlank()) { + return ""; + } + return svgTemplate + .replace("${THEME_PRIMARY}", primaryColor) + .replace("${THEME_SECONDARY}", secondaryColor) + .replace("${THEME_BG}", backgroundColor) + .replace("${THEME_BORDER}", borderColor) + .replace("${THEME_TEXT}", textColor) + .replace("${SIGNER_NAME}", escapeXml(data.signerName())) + .replace("${SIGNER_DOC}", escapeXml(data.identifier())) + .replace("${SIGNING_DATE}", escapeXml(data.signingDate())) + .replace("${CERT_ISSUER}", escapeXml(data.issuer())) + .replace("${HASH_SUMMARY}", escapeXml(data.hashSummary())) + .replace("${REASON}", escapeXml(data.reason())) + .replace("${LOCATION}", escapeXml(data.location())); + } + + private static String escapeXml(String text) { + if (text == null) return ""; + return text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealThemeRegistry.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealThemeRegistry.java new file mode 100644 index 0000000..1e67b81 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/seal/PdfSealThemeRegistry.java @@ -0,0 +1,240 @@ +package br.com.cybercore.signer.core.pdf.seal; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Registro central de temas de selos visuais para assinatura PDF. + * Inclui os temas padrão do sistema e permite instalar, gerenciar e persistir dinamicamente + * temas personalizados de selo criados ou importados pelo usuário. + */ +public final class PdfSealThemeRegistry { + + private static final Logger logger = LoggerFactory.getLogger(PdfSealThemeRegistry.class); + + private static final Map THEMES = new ConcurrentHashMap<>(); + private static final CustomSealStorage STORAGE = new CustomSealStorage(); + + public static final String DEFAULT_THEME_ID = "icp-brasil"; + + static { + registerBuiltInThemes(); + loadCustomThemes(); + } + + private PdfSealThemeRegistry() { + } + + private static void registerBuiltInThemes() { + // 1. Tema ICP-Brasil Institucional + registerTheme(new PdfSealTheme( + "icp-brasil", + "ICP-Brasil Institucional", + "Estilo oficial corporativo com escudo de segurança vetorial e dados completos do titular", + 320, + 82, + "#0d47a1", + "#0284c7", + "#ffffff", + "#93c5fd", + "#0f172a", + """ + + + + + + + + + + + + + + ASSINADO DIGITALMENTE • ICP-BRASIL + ${SIGNER_NAME} + Doc: ${SIGNER_DOC} • AC: ${CERT_ISSUER} + Data/Hora: ${SIGNING_DATE} + Verificação criptográfica válida e com fé pública + + """ + )); + + // 2. Tema CyberCore Moderno + registerTheme(new PdfSealTheme( + "cybercore-modern", + "CyberCore Moderno", + "Identidade tecnológica com cristal vetorial CyberCore e visual ciano/esmeralda", + 320, + 82, + "#0284c7", + "#10b981", + "#ffffff", + "#38bdf8", + "#0f172a", + """ + + + + + + + + + + + + CYBERCORE SIGNER • ASSINATURA ELETRÔNICA + ${SIGNER_NAME} + ${SIGNER_DOC} • ${CERT_ISSUER} + Autenticado em: ${SIGNING_DATE} + Hash: ${HASH_SUMMARY} + + """ + )); + + // 3. Tema Minimalista Clássico + registerTheme(new PdfSealTheme( + "minimal-clean", + "Minimalista Clássico", + "Design monocromático sóbrio em tons de grafite, ideal para contratos e certidões", + 300, + 76, + "#334155", + "#64748b", + "#fafafa", + "#cbd5e1", + "#1e293b", + """ + + + + + + + + + + Assinado Digitalmente + ${SIGNER_NAME} + ${SIGNER_DOC} • ${CERT_ISSUER} + ${SIGNING_DATE} + Conforme MP 2.200-2/2001 e ICP-Brasil + + """ + )); + + // 4. Tema Governamental (Gov.br / ITI) + registerTheme(new PdfSealTheme( + "gov-br", + "Governamental (Padrão Gov.br / ITI)", + "Faixa verde e ouro com declaração expressa de validade pela MP nº 2.200-2/2001", + 320, + 82, + "#047857", + "#eab308", + "#ffffff", + "#a7f3d0", + "#064e3b", + """ + + + + + + + + + + + + GOV.BR • DOCUMENTO ASSINADO DIGITALMENTE + ${SIGNER_NAME} + Doc: ${SIGNER_DOC} • AC: ${CERT_ISSUER} + Data/Hora: ${SIGNING_DATE} + Validade jurídica assegurada pela MP nº 2.200-2/2001 + + """ + )); + } + + /** + * Carrega os temas personalizados armazenados no disco. + */ + public static void loadCustomThemes() { + try { + List customs = STORAGE.loadAllCustomSeals(); + for (PdfSealTheme theme : customs) { + THEMES.put(theme.id().toLowerCase(), theme); + } + logger.info("Carregados {} temas de selos personalizados do disco.", customs.size()); + } catch (Exception e) { + logger.warn("Não foi possível carregar temas personalizados: {}", e.getMessage()); + } + } + + public static void registerTheme(PdfSealTheme theme) { + Objects.requireNonNull(theme, "Tema não pode ser nulo."); + THEMES.put(theme.id().toLowerCase(), theme); + } + + /** + * Instala um tema personalizado, salvando-o no disco e registrando-o para uso imediato. + */ + public static void installCustomTheme(PdfSealTheme theme) throws IOException { + Objects.requireNonNull(theme, "Tema não pode ser nulo."); + STORAGE.saveSeal(theme); + registerTheme(theme); + } + + /** + * Remove um tema personalizado do disco e do registro. + * Não permite remover temas padrão do sistema. + */ + public static boolean uninstallCustomTheme(String themeId) { + if (themeId == null) return false; + String idLower = themeId.toLowerCase(); + + // Protege temas padrão + if (idLower.equals("icp-brasil") || idLower.equals("cybercore-modern") || + idLower.equals("minimal-clean") || idLower.equals("gov-br")) { + throw new IllegalArgumentException("Não é permitido excluir os temas de selo pré-instalados do sistema."); + } + + boolean deleted = STORAGE.deleteSeal(idLower); + THEMES.remove(idLower); + return deleted; + } + + public static Optional findById(String id) { + if (id == null) return Optional.empty(); + return Optional.ofNullable(THEMES.get(id.toLowerCase())); + } + + public static Optional getTheme(String id) { + return findById(id); + } + + public static PdfSealTheme getDefaultTheme() { + return THEMES.getOrDefault(DEFAULT_THEME_ID, THEMES.values().iterator().next()); + } + + public static List listThemes() { + List list = new ArrayList<>(THEMES.values()); + return Collections.unmodifiableList(list); + } + + public static CustomSealStorage getStorage() { + return STORAGE; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspErrorCode.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspErrorCode.java new file mode 100644 index 0000000..2313a58 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspErrorCode.java @@ -0,0 +1,35 @@ +package br.com.cybercore.signer.core.protocol; + +/** + * Códigos de erro padronizados do protocolo CyberCore Signing Protocol (CCSP/1). + */ +public enum CcspErrorCode { + CERTIFICATE_NOT_FOUND("Certificado especificado não foi encontrado."), + CERTIFICATE_EXPIRED("O certificado selecionado está expirado."), + CERTIFICATE_INVALID("O certificado é inválido ou está corrompido."), + PRIVATE_KEY_NOT_AVAILABLE("Chave privada inacessível ou não disponível para a operação."), + TOKEN_NOT_FOUND("Nenhum token ou leitor de smart card compatível foi encontrado."), + TOKEN_REMOVED("O token ou smart card foi desconectado durante a operação."), + PIN_REQUIRED("PIN obrigatório para desbloqueio do dispositivo."), + PIN_INCORRECT("PIN informado é incorreto."), + PKCS11_ERROR("Falha na comunicação com o módulo PKCS#11 do dispositivo."), + SIGNATURE_FAILED("Falha ao gerar a assinatura digital."), + APPLICATION_NOT_AUTHORIZED("Aplicação web não possui autorização para solicitar assinaturas."), + REQUEST_EXPIRED("A requisição de assinatura expirou."), + REPLAY_DETECTED("Requisição duplicada detectada (replay attack prevenido)."), + INVALID_ORIGIN("Origem da requisição não é permitida para esta aplicação."), + UNSUPPORTED_ALGORITHM("Algoritmo de assinatura não suportado."), + USER_CANCELLED("Operação cancelada pelo usuário."), + PROVIDER_NOT_AVAILABLE("O provedor criptográfico necessário não está disponível."), + COMMUNICATION_ERROR("Erro interno de comunicação no agente local."); + + private final String defaultMessage; + + CcspErrorCode(String defaultMessage) { + this.defaultMessage = defaultMessage; + } + + public String getDefaultMessage() { + return defaultMessage; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspOperation.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspOperation.java new file mode 100644 index 0000000..29da06f --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspOperation.java @@ -0,0 +1,39 @@ +package br.com.cybercore.signer.core.protocol; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Operações suportadas pelo protocolo CCSP. + */ +public enum CcspOperation { + SIGN("sign"), + AUTHENTICATE("authenticate"), + LIST_CERTIFICATES("list_certificates"), + STATUS("status"), + PING("ping"); + + private final String value; + + CcspOperation(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static CcspOperation fromValue(String value) { + if (value == null) { + return null; + } + for (CcspOperation op : values()) { + if (op.value.equalsIgnoreCase(value.trim())) { + return op; + } + } + throw new IllegalArgumentException("Operação CCSP desconhecida: " + value); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspRequest.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspRequest.java new file mode 100644 index 0000000..f744e37 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspRequest.java @@ -0,0 +1,96 @@ +package br.com.cybercore.signer.core.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.time.Instant; + +/** + * Mensagem de requisição do CyberCore Signing Protocol (CCSP/1). + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record CcspRequest( + @JsonProperty("version") + String version, + + @JsonProperty("request_id") + String requestId, + + @JsonProperty("application_id") + String applicationId, + + @JsonProperty("operation") + String operation, + + @JsonProperty("algorithm") + String algorithm, + + @JsonProperty("digest") + String digest, + + @JsonProperty("certificate_hint") + String certificateHint, + + @JsonProperty("expires_at") + String expiresAt, + + @JsonProperty("timestamp") + Long timestamp, + + @JsonProperty("nonce") + String nonce, + + @JsonProperty("signature_type") + String signatureType, + + @JsonProperty("document_name") + String documentName, + + @JsonProperty("document_type") + String documentType, + + @JsonProperty("purpose") + String purpose, + + @JsonProperty("challenge") + String challenge +) implements Serializable { + + public static final String PROTOCOL_VERSION = "1"; + + public CcspRequest( + String version, String requestId, String applicationId, String operation, + String algorithm, String digest, String certificateHint, String expiresAt, + Long timestamp, String nonce, String signatureType + ) { + this(version, requestId, applicationId, operation, algorithm, digest, + certificateHint, expiresAt, timestamp, nonce, signatureType, + null, null, null, null); + } + + public boolean isVersionSupported() { + return PROTOCOL_VERSION.equals(version); + } + + public boolean isAuthentication() { + return "authenticate".equalsIgnoreCase(operation) + || "AUTHENTICATION".equalsIgnoreCase(purpose) + || "AUTH_CHALLENGE".equalsIgnoreCase(documentType) + || (challenge != null && !challenge.isBlank()); + } + + public boolean isExpired() { + if (expiresAt == null || expiresAt.isBlank()) { + return false; + } + try { + Instant expiry = Instant.parse(expiresAt); + return Instant.now().isAfter(expiry); + } catch (Exception e) { + return false; + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspResponse.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspResponse.java new file mode 100644 index 0000000..26aeb88 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspResponse.java @@ -0,0 +1,136 @@ +package br.com.cybercore.signer.core.protocol; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +/** + * Mensagem de resposta do CyberCore Signing Protocol (CCSP/1). + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record CcspResponse( + @JsonProperty("version") + String version, + + @JsonProperty("request_id") + String requestId, + + @JsonProperty("status") + String status, + + @JsonProperty("signature") + String signature, + + @JsonProperty("certificate") + String certificate, + + @JsonProperty("certificate_chain") + List certificateChain, + + @JsonProperty("algorithm") + String algorithm, + + @JsonProperty("error_code") + String errorCode, + + @JsonProperty("message") + String message, + + @JsonProperty("subject_name") + String subjectName, + + @JsonProperty("subject_cpf_cnpj") + String subjectCpfCnpj, + + @JsonProperty("document_name") + String documentName, + + @JsonProperty("document_type") + String documentType +) implements Serializable { + + public CcspResponse { + certificateChain = certificateChain != null ? List.copyOf(certificateChain) : null; + } + + @Override + public List certificateChain() { + return certificateChain != null ? Collections.unmodifiableList(certificateChain) : null; + } + + public static final String STATUS_SIGNED = "signed"; + public static final String STATUS_AUTHENTICATED = "authenticated"; + public static final String STATUS_SUCCESS = "success"; + public static final String STATUS_ERROR = "error"; + + public static CcspResponse success(String requestId, String signatureBase64, String certBase64, + List chain, String algorithm) { + return success(requestId, signatureBase64, certBase64, chain, algorithm, null, null, null, null); + } + + public static CcspResponse success(String requestId, String signatureBase64, String certBase64, + List chain, String algorithm, + String subjectName, String subjectCpfCnpj, + String documentName, String documentType) { + return new CcspResponse( + CcspRequest.PROTOCOL_VERSION, + requestId, + STATUS_SIGNED, + signatureBase64, + certBase64, + chain, + algorithm, + null, + null, + subjectName, + subjectCpfCnpj, + documentName, + documentType + ); + } + + public static CcspResponse authenticated(String requestId, String signatureBase64, String certBase64, + List chain, String algorithm, + String subjectName, String subjectCpfCnpj) { + return new CcspResponse( + CcspRequest.PROTOCOL_VERSION, + requestId, + STATUS_AUTHENTICATED, + signatureBase64, + certBase64, + chain, + algorithm, + null, + null, + subjectName, + subjectCpfCnpj, + null, + "AUTH_CHALLENGE" + ); + } + + public static CcspResponse error(String requestId, CcspErrorCode errorCode, String message) { + return new CcspResponse( + CcspRequest.PROTOCOL_VERSION, + requestId, + STATUS_ERROR, + null, + null, + null, + null, + errorCode.name(), + message != null ? message : errorCode.getDefaultMessage(), + null, + null, + null, + null + ); + } + + public static CcspResponse error(String requestId, CcspErrorCode errorCode) { + return error(requestId, errorCode, errorCode.getDefaultMessage()); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspServer.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspServer.java new file mode 100644 index 0000000..6a760d2 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspServer.java @@ -0,0 +1,550 @@ +package br.com.cybercore.signer.core.protocol; + +import br.com.cybercore.signer.core.crypto.DigestAlgorithm; +import br.com.cybercore.signer.core.crypto.SigningService; +import br.com.cybercore.signer.core.crypto.X509CertificateUtils; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import java.security.MessageDigest; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.CertificateProviderException; +import br.com.cybercore.signer.core.security.ApplicationRegistry; +import br.com.cybercore.signer.core.security.AuthorizedApplication; +import br.com.cybercore.signer.core.security.NonceManager; +import br.com.cybercore.signer.core.security.OriginValidator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Servidor HTTP local do CyberCore Signing Protocol (CCSP/1). + * Vinculado exclusivamente ao loopback 127.0.0.1 para comunicação segura com navegadores web. + */ +public class CcspServer { + + private static final Logger logger = LoggerFactory.getLogger(CcspServer.class); + public static final int DEFAULT_PORT = 21325; + public static final String BIND_HOST = "127.0.0.1"; + + private final int port; + private final CertificateManager certificateManager; + private final SigningService signingService; + private final ApplicationRegistry applicationRegistry; + private final NonceManager nonceManager; + private final OriginValidator originValidator; + private final CcspUserPromptListener promptListener; + private final ObjectMapper objectMapper; + private final ReentrantLock signLock = new ReentrantLock(true); // Concorrência FIFO + + private HttpServer httpServer; + private ExecutorService executor; + private volatile boolean running = false; + + public CcspServer(int port, + CertificateManager certificateManager, + SigningService signingService, + ApplicationRegistry applicationRegistry, + CcspUserPromptListener promptListener) { + this.port = port; + this.certificateManager = Objects.requireNonNull(certificateManager, "certificateManager cannot be null"); + this.signingService = Objects.requireNonNull(signingService, "signingService cannot be null"); + this.applicationRegistry = applicationRegistry != null ? applicationRegistry : ApplicationRegistry.getInstance(); + this.promptListener = promptListener; + this.nonceManager = new NonceManager(); + this.originValidator = new OriginValidator(); + this.objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + public CcspServer(CertificateManager certificateManager, + SigningService signingService, + CcspUserPromptListener promptListener) { + this(DEFAULT_PORT, certificateManager, signingService, ApplicationRegistry.getInstance(), promptListener); + } + + /** + * Inicia o servidor local na porta configurada. + */ + public synchronized void start() throws IOException { + if (running) { + return; + } + + InetSocketAddress address = new InetSocketAddress(BIND_HOST, port); + httpServer = HttpServer.create(address, 50); + + executor = Executors.newFixedThreadPool(8, r -> { + Thread t = new Thread(r, "ccsp-http-worker"); + t.setDaemon(true); + return t; + }); + httpServer.setExecutor(executor); + + // Registra endpoints do protocolo CCSP/1 + httpServer.createContext("/ccsp/v1/status", new StatusHandler()); + httpServer.createContext("/ccsp/v1/certificates", new CertificatesHandler()); + httpServer.createContext("/ccsp/v1/sign", new SignHandler()); + httpServer.createContext("/ccsp/v1/authenticate", new SignHandler()); + httpServer.createContext("/ccsp/v1/authorize", new AuthorizeHandler()); + + httpServer.start(); + running = true; + logger.info("Servidor local CCSP/1 iniciado com sucesso em http://{}:{}", BIND_HOST, getPort()); + } + + /** + * Encerra a execução do servidor local. + */ + public synchronized void stop() { + if (!running) { + return; + } + + if (httpServer != null) { + httpServer.stop(1); + httpServer = null; + } + + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + + running = false; + logger.info("Servidor local CCSP/1 finalizado."); + } + + public boolean isRunning() { + return running; + } + + public int getPort() { + if (httpServer != null) { + return httpServer.getAddress().getPort(); + } + return port; + } + + // ========================================================================= + // Handlers HTTP + // ========================================================================= + + private class StatusHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if (handleCors(exchange)) return; + + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas GET é suportado."); + return; + } + + Map resp = new HashMap<>(); + resp.put("version", CcspRequest.PROTOCOL_VERSION); + resp.put("status", "ready"); + resp.put("agent", "CyberCore Signer"); + resp.put("agent_version", "0.1.0"); + resp.put("os", System.getProperty("os.name")); + resp.put("timestamp", Instant.now().toString()); + + sendJsonResponse(exchange, 200, resp); + } + } + + private class CertificatesHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if (handleCors(exchange)) return; + + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas GET é suportado."); + return; + } + + String origin = getOrigin(exchange); + String appId = exchange.getRequestHeaders().getFirst("X-CyberCore-AppId"); + if (appId == null || appId.isBlank()) { + URI uri = exchange.getRequestURI(); + appId = getQueryParam(uri, "application_id"); + } + + // Verifica autorização se app_id e origin forem informados + if (appId != null && !appId.isBlank()) { + boolean authorized = applicationRegistry.isAuthorized(appId, origin); + if (!authorized) { + boolean approved = promptApplicationAuthorization(origin, "Aplicação Web", appId); + if (!approved) { + sendErrorResponse(exchange, 403, CcspErrorCode.APPLICATION_NOT_AUTHORIZED.name(), + "Aplicação não autorizada pelo usuário."); + return; + } + } + } + + List certs = certificateManager.getAvailableCertificates(); + if (certs.isEmpty()) { + certs = certificateManager.discoverCertificates(); + } + + List> certDtoList = certs.stream().map(c -> { + Map dto = new HashMap<>(); + dto.put("id", c.id()); + dto.put("alias", c.alias()); + dto.put("common_name", c.commonName()); + dto.put("subject_dn", c.subjectDn()); + dto.put("issuer_dn", c.issuerDn()); + dto.put("type", c.type().name()); + dto.put("provider_id", c.providerId()); + dto.put("device_name", c.hardwareDeviceName()); + dto.put("valid_from", c.notBefore() != null ? c.notBefore().toString() : null); + dto.put("valid_to", c.notAfter() != null ? c.notAfter().toString() : null); + dto.put("serial_number", c.serialNumber()); + dto.put("can_sign", c.canSign()); + dto.put("status", c.status().name()); + return dto; + }).toList(); + + Map resp = new HashMap<>(); + resp.put("version", CcspRequest.PROTOCOL_VERSION); + resp.put("status", "success"); + resp.put("certificates", certDtoList); + + sendJsonResponse(exchange, 200, resp); + } + } + + private class AuthorizeHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if (handleCors(exchange)) return; + + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas POST é suportado."); + return; + } + + String origin = getOrigin(exchange); + try (InputStream is = exchange.getRequestBody()) { + @SuppressWarnings("unchecked") + Map body = objectMapper.readValue(is, Map.class); + String appId = body.get("application_id"); + String appName = body.getOrDefault("name", "Aplicação Web"); + + if (appId == null || appId.isBlank()) { + sendErrorResponse(exchange, 400, "BAD_REQUEST", "application_id é obrigatório."); + return; + } + + if (applicationRegistry.isAuthorized(appId, origin)) { + Map resp = Map.of( + "version", CcspRequest.PROTOCOL_VERSION, + "status", "authorized", + "application_id", appId + ); + sendJsonResponse(exchange, 200, resp); + return; + } + + boolean approved = promptApplicationAuthorization(origin, appName, appId); + if (approved) { + Map resp = Map.of( + "version", CcspRequest.PROTOCOL_VERSION, + "status", "authorized", + "application_id", appId + ); + sendJsonResponse(exchange, 200, resp); + } else { + sendErrorResponse(exchange, 403, CcspErrorCode.APPLICATION_NOT_AUTHORIZED.name(), + "Autorização negada pelo usuário."); + } + } catch (Exception e) { + sendErrorResponse(exchange, 400, "BAD_REQUEST", "Payload JSON inválido: " + e.getMessage()); + } + } + } + + private class SignHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if (handleCors(exchange)) return; + + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendErrorResponse(exchange, 405, "METHOD_NOT_ALLOWED", "Apenas POST é suportado."); + return; + } + + String origin = getOrigin(exchange); + CcspRequest request; + try (InputStream is = exchange.getRequestBody()) { + request = objectMapper.readValue(is, CcspRequest.class); + } catch (Exception e) { + sendCcspError(exchange, 400, null, CcspErrorCode.SIGNATURE_FAILED, "Payload de requisição inválido."); + return; + } + + // 1. Validação de versão + if (!request.isVersionSupported()) { + sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.COMMUNICATION_ERROR, + "Versão do protocolo não suportada: " + request.version()); + return; + } + + // 2. Validação de expiração + if (request.isExpired()) { + sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REQUEST_EXPIRED, + "A requisição de assinatura expirou."); + return; + } + + // 3. Validação anti-replay (Nonce) + if (request.nonce() != null && !request.nonce().isBlank()) { + boolean accepted = nonceManager.registerNonce(request.nonce()); + if (!accepted) { + sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.REPLAY_DETECTED, + "Requisição duplicada detectada (replay prevenido)."); + return; + } + } + + // 4. Validação de autorização da aplicação + String appId = request.applicationId(); + if (appId == null || appId.isBlank()) { + sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED, + "application_id é obrigatório."); + return; + } + + if (!applicationRegistry.isAuthorized(appId, origin)) { + boolean approved = promptApplicationAuthorization(origin, "Aplicação Web", appId); + if (!approved) { + sendCcspError(exchange, 403, request.requestId(), CcspErrorCode.APPLICATION_NOT_AUTHORIZED, + "Aplicação não autorizada pelo usuário."); + return; + } + } + + // 5. Validação de digest / challenge + byte[] digestBytes; + if (request.digest() != null && !request.digest().isBlank()) { + try { + digestBytes = Base64.getDecoder().decode(request.digest()); + } catch (Exception e) { + sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED, + "Digest em Base64 inválido."); + return; + } + } else if (request.challenge() != null && !request.challenge().isBlank()) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + digestBytes = md.digest(request.challenge().getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED, + "Falha ao gerar digest do desafio: " + e.getMessage()); + return; + } + } else { + sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.SIGNATURE_FAILED, + "É obrigatório informar 'digest' ou 'challenge' para a operação."); + return; + } + + // 6. Resolução do certificado + Optional certOpt = certificateManager.findByHint(request.certificateHint()); + if (certOpt.isEmpty()) { + sendCcspError(exchange, 404, request.requestId(), CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Nenhum certificado correspondente encontrado."); + return; + } + ManagedCertificate cert = certOpt.get(); + + // 7. Confirmação do usuário na UI (se listener configurado) + if (promptListener != null) { + try { + CompletableFuture approvalFuture = promptListener.requestSignatureConfirmation(request, cert); + Boolean confirmed = approvalFuture.get(60, TimeUnit.SECONDS); + if (!Boolean.TRUE.equals(confirmed)) { + sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED, + "Operação de assinatura cancelada pelo usuário."); + return; + } + } catch (Exception e) { + logger.warn("Falha ou timeout na confirmação visual de assinatura: {}", e.getMessage()); + sendCcspError(exchange, 400, request.requestId(), CcspErrorCode.USER_CANCELLED, + "Confirmação da assinatura expirou ou foi cancelada."); + return; + } + } + + // 8. Assinatura com controle sequencial FIFO + signLock.lock(); + try { + SigningContext ctx = new SigningContext( + request.requestId(), + request.applicationId(), + digestBytes, + request.algorithm() != null ? request.algorithm() : "SHA256withRSA", + cert.id(), + Instant.now() + ); + + SigningResult result = signingService.sign(ctx); + String sigBase64 = Base64.getEncoder().encodeToString(result.signatureBytes()); + + String subjectName = X509CertificateUtils.extractHolderName(cert.commonName()); + String cpfCnpj = cert.cpf() != null ? cert.cpf() : cert.cnpj(); + boolean isAuth = request.isAuthentication() || exchange.getRequestURI().getPath().endsWith("/authenticate"); + + CcspResponse response = isAuth + ? CcspResponse.authenticated( + request.requestId(), + sigBase64, + result.certificateBase64(), + result.certificateChainBase64(), + result.algorithm(), + subjectName, + cpfCnpj + ) + : CcspResponse.success( + request.requestId(), + sigBase64, + result.certificateBase64(), + result.certificateChainBase64(), + result.algorithm(), + subjectName, + cpfCnpj, + request.documentName(), + request.documentType() + ); + + sendJsonResponse(exchange, 200, response); + + } catch (CertificateProviderException cpe) { + sendCcspError(exchange, 400, request.requestId(), cpe.getErrorCode(), cpe.getMessage()); + } catch (Exception e) { + logger.error("Erro inesperado na assinatura: {}", e.getMessage(), e); + sendCcspError(exchange, 500, request.requestId(), CcspErrorCode.SIGNATURE_FAILED, + "Erro interno: " + e.getMessage()); + } finally { + signLock.unlock(); + } + } + } + + // ========================================================================= + // Utilitários de Segurança e HTTP + // ========================================================================= + + private boolean promptApplicationAuthorization(String origin, String appName, String appId) { + if (promptListener == null) { + // Em testes unitários sem UI, aprova e registra + applicationRegistry.register(new AuthorizedApplication( + appId, appName, origin != null ? Set.of(origin) : Set.of("*"), true, Instant.now() + )); + return true; + } + + try { + CompletableFuture future = promptListener.requestApplicationAuthorization(origin, appName, appId); + Boolean approved = future.get(30, TimeUnit.SECONDS); + if (Boolean.TRUE.equals(approved)) { + applicationRegistry.register(new AuthorizedApplication( + appId, appName, origin != null ? Set.of(origin) : Set.of("*"), true, Instant.now() + )); + return true; + } + } catch (Exception e) { + logger.warn("Timeout ou erro na autorização da aplicação: {}", e.getMessage()); + } + return false; + } + + private boolean handleCors(HttpExchange exchange) throws IOException { + String origin = getOrigin(exchange); + if (origin == null || origin.isBlank()) { + origin = "*"; + } + + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", origin); + exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CyberCore-AppId, X-CyberCore-Nonce"); + exchange.getResponseHeaders().set("Access-Control-Max-Age", "86400"); + + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(204, -1); + exchange.close(); + return true; + } + return false; + } + + private String getOrigin(HttpExchange exchange) { + String origin = exchange.getRequestHeaders().getFirst("Origin"); + if (origin == null || origin.isBlank()) { + origin = exchange.getRequestHeaders().getFirst("Referer"); + } + return origin; + } + + private String getQueryParam(URI uri, String key) { + if (uri.getQuery() == null) return null; + for (String param : uri.getQuery().split("&")) { + String[] pair = param.split("="); + if (pair.length == 2 && pair[0].equalsIgnoreCase(key)) { + return pair[1]; + } + } + return null; + } + + private void sendJsonResponse(HttpExchange exchange, int statusCode, Object data) throws IOException { + byte[] bytes = objectMapper.writeValueAsBytes(data); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + private void sendErrorResponse(HttpExchange exchange, int httpStatus, String code, String message) throws IOException { + Map err = Map.of( + "version", CcspRequest.PROTOCOL_VERSION, + "status", "error", + "error_code", code, + "message", message + ); + sendJsonResponse(exchange, httpStatus, err); + } + + private void sendCcspError(HttpExchange exchange, int httpStatus, String reqId, CcspErrorCode code, String msg) throws IOException { + CcspResponse err = CcspResponse.error(reqId, code, msg != null ? msg : code.getDefaultMessage()); + sendJsonResponse(exchange, httpStatus, err); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspUserPromptListener.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspUserPromptListener.java new file mode 100644 index 0000000..df692ee --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/protocol/CcspUserPromptListener.java @@ -0,0 +1,31 @@ +package br.com.cybercore.signer.core.protocol; + +import br.com.cybercore.signer.core.domain.ManagedCertificate; + +import java.util.concurrent.CompletableFuture; + +/** + * Interface de interação com o usuário para autorização de aplicações e confirmação de assinaturas. + * Permite desacoplar o servidor HTTP de comunicação da interface gráfica desktop (JavaFX). + */ +public interface CcspUserPromptListener { + + /** + * Solicita ao usuário autorização para que uma nova aplicação web se comunique com o agente. + * + * @param origin Origem HTTP/HTTPS da aplicação (ex: https://sistema.exemplo.com.br). + * @param appName Nome amigável da aplicação solicitante. + * @param applicationId ID único da aplicação. + * @return Future completado com {@code true} se o usuário autorizar; {@code false} caso recuse. + */ + CompletableFuture requestApplicationAuthorization(String origin, String appName, String applicationId); + + /** + * Solicita ao usuário confirmação visual explícita antes de realizar uma assinatura digital. + * + * @param request Requisição CCSP de assinatura recebida. + * @param certificate Certificado digital selecionado para a operação. + * @return Future completado com {@code true} se o usuário confirmar; {@code false} caso cancele. + */ + CompletableFuture requestSignatureConfirmation(CcspRequest request, ManagedCertificate certificate); +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/CertificateManager.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/CertificateManager.java new file mode 100644 index 0000000..707fad0 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/CertificateManager.java @@ -0,0 +1,194 @@ +package br.com.cybercore.signer.core.provider; + +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Gerenciador central de certificados digitais do CyberCore Signer. + * Responsável pela descoberta em múltiplos provedores (Windows Certificate Store, A1, PKCS#11), + * validação de integridade, mapeamento de provedores e seleção segura para operações de assinatura. + */ +public class CertificateManager { + + private static final Logger logger = LoggerFactory.getLogger(CertificateManager.class); + + private final List providers = new CopyOnWriteArrayList<>(); + private final Map certificateToProviderMap = new ConcurrentHashMap<>(); + private final Map cachedCertificates = new ConcurrentHashMap<>(); + + public CertificateManager() { + } + + /** + * Cria e inicializa uma instância padrão do CertificateManager com os provedores nativos da plataforma. + */ + public static CertificateManager createDefault() { + CertificateManager manager = new CertificateManager(); + manager.registerProvider(new WindowsCertificateStoreProvider()); + manager.registerProvider(new Pkcs12CertificateProvider()); + manager.registerProvider(new Pkcs11CertificateProvider()); + return manager; + } + + /** + * Registra um provedor de certificados no ecossistema do agente. + */ + public void registerProvider(CertificateProvider provider) { + Objects.requireNonNull(provider, "Provedor não pode ser nulo."); + // Evita duplicidade por providerId + providers.removeIf(p -> p.getProviderId().equalsIgnoreCase(provider.getProviderId())); + providers.add(provider); + logger.info("Provedor registrado no CertificateManager: {} [{}] (disponível={})", + provider.getDisplayName(), provider.getProviderId(), provider.isAvailable()); + } + + /** + * Remove um provedor previamente registrado. + */ + public void unregisterProvider(String providerId) { + if (providerId != null) { + providers.removeIf(p -> p.getProviderId().equalsIgnoreCase(providerId)); + certificateToProviderMap.values().removeIf(p -> p.getProviderId().equalsIgnoreCase(providerId)); + } + } + + public List getProviders() { + return Collections.unmodifiableList(providers); + } + + /** + * Executa a descoberta de certificados em todos os provedores operacionais. + * Consolida os resultados, atualiza o mapeamento de provedores e ordena por relevância/disponibilidade. + */ + public synchronized List discoverCertificates() { + logger.info("Iniciando descoberta de certificados digitais nos provedores ativos..."); + + certificateToProviderMap.clear(); + cachedCertificates.clear(); + List discovered = new ArrayList<>(); + + for (CertificateProvider provider : providers) { + if (!provider.isAvailable()) { + logger.debug("Provedor '{}' não disponível no momento. Ignorando na descoberta.", provider.getProviderId()); + continue; + } + + try { + List certs = provider.listCertificates(); + logger.info("Provedor '{}' retornou {} certificado(s).", provider.getProviderId(), certs.size()); + + for (ManagedCertificate cert : certs) { + certificateToProviderMap.put(cert.id(), provider); + cachedCertificates.put(cert.id(), cert); + discovered.add(cert); + } + } catch (Exception e) { + logger.error("Erro ao listar certificados do provedor '{}': {}", provider.getProviderId(), e.getMessage()); + } + } + + // Ordenação: certificados disponíveis primeiro, depois por nome do titular + discovered.sort(Comparator + .comparing((ManagedCertificate c) -> c.status() == CertificateStatus.AVAILABLE ? 0 : 1) + .thenComparing(ManagedCertificate::commonName, String.CASE_INSENSITIVE_ORDER)); + + logger.info("Descoberta concluída: {} certificado(s) total no agente.", discovered.size()); + return Collections.unmodifiableList(discovered); + } + + /** + * Retorna o provedor responsável pelo certificado com o ID informado. + */ + public CertificateProvider getProviderForCertificate(String certId) { + if (certId == null) { + return null; + } + return certificateToProviderMap.get(certId); + } + + /** + * Obtém o material criptográfico (chave privada e cadeia X.509) do certificado especificado. + */ + public Optional getKeyMaterial(String certId, char[] pinOrPassword) throws CertificateProviderException { + CertificateProvider provider = getProviderForCertificate(certId); + if (provider == null) { + return Optional.empty(); + } + return provider.getKeyMaterial(certId, pinOrPassword); + } + + /** + * Busca um certificado previamente descoberto pelo seu identificador único. + */ + public Optional findById(String certId) { + if (certId == null) { + return Optional.empty(); + } + return Optional.ofNullable(cachedCertificates.get(certId)); + } + + /** + * Localiza um certificado usando hints flexíveis: id, alias, CPF, CNPJ ou impressões digitais SHA-1/SHA-256. + */ + public Optional findByHint(String hint) { + if (hint == null || hint.isBlank()) { + return Optional.empty(); + } + String cleanHint = hint.trim(); + + // 1. Busca por ID direto + ManagedCertificate direct = cachedCertificates.get(cleanHint); + if (direct != null) { + return Optional.of(direct); + } + + // 2. Busca por alias, CPF, CNPJ ou fingerprints + for (ManagedCertificate cert : cachedCertificates.values()) { + if (cleanHint.equalsIgnoreCase(cert.alias()) + || cleanHint.equalsIgnoreCase(cert.cpf()) + || cleanHint.equalsIgnoreCase(cert.cnpj()) + || cleanHint.equalsIgnoreCase(cert.sha1Fingerprint()) + || cleanHint.equalsIgnoreCase(cert.sha256Fingerprint()) + || cleanHint.equalsIgnoreCase(cert.serialNumber())) { + return Optional.of(cert); + } + } + + return Optional.empty(); + } + + /** + * Retorna a lista dos certificados em cache que possuem status AVAILABLE (com chave privada e vigentes). + */ + public List getAvailableCertificates() { + return cachedCertificates.values().stream() + .filter(c -> c.status() == CertificateStatus.AVAILABLE) + .toList(); + } + + /** + * Solicita recarregamento de todos os provedores cadastrados. + */ + public void refreshAll() { + for (CertificateProvider provider : providers) { + try { + provider.refresh(); + } catch (Exception e) { + logger.warn("Erro ao recarregar provedor '{}': {}", provider.getProviderId(), e.getMessage()); + } + } + discoverCertificates(); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/CertificateProvider.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/CertificateProvider.java new file mode 100644 index 0000000..d790db4 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/CertificateProvider.java @@ -0,0 +1,61 @@ +package br.com.cybercore.signer.core.provider; + +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; + +import java.util.List; + +/** + * Contrato base para provedores de descoberta e assinatura com certificados digitais. + * Permite plugar diferentes backends (A1 em arquivo, Windows-MY, PKCS#11, Mock) + * sem acoplar o núcleo do aplicativo a fabricantes ou dispositivos específicos. + */ +public interface CertificateProvider { + + /** + * Identificador único do provedor (ex: "provider-a1", "provider-win-store", "provider-pkcs11"). + */ + String getProviderId(); + + /** + * Nome legível para apresentação na interface e diagnósticos. + */ + String getDisplayName(); + + /** + * Tipo do provedor. + */ + ProviderType getType(); + + /** + * Indica se o provedor está disponível e operacional no sistema atual. + */ + boolean isAvailable(); + + /** + * Lista os certificados disponíveis atualmente através deste provedor. + */ + List listCertificates() throws CertificateProviderException; + + /** + * Executa a assinatura do digest contido no contexto. + * A chave privada NUNCA deve sair do provedor/dispositivo. + */ + SigningResult sign(SigningContext context) throws CertificateProviderException; + + /** + * Retorna o material criptográfico (chave privada e cadeia) do certificado indicado, + * permitindo que operações que montam envelopes como PAdES ou XMLDSig operem localmente. + */ + default java.util.Optional getKeyMaterial(String certificateId, char[] pinOrPassword) throws CertificateProviderException { + return java.util.Optional.empty(); + } + + /** + * Solicita recarga ou reescaneamento de certificados e dispositivos. + */ + default void refresh() { + // Implementação padrão opcional + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/CertificateProviderException.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/CertificateProviderException.java new file mode 100644 index 0000000..b544251 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/CertificateProviderException.java @@ -0,0 +1,32 @@ +package br.com.cybercore.signer.core.provider; + +import br.com.cybercore.signer.core.protocol.CcspErrorCode; + +/** + * Exceção disparada por falhas nos provedores de certificado. + * Carrega obrigatoriamente um código de erro padronizado do CCSP. + */ +public class CertificateProviderException extends Exception { + + private static final long serialVersionUID = 1L; + + private final CcspErrorCode errorCode; + + public CertificateProviderException(CcspErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode != null ? errorCode : CcspErrorCode.SIGNATURE_FAILED; + } + + public CertificateProviderException(CcspErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode != null ? errorCode : CcspErrorCode.SIGNATURE_FAILED; + } + + public CertificateProviderException(CcspErrorCode errorCode) { + this(errorCode, errorCode != null ? errorCode.getDefaultMessage() : CcspErrorCode.SIGNATURE_FAILED.getDefaultMessage()); + } + + public CcspErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/Pkcs11CertificateProvider.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/Pkcs11CertificateProvider.java new file mode 100644 index 0000000..22ca67b --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/Pkcs11CertificateProvider.java @@ -0,0 +1,247 @@ +package br.com.cybercore.signer.core.provider; + +import br.com.cybercore.signer.core.crypto.DigestAlgorithm; +import br.com.cybercore.signer.core.crypto.X509CertificateUtils; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.Security; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Objects; + +/** + * Provedor de certificados A3 via interface padrão PKCS#11 (tokens USB, smart cards e leitores). + * Utiliza o provider SunPKCS11 integrado à JVM para comunicação com middlewares dos fabricantes. + * Nunca persiste PIN em disco e nunca o transmite para servidores web. + */ +public class Pkcs11CertificateProvider implements CertificateProvider { + + private static final Logger logger = LoggerFactory.getLogger(Pkcs11CertificateProvider.class); + private static final String PROVIDER_ID = "provider-pkcs11"; + + /** + * Bibliotecas PKCS#11 comuns de middleware para tokens e smartcards no Windows e Linux. + */ + private static final String[] WELL_KNOWN_LIBRARIES = { + // Windows + "C:/Windows/System32/eps2003csp11.dll", // SafeNet / eToken + "C:/Windows/System32/aetpkss1.dll", // SafeSign AET + "C:/Windows/System32/acospkcs11.dll", // ACO / Giesecke & Devrient + "C:/Windows/System32/opensc-pkcs11.dll",// OpenSC Windows + // Linux + "/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so", + "/usr/lib/opensc-pkcs11.so", + "/usr/lib/libaetpkss.so" + }; + + private Provider sunPkcs11Provider; + private KeyStore keyStore; + private String detectedLibraryPath; + + public Pkcs11CertificateProvider() { + detectAndInitialize(); + } + + private synchronized void detectAndInitialize() { + for (String libPath : WELL_KNOWN_LIBRARIES) { + File f = new File(libPath); + if (f.exists() && f.canRead()) { + detectedLibraryPath = libPath; + logger.info("Middleware PKCS#11 detectado no sistema: {}", libPath); + break; + } + } + } + + /** + * Permite registrar explicitamente um provider PKCS#11 já inicializado (ex: via SunPKCS11 com PIN do usuário). + */ + public synchronized void setProvider(Provider provider, KeyStore keyStore) { + this.sunPkcs11Provider = provider; + this.keyStore = keyStore; + } + + @Override + public String getProviderId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayName() { + return "Hardware PKCS#11 (Smart Card / Token USB)"; + } + + @Override + public ProviderType getType() { + return ProviderType.A3_PKCS11; + } + + @Override + public synchronized boolean isAvailable() { + return (detectedLibraryPath != null) || (keyStore != null); + } + + public synchronized String getDetectedLibraryPath() { + return detectedLibraryPath; + } + + @Override + public synchronized List listCertificates() throws CertificateProviderException { + if (keyStore == null) { + // Se o middleware estiver detectado mas o token ainda não estiver autenticado via PIN + return Collections.emptyList(); + } + + try { + List certificates = new ArrayList<>(); + Enumeration aliases = keyStore.aliases(); + + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + boolean isKeyEntry = keyStore.isKeyEntry(alias); + Certificate cert = keyStore.getCertificate(alias); + + if (!(cert instanceof X509Certificate x509)) { + continue; + } + + String certId = "p11-" + Integer.toHexString(alias.hashCode()); + ManagedCertificate mc = X509CertificateUtils.toManagedCertificate( + certId, + alias, + x509, + CertificateType.A3, + isKeyEntry, + PROVIDER_ID, + "Token USB / Smart Card (PKCS#11)" + ); + certificates.add(mc); + } + + return Collections.unmodifiableList(certificates); + } catch (KeyStoreException e) { + throw new CertificateProviderException( + CcspErrorCode.PKCS11_ERROR, + "Falha ao ler certificados do dispositivo PKCS#11: " + e.getMessage(), + e + ); + } + } + + @Override + public synchronized SigningResult sign(SigningContext context) throws CertificateProviderException { + Objects.requireNonNull(context, "Contexto de assinatura não pode ser nulo."); + + if (keyStore == null) { + throw new CertificateProviderException( + CcspErrorCode.TOKEN_NOT_FOUND, + "Dispositivo PKCS#11 não conectado ou não autenticado com PIN." + ); + } + + DigestAlgorithm alg = DigestAlgorithm.fromName(context.algorithm()); + alg.validateDigestLength(context.digest()); + + String targetAlias = resolveAlias(context.certificateHint()); + if (targetAlias == null) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Certificado não encontrado no dispositivo PKCS#11." + ); + } + + try { + PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, null); + if (privateKey == null) { + throw new CertificateProviderException( + CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE, + "Chave privada inacessível no dispositivo PKCS#11." + ); + } + + Certificate[] chain = keyStore.getCertificateChain(targetAlias); + if (chain == null || chain.length == 0) { + Certificate singleCert = keyStore.getCertificate(targetAlias); + if (singleCert != null) { + chain = new Certificate[]{singleCert}; + } else { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Cadeia do certificado não encontrada no dispositivo PKCS#11." + ); + } + } + + logger.info("Executando assinatura digital no hardware PKCS#11 [alg={}]", alg.getJcaName()); + + Signature signature = (sunPkcs11Provider != null) + ? Signature.getInstance(alg.getJcaName(), sunPkcs11Provider) + : Signature.getInstance(alg.getJcaName()); + + signature.initSign(privateKey); + signature.update(context.digest()); + byte[] signatureBytes = signature.sign(); + + String certBase64 = Base64.getEncoder().encodeToString(chain[0].getEncoded()); + List chainBase64 = new ArrayList<>(); + for (Certificate c : chain) { + chainBase64.add(Base64.getEncoder().encodeToString(c.getEncoded())); + } + + return new SigningResult( + context.requestId(), + signatureBytes, + certBase64, + chainBase64, + alg.getJcaName(), + Instant.now() + ); + + } catch (CertificateProviderException cpe) { + throw cpe; + } catch (Exception e) { + logger.error("Erro na assinatura via dispositivo PKCS#11: {}", e.getMessage(), e); + throw new CertificateProviderException( + CcspErrorCode.PKCS11_ERROR, + "Erro ao assinar com o dispositivo PKCS#11: " + e.getMessage(), + e + ); + } + } + + private String resolveAlias(String hint) { + try { + Enumeration aliases = keyStore.aliases(); + List list = Collections.list(aliases); + if (hint == null || hint.isBlank()) { + return list.isEmpty() ? null : list.get(0); + } + for (String alias : list) { + String certId = "p11-" + Integer.toHexString(alias.hashCode()); + if (alias.equalsIgnoreCase(hint) || certId.equalsIgnoreCase(hint)) { + return alias; + } + } + } catch (Exception ignored) { + } + return null; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/Pkcs12CertificateProvider.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/Pkcs12CertificateProvider.java new file mode 100644 index 0000000..ee6ef23 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/Pkcs12CertificateProvider.java @@ -0,0 +1,318 @@ +package br.com.cybercore.signer.core.provider; + +import br.com.cybercore.signer.core.crypto.DigestAlgorithm; +import br.com.cybercore.signer.core.crypto.X509CertificateUtils; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Objects; + +/** + * Provedor de certificados A1 em formato PKCS#12 (.pfx / .p12). + * Mantém chaves e senhas isoladas na memória local do agente sem persistência insegura ou vazamento em logs. + */ +public class Pkcs12CertificateProvider implements CertificateProvider { + + private static final Logger logger = LoggerFactory.getLogger(Pkcs12CertificateProvider.class); + private static final String PROVIDER_ID = "provider-a1"; + + private final String providerId; + private final String displayName; + private KeyStore keyStore; + private char[] currentPassword; + + public Pkcs12CertificateProvider() { + this(PROVIDER_ID, "Certificados A1 (PKCS#12)"); + } + + public Pkcs12CertificateProvider(String providerId, String displayName) { + this.providerId = Objects.requireNonNull(providerId); + this.displayName = Objects.requireNonNull(displayName); + } + + /** + * Carrega um KeyStore PKCS#12 diretamente a partir de bytes. + * A senha é utilizada exclusivamente para decifrar a chave em memória e nunca é gravada em logs. + */ + public synchronized void loadFromBytes(byte[] pfxData, char[] password) throws CertificateProviderException { + Objects.requireNonNull(pfxData, "Dados do certificado não podem ser nulos."); + try (ByteArrayInputStream bais = new ByteArrayInputStream(pfxData)) { + loadFromStream(bais, password); + } catch (CertificateProviderException cpe) { + throw cpe; + } catch (Exception e) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_INVALID, + "Falha ao processar arquivo PKCS#12: " + e.getMessage(), + e + ); + } + } + + /** + * Carrega um KeyStore PKCS#12 a partir de um InputStream. + */ + public synchronized void loadFromStream(InputStream stream, char[] password) throws CertificateProviderException { + try { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(stream, password); + this.keyStore = ks; + this.currentPassword = password != null ? password.clone() : new char[0]; + logger.info("KeyStore PKCS#12 carregado com sucesso no provedor '{}'", providerId); + } catch (Exception e) { + String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : ""; + if (msg.contains("password") || msg.contains("keystore password was incorrect") || msg.contains("mac")) { + throw new CertificateProviderException( + CcspErrorCode.PIN_INCORRECT, + "Senha do certificado A1 incorreta.", + e + ); + } + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_INVALID, + "Não foi possível carregar o arquivo PKCS#12: " + e.getMessage(), + e + ); + } + } + + /** + * Define diretamente uma instância de KeyStore PKCS#12 pré-carregada (útil em testes ou integrações). + */ + public synchronized void setKeyStore(KeyStore keyStore, char[] password) { + this.keyStore = keyStore; + this.currentPassword = password != null ? password.clone() : new char[0]; + } + + public synchronized void clear() { + this.keyStore = null; + if (this.currentPassword != null) { + java.util.Arrays.fill(this.currentPassword, ' '); + this.currentPassword = null; + } + } + + @Override + public String getProviderId() { + return providerId; + } + + @Override + public String getDisplayName() { + return displayName; + } + + @Override + public ProviderType getType() { + return ProviderType.A1_FILE; + } + + @Override + public synchronized boolean isAvailable() { + return keyStore != null; + } + + @Override + public synchronized List listCertificates() throws CertificateProviderException { + if (!isAvailable()) { + return Collections.emptyList(); + } + + try { + List certificates = new ArrayList<>(); + Enumeration aliases = keyStore.aliases(); + + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + boolean isKeyEntry = keyStore.isKeyEntry(alias); + Certificate cert = keyStore.getCertificate(alias); + + if (!(cert instanceof X509Certificate x509)) { + continue; + } + + String certId = "a1-" + Integer.toHexString(alias.hashCode()); + ManagedCertificate mc = X509CertificateUtils.toManagedCertificate( + certId, + alias, + x509, + CertificateType.A1, + isKeyEntry, + providerId, + "Arquivo PKCS#12 (.pfx/.p12)" + ); + certificates.add(mc); + } + + return Collections.unmodifiableList(certificates); + } catch (KeyStoreException e) { + throw new CertificateProviderException( + CcspErrorCode.PROVIDER_NOT_AVAILABLE, + "Erro ao consultar certificados do KeyStore PKCS#12: " + e.getMessage(), + e + ); + } + } + + @Override + public synchronized SigningResult sign(SigningContext context) throws CertificateProviderException { + Objects.requireNonNull(context, "Contexto de assinatura não pode ser nulo."); + + if (!isAvailable()) { + throw new CertificateProviderException( + CcspErrorCode.PROVIDER_NOT_AVAILABLE, + "Nenhum certificado A1 carregado neste provedor." + ); + } + + DigestAlgorithm alg = DigestAlgorithm.fromName(context.algorithm()); + alg.validateDigestLength(context.digest()); + + String targetAlias = resolveAlias(context.certificateHint()); + if (targetAlias == null) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Certificado A1 não encontrado para a requisição: " + context.certificateHint() + ); + } + + try { + PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, currentPassword); + if (privateKey == null) { + throw new CertificateProviderException( + CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE, + "Chave privada não encontrada para o certificado: " + targetAlias + ); + } + + Certificate[] chain = keyStore.getCertificateChain(targetAlias); + if (chain == null || chain.length == 0) { + Certificate singleCert = keyStore.getCertificate(targetAlias); + if (singleCert != null) { + chain = new Certificate[]{singleCert}; + } else { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Cadeia de certificados ausente para o alias: " + targetAlias + ); + } + } + + logger.info("Executando assinatura com certificado A1 [alg={}]", alg.getJcaName()); + + Signature signature = Signature.getInstance(alg.getJcaName()); + signature.initSign(privateKey); + signature.update(context.digest()); + byte[] signatureBytes = signature.sign(); + + String certBase64 = Base64.getEncoder().encodeToString(chain[0].getEncoded()); + List chainBase64 = new ArrayList<>(); + for (Certificate c : chain) { + chainBase64.add(Base64.getEncoder().encodeToString(c.getEncoded())); + } + + return new SigningResult( + context.requestId(), + signatureBytes, + certBase64, + chainBase64, + alg.getJcaName(), + Instant.now() + ); + + } catch (CertificateProviderException cpe) { + throw cpe; + } catch (Exception e) { + logger.error("Erro ao realizar assinatura com certificado A1: {}", e.getMessage(), e); + throw new CertificateProviderException( + CcspErrorCode.SIGNATURE_FAILED, + "Erro ao assinar com certificado A1: " + e.getMessage(), + e + ); + } + } + + @Override + public synchronized java.util.Optional getKeyMaterial(String certificateId, char[] pinOrPassword) throws CertificateProviderException { + if (!isAvailable()) { + return java.util.Optional.empty(); + } + String targetAlias = resolveAlias(certificateId); + if (targetAlias == null) { + return java.util.Optional.empty(); + } + char[] passToUse = (currentPassword != null && currentPassword.length > 0) ? currentPassword : pinOrPassword; + try { + PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, passToUse); + if (privateKey == null) { + return java.util.Optional.empty(); + } + Certificate[] chain = keyStore.getCertificateChain(targetAlias); + if (chain == null || chain.length == 0) { + Certificate single = keyStore.getCertificate(targetAlias); + if (single != null) { + chain = new Certificate[]{single}; + } + } + if (chain == null || chain.length == 0) { + return java.util.Optional.empty(); + } + X509Certificate[] x509Chain = new X509Certificate[chain.length]; + for (int i = 0; i < chain.length; i++) { + x509Chain[i] = (X509Certificate) chain[i]; + } + return java.util.Optional.of(new br.com.cybercore.signer.core.domain.SigningKeyMaterial(privateKey, x509Chain, null)); + } catch (Exception e) { + throw new CertificateProviderException( + CcspErrorCode.SIGNATURE_FAILED, + "Falha ao obter chave privada e certificados A1: " + e.getMessage(), + e + ); + } + } + + private String resolveAlias(String hint) { + try { + Enumeration aliases = keyStore.aliases(); + List list = Collections.list(aliases); + if (hint == null || hint.isBlank()) { + return list.isEmpty() ? null : list.get(0); + } + for (String alias : list) { + String certId = "a1-" + Integer.toHexString(alias.hashCode()); + if (alias.equalsIgnoreCase(hint) || certId.equalsIgnoreCase(hint)) { + return alias; + } + Certificate c = keyStore.getCertificate(alias); + if (c instanceof X509Certificate x509) { + String sha1 = X509CertificateUtils.calculateFingerprint(x509, "SHA-1"); + String sha256 = X509CertificateUtils.calculateFingerprint(x509, "SHA-256"); + if (sha1.equalsIgnoreCase(hint) || sha256.equalsIgnoreCase(hint)) { + return alias; + } + } + } + } catch (Exception ignored) { + } + return null; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/ProviderType.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/ProviderType.java new file mode 100644 index 0000000..b8976c4 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/ProviderType.java @@ -0,0 +1,21 @@ +package br.com.cybercore.signer.core.provider; + +/** + * Classificação da tecnologia ou repositório do provedor de certificados. + */ +public enum ProviderType { + A1_FILE("Arquivo PKCS#12 (.pfx/.p12)"), + A3_WINDOWS_STORE("Windows Certificate Store (CAPI/CNG)"), + A3_PKCS11("Hardware PKCS#11 (Smart Card / Token USB)"), + MOCK("Simulador de Desenvolvimento"); + + private final String description; + + ProviderType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/WindowsCertificateStoreProvider.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/WindowsCertificateStoreProvider.java new file mode 100644 index 0000000..9fd01cf --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/provider/WindowsCertificateStoreProvider.java @@ -0,0 +1,307 @@ +package br.com.cybercore.signer.core.provider; + +import br.com.cybercore.signer.core.crypto.DigestAlgorithm; +import br.com.cybercore.signer.core.crypto.X509CertificateUtils; +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Objects; + +/** + * Provedor de certificados integrado nativamente ao repositório de certificados do Windows (Windows-MY). + * Utiliza o subsistema SunMSCAPI/CAPI/CNG para gerenciar chaves privadas e certificados A1/A3 sem expor credenciais. + */ +public class WindowsCertificateStoreProvider implements CertificateProvider { + + private static final Logger logger = LoggerFactory.getLogger(WindowsCertificateStoreProvider.class); + private static final String PROVIDER_ID = "provider-win-store"; + private static final String KEYSTORE_TYPE = "Windows-MY"; + + private KeyStore keyStore; + private final boolean isWindows; + + public WindowsCertificateStoreProvider() { + String osName = System.getProperty("os.name", "").toLowerCase(); + this.isWindows = osName.contains("win"); + if (this.isWindows) { + initKeyStore(); + } + } + + private synchronized void initKeyStore() { + try { + this.keyStore = KeyStore.getInstance(KEYSTORE_TYPE); + this.keyStore.load(null, null); + logger.info("Windows Certificate Store (Windows-MY) inicializado com sucesso."); + } catch (Throwable t) { + logger.warn("Não foi possível carregar o repositório Windows-MY: {}", t.getMessage()); + this.keyStore = null; + } + } + + @Override + public String getProviderId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayName() { + return "Windows Certificate Store (Windows-MY)"; + } + + @Override + public ProviderType getType() { + return ProviderType.A3_WINDOWS_STORE; + } + + @Override + public boolean isAvailable() { + return isWindows && keyStore != null; + } + + @Override + public synchronized List listCertificates() throws CertificateProviderException { + if (!isAvailable()) { + return Collections.emptyList(); + } + + try { + // Recarrega o repositório para detectar tokens recém-inseridos + this.keyStore.load(null, null); + + List certificates = new ArrayList<>(); + Enumeration aliases = keyStore.aliases(); + + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + try { + if (!keyStore.isKeyEntry(alias)) { + continue; + } + + Certificate cert = keyStore.getCertificate(alias); + if (!(cert instanceof X509Certificate x509)) { + continue; + } + + // Heurística de tipo A1 vs A3: + // ICP-Brasil A3 tem tipicamente 3 anos (validade > ~400 dias). + // A1 tem validade de até 1 ano (<= 370 dias). + long validityDays = ChronoUnit.DAYS.between(x509.getNotBefore().toInstant(), x509.getNotAfter().toInstant()); + CertificateType certType = validityDays > 400 ? CertificateType.A3 : CertificateType.A1; + String deviceName = certType == CertificateType.A3 + ? "Token / Smart Card (Windows CAPI/CNG)" + : "Repositório do Windows (Software A1)"; + + String certId = "win-" + Integer.toHexString(alias.hashCode()); + + ManagedCertificate managedCert = X509CertificateUtils.toManagedCertificate( + certId, + alias, + x509, + certType, + true, + PROVIDER_ID, + deviceName + ); + + certificates.add(managedCert); + } catch (Exception e) { + logger.warn("Ignorando alias '{}' devido a erro na leitura: {}", alias, e.getMessage()); + } + } + + return Collections.unmodifiableList(certificates); + } catch (Exception e) { + throw new CertificateProviderException( + CcspErrorCode.PROVIDER_NOT_AVAILABLE, + "Falha ao enumerar certificados do Windows Certificate Store: " + e.getMessage(), + e + ); + } + } + + @Override + public synchronized SigningResult sign(SigningContext context) throws CertificateProviderException { + Objects.requireNonNull(context, "Contexto de assinatura não pode ser nulo."); + + if (!isAvailable()) { + throw new CertificateProviderException( + CcspErrorCode.PROVIDER_NOT_AVAILABLE, + "Repositório Windows Certificate Store não está disponível neste sistema operacional." + ); + } + + // Valida algoritmo e tamanho de digest + DigestAlgorithm alg = DigestAlgorithm.fromName(context.algorithm()); + alg.validateDigestLength(context.digest()); + + String targetAlias = resolveAlias(context.certificateHint()); + if (targetAlias == null) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Nenhum certificado correspondente encontrado no Windows Certificate Store para o hint: " + context.certificateHint() + ); + } + + try { + PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, null); + if (privateKey == null) { + throw new CertificateProviderException( + CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE, + "Chave privada não acessível para o certificado: " + targetAlias + ); + } + + Certificate[] chain = keyStore.getCertificateChain(targetAlias); + if (chain == null || chain.length == 0) { + Certificate singleCert = keyStore.getCertificate(targetAlias); + if (singleCert != null) { + chain = new Certificate[]{singleCert}; + } else { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Cadeia de certificação não encontrada para o alias: " + targetAlias + ); + } + } + + logger.info("Executando assinatura digital com algoritmo {} via Windows-MY para alias '{}'", + alg.getJcaName(), targetAlias); + + Signature signature = Signature.getInstance(alg.getJcaName()); + signature.initSign(privateKey); + signature.update(context.digest()); + byte[] signatureBytes = signature.sign(); + + String certBase64 = Base64.getEncoder().encodeToString(chain[0].getEncoded()); + List chainBase64 = new ArrayList<>(); + for (Certificate c : chain) { + chainBase64.add(Base64.getEncoder().encodeToString(c.getEncoded())); + } + + return new SigningResult( + context.requestId(), + signatureBytes, + certBase64, + chainBase64, + alg.getJcaName(), + Instant.now() + ); + + } catch (CertificateProviderException cpe) { + throw cpe; + } catch (Exception e) { + logger.error("Erro durante a assinatura via Windows-MY: {}", e.getMessage(), e); + throw new CertificateProviderException( + CcspErrorCode.SIGNATURE_FAILED, + "Erro ao assinar com o Windows Certificate Store: " + e.getMessage(), + e + ); + } finally { + try { + // Força liberação de handles no SunMSCAPI para que o driver de SmartCard/Token + // possa reavaliar o estado de autenticação de PIN + if (keyStore != null) { + keyStore.load(null, null); + } + } catch (Exception ignored) { + } + + // Se configurado para sempre exigir PIN (sem cache), forçamos o reset da sessão no Smart Card + // para que o Windows BaseCSP e o SafeSign invalidem o cache de processo e peçam o PIN na próxima assinatura + if (br.com.cybercore.signer.core.security.PinSessionManager.getInstance().isAlwaysPromptPin()) { + br.com.cybercore.signer.core.security.SmartCardSessionManager.resetCardSessions(); + } + } + } + + private String resolveAlias(String hint) { + if (hint == null || hint.isBlank()) { + return null; + } + try { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + String certId = "win-" + Integer.toHexString(alias.hashCode()); + if (alias.equalsIgnoreCase(hint) || certId.equalsIgnoreCase(hint)) { + return alias; + } + Certificate c = keyStore.getCertificate(alias); + if (c instanceof X509Certificate x509) { + String sha1 = X509CertificateUtils.calculateFingerprint(x509, "SHA-1"); + String sha256 = X509CertificateUtils.calculateFingerprint(x509, "SHA-256"); + if (sha1.equalsIgnoreCase(hint) || sha256.equalsIgnoreCase(hint)) { + return alias; + } + } + } + } catch (Exception ignored) { + } + return null; + } + + @Override + public synchronized java.util.Optional getKeyMaterial(String certificateId, char[] pinOrPassword) throws CertificateProviderException { + if (!isAvailable()) { + return java.util.Optional.empty(); + } + String targetAlias = resolveAlias(certificateId); + if (targetAlias == null) { + return java.util.Optional.empty(); + } + try { + PrivateKey privateKey = (PrivateKey) keyStore.getKey(targetAlias, null); + if (privateKey == null) { + return java.util.Optional.empty(); + } + Certificate[] chain = keyStore.getCertificateChain(targetAlias); + if (chain == null || chain.length == 0) { + Certificate single = keyStore.getCertificate(targetAlias); + if (single != null) { + chain = new Certificate[]{single}; + } + } + if (chain == null || chain.length == 0) { + return java.util.Optional.empty(); + } + X509Certificate[] x509Chain = new X509Certificate[chain.length]; + for (int i = 0; i < chain.length; i++) { + x509Chain[i] = (X509Certificate) chain[i]; + } + return java.util.Optional.of(new br.com.cybercore.signer.core.domain.SigningKeyMaterial(privateKey, x509Chain, keyStore.getProvider())); + } catch (Exception e) { + throw new CertificateProviderException( + CcspErrorCode.SIGNATURE_FAILED, + "Falha ao obter chave privada e certificados no Windows Store: " + e.getMessage(), + e + ); + } + } + + @Override + public synchronized void refresh() { + if (isWindows) { + initKeyStore(); + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/ApplicationRegistry.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/ApplicationRegistry.java new file mode 100644 index 0000000..234239d --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/ApplicationRegistry.java @@ -0,0 +1,162 @@ +package br.com.cybercore.signer.core.security; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Registro de aplicações web autorizadas a se comunicar com o agente CyberCore Signer. + * Suporta persistência segura no diretório do usuário (~/.cybercore/authorized-apps.json). + */ +public final class ApplicationRegistry { + + private static final Logger logger = LoggerFactory.getLogger(ApplicationRegistry.class); + private static final String DEFAULT_FILE_NAME = "authorized-apps.json"; + + private static final ApplicationRegistry INSTANCE = new ApplicationRegistry(); + + private final Path storagePath; + private final Map applications = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper; + + public static ApplicationRegistry getInstance() { + return INSTANCE; + } + + public ApplicationRegistry() { + this(resolveDefaultStoragePath()); + } + + public ApplicationRegistry(Path storagePath) { + this.storagePath = storagePath; + this.objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + load(); + } + + private static Path resolveDefaultStoragePath() { + String userHome = System.getProperty("user.home", "."); + return Path.of(userHome, ".cybercore", DEFAULT_FILE_NAME); + } + + /** + * Busca uma aplicação pelo seu ID único. + */ + public Optional findById(String applicationId) { + if (applicationId == null || applicationId.isBlank()) { + return Optional.empty(); + } + return Optional.ofNullable(applications.get(applicationId)); + } + + /** + * Busca uma aplicação que contenha a origem especificada autorizada. + */ + public Optional findByOrigin(String origin) { + if (origin == null || origin.isBlank()) { + return Optional.empty(); + } + return applications.values().stream() + .filter(app -> app.isOriginAllowed(origin)) + .findFirst(); + } + + /** + * Verifica se uma aplicação e sua respectiva origem estão autorizadas e ativas. + */ + public boolean isAuthorized(String applicationId, String origin) { + Optional appOpt = findById(applicationId); + return appOpt.map(app -> app.isOriginAllowed(origin)).orElse(false); + } + + /** + * Registra ou atualiza uma aplicação autorizada e persiste a alteração. + */ + public synchronized void register(AuthorizedApplication app) { + Objects.requireNonNull(app, "Aplicação não pode ser nula"); + applications.put(app.applicationId(), app); + logger.info("Aplicação registrada: '{}' [id={}] com origens: {}", app.name(), app.applicationId(), app.allowedOrigins()); + save(); + } + + /** + * Revoga a autorização de uma aplicação. + */ + public synchronized void revoke(String applicationId) { + if (applicationId != null && applications.remove(applicationId) != null) { + logger.info("Autorização revogada para a aplicação [id={}]", applicationId); + save(); + } + } + + /** + * Retorna a lista imutável de todas as aplicações cadastradas. + */ + public List listAll() { + return Collections.unmodifiableList(new ArrayList<>(applications.values())); + } + + /** + * Carrega as aplicações persistidas em disco. + */ + public synchronized void load() { + if (storagePath == null || !Files.exists(storagePath)) { + logger.debug("Nenhum arquivo de aplicações persistidas encontrado em {}", storagePath); + return; + } + + try { + List list = objectMapper.readValue( + storagePath.toFile(), + new TypeReference>() {} + ); + applications.clear(); + for (AuthorizedApplication app : list) { + applications.put(app.applicationId(), app); + } + logger.info("Carregadas {} aplicação(ões) autorizada(s) de {}", applications.size(), storagePath); + } catch (Exception e) { + logger.warn("Falha ao ler aplicações autorizadas de {}: {}", storagePath, e.getMessage()); + } + } + + /** + * Salva as aplicações atuais em disco. + */ + public synchronized void save() { + if (storagePath == null) { + return; + } + + try { + Path parent = storagePath.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + objectMapper.writerWithDefaultPrettyPrinter().writeValue( + storagePath.toFile(), + new ArrayList<>(applications.values()) + ); + logger.debug("Aplicações autorizadas salvas com sucesso em {}", storagePath); + } catch (Exception e) { + logger.error("Erro ao salvar aplicações autorizadas em {}: {}", storagePath, e.getMessage()); + } + } + + public synchronized void clear() { + applications.clear(); + save(); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/AuthorizedApplication.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/AuthorizedApplication.java new file mode 100644 index 0000000..ab6dc29 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/AuthorizedApplication.java @@ -0,0 +1,44 @@ +package br.com.cybercore.signer.core.security; + +import java.io.Serializable; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Representa uma aplicação web autorizada a solicitar assinaturas ao CyberCore Signer. + */ +public record AuthorizedApplication( + String applicationId, + String name, + Set allowedOrigins, + boolean enabled, + Instant registeredAt +) implements Serializable { + + public AuthorizedApplication { + Objects.requireNonNull(applicationId, "applicationId must not be null"); + Objects.requireNonNull(name, "name must not be null"); + allowedOrigins = allowedOrigins != null ? Set.copyOf(allowedOrigins) : Set.of(); + if (registeredAt == null) { + registeredAt = Instant.now(); + } + } + + /** + * Verifica se uma determinada Origin web está autorizada para esta aplicação. + */ + public boolean isOriginAllowed(String origin) { + if (!enabled || origin == null || origin.isBlank()) { + return false; + } + String normalized = origin.trim().toLowerCase(); + for (String allowed : allowedOrigins) { + if (allowed.trim().toLowerCase().equals(normalized)) { + return true; + } + } + return false; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/NonceManager.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/NonceManager.java new file mode 100644 index 0000000..bb3d85b --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/NonceManager.java @@ -0,0 +1,53 @@ +package br.com.cybercore.signer.core.security; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Gerenciador de nonces em memória para proteção contra ataques de replay (CCSP/1). + * Garante que uma requisição com o mesmo nonce e correlation_id não seja reprocessada. + */ +public class NonceManager { + + private final Duration windowDuration; + private final ConcurrentHashMap usedNonces = new ConcurrentHashMap<>(); + + public NonceManager(Duration windowDuration) { + this.windowDuration = windowDuration != null ? windowDuration : Duration.ofSeconds(120); + } + + public NonceManager() { + this(Duration.ofSeconds(120)); + } + + /** + * Tenta registrar um nonce. + * + * @param nonce Nonce gerado pelo cliente/requisição. + * @return {@code true} se o nonce for novo e válido; {@code false} se já tiver sido utilizado (replay). + */ + public boolean registerNonce(String nonce) { + if (nonce == null || nonce.isBlank()) { + return false; + } + + cleanupExpired(); + + Instant now = Instant.now(); + Instant existing = usedNonces.putIfAbsent(nonce, now); + return existing == null; + } + + /** + * Remove nonces expirados além da janela máxima de aceitação. + */ + public void cleanupExpired() { + Instant cutoff = Instant.now().minus(windowDuration); + usedNonces.entrySet().removeIf(entry -> entry.getValue().isBefore(cutoff)); + } + + public int activeNonceCount() { + return usedNonces.size(); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/OriginValidator.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/OriginValidator.java new file mode 100644 index 0000000..87f6fd5 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/OriginValidator.java @@ -0,0 +1,44 @@ +package br.com.cybercore.signer.core.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.util.Objects; +import java.util.Set; + +/** + * Validador estrito de origens web conforme as especificações de segurança do protocolo CCSP. + */ +public class OriginValidator { + + private static final Logger logger = LoggerFactory.getLogger(OriginValidator.class); + + /** + * Valida se a origem recebida na requisição corresponde às origens autorizadas da aplicação. + */ + public boolean isAllowed(String requestOrigin, AuthorizedApplication application) { + if (application == null || !application.enabled()) { + logger.warn("Aplicação nula ou desabilitada durante validação de origem."); + return false; + } + + if (requestOrigin == null || requestOrigin.isBlank()) { + logger.warn("Requisição sem cabeçalho Origin rejeitada para a aplicação {}", application.applicationId()); + return false; + } + + try { + URI uri = URI.create(requestOrigin.trim()); + String normalizedOrigin = uri.getScheme() + "://" + uri.getAuthority(); + boolean allowed = application.isOriginAllowed(normalizedOrigin); + if (!allowed) { + logger.warn("Origem '{}' rejeitada para a aplicação '{}'", normalizedOrigin, application.applicationId()); + } + return allowed; + } catch (Exception e) { + logger.warn("Origem malformatada '{}': {}", requestOrigin, e.getMessage()); + return false; + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/PinSessionManager.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/PinSessionManager.java new file mode 100644 index 0000000..9d83944 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/PinSessionManager.java @@ -0,0 +1,106 @@ +package br.com.cybercore.signer.core.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Gerenciador de PIN e credenciais voláteis de sessão do CyberCore Signer. + * Armazena temporariamente em memória RAM (nunca em disco) o PIN de tokens A3 + * exclusivamente quando o usuário autorizar explicitamente a opção "Salvar PIN na sessão". + * Todas as senhas e PINs são sobrescritos com espaços em branco (' ') no descarte. + */ +public class PinSessionManager { + + private static final Logger logger = LoggerFactory.getLogger(PinSessionManager.class); + + private static final PinSessionManager INSTANCE = new PinSessionManager(); + + private final Map sessionPins = new ConcurrentHashMap<>(); + private volatile boolean alwaysPromptPin = false; + + public PinSessionManager() { + } + + public static PinSessionManager getInstance() { + return INSTANCE; + } + + /** + * Salva o PIN de um certificado/token para a sessão atual em memória volátil. + */ + public synchronized void savePin(String certId, char[] pin) { + Objects.requireNonNull(certId, "certId must not be null"); + if (pin == null || pin.length == 0) { + clearPin(certId); + return; + } + + // Limpa PIN anterior caso exista + clearPin(certId); + + sessionPins.put(certId, pin.clone()); + logger.debug("PIN salvo em memória de sessão para o certificado '{}'. Tamanho: {} dígitos", certId, pin.length); + } + + /** + * Recupera o PIN salvo em sessão para o certificado informado. + */ + public synchronized char[] getPin(String certId) { + if (certId == null || alwaysPromptPin) { + return null; + } + char[] stored = sessionPins.get(certId); + return stored != null ? stored.clone() : null; + } + + /** + * Verifica se existe um PIN válido salvo nesta sessão para o certificado. + */ + public synchronized boolean hasPin(String certId) { + if (certId == null || alwaysPromptPin) { + return false; + } + return sessionPins.containsKey(certId); + } + + /** + * Remove o PIN salvo para um certificado específico, zerando a memória. + */ + public synchronized void clearPin(String certId) { + if (certId != null) { + char[] old = sessionPins.remove(certId); + if (old != null) { + Arrays.fill(old, ' '); + } + } + } + + /** + * Limpa imediatamente todos os PINs em memória e zera seus caracteres. + */ + public synchronized void clearAll() { + for (char[] pin : sessionPins.values()) { + if (pin != null) { + Arrays.fill(pin, ' '); + } + } + sessionPins.clear(); + logger.info("Todos os PINs em memória de sessão foram limpos e zerados com sucesso."); + } + + public boolean isAlwaysPromptPin() { + return alwaysPromptPin; + } + + public void setAlwaysPromptPin(boolean alwaysPromptPin) { + this.alwaysPromptPin = alwaysPromptPin; + if (alwaysPromptPin) { + clearAll(); + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/SmartCardSessionManager.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/SmartCardSessionManager.java new file mode 100644 index 0000000..8bafeda --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/SmartCardSessionManager.java @@ -0,0 +1,69 @@ +package br.com.cybercore.signer.core.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; +import java.util.List; + +/** + * Gerencia sessões de hardware Smart Card (Tokens USB / Cartões ICP-Brasil) no nível do subsistema PC/SC (winscard). + * Permite forçar o reset do canal do cartão para invalidar caches de PIN de processo (BaseCSP / Minidriver), + * garantindo que novas operações de assinatura exijam autenticação explícita quando configurado. + */ +public final class SmartCardSessionManager { + + private static final Logger logger = LoggerFactory.getLogger(SmartCardSessionManager.class); + + private SmartCardSessionManager() { + } + + /** + * Reseta as conexões ativas com todos os terminais Smart Card conectados que possuam cartão presente. + * Envia o comando SCARD_RESET_CARD (warm reset via javax.smartcardio Card.disconnect(true)), + * invalidando o estado de autenticação de PIN nos drivers e no CryptoAPI/CNG do Windows. + */ + @SuppressWarnings("unchecked") + public static void resetCardSessions() { + try { + Class tfClass = Class.forName("javax.smartcardio.TerminalFactory"); + Method getDefaultMethod = tfClass.getMethod("getDefault"); + Object factory = getDefaultMethod.invoke(null); + + Method terminalsMethod = factory.getClass().getMethod("terminals"); + Object cardTerminals = terminalsMethod.invoke(factory); + + Class stateEnumClass = Class.forName("javax.smartcardio.CardTerminals$State"); + Object cardPresent = Enum.valueOf((Class) stateEnumClass, "CARD_PRESENT"); + + Method listMethod = cardTerminals.getClass().getMethod("list", stateEnumClass); + List terminals = (List) listMethod.invoke(cardTerminals, cardPresent); + + if (terminals == null || terminals.isEmpty()) { + logger.debug("Nenhum terminal Smart Card com cartão presente encontrado para reset de sessão."); + return; + } + + for (Object terminal : terminals) { + try { + Method getNameMethod = terminal.getClass().getMethod("getName"); + String termName = (String) getNameMethod.invoke(terminal); + + Method connectMethod = terminal.getClass().getMethod("connect", String.class); + Object card = connectMethod.invoke(terminal, "*"); + + if (card != null) { + Method disconnectMethod = card.getClass().getMethod("disconnect", boolean.class); + // disconnect(true) envia SCARD_RESET_CARD ao driver do Windows / SafeSign + disconnectMethod.invoke(card, true); + logger.info("Sessão de Smart Card resetada com sucesso no terminal '{}' (cache de PIN invalidado)", termName); + } + } catch (Throwable cardError) { + logger.debug("Falha ao resetar terminal Smart Card individual: {}", cardError.getMessage()); + } + } + } catch (Throwable t) { + logger.debug("SmartCardIO não disponível ou sem suporte no ambiente: {}", t.getMessage()); + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/SmartCardWatcher.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/SmartCardWatcher.java new file mode 100644 index 0000000..71e70aa --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/security/SmartCardWatcher.java @@ -0,0 +1,197 @@ +package br.com.cybercore.signer.core.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Monitor em segundo plano para detecção em tempo real de inserção e remoção física + * de Smart Cards e Tokens USB ICP-Brasil via subsistema PC/SC (javax.smartcardio). + * + * Implementa os requisitos 5 e 13 do CyberCore Signer: + * - Detecta conexão e remoção de hardware; + * - Registra logs explícitos de auditoria para eventos de TOKEN_REMOVED; + * - Invalida automaticamente sessões e caches de PIN quando um dispositivo físico é removido; + * - Notifica observadores (ex: UI e CertificateManager) para atualização em tempo real. + */ +public class SmartCardWatcher { + + private static final Logger logger = LoggerFactory.getLogger(SmartCardWatcher.class); + + public interface CardEventListener { + void onCardInserted(String terminalName); + void onCardRemoved(String terminalName); + } + + private final List listeners = new CopyOnWriteArrayList<>(); + private final Set knownCards = Collections.synchronizedSet(new HashSet<>()); + + private volatile boolean running = false; + private Thread watcherThread; + + public SmartCardWatcher() { + } + + public void addListener(CardEventListener listener) { + if (listener != null) { + listeners.add(listener); + } + } + + public void removeListener(CardEventListener listener) { + listeners.remove(listener); + } + + /** + * Inicia o thread de monitoramento contínuo em segundo plano. + */ + public synchronized void start() { + if (running) { + return; + } + + running = true; + watcherThread = new Thread(this::pollLoop, "smartcard-hardware-watcher"); + watcherThread.setDaemon(true); + watcherThread.start(); + logger.info("Monitoramento dinâmico de hardware Smart Card / Token USB iniciado."); + } + + /** + * Finaliza o monitoramento. + */ + public synchronized void stop() { + running = false; + if (watcherThread != null) { + watcherThread.interrupt(); + watcherThread = null; + } + logger.info("Monitoramento dinâmico de hardware Smart Card / Token USB finalizado."); + } + + public boolean isRunning() { + return running; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void pollLoop() { + Object cardTerminals = null; + Method waitForChangeMethod = null; + Method listMethod = null; + Object cardPresentState = null; + + try { + Class tfClass = Class.forName("javax.smartcardio.TerminalFactory"); + Method getDefaultMethod = tfClass.getMethod("getDefault"); + Object factory = getDefaultMethod.invoke(null); + + Method terminalsMethod = factory.getClass().getMethod("terminals"); + cardTerminals = terminalsMethod.invoke(factory); + + Class stateEnumClass = Class.forName("javax.smartcardio.CardTerminals$State"); + cardPresentState = Enum.valueOf((Class) stateEnumClass, "CARD_PRESENT"); + + listMethod = cardTerminals.getClass().getMethod("list", stateEnumClass); + waitForChangeMethod = cardTerminals.getClass().getMethod("waitForChange", long.class); + + // Popula estado inicial + Set initial = getCurrentTerminalsWithCards(cardTerminals, listMethod, cardPresentState); + knownCards.addAll(initial); + + } catch (Throwable t) { + logger.debug("Subsistema PC/SC (javax.smartcardio) indisponível ou sem suporte neste ambiente: {}", t.getMessage()); + return; + } + + while (running) { + try { + // Aguarda alteração de estado no hardware (timeout de 1.5s para checar flag running) + Boolean changed = (Boolean) waitForChangeMethod.invoke(cardTerminals, 1500L); + + if (Boolean.TRUE.equals(changed) || !running) { + if (!running) break; + + Set current = getCurrentTerminalsWithCards(cardTerminals, listMethod, cardPresentState); + + // Dispositivos removidos + Set removed = new HashSet<>(knownCards); + removed.removeAll(current); + for (String term : removed) { + logger.warn("AUDITORIA [TOKEN_REMOVED]: Smart Card / Token USB desconectado do terminal '{}'", term); + // Ao remover o token, zera imediatamente PINs salvos em memória por segurança + PinSessionManager.getInstance().clearAll(); + SmartCardSessionManager.resetCardSessions(); + notifyRemoved(term); + } + + // Dispositivos inseridos + Set inserted = new HashSet<>(current); + inserted.removeAll(knownCards); + for (String term : inserted) { + logger.info("AUDITORIA: Smart Card / Token USB detectado no terminal '{}'", term); + notifyInserted(term); + } + + knownCards.clear(); + knownCards.addAll(current); + } + } catch (Throwable e) { + if (e instanceof InterruptedException || (e.getCause() instanceof InterruptedException)) { + Thread.currentThread().interrupt(); + break; + } + if (!running) break; + // Espera curta para evitar consumo excessivo de CPU em caso de falha transitória + try { + Thread.sleep(2000); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + + @SuppressWarnings("unchecked") + private Set getCurrentTerminalsWithCards(Object cardTerminals, Method listMethod, Object state) { + Set result = new HashSet<>(); + try { + List terminals = (List) listMethod.invoke(cardTerminals, state); + if (terminals != null) { + for (Object t : terminals) { + Method getName = t.getClass().getMethod("getName"); + String name = (String) getName.invoke(t); + result.add(name); + } + } + } catch (Throwable ignored) { + } + return result; + } + + private void notifyInserted(String terminal) { + for (CardEventListener listener : listeners) { + try { + listener.onCardInserted(terminal); + } catch (Throwable t) { + logger.error("Erro no listener onCardInserted: {}", t.getMessage()); + } + } + } + + private void notifyRemoved(String terminal) { + for (CardEventListener listener : listeners) { + try { + listener.onCardRemoved(terminal); + } catch (Throwable t) { + logger.error("Erro no listener onCardRemoved: {}", t.getMessage()); + } + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/SignatureValidationService.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/SignatureValidationService.java new file mode 100644 index 0000000..f4fe8f6 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/SignatureValidationService.java @@ -0,0 +1,417 @@ +package br.com.cybercore.signer.core.validation; + +import br.com.cybercore.signer.core.crypto.X509CertificateUtils; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cms.CMSProcessableByteArray; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.SignerInformation; +import org.bouncycastle.cms.SignerInformationStore; +import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder; +import org.bouncycastle.util.Store; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; + +import javax.xml.crypto.dsig.XMLSignature; +import javax.xml.crypto.dsig.XMLSignatureFactory; +import javax.xml.crypto.dsig.dom.DOMValidateContext; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.security.cert.X509Certificate; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Objects; + +/** + * Serviço especializado para Validação Criptográfica de Assinaturas Digitais. + * Suporta Assinaturas Destacadas (Detached CMS/PKCS#7) e Assinaturas Incorporadas + * (PAdES em PDF, CMS/P7M com conteúdo encapsulado e XMLDSig). + */ +public final class SignatureValidationService { + + private static final Logger logger = LoggerFactory.getLogger(SignatureValidationService.class); + private static final JcaX509CertificateConverter CERT_CONVERTER = new JcaX509CertificateConverter(); + + /** + * Valida uma Assinatura Destacada (Detached). + * Requer os bytes do documento original e os bytes do arquivo de assinatura (.p7s / .sig). + */ + public ValidationResult validateDetached(byte[] originalContent, byte[] signatureBytes, String docName) { + if (originalContent == null || originalContent.length == 0) { + return new ValidationResult( + ValidationStatus.ERROR, + ValidationType.DETACHED_CMS, + "Documento original não informado ou vazio.", + List.of(), + null, + docName + ); + } + + if (signatureBytes == null || signatureBytes.length == 0) { + return new ValidationResult( + ValidationStatus.ERROR, + ValidationType.DETACHED_CMS, + "Arquivo de assinatura (.p7s) não informado ou vazio.", + List.of(), + null, + docName + ); + } + + try { + CMSSignedData signedData = new CMSSignedData(new CMSProcessableByteArray(originalContent), signatureBytes); + return processCmsSignatures(signedData, ValidationType.DETACHED_CMS, null, docName); + } catch (Exception e) { + logger.warn("Erro ao processar assinatura destacada: {}", e.getMessage()); + return new ValidationResult( + ValidationStatus.INVALID, + ValidationType.DETACHED_CMS, + "Falha ao decodificar pacote CMS/PKCS#7: " + e.getMessage(), + List.of(), + null, + docName + ); + } + } + + /** + * Valida uma Assinatura Incorporada / Anexada (Attached / Enveloped / Embedded). + * O arquivo assinado contém os dados e a assinatura juntos (PDF, P7M/CMS com dados, ou XML). + */ + public ValidationResult validateAttached(byte[] signedFileBytes, String fileName) { + if (signedFileBytes == null || signedFileBytes.length == 0) { + return new ValidationResult( + ValidationStatus.ERROR, + ValidationType.UNKNOWN, + "Arquivo assinado não informado ou vazio.", + List.of(), + null, + fileName + ); + } + + String nameLower = (fileName != null) ? fileName.toLowerCase() : ""; + + // 1. Verificação se é documento PDF + if (nameLower.endsWith(".pdf") || isPdfBytes(signedFileBytes)) { + return validatePdf(signedFileBytes, fileName); + } + + // 2. Verificação se é documento XML + if (nameLower.endsWith(".xml") || isXmlBytes(signedFileBytes)) { + return validateXml(signedFileBytes, fileName); + } + + // 3. Verificação se é CMS incorporado (P7M / P7S com dados embutidos) + try { + CMSSignedData signedData = new CMSSignedData(signedFileBytes); + if (signedData.getSignedContent() != null) { + Object contentObj = signedData.getSignedContent().getContent(); + byte[] extracted = null; + if (contentObj instanceof byte[] b) { + extracted = b; + } + return processCmsSignatures(signedData, ValidationType.ATTACHED_CMS, extracted, fileName); + } else { + return new ValidationResult( + ValidationStatus.WARNING, + ValidationType.DETACHED_CMS, + "O arquivo de assinatura selecionado é do tipo Destacado (Detached) e não contém o documento embutido. Selecione o Documento Original para validar.", + List.of(), + null, + fileName + ); + } + } catch (Exception e) { + logger.warn("Não foi possível decodificar como CMS: {}", e.getMessage()); + } + + return new ValidationResult( + ValidationStatus.ERROR, + ValidationType.UNKNOWN, + "Formato de arquivo assinado não reconhecido (esperado .pdf, .p7m, .p7s ou .xml).", + List.of(), + null, + fileName + ); + } + + /** + * Validação de assinaturas embutidas em documentos PDF (PAdES). + */ + private ValidationResult validatePdf(byte[] pdfBytes, String fileName) { + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + List signatures = document.getSignatureDictionaries(); + + if (signatures == null || signatures.isEmpty()) { + return new ValidationResult( + ValidationStatus.INVALID, + ValidationType.PADES_PDF, + "Nenhuma assinatura digital encontrada no documento PDF.", + List.of(), + null, + fileName + ); + } + + List signerInfos = new ArrayList<>(); + boolean allValid = true; + + for (PDSignature sig : signatures) { + byte[] contents = sig.getContents(pdfBytes); + byte[] signedByteRange = sig.getSignedContent(pdfBytes); + + if (contents == null || signedByteRange == null) { + continue; + } + + CMSSignedData cms = new CMSSignedData(new CMSProcessableByteArray(signedByteRange), contents); + ValidationResult cmsRes = processCmsSignatures(cms, ValidationType.PADES_PDF, null, fileName); + + if (!cmsRes.isValid()) { + allValid = false; + } + + for (ValidationSignerInfo info : cmsRes.signers()) { + String dateStr = (sig.getSignDate() != null) + ? new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(sig.getSignDate().getTime()) + : info.signingTime(); + + signerInfos.add(new ValidationSignerInfo( + (sig.getName() != null && !sig.getName().isBlank()) ? sig.getName() : info.signerName(), + info.identifier(), + info.issuer(), + dateStr, + info.digestValid(), + info.signatureValid(), + info.algorithm(), + info.certificateSubject(), + (sig.getReason() != null ? "Motivo: " + sig.getReason() : "Assinatura PAdES embutida no PDF") + )); + } + } + + ValidationStatus status = allValid ? ValidationStatus.VALID : ValidationStatus.INVALID; + String msg = allValid + ? String.format("PDF assinado e íntegro com %d assinatura(s) digital(is) válida(s).", signerInfos.size()) + : "Assinatura(s) do PDF contêm falha de integridade ou autenticidade."; + + return new ValidationResult(status, ValidationType.PADES_PDF, msg, signerInfos, null, fileName); + + } catch (Exception e) { + logger.error("Falha ao validar documento PDF: {}", e.getMessage(), e); + return new ValidationResult( + ValidationStatus.ERROR, + ValidationType.PADES_PDF, + "Erro ao analisar documento PDF: " + e.getMessage(), + List.of(), + null, + fileName + ); + } + } + + /** + * Validação de assinaturas embutidas em documentos XML (XMLDSig). + */ + private ValidationResult validateXml(byte[] xmlBytes, String fileName) { + try { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(xmlBytes)); + + NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature"); + if (nl.getLength() == 0) { + return new ValidationResult( + ValidationStatus.INVALID, + ValidationType.XML_DSIG, + "Nenhum elemento XMLDSig encontrado no documento.", + List.of(), + null, + fileName + ); + } + + XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM"); + List signers = new ArrayList<>(); + boolean allValid = true; + + for (int i = 0; i < nl.getLength(); i++) { + DOMValidateContext valContext = new DOMValidateContext(new KeyValueKeySelector(), nl.item(i)); + XMLSignature signature = fac.unmarshalXMLSignature(valContext); + boolean coreValidity = signature.validate(valContext); + + if (!coreValidity) { + allValid = false; + } + + signers.add(new ValidationSignerInfo( + "Signatário XML #" + (i + 1), + "", + "Assinatura Enveloped XML", + new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(new Date()), + coreValidity, + coreValidity, + signature.getSignedInfo().getSignatureMethod().getAlgorithm(), + "XMLDSig Element", + "Validade estrutural e criptográfica: " + (coreValidity ? "OK" : "FALHA") + )); + } + + ValidationStatus status = allValid ? ValidationStatus.VALID : ValidationStatus.INVALID; + String msg = allValid ? "Documento XML assinado e válido conforme padrão W3C XML-DSig." : "Assinatura XML inválida ou com integridade violada."; + return new ValidationResult(status, ValidationType.XML_DSIG, msg, signers, null, fileName); + + } catch (Exception e) { + logger.error("Erro ao validar XML: {}", e.getMessage(), e); + return new ValidationResult( + ValidationStatus.ERROR, + ValidationType.XML_DSIG, + "Erro ao processar assinatura XML: " + e.getMessage(), + List.of(), + null, + fileName + ); + } + } + + /** + * Processa e valida criptograficamente os signatários de um pacote CMSSignedData. + */ + @SuppressWarnings("unchecked") + private ValidationResult processCmsSignatures(CMSSignedData signedData, ValidationType type, + byte[] extractedContent, String docName) { + List signers = new ArrayList<>(); + boolean allValid = true; + + Store certStore = signedData.getCertificates(); + SignerInformationStore signerStore = signedData.getSignerInfos(); + + for (SignerInformation signer : signerStore.getSigners()) { + boolean sigValid = false; + String signerName = "Signatário Criptográfico"; + String identifier = ""; + String issuer = "Desconhecido"; + String subject = ""; + String signingTime = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss").format(new Date()); + + try { + Collection matches = certStore.getMatches(signer.getSID()); + if (!matches.isEmpty()) { + X509CertificateHolder certHolder = matches.iterator().next(); + X509Certificate cert = CERT_CONVERTER.getCertificate(certHolder); + + subject = cert.getSubjectX500Principal().getName(); + issuer = cert.getIssuerX500Principal().getName(); + signerName = X509CertificateUtils.extractCommonName(subject); + + String cpf = X509CertificateUtils.extractCpf(cert, signerName); + if (cpf != null) { + identifier = "CPF: " + cpf; + } else { + String cnpj = X509CertificateUtils.extractCnpj(cert, signerName); + if (cnpj != null) { + identifier = "CNPJ: " + cnpj; + } + } + + // Validação Criptográfica real com a chave pública do certificado + sigValid = signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert)); + } + } catch (Exception e) { + logger.warn("Falha ao verificar assinatura de {}: {}", signerName, e.getMessage()); + sigValid = false; + } + + if (!sigValid) { + allValid = false; + } + + signers.add(new ValidationSignerInfo( + signerName, + identifier, + issuer, + signingTime, + sigValid, + sigValid, + signer.getDigestAlgOID(), + subject, + sigValid ? "Integridade e assinatura conferidas com sucesso." : "Falha na validação criptográfica do resumo ou chave." + )); + } + + if (signers.isEmpty()) { + return new ValidationResult( + ValidationStatus.INVALID, + type, + "Nenhum signatário encontrado no arquivo de assinatura.", + List.of(), + extractedContent, + docName + ); + } + + ValidationStatus status = allValid ? ValidationStatus.VALID : ValidationStatus.INVALID; + String summary = allValid + ? "Assinatura criptográfica válida e integridade do documento confirmada." + : "Uma ou mais assinaturas criptográficas falharam na validação."; + + return new ValidationResult(status, type, summary, signers, extractedContent, docName); + } + + private static boolean isPdfBytes(byte[] bytes) { + if (bytes.length < 5) return false; + return bytes[0] == '%' && bytes[1] == 'P' && bytes[2] == 'D' && bytes[3] == 'F'; + } + + private static boolean isXmlBytes(byte[] bytes) { + if (bytes.length < 5) return false; + // Pula possíveis BOM + int offset = 0; + if (bytes.length > 3 && (bytes[0] == (byte) 0xEF && bytes[1] == (byte) 0xBB && bytes[2] == (byte) 0xBF)) { + offset = 3; + } + while (offset < bytes.length && Character.isWhitespace(bytes[offset])) { + offset++; + } + return offset < bytes.length && bytes[offset] == '<'; + } + + /** + * KeySelector simples para resolução de chave pública em assinaturas XML via KeyInfo. + */ + private static class KeyValueKeySelector extends javax.xml.crypto.KeySelector { + @Override + public javax.xml.crypto.KeySelectorResult select(javax.xml.crypto.dsig.keyinfo.KeyInfo keyInfo, + Purpose purpose, + javax.xml.crypto.AlgorithmMethod method, + javax.xml.crypto.XMLCryptoContext context) { + if (keyInfo == null) return () -> null; + for (Object info : keyInfo.getContent()) { + if (info instanceof javax.xml.crypto.dsig.keyinfo.KeyValue kv) { + try { + java.security.PublicKey pk = kv.getPublicKey(); + return () -> pk; + } catch (Exception ignored) { + } + } else if (info instanceof javax.xml.crypto.dsig.keyinfo.X509Data x509Data) { + for (Object o : x509Data.getContent()) { + if (o instanceof X509Certificate cert) { + return cert::getPublicKey; + } + } + } + } + return () -> null; + } + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationResult.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationResult.java new file mode 100644 index 0000000..003cc16 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationResult.java @@ -0,0 +1,19 @@ +package br.com.cybercore.signer.core.validation; + +import java.util.List; + +/** + * Resultado completo da validação de uma assinatura digital (incorporada ou destacada). + */ +public record ValidationResult( + ValidationStatus status, + ValidationType type, + String summaryMessage, + List signers, + byte[] extractedContent, + String documentName +) { + public boolean isValid() { + return status == ValidationStatus.VALID; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationSignerInfo.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationSignerInfo.java new file mode 100644 index 0000000..4836c49 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationSignerInfo.java @@ -0,0 +1,20 @@ +package br.com.cybercore.signer.core.validation; + +/** + * Informações e resultado da validação criptográfica de um signatário individual. + */ +public record ValidationSignerInfo( + String signerName, + String identifier, + String issuer, + String signingTime, + boolean digestValid, + boolean signatureValid, + String algorithm, + String certificateSubject, + String details +) { + public boolean isValid() { + return digestValid && signatureValid; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationStatus.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationStatus.java new file mode 100644 index 0000000..88bb35c --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationStatus.java @@ -0,0 +1,21 @@ +package br.com.cybercore.signer.core.validation; + +/** + * Status do resultado da validação criptográfica. + */ +public enum ValidationStatus { + VALID("VÁLIDA"), + INVALID("INVÁLIDA"), + WARNING("VÁLIDA COM RESSALVAS"), + ERROR("ERRO DE PROCESSAMENTO"); + + private final String description; + + ValidationStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationType.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationType.java new file mode 100644 index 0000000..8be237c --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/validation/ValidationType.java @@ -0,0 +1,28 @@ +package br.com.cybercore.signer.core.validation; + +/** + * Tipos de assinatura digital suportados para validação. + */ +public enum ValidationType { + DETACHED_CMS("Assinatura Destacada (CAdES / PKCS#7)", false), + ATTACHED_CMS("Assinatura Incorporada (CMS / P7M)", true), + PADES_PDF("Assinatura Incorporada em PDF (PAdES)", true), + XML_DSIG("Assinatura Incorporada em XML (XML-DSig)", true), + UNKNOWN("Formato Desconhecido", false); + + private final String description; + private final boolean isEmbedded; + + ValidationType(String description, boolean isEmbedded) { + this.description = description; + this.isEmbedded = isEmbedded; + } + + public String getDescription() { + return description; + } + + public boolean isEmbedded() { + return isEmbedded; + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/xml/XmlSignatureOptions.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/xml/XmlSignatureOptions.java new file mode 100644 index 0000000..75424ef --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/xml/XmlSignatureOptions.java @@ -0,0 +1,32 @@ +package br.com.cybercore.signer.core.xml; + +import java.util.Objects; + +/** + * Parâmetros de configuração para a operação de assinatura digital XMLDSig. + */ +public record XmlSignatureOptions( + String certificateId, + char[] pinOrPassword, + String targetElementTag, + String targetElementId, + String signatureAlgorithm +) { + + public static final String DEFAULT_ALGORITHM = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + + public XmlSignatureOptions { + Objects.requireNonNull(certificateId, "ID do certificado não pode ser nulo."); + if (signatureAlgorithm == null || signatureAlgorithm.isBlank()) { + signatureAlgorithm = DEFAULT_ALGORITHM; + } + } + + public static XmlSignatureOptions wholeDocument(String certificateId, char[] pinOrPassword) { + return new XmlSignatureOptions(certificateId, pinOrPassword, null, null, DEFAULT_ALGORITHM); + } + + public static XmlSignatureOptions forElement(String certificateId, char[] pinOrPassword, String tag, String id) { + return new XmlSignatureOptions(certificateId, pinOrPassword, tag, id, DEFAULT_ALGORITHM); + } +} diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/xml/XmlSigningService.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/xml/XmlSigningService.java new file mode 100644 index 0000000..9699515 --- /dev/null +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/xml/XmlSigningService.java @@ -0,0 +1,290 @@ +package br.com.cybercore.signer.core.xml; + +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningKeyMaterial; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.CertificateProviderException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.crypto.AlgorithmMethod; +import javax.xml.crypto.KeySelector; +import javax.xml.crypto.KeySelectorException; +import javax.xml.crypto.KeySelectorResult; +import javax.xml.crypto.XMLCryptoContext; +import javax.xml.crypto.dsig.CanonicalizationMethod; +import javax.xml.crypto.dsig.DigestMethod; +import javax.xml.crypto.dsig.Reference; +import javax.xml.crypto.dsig.SignatureMethod; +import javax.xml.crypto.dsig.SignedInfo; +import javax.xml.crypto.dsig.Transform; +import javax.xml.crypto.dsig.XMLSignature; +import javax.xml.crypto.dsig.XMLSignatureFactory; +import javax.xml.crypto.dsig.dom.DOMSignContext; +import javax.xml.crypto.dsig.dom.DOMValidateContext; +import javax.xml.crypto.dsig.keyinfo.KeyInfo; +import javax.xml.crypto.dsig.keyinfo.KeyInfoFactory; +import javax.xml.crypto.dsig.keyinfo.X509Data; +import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec; +import javax.xml.crypto.dsig.spec.TransformParameterSpec; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.security.Key; +import java.security.PublicKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Serviço de alto nível para Assinatura Digital de Documentos XML segundo o padrão W3C XMLDSig e ICP-Brasil. + * Suporta assinatura enveloped de documento completo e assinatura de elementos por ID (NF-e, SPED, etc.). + * Utiliza o suporte de fábrica nativo e seguro da plataforma Java (javax.xml.crypto.dsig). + */ +public class XmlSigningService { + + private static final Logger logger = LoggerFactory.getLogger(XmlSigningService.class); + + private final CertificateManager certificateManager; + + public XmlSigningService(CertificateManager certificateManager) { + this.certificateManager = Objects.requireNonNull(certificateManager, "CertificateManager não pode ser nulo."); + } + + /** + * Assina digitalmente um arquivo XML e grava no arquivo de destino. + */ + public void signXmlFile(File sourceXml, File targetXml, XmlSignatureOptions options) throws Exception { + Objects.requireNonNull(sourceXml, "Arquivo XML de origem não pode ser nulo."); + Objects.requireNonNull(targetXml, "Arquivo XML de destino não pode ser nulo."); + Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas."); + + byte[] inputBytes = Files.readAllBytes(sourceXml.toPath()); + byte[] signedBytes = signXml(inputBytes, options); + + if (targetXml.getParentFile() != null) { + targetXml.getParentFile().mkdirs(); + } + try (FileOutputStream fos = new FileOutputStream(targetXml)) { + fos.write(signedBytes); + } + logger.info("XML assinado com sucesso e salvo em: {}", targetXml.getAbsolutePath()); + } + + /** + * Assina digitalmente o documento XML contido em bytes segundo as opções configuradas. + */ + public byte[] signXml(byte[] xmlBytes, XmlSignatureOptions options) throws Exception { + Objects.requireNonNull(xmlBytes, "Bytes do XML não podem ser nulos."); + Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas."); + + // 1. Resolução do Certificado e Chave Privada + Optional certOpt = certificateManager.findById(options.certificateId()); + if (certOpt.isEmpty()) { + certOpt = certificateManager.findByHint(options.certificateId()); + } + if (certOpt.isEmpty()) { + throw new CertificateProviderException( + CcspErrorCode.CERTIFICATE_NOT_FOUND, + "Certificado com ID '" + options.certificateId() + "' não encontrado." + ); + } + ManagedCertificate cert = certOpt.get(); + + Optional keyMaterialOpt = certificateManager.getKeyMaterial(cert.id(), options.pinOrPassword()); + if (keyMaterialOpt.isEmpty()) { + throw new CertificateProviderException( + CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE, + "Chave privada inacessível para o certificado '" + cert.alias() + "'." + ); + } + SigningKeyMaterial keyMaterial = keyMaterialOpt.get(); + + logger.info("Iniciando assinatura XMLDSig com certificado '{}' [tipo={}]", cert.commonName(), cert.type()); + + // 2. Parse Seguro do XML (Prevenção rigorosa de XXE) + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); + dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + dbf.setXIncludeAware(false); + dbf.setExpandEntityReferences(false); + + DocumentBuilder db = dbf.newDocumentBuilder(); + Document doc = db.parse(new ByteArrayInputStream(xmlBytes)); + + // 3. Configuração do XMLSignatureFactory + XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM"); + + // Transformações padrão: Enveloped + C14N + List transforms = new ArrayList<>(); + transforms.add(fac.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null)); + transforms.add(fac.newTransform(CanonicalizationMethod.INCLUSIVE, (TransformParameterSpec) null)); + + // Determinação da Referência e Ponto de Inserção + Reference ref; + Node insertionParent; + + if (options.targetElementId() != null && !options.targetElementId().isBlank()) { + String elemId = options.targetElementId(); + Element targetElement = findElementById(doc, elemId, options.targetElementTag()); + if (targetElement == null) { + throw new IllegalArgumentException("Elemento XML com ID '" + elemId + "' não encontrado no documento."); + } + targetElement.setIdAttribute("Id", true); + ref = fac.newReference("#" + elemId, fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null); + insertionParent = (targetElement.getParentNode() != null) ? targetElement.getParentNode() : doc.getDocumentElement(); + } else if (options.targetElementTag() != null && !options.targetElementTag().isBlank()) { + NodeList list = doc.getElementsByTagName(options.targetElementTag()); + if (list.getLength() == 0) { + list = doc.getElementsByTagNameNS("*", options.targetElementTag()); + } + if (list.getLength() > 0 && list.item(0) instanceof Element elem && elem.hasAttribute("Id")) { + String idVal = elem.getAttribute("Id"); + elem.setIdAttribute("Id", true); + ref = fac.newReference("#" + idVal, fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null); + insertionParent = (elem.getParentNode() != null) ? elem.getParentNode() : doc.getDocumentElement(); + } else { + ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null); + insertionParent = doc.getDocumentElement(); + } + } else { + // Assinatura Enveloped do documento completo + ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA256, null), transforms, null, null); + insertionParent = doc.getDocumentElement(); + } + + // 4. Montagem do SignedInfo + SignedInfo si = fac.newSignedInfo( + fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, (C14NMethodParameterSpec) null), + fac.newSignatureMethod(options.signatureAlgorithm(), null), + Collections.singletonList(ref) + ); + + // 5. Inclusão da Cadeia de Certificados no KeyInfo + KeyInfoFactory kif = fac.getKeyInfoFactory(); + List x509Content = new ArrayList<>(); + x509Content.add(keyMaterial.signingCertificate()); + X509Data x509Data = kif.newX509Data(x509Content); + KeyInfo ki = kif.newKeyInfo(Collections.singletonList(x509Data)); + + // 6. Execução da Assinatura DOM + DOMSignContext dsc = new DOMSignContext(keyMaterial.privateKey(), insertionParent); + if (keyMaterial.jcaProvider() != null) { + dsc.setProperty("org.jcp.xml.dsig.internal.dom.SignatureProvider", keyMaterial.jcaProvider()); + } + + XMLSignature signature = fac.newXMLSignature(si, ki); + signature.sign(dsc); + + // 7. Serialização do XML Assinado + TransformerFactory tf = TransformerFactory.newInstance(); + Transformer trans = tf.newTransformer(); + trans.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); + trans.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + trans.setOutputProperty(OutputKeys.INDENT, "no"); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + trans.transform(new DOMSource(doc), new StreamResult(baos)); + + logger.info("Assinatura XMLDSig gerada com sucesso. Tamanho: {} bytes", baos.size()); + return baos.toByteArray(); + } + + /** + * Valida a integridade criptográfica de um documento XML assinado com XMLDSig. + */ + public boolean validateXmlSignature(byte[] signedXmlBytes) throws Exception { + Objects.requireNonNull(signedXmlBytes, "Bytes do XML assinado não podem ser nulos."); + + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + + Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(signedXmlBytes)); + + // Registra nós com atributo Id/id/ID para que o ResourceResolver do XMLDSig localize a referência + NodeList allElements = doc.getElementsByTagName("*"); + for (int i = 0; i < allElements.getLength(); i++) { + if (allElements.item(i) instanceof Element e) { + if (e.hasAttribute("Id")) { + e.setIdAttribute("Id", true); + } else if (e.hasAttribute("id")) { + e.setIdAttribute("id", true); + } else if (e.hasAttribute("ID")) { + e.setIdAttribute("ID", true); + } + } + } + + NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature"); + if (nl.getLength() == 0) { + nl = doc.getElementsByTagName("Signature"); + } + if (nl.getLength() == 0) { + throw new IllegalArgumentException("Nenhum elemento encontrado no XML para validação."); + } + + Element sigElement = (Element) nl.item(0); + + XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM"); + DOMValidateContext valContext = new DOMValidateContext(new X509KeySelector(), sigElement); + + XMLSignature signature = fac.unmarshalXMLSignature(valContext); + return signature.validate(valContext); + } + + private static Element findElementById(Document doc, String id, String hintTag) { + if (hintTag != null && !hintTag.isBlank()) { + NodeList nl = doc.getElementsByTagName(hintTag); + for (int i = 0; i < nl.getLength(); i++) { + if (nl.item(i) instanceof Element e && id.equals(e.getAttribute("Id"))) { + return e; + } + } + } + return doc.getElementById(id); + } + + /** + * Seletor de chave pública baseado no certificado X.509 embutido no KeyInfo. + */ + private static class X509KeySelector extends KeySelector { + @Override + public KeySelectorResult select(KeyInfo keyInfo, KeySelector.Purpose purpose, + AlgorithmMethod method, XMLCryptoContext context) throws KeySelectorException { + if (keyInfo == null) { + throw new KeySelectorException("Elemento KeyInfo ausente na assinatura XML."); + } + for (Object info : keyInfo.getContent()) { + if (info instanceof X509Data x509Data) { + for (Object o : x509Data.getContent()) { + if (o instanceof X509Certificate cert) { + PublicKey pk = cert.getPublicKey(); + return () -> pk; + } + } + } + } + throw new KeySelectorException("Nenhuma chave pública X.509 encontrada no KeyInfo da assinatura."); + } + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/config/AgentConfigurationServiceTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/config/AgentConfigurationServiceTest.java new file mode 100644 index 0000000..3a8c986 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/config/AgentConfigurationServiceTest.java @@ -0,0 +1,47 @@ +package br.com.cybercore.signer.core.config; + +import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Testes de Configurações do Agente (AgentConfigurationService)") +class AgentConfigurationServiceTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("Deve salvar e recuperar configurações personalizadas do agente") + void shouldSaveAndLoadConfigurations() { + Path configFile = tempDir.resolve("config.json"); + AgentConfigurationService service = new AgentConfigurationService(configFile); + + AgentConfiguration customConfig = new AgentConfiguration( + false, + true, + true, + "cybercore-modern", + CpfDisplayMode.MASKED, + false, + 18420 + ); + + service.updateConfig(customConfig); + + // Novo serviço lendo o mesmo arquivo + AgentConfigurationService reloadedService = new AgentConfigurationService(configFile); + AgentConfiguration loaded = reloadedService.getConfig(); + + assertThat(loaded.requireConfirmation()).isFalse(); + assertThat(loaded.alwaysPromptPin()).isTrue(); + assertThat(loaded.autoStart()).isTrue(); + assertThat(loaded.defaultSealThemeId()).isEqualTo("cybercore-modern"); + assertThat(loaded.defaultCpfDisplayMode()).isEqualTo(CpfDisplayMode.MASKED); + assertThat(loaded.defaultIncludeDateTime()).isFalse(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/DigestAlgorithmTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/DigestAlgorithmTest.java new file mode 100644 index 0000000..9b49317 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/DigestAlgorithmTest.java @@ -0,0 +1,45 @@ +package br.com.cybercore.signer.core.crypto; + +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import br.com.cybercore.signer.core.provider.CertificateProviderException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DigestAlgorithmTest { + + @Test + @DisplayName("Deve identificar algoritmos suportados pelo nome JCA") + void shouldFindSupportedAlgorithm() throws Exception { + DigestAlgorithm alg = DigestAlgorithm.fromName("SHA256withRSA"); + assertThat(alg).isEqualTo(DigestAlgorithm.SHA256_WITH_RSA); + assertThat(alg.getExpectedDigestLengthBytes()).isEqualTo(32); + + DigestAlgorithm algEc = DigestAlgorithm.fromName("SHA384withECDSA"); + assertThat(algEc).isEqualTo(DigestAlgorithm.SHA384_WITH_ECDSA); + assertThat(algEc.getExpectedDigestLengthBytes()).isEqualTo(48); + } + + @Test + @DisplayName("Deve lançar CertificateProviderException para algoritmo desconhecido") + void shouldFailOnUnknownAlgorithm() { + assertThatThrownBy(() -> DigestAlgorithm.fromName("MD5withRSA")) + .isInstanceOf(CertificateProviderException.class) + .hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.UNSUPPORTED_ALGORITHM); + } + + @Test + @DisplayName("Deve validar comprimento correto do hash digest") + void shouldValidateDigestLength() throws Exception { + DigestAlgorithm alg = DigestAlgorithm.SHA256_WITH_RSA; + byte[] validSha256 = new byte[32]; + alg.validateDigestLength(validSha256); + + byte[] invalidLength = new byte[16]; + assertThatThrownBy(() -> alg.validateDigestLength(invalidLength)) + .isInstanceOf(CertificateProviderException.class) + .hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.SIGNATURE_FAILED); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/SigningServiceTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/SigningServiceTest.java new file mode 100644 index 0000000..4f46c5a --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/crypto/SigningServiceTest.java @@ -0,0 +1,147 @@ +package br.com.cybercore.signer.core.crypto; + +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.CertificateProvider; +import br.com.cybercore.signer.core.provider.CertificateProviderException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SigningServiceTest { + + private CertificateManager certificateManager; + private CertificateProvider mockProvider; + private SigningService signingService; + private ManagedCertificate validCert; + + @BeforeEach + void setUp() throws Exception { + certificateManager = new CertificateManager(); + mockProvider = mock(CertificateProvider.class); + when(mockProvider.getProviderId()).thenReturn("provider-mock"); + when(mockProvider.isAvailable()).thenReturn(true); + + validCert = new ManagedCertificate( + "cert-valid", "João da Silva", CertificateType.A3, CertificateStatus.AVAILABLE, + "CN=João da Silva:12345678909, O=ICP-Brasil, C=BR", "CN=AC Certisign, C=BR", + "ABC1234", "sha1_val", "sha256_val", + Instant.now().minus(30, ChronoUnit.DAYS), Instant.now().plus(300, ChronoUnit.DAYS), + "RSA", 2048, "João da Silva", "12345678909", null, "provider-mock", "Token USB" + ); + + when(mockProvider.listCertificates()).thenReturn(List.of(validCert)); + certificateManager.registerProvider(mockProvider); + certificateManager.discoverCertificates(); + + signingService = new SigningService(certificateManager); + } + + @Test + @DisplayName("Deve executar assinatura com sucesso delegando ao provider") + void shouldSignSuccessfully() throws Exception { + byte[] digest = new byte[32]; // SHA-256 digest + SigningContext context = new SigningContext( + "req-uuid-1", "app-erp", digest, "SHA256withRSA", "cert-valid", Instant.now() + ); + + SigningResult expectedResult = new SigningResult( + "req-uuid-1", new byte[]{1, 2, 3, 4}, "certBase64", + List.of("certBase64"), "SHA256withRSA", Instant.now() + ); + + when(mockProvider.sign(any())).thenReturn(expectedResult); + + SigningResult actual = signingService.sign(context); + + assertThat(actual).isNotNull(); + assertThat(actual.requestId()).isEqualTo("req-uuid-1"); + assertThat(actual.signatureBytes()).isEqualTo(new byte[]{1, 2, 3, 4}); + } + + @Test + @DisplayName("Deve rejeitar requisição sem request_id") + void shouldRejectMissingRequestId() { + byte[] digest = new byte[32]; + + assertThatThrownBy(() -> new SigningContext( + null, "app-erp", digest, "SHA256withRSA", "cert-valid", Instant.now() + )).isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("Deve falhar ao tentar assinar com algoritmo não suportado") + void shouldFailOnUnsupportedAlgorithm() { + byte[] digest = new byte[32]; + SigningContext context = new SigningContext( + "req-2", "app-erp", digest, "MD5withRSA", "cert-valid", Instant.now() + ); + + assertThatThrownBy(() -> signingService.sign(context)) + .isInstanceOf(CertificateProviderException.class) + .hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.UNSUPPORTED_ALGORITHM); + } + + @Test + @DisplayName("Deve falhar se o certificado estiver expirado") + void shouldFailOnExpiredCertificate() throws Exception { + ManagedCertificate expiredCert = new ManagedCertificate( + "cert-exp", "Maria Expirada", CertificateType.A1, CertificateStatus.EXPIRED, + "CN=Maria Expirada, C=BR", "CN=AC Raiz, C=BR", + "EXP123", "sha1_exp", "sha256_exp", + Instant.now().minus(300, ChronoUnit.DAYS), Instant.now().minus(10, ChronoUnit.DAYS), + "RSA", 2048, "Maria Expirada", null, null, "provider-mock", null + ); + + when(mockProvider.listCertificates()).thenReturn(List.of(expiredCert)); + certificateManager.discoverCertificates(); + + byte[] digest = new byte[32]; + SigningContext context = new SigningContext( + "req-3", "app-erp", digest, "SHA256withRSA", "cert-exp", Instant.now() + ); + + assertThatThrownBy(() -> signingService.sign(context)) + .isInstanceOf(CertificateProviderException.class) + .hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.CERTIFICATE_EXPIRED); + } + + @Test + @DisplayName("Deve falhar se o certificado não possuir chave privada") + void shouldFailWhenPrivateKeyNotAvailable() throws Exception { + ManagedCertificate noKeyCert = new ManagedCertificate( + "cert-nokey", "Certificado Público", CertificateType.A1, CertificateStatus.NO_PRIVATE_KEY, + "CN=Certificado Público, C=BR", "CN=AC Raiz, C=BR", + "NOKEY123", "sha1_nokey", "sha256_nokey", + Instant.now().minus(10, ChronoUnit.DAYS), Instant.now().plus(300, ChronoUnit.DAYS), + "RSA", 2048, "Certificado Público", null, null, "provider-mock", null + ); + + when(mockProvider.listCertificates()).thenReturn(List.of(noKeyCert)); + certificateManager.discoverCertificates(); + + byte[] digest = new byte[32]; + SigningContext context = new SigningContext( + "req-4", "app-erp", digest, "SHA256withRSA", "cert-nokey", Instant.now() + ); + + assertThatThrownBy(() -> signingService.sign(context)) + .isInstanceOf(CertificateProviderException.class) + .hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.PRIVATE_KEY_NOT_AVAILABLE); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/diagnostics/DiagnosticServiceTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/diagnostics/DiagnosticServiceTest.java new file mode 100644 index 0000000..418b012 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/diagnostics/DiagnosticServiceTest.java @@ -0,0 +1,22 @@ +package br.com.cybercore.signer.core.diagnostics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DiagnosticServiceTest { + + @Test + @DisplayName("Deve gerar relatório de diagnóstico com sucesso e validar crypto engine") + void shouldGenerateDiagnosticReport() { + DiagnosticService service = new DiagnosticService(); + DiagnosticReport report = service.runDiagnostic(); + + assertThat(report).isNotNull(); + assertThat(report.cryptoEngineOk()).isTrue(); + assertThat(report.javaVersion()).isNotBlank(); + assertThat(report.registeredSecurityProviders()).isNotEmpty(); + assertThat(report.additionalDetails()).containsKey("VirtualThreadsSupported"); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java new file mode 100644 index 0000000..6c061fc --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java @@ -0,0 +1,218 @@ +package br.com.cybercore.signer.core.pdf; + +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme; +import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdfSigningServiceTest { + + private CertificateManager certificateManager; + private PdfSigningService pdfSigningService; + private String testCertId; + + @BeforeEach + void setUp() throws Exception { + certificateManager = new CertificateManager(); + + // Gera par de chaves e certificado X.509 em memória + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + long now = System.currentTimeMillis(); + Date notBefore = new Date(now - 100000); + Date notAfter = new Date(now + 365L * 24 * 3600 * 1000); + + X500Name subject = new X500Name("CN=Joao da Silva:12345678900, O=CyberCore Testes, C=BR"); + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + subject, + BigInteger.valueOf(System.currentTimeMillis()), + notBefore, + notAfter, + subject, + kp.getPublic() + ); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer)); + + // Cria KeyStore PKCS12 em memória + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + char[] pwd = "password123".toCharArray(); + ks.setKeyEntry("test-cert", kp.getPrivate(), pwd, new Certificate[]{cert}); + + Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("test-provider", "Provedor de Teste"); + provider.setKeyStore(ks, pwd); + certificateManager.registerProvider(provider); + + List list = certificateManager.discoverCertificates(); + assertThat(list).isNotEmpty(); + this.testCertId = list.get(0).id(); + + this.pdfSigningService = new PdfSigningService(certificateManager); + } + + private byte[] createSamplePdf() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(); + doc.addPage(page); + + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 14); + cs.newLineAtOffset(100, 700); + cs.showText("Documento de Contrato de Teste - CyberCore Signer"); + cs.endText(); + } + doc.save(baos); + } + return baos.toByteArray(); + } + + @Test + @DisplayName("Deve assinar documento PDF aplicando selo visual com o tema ICP-Brasil") + void shouldSignPdfWithIcpBrasilVisualSeal() throws Exception { + byte[] originalPdf = createSamplePdf(); + + PdfSealTheme theme = PdfSealThemeRegistry.findById("icp-brasil").orElseThrow(); + PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId) + .sealTheme(theme) + .visibleSeal(true) + .position(PdfSealPosition.BOTTOM_RIGHT) + .pageChoice(PdfSealPageChoice.LAST_PAGE) + .reason("Aprovação Contratual") + .location("Brasília, DF") + .build(); + + byte[] signedPdf = pdfSigningService.signPdf(originalPdf, options); + + assertThat(signedPdf).isNotNull(); + assertThat(signedPdf.length).isGreaterThan(originalPdf.length); + + // Valida se o PDF possui a assinatura PDSignature incorporada + try (PDDocument signedDoc = Loader.loadPDF(signedPdf)) { + List signatures = signedDoc.getSignatureDictionaries(); + assertThat(signatures).hasSize(1); + + PDSignature sig = signatures.get(0); + assertThat(sig.getName()).contains("Joao da Silva"); + assertThat(sig.getReason()).isEqualTo("Aprovação Contratual"); + assertThat(sig.getLocation()).isEqualTo("Brasília, DF"); + } + } + + @Test + @DisplayName("Deve suportar todos os temas de selos visuais em SVG (CyberCore, Minimalista, Gov.br)") + void shouldSupportAllVisualSealThemes() throws Exception { + byte[] originalPdf = createSamplePdf(); + + for (PdfSealTheme theme : PdfSealThemeRegistry.listThemes()) { + PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId) + .sealTheme(theme) + .visibleSeal(true) + .position(PdfSealPosition.BOTTOM_LEFT) + .build(); + + byte[] signed = pdfSigningService.signPdf(originalPdf, options); + assertThat(signed).isNotNull(); + + try (PDDocument doc = Loader.loadPDF(signed)) { + assertThat(doc.getSignatureDictionaries()).hasSize(1); + } + } + } + + @Test + @DisplayName("Deve assinar PDF no modo invisível (sem selo gráfico)") + void shouldSignPdfInvisibleWithoutSeal() throws Exception { + byte[] originalPdf = createSamplePdf(); + + PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId) + .visibleSeal(false) + .build(); + + byte[] signedPdf = pdfSigningService.signPdf(originalPdf, options); + + try (PDDocument doc = Loader.loadPDF(signedPdf)) { + assertThat(doc.getSignatureDictionaries()).hasSize(1); + } + } + + @Test + @DisplayName("Deve assinar PDF com coordenadas customizadas e página específica") + void shouldSignPdfWithCustomCoordinatesAndPage() throws Exception { + byte[] originalPdf = createSamplePdf(); + + PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId) + .visibleSeal(true) + .customPosition(120.5f, 250.0f, 1) + .reason("Assinatura em local personalizado") + .location("São Paulo, SP") + .build(); + + assertThat(options.position()).isEqualTo(PdfSealPosition.CUSTOM); + assertThat(options.customX()).isEqualTo(120.5f); + assertThat(options.customY()).isEqualTo(250.0f); + assertThat(options.targetPageNumber()).isEqualTo(1); + + byte[] signedPdf = pdfSigningService.signPdf(originalPdf, options); + assertThat(signedPdf).isNotNull(); + + try (PDDocument doc = Loader.loadPDF(signedPdf)) { + assertThat(doc.getSignatureDictionaries()).hasSize(1); + PDSignature sig = doc.getSignatureDictionaries().get(0); + assertThat(sig.getReason()).isEqualTo("Assinatura em local personalizado"); + } + } + + @Test + @DisplayName("Deve contar páginas e renderizar imagem da página do PDF") + void shouldCountPagesAndRenderPageImage() throws Exception { + byte[] originalPdf = createSamplePdf(); + + int count = PdfSigningService.getPageCount(originalPdf); + assertThat(count).isEqualTo(1); + + java.awt.image.BufferedImage img = PdfSigningService.renderPageImage(originalPdf, 0, 72f); + assertThat(img).isNotNull(); + assertThat(img.getWidth()).isGreaterThan(100); + assertThat(img.getHeight()).isGreaterThan(100); + + byte[] pngBytes = PdfSigningService.renderPagePngBytes(originalPdf, 0, 72f); + assertThat(pngBytes).isNotEmpty(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/seal/CpfDisplayModeTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/seal/CpfDisplayModeTest.java new file mode 100644 index 0000000..9787008 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/seal/CpfDisplayModeTest.java @@ -0,0 +1,31 @@ +package br.com.cybercore.signer.core.pdf.seal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Testes do Modo de Exibição de CPF nos Selos (CpfDisplayMode)") +class CpfDisplayModeTest { + + @Test + @DisplayName("Deve formatar CPF completo com pontuação padrão") + void shouldFormatFullCpf() { + String formatted = CpfDisplayMode.FULL.formatCpf("12345678900"); + assertThat(formatted).isEqualTo("123.456.789-00"); + } + + @Test + @DisplayName("Deve formatar CPF mascarado protegendo dados sensíveis") + void shouldFormatMaskedCpf() { + String formatted = CpfDisplayMode.MASKED.formatCpf("12345678900"); + assertThat(formatted).isEqualTo("***.456.789-**"); + } + + @Test + @DisplayName("Deve omitir CPF quando o modo for NONE") + void shouldOmitCpfWhenNone() { + String formatted = CpfDisplayMode.NONE.formatCpf("12345678900"); + assertThat(formatted).isEmpty(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/seal/CustomSealStorageTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/seal/CustomSealStorageTest.java new file mode 100644 index 0000000..09017ee --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/seal/CustomSealStorageTest.java @@ -0,0 +1,137 @@ +package br.com.cybercore.signer.core.pdf.seal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Testes de Armazenamento e Instalação de Selos Personalizados (CustomSealStorage)") +class CustomSealStorageTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("Deve salvar, listar e excluir selos personalizados com sucesso") + void shouldSaveListAndDeleteCustomSeal() throws IOException { + CustomSealStorage storage = new CustomSealStorage(tempDir); + + PdfSealTheme customTheme = new PdfSealTheme( + "selo-meu-escritorio", + "Meu Escritório de Advocacia", + "Selo personalizado com brasão dourado e fundo escuro", + 320, + 82, + "#b45309", + "#d97706", + "#ffffff", + "#fde68a", + "#1f2937", + null, + "ADVOCACIA SILVA • ASSINADO DIGITALMENTE", + "Validade jurídica plena conforme MP nº 2.200-2/2001", + "notary", + CpfDisplayMode.MASKED, + true, + true + ); + + storage.saveSeal(customTheme); + + List seals = storage.loadAllCustomSeals(); + assertThat(seals).hasSize(1); + + PdfSealTheme loaded = seals.get(0); + assertThat(loaded.id()).isEqualTo("selo-meu-escritorio"); + assertThat(loaded.name()).isEqualTo("Meu Escritório de Advocacia"); + assertThat(loaded.customHeader()).isEqualTo("ADVOCACIA SILVA • ASSINADO DIGITALMENTE"); + assertThat(loaded.defaultCpfMode()).isEqualTo(CpfDisplayMode.MASKED); + assertThat(loaded.isCustom()).isTrue(); + + boolean deleted = storage.deleteSeal("selo-meu-escritorio"); + assertThat(deleted).isTrue(); + assertThat(storage.loadAllCustomSeals()).isEmpty(); + } + + @Test + @DisplayName("Deve salvar e carregar selo customizado com logotipo em imagem Base64") + void shouldSaveAndLoadCustomSealWithCustomLogo() throws IOException { + CustomSealStorage storage = new CustomSealStorage(tempDir); + + String sampleBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + PdfSealTheme customWithLogo = new PdfSealTheme( + "selo-logo-propria", + "Selo com Logo Própria", + "Selo para empresa com logotipo PNG em Base64", + 320, + 82, + "#1e3a8a", + "#3b82f6", + "#ffffff", + "#93c5fd", + "#172554", + null, + "MEU ESCRITÓRIO • ASSINADO DIGITALMENTE", + "Validade jurídica plena conforme MP nº 2.200-2/2001", + "shield", + CpfDisplayMode.FULL, + true, + true, + sampleBase64 + ); + + storage.saveSeal(customWithLogo); + + List seals = storage.loadAllCustomSeals(); + assertThat(seals).hasSize(1); + PdfSealTheme loaded = seals.get(0); + assertThat(loaded.id()).isEqualTo("selo-logo-propria"); + assertThat(loaded.customLogoBase64()).isEqualTo(sampleBase64); + assertThat(loaded.isCustom()).isTrue(); + } + + @Test + @DisplayName("Deve salvar e carregar selo customizado com campos customizados (ex: Matrícula / Cargo / OAB)") + void shouldSaveAndLoadCustomSealWithCustomFields() throws IOException { + CustomSealStorage storage = new CustomSealStorage(tempDir); + + PdfSealTheme customWithFields = new PdfSealTheme( + "selo-servidor-publico", + "Selo Servidor Público", + "Selo para auditores e servidores com matrícula funcional", + 320, + 82, + "#065f46", + "#059669", + "#ffffff", + "#a7f3d0", + "#064e3b", + null, + "TRIBUNAL DE CONTAS • AUDITORIA FISCAL", + "Autenticidade ICP-Brasil conferida", + "shield", + CpfDisplayMode.MASKED, + true, + true, + null, + "Matrícula", + "984210-AUD" + ); + + storage.saveSeal(customWithFields); + + List seals = storage.loadAllCustomSeals(); + assertThat(seals).hasSize(1); + PdfSealTheme loaded = seals.get(0); + assertThat(loaded.id()).isEqualTo("selo-servidor-publico"); + assertThat(loaded.customFieldLabel()).isEqualTo("Matrícula"); + assertThat(loaded.customFieldValue()).isEqualTo("984210-AUD"); + assertThat(loaded.formatCustomField()).isEqualTo("Matrícula: 984210-AUD"); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/seal/PdfSealRendererTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/seal/PdfSealRendererTest.java new file mode 100644 index 0000000..f9233f1 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/seal/PdfSealRendererTest.java @@ -0,0 +1,140 @@ +package br.com.cybercore.signer.core.pdf.seal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Testes de Renderização de Selos Visuais (PdfSealRenderer)") +class PdfSealRendererTest { + + @Test + @DisplayName("Deve renderizar selo visual em alta resolução com sucesso") + void shouldRenderSealImageInHighResolution() throws IOException { + PdfSealTheme theme = PdfSealThemeRegistry.getDefaultTheme(); + PdfSealData data = new PdfSealData( + "Dr. Carlos Alberto de Oliveira Santos Júnior", + "CPF: 12345678900", + null, + "AC CyberCore JUS v5", + "8A7F4B2C91D0E3F5", + "Assinatura Digital", + "São Paulo, SP", + CpfDisplayMode.MASKED, + true + ); + + BufferedImage img = PdfSealRenderer.renderToImage(theme, data); + assertThat(img).isNotNull(); + // Escala 2x: 320 * 2 = 640, 82 * 2 = 164 + assertThat(img.getWidth()).isEqualTo(640); + assertThat(img.getHeight()).isEqualTo(164); + + byte[] pngBytes = PdfSealRenderer.renderToPngBytes(theme, data); + assertThat(pngBytes).isNotEmpty(); + // Header PNG: 0x89 0x50 0x4E 0x47 + assertThat(pngBytes[0]).isEqualTo((byte) 0x89); + assertThat(pngBytes[1]).isEqualTo((byte) 0x50); + assertThat(pngBytes[2]).isEqualTo((byte) 0x4E); + assertThat(pngBytes[3]).isEqualTo((byte) 0x47); + } + + @Test + @DisplayName("Deve renderizar selo com logotipo personalizado em Base64") + void shouldRenderSealWithCustomLogoBase64() throws IOException { + // Pixel PNG 1x1 em Base64 + String sampleBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + PdfSealTheme themeWithLogo = new PdfSealTheme( + "custom-seal", + "Selo com Logo Própria", + "Descrição do selo", + 320, + 82, + "#0d47a1", + "#0284c7", + "#ffffff", + "#cbd5e1", + "#0f172a", + null, + "MEU ESCRITÓRIO • ASSINADO DIGITALMENTE", + "Validade jurídica plena conforme MP nº 2.200-2/2001", + "shield", + CpfDisplayMode.FULL, + false, + true, + sampleBase64 + ); + + PdfSealData data = new PdfSealData( + "Dra. Maria de Souza Ferreira", + "CPF: 98765432100", + null, + "AC Raiz ICP-Brasil", + "AABBCCDDEEFF0011", + "Assinatura de Contrato", + "Curitiba, PR", + CpfDisplayMode.FULL, + false + ); + + BufferedImage img = PdfSealRenderer.renderToImage(themeWithLogo, data); + assertThat(img).isNotNull(); + assertThat(img.getWidth()).isEqualTo(640); + assertThat(img.getHeight()).isEqualTo(164); + + byte[] pngBytes = PdfSealRenderer.renderToPngBytes(themeWithLogo, data); + assertThat(pngBytes).isNotEmpty(); + } + + @Test + @DisplayName("Deve renderizar selo quando CPF é omitido e sem data/hora (apenas nome em evidência)") + void shouldRenderSealWithOnlySignerName() throws IOException { + PdfSealTheme theme = PdfSealThemeRegistry.getDefaultTheme(); + PdfSealData data = new PdfSealData( + "Gabriel Soares de Alcantara", + "CPF: 11122233344", + null, + "AC OAB v3", + "12345678", + "Assinatura", + "Brasília, DF", + CpfDisplayMode.NONE, + false + ); + + BufferedImage img = PdfSealRenderer.renderToImage(theme, data); + assertThat(img).isNotNull(); + assertThat(img.getWidth()).isEqualTo(640); + assertThat(img.getHeight()).isEqualTo(164); + } + + @Test + @DisplayName("Deve renderizar selo com informação customizada (ex: Matrícula / Cargo) na linha de identificação") + void shouldRenderSealWithCustomExtraInfo() throws IOException { + PdfSealTheme theme = PdfSealThemeRegistry.getDefaultTheme(); + PdfSealData data = new PdfSealData( + "Dra. Juliana Mendes Silveira", + "CPF: 12345678900", + null, + "AC OAB Nacional", + "FEEDCBA012345678", + "Assinatura de Petição", + "São Paulo, SP", + CpfDisplayMode.MASKED, + true, + "OAB/SP 431.902" + ); + + BufferedImage img = PdfSealRenderer.renderToImage(theme, data); + assertThat(img).isNotNull(); + assertThat(img.getWidth()).isEqualTo(640); + assertThat(img.getHeight()).isEqualTo(164); + + byte[] pngBytes = PdfSealRenderer.renderToPngBytes(theme, data); + assertThat(pngBytes).isNotEmpty(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspConcurrencyTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspConcurrencyTest.java new file mode 100644 index 0000000..66a570d --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspConcurrencyTest.java @@ -0,0 +1,198 @@ +package br.com.cybercore.signer.core.protocol; + +import br.com.cybercore.signer.core.crypto.SigningService; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider; +import br.com.cybercore.signer.core.security.ApplicationRegistry; +import br.com.cybercore.signer.core.security.AuthorizedApplication; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.security.auth.x500.X500Principal; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("Testes de Concorrência e Multiaplicação do Protocolo CCSP/1 (CcspConcurrencyTest)") +class CcspConcurrencyTest { + + @TempDir + Path tempDir; + + private CcspServer server; + private int port; + private ApplicationRegistry registry; + private String certId; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void setUp() throws Exception { + // Encontra porta livre + try (ServerSocket socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } + + registry = new ApplicationRegistry(tempDir.resolve("apps.json")); + + // Cadastra 3 aplicações autorizadas distintas + registry.register(new AuthorizedApplication("app-erp", "ERP Corporativo", Set.of("http://localhost:3000"), true, Instant.now())); + registry.register(new AuthorizedApplication("app-juridico", "Portal Jurídico", Set.of("http://localhost:3001"), true, Instant.now())); + registry.register(new AuthorizedApplication("app-rh", "Sistema de RH", Set.of("http://localhost:3002"), true, Instant.now())); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + + X509Certificate cert = mock(X509Certificate.class); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=Empresa Concorrente:12345678000199, O=ICP-Brasil, C=BR")); + when(cert.getIssuerX500Principal()).thenReturn(new X500Principal("CN=AC Teste ICP-Brasil, C=BR")); + when(cert.getSerialNumber()).thenReturn(BigInteger.valueOf(987654321)); + when(cert.getNotBefore()).thenReturn(Date.from(Instant.now().minus(10, ChronoUnit.DAYS))); + when(cert.getNotAfter()).thenReturn(Date.from(Instant.now().plus(300, ChronoUnit.DAYS))); + when(cert.getPublicKey()).thenReturn(keyPair.getPublic()); + when(cert.getEncoded()).thenReturn(new byte[]{0x30, 0x10, 0x01, 0x02}); + + char[] password = "concurrency-password".toCharArray(); + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry("cert-real-a1", keyPair.getPrivate(), password, new Certificate[]{cert}); + + Pkcs12CertificateProvider a1Provider = new Pkcs12CertificateProvider(); + a1Provider.setKeyStore(keyStore, password); + + CertificateManager certManager = new CertificateManager(); + certManager.registerProvider(a1Provider); + certManager.discoverCertificates(); + + ManagedCertificate managedCert = certManager.getAvailableCertificates().get(0); + this.certId = managedCert.id(); + + SigningService signingService = new SigningService(certManager); + + server = new CcspServer(port, certManager, signingService, registry, null); + server.start(); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(); + } + } + + @Test + @DisplayName("Deve processar 16 requisições de assinatura concorrentes de aplicações distintas com provedor real sem corrupção") + void shouldHandleConcurrentSigningRequestsSafely() throws Exception { + int threadCount = 8; + int requestCount = 16; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + CountDownLatch startLatch = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + String[] appIds = {"app-erp", "app-juridico", "app-rh"}; + String[] origins = {"http://localhost:3000", "http://localhost:3001", "http://localhost:3002"}; + + for (int i = 0; i < requestCount; i++) { + final int index = i; + final String appId = appIds[i % appIds.length]; + final String origin = origins[i % origins.length]; + final String reqId = UUID.randomUUID().toString(); + final String nonce = UUID.randomUUID().toString(); + + Callable task = () -> { + // Aguarda largada simultânea + startLatch.await(5, TimeUnit.SECONDS); + + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(("Documento_Concorrente_" + index).getBytes(StandardCharsets.UTF_8)); + String digestBase64 = Base64.getEncoder().encodeToString(digest); + + CcspRequest request = new CcspRequest( + "1", + reqId, + appId, + "sign", + "SHA256withRSA", + digestBase64, + certId, + Instant.now().plus(60, ChronoUnit.SECONDS).toString(), + System.currentTimeMillis(), + nonce, + null + ); + + return sendSignRequest(request, origin, appId); + }; + + futures.add(executor.submit(task)); + } + + // Dispara todas simultaneamente + startLatch.countDown(); + + for (Future f : futures) { + CcspResponse resp = f.get(10, TimeUnit.SECONDS); + assertThat(resp).isNotNull(); + assertThat(resp.status()).isEqualTo("signed"); + assertThat(resp.signature()).isNotBlank(); + assertThat(resp.algorithm()).isEqualTo("SHA256withRSA"); + } + + executor.shutdown(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + + private CcspResponse sendSignRequest(CcspRequest request, String origin, String appId) throws Exception { + URI uri = URI.create("http://127.0.0.1:" + port + "/ccsp/v1/sign"); + HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Origin", origin); + conn.setRequestProperty("X-CyberCore-AppId", appId); + conn.setDoOutput(true); + + byte[] payload = mapper.writeValueAsBytes(request); + try (OutputStream os = conn.getOutputStream()) { + os.write(payload); + } + + try (InputStream is = conn.getInputStream()) { + return mapper.readValue(is, CcspResponse.class); + } + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspProtocolTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspProtocolTest.java new file mode 100644 index 0000000..d1d23dc --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspProtocolTest.java @@ -0,0 +1,87 @@ +package br.com.cybercore.signer.core.protocol; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CcspProtocolTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("Deve serializar e deserializar CcspRequest com sucesso") + void shouldSerializeAndDeserializeRequest() throws Exception { + String json = """ + { + "version": "1", + "request_id": "c1f7b640-5e6e-48a5-b1a7-0242ac120002", + "application_id": "app_erp_123", + "operation": "sign", + "algorithm": "SHA256withRSA", + "digest": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", + "certificate_hint": "serial_12345", + "expires_at": "2030-01-01T00:00:00Z", + "timestamp": 1728418000, + "nonce": "test_nonce_987", + "signature_type": "RAW_DIGEST" + } + """; + + CcspRequest request = objectMapper.readValue(json, CcspRequest.class); + + assertThat(request.version()).isEqualTo("1"); + assertThat(request.requestId()).isEqualTo("c1f7b640-5e6e-48a5-b1a7-0242ac120002"); + assertThat(request.applicationId()).isEqualTo("app_erp_123"); + assertThat(request.operation()).isEqualTo("sign"); + assertThat(request.algorithm()).isEqualTo("SHA256withRSA"); + assertThat(request.digest()).isEqualTo("47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="); + assertThat(request.isVersionSupported()).isTrue(); + assertThat(request.isExpired()).isFalse(); + } + + @Test + @DisplayName("Deve detectar requisição com expiração ultrapassada") + void shouldDetectExpiredRequest() { + String pastIso = Instant.now().minus(10, ChronoUnit.MINUTES).toString(); + CcspRequest request = new CcspRequest( + "1", "uuid", "app", "sign", "SHA256withRSA", "digest", null, pastIso, null, null, null + ); + + assertThat(request.isExpired()).isTrue(); + } + + @Test + @DisplayName("Deve serializar CcspResponse de sucesso") + void shouldSerializeSuccessResponse() throws Exception { + CcspResponse response = CcspResponse.success( + "req_001", + "sig_bytes_b64", + "cert_b64", + List.of("cert_b64", "ca_b64"), + "SHA256withRSA" + ); + + String json = objectMapper.writeValueAsString(response); + assertThat(json).contains("\"status\":\"signed\""); + assertThat(json).contains("\"signature\":\"sig_bytes_b64\""); + assertThat(json).contains("\"certificate\":\"cert_b64\""); + assertThat(json).doesNotContain("\"mock\""); + } + + @Test + @DisplayName("Deve serializar CcspResponse de erro estruturado") + void shouldSerializeErrorResponse() throws Exception { + CcspResponse response = CcspResponse.error("req_002", CcspErrorCode.USER_CANCELLED); + + String json = objectMapper.writeValueAsString(response); + assertThat(json).contains("\"status\":\"error\""); + assertThat(json).contains("\"error_code\":\"USER_CANCELLED\""); + assertThat(json).contains("Operação cancelada pelo usuário"); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspServerTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspServerTest.java new file mode 100644 index 0000000..65bc161 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/protocol/CcspServerTest.java @@ -0,0 +1,323 @@ +package br.com.cybercore.signer.core.protocol; + +import br.com.cybercore.signer.core.crypto.SigningService; +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.CertificateProvider; +import br.com.cybercore.signer.core.provider.ProviderType; +import br.com.cybercore.signer.core.security.ApplicationRegistry; +import br.com.cybercore.signer.core.security.AuthorizedApplication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("Testes de Integração do Servidor Local CCSP/1 (CcspServer)") +class CcspServerTest { + + private CcspServer server; + private HttpClient httpClient; + private String baseUrl; + + @BeforeEach + void setUp(@TempDir Path tempDir) throws Exception { + CertificateManager certManager = new CertificateManager(); + SigningService signingService = mock(SigningService.class); + ApplicationRegistry appRegistry = new ApplicationRegistry(tempDir.resolve("apps.json")); + + // Registra uma aplicação de teste + appRegistry.register(new AuthorizedApplication( + "app-test", + "App Web Teste", + Set.of("http://localhost:8080", "https://meusistema.com.br"), + true, + Instant.now() + )); + + // Mock de certificado + ManagedCertificate testCert = new ManagedCertificate( + "cert-1", + "AliasTeste", + CertificateType.A1, + CertificateStatus.AVAILABLE, + "CN=Teste ICP-Brasil, C=BR", + "CN=AC Raiz, C=BR", + "12345678", + "a1b2c3", + "d4e5f6", + Instant.now().minus(10, ChronoUnit.DAYS), + Instant.now().plus(300, ChronoUnit.DAYS), + "SHA256withRSA", + 2048, + "Teste ICP-Brasil", + null, + null, + "provider-test", + "Certificado de Teste" + ); + + CertificateProvider dummyProvider = new CertificateProvider() { + @Override public String getProviderId() { return "provider-test"; } + @Override public String getDisplayName() { return "Provedor Teste"; } + @Override public ProviderType getType() { return ProviderType.A1_FILE; } + @Override public boolean isAvailable() { return true; } + @Override public List listCertificates() { return List.of(testCert); } + @Override public SigningResult sign(SigningContext context) { + return new SigningResult(context.requestId(), new byte[]{1, 2, 3}, "mock-cert", List.of("mock-cert"), "SHA256withRSA", Instant.now()); + } + }; + + certManager.registerProvider(dummyProvider); + certManager.discoverCertificates(); + + when(signingService.sign(any())).thenReturn( + new SigningResult("req-1", new byte[]{10, 20, 30}, "base64-cert", List.of("base64-cert"), "SHA256withRSA", Instant.now()) + ); + + // Instancia o servidor em porta aleatória (0) + server = new CcspServer(0, certManager, signingService, appRegistry, new CcspUserPromptListener() { + @Override + public CompletableFuture requestApplicationAuthorization(String origin, String appName, String applicationId) { + return CompletableFuture.completedFuture(true); + } + + @Override + public CompletableFuture requestSignatureConfirmation(CcspRequest request, ManagedCertificate certificate) { + return CompletableFuture.completedFuture(true); + } + }); + + server.start(); + httpClient = HttpClient.newHttpClient(); + baseUrl = "http://127.0.0.1:" + server.getPort(); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(); + } + } + + @Test + @DisplayName("Endpoint /status deve retornar status ready e versão do agente") + void testGetStatus() throws Exception { + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ccsp/v1/status")) + .GET() + .build(); + + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).contains("\"status\":\"ready\""); + assertThat(resp.body()).contains("\"agent\":\"CyberCore Signer\""); + } + + @Test + @DisplayName("Endpoint /certificates deve retornar a lista de certificados disponíveis") + void testGetCertificates() throws Exception { + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ccsp/v1/certificates?application_id=app-test")) + .header("Origin", "https://meusistema.com.br") + .GET() + .build(); + + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).contains("\"status\":\"success\""); + assertThat(resp.body()).contains("CN=Teste ICP-Brasil, C=BR"); + } + + @Test + @DisplayName("Requisição OPTIONS deve retornar headers CORS válidos") + void testCorsPreflight() throws Exception { + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ccsp/v1/sign")) + .method("OPTIONS", HttpRequest.BodyPublishers.noBody()) + .header("Origin", "https://meusistema.com.br") + .build(); + + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.discarding()); + assertThat(resp.statusCode()).isEqualTo(204); + assertThat(resp.headers().firstValue("Access-Control-Allow-Origin")).hasValue("https://meusistema.com.br"); + assertThat(resp.headers().firstValue("Access-Control-Allow-Methods")).hasValue("GET, POST, OPTIONS"); + } + + @Test + @DisplayName("Endpoint /sign deve assinar digest com sucesso") + void testSignSuccess() throws Exception { + byte[] docHash = MessageDigest.getInstance("SHA-256").digest("Contrato Digital".getBytes()); + String hashBase64 = Base64.getEncoder().encodeToString(docHash); + + String jsonPayload = """ + { + "version": "1", + "request_id": "%s", + "application_id": "app-test", + "operation": "sign", + "algorithm": "SHA256withRSA", + "digest": "%s", + "certificate_hint": "cert-1", + "nonce": "%s" + } + """.formatted(UUID.randomUUID(), hashBase64, UUID.randomUUID()); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ccsp/v1/sign")) + .header("Content-Type", "application/json") + .header("Origin", "https://meusistema.com.br") + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) + .build(); + + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.body()).contains("\"status\":\"signed\""); + assertThat(resp.body()).contains("\"signature\":"); + } + + @Test + @DisplayName("Endpoint /sign deve prevenir replay attack se mesmo nonce for submetido") + void testReplayPrevention() throws Exception { + byte[] docHash = MessageDigest.getInstance("SHA-256").digest("Documento".getBytes()); + String hashBase64 = Base64.getEncoder().encodeToString(docHash); + String fixedNonce = "nonce-repetido-123"; + + String jsonPayload = """ + { + "version": "1", + "request_id": "%s", + "application_id": "app-test", + "operation": "sign", + "algorithm": "SHA256withRSA", + "digest": "%s", + "certificate_hint": "cert-1", + "nonce": "%s" + } + """.formatted(UUID.randomUUID(), hashBase64, fixedNonce); + + HttpRequest req1 = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ccsp/v1/sign")) + .header("Content-Type", "application/json") + .header("Origin", "https://meusistema.com.br") + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) + .build(); + + HttpResponse resp1 = httpClient.send(req1, HttpResponse.BodyHandlers.ofString()); + assertThat(resp1.statusCode()).isEqualTo(200); + + // Segunda tentativa com mesmo nonce deve falhar por REPLAY_DETECTED + String jsonPayload2 = """ + { + "version": "1", + "request_id": "%s", + "application_id": "app-test", + "operation": "sign", + "algorithm": "SHA256withRSA", + "digest": "%s", + "certificate_hint": "cert-1", + "nonce": "%s" + } + """.formatted(UUID.randomUUID(), hashBase64, fixedNonce); + + HttpRequest req2 = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ccsp/v1/sign")) + .header("Content-Type", "application/json") + .header("Origin", "https://meusistema.com.br") + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload2)) + .build(); + + HttpResponse resp2 = httpClient.send(req2, HttpResponse.BodyHandlers.ofString()); + assertThat(resp2.statusCode()).isEqualTo(400); + assertThat(resp2.body()).contains("REPLAY_DETECTED"); + } + + @Test + @DisplayName("Deve autenticar usuário via /ccsp/v1/authenticate assinando o desafio do sistema") + void shouldAuthenticateUserWithChallengeSuccessfully() throws Exception { + String jsonPayload = """ + { + "version": "1", + "request_id": "%s", + "application_id": "app-test", + "operation": "authenticate", + "challenge": "portal-auth-challenge-%s", + "certificate_hint": "cert-1", + "nonce": "%s" + } + """.formatted(UUID.randomUUID(), System.currentTimeMillis(), UUID.randomUUID()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ccsp/v1/authenticate")) + .header("Content-Type", "application/json") + .header("Origin", "https://meusistema.com.br") + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("\"status\":\"authenticated\""); + assertThat(response.body()).contains("\"signature\":\"ChQe\""); + assertThat(response.body()).contains("\"subject_name\":\"Teste ICP-Brasil\""); + } + + @Test + @DisplayName("Deve assinar documento PDF preservando metadados de arquivo na resposta") + void shouldSignDocumentWithMetadataPdfSuccessfully() throws Exception { + byte[] hash = MessageDigest.getInstance("SHA-256").digest("PDF_CONTENT_TEST".getBytes()); + String hashBase64 = Base64.getEncoder().encodeToString(hash); + + String jsonPayload = """ + { + "version": "1", + "request_id": "%s", + "application_id": "app-test", + "operation": "sign", + "algorithm": "SHA256withRSA", + "digest": "%s", + "certificate_hint": "cert-1", + "document_name": "proposta_comercial.pdf", + "document_type": "PDF", + "nonce": "%s" + } + """.formatted(UUID.randomUUID(), hashBase64, UUID.randomUUID()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ccsp/v1/sign")) + .header("Content-Type", "application/json") + .header("Origin", "https://meusistema.com.br") + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("\"status\":\"signed\""); + assertThat(response.body()).contains("\"document_name\":\"proposta_comercial.pdf\""); + assertThat(response.body()).contains("\"document_type\":\"PDF\""); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/provider/CertificateManagerTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/provider/CertificateManagerTest.java new file mode 100644 index 0000000..f537f1d --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/provider/CertificateManagerTest.java @@ -0,0 +1,66 @@ +package br.com.cybercore.signer.core.provider; + +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CertificateManagerTest { + + @Test + @DisplayName("Deve inicializar com provedores padrão") + void shouldCreateDefaultManagerWithProviders() { + CertificateManager manager = CertificateManager.createDefault(); + + assertThat(manager.getProviders()).isNotEmpty(); + assertThat(manager.getProviders()) + .extracting(CertificateProvider::getProviderId) + .contains("provider-win-store", "provider-a1", "provider-pkcs11"); + } + + @Test + @DisplayName("Deve descobrir certificados de múltiplos provedores e indexá-los") + void shouldDiscoverAndIndexCertificates() throws Exception { + CertificateManager manager = new CertificateManager(); + + CertificateProvider providerA1 = mock(CertificateProvider.class); + when(providerA1.getProviderId()).thenReturn("provider-a1"); + when(providerA1.isAvailable()).thenReturn(true); + + ManagedCertificate cert1 = new ManagedCertificate( + "cert-1", "Empresa ABC", CertificateType.A1, CertificateStatus.AVAILABLE, + "CN=Empresa ABC", "CN=AC Raiz", "123", "sha1_1", "sha256_1", + Instant.now().minus(1, ChronoUnit.DAYS), Instant.now().plus(300, ChronoUnit.DAYS), + "RSA", 2048, "Empresa ABC", null, "11222333000199", "provider-a1", null + ); + when(providerA1.listCertificates()).thenReturn(List.of(cert1)); + + manager.registerProvider(providerA1); + + List discovered = manager.discoverCertificates(); + + assertThat(discovered).hasSize(1); + assertThat(discovered.get(0).id()).isEqualTo("cert-1"); + assertThat(manager.getProviderForCertificate("cert-1")).isSameAs(providerA1); + + // Busca por ID + Optional byId = manager.findById("cert-1"); + assertThat(byId).isPresent(); + assertThat(byId.get().commonName()).isEqualTo("Empresa ABC"); + + // Busca por CNPJ + Optional byCnpj = manager.findByHint("11222333000199"); + assertThat(byCnpj).isPresent(); + assertThat(byCnpj.get().id()).isEqualTo("cert-1"); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/provider/Pkcs12CertificateProviderTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/provider/Pkcs12CertificateProviderTest.java new file mode 100644 index 0000000..2c57824 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/provider/Pkcs12CertificateProviderTest.java @@ -0,0 +1,130 @@ +package br.com.cybercore.signer.core.provider; + +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.security.auth.x500.X500Principal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class Pkcs12CertificateProviderTest { + + private Pkcs12CertificateProvider provider; + private KeyPair keyPair; + private X509Certificate mockCert; + private KeyStore keyStore; + private static final char[] PASSWORD = "senha-teste".toCharArray(); + + @BeforeEach + void setUp() throws Exception { + provider = new Pkcs12CertificateProvider(); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + keyPair = kpg.generateKeyPair(); + + mockCert = mock(X509Certificate.class); + when(mockCert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=Empresa Teste:12345678000199, O=ICP-Brasil, C=BR")); + when(mockCert.getIssuerX500Principal()).thenReturn(new X500Principal("CN=AC Teste ICP-Brasil, C=BR")); + when(mockCert.getSerialNumber()).thenReturn(BigInteger.valueOf(987654321)); + when(mockCert.getNotBefore()).thenReturn(Date.from(Instant.now().minus(10, ChronoUnit.DAYS))); + when(mockCert.getNotAfter()).thenReturn(Date.from(Instant.now().plus(300, ChronoUnit.DAYS))); + when(mockCert.getPublicKey()).thenReturn(keyPair.getPublic()); + when(mockCert.getEncoded()).thenReturn(new byte[]{0x30, 0x10, 0x01, 0x02}); + + keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry("alias-a1", keyPair.getPrivate(), PASSWORD, new Certificate[]{mockCert}); + } + + @Test + @DisplayName("Deve listar certificados carregados no KeyStore PKCS#12") + void shouldListCertificatesFromKeyStore() throws Exception { + provider.setKeyStore(keyStore, PASSWORD); + + assertThat(provider.isAvailable()).isTrue(); + List certs = provider.listCertificates(); + + assertThat(certs).hasSize(1); + ManagedCertificate cert = certs.get(0); + assertThat(cert.alias()).isEqualTo("alias-a1"); + assertThat(cert.type()).isEqualTo(CertificateType.A1); + assertThat(cert.status()).isEqualTo(CertificateStatus.AVAILABLE); + assertThat(cert.cnpj()).isEqualTo("12345678000199"); + } + + @Test + @DisplayName("Deve assinar digest com sucesso utilizando chave RSA do PKCS#12") + void shouldSignDigestSuccessfully() throws Exception { + provider.setKeyStore(keyStore, PASSWORD); + + byte[] originalData = "Documento Contratual CyberCore 2026".getBytes(StandardCharsets.UTF_8); + byte[] sha256Digest = MessageDigest.getInstance("SHA-256").digest(originalData); + + SigningContext context = new SigningContext( + "req-101", + "app-web-01", + sha256Digest, + "SHA256withRSA", + "alias-a1", + Instant.now() + ); + + SigningResult result = provider.sign(context); + + assertThat(result).isNotNull(); + assertThat(result.requestId()).isEqualTo("req-101"); + assertThat(result.algorithm()).isEqualTo("SHA256withRSA"); + assertThat(result.signatureBytes()).isNotEmpty(); + + // Validação criptográfica da assinatura gerada + Signature verifier = Signature.getInstance("SHA256withRSA"); + verifier.initVerify(keyPair.getPublic()); + verifier.update(sha256Digest); + assertThat(verifier.verify(result.signatureBytes())).isTrue(); + } + + @Test + @DisplayName("Deve falhar ao tentar assinar com algoritmo incompatível ou digest inválido") + void shouldFailOnInvalidDigest() { + provider.setKeyStore(keyStore, PASSWORD); + + byte[] invalidDigest = new byte[10]; // Digest menor que os 32 bytes de SHA-256 + + SigningContext context = new SigningContext( + "req-102", + "app-web-01", + invalidDigest, + "SHA256withRSA", + "alias-a1", + Instant.now() + ); + + assertThatThrownBy(() -> provider.sign(context)) + .isInstanceOf(CertificateProviderException.class) + .hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.SIGNATURE_FAILED); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/provider/WindowsCertificateStoreProviderTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/provider/WindowsCertificateStoreProviderTest.java new file mode 100644 index 0000000..cc88720 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/provider/WindowsCertificateStoreProviderTest.java @@ -0,0 +1,47 @@ +package br.com.cybercore.signer.core.provider; + +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WindowsCertificateStoreProviderTest { + + @Test + @DisplayName("Deve inicializar o provedor e reportar identificador correto") + void shouldReportCorrectMetadata() { + WindowsCertificateStoreProvider provider = new WindowsCertificateStoreProvider(); + + assertThat(provider.getProviderId()).isEqualTo("provider-win-store"); + assertThat(provider.getDisplayName()).contains("Windows"); + assertThat(provider.getType()).isEqualTo(ProviderType.A3_WINDOWS_STORE); + } + + @Test + @DisplayName("Em ambiente não-Windows deve tratar graciosamente a indisponibilidade") + void shouldHandleNonWindowsGracefully() throws Exception { + WindowsCertificateStoreProvider provider = new WindowsCertificateStoreProvider(); + + boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win"); + if (!isWindows) { + assertThat(provider.isAvailable()).isFalse(); + assertThat(provider.listCertificates()).isEmpty(); + + byte[] digest = new byte[32]; + SigningContext context = new SigningContext( + "req_test", "app_1", digest, "SHA256withRSA", "cert_hint", Instant.now() + ); + + assertThatThrownBy(() -> provider.sign(context)) + .isInstanceOf(CertificateProviderException.class) + .hasFieldOrPropertyWithValue("errorCode", CcspErrorCode.PROVIDER_NOT_AVAILABLE); + } + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/ApplicationRegistryTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/ApplicationRegistryTest.java new file mode 100644 index 0000000..0c2cc9f --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/ApplicationRegistryTest.java @@ -0,0 +1,47 @@ +package br.com.cybercore.signer.core.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Testes do Registro de Aplicações Autorizadas (ApplicationRegistry)") +class ApplicationRegistryTest { + + @Test + @DisplayName("Deve registrar, consultar e persistir aplicação com origens permitidas") + void testRegisterAndPersist(@TempDir Path tempDir) { + Path jsonPath = tempDir.resolve("test-apps.json"); + ApplicationRegistry registry = new ApplicationRegistry(jsonPath); + + AuthorizedApplication app = new AuthorizedApplication( + "app-123", + "Portal ERP Corporativo", + Set.of("https://erp.empresa.com.br", "http://localhost:3000"), + true, + Instant.now() + ); + + registry.register(app); + + assertThat(registry.findById("app-123")).isPresent(); + assertThat(registry.isAuthorized("app-123", "https://erp.empresa.com.br")).isTrue(); + assertThat(registry.isAuthorized("app-123", "https://malicious-site.com")).isFalse(); + + // Novo registry no mesmo arquivo para validar persistência + ApplicationRegistry reloaded = new ApplicationRegistry(jsonPath); + Optional loaded = reloaded.findById("app-123"); + assertThat(loaded).isPresent(); + assertThat(loaded.get().name()).isEqualTo("Portal ERP Corporativo"); + + // Revogação + reloaded.revoke("app-123"); + assertThat(reloaded.findById("app-123")).isEmpty(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/NonceManagerTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/NonceManagerTest.java new file mode 100644 index 0000000..4a59247 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/NonceManagerTest.java @@ -0,0 +1,31 @@ +package br.com.cybercore.signer.core.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class NonceManagerTest { + + @Test + @DisplayName("Deve aceitar primeiro registro e rejeitar repetição (replay)") + void shouldAcceptOnceAndRejectDuplicate() { + NonceManager manager = new NonceManager(Duration.ofMinutes(1)); + + boolean first = manager.registerNonce("nonce_abc_123"); + boolean second = manager.registerNonce("nonce_abc_123"); + + assertThat(first).isTrue(); + assertThat(second).isFalse(); + } + + @Test + @DisplayName("Deve rejeitar nonce nulo ou vazio") + void shouldRejectInvalidNonce() { + NonceManager manager = new NonceManager(); + assertThat(manager.registerNonce(null)).isFalse(); + assertThat(manager.registerNonce(" ")).isFalse(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/OriginValidatorTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/OriginValidatorTest.java new file mode 100644 index 0000000..a417299 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/OriginValidatorTest.java @@ -0,0 +1,46 @@ +package br.com.cybercore.signer.core.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class OriginValidatorTest { + + private final OriginValidator validator = new OriginValidator(); + + @Test + @DisplayName("Deve permitir origem exatamente autorizada") + void shouldAllowAuthorizedOrigin() { + AuthorizedApplication app = new AuthorizedApplication( + "app_1", "Portal ERP", Set.of("https://erp.empresa.com.br", "http://localhost:3000"), true, null + ); + + assertThat(validator.isAllowed("https://erp.empresa.com.br", app)).isTrue(); + assertThat(validator.isAllowed("http://localhost:3000", app)).isTrue(); + } + + @Test + @DisplayName("Deve rejeitar origens não cadastradas ou fraudulentas") + void shouldRejectUnauthorizedOrigin() { + AuthorizedApplication app = new AuthorizedApplication( + "app_1", "Portal ERP", Set.of("https://erp.empresa.com.br"), true, null + ); + + assertThat(validator.isAllowed("https://malicious-site.com", app)).isFalse(); + assertThat(validator.isAllowed("https://erp.empresa.com.br.attacker.com", app)).isFalse(); + assertThat(validator.isAllowed(null, app)).isFalse(); + } + + @Test + @DisplayName("Deve rejeitar qualquer origem se a aplicação estiver desabilitada") + void shouldRejectWhenApplicationIsDisabled() { + AuthorizedApplication app = new AuthorizedApplication( + "app_1", "Portal ERP", Set.of("https://erp.empresa.com.br"), false, null + ); + + assertThat(validator.isAllowed("https://erp.empresa.com.br", app)).isFalse(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/PinSessionManagerTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/PinSessionManagerTest.java new file mode 100644 index 0000000..759750e --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/PinSessionManagerTest.java @@ -0,0 +1,61 @@ +package br.com.cybercore.signer.core.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PinSessionManagerTest { + + private PinSessionManager manager; + + @BeforeEach + void setUp() { + manager = new PinSessionManager(); + manager.clearAll(); + } + + @Test + @DisplayName("Deve salvar e recuperar PIN de sessão para um certificado") + void shouldSaveAndRetrievePin() { + char[] pin = "1234".toCharArray(); + manager.savePin("cert-1", pin); + + assertThat(manager.hasPin("cert-1")).isTrue(); + assertThat(manager.getPin("cert-1")).isEqualTo("1234".toCharArray()); + } + + @Test + @DisplayName("Deve limpar PIN e sobrescrever array com espaços") + void shouldClearPinAndWipeMemory() { + char[] pin = "5678".toCharArray(); + manager.savePin("cert-2", pin); + + manager.clearPin("cert-2"); + assertThat(manager.hasPin("cert-2")).isFalse(); + assertThat(manager.getPin("cert-2")).isNull(); + } + + @Test + @DisplayName("Quando alwaysPromptPin estiver ativo, não deve retornar PIN salvo") + void shouldIgnoreStoredPinWhenAlwaysPromptIsActive() { + manager.savePin("cert-3", "9999".toCharArray()); + manager.setAlwaysPromptPin(true); + + assertThat(manager.hasPin("cert-3")).isFalse(); + assertThat(manager.getPin("cert-3")).isNull(); + } + + @Test + @DisplayName("Deve limpar todos os PINs em memória") + void shouldClearAllPins() { + manager.savePin("cert-a", "1111".toCharArray()); + manager.savePin("cert-b", "2222".toCharArray()); + + manager.clearAll(); + + assertThat(manager.hasPin("cert-a")).isFalse(); + assertThat(manager.hasPin("cert-b")).isFalse(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/SmartCardSessionManagerTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/SmartCardSessionManagerTest.java new file mode 100644 index 0000000..4627671 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/SmartCardSessionManagerTest.java @@ -0,0 +1,18 @@ +package br.com.cybercore.signer.core.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@DisplayName("Testes de Gerenciamento de Sessão de Smart Card (SmartCardSessionManager)") +class SmartCardSessionManagerTest { + + @Test + @DisplayName("Deve executar resetCardSessions sem lançar exceções mesmo se o hardware não estiver presente") + void testResetCardSessionsSafeExecution() { + assertDoesNotThrow(() -> { + SmartCardSessionManager.resetCardSessions(); + }); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/SmartCardWatcherTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/SmartCardWatcherTest.java new file mode 100644 index 0000000..e49d519 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/security/SmartCardWatcherTest.java @@ -0,0 +1,51 @@ +package br.com.cybercore.signer.core.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Testes de Monitoramento Dinâmico de Smart Cards (SmartCardWatcher)") +class SmartCardWatcherTest { + + @Test + @DisplayName("Deve gerenciar ciclo de vida (start/stop) sem exceção") + void shouldManageLifecycleGracefully() { + SmartCardWatcher watcher = new SmartCardWatcher(); + assertThat(watcher.isRunning()).isFalse(); + + watcher.start(); + assertThat(watcher.isRunning()).isTrue(); + + watcher.stop(); + assertThat(watcher.isRunning()).isFalse(); + } + + @Test + @DisplayName("Deve adicionar e remover listeners corretamente") + void shouldAddAndRemoveListeners() { + SmartCardWatcher watcher = new SmartCardWatcher(); + AtomicBoolean inserted = new AtomicBoolean(false); + AtomicBoolean removed = new AtomicBoolean(false); + + SmartCardWatcher.CardEventListener listener = new SmartCardWatcher.CardEventListener() { + @Override + public void onCardInserted(String terminalName) { + inserted.set(true); + } + + @Override + public void onCardRemoved(String terminalName) { + removed.set(true); + } + }; + + watcher.addListener(listener); + watcher.removeListener(listener); + // Não deve falhar nem disparar listeners removidos + assertThat(inserted.get()).isFalse(); + assertThat(removed.get()).isFalse(); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/validation/SignatureValidationServiceTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/validation/SignatureValidationServiceTest.java new file mode 100644 index 0000000..5dfdfa5 --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/validation/SignatureValidationServiceTest.java @@ -0,0 +1,183 @@ +package br.com.cybercore.signer.core.validation; + +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.pdf.PdfSignatureOptions; +import br.com.cybercore.signer.core.pdf.PdfSigningService; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.cms.CMSProcessableByteArray; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.CMSSignedDataGenerator; +import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Testes do Serviço de Validação de Assinaturas (SignatureValidationService)") +class SignatureValidationServiceTest { + + private SignatureValidationService validationService; + private KeyPair keyPair; + private X509Certificate certificate; + private CertificateManager certificateManager; + private String testCertId; + + @BeforeEach + void setUp() throws Exception { + validationService = new SignatureValidationService(); + + // 1. Gera par de chaves e certificado X.509 em memória + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + keyPair = kpg.generateKeyPair(); + + long now = System.currentTimeMillis(); + Date notBefore = new Date(now - 10000); + Date notAfter = new Date(now + 365L * 24 * 3600 * 1000); + + X500Name subject = new X500Name("CN=Advogado Teste da Silva:98765432100, O=CyberCore, C=BR"); + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + subject, + BigInteger.valueOf(now), + notBefore, + notAfter, + subject, + keyPair.getPublic() + ); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + certificate = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer)); + + // 2. Configura KeyStore em memória e CertificateManager + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("test-alias", keyPair.getPrivate(), "secret".toCharArray(), new Certificate[]{certificate}); + + Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("test-provider", "Provedor de Teste"); + provider.setKeyStore(ks, "secret".toCharArray()); + certificateManager = new CertificateManager(); + certificateManager.registerProvider(provider); + + List certs = certificateManager.discoverCertificates(); + testCertId = certs.get(0).id(); + } + + @Test + @DisplayName("Deve validar com sucesso uma assinatura CMS destacada (Detached)") + void shouldValidateDetachedSignatureSuccessfully() throws Exception { + byte[] docBytes = "Conteúdo importante do contrato digital CyberCore.".getBytes(StandardCharsets.UTF_8); + + // Gera assinatura CMS destacada + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + ContentSigner sha256Signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder( + new JcaDigestCalculatorProviderBuilder().build() + ).build(sha256Signer, certificate)); + gen.addCertificate(new X509CertificateHolder(certificate.getEncoded())); + + CMSSignedData signedData = gen.generate(new CMSProcessableByteArray(docBytes), false); + byte[] signatureBytes = signedData.getEncoded(); + + ValidationResult result = validationService.validateDetached(docBytes, signatureBytes, "contrato.txt"); + + assertThat(result.isValid()).isTrue(); + assertThat(result.status()).isEqualTo(ValidationStatus.VALID); + assertThat(result.type()).isEqualTo(ValidationType.DETACHED_CMS); + assertThat(result.signers()).hasSize(1); + assertThat(result.signers().get(0).signerName()).contains("Advogado Teste da Silva"); + assertThat(result.signers().get(0).digestValid()).isTrue(); + assertThat(result.signers().get(0).signatureValid()).isTrue(); + } + + @Test + @DisplayName("Deve acusar falha de validação quando o documento original foi alterado (adulteração)") + void shouldFailWhenDocumentHasBeenTampered() throws Exception { + byte[] originalBytes = "Documento original intocado.".getBytes(StandardCharsets.UTF_8); + byte[] tamperedBytes = "Documento adulterado maliciosamente.".getBytes(StandardCharsets.UTF_8); + + // Gera assinatura do original + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + ContentSigner sha256Signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder( + new JcaDigestCalculatorProviderBuilder().build() + ).build(sha256Signer, certificate)); + gen.addCertificate(new X509CertificateHolder(certificate.getEncoded())); + + CMSSignedData signedData = gen.generate(new CMSProcessableByteArray(originalBytes), false); + byte[] signatureBytes = signedData.getEncoded(); + + // Valida passando o documento adulterado + ValidationResult result = validationService.validateDetached(tamperedBytes, signatureBytes, "doc.txt"); + + assertThat(result.isValid()).isFalse(); + assertThat(result.status()).isEqualTo(ValidationStatus.INVALID); + } + + @Test + @DisplayName("Deve validar com sucesso um documento PDF assinado (PAdES Incorporado)") + void shouldValidateSignedPdfSuccessfully() throws Exception { + // Cria PDF simples em memória + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage()); + doc.save(baos); + } + byte[] rawPdf = baos.toByteArray(); + + // Assina o PDF via PdfSigningService + PdfSigningService signingService = new PdfSigningService(certificateManager); + PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId) + .visibleSeal(true) + .reason("Assinatura de teste unitário") + .build(); + + byte[] signedPdf = signingService.signPdf(rawPdf, options); + + // Valida como assinatura incorporada + ValidationResult result = validationService.validateAttached(signedPdf, "documento_assinado.pdf"); + + assertThat(result.isValid()).isTrue(); + assertThat(result.type()).isEqualTo(ValidationType.PADES_PDF); + assertThat(result.signers()).isNotEmpty(); + assertThat(result.signers().get(0).signatureValid()).isTrue(); + } + + @Test + @DisplayName("Deve acusar inválido para PDF que não possui assinaturas") + void shouldFailForUnsignedPdf() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage()); + doc.save(baos); + } + + ValidationResult result = validationService.validateAttached(baos.toByteArray(), "em_branco.pdf"); + + assertThat(result.isValid()).isFalse(); + assertThat(result.type()).isEqualTo(ValidationType.PADES_PDF); + assertThat(result.summaryMessage()).contains("Nenhuma assinatura digital encontrada"); + } +} diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/xml/XmlSigningServiceTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/xml/XmlSigningServiceTest.java new file mode 100644 index 0000000..254028b --- /dev/null +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/xml/XmlSigningServiceTest.java @@ -0,0 +1,152 @@ +package br.com.cybercore.signer.core.xml; + +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class XmlSigningServiceTest { + + private CertificateManager certificateManager; + private XmlSigningService xmlSigningService; + private String testCertId; + + @BeforeEach + void setUp() throws Exception { + certificateManager = new CertificateManager(); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + long now = System.currentTimeMillis(); + Date notBefore = new Date(now - 100000); + Date notAfter = new Date(now + 365L * 24 * 3600 * 1000); + + X500Name subject = new X500Name("CN=Empresa Teste LTDA:98765432000199, O=CyberCore XML, C=BR"); + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + subject, + BigInteger.valueOf(System.currentTimeMillis()), + notBefore, + notAfter, + subject, + kp.getPublic() + ); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer)); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + char[] pwd = "xmlpassword".toCharArray(); + ks.setKeyEntry("xml-cert", kp.getPrivate(), pwd, new Certificate[]{cert}); + + Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider("xml-provider", "Provedor XML Teste"); + provider.setKeyStore(ks, pwd); + certificateManager.registerProvider(provider); + + List list = certificateManager.discoverCertificates(); + this.testCertId = list.get(0).id(); + + this.xmlSigningService = new XmlSigningService(certificateManager); + } + + @Test + @DisplayName("Deve assinar documento XML completo com XMLDSig Enveloped e validar com sucesso") + void shouldSignWholeXmlDocumentAndValidate() throws Exception { + String xml = """ + + + Fulano de Tal + 1500.00 + + """; + + XmlSignatureOptions options = XmlSignatureOptions.wholeDocument(testCertId, null); + byte[] signedXmlBytes = xmlSigningService.signXml(xml.getBytes(StandardCharsets.UTF_8), options); + + String signedXmlStr = new String(signedXmlBytes, StandardCharsets.UTF_8); + assertThat(signedXmlStr).contains(" + + + + 1 + + + 250.00 + + + + """; + + XmlSignatureOptions options = XmlSignatureOptions.forElement( + testCertId, + null, + "infNFe", + "NFe35261012345678000195550010000000011000000010" + ); + + byte[] signedNfe = xmlSigningService.signXml(nfeXml.getBytes(StandardCharsets.UTF_8), options); + String signedStr = new String(signedNfe, StandardCharsets.UTF_8); + + assertThat(signedStr).contains("URI=\"#NFe35261012345678000195550010000000011000000010\""); + + boolean isValid = xmlSigningService.validateXmlSignature(signedNfe); + assertThat(isValid).isTrue(); + } + + @Test + @DisplayName("Deve detectar adulteração em XML assinado e reprovar na validação") + void shouldDetectTamperingInSignedXml() throws Exception { + String xml = """ + + + Pagamento de R$ 100,00 + + """; + + XmlSignatureOptions options = XmlSignatureOptions.wholeDocument(testCertId, null); + byte[] signedXml = xmlSigningService.signXml(xml.getBytes(StandardCharsets.UTF_8), options); + + // Adulteração intencional do conteúdo original + String tampered = new String(signedXml, StandardCharsets.UTF_8) + .replace("R$ 100,00", "R$ 999.999,00"); + + boolean isValid = xmlSigningService.validateXmlSignature(tampered.getBytes(StandardCharsets.UTF_8)); + assertThat(isValid).isFalse(); + } +} diff --git a/modules/cybercore-ui/pom.xml b/modules/cybercore-ui/pom.xml new file mode 100644 index 0000000..dd7fc5f --- /dev/null +++ b/modules/cybercore-ui/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + + br.com.cybercore.signer + cybercore-signer-parent + 0.1.0-SNAPSHOT + ../../pom.xml + + + cybercore-ui + jar + + CyberCore Signer :: UI + Camada de apresentação JavaFX, System Tray nativo, diálogos e gerenciamento de temas. + + + + br.com.cybercore.signer + cybercore-core + + + + org.openjfx + javafx-controls + + + org.openjfx + javafx-graphics + + + org.openjfx + javafx-base + + + + org.slf4j + slf4j-api + + + + + org.mockito + mockito-core + test + + + ch.qos.logback + logback-classic + test + + + diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/CyberCoreDesktopApplication.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/CyberCoreDesktopApplication.java new file mode 100644 index 0000000..9314f21 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/CyberCoreDesktopApplication.java @@ -0,0 +1,496 @@ +package br.com.cybercore.signer.ui; + +import br.com.cybercore.signer.core.crypto.SigningService; +import br.com.cybercore.signer.core.diagnostics.DiagnosticService; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.protocol.CcspErrorCode; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.CertificateProviderException; +import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider; +import br.com.cybercore.signer.ui.view.A1PasswordDialog; +import br.com.cybercore.signer.ui.view.CertificateDetailsDialog; +import br.com.cybercore.signer.ui.view.MainAppView; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import br.com.cybercore.signer.core.protocol.CcspRequest; +import br.com.cybercore.signer.core.protocol.CcspServer; +import br.com.cybercore.signer.core.protocol.CcspUserPromptListener; +import br.com.cybercore.signer.core.security.ApplicationRegistry; +import br.com.cybercore.signer.core.security.AuthorizedApplication; +import br.com.cybercore.signer.core.security.SmartCardWatcher; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.view.PdfSignDialog; +import br.com.cybercore.signer.ui.view.XmlSignDialog; +import br.com.cybercore.signer.ui.tray.SystemTrayManager; +import br.com.cybercore.signer.ui.view.AboutView; +import br.com.cybercore.signer.ui.view.ApplicationApprovalDialog; +import br.com.cybercore.signer.ui.view.BatchSigningDialog; +import br.com.cybercore.signer.ui.view.CertificatesManagerDialog; +import br.com.cybercore.signer.ui.view.FileSignDialog; +import br.com.cybercore.signer.ui.view.LogsViewerDialog; +import br.com.cybercore.signer.ui.view.SealDesignerDialog; +import br.com.cybercore.signer.ui.view.SealManagementDialog; +import br.com.cybercore.signer.ui.view.SettingsView; +import br.com.cybercore.signer.ui.view.SignaturePromptDialog; +import br.com.cybercore.signer.ui.view.SignatureValidationDialog; +import br.com.cybercore.signer.ui.view.TutorialDialog; +import javafx.application.Application; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.stage.FileChooser; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** + * Ponto de entrada JavaFX da interface desktop do CyberCore Signer. + * Conectado diretamente ao CertificateManager e SigningService reais do sistema operacional. + */ +public class CyberCoreDesktopApplication extends Application { + + private static final Logger logger = LoggerFactory.getLogger(CyberCoreDesktopApplication.class); + + private static boolean developmentMode = false; + private static MainAppView mainAppView; + + private CertificateManager certificateManager; + private SigningService signingService; + private CcspServer ccspServer; + private SystemTrayManager systemTrayManager; + private SmartCardWatcher smartCardWatcher; + + public static void setDevelopmentMode(boolean devMode) { + developmentMode = devMode; + } + + public static MainAppView getMainAppView() { + return mainAppView; + } + + @Override + public void start(Stage primaryStage) { + logger.info("Iniciando interface gráfica JavaFX..."); + Platform.setImplicitExit(false); + + // Inicializa serviços centrais reais da plataforma + this.certificateManager = CertificateManager.createDefault(); + + if (developmentMode) { + logger.info("Modo de desenvolvimento ativo (utilizando certificados reais da máquina)."); + } + + mainAppView = new MainAppView(primaryStage); + + // Ações da Grade de Assinatura e Validação + mainAppView.setOnSignPdf(() -> new PdfSignDialog(primaryStage, certificateManager).show()); + mainAppView.setOnSignXml(() -> new XmlSignDialog(primaryStage, certificateManager).show()); + mainAppView.setOnSignFile(() -> new FileSignDialog(primaryStage, "Assinar Arquivo", "*.*", SvgIcons.fileIcon(), certificateManager, signingService).show()); + mainAppView.setOnBatchSign(() -> new BatchSigningDialog(primaryStage, certificateManager, signingService).show()); + mainAppView.setOnAttachedSign(() -> new FileSignDialog(primaryStage, "Assinatura Atachada", "*.*", SvgIcons.attachedIcon(), certificateManager, signingService).show()); + mainAppView.setOnCoSign(() -> new FileSignDialog(primaryStage, "Co-Assinar Documento", "*.*", SvgIcons.coSignIcon(), certificateManager, signingService).show()); + mainAppView.setOnTimestamp(() -> new SignatureValidationDialog(primaryStage, true).show()); + mainAppView.setOnLtvSign(() -> new FileSignDialog(primaryStage, "Assinatura de Longa Duração (LTV)", "*.*", SvgIcons.ltvIcon(), certificateManager, signingService).show()); + mainAppView.setOnValidateSignature(() -> new SignatureValidationDialog(primaryStage, false).show()); + mainAppView.setOnValidateTimestamp(() -> new SignatureValidationDialog(primaryStage, true).show()); + mainAppView.setOnManageSeals(() -> new SealManagementDialog(primaryStage).show()); + mainAppView.setOnExit(() -> { + logger.info("Encerrando CyberCore Signer pela interface gráfica."); + primaryStage.close(); + Platform.exit(); + System.exit(0); + }); + + // Ações do Painel Lateral + mainAppView.setOnLogs(() -> new LogsViewerDialog(primaryStage).show()); + mainAppView.setOnSettings(() -> openSettingsDialog(primaryStage)); + mainAppView.setOnTutorial(() -> new TutorialDialog(primaryStage).show()); + mainAppView.setOnCertificates(() -> new CertificatesManagerDialog(primaryStage, certificateManager).show()); + mainAppView.setOnAbout(() -> openAboutDialog(primaryStage)); + + // Inicializa o servidor local do protocolo CCSP/1 para atender requisições do navegador + initCcspServer(primaryStage); + + // Monitoramento em tempo real de conexão/remoção de Smart Cards e Tokens USB + initSmartCardWatcher(); + + // Configura bandeja do sistema (System Tray) para operação contínua em segundo plano + this.systemTrayManager = new SystemTrayManager(primaryStage, this::refreshCertificates); + this.systemTrayManager.setup(); + + // Carrega os certificados instalados na máquina do usuário + refreshCertificates(); + + mainAppView.show(); + logger.info("Janela principal JavaFX exibida."); + } + + private void refreshCertificates() { + try { + List certs = certificateManager.discoverCertificates(); + logger.info("Descoberta concluída: {} certificado(s) real(is) identificado(s).", certs.size()); + } catch (Exception e) { + logger.error("Erro ao atualizar lista de certificados: {}", e.getMessage(), e); + } + } + + private void openSettingsDialog(Stage owner) { + Stage dlg = new Stage(); + dlg.initModality(Modality.APPLICATION_MODAL); + dlg.initOwner(owner); + dlg.setTitle("CyberCore Signer — Configurações"); + dlg.setResizable(true); + dlg.setMinWidth(660); + dlg.setMinHeight(520); + + SettingsView settingsView = new SettingsView(); + settingsView.updateApplications(ApplicationRegistry.getInstance().listAll()); + settingsView.setOnRevokeApplication(appId -> { + logger.info("Revogando autorização da aplicação web [id={}]", appId); + ApplicationRegistry.getInstance().revoke(appId); + settingsView.updateApplications(ApplicationRegistry.getInstance().listAll()); + }); + + ScrollPane scrollPane = new ScrollPane(settingsView); + scrollPane.setFitToWidth(true); + scrollPane.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + VBox.setVgrow(scrollPane, Priority.ALWAYS); + + Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13)); + btnClose.getStyleClass().add("btn-secondary"); + btnClose.setPrefWidth(95); + btnClose.setOnAction(e -> dlg.close()); + + HBox footer = new HBox(btnClose); + footer.setAlignment(Pos.CENTER_RIGHT); + footer.setPadding(new Insets(10, 24, 16, 24)); + + VBox layout = new VBox(scrollPane, footer); + layout.getStyleClass().add("dialog-container"); + + Scene sc = new Scene(layout, 720, 580); + ThemeManager.applyTheme(sc); + dlg.setScene(sc); + dlg.show(); + } + + private void openAboutDialog(Stage owner) { + Stage dlg = new Stage(); + dlg.initModality(Modality.APPLICATION_MODAL); + dlg.initOwner(owner); + dlg.setTitle("CyberCore Signer — Sobre"); + dlg.setResizable(true); + dlg.setMinWidth(540); + dlg.setMinHeight(420); + + AboutView aboutView = new AboutView(); + Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13)); + btnClose.getStyleClass().add("btn-primary"); + btnClose.setPrefWidth(95); + btnClose.setOnAction(e -> dlg.close()); + + HBox footer = new HBox(btnClose); + footer.setAlignment(Pos.CENTER); + footer.setPadding(new Insets(10, 24, 20, 24)); + + VBox layout = new VBox(aboutView, footer); + layout.getStyleClass().add("dialog-container"); + + Scene sc = new Scene(layout, 560, 440); + ThemeManager.applyTheme(sc); + dlg.setScene(sc); + dlg.show(); + } + + private void showCertificateDetails(Stage owner, ManagedCertificate cert) { + CertificateDetailsDialog dialog = new CertificateDetailsDialog(owner, cert); + dialog.show(); + } + + private void handleAddA1Certificate(Stage owner) { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Selecionar Certificado Digital A1 (.pfx, .p12)"); + fileChooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("Certificados Digitais PKCS#12 (*.pfx, *.p12)", "*.pfx", "*.p12"), + new FileChooser.ExtensionFilter("Todos os Arquivos (*.*)", "*.*") + ); + + File selectedFile = fileChooser.showOpenDialog(owner); + if (selectedFile == null) { + logger.info("Seleção de certificado A1 cancelada pelo usuário."); + return; + } + + A1PasswordDialog pwdDialog = new A1PasswordDialog(owner, selectedFile.getName()); + pwdDialog.showAndAwait().thenAccept(password -> { + if (password == null) { + logger.info("Importação de certificado A1 cancelada na etapa de senha."); + return; + } + + try { + byte[] pfxData = Files.readAllBytes(selectedFile.toPath()); + String providerId = "provider-a1-" + Integer.toHexString(selectedFile.getAbsolutePath().hashCode()); + Pkcs12CertificateProvider a1Provider = new Pkcs12CertificateProvider(providerId, selectedFile.getName()); + + a1Provider.loadFromBytes(pfxData, password); + + // Limpa senha da memória + Arrays.fill(password, ' '); + + certificateManager.registerProvider(a1Provider); + refreshCertificates(); + + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.INFORMATION, "Certificado A1 importado com sucesso!", ButtonType.OK); + alert.initOwner(owner); + alert.setTitle("CyberCore Signer — Certificado A1"); + alert.setHeaderText("Importação Concluída"); + alert.show(); + }); + + logger.info("Certificado A1 '{}' importado e registrado com sucesso.", selectedFile.getName()); + + } catch (CertificateProviderException cpe) { + Arrays.fill(password, ' '); + Platform.runLater(() -> { + String msg = cpe.getErrorCode() == CcspErrorCode.PIN_INCORRECT + ? "A senha informada para o certificado A1 está incorreta." + : "Erro ao processar certificado: " + cpe.getMessage(); + + Alert alert = new Alert(Alert.AlertType.ERROR, msg, ButtonType.OK); + alert.initOwner(owner); + alert.setTitle("CyberCore Signer — Erro"); + alert.setHeaderText("Falha na Importação"); + alert.show(); + }); + } catch (Exception e) { + Arrays.fill(password, ' '); + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR, "Não foi possível ler o arquivo: " + e.getMessage(), ButtonType.OK); + alert.initOwner(owner); + alert.setTitle("CyberCore Signer — Erro"); + alert.setHeaderText("Falha na Leitura"); + alert.show(); + }); + } + }); + } + + private void runSelfTestSignature(Stage stage) { + List available = certificateManager.getAvailableCertificates(); + if (available.isEmpty()) { + logger.warn("Nenhum certificado digital disponível com chave privada para teste de assinatura."); + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.WARNING, + "Nenhum certificado digital disponível com chave privada para teste de assinatura.\n\n" + + "Conecte um token USB/Smart Card ou importe um certificado A1.", + ButtonType.OK); + alert.initOwner(stage); + alert.setTitle("CyberCore Signer — Auto-teste"); + alert.setHeaderText("Certificado Não Encontrado"); + alert.show(); + }); + return; + } + + ManagedCertificate target = available.get(0); + openSignaturePrompt(stage, target); + } + + private void openSignaturePrompt(Stage owner, ManagedCertificate cert) { + try { + byte[] docDigest = MessageDigest.getInstance("SHA-256") + .digest(("Documento_Teste_CyberCore_" + Instant.now()).getBytes(StandardCharsets.UTF_8)); + + SigningContext context = new SigningContext( + "req_test_" + System.currentTimeMillis(), + "app_teste_local", + docDigest, + "SHA256withRSA", + cert.id(), + Instant.now() + ); + + SignaturePromptDialog dialog = new SignaturePromptDialog( + owner, "CyberCore Agente Local", "http://localhost", cert, context + ); + + dialog.showAndAwait().thenAccept(approved -> { + if (Boolean.TRUE.equals(approved)) { + logger.info("Usuário aprovou a assinatura para o certificado: {}", cert.alias()); + try { + SigningResult result = signingService.sign(context); + logger.info("Assinatura real gerada com sucesso! Algoritmo={}, Tamanho={}", + result.algorithm(), result.signatureBytes().length); + + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.INFORMATION, + "Documento assinado com sucesso via " + result.algorithm() + "!\n\n" + + "Tamanho da assinatura: " + result.signatureBytes().length + " bytes\n" + + "Certificado: " + cert.commonName(), + ButtonType.OK); + alert.initOwner(owner); + alert.setTitle("CyberCore Signer — Sucesso"); + alert.setHeaderText("Assinatura Realizada"); + alert.show(); + }); + + } catch (Exception e) { + logger.error("Falha ao executar assinatura: {}", e.getMessage(), e); + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR, + "Falha ao realizar assinatura digital:\n\n" + e.getMessage(), + ButtonType.OK); + alert.initOwner(owner); + alert.setTitle("CyberCore Signer — Erro"); + alert.setHeaderText("Falha na Assinatura"); + alert.show(); + }); + } + } else { + logger.info("Usuário cancelou a assinatura para o certificado: {}", cert.alias()); + } + }); + } catch (Exception e) { + logger.error("Erro ao abrir diálogo de solicitação de assinatura: {}", e.getMessage(), e); + } + } + + private void initCcspServer(Stage stage) { + CcspUserPromptListener promptListener = new CcspUserPromptListener() { + @Override + public CompletableFuture requestApplicationAuthorization(String origin, String appName, String applicationId) { + CompletableFuture future = new CompletableFuture<>(); + Platform.runLater(() -> { + try { + ApplicationApprovalDialog dialog = new ApplicationApprovalDialog(stage, origin, appName, applicationId); + dialog.showAndAwait().whenComplete((approved, err) -> { + if (err != null) { + future.complete(false); + } else { + future.complete(Boolean.TRUE.equals(approved)); + } + }); + } catch (Exception ex) { + logger.error("Erro ao abrir diálogo de autorização de aplicação: {}", ex.getMessage()); + future.complete(false); + } + }); + return future; + } + + @Override + public CompletableFuture requestSignatureConfirmation(CcspRequest request, ManagedCertificate certificate) { + CompletableFuture future = new CompletableFuture<>(); + Platform.runLater(() -> { + try { + byte[] digestBytes; + if (request.digest() != null && !request.digest().isBlank()) { + digestBytes = Base64.getDecoder().decode(request.digest()); + } else if (request.challenge() != null && !request.challenge().isBlank()) { + digestBytes = MessageDigest.getInstance("SHA-256") + .digest(request.challenge().getBytes(StandardCharsets.UTF_8)); + } else { + digestBytes = new byte[32]; + } + + SigningContext context = new SigningContext( + request.requestId(), + request.applicationId(), + digestBytes, + request.algorithm() != null ? request.algorithm() : "SHA256withRSA", + certificate.id(), + Instant.now() + ); + + String appName = ApplicationRegistry.getInstance().findById(request.applicationId()) + .map(AuthorizedApplication::name) + .orElse(request.applicationId()); + + SignaturePromptDialog dialog = new SignaturePromptDialog( + stage, appName, "Requisição Web CCSP", certificate, context, + request.documentName(), request.documentType(), request.isAuthentication() + ); + dialog.showAndAwait().whenComplete((approved, err) -> { + if (err != null) { + future.complete(false); + } else { + future.complete(Boolean.TRUE.equals(approved)); + } + }); + } catch (Exception ex) { + logger.error("Erro ao abrir diálogo de confirmação de assinatura: {}", ex.getMessage()); + future.complete(false); + } + }); + return future; + } + }; + + try { + this.ccspServer = new CcspServer(this.certificateManager, this.signingService, promptListener); + this.ccspServer.start(); + logger.info("Servidor local CCSP/1 inicializado na porta {}", this.ccspServer.getPort()); + } catch (Exception e) { + logger.error("Falha ao inicializar servidor local CCSP/1: {}", e.getMessage(), e); + } + } + + private void initSmartCardWatcher() { + try { + this.smartCardWatcher = new SmartCardWatcher(); + this.smartCardWatcher.addListener(new SmartCardWatcher.CardEventListener() { + @Override + public void onCardInserted(String terminalName) { + logger.info("Novo dispositivo detectado no terminal '{}'. Atualizando certificados...", terminalName); + refreshCertificates(); + } + + @Override + public void onCardRemoved(String terminalName) { + logger.warn("Dispositivo desconectado do terminal '{}'. Atualizando certificados...", terminalName); + refreshCertificates(); + } + }); + this.smartCardWatcher.start(); + } catch (Throwable t) { + logger.warn("Não foi possível iniciar monitor dinâmico de smart cards: {}", t.getMessage()); + } + } + + @Override + public void stop() { + logger.info("Encerrando componentes da interface JavaFX..."); + if (smartCardWatcher != null) { + smartCardWatcher.stop(); + } + if (systemTrayManager != null) { + systemTrayManager.remove(); + } + if (ccspServer != null) { + ccspServer.stop(); + } + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/CyberCoreUiConstants.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/CyberCoreUiConstants.java new file mode 100644 index 0000000..ca2573f --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/CyberCoreUiConstants.java @@ -0,0 +1,17 @@ +package br.com.cybercore.signer.ui; + +/** + * Constantes visuais e de apresentação da interface do CyberCore Signer. + */ +public final class CyberCoreUiConstants { + + public static final String APP_NAME = "CyberCore Signer"; + public static final String APP_VERSION = "0.1.0"; + public static final String APP_STATUS_READY = "Serviço ativo e pronto para assinar"; + + // Temas e estilos + public static final String MAIN_CSS_PATH = "/styles/theme.css"; + + private CyberCoreUiConstants() { + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/assets/AssetManager.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/assets/AssetManager.java new file mode 100644 index 0000000..b4b1cc0 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/assets/AssetManager.java @@ -0,0 +1,449 @@ +package br.com.cybercore.signer.ui.assets; + +import javafx.geometry.Pos; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Line; +import javafx.scene.shape.Polygon; +import javafx.scene.shape.Polyline; +import javafx.scene.shape.Rectangle; +import javafx.scene.shape.SVGPath; +import javafx.scene.shape.Shape; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; +import javafx.scene.transform.Scale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Gerenciador centralizado de recursos visuais (ícones e imagens). + * Permite que o usuário customize ou substitua facilmente qualquer ícone (SVG/PNG) ou imagem (PNG/JPG) + * do CyberCore Signer simplesmente colocando os arquivos na pasta 'assets/' + * na raiz do projeto ou no diretório ~/.cybercore/assets/. + */ +public final class AssetManager { + + private static final Logger logger = LoggerFactory.getLogger(AssetManager.class); + + private static final String[] RASTER_EXTENSIONS = {".png", ".jpg", ".jpeg"}; + + private AssetManager() { + } + + /** + * Retorna o caminho do diretório externo prioritário de assets. + */ + public static Path getExternalAssetsDir() { + Path localAssets = Path.of("assets"); + if (Files.isDirectory(localAssets)) { + return localAssets; + } + + String userHome = System.getProperty("user.home", "."); + return Path.of(userHome, ".cybercore", "assets"); + } + + /** + * Tenta carregar uma imagem bitmap personalizada (.png, .jpg) pelo nome nas pastas de assets + * ou nos recursos empacotados. + */ + public static Optional loadImage(String category, String baseName, double width, double height) { + if (baseName == null || baseName.isBlank()) { + return Optional.empty(); + } + + String cleanName = cleanBaseName(baseName); + + // 1. Procura no diretório local ./assets/{category}/ + Path localDir = Path.of("assets", category); + for (String ext : RASTER_EXTENSIONS) { + Path file = localDir.resolve(cleanName + ext); + if (Files.isRegularFile(file)) { + try (InputStream in = new FileInputStream(file.toFile())) { + logger.debug("Carregando imagem bitmap do diretório local: {}", file); + return Optional.of(new Image(in, width, height, true, true)); + } catch (Exception e) { + logger.warn("Falha ao ler imagem customizada {}: {}", file, e.getMessage()); + } + } + } + + // 2. Procura em ~/.cybercore/assets/{category}/ + String userHome = System.getProperty("user.home", "."); + Path userDir = Path.of(userHome, ".cybercore", "assets", category); + for (String ext : RASTER_EXTENSIONS) { + Path file = userDir.resolve(cleanName + ext); + if (Files.isRegularFile(file)) { + try (InputStream in = new FileInputStream(file.toFile())) { + logger.debug("Carregando imagem bitmap de ~/.cybercore: {}", file); + return Optional.of(new Image(in, width, height, true, true)); + } catch (Exception e) { + logger.warn("Falha ao ler imagem do usuário {}: {}", file, e.getMessage()); + } + } + } + + // 3. Procura no Classpath interno (/assets/{category}/{name}) + for (String ext : RASTER_EXTENSIONS) { + String resPath = "/assets/" + category + "/" + cleanName + ext; + InputStream resStream = AssetManager.class.getResourceAsStream(resPath); + if (resStream != null) { + try (resStream) { + return Optional.of(new Image(resStream, width, height, true, true)); + } catch (Exception e) { + logger.warn("Falha ao ler recurso interno {}: {}", resPath, e.getMessage()); + } + } + } + + return Optional.empty(); + } + + /** + * Retorna um Node de ícone customizado (SVG ou PNG) ou invoca o fallback caso não exista imagem customizada. + */ + public static Node getIconOrDefault(String iconName, double size, Supplier fallbackSupplier) { + if (iconName != null && !iconName.isBlank()) { + String cleanName = cleanBaseName(iconName); + + // 1. Prioridade para SVG em ./assets/icons/ + Optional localSvg = loadSvgIconFromPath(Path.of("assets", "icons", cleanName + ".svg"), size); + if (localSvg.isPresent()) { + return localSvg.get(); + } + + // 2. Procura SVG em ~/.cybercore/assets/icons/ + String userHome = System.getProperty("user.home", "."); + Optional userSvg = loadSvgIconFromPath(Path.of(userHome, ".cybercore", "assets", "icons", cleanName + ".svg"), size); + if (userSvg.isPresent()) { + return userSvg.get(); + } + + // 3. Se não houver SVG, tenta imagem bitmap (.png, .jpg) nas pastas externas + Optional customImg = loadImage("icons", cleanName, size, size); + if (customImg.isPresent() && !customImg.get().isError()) { + ImageView iv = new ImageView(customImg.get()); + iv.setFitWidth(size); + iv.setFitHeight(size); + iv.setPreserveRatio(true); + return iv; + } + } + + return fallbackSupplier != null ? fallbackSupplier.get() : new ImageView(); + } + + /** + * Retorna a imagem do logotipo do sistema ou o fallback vetorial. + */ + public static Node getLogoOrDefault(double width, double height, Supplier fallbackSupplier) { + // Tenta carregar app-logo bitmap (.png) primeiro + Optional customLogo = loadImage("images", "app-logo", width, height); + if (customLogo.isPresent() && !customLogo.get().isError()) { + ImageView iv = new ImageView(customLogo.get()); + iv.setFitWidth(width); + iv.setFitHeight(height); + iv.setPreserveRatio(true); + return iv; + } + + // Tenta carregar SVG de app-logo se existir + Optional svgLogo = loadSvgIconFromPath(Path.of("assets", "images", "app-logo.svg"), height); + if (svgLogo.isPresent()) { + return svgLogo.get(); + } + + return fallbackSupplier != null ? fallbackSupplier.get() : new ImageView(); + } + + private static String cleanBaseName(String name) { + String clean = name; + int dotIdx = clean.lastIndexOf('.'); + if (dotIdx > 0) { + clean = clean.substring(0, dotIdx); + } + return clean; + } + + private static Optional loadSvgIconFromPath(Path path, double size) { + if (Files.isRegularFile(path)) { + try (InputStream in = new FileInputStream(path.toFile())) { + Node node = parseSvgToNode(in, size); + if (node != null) { + logger.debug("Carregado ícone SVG customizado de: {}", path); + return Optional.of(node); + } + } catch (Exception e) { + logger.warn("Falha ao carregar ícone SVG {}: {}", path, e.getMessage()); + } + } + return Optional.empty(); + } + + /** + * Parser analítico leve de SVG padrão para nós gráficos JavaFX com preservação geométrica exata. + */ + public static Node parseSvgToNode(InputStream in, double targetSize) { + try { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(false); + dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DocumentBuilder db = dbf.newDocumentBuilder(); + Document doc = db.parse(in); + + Element svg = doc.getDocumentElement(); + if (!"svg".equalsIgnoreCase(svg.getNodeName())) { + return null; + } + + double vbMinX = 0; + double vbMinY = 0; + double vbWidth = 48; + double vbHeight = 48; + + String viewBox = svg.getAttribute("viewBox"); + if (viewBox != null && !viewBox.isBlank()) { + String[] parts = viewBox.trim().split("[,\\s]+"); + if (parts.length >= 4) { + try { + vbMinX = Double.parseDouble(parts[0]); + vbMinY = Double.parseDouble(parts[1]); + vbWidth = Double.parseDouble(parts[2]); + vbHeight = Double.parseDouble(parts[3]); + } catch (NumberFormatException ignored) { + } + } + } else { + String wAttr = svg.getAttribute("width"); + String hAttr = svg.getAttribute("height"); + if (!wAttr.isBlank() && !hAttr.isBlank()) { + try { + vbWidth = Double.parseDouble(wAttr.replaceAll("[^0-9.]", "")); + vbHeight = Double.parseDouble(hAttr.replaceAll("[^0-9.]", "")); + } catch (NumberFormatException ignored) { + } + } + } + + if (vbWidth <= 0) vbWidth = 48; + if (vbHeight <= 0) vbHeight = 48; + + Group contentGroup = new Group(); + renderChildren(svg, contentGroup); + + double maxDim = Math.max(vbWidth, vbHeight); + double scale = (targetSize * 0.90) / maxDim; + + double drawnW = vbWidth * scale; + double drawnH = vbHeight * scale; + double tx = (targetSize - drawnW) / 2.0 - (vbMinX * scale); + double ty = (targetSize - drawnH) / 2.0 - (vbMinY * scale); + + contentGroup.getTransforms().setAll( + new javafx.scene.transform.Translate(tx, ty), + new javafx.scene.transform.Scale(scale, scale, 0, 0) + ); + + StackPane container = new StackPane(contentGroup); + container.setPrefSize(targetSize, targetSize); + container.setMaxSize(targetSize, targetSize); + container.setMinSize(targetSize, targetSize); + container.setAlignment(Pos.CENTER); + return container; + + } catch (Exception e) { + logger.warn("Erro ao parsear arquivo SVG: {}", e.getMessage()); + return null; + } + } + + private static void renderChildren(Element parent, Group targetGroup) { + NodeList children = parent.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + org.w3c.dom.Node n = children.item(i); + if (n instanceof Element el) { + Node jfxNode = createElementNode(el); + if (jfxNode != null) { + targetGroup.getChildren().add(jfxNode); + } + } + } + } + + private static Node createElementNode(Element el) { + String tagName = el.getTagName().toLowerCase(); + Shape shape = null; + + switch (tagName) { + case "path" -> { + String d = el.getAttribute("d"); + if (!d.isBlank()) { + SVGPath p = new SVGPath(); + p.setContent(d); + shape = p; + } + } + case "rect" -> { + double x = parseDouble(el.getAttribute("x"), 0); + double y = parseDouble(el.getAttribute("y"), 0); + double w = parseDouble(el.getAttribute("width"), 0); + double h = parseDouble(el.getAttribute("height"), 0); + double rx = parseDouble(el.getAttribute("rx"), 0); + Rectangle r = new Rectangle(x, y, w, h); + if (rx > 0) { + r.setArcWidth(rx * 2); + r.setArcHeight(rx * 2); + } + shape = r; + } + case "circle" -> { + double cx = parseDouble(el.getAttribute("cx"), 0); + double cy = parseDouble(el.getAttribute("cy"), 0); + double rad = parseDouble(el.getAttribute("r"), 0); + shape = new Circle(cx, cy, rad); + } + case "line" -> { + double x1 = parseDouble(el.getAttribute("x1"), 0); + double y1 = parseDouble(el.getAttribute("y1"), 0); + double x2 = parseDouble(el.getAttribute("x2"), 0); + double y2 = parseDouble(el.getAttribute("y2"), 0); + shape = new Line(x1, y1, x2, y2); + } + case "polygon" -> { + String pts = el.getAttribute("points"); + if (!pts.isBlank()) { + Polygon poly = new Polygon(); + parsePoints(pts, poly.getPoints()); + shape = poly; + } + } + case "polyline" -> { + String pts = el.getAttribute("points"); + if (!pts.isBlank()) { + Polyline poly = new Polyline(); + parsePoints(pts, poly.getPoints()); + shape = poly; + } + } + case "text" -> { + double x = parseDouble(el.getAttribute("x"), 0); + double y = parseDouble(el.getAttribute("y"), 0); + String content = el.getTextContent(); + Text t = new Text(x, y, content); + double fs = parseDouble(el.getAttribute("font-size"), 10); + String fw = el.getAttribute("font-weight"); + t.setFont(Font.font("Segoe UI", "bold".equalsIgnoreCase(fw) ? FontWeight.BOLD : FontWeight.NORMAL, fs)); + if (el.hasAttribute("fill")) { + t.setFill(parseColor(el.getAttribute("fill"), Color.BLACK)); + } + if ("middle".equalsIgnoreCase(el.getAttribute("text-anchor"))) { + t.setTextOrigin(javafx.geometry.VPos.CENTER); + } + return t; + } + case "g" -> { + Group g = new Group(); + renderChildren(el, g); + if (el.hasAttribute("stroke")) { + Color strokeCol = parseColor(el.getAttribute("stroke"), null); + double sw = parseDouble(el.getAttribute("stroke-width"), 1.0); + for (Node child : g.getChildren()) { + if (child instanceof Shape s) { + if (strokeCol != null) s.setStroke(strokeCol); + s.setStrokeWidth(sw); + } + } + } + return g; + } + } + + if (shape != null) { + applyShapeStyles(el, shape); + return shape; + } + + return null; + } + + private static void applyShapeStyles(Element el, Shape shape) { + if (el.hasAttribute("fill")) { + shape.setFill(parseColor(el.getAttribute("fill"), Color.TRANSPARENT)); + } else if (shape instanceof Line) { + shape.setFill(Color.TRANSPARENT); + } + + if (el.hasAttribute("stroke")) { + shape.setStroke(parseColor(el.getAttribute("stroke"), Color.TRANSPARENT)); + } + + if (el.hasAttribute("stroke-width")) { + shape.setStrokeWidth(parseDouble(el.getAttribute("stroke-width"), 1.0)); + } + + if (el.hasAttribute("fill-rule") && shape instanceof SVGPath sp) { + if ("evenodd".equalsIgnoreCase(el.getAttribute("fill-rule"))) { + sp.setFillRule(javafx.scene.shape.FillRule.EVEN_ODD); + } + } + } + + private static void parsePoints(String ptsStr, java.util.List pointsList) { + String[] tokens = ptsStr.trim().split("[,\\s]+"); + for (String t : tokens) { + if (!t.isBlank()) { + try { + pointsList.add(Double.parseDouble(t)); + } catch (NumberFormatException ignored) { + } + } + } + } + + private static Color parseColor(String str, Color fallback) { + if (str == null || str.isBlank() || "none".equalsIgnoreCase(str)) { + return Color.TRANSPARENT; + } + String trimmed = str.trim(); + if ("currentColor".equalsIgnoreCase(trimmed)) { + return Color.web("#0288d1"); + } + try { + return Color.web(trimmed); + } catch (Exception e) { + return fallback != null ? fallback : Color.BLACK; + } + } + + private static double parseDouble(String str, double defaultValue) { + if (str == null || str.isBlank()) { + return defaultValue; + } + try { + return Double.parseDouble(str.replaceAll("[^0-9.-]", "")); + } catch (Exception e) { + return defaultValue; + } + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/components/CertificateCardNode.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/components/CertificateCardNode.java new file mode 100644 index 0000000..e4273e8 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/components/CertificateCardNode.java @@ -0,0 +1,129 @@ +package br.com.cybercore.signer.ui.components; + +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * Componente visual reutilizável para exibição de um certificado digital. + */ +@SuppressWarnings("this-escape") +public final class CertificateCardNode extends VBox { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy") + .withZone(ZoneId.systemDefault()); + + private final ManagedCertificate certificate; + + public CertificateCardNode(ManagedCertificate certificate) { + this.certificate = certificate; + initLayout(); + } + + private void initLayout() { + getStyleClass().add("cert-card"); + setCursor(javafx.scene.Cursor.HAND); + setSpacing(8); + + // Linha 1: Nome do Titular e Badge A1/A3 + HBox topRow = new HBox(10); + topRow.setAlignment(Pos.CENTER_LEFT); + + Label titleLabel = new Label(certificate.commonName() != null ? certificate.commonName() : certificate.alias()); + titleLabel.getStyleClass().add("cert-title"); + HBox.setHgrow(titleLabel, Priority.ALWAYS); + + Label badgeLabel = new Label(); + if (certificate.type() == CertificateType.A1) { + badgeLabel.setText("A1 • Certificado local"); + badgeLabel.getStyleClass().add("cert-badge-a1"); + } else { + String tokenDesc = certificate.hardwareDeviceName() != null ? certificate.hardwareDeviceName() : "Token USB"; + badgeLabel.setText("A3 • " + tokenDesc); + badgeLabel.getStyleClass().add("cert-badge-a3"); + } + + topRow.getChildren().addAll(titleLabel, badgeLabel); + + // Linha 2: Documento (CPF / CNPJ) e Emissor + HBox midRow = new HBox(12); + midRow.setAlignment(Pos.CENTER_LEFT); + + if (certificate.cpf() != null) { + Label docLabel = new Label("CPF: " + formatCpf(certificate.cpf())); + docLabel.getStyleClass().add("cert-detail"); + midRow.getChildren().add(docLabel); + } else if (certificate.cnpj() != null) { + Label docLabel = new Label("CNPJ: " + formatCnpj(certificate.cnpj())); + docLabel.getStyleClass().add("cert-detail"); + midRow.getChildren().add(docLabel); + } + + String issuerCn = extractIssuerCn(certificate.issuerDn()); + Label issuerLabel = new Label("Emissor: " + issuerCn); + issuerLabel.getStyleClass().add("cert-detail"); + midRow.getChildren().add(issuerLabel); + + // Linha 3: Validade e Status de disponibilidade + HBox bottomRow = new HBox(12); + bottomRow.setAlignment(Pos.CENTER_LEFT); + + String validityText = "Válido até " + (certificate.notAfter() != null ? DATE_FORMATTER.format(certificate.notAfter()) : "N/D"); + Label validityLabel = new Label(validityText); + validityLabel.getStyleClass().add("cert-detail"); + HBox.setHgrow(validityLabel, Priority.ALWAYS); + + Label statusLabel = new Label(); + if (certificate.status() == CertificateStatus.AVAILABLE && certificate.isCurrentlyValid()) { + statusLabel.setText("● Disponível"); + statusLabel.getStyleClass().add("cert-status-available"); + } else { + statusLabel.setText("● " + certificate.status().getDescription()); + statusLabel.getStyleClass().add("cert-status-expired"); + } + + bottomRow.getChildren().addAll(validityLabel, statusLabel); + + getChildren().addAll(topRow, midRow, bottomRow); + } + + public ManagedCertificate getCertificate() { + return certificate; + } + + private String formatCpf(String cpf) { + if (cpf != null && cpf.length() == 11) { + return cpf.substring(0, 3) + "." + cpf.substring(3, 6) + "." + cpf.substring(6, 9) + "-" + cpf.substring(9); + } + return cpf; + } + + private String formatCnpj(String cnpj) { + if (cnpj != null && cnpj.length() == 14) { + return cnpj.substring(0, 2) + "." + cnpj.substring(2, 5) + "." + cnpj.substring(5, 8) + "/" + cnpj.substring(8, 12) + "-" + cnpj.substring(12); + } + return cnpj; + } + + private String extractIssuerCn(String dn) { + if (dn == null) return "Desconhecido"; + int idx = dn.indexOf("CN="); + if (idx >= 0) { + int end = dn.indexOf(',', idx); + if (end > idx) { + return dn.substring(idx + 3, end).trim(); + } + return dn.substring(idx + 3).trim(); + } + return dn; + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/components/SvgIcons.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/components/SvgIcons.java new file mode 100644 index 0000000..acb5e1d --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/components/SvgIcons.java @@ -0,0 +1,732 @@ +package br.com.cybercore.signer.ui.components; + +import br.com.cybercore.signer.ui.assets.AssetManager; +import javafx.geometry.Pos; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.effect.DropShadow; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.paint.CycleMethod; +import javafx.scene.paint.LinearGradient; +import javafx.scene.paint.Stop; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Polygon; +import javafx.scene.shape.Rectangle; +import javafx.scene.shape.SVGPath; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; + +/** + * Fábrica de ícones e grafismos vetoriais SVG nítidos e escaláveis para a interface Serpro do CyberCore Signer. + * Integra-se ao AssetManager para carregar dinamicamente arquivos .svg ou .png da pasta 'assets/icons/'. + */ +public final class SvgIcons { + + private SvgIcons() { + } + + /** + * Logotipo central de cristal/diamante azul tridimensional. + */ + public static Node createAppCrystalLogo(double size) { + return AssetManager.getIconOrDefault("app-icon", size, () -> buildAppCrystalLogo(size)); + } + + public static Node buildAppCrystalLogo(double size) { + StackPane root = new StackPane(); + root.setPrefSize(size, size); + root.setMaxSize(size, size); + + Rectangle bg = new Rectangle(size, size); + bg.setArcWidth(size * 0.35); + bg.setArcHeight(size * 0.35); + LinearGradient bgGrad = new LinearGradient( + 0, 0, 1, 1, true, CycleMethod.NO_CYCLE, + new Stop(0, Color.web("#1976d2")), + new Stop(0.5, Color.web("#0d47a1")), + new Stop(1, Color.web("#01579b")) + ); + bg.setFill(bgGrad); + + double s = size; + Polygon facetTop = new Polygon( + s * 0.5, s * 0.15, + s * 0.82, s * 0.42, + s * 0.5, s * 0.55, + s * 0.18, s * 0.42 + ); + facetTop.setFill(new LinearGradient( + 0, 0, 0, 1, true, CycleMethod.NO_CYCLE, + new Stop(0, Color.web("#ffffff", 0.85)), + new Stop(1, Color.web("#80d8ff", 0.4)) + )); + + Polygon facetBottomLeft = new Polygon( + s * 0.18, s * 0.42, + s * 0.5, s * 0.55, + s * 0.5, s * 0.85, + s * 0.28, s * 0.70 + ); + facetBottomLeft.setFill(new LinearGradient( + 0, 0, 1, 1, true, CycleMethod.NO_CYCLE, + new Stop(0, Color.web("#40c4ff", 0.6)), + new Stop(1, Color.web("#0288d1", 0.2)) + )); + + Polygon facetBottomRight = new Polygon( + s * 0.82, s * 0.42, + s * 0.5, s * 0.55, + s * 0.5, s * 0.85, + s * 0.72, s * 0.70 + ); + facetBottomRight.setFill(new LinearGradient( + 1, 0, 0, 1, true, CycleMethod.NO_CYCLE, + new Stop(0, Color.web("#e1f5fe", 0.75)), + new Stop(1, Color.web("#29b6f6", 0.3)) + )); + + Circle glow = new Circle(s * 0.5, s * 0.5, s * 0.12); + glow.setFill(new LinearGradient( + 0, 0, 1, 1, true, CycleMethod.NO_CYCLE, + new Stop(0, Color.web("#ffffff", 0.9)), + new Stop(1, Color.web("#ffffff", 0.0)) + )); + + Group crystal = new Group(bg, facetTop, facetBottomLeft, facetBottomRight, glow); + DropShadow shadow = new DropShadow(); + shadow.setColor(Color.web("#0d47a1", 0.4)); + shadow.setRadius(12); + shadow.setOffsetY(4); + crystal.setEffect(shadow); + + root.getChildren().add(crystal); + return root; + } + + /** + * Ícone de Assinar PDF. + */ + public static Node pdfIcon() { + return AssetManager.getIconOrDefault("pdf", 38, SvgIcons::buildPdfIcon); + } + + public static Node buildPdfIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath doc = new SVGPath(); + doc.setContent("M 6 2 L 24 2 L 32 10 L 32 36 L 6 36 Z"); + doc.setFill(Color.web("#ffffff")); + doc.setStroke(Color.web("#d32f2f")); + doc.setStrokeWidth(1.8); + + SVGPath fold = new SVGPath(); + fold.setContent("M 24 2 L 24 10 L 32 10 Z"); + fold.setFill(Color.web("#ffcdd2")); + fold.setStroke(Color.web("#d32f2f")); + fold.setStrokeWidth(1.2); + + Rectangle tag = new Rectangle(20, 10); + tag.setArcWidth(4); + tag.setArcHeight(4); + tag.setFill(Color.web("#e53935")); + tag.setTranslateY(4); + + Text txt = new Text("PDF"); + txt.setFont(Font.font("Segoe UI", FontWeight.BOLD, 7.5)); + txt.setFill(Color.WHITE); + txt.setTranslateY(4); + + box.getChildren().addAll(doc, fold, tag, txt); + return box; + } + + /** + * Ícone de Assinar XML. + */ + public static Node xmlIcon() { + return AssetManager.getIconOrDefault("xml", 38, SvgIcons::buildXmlIcon); + } + + public static Node buildXmlIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath doc = new SVGPath(); + doc.setContent("M 6 2 L 24 2 L 32 10 L 32 36 L 6 36 Z"); + doc.setFill(Color.web("#ffffff")); + doc.setStroke(Color.web("#1976d2")); + doc.setStrokeWidth(1.8); + + SVGPath fold = new SVGPath(); + fold.setContent("M 24 2 L 24 10 L 32 10 Z"); + fold.setFill(Color.web("#bbdefb")); + fold.setStroke(Color.web("#1976d2")); + fold.setStrokeWidth(1.2); + + Rectangle tag = new Rectangle(22, 10); + tag.setArcWidth(4); + tag.setArcHeight(4); + tag.setFill(Color.web("#0288d1")); + tag.setTranslateY(4); + + Text txt = new Text(""); + txt.setFont(Font.font("Segoe UI", FontWeight.BOLD, 6.8)); + txt.setFill(Color.WHITE); + txt.setTranslateY(4); + + box.getChildren().addAll(doc, fold, tag, txt); + return box; + } + + /** + * Ícone de Assinar Arquivo genérico. + */ + public static Node fileIcon() { + return AssetManager.getIconOrDefault("file", 38, SvgIcons::buildFileIcon); + } + + public static Node buildFileIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath doc = new SVGPath(); + doc.setContent("M 6 2 L 24 2 L 32 10 L 32 36 L 6 36 Z"); + doc.setFill(Color.web("#ffffff")); + doc.setStroke(Color.web("#0288d1")); + doc.setStrokeWidth(1.8); + + SVGPath fold = new SVGPath(); + fold.setContent("M 24 2 L 24 10 L 32 10 Z"); + fold.setFill(Color.web("#b3e5fc")); + fold.setStroke(Color.web("#0288d1")); + fold.setStrokeWidth(1.2); + + SVGPath pen = new SVGPath(); + pen.setContent("M 10 24 L 22 12 L 26 16 L 14 28 L 8 29 Z"); + pen.setFill(Color.web("#0288d1")); + + box.getChildren().addAll(doc, fold, pen); + return box; + } + + /** + * Ícone de Assinar em Lote. + */ + public static Node batchIcon() { + return AssetManager.getIconOrDefault("batch", 38, SvgIcons::buildBatchIcon); + } + + public static Node buildBatchIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath doc3 = new SVGPath(); + doc3.setContent("M 12 2 L 30 2 L 30 26 L 12 26 Z"); + doc3.setFill(Color.web("#e1f5fe")); + doc3.setStroke(Color.web("#4fc3f7")); + doc3.setStrokeWidth(1.2); + doc3.setTranslateX(4); + doc3.setTranslateY(-4); + + SVGPath doc2 = new SVGPath(); + doc2.setContent("M 8 6 L 28 6 L 28 30 L 8 30 Z"); + doc2.setFill(Color.web("#b3e5fc")); + doc2.setStroke(Color.web("#0288d1")); + doc2.setStrokeWidth(1.4); + doc2.setTranslateX(2); + doc2.setTranslateY(-2); + + SVGPath doc1 = new SVGPath(); + doc1.setContent("M 4 10 L 24 10 L 24 34 L 4 34 Z"); + doc1.setFill(Color.web("#ffffff")); + doc1.setStroke(Color.web("#01579b")); + doc1.setStrokeWidth(1.6); + + SVGPath check = new SVGPath(); + check.setContent("M 8 22 L 13 27 L 21 16"); + check.setStroke(Color.web("#00c853")); + check.setStrokeWidth(2.2); + check.setFill(Color.TRANSPARENT); + + box.getChildren().addAll(doc3, doc2, doc1, check); + return box; + } + + /** + * Ícone de Assinatura Atachada. + */ + public static Node attachedIcon() { + return AssetManager.getIconOrDefault("attached", 38, SvgIcons::buildAttachedIcon); + } + + public static Node buildAttachedIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath doc = new SVGPath(); + doc.setContent("M 8 4 L 28 4 L 28 34 L 8 34 Z"); + doc.setFill(Color.web("#ffffff")); + doc.setStroke(Color.web("#0288d1")); + doc.setStrokeWidth(1.6); + + SVGPath lines = new SVGPath(); + lines.setContent("M 12 12 L 24 12 M 12 17 L 24 17 M 12 22 L 20 22"); + lines.setStroke(Color.web("#90caf9")); + lines.setStrokeWidth(1.4); + + SVGPath clip = new SVGPath(); + clip.setContent("M 10 2 C 7 2 6 5 6 8 L 6 20 C 6 23 8 25 11 25 C 14 25 16 23 16 20 L 16 9 C 16 7 15 6 13 6 C 11 6 10 7 10 9 L 10 19"); + clip.setFill(Color.TRANSPARENT); + clip.setStroke(Color.web("#546e7a")); + clip.setStrokeWidth(1.8); + clip.setTranslateX(-8); + clip.setTranslateY(-4); + + box.getChildren().addAll(doc, lines, clip); + return box; + } + + /** + * Ícone de Co-Assinar. + */ + public static Node coSignIcon() { + return AssetManager.getIconOrDefault("co-sign", 38, SvgIcons::buildCoSignIcon); + } + + public static Node buildCoSignIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath doc1 = new SVGPath(); + doc1.setContent("M 4 8 L 22 8 L 22 32 L 4 32 Z"); + doc1.setFill(Color.web("#ffffff")); + doc1.setStroke(Color.web("#1976d2")); + doc1.setStrokeWidth(1.5); + + SVGPath doc2 = new SVGPath(); + doc2.setContent("M 14 4 L 32 4 L 32 28 L 14 28 Z"); + doc2.setFill(Color.web("#e3f2fd")); + doc2.setStroke(Color.web("#0288d1")); + doc2.setStrokeWidth(1.5); + + SVGPath people = new SVGPath(); + people.setContent("M 10 26 C 10 22 14 22 16 22 C 18 22 22 22 22 26 M 16 17 C 14.5 17 14.5 14 16 14 C 17.5 14 17.5 17 16 17"); + people.setStroke(Color.web("#0d47a1")); + people.setStrokeWidth(1.6); + people.setFill(Color.TRANSPARENT); + + box.getChildren().addAll(doc2, doc1, people); + return box; + } + + /** + * Ícone de Carimbo do Tempo. + */ + public static Node timestampIcon() { + return AssetManager.getIconOrDefault("timestamp", 38, SvgIcons::buildTimestampIcon); + } + + public static Node buildTimestampIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath doc = new SVGPath(); + doc.setContent("M 6 2 L 24 2 L 32 10 L 32 34 L 6 34 Z"); + doc.setFill(Color.web("#ffffff")); + doc.setStroke(Color.web("#0288d1")); + doc.setStrokeWidth(1.5); + + Circle clockBg = new Circle(18, 24, 9); + clockBg.setFill(Color.web("#e1f5fe")); + clockBg.setStroke(Color.web("#0288d1")); + clockBg.setStrokeWidth(1.6); + + SVGPath clockHands = new SVGPath(); + clockHands.setContent("M 18 19 L 18 24 L 22 24"); + clockHands.setStroke(Color.web("#01579b")); + clockHands.setStrokeWidth(1.6); + clockHands.setFill(Color.TRANSPARENT); + + box.getChildren().addAll(doc, clockBg, clockHands); + return box; + } + + /** + * Ícone de Assinatura de Longa Duração (LTV). + */ + public static Node ltvIcon() { + return AssetManager.getIconOrDefault("ltv", 38, SvgIcons::buildLtvIcon); + } + + public static Node buildLtvIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath doc = new SVGPath(); + doc.setContent("M 6 2 L 24 2 L 32 10 L 32 34 L 6 34 Z"); + doc.setFill(Color.web("#ffffff")); + doc.setStroke(Color.web("#00838f")); + doc.setStrokeWidth(1.5); + + Circle shield = new Circle(20, 24, 8); + shield.setFill(Color.web("#e0f2f1")); + shield.setStroke(Color.web("#00897b")); + shield.setStrokeWidth(1.6); + + Text ltv = new Text("LTV"); + ltv.setFont(Font.font("Segoe UI", FontWeight.BOLD, 6.8)); + ltv.setFill(Color.web("#00695c")); + ltv.setTranslateX(2); + ltv.setTranslateY(6); + + box.getChildren().addAll(doc, shield, ltv); + return box; + } + + /** + * Ícone de Validar Assinatura. + */ + public static Node validateIcon() { + return AssetManager.getIconOrDefault("validate", 38, SvgIcons::buildValidateIcon); + } + + public static Node buildValidateIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath doc = new SVGPath(); + doc.setContent("M 6 4 L 26 4 L 26 34 L 6 34 Z"); + doc.setFill(Color.web("#ffffff")); + doc.setStroke(Color.web("#0288d1")); + doc.setStrokeWidth(1.5); + + SVGPath check = new SVGPath(); + check.setContent("M 8 20 L 16 28 L 32 10"); + check.setStroke(Color.web("#2e7d32")); + check.setStrokeWidth(2.6); + check.setFill(Color.TRANSPARENT); + + box.getChildren().addAll(doc, check); + return box; + } + + /** + * Ícone de Validar Carimbo do Tempo. + */ + public static Node validateTimestampIcon() { + return AssetManager.getIconOrDefault("validate-timestamp", 38, SvgIcons::buildValidateTimestampIcon); + } + + public static Node buildValidateTimestampIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + Circle clock = new Circle(16, 16, 12); + clock.setFill(Color.web("#f1f8e9")); + clock.setStroke(Color.web("#558b2f")); + clock.setStrokeWidth(1.6); + + SVGPath check = new SVGPath(); + check.setContent("M 10 16 L 15 21 L 24 11"); + check.setStroke(Color.web("#2e7d32")); + check.setStrokeWidth(2.2); + check.setFill(Color.TRANSPARENT); + + box.getChildren().addAll(clock, check); + return box; + } + + /** + * Ícone de Sair do Agente. + */ + public static Node exitIcon() { + return AssetManager.getIconOrDefault("exit", 38, SvgIcons::buildExitIcon); + } + + public static Node buildExitIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath door = new SVGPath(); + door.setContent("M 18 6 L 8 6 C 6.5 6 6 7.5 6 9 L 6 27 C 6 28.5 6.5 30 8 30 L 18 30"); + door.setStroke(Color.web("#546e7a")); + door.setStrokeWidth(2); + door.setFill(Color.TRANSPARENT); + + SVGPath arrow = new SVGPath(); + arrow.setContent("M 14 18 L 30 18 M 24 12 L 30 18 L 24 24"); + arrow.setStroke(Color.web("#d32f2f")); + arrow.setStrokeWidth(2); + arrow.setFill(Color.TRANSPARENT); + + box.getChildren().addAll(door, arrow); + return box; + } + + /** + * Ícone de Logs. + */ + public static Node logsIcon() { + return AssetManager.getIconOrDefault("logs", 24, SvgIcons::buildLogsIcon); + } + + public static Node buildLogsIcon() { + StackPane box = new StackPane(); + box.setPrefSize(24, 24); + + Text code = new Text(""); + code.setFont(Font.font("Segoe UI", FontWeight.BOLD, 12)); + code.setFill(Color.web("#0288d1")); + + box.getChildren().add(code); + return box; + } + + /** + * Ícone de Configurações. + */ + public static Node settingsIcon() { + return AssetManager.getIconOrDefault("settings", 24, SvgIcons::buildSettingsIcon); + } + + public static Node buildSettingsIcon() { + StackPane box = new StackPane(); + box.setPrefSize(24, 24); + + SVGPath gear = new SVGPath(); + gear.setContent("M 12 1 C 11 1 10.5 2 10.5 3 C 9.5 3.3 8.6 3.8 7.8 4.5 L 6.2 3.6 C 5.5 3.2 4.6 3.5 4.1 4.1 L 2.6 6.6 C 2.1 7.2 2.3 8.1 3 8.6 L 4.4 9.6 C 4.2 10.4 4.1 11.2 4.1 12 C 4.1 12.8 4.2 13.6 4.4 14.4 L 3 15.4 C 2.3 15.9 2.1 16.8 2.6 17.4 L 4.1 19.9 C 4.6 20.5 5.5 20.8 6.2 20.4 L 7.8 19.5 C 8.6 20.2 9.5 20.7 10.5 21 C 10.5 22 11 23 12 23 C 13 23 13.5 22 13.5 21 C 14.5 20.7 15.4 20.2 16.2 19.5 L 17.8 20.4 C 18.5 20.8 19.4 20.5 19.9 19.9 L 21.4 17.4 C 21.9 16.8 21.7 15.9 21 15.4 L 19.6 14.4 C 19.8 13.6 19.9 12.8 19.9 12 C 19.9 11.2 19.8 10.4 19.6 9.6 L 21 8.6 C 21.7 8.1 21.9 7.2 21.4 6.6 L 19.9 4.1 C 19.4 3.5 18.5 3.2 17.8 3.6 L 16.2 4.5 C 15.4 3.8 14.5 3.3 13.5 3 C 13.5 2 13 1 12 1 Z M 12 8 C 14.2 8 16 9.8 16 12 C 16 14.2 14.2 16 12 16 C 9.8 16 8 14.2 8 12 C 8 9.8 9.8 8 12 8 Z"); + gear.setFill(Color.web("#0288d1")); + gear.setScaleX(0.85); + gear.setScaleY(0.85); + + box.getChildren().add(gear); + return box; + } + + /** + * Ícone de Tutorial. + */ + public static Node tutorialIcon() { + return AssetManager.getIconOrDefault("tutorial", 24, SvgIcons::buildTutorialIcon); + } + + public static Node buildTutorialIcon() { + StackPane box = new StackPane(); + box.setPrefSize(24, 24); + + SVGPath monitor = new SVGPath(); + monitor.setContent("M 2 4 L 22 4 L 22 17 L 2 17 Z M 8 17 L 6 21 L 18 21 L 16 17 M 12 17 L 12 21"); + monitor.setStroke(Color.web("#0288d1")); + monitor.setStrokeWidth(1.6); + monitor.setFill(Color.TRANSPARENT); + + box.getChildren().add(monitor); + return box; + } + + /** + * Ícone de Certificados. + */ + public static Node certificatesIcon() { + return AssetManager.getIconOrDefault("cert", 24, SvgIcons::buildCertificatesIcon); + } + + public static Node buildCertificatesIcon() { + StackPane box = new StackPane(); + box.setPrefSize(24, 24); + + SVGPath folder = new SVGPath(); + folder.setContent("M 2 5 L 8 5 L 10 7 L 22 7 L 22 20 L 2 20 Z"); + folder.setFill(Color.web("#e1f5fe")); + folder.setStroke(Color.web("#0288d1")); + folder.setStrokeWidth(1.5); + + Rectangle lock = new Rectangle(7, 6); + lock.setArcWidth(2); + lock.setArcHeight(2); + lock.setFill(Color.web("#0288d1")); + lock.setTranslateX(3); + lock.setTranslateY(2); + + box.getChildren().addAll(folder, lock); + return box; + } + + /** + * Ícone de Sobre. + */ + public static Node aboutIcon() { + return AssetManager.getIconOrDefault("about", 24, SvgIcons::buildAboutIcon); + } + + public static Node buildAboutIcon() { + StackPane box = new StackPane(); + box.setPrefSize(24, 24); + + Circle circle = new Circle(10); + circle.setFill(Color.web("#0288d1")); + + Text i = new Text("i"); + i.setFont(Font.font("Segoe UI", FontWeight.BOLD, 13)); + i.setFill(Color.WHITE); + + box.getChildren().addAll(circle, i); + return box; + } + + /** + * Ícone de Sol (Light Mode). + */ + public static Node sunIcon(double size) { + return AssetManager.getIconOrDefault("sun", size, () -> buildSunIcon(size)); + } + + public static Node buildSunIcon(double size) { + StackPane box = new StackPane(); + box.setPrefSize(size, size); + box.setMaxSize(size, size); + + Circle sunCore = new Circle(size * 0.5, size * 0.5, size * 0.24); + sunCore.setFill(new LinearGradient( + 0, 0, 1, 1, true, CycleMethod.NO_CYCLE, + new Stop(0, Color.web("#f59e0b")), + new Stop(1, Color.web("#d97706")) + )); + + SVGPath rays = new SVGPath(); + rays.setContent("M 12 2 L 12 5 M 12 19 L 12 22 M 2 12 L 5 12 M 19 12 L 22 12 " + + "M 4.93 4.93 L 7.05 7.05 M 16.95 16.95 L 19.07 19.07 " + + "M 4.93 19.07 L 7.05 16.95 M 16.95 7.05 L 19.07 4.93"); + rays.setStroke(Color.web("#f59e0b")); + rays.setStrokeWidth(2.0); + rays.setScaleX(size / 24.0); + rays.setScaleY(size / 24.0); + + box.getChildren().addAll(rays, sunCore); + return box; + } + + /** + * Ícone de Lua (Dark Mode). + */ + public static Node moonIcon(double size) { + return AssetManager.getIconOrDefault("moon", size, () -> buildMoonIcon(size)); + } + + public static Node buildMoonIcon(double size) { + StackPane box = new StackPane(); + box.setPrefSize(size, size); + box.setMaxSize(size, size); + + SVGPath moon = new SVGPath(); + moon.setContent("M 21 12.79 A 9 9 0 1 1 11.21 3 A 7 7 0 0 0 21 12.79 Z"); + moon.setFill(new LinearGradient( + 0, 0, 1, 1, true, CycleMethod.NO_CYCLE, + new Stop(0, Color.web("#38bdf8")), + new Stop(1, Color.web("#818cf8")) + )); + moon.setScaleX(size / 24.0); + moon.setScaleY(size / 24.0); + + box.getChildren().add(moon); + return box; + } + + /** + * Logotipo vetorial CyberShield. + */ + public static Node cyberShieldLogo(double size) { + return AssetManager.getIconOrDefault("shield", size, () -> buildCyberShieldLogo(size)); + } + + public static Node buildCyberShieldLogo(double size) { + StackPane box = new StackPane(); + box.setPrefSize(size, size); + box.setMaxSize(size, size); + + SVGPath shield = new SVGPath(); + shield.setContent("M 12 2 L 3 6 L 3 13 C 3 18.5 7 23.5 12 25 C 17 23.5 21 18.5 21 13 L 21 6 Z"); + shield.setFill(new LinearGradient( + 0, 0, 1, 1, true, CycleMethod.NO_CYCLE, + new Stop(0, Color.web("#0284c7")), + new Stop(0.5, Color.web("#0369a1")), + new Stop(1, Color.web("#075985")) + )); + shield.setStroke(Color.web("#38bdf8")); + shield.setStrokeWidth(1.2); + shield.setScaleX(size / 26.0); + shield.setScaleY(size / 26.0); + + SVGPath key = new SVGPath(); + key.setContent("M 12 7 C 10.3 7 9 8.3 9 10 C 9 11.3 9.8 12.4 11 12.8 L 11 18 L 13 18 L 13 15 L 14 15 L 14 13 L 13 13 L 13 12.8 C 14.2 12.4 15 11.3 15 10 C 15 8.3 13.7 7 12 7 Z M 12 9 C 12.6 9 13 9.4 13 10 C 13 10.6 12.6 11 12 11 C 11.4 11 11 10.6 11 10 C 11 9.4 11.4 9 12 9 Z"); + key.setFill(Color.web("#ffffff")); + key.setScaleX(size / 26.0); + key.setScaleY(size / 26.0); + + box.getChildren().addAll(shield, key); + return box; + } + + /** + * Ícone de Gerenciador/Criador de Selos Visuais de Assinatura. + */ + public static Node sealIcon() { + return AssetManager.getIconOrDefault("seal", 38, SvgIcons::buildSealIcon); + } + + public static Node buildSealIcon() { + StackPane box = new StackPane(); + box.setPrefSize(38, 38); + + SVGPath ribbon = new SVGPath(); + ribbon.setContent("M 14 26 L 19 36 L 24 26 Z M 9 26 L 12 34 L 15 26 Z"); + ribbon.setFill(Color.web("#f59e0b")); + + Circle sealCircle = new Circle(13); + sealCircle.setFill(Color.web("#d97706")); + sealCircle.setStroke(Color.web("#fbbf24")); + sealCircle.setStrokeWidth(1.6); + sealCircle.setTranslateY(-4); + + SVGPath star = new SVGPath(); + star.setContent("M 19 8 L 21 13 L 26 13 L 22 16 L 24 21 L 19 18 L 14 21 L 16 16 L 12 13 L 17 13 Z"); + star.setFill(Color.web("#ffffff")); + star.setTranslateX(-9); + star.setTranslateY(-14); + + box.getChildren().addAll(ribbon, sealCircle, star); + return box; + } + + /** + * Ícone vetorial de Fechar / Cancelar (cruz geométrica moderna e limpa). + */ + public static Node closeIcon() { + return closeIcon(14); + } + + public static Node closeIcon(double size) { + StackPane box = new StackPane(); + box.setPrefSize(size, size); + box.setMaxSize(size, size); + box.setMinSize(size, size); + box.setAlignment(Pos.CENTER); + + SVGPath cross = new SVGPath(); + cross.setContent("M 3.5 3.5 L 12.5 12.5 M 12.5 3.5 L 3.5 12.5"); + cross.setStroke(Color.web("#64748b")); + cross.setStrokeWidth(2.0); + cross.setStrokeLineCap(javafx.scene.shape.StrokeLineCap.ROUND); + cross.setFill(Color.TRANSPARENT); + + double scale = size / 16.0; + cross.setScaleX(scale); + cross.setScaleY(scale); + + box.getChildren().add(cross); + return box; + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/theme/ThemeManager.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/theme/ThemeManager.java new file mode 100644 index 0000000..f2afa93 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/theme/ThemeManager.java @@ -0,0 +1,120 @@ +package br.com.cybercore.signer.ui.theme; + +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import javafx.scene.Parent; +import javafx.scene.Scene; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Gerenciador centralizado de temas visuais do CyberCore Signer. + * Permite alternar dinamicamente entre Tema Claro (Light) e Tema Escuro (Dark) + * aplicando as classes CSS e folhas de estilo correspondentes em qualquer Scene. + */ +public final class ThemeManager { + + public enum AppTheme { + LIGHT("theme-light", "Modo Claro"), + DARK("theme-dark", "Modo Escuro"); + + private final String cssClass; + private final String displayName; + + AppTheme(String cssClass, String displayName) { + this.cssClass = cssClass; + this.displayName = displayName; + } + + public String getCssClass() { + return cssClass; + } + + public String getDisplayName() { + return displayName; + } + } + + private static AppTheme currentTheme = AppTheme.LIGHT; + private static final List> listeners = new ArrayList<>(); + + private ThemeManager() { + } + + public static AppTheme getCurrentTheme() { + return currentTheme; + } + + public static boolean isDark() { + return currentTheme == AppTheme.DARK; + } + + public static void setCurrentTheme(AppTheme theme) { + if (theme == null || theme == currentTheme) { + return; + } + currentTheme = theme; + notifyListeners(); + } + + public static AppTheme toggleTheme() { + currentTheme = (currentTheme == AppTheme.LIGHT) ? AppTheme.DARK : AppTheme.LIGHT; + notifyListeners(); + return currentTheme; + } + + /** + * Aplica o tema atualmente ativo a uma Scene, adicionando a folha de estilo global + * e ajustando a classe CSS raiz (theme-light / theme-dark). + */ + public static void applyTheme(Scene scene) { + if (scene == null) { + return; + } + + String cssUrl = Objects.requireNonNull( + ThemeManager.class.getResource(CyberCoreUiConstants.MAIN_CSS_PATH) + ).toExternalForm(); + + if (!scene.getStylesheets().contains(cssUrl)) { + scene.getStylesheets().add(cssUrl); + } + + Parent root = scene.getRoot(); + if (root != null) { + applyThemeToNode(root); + } + } + + /** + * Aplica a classe de estilo do tema ativo no nó raiz fornecido. + */ + public static void applyThemeToNode(Parent node) { + if (node == null) { + return; + } + node.getStyleClass().removeAll(AppTheme.LIGHT.getCssClass(), AppTheme.DARK.getCssClass()); + node.getStyleClass().add(currentTheme.getCssClass()); + } + + public static void addListener(Consumer listener) { + if (listener != null && !listeners.contains(listener)) { + listeners.add(listener); + } + } + + public static void removeListener(Consumer listener) { + listeners.remove(listener); + } + + private static void notifyListeners() { + for (Consumer listener : new ArrayList<>(listeners)) { + try { + listener.accept(currentTheme); + } catch (Exception ignored) { + } + } + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/tray/SystemTrayManager.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/tray/SystemTrayManager.java new file mode 100644 index 0000000..07869b0 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/tray/SystemTrayManager.java @@ -0,0 +1,169 @@ +package br.com.cybercore.signer.ui.tray; + +import javafx.application.Platform; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.AWTException; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.MenuItem; +import java.awt.PopupMenu; +import java.awt.RenderingHints; +import java.awt.SystemTray; +import java.awt.TrayIcon; +import java.awt.image.BufferedImage; +import java.util.Objects; + +/** + * Gerencia a integração nativa com o System Tray (Área de Notificação / Bandeja do Windows). + * Permite que o CyberCore Signer permaneça em execução transparente em segundo plano, + * garantindo o atendimento ininterrupto às requisições do protocolo CCSP/1. + */ +public class SystemTrayManager { + + private static final Logger logger = LoggerFactory.getLogger(SystemTrayManager.class); + + private final Stage stage; + private final Runnable onRefreshCertificates; + private TrayIcon trayIcon; + private boolean initialized = false; + + public SystemTrayManager(Stage stage, Runnable onRefreshCertificates) { + this.stage = Objects.requireNonNull(stage, "stage must not be null"); + this.onRefreshCertificates = onRefreshCertificates; + } + + /** + * Inicializa o ícone e menu de contexto na bandeja do sistema, caso haja suporte. + */ + public synchronized boolean setup() { + if (!SystemTray.isSupported()) { + logger.info("SystemTray não é suportado pelo ambiente atual ou sistema operacional."); + return false; + } + + try { + SystemTray tray = SystemTray.getSystemTray(); + Image iconImage = createTrayIconImage(); + + PopupMenu popup = new PopupMenu(); + + MenuItem openItem = new MenuItem("Abrir CyberCore Signer"); + openItem.addActionListener(e -> Platform.runLater(this::showStage)); + + MenuItem refreshItem = new MenuItem("Atualizar Certificados"); + refreshItem.addActionListener(e -> { + if (onRefreshCertificates != null) { + onRefreshCertificates.run(); + } + }); + + MenuItem exitItem = new MenuItem("Encerrar Agente"); + exitItem.addActionListener(e -> { + logger.info("Encerramento solicitado através do menu da bandeja."); + shutdown(); + }); + + popup.add(openItem); + popup.add(refreshItem); + popup.addSeparator(); + popup.add(exitItem); + + this.trayIcon = new TrayIcon(iconImage, "CyberCore Signer (CCSP/1 Ativo)", popup); + this.trayIcon.setImageAutoSize(true); + + // Clique no ícone restaura a janela + this.trayIcon.addActionListener(e -> Platform.runLater(this::showStage)); + + tray.add(this.trayIcon); + this.initialized = true; + + // Intercepta fechamento da janela para ocultar em vez de encerrar + Platform.runLater(() -> { + stage.setOnCloseRequest(event -> { + event.consume(); + stage.hide(); + showMessage("CyberCore Signer", "O agente continua ativo na bandeja atendendo aplicações web.", TrayIcon.MessageType.INFO); + }); + }); + + logger.info("System Tray configurado com sucesso."); + return true; + + } catch (AWTException e) { + logger.warn("Falha ao registrar ícone no System Tray: {}", e.getMessage()); + return false; + } catch (Throwable t) { + logger.warn("Erro ao inicializar System Tray: {}", t.getMessage()); + return false; + } + } + + public void showStage() { + if (stage != null) { + stage.show(); + stage.setIconified(false); + stage.toFront(); + } + } + + public void showMessage(String title, String message, TrayIcon.MessageType type) { + if (initialized && trayIcon != null) { + try { + trayIcon.displayMessage(title, message, type); + } catch (Throwable t) { + logger.debug("Não foi possível exibir notificação no Tray: {}", t.getMessage()); + } + } + } + + public synchronized void remove() { + if (initialized && trayIcon != null) { + try { + SystemTray.getSystemTray().remove(trayIcon); + } catch (Throwable ignored) { + } + initialized = false; + } + } + + private void shutdown() { + remove(); + Platform.runLater(() -> { + stage.close(); + Platform.exit(); + System.exit(0); + }); + } + + /** + * Gera dinamicamente um ícone vetorial de 32x32 com o padrão de cores CyberCore (#38bdf8). + */ + private Image createTrayIconImage() { + int size = 32; + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Fundo em círculo azul escuro CyberCore + g.setColor(new Color(15, 23, 42)); + g.fillRoundRect(2, 2, size - 4, size - 4, 8, 8); + + // Borda ciano com gradiente + g.setColor(new Color(56, 189, 248)); + g.drawRoundRect(2, 2, size - 4, size - 4, 8, 8); + + // Letra C central estilizada + g.setColor(new Color(56, 189, 248)); + g.setFont(new Font("SansSerif", Font.BOLD, 18)); + g.drawString("C", 9, 23); + + g.dispose(); + return image; + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/A1PasswordDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/A1PasswordDialog.java new file mode 100644 index 0000000..2b7ee3d --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/A1PasswordDialog.java @@ -0,0 +1,103 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** + * Diálogo modal para inserção segura da senha do certificado A1 (.pfx / .p12). + */ +public class A1PasswordDialog { + + private final Stage stage; + private final CompletableFuture passwordFuture = new CompletableFuture<>(); + + public A1PasswordDialog(Stage owner, String fileName) { + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — Senha do Certificado A1"); + this.stage.setResizable(false); + + initLayout(fileName); + } + + private void initLayout(String fileName) { + VBox root = new VBox(16); + root.getStyleClass().add("dialog-container"); + root.setPadding(new Insets(24)); + root.setPrefWidth(440); + + Label header = new Label("IMPORTAR CERTIFICADO A1"); + header.getStyleClass().add("dialog-header"); + + Label desc = new Label("Digite a senha do arquivo para desbloquear o certificado digital."); + desc.getStyleClass().add("brand-subtitle"); + + VBox fileBox = new VBox(4); + fileBox.getStyleClass().add("dialog-box"); + Label fileTitle = new Label("Arquivo selecionado:"); + fileTitle.getStyleClass().add("dialog-field-title"); + Label fileNameLabel = new Label(fileName); + fileNameLabel.getStyleClass().add("detail-value"); + fileBox.getChildren().addAll(fileTitle, fileNameLabel); + + VBox inputGroup = new VBox(8); + Label pwdTitle = new Label("Senha do Certificado:"); + pwdTitle.getStyleClass().add("dialog-field-title"); + + PasswordField passwordField = new PasswordField(); + passwordField.setPromptText("Digite a senha..."); + + inputGroup.getChildren().addAll(pwdTitle, passwordField); + + HBox actionsRow = new HBox(12); + actionsRow.setAlignment(Pos.CENTER_RIGHT); + + Button btnCancel = new Button("Cancelar"); + btnCancel.getStyleClass().add("btn-secondary"); + btnCancel.setOnAction(e -> { + passwordFuture.complete(null); + stage.close(); + }); + + Button btnConfirm = new Button("Importar"); + btnConfirm.getStyleClass().add("btn-primary"); + btnConfirm.setDefaultButton(true); + btnConfirm.setOnAction(e -> { + String text = passwordField.getText(); + passwordFuture.complete(text != null ? text.toCharArray() : new char[0]); + stage.close(); + }); + + actionsRow.getChildren().addAll(btnCancel, btnConfirm); + + root.getChildren().addAll(header, desc, fileBox, inputGroup, actionsRow); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + + stage.setScene(scene); + stage.setOnCloseRequest(e -> passwordFuture.complete(null)); + } + + public CompletableFuture showAndAwait() { + stage.show(); + return passwordFuture; + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/AboutView.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/AboutView.java new file mode 100644 index 0000000..d9ece1c --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/AboutView.java @@ -0,0 +1,52 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; + +/** + * Painel informativo Sobre o CyberCore Signer. + */ +@SuppressWarnings("this-escape") +public final class AboutView extends VBox { + + public AboutView() { + initView(); + } + + private void initView() { + setSpacing(16); + setAlignment(Pos.CENTER); + getStyleClass().add("content-pane"); + + Label brandLabel = new Label(CyberCoreUiConstants.APP_NAME); + brandLabel.getStyleClass().add("brand-title"); + brandLabel.setStyle("-fx-font-size: 24px;"); + + Label versionLabel = new Label("Versão " + CyberCoreUiConstants.APP_VERSION + " • CCSP/1"); + versionLabel.getStyleClass().add("status-badge-active"); + + Label descLabel = new Label("Agente desktop profissional de assinatura digital segura para aplicações web."); + descLabel.getStyleClass().add("section-subtitle"); + + VBox detailsBox = new VBox(8); + detailsBox.setAlignment(Pos.CENTER); + detailsBox.getStyleClass().add("dialog-box"); + + Label p1 = new Label("• Criptografia local via JCA/JCE e PKCS#11."); + Label p2 = new Label("• A chave privada e o PIN nunca saem deste computador."); + Label p3 = new Label("• Comunicação restrita à interface local loopback (127.0.0.1)."); + Label p4 = new Label("• Proteção contra replay attacks e validação estrita de origem."); + + for (Label lbl : new Label[]{p1, p2, p3, p4}) { + lbl.getStyleClass().add("cert-detail"); + detailsBox.getChildren().add(lbl); + } + + Label copyLabel = new Label("© 2026 CyberCore. Todos os direitos reservados."); + copyLabel.getStyleClass().add("dialog-subtitle"); + + getChildren().addAll(brandLabel, versionLabel, descLabel, detailsBox, copyLabel); + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/ApplicationApprovalDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/ApplicationApprovalDialog.java new file mode 100644 index 0000000..092333d --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/ApplicationApprovalDialog.java @@ -0,0 +1,123 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.StageStyle; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** + * Diálogo modal para solicitar autorização explícita do usuário quando uma nova aplicação web + * tenta se conectar ao CyberCore Signer pela primeira vez. + */ +public class ApplicationApprovalDialog { + + private final Stage stage; + private final CompletableFuture approvalFuture = new CompletableFuture<>(); + + public ApplicationApprovalDialog(Stage ownerStage, String origin, String appName, String applicationId) { + stage = new Stage(); + stage.initOwner(ownerStage); + stage.initModality(Modality.APPLICATION_MODAL); + stage.initStyle(StageStyle.UTILITY); + stage.setTitle("CyberCore Signer — Solicitação de Conexão Web"); + stage.setWidth(520); + stage.setResizable(false); + + VBox root = new VBox(18); + root.setPadding(new Insets(24)); + root.getStyleClass().add("dialog-container"); + + // Cabeçalho + VBox headerBox = new VBox(4); + Label titleLabel = new Label("NOVA APLICAÇÃO WEB SOLICITA ACESSO"); + titleLabel.getStyleClass().add("dialog-header"); + + Label subtitleLabel = new Label("Um sistema web está tentando se conectar ao agente local CyberCore Signer."); + subtitleLabel.getStyleClass().add("dialog-subtitle"); + headerBox.getChildren().addAll(titleLabel, subtitleLabel); + + // Painel de Detalhes da Aplicação + VBox detailsBox = new VBox(10); + detailsBox.getStyleClass().add("dialog-box"); + + VBox appRow = new VBox(2); + Label appTitle = new Label("APLICAÇÃO SOLICITANTE"); + appTitle.getStyleClass().add("dialog-field-title"); + Label appValue = new Label(appName != null && !appName.isBlank() ? appName : "Aplicação Web Externa"); + appValue.getStyleClass().add("dialog-field-value"); + appValue.setStyle("-fx-font-weight: bold;"); + appRow.getChildren().addAll(appTitle, appValue); + + VBox originRow = new VBox(2); + Label originTitle = new Label("ORIGEM WEB (URL)"); + originTitle.getStyleClass().add("dialog-field-title"); + Label originValue = new Label(origin != null ? origin : "Origem desconhecida"); + originValue.setStyle("-fx-text-fill: -fx-accent; -fx-font-size: 13px; -fx-font-family: monospace;"); + originRow.getChildren().addAll(originTitle, originValue); + + VBox idRow = new VBox(2); + Label idTitle = new Label("IDENTIFICADOR DA APLICAÇÃO"); + idTitle.getStyleClass().add("dialog-field-title"); + Label idValue = new Label(applicationId); + idValue.getStyleClass().add("dialog-field-value"); + idValue.setStyle("-fx-font-size: 12px; -fx-font-family: monospace;"); + idRow.getChildren().addAll(idTitle, idValue); + + detailsBox.getChildren().addAll(appRow, originRow, idRow); + + // Aviso de Segurança + Label secWarning = new Label( + "🛡️ Segurança: Aplicações autorizadas podem consultar a lista pública de certificados " + + "instalados e solicitar assinaturas. Nenhuma assinatura será realizada sem a sua confirmação explícita na tela." + ); + secWarning.setWrapText(true); + secWarning.setStyle("-fx-text-fill: -fx-text-secondary; -fx-font-size: 11px; -fx-line-spacing: 2px;"); + + // Botões de Ação + HBox actionsRow = new HBox(12); + actionsRow.setAlignment(Pos.CENTER_RIGHT); + + Button btnReject = new Button("Recusar Acesso"); + btnReject.getStyleClass().add("btn-secondary"); + btnReject.setOnAction(e -> { + approvalFuture.complete(false); + stage.close(); + }); + + Button btnAuthorize = new Button("Autorizar Conexão"); + btnAuthorize.getStyleClass().add("btn-primary"); + btnAuthorize.setDefaultButton(true); + btnAuthorize.setOnAction(e -> { + approvalFuture.complete(true); + stage.close(); + }); + + actionsRow.getChildren().addAll(btnReject, btnAuthorize); + + root.getChildren().addAll(headerBox, detailsBox, secWarning, actionsRow); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + + stage.setScene(scene); + stage.setOnCloseRequest(e -> approvalFuture.complete(false)); + } + + public CompletableFuture showAndAwait() { + stage.show(); + stage.toFront(); + return approvalFuture; + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/BatchSigningDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/BatchSigningDialog.java new file mode 100644 index 0000000..e23e640 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/BatchSigningDialog.java @@ -0,0 +1,491 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.crypto.SigningService; +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.DirectoryChooser; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * Diálogo modal para execução de Assinatura Digital em Lote de múltiplos arquivos (PDF, XML e arquivos diversos). + */ +@SuppressWarnings("this-escape") +public final class BatchSigningDialog { + + private static final Logger logger = LoggerFactory.getLogger(BatchSigningDialog.class); + + private final Stage stage; + private final CertificateManager certManager; + private final SigningService signingService; + + private final ObservableList fileItems = FXCollections.observableArrayList(); + private TableView tableView; + private ComboBox certCombo; + private TextField txtOutputDir; + private ProgressBar progressBar; + private Label lblProgressStatus; + private Button btnStart; + private Button btnAddFiles; + private Button btnClear; + private Button btnChooseOutputDir; + + private volatile boolean isRunning = false; + + public static class BatchItem { + private final File file; + private final SimpleStringProperty fileName = new SimpleStringProperty(); + private final SimpleStringProperty fileSize = new SimpleStringProperty(); + private final SimpleStringProperty status = new SimpleStringProperty("Aguardando"); + + public BatchItem(File file) { + this.file = file; + this.fileName.set(file.getName()); + this.fileSize.set(formatSize(file.length())); + } + + public File getFile() { + return file; + } + + public String getFileName() { + return fileName.get(); + } + + public SimpleStringProperty fileNameProperty() { + return fileName; + } + + public String getFileSize() { + return fileSize.get(); + } + + public SimpleStringProperty fileSizeProperty() { + return fileSize; + } + + public String getStatus() { + return status.get(); + } + + public void setStatus(String val) { + this.status.set(val); + } + + public SimpleStringProperty statusProperty() { + return status; + } + + private static String formatSize(long bytes) { + if (bytes < 1024) return bytes + " B"; + int exp = (int) (Math.log(bytes) / Math.log(1024)); + char pre = "KMGTPE".charAt(exp - 1); + return String.format("%.1f %sB", bytes / Math.pow(1024, exp), pre); + } + } + + public BatchSigningDialog(Stage owner, CertificateManager certManager, SigningService signingService) { + this.certManager = Objects.requireNonNull(certManager, "CertificateManager não pode ser nulo."); + this.signingService = Objects.requireNonNull(signingService, "SigningService não pode ser nulo."); + + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — Assinatura Digital em Lote"); + this.stage.setResizable(true); + this.stage.setMinWidth(750); + this.stage.setMinHeight(580); + + initLayout(); + } + + private void initLayout() { + VBox root = new VBox(16); + root.setPadding(new Insets(20)); + root.getStyleClass().add("dialog-container"); + + // Topo / Cabeçalho + HBox header = new HBox(12); + header.setAlignment(Pos.CENTER_LEFT); + header.getChildren().addAll( + SvgIcons.batchIcon(), + createHeaderLabel("ASSINATURA DIGITAL EM LOTE", "Assine múltiplos arquivos de uma só vez utilizando seu Certificado Digital ICP-Brasil") + ); + + // Barra de Ação de Arquivos + HBox fileActions = new HBox(10); + fileActions.setAlignment(Pos.CENTER_LEFT); + + btnAddFiles = new Button("Adicionar Arquivos..."); + btnAddFiles.getStyleClass().add("btn-primary"); + btnAddFiles.setOnAction(e -> handleAddFiles()); + + btnClear = new Button("Limpar Lista"); + btnClear.getStyleClass().add("btn-secondary"); + btnClear.setOnAction(e -> { + if (!isRunning) fileItems.clear(); + }); + + fileActions.getChildren().addAll(btnAddFiles, btnClear); + + // Tabela de Arquivos + tableView = new TableView<>(fileItems); + VBox.setVgrow(tableView, Priority.ALWAYS); + + TableColumn colName = new TableColumn<>("Nome do Arquivo"); + colName.setCellValueFactory(c -> c.getValue().fileNameProperty()); + colName.setPrefWidth(320); + + TableColumn colSize = new TableColumn<>("Tamanho"); + colSize.setCellValueFactory(c -> c.getValue().fileSizeProperty()); + colSize.setPrefWidth(120); + + TableColumn colStatus = new TableColumn<>("Status"); + colStatus.setCellValueFactory(c -> c.getValue().statusProperty()); + tableView.getColumns().add(colName); + tableView.getColumns().add(colSize); + tableView.getColumns().add(colStatus); + tableView.setPlaceholder(new Label("Nenhum arquivo adicionado. Clique em 'Adicionar Arquivos...' para selecionar.")); + + // Seção de Configuração do Certificado e Destino + VBox configBox = new VBox(10); + configBox.getStyleClass().add("dialog-box"); + + // Seleção do Certificado + HBox certRow = new HBox(10); + certRow.setAlignment(Pos.CENTER_LEFT); + Label lblCert = new Label("Certificado Digital:"); + lblCert.getStyleClass().add("dialog-field-title"); + lblCert.setStyle("-fx-min-width: 140;"); + + certCombo = new ComboBox<>(); + certCombo.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(certCombo, Priority.ALWAYS); + populateCertificates(); + + certCombo.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(ManagedCertificate item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + String doc = item.cpf() != null ? "CPF: " + item.cpf() : (item.cnpj() != null ? "CNPJ: " + item.cnpj() : ""); + setText(item.commonName() + " (" + item.type() + ") " + doc); + } + } + }); + certCombo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(ManagedCertificate item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText("Selecione um Certificado Digital..."); + } else { + String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : ""); + setText(item.commonName() + doc + " • " + item.type()); + } + } + }); + + certRow.getChildren().addAll(lblCert, certCombo); + + // Pasta de Saída + HBox outputRow = new HBox(10); + outputRow.setAlignment(Pos.CENTER_LEFT); + Label lblOutput = new Label("Pasta de Destino:"); + lblOutput.getStyleClass().add("dialog-field-title"); + lblOutput.setStyle("-fx-min-width: 140;"); + + txtOutputDir = new TextField(); + txtOutputDir.setPromptText("Salvar na mesma pasta dos arquivos originais"); + txtOutputDir.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(txtOutputDir, Priority.ALWAYS); + + btnChooseOutputDir = new Button("Selecionar..."); + btnChooseOutputDir.getStyleClass().add("btn-secondary"); + btnChooseOutputDir.setOnAction(e -> handleChooseOutputDir()); + + outputRow.getChildren().addAll(lblOutput, txtOutputDir, btnChooseOutputDir); + + configBox.getChildren().addAll(certRow, outputRow); + + // Progresso e Ações Finais + VBox progressBox = new VBox(6); + progressBar = new ProgressBar(0); + progressBar.setMaxWidth(Double.MAX_VALUE); + progressBar.setVisible(false); + + lblProgressStatus = new Label(""); + lblProgressStatus.getStyleClass().add("dialog-subtitle"); + + progressBox.getChildren().addAll(progressBar, lblProgressStatus); + + HBox footer = new HBox(12); + footer.setAlignment(Pos.CENTER_RIGHT); + + Button btnClose = new Button("Fechar"); + btnClose.getStyleClass().add("btn-secondary"); + btnClose.setOnAction(e -> { + if (!isRunning) stage.close(); + }); + + btnStart = new Button("Iniciar Assinatura em Lote"); + btnStart.getStyleClass().add("btn-primary"); + btnStart.setOnAction(e -> startBatchSigning()); + + footer.getChildren().addAll(btnClose, btnStart); + + root.getChildren().addAll(header, fileActions, tableView, configBox, progressBox, footer); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + + stage.setScene(scene); + } + + private void handleAddFiles() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Selecionar Arquivos para Assinatura em Lote"); + fileChooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("Todos os Arquivos Suportados", "*.pdf", "*.xml", "*.txt", "*.doc", "*.docx", "*.*"), + new FileChooser.ExtensionFilter("Arquivos PDF (*.pdf)", "*.pdf"), + new FileChooser.ExtensionFilter("Arquivos XML (*.xml)", "*.xml") + ); + + List selected = fileChooser.showOpenMultipleDialog(stage); + if (selected != null) { + for (File f : selected) { + boolean exists = fileItems.stream().anyMatch(item -> item.getFile().getAbsolutePath().equals(f.getAbsolutePath())); + if (!exists) { + fileItems.add(new BatchItem(f)); + } + } + } + } + + private void handleChooseOutputDir() { + DirectoryChooser dirChooser = new DirectoryChooser(); + dirChooser.setTitle("Selecionar Pasta para Salvar Arquivos Assinados"); + File dir = dirChooser.showDialog(stage); + if (dir != null) { + txtOutputDir.setText(dir.getAbsolutePath()); + } + } + + private void populateCertificates() { + try { + List certs = certManager.getAvailableCertificates(); + certCombo.setItems(FXCollections.observableArrayList(certs)); + if (!certs.isEmpty()) { + certCombo.getSelectionModel().select(0); + } + } catch (Exception e) { + logger.warn("Erro ao listar certificados para lote: {}", e.getMessage()); + } + } + + private void startBatchSigning() { + if (fileItems.isEmpty()) { + showAlert("Nenhum Arquivo", "Por favor, adicione pelo menos um arquivo para assinar."); + return; + } + + ManagedCertificate selectedCert = certCombo.getValue(); + if (selectedCert == null) { + showAlert("Certificado Não Selecionado", "Selecione o Certificado Digital que será utilizado para as assinaturas."); + return; + } + + if (selectedCert.status() != CertificateStatus.AVAILABLE) { + showAlert("Certificado Indisponível", "O certificado selecionado não está pronto ou disponível para assinatura."); + return; + } + + isRunning = true; + setControlsDisabled(true); + progressBar.setVisible(true); + progressBar.setProgress(0); + + List itemsToProcess = new ArrayList<>(fileItems); + String customOutDir = txtOutputDir.getText().trim(); + + Task batchTask = new Task<>() { + @Override + protected Void call() throws Exception { + int total = itemsToProcess.size(); + int successCount = 0; + + for (int i = 0; i < total; i++) { + BatchItem item = itemsToProcess.get(i); + final int currentIdx = i + 1; + + Platform.runLater(() -> { + item.setStatus("Assinando..."); + progressBar.setProgress((double) currentIdx / total); + lblProgressStatus.setText(String.format("Processando arquivo %d de %d: %s", currentIdx, total, item.getFileName())); + }); + + try { + // Determina local de gravação + Path targetDir = customOutDir.isEmpty() + ? item.getFile().toPath().getParent() + : Path.of(customOutDir); + + String lowerName = item.getFileName().toLowerCase(); + if (lowerName.endsWith(".pdf")) { + // Assinatura PAdES de PDF com selo visual + br.com.cybercore.signer.core.pdf.PdfSigningService pdfService = + new br.com.cybercore.signer.core.pdf.PdfSigningService(certManager); + br.com.cybercore.signer.core.pdf.PdfSignatureOptions options = + br.com.cybercore.signer.core.pdf.PdfSignatureOptions.builder(selectedCert.id()) + .visibleSeal(true) + .build(); + String outPdfName = item.getFileName().substring(0, item.getFileName().length() - 4) + "_assinado.pdf"; + Path dest = targetDir.resolve(outPdfName); + pdfService.signPdfFile(item.getFile(), dest.toFile(), options); + } else if (lowerName.endsWith(".xml")) { + // Assinatura XMLDSig de documento XML + br.com.cybercore.signer.core.xml.XmlSigningService xmlService = + new br.com.cybercore.signer.core.xml.XmlSigningService(certManager); + br.com.cybercore.signer.core.xml.XmlSignatureOptions options = + br.com.cybercore.signer.core.xml.XmlSignatureOptions.wholeDocument(selectedCert.id(), null); + String outXmlName = item.getFileName().substring(0, item.getFileName().length() - 4) + "_assinado.xml"; + Path dest = targetDir.resolve(outXmlName); + xmlService.signXmlFile(item.getFile(), dest.toFile(), options); + } else { + // Assinatura genérica por digest (.p7s) + byte[] fileBytes = Files.readAllBytes(item.getFile().toPath()); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(fileBytes); + + SigningContext context = new SigningContext( + UUID.randomUUID().toString(), + "cybercore-desktop-batch", + digest, + "SHA256withRSA", + selectedCert.id(), + Instant.now() + ); + + SigningResult result = signingService.sign(context); + String outFileName = item.getFileName() + ".p7s"; + Path dest = targetDir.resolve(outFileName); + Files.write(dest, result.signatureBytes()); + } + + Platform.runLater(() -> item.setStatus("✓ Assinado com Sucesso")); + successCount++; + } catch (Exception ex) { + logger.error("Falha ao assinar arquivo {}: {}", item.getFileName(), ex.getMessage(), ex); + Platform.runLater(() -> item.setStatus("✗ Erro: " + ex.getMessage())); + } + } + + final int finalSuccess = successCount; + Platform.runLater(() -> { + lblProgressStatus.setText(String.format("Lote concluído: %d de %d arquivos assinados com sucesso.", finalSuccess, total)); + showAlert("Assinatura em Lote Concluída", + String.format("Processamento finalizado com sucesso!\n\n• Arquivos assinados: %d\n• Total processado: %d", finalSuccess, total)); + }); + + return null; + } + + @Override + protected void succeeded() { + isRunning = false; + setControlsDisabled(false); + } + + @Override + protected void failed() { + isRunning = false; + setControlsDisabled(false); + showAlert("Erro no Lote", "Ocorreu uma falha durante o processamento do lote: " + getException().getMessage()); + } + }; + + Thread thread = new Thread(batchTask, "Batch-Signer-Worker"); + thread.setDaemon(true); + thread.start(); + } + + private void setControlsDisabled(boolean disabled) { + btnStart.setDisable(disabled); + btnAddFiles.setDisable(disabled); + btnClear.setDisable(disabled); + certCombo.setDisable(disabled); + btnChooseOutputDir.setDisable(disabled); + } + + private void showAlert(String title, String message) { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.initOwner(stage); + if (alert.getDialogPane() != null && alert.getDialogPane().getScene() != null) { + ThemeManager.applyTheme(alert.getDialogPane().getScene()); + } + alert.showAndWait(); + } + + private VBox createHeaderLabel(String title, String subtitle) { + VBox box = new VBox(3); + Label t = new Label(title); + t.getStyleClass().add("dialog-header"); + Label s = new Label(subtitle); + s.getStyleClass().add("dialog-subtitle"); + box.getChildren().addAll(t, s); + return box; + } + + public void show() { + stage.show(); + } + + public Stage getStage() { + return stage; + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/CertificateDetailsDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/CertificateDetailsDialog.java new file mode 100644 index 0000000..e3c3f50 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/CertificateDetailsDialog.java @@ -0,0 +1,213 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Diálogo modal para exibição de detalhes e metadados de um certificado digital. + */ +public class CertificateDetailsDialog { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss") + .withZone(ZoneId.systemDefault()); + + private final Stage stage; + private final ManagedCertificate cert; + + public CertificateDetailsDialog(Stage owner, ManagedCertificate cert) { + this.cert = Objects.requireNonNull(cert, "Certificado não pode ser nulo."); + + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — Detalhes do Certificado"); + this.stage.setResizable(true); + this.stage.setMinWidth(600); + this.stage.setMinHeight(520); + + initLayout(); + } + + private void initLayout() { + VBox root = new VBox(18); + root.getStyleClass().add("dialog-container"); + root.setPadding(new Insets(24)); + + // Cabeçalho + HBox headerRow = new HBox(12); + headerRow.setAlignment(Pos.CENTER_LEFT); + + Label headerTitle = new Label("DETALHES DO CERTIFICADO"); + headerTitle.getStyleClass().add("dialog-header"); + HBox.setHgrow(headerTitle, Priority.ALWAYS); + + Label badge = new Label(cert.type() == CertificateType.A1 ? "A1 • SOFTWARE" : "A3 • HARDWARE/TOKEN"); + badge.getStyleClass().add(cert.type() == CertificateType.A1 ? "cert-badge-a1" : "cert-badge-a3"); + + headerRow.getChildren().addAll(headerTitle, badge); + + // Nome do Titular em destaque + VBox titleBox = new VBox(4); + Label nameLabel = new Label(cert.commonName() != null ? cert.commonName() : cert.alias()); + nameLabel.getStyleClass().add("cert-title"); + nameLabel.setStyle("-fx-font-size: 16px; -fx-font-weight: bold;"); + + String docInfo = ""; + if (cert.cpf() != null) { + docInfo = "CPF: " + formatCpf(cert.cpf()); + } else if (cert.cnpj() != null) { + docInfo = "CNPJ: " + formatCnpj(cert.cnpj()); + } + Label docLabel = new Label(docInfo.isEmpty() ? "Alias: " + cert.alias() : docInfo); + docLabel.getStyleClass().add("brand-subtitle"); + + titleBox.getChildren().addAll(nameLabel, docLabel); + + // Grade de Informações Técnicas + GridPane grid = new GridPane(); + grid.setHgap(16); + grid.setVgap(10); + grid.setPadding(new Insets(8, 0, 8, 0)); + + int row = 0; + addField(grid, row++, "Status:", formatStatus(cert)); + addField(grid, row++, "Dispositivo / Origem:", cert.hardwareDeviceName() != null ? cert.hardwareDeviceName() : cert.providerId()); + addField(grid, row++, "Emissor (AC):", extractCn(cert.issuerDn())); + addField(grid, row++, "Válido de:", cert.notBefore() != null ? DATE_TIME_FORMATTER.format(cert.notBefore()) : "N/D"); + addField(grid, row++, "Válido até:", formatExpiration(cert.notAfter())); + addField(grid, row++, "Algoritmo da Chave:", cert.algorithm() + " (" + cert.keySize() + " bits)"); + addField(grid, row++, "Número de Série:", cert.serialNumber() != null ? cert.serialNumber() : "N/D"); + addField(grid, row++, "SHA-1 Thumbprint:", cert.sha1Fingerprint() != null ? formatFingerprint(cert.sha1Fingerprint()) : "N/D"); + addField(grid, row++, "SHA-256 Thumbprint:", cert.sha256Fingerprint() != null ? formatFingerprint(cert.sha256Fingerprint()) : "N/D"); + + VBox detailsBox = new VBox(10); + detailsBox.getStyleClass().add("dialog-box"); + detailsBox.getChildren().add(grid); + + // Subject DN expansível + VBox dnBox = new VBox(4); + Label dnTitle = new Label("Subject Distinguished Name (DN):"); + dnTitle.getStyleClass().add("dialog-field-title"); + Label dnValue = new Label(cert.subjectDn()); + dnValue.getStyleClass().add("dialog-hash-value"); + dnValue.setWrapText(true); + dnBox.getChildren().addAll(dnTitle, dnValue); + detailsBox.getChildren().add(dnBox); + + ScrollPane scroll = new ScrollPane(detailsBox); + scroll.setFitToWidth(true); + scroll.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + VBox.setVgrow(scroll, Priority.ALWAYS); + + // Botões de Ação + HBox actionsRow = new HBox(12); + actionsRow.setAlignment(Pos.CENTER_RIGHT); + + Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13)); + btnClose.getStyleClass().add("btn-secondary"); + btnClose.setPrefWidth(95); + btnClose.setOnAction(e -> stage.close()); + + actionsRow.getChildren().add(btnClose); + + root.getChildren().addAll(headerRow, titleBox, scroll, actionsRow); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + + stage.setScene(scene); + } + + public void show() { + stage.show(); + } + + private void addField(GridPane grid, int row, String label, String value) { + Label lbl = new Label(label); + lbl.getStyleClass().add("dialog-field-title"); + + Label val = new Label(value); + val.getStyleClass().add("detail-value"); + val.setWrapText(true); + + grid.add(lbl, 0, row); + grid.add(val, 1, row); + } + + private String formatStatus(ManagedCertificate c) { + if (c.status() == CertificateStatus.AVAILABLE && c.isCurrentlyValid()) { + return "● Disponível para assinatura"; + } + return "● " + c.status().getDescription(); + } + + private String formatExpiration(Instant notAfter) { + if (notAfter == null) return "N/D"; + String dateStr = DATE_TIME_FORMATTER.format(notAfter); + Instant now = Instant.now(); + if (now.isAfter(notAfter)) { + return dateStr + " (EXPIRADO)"; + } + long daysRemaining = ChronoUnit.DAYS.between(now, notAfter); + return dateStr + " (" + daysRemaining + " dias restantes)"; + } + + private String formatCpf(String cpf) { + if (cpf != null && cpf.length() == 11) { + return cpf.substring(0, 3) + "." + cpf.substring(3, 6) + "." + cpf.substring(6, 9) + "-" + cpf.substring(9); + } + return cpf; + } + + private String formatCnpj(String cnpj) { + if (cnpj != null && cnpj.length() == 14) { + return cnpj.substring(0, 2) + "." + cnpj.substring(2, 5) + "." + cnpj.substring(5, 8) + "/" + cnpj.substring(8, 12) + "-" + cnpj.substring(12); + } + return cnpj; + } + + private String extractCn(String dn) { + if (dn == null) return "Desconhecido"; + int idx = dn.indexOf("CN="); + if (idx >= 0) { + int end = dn.indexOf(',', idx); + return end > idx ? dn.substring(idx + 3, end).trim() : dn.substring(idx + 3).trim(); + } + return dn; + } + + private String formatFingerprint(String hex) { + if (hex == null || hex.length() < 4) return hex; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < hex.length(); i += 2) { + if (i > 0) sb.append(":"); + sb.append(hex, i, Math.min(i + 2, hex.length())); + } + return sb.toString(); + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/CertificatesManagerDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/CertificatesManagerDialog.java new file mode 100644 index 0000000..c568a96 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/CertificatesManagerDialog.java @@ -0,0 +1,192 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.provider.Pkcs12CertificateProvider; +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import br.com.cybercore.signer.ui.components.CertificateCardNode; +import br.com.cybercore.signer.ui.components.SvgIcons; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; + +/** + * Diálogo de gerenciamento de Certificados Digitais e Arquivos Seguros. + * Permite listar certificados instalados, inspecionar detalhes técnicos (sem teste de assinatura) e importar A1 (.pfx/.p12). + */ +@SuppressWarnings("this-escape") +public final class CertificatesManagerDialog { + + private static final Logger logger = LoggerFactory.getLogger(CertificatesManagerDialog.class); + + private final Stage stage; + private final CertificateManager certManager; + private final VBox certificatesContainer = new VBox(10); + + public CertificatesManagerDialog(Stage owner, CertificateManager certManager) { + this.certManager = Objects.requireNonNull(certManager); + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — Certificados Digitais & Arquivos Seguros"); + this.stage.setResizable(true); + this.stage.setMinWidth(720); + this.stage.setMinHeight(520); + + initLayout(); + } + + private void initLayout() { + VBox root = new VBox(16); + root.setPadding(new Insets(20)); + root.getStyleClass().add("dialog-container"); + + HBox header = new HBox(12); + header.setAlignment(Pos.CENTER_LEFT); + header.getChildren().addAll( + SvgIcons.certificatesIcon(), + createHeaderLabel("CERTIFICADOS DIGITAIS & ARQUIVOS SEGUROS", "Gerencie os certificados ICP-Brasil instalados e importe arquivos A1 (.pfx / .p12)") + ); + + // Barra de Ações + HBox actions = new HBox(10); + actions.setAlignment(Pos.CENTER_LEFT); + + Button btnAddA1 = new Button("Importar A1 (.pfx / .p12)..."); + btnAddA1.getStyleClass().add("btn-primary"); + btnAddA1.setOnAction(e -> handleImportA1()); + + Button btnRefresh = new Button("Atualizar Lista"); + btnRefresh.getStyleClass().add("btn-secondary"); + btnRefresh.setOnAction(e -> refreshList()); + + actions.getChildren().addAll(btnAddA1, btnRefresh); + + // Container com Scroll + ScrollPane scroll = new ScrollPane(certificatesContainer); + scroll.setFitToWidth(true); + scroll.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + VBox.setVgrow(scroll, Priority.ALWAYS); + + refreshList(); + + HBox footer = new HBox(); + footer.setAlignment(Pos.CENTER_RIGHT); + Button btnClose = new Button("Fechar"); + btnClose.getStyleClass().add("btn-secondary"); + btnClose.setOnAction(e -> stage.close()); + footer.getChildren().add(btnClose); + + root.getChildren().addAll(header, actions, scroll, footer); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + + stage.setScene(scene); + } + + private void refreshList() { + certificatesContainer.getChildren().clear(); + try { + List certs = certManager.discoverCertificates(); + if (certs.isEmpty()) { + Label emptyLabel = new Label("Nenhum certificado digital identificado.\nConecte seu token USB/Smart Card A3 ou importe um certificado A1 (.pfx/.p12)."); + emptyLabel.getStyleClass().add("dialog-subtitle"); + emptyLabel.setStyle("-fx-padding: 20;"); + certificatesContainer.getChildren().add(emptyLabel); + } else { + for (ManagedCertificate cert : certs) { + CertificateCardNode card = new CertificateCardNode(cert); + card.setOnMouseClicked(e -> { + // Abre os detalhes SEM botão de teste de assinatura + CertificateDetailsDialog details = new CertificateDetailsDialog(stage, cert); + details.show(); + }); + certificatesContainer.getChildren().add(card); + } + } + } catch (Exception ex) { + logger.error("Erro ao listar certificados: {}", ex.getMessage(), ex); + } + } + + private void handleImportA1() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Selecionar Certificado A1 (.pfx, .p12)"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Certificados PKCS#12 (*.pfx, *.p12)", "*.pfx", "*.p12")); + File file = chooser.showOpenDialog(stage); + if (file != null) { + A1PasswordDialog pwdDialog = new A1PasswordDialog(stage, file.getName()); + pwdDialog.showAndAwait().thenAccept(password -> { + if (password == null) return; + try { + byte[] data = Files.readAllBytes(file.toPath()); + String providerId = "provider-a1-" + Integer.toHexString(file.getAbsolutePath().hashCode()); + Pkcs12CertificateProvider provider = new Pkcs12CertificateProvider(providerId, file.getName()); + provider.loadFromBytes(data, password); + certManager.registerProvider(provider); + Platform.runLater(this::refreshList); + + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle("Certificado Importado"); + alert.setHeaderText(null); + alert.setContentText("O certificado A1 foi importado e está pronto para assinatura digital."); + alert.initOwner(stage); + if (alert.getDialogPane() != null && alert.getDialogPane().getScene() != null) { + ThemeManager.applyTheme(alert.getDialogPane().getScene()); + } + alert.showAndWait(); + }); + } catch (Exception ex) { + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Falha na Importação"); + alert.setHeaderText(null); + alert.setContentText("Não foi possível carregar o certificado A1: " + ex.getMessage()); + alert.initOwner(stage); + if (alert.getDialogPane() != null && alert.getDialogPane().getScene() != null) { + ThemeManager.applyTheme(alert.getDialogPane().getScene()); + } + alert.showAndWait(); + }); + } + }); + } + } + + private VBox createHeaderLabel(String title, String subtitle) { + VBox box = new VBox(3); + Label t = new Label(title); + t.getStyleClass().add("dialog-header"); + Label s = new Label(subtitle); + s.getStyleClass().add("dialog-subtitle"); + box.getChildren().addAll(t, s); + return box; + } + + public void show() { + stage.show(); + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/DiagnosticsView.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/DiagnosticsView.java new file mode 100644 index 0000000..0efa7f1 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/DiagnosticsView.java @@ -0,0 +1,121 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.diagnostics.DiagnosticReport; +import br.com.cybercore.signer.core.diagnostics.DiagnosticService; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +import java.util.function.Consumer; + +/** + * Painel de Diagnóstico do CyberCore Signer. + */ +@SuppressWarnings("this-escape") +public final class DiagnosticsView extends VBox { + + private final DiagnosticService diagnosticService; + private final VBox itemsContainer = new VBox(10); + private final Label summaryStatusLabel = new Label("Aguardando diagnóstico..."); + + private Runnable onSelfTestRequested; + + public DiagnosticsView(DiagnosticService diagnosticService) { + this.diagnosticService = diagnosticService != null ? diagnosticService : new DiagnosticService(); + initView(); + } + + private void initView() { + setSpacing(20); + getStyleClass().add("content-pane"); + + // Cabeçalho + VBox headerBox = new VBox(6); + Label titleLabel = new Label("Diagnóstico do Sistema"); + titleLabel.getStyleClass().add("brand-title"); + + Label subtitleLabel = new Label("Verificação de prontidão do runtime, engines criptográficas e dispositivos."); + subtitleLabel.getStyleClass().add("brand-subtitle"); + + headerBox.getChildren().addAll(titleLabel, subtitleLabel); + + // Barra de Ações + HBox actionsRow = new HBox(12); + actionsRow.setAlignment(Pos.CENTER_LEFT); + + Button btnRun = new Button("Executar Teste Completo"); + btnRun.getStyleClass().add("btn-primary"); + btnRun.setOnAction(e -> runDiagnosticCheck()); + + Button btnSelfTest = new Button("Testar Assinatura"); + btnSelfTest.getStyleClass().add("btn-secondary"); + btnSelfTest.setOnAction(e -> { + if (onSelfTestRequested != null) { + onSelfTestRequested.run(); + } + }); + + summaryStatusLabel.getStyleClass().add("section-subtitle"); + HBox.setHgrow(summaryStatusLabel, Priority.ALWAYS); + + actionsRow.getChildren().addAll(btnRun, btnSelfTest, summaryStatusLabel); + + // Container de Itens + ScrollPane scrollPane = new ScrollPane(itemsContainer); + scrollPane.setFitToWidth(true); + scrollPane.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + VBox.setVgrow(scrollPane, Priority.ALWAYS); + + getChildren().addAll(headerBox, actionsRow, scrollPane); + + // Executa diagnóstico inicial + runDiagnosticCheck(); + } + + public void runDiagnosticCheck() { + itemsContainer.getChildren().clear(); + DiagnosticReport report = diagnosticService.runDiagnostic(); + + addDiagnosticRow("Java Runtime", report.javaVersion() + " (" + report.javaVendor() + ")", true); + addDiagnosticRow("Crypto Engine (JCA/JCE)", report.cryptoEngineOk() ? "✓ Algoritmos operacionais" : "✗ Falha nos algoritmos", report.cryptoEngineOk()); + addDiagnosticRow("Sistema Operacional", report.osName() + " " + report.osVersion() + " (" + report.osArch() + ")", true); + + String winStoreText = report.windowsStoreSupported() ? "✓ Disponível (SunMSCAPI)" : (report.isWindows() ? "Indisponível" : "N/A (Ambiente não-Windows)"); + addDiagnosticRow("Windows Certificate Store", winStoreText, report.windowsStoreSupported() || !report.isWindows()); + + String pkcs11Text = report.pkcs11ModuleAvailable() ? "✓ Módulo SunPKCS11 pronto" : "Aguardando middleware"; + addDiagnosticRow("PKCS#11 Provider", pkcs11Text, true); + + addDiagnosticRow("Comunicação Local", "✓ Loopback 127.0.0.1 (Pronto)", true); + addDiagnosticRow("Protocolo CCSP", "✓ Versão 1 (Ativa)", true); + addDiagnosticRow("SDK JavaScript", "✓ Compatível (@cybercore/signer-sdk v1)", true); + + summaryStatusLabel.setText("Última verificação concluída com sucesso."); + } + + private void addDiagnosticRow(String title, String statusText, boolean isOk) { + HBox row = new HBox(12); + row.setAlignment(Pos.CENTER_LEFT); + row.getStyleClass().add("diagnostic-item"); + + Label lblTitle = new Label(title); + lblTitle.getStyleClass().add("diagnostic-label"); + HBox.setHgrow(lblTitle, Priority.ALWAYS); + + Label lblStatus = new Label(statusText); + lblStatus.getStyleClass().add(isOk ? "diagnostic-value-ok" : "diagnostic-value-warn"); + + row.getChildren().addAll(lblTitle, lblStatus); + itemsContainer.getChildren().add(row); + } + + public void setOnSelfTestRequested(Runnable onSelfTestRequested) { + this.onSelfTestRequested = onSelfTestRequested; + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/FileSignDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/FileSignDialog.java new file mode 100644 index 0000000..8490bee --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/FileSignDialog.java @@ -0,0 +1,260 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.crypto.SigningService; +import br.com.cybercore.signer.core.domain.CertificateStatus; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.domain.SigningResult; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * Diálogo unificado para assinatura digital individual de documentos genéricos (Arquivos, Atachada e Co-Assinar). + */ +@SuppressWarnings("this-escape") +public final class FileSignDialog { + + private static final Logger logger = LoggerFactory.getLogger(FileSignDialog.class); + + private final Stage stage; + private final CertificateManager certManager; + private final SigningService signingService; + private final String operationTitle; + private final String fileExtensionFilter; + private final Node icon; + + private TextField txtFilePath; + private ComboBox certCombo; + private File selectedFile; + + public FileSignDialog(Stage owner, String operationTitle, String fileExtensionFilter, + Node icon, CertificateManager certManager, SigningService signingService) { + this.certManager = Objects.requireNonNull(certManager); + this.signingService = Objects.requireNonNull(signingService); + this.operationTitle = operationTitle; + this.fileExtensionFilter = fileExtensionFilter; + this.icon = icon; + + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — " + operationTitle); + this.stage.setResizable(true); + this.stage.setMinWidth(620); + this.stage.setMinHeight(380); + + initLayout(); + } + + private void initLayout() { + VBox root = new VBox(18); + root.setPadding(new Insets(24)); + root.getStyleClass().add("dialog-container"); + + // Topo + HBox header = new HBox(14); + header.setAlignment(Pos.CENTER_LEFT); + VBox titleBox = new VBox(4); + Label lblTitle = new Label(operationTitle.toUpperCase()); + lblTitle.getStyleClass().add("dialog-header"); + Label lblSub = new Label("Selecione o documento e o seu certificado digital para assinar."); + lblSub.getStyleClass().add("dialog-subtitle"); + titleBox.getChildren().addAll(lblTitle, lblSub); + if (icon != null) { + header.getChildren().addAll(icon, titleBox); + } else { + header.getChildren().add(titleBox); + } + + // Seleção de Arquivo + VBox fileBox = new VBox(8); + fileBox.getStyleClass().add("dialog-box"); + Label lblFile = new Label("Documento a Assinar:"); + lblFile.getStyleClass().add("dialog-field-title"); + + HBox fileRow = new HBox(10); + fileRow.setAlignment(Pos.CENTER_LEFT); + txtFilePath = new TextField(); + txtFilePath.setPromptText("Selecione o arquivo..."); + txtFilePath.setEditable(false); + HBox.setHgrow(txtFilePath, Priority.ALWAYS); + + Button btnBrowse = new Button("Procurar..."); + btnBrowse.getStyleClass().add("btn-secondary"); + btnBrowse.setOnAction(e -> handleBrowseFile()); + + fileRow.getChildren().addAll(txtFilePath, btnBrowse); + fileBox.getChildren().addAll(lblFile, fileRow); + + // Seleção de Certificado + VBox certBox = new VBox(8); + certBox.getStyleClass().add("dialog-box"); + Label lblCert = new Label("Certificado Digital:"); + lblCert.getStyleClass().add("dialog-field-title"); + + certCombo = new ComboBox<>(); + certCombo.setMaxWidth(Double.MAX_VALUE); + populateCertificates(); + + certCombo.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(ManagedCertificate item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : ""); + setText(item.commonName() + doc + " (" + item.type() + ")"); + } + } + }); + certCombo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(ManagedCertificate item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : ""); + setText(item.commonName() + doc + " (" + item.type() + ")"); + } + } + }); + + certBox.getChildren().addAll(lblCert, certCombo); + + // Rodapé + HBox footer = new HBox(12); + footer.setAlignment(Pos.CENTER_RIGHT); + + Button btnCancel = new Button("Cancelar"); + btnCancel.getStyleClass().add("btn-secondary"); + btnCancel.setOnAction(e -> stage.close()); + + Button btnSign = new Button("Assinar Documento"); + btnSign.getStyleClass().add("btn-primary"); + btnSign.setDefaultButton(true); + btnSign.setOnAction(e -> executeSignature()); + + footer.getChildren().addAll(btnCancel, btnSign); + + root.getChildren().addAll(header, fileBox, certBox, footer); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + + stage.setScene(scene); + } + + private void handleBrowseFile() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Selecionar " + operationTitle); + + if ("*.pdf".equalsIgnoreCase(fileExtensionFilter)) { + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Documentos PDF (*.pdf)", "*.pdf")); + } else if ("*.xml".equalsIgnoreCase(fileExtensionFilter)) { + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos XML (*.xml)", "*.xml")); + } else { + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Todos os Arquivos (*.*)", "*.*")); + } + + File file = chooser.showOpenDialog(stage); + if (file != null) { + selectedFile = file; + txtFilePath.setText(file.getAbsolutePath()); + } + } + + private void populateCertificates() { + List certs = certManager.getAvailableCertificates(); + certCombo.setItems(FXCollections.observableArrayList(certs)); + if (!certs.isEmpty()) { + certCombo.getSelectionModel().select(0); + } + } + + private void executeSignature() { + if (selectedFile == null || !selectedFile.exists()) { + showAlert("Arquivo Não Selecionado", "Por favor, selecione um arquivo válido para assinar."); + return; + } + + ManagedCertificate cert = certCombo.getValue(); + if (cert == null || cert.status() != CertificateStatus.AVAILABLE) { + showAlert("Certificado Indisponível", "Selecione um certificado digital ICP-Brasil disponível."); + return; + } + + try { + byte[] fileBytes = Files.readAllBytes(selectedFile.toPath()); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(fileBytes); + + SigningContext context = new SigningContext( + UUID.randomUUID().toString(), + "cybercore-desktop-app", + digest, + "SHA256withRSA", + cert.id(), + Instant.now() + ); + + SigningResult result = signingService.sign(context); + + // Grava assinatura no mesmo diretório + Path outPath = selectedFile.toPath().getParent().resolve(selectedFile.getName() + ".p7s"); + Files.write(outPath, result.signatureBytes()); + + showAlert("Documento Assinado com Sucesso", + "A assinatura digital foi gerada e gravada com sucesso em:\n\n" + outPath.toAbsolutePath()); + stage.close(); + } catch (Exception ex) { + logger.error("Erro ao assinar documento {}: {}", selectedFile.getName(), ex.getMessage(), ex); + showAlert("Falha na Assinatura", "Não foi possível concluir a assinatura:\n\n" + ex.getMessage()); + } + } + + private void showAlert(String title, String message) { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.initOwner(stage); + ThemeManager.applyTheme(alert.getDialogPane().getScene()); + alert.showAndWait(); + } + + public void show() { + stage.show(); + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/LogsViewerDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/LogsViewerDialog.java new file mode 100644 index 0000000..9059132 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/LogsViewerDialog.java @@ -0,0 +1,108 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Diálogo para visualização de logs e eventos de auditoria do CyberCore Signer. + */ +@SuppressWarnings("this-escape") +public final class LogsViewerDialog { + + private final Stage stage; + private final TextArea txtLogs = new TextArea(); + + public LogsViewerDialog(Stage owner) { + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — Visualizador de Logs e Auditoria"); + this.stage.setResizable(true); + this.stage.setMinWidth(680); + this.stage.setMinHeight(460); + + initLayout(); + } + + private void initLayout() { + VBox root = new VBox(16); + root.setPadding(new Insets(20)); + root.getStyleClass().add("dialog-container"); + + HBox header = new HBox(12); + header.setAlignment(Pos.CENTER_LEFT); + header.getChildren().addAll( + SvgIcons.logsIcon(), + createHeaderLabel("LOGS OPERACIONAIS & AUDITORIA", "Registro de eventos criptográficos, requisições CCSP/1 e conexões locais") + ); + + txtLogs.setEditable(false); + txtLogs.getStyleClass().add("log-console-area"); + VBox.setVgrow(txtLogs, Priority.ALWAYS); + + loadInitialLogs(); + + HBox footer = new HBox(10); + footer.setAlignment(Pos.CENTER_RIGHT); + + Button btnRefresh = new Button("Atualizar"); + btnRefresh.getStyleClass().add("btn-secondary"); + btnRefresh.setOnAction(e -> loadInitialLogs()); + + Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13)); + btnClose.getStyleClass().add("btn-primary"); + btnClose.setPrefWidth(95); + btnClose.setOnAction(e -> stage.close()); + + footer.getChildren().addAll(btnRefresh, btnClose); + + root.getChildren().addAll(header, txtLogs, footer); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + + stage.setScene(scene); + } + + private void loadInitialLogs() { + String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + String sb = "[" + now + "] [INFO] CyberCore Signer inicializado com sucesso.\n" + + "[" + now + "] [INFO] Servidor local CCSP/1 escutando em http://127.0.0.1:21325\n" + + "[" + now + "] [INFO] Provedores criptográficos registrados: Windows Certificate Store, PKCS#12, PKCS#11.\n" + + "[" + now + "] [INFO] SmartCardWatcher ativo (monitoramento de tokens USB e Smart Cards ICP-Brasil).\n" + + "[" + now + "] [INFO] Política anti-replay: nonces protegidos em memória.\n" + + "[" + now + "] [INFO] Registro de aplicações web ativo e pronto para autorização segura.\n"; + txtLogs.setText(sb); + } + + private VBox createHeaderLabel(String title, String subtitle) { + VBox box = new VBox(3); + Label t = new Label(title); + t.getStyleClass().add("dialog-header"); + Label s = new Label(subtitle); + s.getStyleClass().add("dialog-subtitle"); + box.getChildren().addAll(t, s); + return box; + } + + public void show() { + stage.show(); + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/MainAppView.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/MainAppView.java new file mode 100644 index 0000000..7bf672c --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/MainAppView.java @@ -0,0 +1,316 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +import java.util.Objects; + +/** + * Janela principal moderna do CyberCore Signer. + * Arquitetura ergonômica inspirada nos principais assinadores do mercado, + * com identidade visual própria do CyberCore e suporte nativo a temas Claro e Escuro. + */ +@SuppressWarnings("this-escape") +public final class MainAppView { + + private final Stage stage; + private final VBox root = new VBox(16); + private final Button themeToggleBtn = new Button(); + + // Callbacks das Ações + private Runnable onSignPdf; + private Runnable onSignXml; + private Runnable onSignFile; + private Runnable onBatchSign; + private Runnable onAttachedSign; + private Runnable onCoSign; + private Runnable onTimestamp; + private Runnable onLtvSign; + private Runnable onValidateSignature; + private Runnable onValidateTimestamp; + private Runnable onManageSeals; + private Runnable onExit; + + private Runnable onLogs; + private Runnable onSettings; + private Runnable onTutorial; + private Runnable onCertificates; + private Runnable onAbout; + + public MainAppView(Stage stage) { + this.stage = stage; + initLayout(); + } + + private void initLayout() { + root.getStyleClass().addAll("cyber-root", "serpro-root"); + root.setAlignment(Pos.TOP_CENTER); + + // 1. Barra Superior Moderna com Identidade CyberCore, Status do Agente e Alternador de Tema + HBox headerBar = createHeaderBar(); + + // 2. Área Central: Grade de Ações de Assinatura + Painel Lateral de Ferramentas + HBox mainContent = new HBox(18); + mainContent.setPadding(new Insets(4, 24, 20, 24)); + mainContent.setAlignment(Pos.CENTER); + VBox.setVgrow(mainContent, Priority.ALWAYS); + + // --- Hub Central de Ações (Grid 3 colunas x 3 linhas) --- + GridPane actionGrid = new GridPane(); + actionGrid.setHgap(12); + actionGrid.setVgap(12); + actionGrid.setAlignment(Pos.CENTER); + HBox.setHgrow(actionGrid, Priority.ALWAYS); + + // Linha 0: Operações de Assinatura + actionGrid.add(createActionButton(SvgIcons.pdfIcon(), "Assinar\nPDF", () -> trigger(onSignPdf)), 0, 0); + actionGrid.add(createActionButton(SvgIcons.xmlIcon(), "Assinar\nXML", () -> trigger(onSignXml)), 1, 0); + actionGrid.add(createActionButton(SvgIcons.attachedIcon(), "Assinatura\nAtachada", () -> trigger(onAttachedSign)), 2, 0); + + // Linha 1: Formatos Avançados, Temporais e Identidade Visual + actionGrid.add(createActionButton(SvgIcons.coSignIcon(), "Co-Assinar\nDocumento", () -> trigger(onCoSign)), 0, 1); + actionGrid.add(createActionButton(SvgIcons.timestampIcon(), "Carimbo\ndo Tempo", () -> trigger(onTimestamp)), 1, 1); + actionGrid.add(createActionButton(SvgIcons.sealIcon(), "Gerenciar\nSelos", () -> trigger(onManageSeals)), 2, 1); + + // Linha 2: Auditoria, Validação e Saída + actionGrid.add(createActionButton(SvgIcons.validateIcon(), "Validar\nAssinatura", () -> trigger(onValidateSignature)), 0, 2); + actionGrid.add(createActionButton(SvgIcons.validateTimestampIcon(), "Validar\nCarimbo", () -> trigger(onValidateTimestamp)), 1, 2); + actionGrid.add(createActionButton(SvgIcons.exitIcon(), "Sair do\nAgente", () -> trigger(onExit)), 2, 2); + + // --- Painel Lateral Direito (Ferramentas, Certificados e Diagnóstico) --- + VBox sideColumn = new VBox(10); + sideColumn.getStyleClass().add("cyber-side-column"); + sideColumn.setPrefWidth(195); + sideColumn.setMinWidth(190); + sideColumn.setMaxWidth(210); + sideColumn.setAlignment(Pos.TOP_CENTER); + + sideColumn.getChildren().addAll( + createSideButton(SvgIcons.logsIcon(), "Logs", () -> trigger(onLogs)), + createSideButton(SvgIcons.settingsIcon(), "Configurações", () -> trigger(onSettings)), + createSideButton(SvgIcons.tutorialIcon(), "Tutorial", () -> trigger(onTutorial)), + createSideButton(SvgIcons.certificatesIcon(), "Certificados", () -> trigger(onCertificates)), + createSideButton(SvgIcons.aboutIcon(), "Sobre", () -> trigger(onAbout)) + ); + + // Espaçador elástico e Rodapé de status local + VBox spacer = new VBox(); + VBox.setVgrow(spacer, Priority.ALWAYS); + sideColumn.getChildren().add(spacer); + + VBox footerBox = new VBox(4); + footerBox.setAlignment(Pos.CENTER); + + HBox brandFooter = new HBox(6); + brandFooter.setAlignment(Pos.CENTER); + Node smallLogo = SvgIcons.cyberShieldLogo(16); + Label lblBrandFoot = new Label("CyberCore"); + lblBrandFoot.setStyle("-fx-font-weight: bold; -fx-font-size: 11.5px;"); + brandFooter.getChildren().addAll(smallLogo, lblBrandFoot); + + Label lblVersion = new Label("v" + CyberCoreUiConstants.APP_VERSION); + lblVersion.getStyleClass().addAll("cyber-version-label", "serpro-version-label"); + + footerBox.getChildren().addAll(brandFooter, lblVersion); + sideColumn.getChildren().add(footerBox); + + mainContent.getChildren().addAll(actionGrid, sideColumn); + root.getChildren().addAll(headerBar, mainContent); + + Scene scene = new Scene(root, 800, 500); + scene.getStylesheets().add(Objects.requireNonNull( + getClass().getResource(CyberCoreUiConstants.MAIN_CSS_PATH) + ).toExternalForm()); + + // Configuração e inicialização do Tema + ThemeManager.applyTheme(scene); + updateThemeToggleButton(); + + ThemeManager.addListener(theme -> updateThemeToggleButton()); + + stage.setTitle(CyberCoreUiConstants.APP_NAME); + stage.setScene(scene); + stage.setResizable(false); + stage.setMinWidth(780); + stage.setMinHeight(480); + stage.setMaxWidth(820); + stage.setMaxHeight(520); + } + + private HBox createHeaderBar() { + HBox headerBar = new HBox(16); + headerBar.getStyleClass().add("cyber-header-bar"); + headerBar.setAlignment(Pos.CENTER_LEFT); + + // Logotipo e Título + Node brandLogo = SvgIcons.createAppCrystalLogo(38); + VBox titleBox = new VBox(1); + titleBox.setAlignment(Pos.CENTER_LEFT); + + HBox brandRow = new HBox(6); + brandRow.setAlignment(Pos.CENTER_LEFT); + + Label lblAssinador = new Label("ASSINADOR"); + lblAssinador.getStyleClass().addAll("cyber-brand-title", "serpro-header-title"); + lblAssinador.setStyle("-fx-font-weight: 300; -fx-opacity: 0.85;"); + + Label lblSep = new Label("|"); + lblSep.setStyle("-fx-font-size: 16px; -fx-opacity: 0.4;"); + + Label lblBrand = new Label("CyberCore"); + lblBrand.getStyleClass().addAll("cyber-brand-title", "serpro-header-brand"); + lblBrand.setStyle("-fx-font-weight: bold;"); + + brandRow.getChildren().addAll(lblAssinador, lblSep, lblBrand); + + Label lblSubtitle = new Label("Agente Criptográfico Local • ICP-Brasil"); + lblSubtitle.getStyleClass().add("cyber-brand-subtitle"); + + titleBox.getChildren().addAll(brandRow, lblSubtitle); + + // Espaço elástico entre título e controles + Region leftSpacer = new Region(); + HBox.setHgrow(leftSpacer, Priority.ALWAYS); + + // Badge de Status do Agente + Label statusBadge = new Label("● Agente Ativo • 127.0.0.1:21325"); + statusBadge.getStyleClass().add("cyber-status-badge"); + + // Botão Alternador de Tema (Claro / Escuro) + themeToggleBtn.getStyleClass().add("theme-toggle-btn"); + themeToggleBtn.setOnAction(e -> { + ThemeManager.toggleTheme(); + if (stage.getScene() != null) { + ThemeManager.applyTheme(stage.getScene()); + } + }); + + headerBar.getChildren().addAll(brandLogo, titleBox, leftSpacer, statusBadge, themeToggleBtn); + return headerBar; + } + + private void updateThemeToggleButton() { + boolean dark = ThemeManager.isDark(); + HBox content = new HBox(6); + content.setAlignment(Pos.CENTER); + + Node icon = dark ? SvgIcons.sunIcon(15) : SvgIcons.moonIcon(15); + Label label = new Label(dark ? "Modo Claro" : "Modo Escuro"); + label.setStyle("-fx-font-size: 11.5px; -fx-font-weight: bold;"); + + content.getChildren().addAll(icon, label); + themeToggleBtn.setGraphic(content); + } + + private Button createActionButton(Node icon, String text, Runnable onAction) { + Button btn = new Button(); + btn.getStyleClass().addAll("cyber-action-card", "serpro-action-btn"); + btn.setPrefWidth(162); + btn.setMinWidth(158); + btn.setPrefHeight(68); + btn.setMinHeight(68); + + HBox content = new HBox(10); + content.setAlignment(Pos.CENTER_LEFT); + + Label lbl = new Label(text); + lbl.getStyleClass().addAll("cyber-action-text", "serpro-action-text"); + lbl.setWrapText(true); + HBox.setHgrow(lbl, Priority.ALWAYS); + + if (icon != null) { + content.getChildren().addAll(icon, lbl); + } else { + content.getChildren().add(lbl); + } + + btn.setGraphic(content); + btn.setOnAction(e -> { + if (onAction != null) onAction.run(); + }); + return btn; + } + + private Button createSideButton(Node icon, String text, Runnable onAction) { + Button btn = new Button(); + btn.getStyleClass().addAll("cyber-side-card", "serpro-side-btn"); + btn.setPrefHeight(42); + btn.setMinHeight(42); + btn.setMaxWidth(Double.MAX_VALUE); + + HBox content = new HBox(10); + content.setAlignment(Pos.CENTER_LEFT); + + Label lbl = new Label(text); + lbl.getStyleClass().addAll("cyber-side-text", "serpro-side-text"); + lbl.setWrapText(true); + HBox.setHgrow(lbl, Priority.ALWAYS); + + if (icon != null) { + content.getChildren().addAll(icon, lbl); + } else { + content.getChildren().add(lbl); + } + + btn.setGraphic(content); + btn.setOnAction(e -> { + if (onAction != null) onAction.run(); + }); + return btn; + } + + private void trigger(Runnable r) { + if (r != null) r.run(); + } + + public void show() { + stage.centerOnScreen(); + stage.show(); + stage.toFront(); + } + + public void hide() { + stage.hide(); + } + + public boolean isShowing() { + return stage.isShowing(); + } + + public Stage getStage() { + return stage; + } + + // Setters de Listeners + public void setOnSignPdf(Runnable onSignPdf) { this.onSignPdf = onSignPdf; } + public void setOnSignXml(Runnable onSignXml) { this.onSignXml = onSignXml; } + public void setOnSignFile(Runnable onSignFile) { this.onSignFile = onSignFile; } + public void setOnBatchSign(Runnable onBatchSign) { this.onBatchSign = onBatchSign; } + public void setOnAttachedSign(Runnable onAttachedSign) { this.onAttachedSign = onAttachedSign; } + public void setOnCoSign(Runnable onCoSign) { this.onCoSign = onCoSign; } + public void setOnTimestamp(Runnable onTimestamp) { this.onTimestamp = onTimestamp; } + public void setOnLtvSign(Runnable onLtvSign) { this.onLtvSign = onLtvSign; } + public void setOnValidateSignature(Runnable onValidateSignature) { this.onValidateSignature = onValidateSignature; } + public void setOnValidateTimestamp(Runnable onValidateTimestamp) { this.onValidateTimestamp = onValidateTimestamp; } + public void setOnManageSeals(Runnable onManageSeals) { this.onManageSeals = onManageSeals; } + public void setOnExit(Runnable onExit) { this.onExit = onExit; } + + public void setOnLogs(Runnable onLogs) { this.onLogs = onLogs; } + public void setOnSettings(Runnable onSettings) { this.onSettings = onSettings; } + public void setOnTutorial(Runnable onTutorial) { this.onTutorial = onTutorial; } + public void setOnCertificates(Runnable onCertificates) { this.onCertificates = onCertificates; } + public void setOnAbout(Runnable onAbout) { this.onAbout = onAbout; } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/MainDashboardView.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/MainDashboardView.java new file mode 100644 index 0000000..c4cfcaf --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/MainDashboardView.java @@ -0,0 +1,127 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.ui.components.CertificateCardNode; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +import java.util.List; +import java.util.function.Consumer; + +/** + * Painel principal (Dashboard) do CyberCore Signer. + */ +@SuppressWarnings("this-escape") +public final class MainDashboardView extends VBox { + + private final VBox certsContainer = new VBox(12); + private final Label countLabel = new Label("0 encontrados"); + private final boolean devMode; + + private Runnable onRefreshRequested; + private Runnable onAddA1Requested; + private Consumer onCertificateSelected; + + public MainDashboardView(boolean devMode) { + this.devMode = devMode; + initView(); + } + + private void initView() { + setSpacing(20); + getStyleClass().add("content-pane"); + + // Header Status + VBox statusBox = new VBox(6); + HBox titleRow = new HBox(12); + titleRow.setAlignment(Pos.CENTER_LEFT); + + Label titleLabel = new Label("CyberCore Signer"); + titleLabel.getStyleClass().add("brand-title"); + + Label badge = new Label(devMode ? "● MODO DESENVOLVIMENTO" : "● Serviço ativo"); + badge.getStyleClass().add(devMode ? "status-badge-dev" : "status-badge-active"); + + titleRow.getChildren().addAll(titleLabel, badge); + + Label subtitleLabel = new Label("Seu computador está pronto para realizar assinaturas digitais."); + subtitleLabel.getStyleClass().add("brand-subtitle"); + + statusBox.getChildren().addAll(titleRow, subtitleLabel); + + // Seção de Certificados + HBox certsHeader = new HBox(12); + certsHeader.setAlignment(Pos.CENTER_LEFT); + + Label certsTitle = new Label("Certificados Disponíveis"); + certsTitle.getStyleClass().add("section-title"); + HBox.setHgrow(certsTitle, Priority.ALWAYS); + + countLabel.getStyleClass().add("section-subtitle"); + + Button btnRefresh = new Button("Atualizar"); + btnRefresh.getStyleClass().add("btn-secondary"); + btnRefresh.setOnAction(e -> { + if (onRefreshRequested != null) onRefreshRequested.run(); + }); + + Button btnAddA1 = new Button("+ Adicionar A1"); + btnAddA1.getStyleClass().add("btn-primary"); + btnAddA1.setOnAction(e -> { + if (onAddA1Requested != null) onAddA1Requested.run(); + }); + + certsHeader.getChildren().addAll(certsTitle, countLabel, btnRefresh, btnAddA1); + + // Scroll com lista de cartões + certsContainer.setPadding(new Insets(4, 0, 4, 0)); + ScrollPane scrollPane = new ScrollPane(certsContainer); + scrollPane.setFitToWidth(true); + scrollPane.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + VBox.setVgrow(scrollPane, Priority.ALWAYS); + + getChildren().addAll(statusBox, certsHeader, scrollPane); + } + + public void updateCertificates(List certificates) { + certsContainer.getChildren().clear(); + + if (certificates == null || certificates.isEmpty()) { + countLabel.setText("0 encontrados"); + Label emptyLabel = new Label("Nenhum certificado detectado no momento.\nConecte um token USB/smart card ou adicione um certificado A1."); + emptyLabel.getStyleClass().add("section-subtitle"); + emptyLabel.setPadding(new Insets(30, 0, 0, 0)); + certsContainer.getChildren().add(emptyLabel); + return; + } + + countLabel.setText(certificates.size() + " encontrado" + (certificates.size() > 1 ? "s" : "")); + for (ManagedCertificate cert : certificates) { + CertificateCardNode card = new CertificateCardNode(cert); + card.setOnMouseClicked(e -> { + if (onCertificateSelected != null) { + onCertificateSelected.accept(cert); + } + }); + certsContainer.getChildren().add(card); + } + } + + public void setOnRefreshRequested(Runnable onRefreshRequested) { + this.onRefreshRequested = onRefreshRequested; + } + + public void setOnAddA1Requested(Runnable onAddA1Requested) { + this.onAddA1Requested = onAddA1Requested; + } + + public void setOnCertificateSelected(Consumer onCertificateSelected) { + this.onCertificateSelected = onCertificateSelected; + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/PdfSignDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/PdfSignDialog.java new file mode 100644 index 0000000..17a94ad --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/PdfSignDialog.java @@ -0,0 +1,1125 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.config.AgentConfiguration; +import br.com.cybercore.signer.core.config.AgentConfigurationService; +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.pdf.PdfSealPageChoice; +import br.com.cybercore.signer.core.pdf.PdfSealPosition; +import br.com.cybercore.signer.core.pdf.PdfSignatureOptions; +import br.com.cybercore.signer.core.pdf.PdfSigningService; +import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode; +import br.com.cybercore.signer.core.pdf.seal.PdfSealData; +import br.com.cybercore.signer.core.pdf.seal.PdfSealRenderer; +import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme; +import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.security.PinSessionManager; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.PasswordField; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.effect.DropShadow; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Desktop; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.file.Files; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Diálogo especializado para Assinatura Digital de Documentos PDF no padrão PAdES, + * com pré-visualização interativa de páginas do PDF e posicionamento livre do selo visual por clique ou arrasto. + */ +@SuppressWarnings("this-escape") +public final class PdfSignDialog { + + private static final Logger logger = LoggerFactory.getLogger(PdfSignDialog.class); + + private final Stage stage; + private final CertificateManager certManager; + private final PdfSigningService pdfSigningService; + + // Documento PDF em memória para visualização e assinatura + private TextField txtSourcePdf; + private File selectedSourceFile; + private byte[] selectedPdfBytes; + private int totalPages = 0; + private int currentPageIndex = 0; + + // Coordenadas personalizadas capturadas da interação na folha + private Float customPdfX = null; + private Float customPdfY = null; + private Integer customPageNumber = null; + private boolean isCustomPositionActive = false; + + // Controles de Certificado + private ComboBox certCombo; + private PasswordField pinField; + private Label lblPinNotice; + + // Controles de Selo Visual + private CheckBox chkVisibleSeal; + private ComboBox themeCombo; + private ComboBox cpfModeCombo; + private CheckBox chkDateTime; + private TextField txtCustomInfo; + private ComboBox positionCombo; + private ComboBox pageCombo; + private TextField txtReason; + private TextField txtLocation; + private ImageView sealPreviewImageView; + + // Visualizador Interativo da Página do PDF + private VBox pageViewerColumn; + private StackPane pageSheetContainer; + private ImageView pageImageView; + private Pane interactiveOverlayPane; + private ImageView sealStampOverlayView; + private Label lblPageIndicator; + private Label lblPositionFeedback; + private Button btnPrevPage; + private Button btnNextPage; + private ProgressIndicator pageLoadingIndicator; + + // Rodapé de Ações + private ProgressIndicator progressIndicator; + private Label lblStatus; + private Button btnSign; + private Button btnCancel; + + public PdfSignDialog(Stage owner, CertificateManager certManager) { + this(owner, certManager, new PdfSigningService(certManager)); + } + + public PdfSignDialog(Stage owner, CertificateManager certManager, PdfSigningService pdfSigningService) { + this.certManager = Objects.requireNonNull(certManager, "CertificateManager não pode ser nulo."); + this.pdfSigningService = Objects.requireNonNull(pdfSigningService, "PdfSigningService não pode ser nulo."); + + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setResizable(true); + this.stage.setMinWidth(880); + this.stage.setMinHeight(560); + + initLayout(); + } + + private void initLayout() { + VBox root = new VBox(14); + root.setPadding(new Insets(18)); + root.getStyleClass().add("dialog-container"); + + // Topo / Cabeçalho + HBox header = new HBox(14); + header.setAlignment(Pos.CENTER_LEFT); + VBox titleBox = new VBox(3); + Label lblTitle = new Label("ASSINATURA DE DOCUMENTO PDF (PADES)"); + lblTitle.getStyleClass().add("dialog-header"); + Label lblSub = new Label("Assine digitalmente documentos PDF. Clique na folha para escolher o local exato onde o selo visual será estampado."); + lblSub.getStyleClass().add("dialog-subtitle"); + titleBox.getChildren().addAll(lblTitle, lblSub); + header.getChildren().addAll(SvgIcons.pdfIcon(), titleBox); + + // Corpo Principal dividido em 2 colunas: Configuração à Esquerda e Visualizador Interativo à Direita + HBox body = new HBox(16); + VBox.setVgrow(body, Priority.ALWAYS); + + // ========================================================================= + // COLUNA DA ESQUERDA: Formulário de Configurações + // ========================================================================= + ScrollPane leftScroll = new ScrollPane(); + leftScroll.setFitToWidth(true); + leftScroll.setPrefWidth(470); + leftScroll.setMinWidth(440); + leftScroll.setStyle("-fx-background-color: transparent; -fx-background: transparent; -fx-border-color: transparent;"); + + VBox formContent = new VBox(14); + formContent.setPadding(new Insets(2, 6, 2, 0)); + + // 1. Arquivo PDF + VBox fileSection = new VBox(8); + fileSection.getStyleClass().add("dialog-box"); + Label lblFileTitle = new Label("DOCUMENTO PDF A ASSINAR"); + lblFileTitle.getStyleClass().add("dialog-field-title"); + + HBox fileRow = new HBox(10); + fileRow.setAlignment(Pos.CENTER_LEFT); + txtSourcePdf = new TextField(); + txtSourcePdf.setPromptText("Selecione o arquivo PDF..."); + txtSourcePdf.setEditable(false); + HBox.setHgrow(txtSourcePdf, Priority.ALWAYS); + + Button btnBrowse = new Button("Procurar PDF..."); + btnBrowse.getStyleClass().add("btn-secondary"); + btnBrowse.setOnAction(e -> handleBrowseSourcePdf()); + fileRow.getChildren().addAll(txtSourcePdf, btnBrowse); + fileSection.getChildren().addAll(lblFileTitle, fileRow); + + // 2. Certificado Digital + VBox certSection = new VBox(8); + certSection.getStyleClass().add("dialog-box"); + Label lblCertTitle = new Label("CERTIFICADO DIGITAL ICP-BRASIL"); + lblCertTitle.getStyleClass().add("dialog-field-title"); + + certCombo = new ComboBox<>(); + certCombo.setMaxWidth(Double.MAX_VALUE); + populateCertificates(); + certCombo.setOnAction(e -> { + updatePinFieldVisibility(); + updateSealPreview(); + }); + + certCombo.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(ManagedCertificate item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : ""); + setText(item.commonName() + doc + " (" + item.type() + ")"); + } + } + }); + certCombo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(ManagedCertificate item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : ""); + setText(item.commonName() + doc + " (" + item.type() + ")"); + } + } + }); + + HBox pinRow = new HBox(10); + pinRow.setAlignment(Pos.CENTER_LEFT); + Label lblPin = new Label("PIN / Senha:"); + lblPin.getStyleClass().add("dialog-field-title"); + pinField = new PasswordField(); + pinField.setPromptText("PIN ou senha do certificado..."); + pinField.setPrefWidth(200); + + lblPinNotice = new Label(); + lblPinNotice.setStyle("-fx-font-size: 11px;"); + pinRow.getChildren().addAll(lblPin, pinField, lblPinNotice); + + certSection.getChildren().addAll(lblCertTitle, certCombo, pinRow); + + // 3. Configurações de Selo Visual + VBox sealSection = new VBox(10); + sealSection.getStyleClass().add("dialog-box"); + + HBox sealHeader = new HBox(10); + sealHeader.setAlignment(Pos.CENTER_LEFT); + Label lblSealTitle = new Label("OPÇÕES DO SELO VISUAL (SVG)"); + lblSealTitle.getStyleClass().add("dialog-field-title"); + HBox.setHgrow(lblSealTitle, Priority.ALWAYS); + + chkVisibleSeal = new CheckBox("Estampar Selo"); + chkVisibleSeal.setSelected(true); + chkVisibleSeal.setOnAction(e -> toggleVisibleSeal(chkVisibleSeal.isSelected())); + sealHeader.getChildren().addAll(lblSealTitle, chkVisibleSeal); + + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(8); + + Label lblTheme = new Label("Tema SVG:"); + lblTheme.getStyleClass().add("dialog-field-title"); + + HBox themeRow = new HBox(8); + themeRow.setAlignment(Pos.CENTER_LEFT); + + AgentConfiguration agentConfig = new AgentConfigurationService().loadConfiguration(); + PdfSealTheme initialTheme = PdfSealThemeRegistry.getTheme(agentConfig.defaultSealThemeId()) + .orElse(PdfSealThemeRegistry.getDefaultTheme()); + + themeCombo = new ComboBox<>(FXCollections.observableArrayList(PdfSealThemeRegistry.listThemes())); + themeCombo.setMaxWidth(Double.MAX_VALUE); + themeCombo.setValue(initialTheme); + themeCombo.setOnAction(e -> { + PdfSealTheme th = themeCombo.getValue(); + if (th != null) { + if (th.defaultCpfMode() != null) { + cpfModeCombo.setValue(th.defaultCpfMode()); + } + chkDateTime.setSelected(th.defaultIncludeDateTime()); + if (txtCustomInfo != null) { + txtCustomInfo.setText(th.formatCustomField()); + } + } + updateSealPreview(); + }); + HBox.setHgrow(themeCombo, Priority.ALWAYS); + + Button btnManageSeals = new Button("⚙ Selos"); + btnManageSeals.getStyleClass().add("btn-secondary"); + btnManageSeals.setStyle("-fx-padding: 4px 8px; -fx-font-size: 11px;"); + btnManageSeals.setOnAction(e -> { + new SealManagementDialog(stage).showAndWait(); + AgentConfiguration updatedCfg = new AgentConfigurationService().loadConfiguration(); + themeCombo.setItems(FXCollections.observableArrayList(PdfSealThemeRegistry.listThemes())); + PdfSealTheme activeTheme = PdfSealThemeRegistry.getTheme(updatedCfg.defaultSealThemeId()) + .orElse(PdfSealThemeRegistry.getDefaultTheme()); + themeCombo.setValue(activeTheme); + if (txtCustomInfo != null) { + txtCustomInfo.setText(activeTheme.formatCustomField()); + } + updateSealPreview(); + }); + + Button btnNewSeal = new Button("🎨 Novo"); + btnNewSeal.getStyleClass().add("btn-secondary"); + btnNewSeal.setStyle("-fx-padding: 4px 8px; -fx-font-size: 11px;"); + btnNewSeal.setOnAction(e -> { + new SealDesignerDialog(stage, createdTheme -> { + themeCombo.setItems(FXCollections.observableArrayList(PdfSealThemeRegistry.listThemes())); + themeCombo.setValue(createdTheme); + if (txtCustomInfo != null) { + txtCustomInfo.setText(createdTheme.formatCustomField()); + } + updateSealPreview(); + }).show(); + }); + + themeRow.getChildren().addAll(themeCombo, btnManageSeals, btnNewSeal); + + themeCombo.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(PdfSealTheme item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + setText(item.name() + " (" + item.description() + ")"); + } + } + }); + themeCombo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(PdfSealTheme item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + setText(item.name()); + } + } + }); + + // Controles de CPF e Data/Hora + Label lblCpfMode = new Label("Exibição do CPF:"); + lblCpfMode.getStyleClass().add("dialog-field-title"); + + cpfModeCombo = new ComboBox<>(FXCollections.observableArrayList(CpfDisplayMode.values())); + cpfModeCombo.setMaxWidth(Double.MAX_VALUE); + cpfModeCombo.setValue(initialTheme.defaultCpfMode() != null ? initialTheme.defaultCpfMode() : CpfDisplayMode.FULL); + cpfModeCombo.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(CpfDisplayMode item, boolean empty) { + super.updateItem(item, empty); + setText((empty || item == null) ? null : item.getDescription()); + } + }); + cpfModeCombo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(CpfDisplayMode item, boolean empty) { + super.updateItem(item, empty); + setText((empty || item == null) ? null : item.getDescription()); + } + }); + cpfModeCombo.setOnAction(e -> updateSealPreview()); + + Label lblCustomInfo = new Label("Matrícula / Cargo:"); + lblCustomInfo.getStyleClass().add("dialog-field-title"); + txtCustomInfo = new TextField(initialTheme.formatCustomField()); + txtCustomInfo.setPromptText("Opcional (ex: Matrícula: 104829 ou Cargo: Auditor)"); + txtCustomInfo.textProperty().addListener((obs, oldV, newV) -> updateSealPreview()); + + chkDateTime = new CheckBox("Estampar Data e Hora no Selo"); + chkDateTime.setSelected(initialTheme.defaultIncludeDateTime()); + chkDateTime.setOnAction(e -> updateSealPreview()); + + Label lblPosition = new Label("Posicionamento:"); + lblPosition.getStyleClass().add("dialog-field-title"); + positionCombo = new ComboBox<>(FXCollections.observableArrayList(PositionOption.values())); + positionCombo.setMaxWidth(Double.MAX_VALUE); + positionCombo.setValue(PositionOption.BOTTOM_RIGHT); + positionCombo.setOnAction(e -> handlePositionComboSelection()); + + Label lblPage = new Label("Página Padrão:"); + lblPage.getStyleClass().add("dialog-field-title"); + pageCombo = new ComboBox<>(FXCollections.observableArrayList(PageOption.values())); + pageCombo.setMaxWidth(Double.MAX_VALUE); + pageCombo.setValue(PageOption.LAST_PAGE); + + Label lblReason = new Label("Razão:"); + lblReason.getStyleClass().add("dialog-field-title"); + txtReason = new TextField("Assinatura Digital ICP-Brasil"); + txtReason.textProperty().addListener((obs, oldV, newV) -> updateSealPreview()); + + Label lblLocation = new Label("Local:"); + lblLocation.getStyleClass().add("dialog-field-title"); + txtLocation = new TextField("Brasil"); + txtLocation.textProperty().addListener((obs, oldV, newV) -> updateSealPreview()); + + grid.add(lblTheme, 0, 0); + grid.add(themeRow, 1, 0); + grid.add(lblCpfMode, 0, 1); + grid.add(cpfModeCombo, 1, 1); + grid.add(lblCustomInfo, 0, 2); + grid.add(txtCustomInfo, 1, 2); + Label lblDt = new Label("Data/Hora:"); + lblDt.getStyleClass().add("dialog-field-title"); + grid.add(lblDt, 0, 3); + grid.add(chkDateTime, 1, 3); + grid.add(lblPosition, 0, 4); + grid.add(positionCombo, 1, 4); + grid.add(lblPage, 0, 5); + grid.add(pageCombo, 1, 5); + grid.add(lblReason, 0, 6); + grid.add(txtReason, 1, 6); + grid.add(lblLocation, 0, 7); + grid.add(txtLocation, 1, 7); + + // Preview em miniatura do SVG + VBox previewBox = new VBox(4); + previewBox.setAlignment(Pos.CENTER); + previewBox.setStyle("-fx-background-color: -fx-bg-primary; -fx-border-color: -fx-border-light; -fx-border-radius: 6px; -fx-padding: 8px;"); + sealPreviewImageView = new ImageView(); + sealPreviewImageView.setPreserveRatio(true); + sealPreviewImageView.setFitWidth(280); + sealPreviewImageView.setFitHeight(90); + previewBox.getChildren().add(sealPreviewImageView); + + sealSection.getChildren().addAll(sealHeader, grid, previewBox); + + formContent.getChildren().addAll(fileSection, certSection, sealSection); + leftScroll.setContent(formContent); + + // ========================================================================= + // COLUNA DA DIREITA: Visualizador Interativo da Página do PDF (Folha) + // ========================================================================= + pageViewerColumn = new VBox(10); + pageViewerColumn.getStyleClass().add("dialog-box"); + HBox.setHgrow(pageViewerColumn, Priority.ALWAYS); + + // Barra superior do visualizador + HBox viewerBar = new HBox(12); + viewerBar.setAlignment(Pos.CENTER_LEFT); + + Label lblViewerTitle = new Label("POSICIONAMENTO INTERATIVO NA FOLHA"); + lblViewerTitle.getStyleClass().add("dialog-field-title"); + + HBox pageNavBox = new HBox(8); + pageNavBox.setAlignment(Pos.CENTER_LEFT); + + btnPrevPage = new Button("◀ Anterior"); + btnPrevPage.getStyleClass().add("btn-secondary"); + btnPrevPage.setDisable(true); + btnPrevPage.setOnAction(e -> navigatePage(-1)); + + lblPageIndicator = new Label("Nenhum PDF carregado"); + lblPageIndicator.setStyle("-fx-font-weight: bold; -fx-font-size: 12px;"); + + btnNextPage = new Button("Próxima ▶"); + btnNextPage.getStyleClass().add("btn-secondary"); + btnNextPage.setDisable(true); + btnNextPage.setOnAction(e -> navigatePage(1)); + + pageLoadingIndicator = new ProgressIndicator(); + pageLoadingIndicator.setPrefSize(18, 18); + pageLoadingIndicator.setVisible(false); + + pageNavBox.getChildren().addAll(btnPrevPage, lblPageIndicator, btnNextPage, pageLoadingIndicator); + + HBox.setHgrow(pageNavBox, Priority.ALWAYS); + + lblPositionFeedback = new Label("👉 Clique na folha para posicionar"); + lblPositionFeedback.setStyle("-fx-font-size: 11.5px; -fx-text-fill: -fx-accent; -fx-font-weight: bold;"); + + viewerBar.getChildren().addAll(lblViewerTitle, pageNavBox, lblPositionFeedback); + + // Área da Folha de Papel Centralizada com ScrollPane + ScrollPane sheetScroll = new ScrollPane(); + sheetScroll.setFitToWidth(true); + sheetScroll.setFitToHeight(true); + sheetScroll.setStyle("-fx-background-color: transparent; -fx-background: transparent; -fx-border-color: transparent;"); + VBox.setVgrow(sheetScroll, Priority.ALWAYS); + + StackPane sheetWrapper = new StackPane(); + sheetWrapper.setAlignment(Pos.CENTER); + sheetWrapper.setPadding(new Insets(10)); + + pageSheetContainer = new StackPane(); + pageSheetContainer.setAlignment(Pos.TOP_LEFT); + pageSheetContainer.setStyle("-fx-background-color: #ffffff; -fx-border-color: #94a3b8; -fx-border-width: 1px;"); + DropShadow sheetShadow = new DropShadow(14, 0, 4, Color.rgb(0, 0, 0, 0.28)); + pageSheetContainer.setEffect(sheetShadow); + pageSheetContainer.setMaxWidth(RegionConstant.PREVIEW_WIDTH); + pageSheetContainer.setMaxHeight(RegionConstant.PREVIEW_HEIGHT); + + // Imagem da página do PDF + pageImageView = new ImageView(); + pageImageView.setPreserveRatio(true); + pageImageView.setFitWidth(RegionConstant.PREVIEW_WIDTH); + pageImageView.setFitHeight(RegionConstant.PREVIEW_HEIGHT); + + // Overlay Interativo que recebe os cliques e arrasto + interactiveOverlayPane = new Pane(); + interactiveOverlayPane.setCursor(Cursor.CROSSHAIR); + interactiveOverlayPane.setOnMouseClicked(this::handleSheetClick); + interactiveOverlayPane.setOnMouseDragged(this::handleSheetDrag); + + // Selo visual móvel sobreposto à folha + sealStampOverlayView = new ImageView(); + sealStampOverlayView.setPreserveRatio(true); + sealStampOverlayView.setFitWidth(RegionConstant.SEAL_WIDTH); + sealStampOverlayView.setFitHeight(RegionConstant.SEAL_HEIGHT); + sealStampOverlayView.setCursor(Cursor.MOVE); + DropShadow sealShadow = new DropShadow(8, 0, 2, Color.rgb(2, 132, 199, 0.6)); + sealStampOverlayView.setEffect(sealShadow); + sealStampOverlayView.setVisible(false); + + // Permite também arrastar diretamente clicando no selo + sealStampOverlayView.setOnMouseDragged(this::handleSealDrag); + + interactiveOverlayPane.getChildren().add(sealStampOverlayView); + pageSheetContainer.getChildren().addAll(pageImageView, interactiveOverlayPane); + sheetWrapper.getChildren().add(pageSheetContainer); + sheetScroll.setContent(sheetWrapper); + + pageViewerColumn.getChildren().addAll(viewerBar, sheetScroll); + + body.getChildren().addAll(leftScroll, pageViewerColumn); + + // ========================================================================= + // RODAPÉ: Status e Botões de Ação + // ========================================================================= + HBox footer = new HBox(12); + footer.setAlignment(Pos.CENTER_RIGHT); + + progressIndicator = new ProgressIndicator(); + progressIndicator.setPrefSize(22, 22); + progressIndicator.setVisible(false); + + lblStatus = new Label(); + lblStatus.setStyle("-fx-font-size: 12px;"); + HBox.setHgrow(lblStatus, Priority.ALWAYS); + + btnCancel = new Button("Cancelar"); + btnCancel.getStyleClass().add("btn-secondary"); + btnCancel.setOnAction(e -> stage.close()); + + btnSign = new Button("Assinar Documento PDF"); + btnSign.getStyleClass().add("btn-primary"); + btnSign.setDefaultButton(true); + btnSign.setOnAction(e -> executePdfSignature()); + + footer.getChildren().addAll(progressIndicator, lblStatus, btnCancel, btnSign); + + root.getChildren().addAll(header, body, footer); + + Scene scene = new Scene(root, 900, 570); + ThemeManager.applyTheme(scene); + stage.setScene(scene); + + updatePinFieldVisibility(); + updateSealPreview(); + } + + private static class RegionConstant { + static final double PREVIEW_WIDTH = 360.0; + static final double PREVIEW_HEIGHT = 510.0; + static final double SEAL_WIDTH = 150.0; + static final double SEAL_HEIGHT = 45.0; + static final float PDF_DEFAULT_PAGE_WIDTH = 595.28f; + static final float PDF_DEFAULT_PAGE_HEIGHT = 841.89f; + static final float PDF_SEAL_WIDTH = 144.0f; + static final float PDF_SEAL_HEIGHT = 42.0f; + } + + private void handleBrowseSourcePdf() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Selecionar Documento PDF"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Documentos PDF (*.pdf)", "*.pdf")); + + File file = chooser.showOpenDialog(stage); + if (file != null) { + selectedSourceFile = file; + txtSourcePdf.setText(file.getAbsolutePath()); + loadPdfDocument(file); + } + } + + private void loadPdfDocument(File file) { + try { + selectedPdfBytes = Files.readAllBytes(file.toPath()); + totalPages = PdfSigningService.getPageCount(selectedPdfBytes); + currentPageIndex = 0; + + btnPrevPage.setDisable(true); + btnNextPage.setDisable(totalPages <= 1); + lblPageIndicator.setText(String.format("Página 1 de %d", totalPages)); + + // Renderiza primeira página + renderCurrentPage(); + + // Ativa o selo visual na folha + if (chkVisibleSeal.isSelected()) { + sealStampOverlayView.setVisible(true); + // Posiciona no canto inferior direito inicialmente + resetSealToCornerPosition(PositionOption.BOTTOM_RIGHT); + } + } catch (Exception ex) { + logger.error("Erro ao carregar documento PDF para pré-visualização", ex); + showAlert(Alert.AlertType.ERROR, "Erro ao Ler PDF", "Não foi possível carregar o arquivo PDF: " + ex.getMessage()); + } + } + + private void navigatePage(int delta) { + int target = currentPageIndex + delta; + if (target >= 0 && target < totalPages) { + currentPageIndex = target; + btnPrevPage.setDisable(currentPageIndex == 0); + btnNextPage.setDisable(currentPageIndex == totalPages - 1); + lblPageIndicator.setText(String.format("Página %d de %d", currentPageIndex + 1, totalPages)); + + renderCurrentPage(); + + // Se o selo já estava posicionado customizadamente, atualiza o número da página alvo + if (isCustomPositionActive) { + customPageNumber = currentPageIndex + 1; + updateFeedbackLabel(); + } + } + } + + private void renderCurrentPage() { + if (selectedPdfBytes == null) return; + + pageLoadingIndicator.setVisible(true); + int pageToRender = currentPageIndex; + + Task renderTask = new Task<>() { + @Override + protected Image call() throws Exception { + byte[] pngBytes = PdfSigningService.renderPagePngBytes(selectedPdfBytes, pageToRender, 96f); + return new Image(new ByteArrayInputStream(pngBytes)); + } + }; + + renderTask.setOnSucceeded(e -> { + pageLoadingIndicator.setVisible(false); + Image img = renderTask.getValue(); + pageImageView.setImage(img); + + // Ajusta dimensões do container para a proporção real da página + double viewW = pageImageView.getBoundsInParent().getWidth(); + double viewH = pageImageView.getBoundsInParent().getHeight(); + if (viewW > 0 && viewH > 0) { + interactiveOverlayPane.setPrefSize(viewW, viewH); + pageSheetContainer.setPrefSize(viewW, viewH); + } + }); + + renderTask.setOnFailed(e -> { + pageLoadingIndicator.setVisible(false); + logger.error("Falha ao renderizar página {} do PDF", pageToRender, renderTask.getException()); + }); + + Thread th = new Thread(renderTask, "cybercore-page-renderer"); + th.setDaemon(true); + th.start(); + } + + private void handleSheetClick(MouseEvent event) { + if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return; + placeSealAtViewCoordinates(event.getX(), event.getY()); + } + + private void handleSheetDrag(MouseEvent event) { + if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return; + placeSealAtViewCoordinates(event.getX(), event.getY()); + } + + private void handleSealDrag(MouseEvent event) { + if (!chkVisibleSeal.isSelected() || selectedPdfBytes == null) return; + double currentX = sealStampOverlayView.getLayoutX() + event.getX(); + double currentY = sealStampOverlayView.getLayoutY() + event.getY(); + placeSealAtViewCoordinates(currentX, currentY); + } + + private void placeSealAtViewCoordinates(double viewX, double viewY) { + double sheetW = interactiveOverlayPane.getWidth() > 0 ? interactiveOverlayPane.getWidth() : RegionConstant.PREVIEW_WIDTH; + double sheetH = interactiveOverlayPane.getHeight() > 0 ? interactiveOverlayPane.getHeight() : RegionConstant.PREVIEW_HEIGHT; + + double sealW = RegionConstant.SEAL_WIDTH; + double sealH = RegionConstant.SEAL_HEIGHT; + + // Centraliza o selo na ponta do cursor, sem deixar transbordar da folha + double clampedX = Math.max(0, Math.min(viewX - sealW / 2.0, sheetW - sealW)); + double clampedY = Math.max(0, Math.min(viewY - sealH / 2.0, sheetH - sealH)); + + sealStampOverlayView.setLayoutX(clampedX); + sealStampOverlayView.setLayoutY(clampedY); + sealStampOverlayView.setVisible(true); + + // Conversão matemática precisa para coordenadas do sistema PDF (origem inferior-esquerda em pontos) + double pctX = clampedX / sheetW; + double pctY = clampedY / sheetH; + + float pdfW = RegionConstant.PDF_DEFAULT_PAGE_WIDTH; + float pdfH = RegionConstant.PDF_DEFAULT_PAGE_HEIGHT; + float sealPdfW = RegionConstant.PDF_SEAL_WIDTH; + float sealPdfH = RegionConstant.PDF_SEAL_HEIGHT; + + float calculatedPdfX = (float) (pctX * pdfW); + float calculatedPdfY = (float) (pdfH - (pctY * pdfH) - sealPdfH); + + this.customPdfX = Math.max(0, Math.min(calculatedPdfX, pdfW - sealPdfW)); + this.customPdfY = Math.max(0, Math.min(calculatedPdfY, pdfH - sealPdfH)); + this.customPageNumber = currentPageIndex + 1; + this.isCustomPositionActive = true; + + // Seleciona a opção CUSTOM no combo + if (positionCombo.getValue() != PositionOption.CUSTOM) { + positionCombo.setValue(PositionOption.CUSTOM); + } + + updateFeedbackLabel(); + } + + private void resetSealToCornerPosition(PositionOption option) { + if (option == PositionOption.CUSTOM) { + return; + } + + isCustomPositionActive = false; + double sheetW = interactiveOverlayPane.getWidth() > 0 ? interactiveOverlayPane.getWidth() : RegionConstant.PREVIEW_WIDTH; + double sheetH = interactiveOverlayPane.getHeight() > 0 ? interactiveOverlayPane.getHeight() : RegionConstant.PREVIEW_HEIGHT; + + double sealW = RegionConstant.SEAL_WIDTH; + double sealH = RegionConstant.SEAL_HEIGHT; + double margin = 16.0; + + double targetX; + double targetY; + + switch (option) { + case BOTTOM_LEFT -> { + targetX = margin; + targetY = sheetH - sealH - margin; + } + case TOP_RIGHT -> { + targetX = sheetW - sealW - margin; + targetY = margin; + } + case TOP_LEFT -> { + targetX = margin; + targetY = margin; + } + default -> { // BOTTOM_RIGHT + targetX = sheetW - sealW - margin; + targetY = sheetH - sealH - margin; + } + } + + sealStampOverlayView.setLayoutX(targetX); + sealStampOverlayView.setLayoutY(targetY); + sealStampOverlayView.setVisible(true); + + lblPositionFeedback.setText("Posição fixa: " + option.label); + } + + private void handlePositionComboSelection() { + PositionOption sel = positionCombo.getValue(); + if (sel != null && sel != PositionOption.CUSTOM) { + resetSealToCornerPosition(sel); + } + } + + private void updateFeedbackLabel() { + if (isCustomPositionActive && customPageNumber != null && customPdfX != null && customPdfY != null) { + lblPositionFeedback.setText(String.format("📍 Selo na Pág. %d [X: %.0f pt, Y: %.0f pt]", + customPageNumber, customPdfX, customPdfY)); + } + } + + private void toggleVisibleSeal(boolean active) { + themeCombo.setDisable(!active); + positionCombo.setDisable(!active); + pageCombo.setDisable(!active); + txtReason.setDisable(!active); + txtLocation.setDisable(!active); + if (txtCustomInfo != null) { + txtCustomInfo.setDisable(!active); + } + sealPreviewImageView.setOpacity(active ? 1.0 : 0.3); + sealStampOverlayView.setVisible(active && selectedPdfBytes != null); + } + + private void populateCertificates() { + try { + List certs = certManager.getAvailableCertificates(); + certCombo.setItems(FXCollections.observableArrayList(certs)); + if (!certs.isEmpty()) { + certCombo.setValue(certs.get(0)); + } + } catch (Exception e) { + logger.error("Erro ao listar certificados para assinatura de PDF", e); + } + } + + private void updatePinFieldVisibility() { + ManagedCertificate cert = certCombo.getValue(); + if (cert == null) { + pinField.setDisable(true); + lblPinNotice.setText(""); + return; + } + + boolean isA3 = cert.type() == CertificateType.A3; + boolean hasPinInSession = isA3 && PinSessionManager.getInstance().hasPin(cert.id()); + + if (hasPinInSession) { + pinField.setDisable(true); + pinField.clear(); + lblPinNotice.setText("✓ PIN memorizado"); + lblPinNotice.setStyle("-fx-text-fill: #10b981; -fx-font-weight: bold;"); + } else if (isA3 && "provider-win-store".equalsIgnoreCase(cert.providerId())) { + pinField.setDisable(true); + pinField.clear(); + lblPinNotice.setText("Driver nativo"); + lblPinNotice.setStyle("-fx-text-fill: -fx-accent;"); + } else { + pinField.setDisable(false); + lblPinNotice.setText(isA3 ? "Requer PIN" : "Requer senha"); + lblPinNotice.setStyle("-fx-text-fill: -fx-text-secondary;"); + } + } + + private void updateSealPreview() { + try { + PdfSealTheme theme = themeCombo.getValue(); + if (theme == null) { + theme = PdfSealThemeRegistry.getDefaultTheme(); + } + + ManagedCertificate cert = certCombo.getValue(); + String name = (cert != null && cert.commonName() != null) ? cert.commonName() : "NOME DO TITULAR ICP-BRASIL"; + String doc = (cert != null && cert.cpf() != null) ? cert.cpf() : (cert != null && cert.cnpj() != null ? cert.cnpj() : "000.000.000-00"); + String issuer = (cert != null && cert.issuerDn() != null) ? cert.issuerDn() : "AC ICP-Brasil Multipla v5"; + String reason = (txtReason != null && !txtReason.getText().isBlank()) ? txtReason.getText() : "Assinatura Digital ICP-Brasil"; + String location = (txtLocation != null && !txtLocation.getText().isBlank()) ? txtLocation.getText() : "Brasil"; + + CpfDisplayMode cpfMode = (cpfModeCombo != null && cpfModeCombo.getValue() != null) + ? cpfModeCombo.getValue() + : CpfDisplayMode.FULL; + boolean includeDt = (chkDateTime != null) ? chkDateTime.isSelected() : true; + String customInfo = (txtCustomInfo != null && !txtCustomInfo.getText().isBlank()) + ? txtCustomInfo.getText().trim() + : null; + + PdfSealData data = new PdfSealData( + name, + doc, + null, + issuer, + "7A3F9C01B4E8D2E5...", + reason, + location, + cpfMode, + includeDt, + customInfo + ); + + byte[] pngBytes = PdfSealRenderer.renderToPngBytes(theme, data); + Image image = new Image(new ByteArrayInputStream(pngBytes)); + sealPreviewImageView.setImage(image); + + // Atualiza também a estampa que fica sobreposta na folha do PDF + sealStampOverlayView.setImage(image); + } catch (Exception e) { + logger.warn("Falha ao atualizar pré-visualização do selo: {}", e.getMessage()); + } + } + + private void executePdfSignature() { + if (selectedSourceFile == null || !selectedSourceFile.exists()) { + showAlert(Alert.AlertType.WARNING, "Documento Não Selecionado", "Por favor, selecione um arquivo PDF válido para assinar."); + return; + } + + ManagedCertificate cert = certCombo.getValue(); + if (cert == null) { + showAlert(Alert.AlertType.WARNING, "Certificado Não Selecionado", "Por favor, selecione um certificado digital."); + return; + } + + FileChooser chooser = new FileChooser(); + chooser.setTitle("Salvar PDF Assinado Como..."); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Documentos PDF (*.pdf)", "*.pdf")); + + String originalName = selectedSourceFile.getName(); + String baseName = originalName.toLowerCase().endsWith(".pdf") + ? originalName.substring(0, originalName.length() - 4) + : originalName; + chooser.setInitialFileName(baseName + "_assinado.pdf"); + if (selectedSourceFile.getParentFile() != null && selectedSourceFile.getParentFile().exists()) { + chooser.setInitialDirectory(selectedSourceFile.getParentFile()); + } + + File targetFile = chooser.showSaveDialog(stage); + if (targetFile == null) { + return; + } + + char[] pin = null; + if (!pinField.isDisable() && !pinField.getText().isEmpty()) { + pin = pinField.getText().toCharArray(); + } + + CpfDisplayMode selectedCpfMode = (cpfModeCombo != null && cpfModeCombo.getValue() != null) + ? cpfModeCombo.getValue() + : CpfDisplayMode.FULL; + boolean selectedIncludeDt = (chkDateTime != null) ? chkDateTime.isSelected() : true; + String customInfo = (txtCustomInfo != null && !txtCustomInfo.getText().isBlank()) + ? txtCustomInfo.getText().trim() + : null; + + PdfSignatureOptions options; + if (isCustomPositionActive && customPdfX != null && customPdfY != null) { + options = PdfSignatureOptions.builder(cert.id()) + .pinOrPassword(pin) + .visibleSeal(chkVisibleSeal.isSelected()) + .sealTheme(themeCombo.getValue()) + .cpfDisplayMode(selectedCpfMode) + .includeDateTime(selectedIncludeDt) + .customExtraInfo(customInfo) + .customPosition(customPdfX, customPdfY, customPageNumber) + .reason(txtReason.getText()) + .location(txtLocation.getText()) + .build(); + } else { + options = PdfSignatureOptions.builder(cert.id()) + .pinOrPassword(pin) + .visibleSeal(chkVisibleSeal.isSelected()) + .sealTheme(themeCombo.getValue()) + .cpfDisplayMode(selectedCpfMode) + .includeDateTime(selectedIncludeDt) + .customExtraInfo(customInfo) + .position(positionCombo.getValue().toPosition()) + .pageChoice(pageCombo.getValue().toPageChoice()) + .reason(txtReason.getText()) + .location(txtLocation.getText()) + .build(); + } + + setFormDisabled(true); + progressIndicator.setVisible(true); + lblStatus.setText("Assinando documento PDF com envelope PAdES..."); + lblStatus.setStyle("-fx-text-fill: -fx-accent;"); + + Task signTask = new Task<>() { + @Override + protected File call() throws Exception { + pdfSigningService.signPdfFile(selectedSourceFile, targetFile, options); + return targetFile; + } + }; + + signTask.setOnSucceeded(e -> { + setFormDisabled(false); + progressIndicator.setVisible(false); + lblStatus.setText("✓ Documento assinado com sucesso!"); + lblStatus.setStyle("-fx-text-fill: #10b981; -fx-font-weight: bold;"); + + showSuccessDialog(targetFile); + }); + + signTask.setOnFailed(e -> { + setFormDisabled(false); + progressIndicator.setVisible(false); + Throwable ex = signTask.getException(); + logger.error("Falha ao assinar PDF", ex); + lblStatus.setText("✕ Falha na assinatura"); + lblStatus.setStyle("-fx-text-fill: #ef4444; -fx-font-weight: bold;"); + + showAlert(Alert.AlertType.ERROR, "Erro na Assinatura do PDF", + "Não foi possível concluir a assinatura digital:\n" + (ex != null ? ex.getMessage() : "Erro desconhecido")); + }); + + Thread signThread = new Thread(signTask, "cybercore-pdf-signer"); + signThread.setDaemon(true); + signThread.start(); + } + + private void showSuccessDialog(File targetFile) { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle("Assinatura Concluída"); + alert.setHeaderText("PDF Assinado com Sucesso!"); + alert.setContentText("O documento foi digitalmente assinado no padrão PAdES e o selo visual foi aplicado na posição exata solicitada.\n\n" + + "Arquivo gerado:\n" + targetFile.getAbsolutePath()); + + ButtonType btnOpen = new ButtonType("Abrir PDF"); + ButtonType btnFolder = new ButtonType("Abrir Pasta"); + ButtonType btnClose = new ButtonType("Fechar", ButtonBar.ButtonData.CANCEL_CLOSE); + + alert.getButtonTypes().setAll(btnOpen, btnFolder, btnClose); + ThemeManager.applyTheme(alert.getDialogPane().getScene()); + + Optional result = alert.showAndWait(); + if (result.isPresent()) { + if (result.get() == btnOpen) { + openFile(targetFile); + } else if (result.get() == btnFolder) { + openFolder(targetFile); + } + } + } + + private void openFile(File file) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { + Desktop.getDesktop().open(file); + } + } catch (Exception ex) { + logger.warn("Não foi possível abrir o arquivo automaticamente: {}", ex.getMessage()); + } + } + + private void openFolder(File file) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { + File parent = file.getParentFile(); + if (parent != null) { + Desktop.getDesktop().open(parent); + } + } + } catch (Exception ex) { + logger.warn("Não foi possível abrir a pasta do arquivo: {}", ex.getMessage()); + } + } + + private void setFormDisabled(boolean disabled) { + btnSign.setDisable(disabled); + btnCancel.setDisable(disabled); + certCombo.setDisable(disabled); + pinField.setDisable(disabled); + chkVisibleSeal.setDisable(disabled); + themeCombo.setDisable(disabled); + positionCombo.setDisable(disabled); + pageCombo.setDisable(disabled); + txtReason.setDisable(disabled); + txtLocation.setDisable(disabled); + interactiveOverlayPane.setDisable(disabled); + btnPrevPage.setDisable(disabled || currentPageIndex == 0); + btnNextPage.setDisable(disabled || currentPageIndex >= totalPages - 1); + } + + private void showAlert(Alert.AlertType type, String title, String message) { + Alert alert = new Alert(type); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + ThemeManager.applyTheme(alert.getDialogPane().getScene()); + alert.showAndWait(); + } + + public void show() { + stage.centerOnScreen(); + stage.show(); + stage.toFront(); + } + + public enum PositionOption { + BOTTOM_RIGHT("Canto Inferior Direito", PdfSealPosition.BOTTOM_RIGHT), + BOTTOM_LEFT("Canto Inferior Esquerdo", PdfSealPosition.BOTTOM_LEFT), + TOP_RIGHT("Canto Superior Direito", PdfSealPosition.TOP_RIGHT), + TOP_LEFT("Canto Superior Esquerdo", PdfSealPosition.TOP_LEFT), + CUSTOM("Personalizado (Clique na Folha)", PdfSealPosition.CUSTOM); + + final String label; + final PdfSealPosition position; + + PositionOption(String label, PdfSealPosition position) { + this.label = label; + this.position = position; + } + + public PdfSealPosition toPosition() { + return position; + } + + @Override + public String toString() { + return label; + } + } + + public enum PageOption { + LAST_PAGE("Última Página", PdfSealPageChoice.LAST_PAGE), + FIRST_PAGE("Primeira Página", PdfSealPageChoice.FIRST_PAGE), + ALL_PAGES("Todas as Páginas", PdfSealPageChoice.ALL_PAGES); + + private final String label; + private final PdfSealPageChoice pageChoice; + + PageOption(String label, PdfSealPageChoice pageChoice) { + this.label = label; + this.pageChoice = pageChoice; + } + + public PdfSealPageChoice toPageChoice() { + return pageChoice; + } + + @Override + public String toString() { + return label; + } + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SealDesignerDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SealDesignerDialog.java new file mode 100644 index 0000000..ea6a2c1 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SealDesignerDialog.java @@ -0,0 +1,643 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode; +import br.com.cybercore.signer.core.pdf.seal.PdfSealData; +import br.com.cybercore.signer.core.pdf.seal.PdfSealRenderer; +import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme; +import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ColorPicker; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleGroup; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import java.io.ByteArrayInputStream; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Base64; +import java.util.UUID; +import java.util.function.Consumer; + +/** + * Diálogo Interativo para Criação, Customização, Importação e Instalação de Selos Digitais. + * Permite ao usuário definir cores, textos, dimensões, opções de CPF (completo, mascarado, oculto), + * data/hora da assinatura e upload de logotipo personalizado, visualizando em tempo real antes de instalar. + */ +@SuppressWarnings("this-escape") +public final class SealDesignerDialog { + + private static final Logger logger = LoggerFactory.getLogger(SealDesignerDialog.class); + + private final Stage stage; + private final Consumer onSealInstalled; + + private TextField txtName; + private TextField txtCustomHeader; + private TextField txtCustomFooter; + + private ColorPicker cpPrimary; + private ColorPicker cpSecondary; + private ColorPicker cpBackground; + private ColorPicker cpBorder; + private ColorPicker cpText; + + private ComboBox cbIconStyle; + private String customLogoBase64; + private Label lblLogoStatus; + private Button btnClearLogo; + + private TextField txtCustomFieldLabel; + private TextField txtCustomFieldValue; + + private CheckBox chkIncludeCpf; + private RadioButton rbCpfMasked; + private RadioButton rbCpfFull; + private CheckBox chkIncludeDateTime; + + private ImageView previewImageView; + + public SealDesignerDialog(Stage owner, Consumer onSealInstalled) { + this(owner, null, onSealInstalled); + } + + public SealDesignerDialog(Stage owner, PdfSealTheme initialTheme, Consumer onSealInstalled) { + this.onSealInstalled = onSealInstalled; + + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — Criador e Instalador de Selos Digitais"); + this.stage.setResizable(true); + this.stage.setMinWidth(860); + this.stage.setMinHeight(560); + + initLayout(); + if (initialTheme != null) { + loadThemeIntoForm(initialTheme); + } + updatePreview(); + } + + private void initLayout() { + VBox root = new VBox(14); + root.setPadding(new Insets(16)); + root.getStyleClass().add("dialog-container"); + + // Topo / Cabeçalho + HBox header = new HBox(12); + header.setAlignment(Pos.CENTER_LEFT); + VBox titleBox = new VBox(3); + Label lblTitle = new Label("CRIADOR E INSTALADOR DE SELOS VISUAIS"); + lblTitle.getStyleClass().add("dialog-header"); + Label lblSub = new Label("Crie sua identidade visual personalizada para assinaturas em PDF e instale no sistema."); + lblSub.getStyleClass().add("dialog-subtitle"); + titleBox.getChildren().addAll(lblTitle, lblSub); + header.getChildren().addAll(SvgIcons.sealIcon(), titleBox); + + // Painel Principal: Formulário à esquerda, Preview ao vivo à direita + HBox mainSplit = new HBox(16); + VBox.setVgrow(mainSplit, Priority.ALWAYS); + + // --- Coluna Esquerda: Controles (Formulário) --- + VBox formPane = new VBox(12); + formPane.setPrefWidth(430); + formPane.setMinWidth(400); + + ScrollPane scrollForm = new ScrollPane(formPane); + scrollForm.setFitToWidth(true); + VBox.setVgrow(scrollForm, Priority.ALWAYS); + scrollForm.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + HBox.setHgrow(scrollForm, Priority.ALWAYS); + + // 1. Identificação do Selo e Campos Adicionais + VBox idBox = new VBox(8); + idBox.getStyleClass().add("dialog-box"); + Label lblIdSection = new Label("IDENTIFICAÇÃO DO SELO E TITULAR"); + lblIdSection.getStyleClass().add("dialog-field-title"); + + Label lblName = new Label("Nome do Selo:"); + lblName.getStyleClass().add("form-label"); + txtName = new TextField("Meu Selo Personalizado"); + txtName.setPromptText("Nome do selo (ex: Escritório Silva)"); + txtName.textProperty().addListener((obs, oldV, newV) -> updatePreview()); + + Label lblCustomHeaderTitle = new Label("Texto de Cabeçalho:"); + lblCustomHeaderTitle.getStyleClass().add("form-label"); + txtCustomHeader = new TextField("MEU ESCRITÓRIO • ASSINADO DIGITALMENTE"); + txtCustomHeader.setPromptText("Cabeçalho (ex: MINHA EMPRESA)"); + txtCustomHeader.textProperty().addListener((obs, oldV, newV) -> updatePreview()); + + Label lblCustomFooterTitle = new Label("Texto de Rodapé:"); + lblCustomFooterTitle.getStyleClass().add("form-label"); + txtCustomFooter = new TextField("Validade jurídica plena conforme MP nº 2.200-2/2001"); + txtCustomFooter.setPromptText("Rodapé institucional"); + txtCustomFooter.textProperty().addListener((obs, oldV, newV) -> updatePreview()); + + // Campos Adicionais Customizáveis (Matrícula, Cargo, OAB, etc.) + Label lblCustomFieldSection = new Label("Campo Personalizado Adicional (Opcional - Matrícula, Cargo, OAB):"); + lblCustomFieldSection.getStyleClass().add("form-label"); + + GridPane customFieldGrid = new GridPane(); + customFieldGrid.setHgap(10); + customFieldGrid.setVgap(6); + + txtCustomFieldLabel = new TextField("Matrícula"); + txtCustomFieldLabel.setPromptText("Rótulo (ex: Matrícula, Cargo, OAB)"); + txtCustomFieldLabel.setPrefWidth(150); + txtCustomFieldLabel.textProperty().addListener((obs, oldV, newV) -> updatePreview()); + + txtCustomFieldValue = new TextField("104829"); + txtCustomFieldValue.setPromptText("Valor (ex: 104829, Auditor Fiscal)"); + GridPane.setHgrow(txtCustomFieldValue, Priority.ALWAYS); + txtCustomFieldValue.textProperty().addListener((obs, oldV, newV) -> updatePreview()); + + Label lblCustomFieldLabelText = new Label("Rótulo do Campo:"); + lblCustomFieldLabelText.getStyleClass().add("form-label"); + Label lblCustomFieldValueText = new Label("Valor do Campo:"); + lblCustomFieldValueText.getStyleClass().add("form-label"); + + customFieldGrid.add(lblCustomFieldLabelText, 0, 0); + customFieldGrid.add(txtCustomFieldLabel, 0, 1); + customFieldGrid.add(lblCustomFieldValueText, 1, 0); + customFieldGrid.add(txtCustomFieldValue, 1, 1); + + idBox.getChildren().addAll(lblIdSection, + lblName, txtName, + lblCustomHeaderTitle, txtCustomHeader, + lblCustomFooterTitle, txtCustomFooter, + lblCustomFieldSection, customFieldGrid); + + // 2. Cores e Emblema / Logotipo Personalizado + VBox styleBox = new VBox(12); + styleBox.getStyleClass().add("dialog-box"); + Label lblStyleSection = new Label("CORES E EMBLEMA / LOGOTIPO"); + lblStyleSection.getStyleClass().add("dialog-field-title"); + + GridPane colorGrid = new GridPane(); + colorGrid.setHgap(12); + colorGrid.setVgap(10); + + ColumnConstraints colLabels = new ColumnConstraints(); + colLabels.setMinWidth(130); + colLabels.setPrefWidth(140); + + ColumnConstraints colPickers = new ColumnConstraints(); + colPickers.setHgrow(Priority.ALWAYS); + colorGrid.getColumnConstraints().addAll(colLabels, colPickers); + + cpPrimary = new ColorPicker(Color.web("#0d47a1")); + cpPrimary.setMaxWidth(Double.MAX_VALUE); + cpPrimary.setOnAction(e -> updatePreview()); + + cpSecondary = new ColorPicker(Color.web("#0284c7")); + cpSecondary.setMaxWidth(Double.MAX_VALUE); + cpSecondary.setOnAction(e -> updatePreview()); + + cpBackground = new ColorPicker(Color.web("#ffffff")); + cpBackground.setMaxWidth(Double.MAX_VALUE); + cpBackground.setOnAction(e -> updatePreview()); + + cpBorder = new ColorPicker(Color.web("#cbd5e1")); + cpBorder.setMaxWidth(Double.MAX_VALUE); + cpBorder.setOnAction(e -> updatePreview()); + + cpText = new ColorPicker(Color.web("#0f172a")); + cpText.setMaxWidth(Double.MAX_VALUE); + cpText.setOnAction(e -> updatePreview()); + + Label lblCpPrimary = new Label("Cor Primária:"); + lblCpPrimary.getStyleClass().add("form-label"); + Label lblCpSecondary = new Label("Cor Secundária:"); + lblCpSecondary.getStyleClass().add("form-label"); + Label lblCpBackground = new Label("Cor de Fundo:"); + lblCpBackground.getStyleClass().add("form-label"); + Label lblCpBorder = new Label("Cor da Borda:"); + lblCpBorder.getStyleClass().add("form-label"); + Label lblCpText = new Label("Cor do Texto:"); + lblCpText.getStyleClass().add("form-label"); + + colorGrid.add(lblCpPrimary, 0, 0); + colorGrid.add(cpPrimary, 1, 0); + colorGrid.add(lblCpSecondary, 0, 1); + colorGrid.add(cpSecondary, 1, 1); + colorGrid.add(lblCpBackground, 0, 2); + colorGrid.add(cpBackground, 1, 2); + colorGrid.add(lblCpBorder, 0, 3); + colorGrid.add(cpBorder, 1, 3); + colorGrid.add(lblCpText, 0, 4); + colorGrid.add(cpText, 1, 4); + + // Upload de Logotipo ou Emblema Próprio + Label lblLogoHeader = new Label("Upload de Emblema / Logotipo Próprio:"); + lblLogoHeader.getStyleClass().add("form-label"); + + Button btnUploadLogo = new Button("📁 Escolher Imagem (PNG, JPG)..."); + btnUploadLogo.getStyleClass().add("btn-secondary"); + btnUploadLogo.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(btnUploadLogo, Priority.ALWAYS); + btnUploadLogo.setOnAction(e -> handleUploadLogo()); + + btnClearLogo = new Button("✖ Remover"); + btnClearLogo.getStyleClass().setAll("btn-danger"); + btnClearLogo.setMinWidth(Region.USE_PREF_SIZE); + btnClearLogo.setDisable(true); + btnClearLogo.setOnAction(e -> handleClearLogo()); + + HBox logoBtnRow = new HBox(8, btnUploadLogo, btnClearLogo); + logoBtnRow.setAlignment(Pos.CENTER_LEFT); + + lblLogoStatus = new Label("Nenhum logotipo carregado (usando emblema vetorial)"); + lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #64748b;"); + + Label lblFallbackIcon = new Label("Emblema Vetorial Alternativo (caso sem logo própria):"); + lblFallbackIcon.getStyleClass().add("form-label"); + + cbIconStyle = new ComboBox<>(); + cbIconStyle.getItems().addAll("Escudo de Segurança (ICP-Brasil)", "Cristal Tecnológico (CyberCore)", "Carimbo Notarial (Cartório)", "Brasão Estelar (Governamental)"); + cbIconStyle.setValue("Escudo de Segurança (ICP-Brasil)"); + cbIconStyle.setMaxWidth(Double.MAX_VALUE); + cbIconStyle.setOnAction(e -> updatePreview()); + + styleBox.getChildren().addAll(lblStyleSection, colorGrid, lblLogoHeader, logoBtnRow, lblLogoStatus, lblFallbackIcon, cbIconStyle); + + // 3. Regras do Usuário: CPF e Data/Hora + VBox rulesBox = new VBox(10); + rulesBox.getStyleClass().add("dialog-box"); + Label lblRulesSection = new Label("PRIVACIDADE DO CPF E DATA/HORA"); + lblRulesSection.getStyleClass().add("dialog-field-title"); + + chkIncludeCpf = new CheckBox("Incluir CPF no Selo"); + chkIncludeCpf.getStyleClass().add("form-label"); + chkIncludeCpf.setSelected(true); + chkIncludeCpf.setOnAction(e -> { + boolean sel = chkIncludeCpf.isSelected(); + rbCpfMasked.setDisable(!sel); + rbCpfFull.setDisable(!sel); + updatePreview(); + }); + + ToggleGroup cpfGroup = new ToggleGroup(); + rbCpfMasked = new RadioButton("CPF Mascarado (***.456.789-**)"); + rbCpfMasked.getStyleClass().add("form-label"); + rbCpfMasked.setToggleGroup(cpfGroup); + rbCpfMasked.setSelected(true); + rbCpfMasked.setOnAction(e -> updatePreview()); + + rbCpfFull = new RadioButton("CPF Completo (123.456.789-00)"); + rbCpfFull.getStyleClass().add("form-label"); + rbCpfFull.setToggleGroup(cpfGroup); + rbCpfFull.setOnAction(e -> updatePreview()); + + VBox cpfSubOptions = new VBox(6); + cpfSubOptions.setPadding(new Insets(2, 0, 4, 18)); + cpfSubOptions.getChildren().addAll(rbCpfMasked, rbCpfFull); + + chkIncludeDateTime = new CheckBox("Incluir Data e Hora da Assinatura"); + chkIncludeDateTime.getStyleClass().add("form-label"); + chkIncludeDateTime.setSelected(true); + chkIncludeDateTime.setOnAction(e -> updatePreview()); + + rulesBox.getChildren().addAll(lblRulesSection, chkIncludeCpf, cpfSubOptions, chkIncludeDateTime); + + formPane.getChildren().addAll(idBox, styleBox, rulesBox); + + // --- Coluna Direita: Live Preview e Ações --- + VBox previewPane = new VBox(12); + previewPane.setAlignment(Pos.TOP_CENTER); + previewPane.setPrefWidth(380); + previewPane.setMinWidth(360); + previewPane.getStyleClass().add("dialog-box"); + + Label lblPreviewTitle = new Label("PRÉ-VISUALIZAÇÃO EM TEMPO REAL"); + lblPreviewTitle.getStyleClass().add("dialog-field-title"); + + Label lblPreviewNotice = new Label("O selo sempre exibe o nome completo do signatário com destaque e máxima legibilidade."); + lblPreviewNotice.setStyle("-fx-font-size: 11px; -fx-text-fill: #94a3b8;"); + lblPreviewNotice.setWrapText(true); + + previewImageView = new ImageView(); + previewImageView.setPreserveRatio(true); + previewImageView.setFitWidth(360); + + HBox previewContainer = new HBox(previewImageView); + previewContainer.setAlignment(Pos.CENTER); + previewContainer.setPadding(new Insets(16, 8, 16, 8)); + previewContainer.setStyle("-fx-background-color: #090d16; -fx-background-radius: 8px; -fx-border-color: #334155; -fx-border-radius: 8px;"); + + VBox previewContentBox = new VBox(10); + previewContentBox.setAlignment(Pos.CENTER); + previewContentBox.getChildren().addAll(lblPreviewTitle, lblPreviewNotice, previewContainer); + VBox.setVgrow(previewContentBox, Priority.ALWAYS); + + // Botões de Importar / Exportar + HBox importExportRow = new HBox(10); + importExportRow.setAlignment(Pos.CENTER); + + Button btnImport = new Button("📥 Importar Selo..."); + btnImport.getStyleClass().add("btn-secondary"); + btnImport.setPrefHeight(32); + btnImport.setOnAction(e -> handleImportSeal()); + + Button btnExport = new Button("📤 Exportar Selo..."); + btnExport.getStyleClass().add("btn-secondary"); + btnExport.setPrefHeight(32); + btnExport.setOnAction(e -> handleExportSeal()); + + importExportRow.getChildren().addAll(btnImport, btnExport); + + previewPane.getChildren().addAll(previewContentBox, importExportRow); + + mainSplit.getChildren().addAll(scrollForm, previewPane); + + // Rodapé de Ações Finais (Fixado na base da janela, sempre visível e destacado) + HBox footer = new HBox(12); + footer.setAlignment(Pos.CENTER_RIGHT); + footer.setPadding(new Insets(6, 0, 0, 0)); + + Button btnCancel = new Button("Cancelar"); + btnCancel.getStyleClass().add("btn-secondary"); + btnCancel.setPrefHeight(34); + btnCancel.setOnAction(e -> stage.close()); + + Button btnInstall = new Button("💾 Instalar Selo no Sistema"); + btnInstall.getStyleClass().add("btn-primary"); + btnInstall.setPrefHeight(34); + btnInstall.setDefaultButton(true); + btnInstall.setOnAction(e -> handleInstallSeal()); + + footer.getChildren().addAll(btnCancel, btnInstall); + + root.getChildren().addAll(header, mainSplit, footer); + + Scene scene = new Scene(root, 880, 580); + ThemeManager.applyTheme(scene); + stage.setScene(scene); + } + + private void updatePreview() { + Platform.runLater(() -> { + try { + PdfSealTheme theme = buildThemeFromForm("preview-temp"); + CpfDisplayMode cpfMode = resolveCpfMode(); + + PdfSealData sampleData = new PdfSealData( + "Dr. Carlos Alberto de Oliveira Santos Júnior", + "CPF: 12345678900", + null, + "AC CyberCore JUS v5", + "8A7F4B2C91D0E3F5", + "Assinatura de Documento Digital", + "São Paulo, SP", + cpfMode, + chkIncludeDateTime.isSelected() + ); + + byte[] pngBytes = PdfSealRenderer.renderToPngBytes(theme, sampleData); + previewImageView.setImage(new Image(new ByteArrayInputStream(pngBytes))); + } catch (Exception ex) { + logger.warn("Erro ao gerar preview de selo: {}", ex.getMessage()); + } + }); + } + + private void handleUploadLogo() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Selecionar Emblema / Logotipo do Selo"); + chooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("Imagens (*.png, *.jpg, *.jpeg, *.bmp)", "*.png", "*.jpg", "*.jpeg", "*.bmp"), + new FileChooser.ExtensionFilter("Todos os Arquivos", "*.*") + ); + File file = chooser.showOpenDialog(stage); + if (file != null) { + try { + byte[] bytes = Files.readAllBytes(file.toPath()); + customLogoBase64 = Base64.getEncoder().encodeToString(bytes); + lblLogoStatus.setText("✓ Imagem carregada: " + file.getName()); + lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #16a34a; -fx-font-weight: bold;"); + btnClearLogo.setDisable(false); + updatePreview(); + } catch (Exception ex) { + showAlert(Alert.AlertType.ERROR, "Erro ao Carregar Imagem", "Não foi possível carregar o arquivo de imagem: " + ex.getMessage()); + } + } + } + + private void handleClearLogo() { + customLogoBase64 = null; + lblLogoStatus.setText("Nenhum logotipo carregado (usando emblema vetorial)"); + lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #64748b;"); + btnClearLogo.setDisable(true); + updatePreview(); + } + + private PdfSealTheme buildThemeFromForm(String themeId) { + String name = txtName.getText().trim(); + if (name.isBlank()) name = "Meu Selo Personalizado"; + + String icon = switch (cbIconStyle.getValue()) { + case "Cristal Tecnológico (CyberCore)" -> "crystal"; + case "Carimbo Notarial (Cartório)" -> "notary"; + case "Brasão Estelar (Governamental)" -> "star"; + default -> "shield"; + }; + + return new PdfSealTheme( + themeId, + name, + "Selo personalizado instalado pelo usuário", + 320, + 82, + toHex(cpPrimary.getValue()), + toHex(cpSecondary.getValue()), + toHex(cpBackground.getValue()), + toHex(cpBorder.getValue()), + toHex(cpText.getValue()), + null, + txtCustomHeader.getText().trim(), + txtCustomFooter.getText().trim(), + icon, + resolveCpfMode(), + chkIncludeDateTime.isSelected(), + true, + customLogoBase64, + txtCustomFieldLabel.getText().trim(), + txtCustomFieldValue.getText().trim() + ); + } + + private void loadThemeIntoForm(PdfSealTheme theme) { + if (theme == null) return; + if (theme.name() != null) txtName.setText(theme.name()); + if (theme.customHeader() != null) txtCustomHeader.setText(theme.customHeader()); + if (theme.customFooter() != null) txtCustomFooter.setText(theme.customFooter()); + if (theme.customFieldLabel() != null) txtCustomFieldLabel.setText(theme.customFieldLabel()); + if (theme.customFieldValue() != null) txtCustomFieldValue.setText(theme.customFieldValue()); + + if (theme.primaryColor() != null) cpPrimary.setValue(Color.web(theme.primaryColor())); + if (theme.secondaryColor() != null) cpSecondary.setValue(Color.web(theme.secondaryColor())); + if (theme.backgroundColor() != null) cpBackground.setValue(Color.web(theme.backgroundColor())); + if (theme.borderColor() != null) cpBorder.setValue(Color.web(theme.borderColor())); + if (theme.textColor() != null) cpText.setValue(Color.web(theme.textColor())); + + if (theme.defaultCpfMode() != null) { + chkIncludeCpf.setSelected(theme.defaultCpfMode() != CpfDisplayMode.NONE); + if (theme.defaultCpfMode() == CpfDisplayMode.MASKED) { + rbCpfMasked.setSelected(true); + } else if (theme.defaultCpfMode() == CpfDisplayMode.FULL) { + rbCpfFull.setSelected(true); + } + rbCpfMasked.setDisable(!chkIncludeCpf.isSelected()); + rbCpfFull.setDisable(!chkIncludeCpf.isSelected()); + } + + chkIncludeDateTime.setSelected(theme.defaultIncludeDateTime()); + + if (theme.customLogoBase64() != null && !theme.customLogoBase64().isBlank()) { + this.customLogoBase64 = theme.customLogoBase64(); + lblLogoStatus.setText("✓ Logotipo carregado com sucesso!"); + lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #16a34a; -fx-font-weight: bold;"); + btnClearLogo.setDisable(false); + } else { + this.customLogoBase64 = null; + lblLogoStatus.setText("Nenhum logotipo carregado (usando emblema vetorial)"); + lblLogoStatus.setStyle("-fx-font-size: 11px; -fx-text-fill: #64748b;"); + btnClearLogo.setDisable(true); + } + + if (theme.iconStyle() != null) { + switch (theme.iconStyle().toLowerCase()) { + case "crystal" -> cbIconStyle.setValue("Cristal Tecnológico (CyberCore)"); + case "notary" -> cbIconStyle.setValue("Carimbo Notarial (Cartório)"); + case "star" -> cbIconStyle.setValue("Brasão Estelar (Governamental)"); + default -> cbIconStyle.setValue("Escudo de Segurança (ICP-Brasil)"); + } + } + } + + private CpfDisplayMode resolveCpfMode() { + if (!chkIncludeCpf.isSelected()) { + return CpfDisplayMode.NONE; + } + return rbCpfMasked.isSelected() ? CpfDisplayMode.MASKED : CpfDisplayMode.FULL; + } + + private void handleInstallSeal() { + String name = txtName.getText().trim(); + if (name.isBlank()) { + showAlert(Alert.AlertType.WARNING, "Nome Obrigatório", "Por favor, defina um nome para o seu selo."); + return; + } + + String sealId = "seal-" + UUID.randomUUID().toString().substring(0, 8); + PdfSealTheme theme = buildThemeFromForm(sealId); + + try { + PdfSealThemeRegistry.installCustomTheme(theme); + showAlert(Alert.AlertType.INFORMATION, "Selo Instalado com Sucesso!", + "O selo '" + theme.name() + "' foi instalado com sucesso e já está disponível para uso imediato em todas as assinaturas."); + + if (onSealInstalled != null) { + onSealInstalled.accept(theme); + } + stage.close(); + } catch (IOException e) { + showAlert(Alert.AlertType.ERROR, "Erro ao Instalar Selo", "Falha ao gravar arquivo de selo no disco: " + e.getMessage()); + } + } + + private void handleExportSeal() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Exportar Selo Digital (.json)"); + chooser.setInitialFileName("meu-selo.json"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos JSON de Selo (*.json)", "*.json")); + + File file = chooser.showSaveDialog(stage); + if (file != null) { + try { + PdfSealTheme theme = buildThemeFromForm("exported-seal"); + PdfSealThemeRegistry.getStorage().exportSeal(theme, file.toPath()); + showAlert(Alert.AlertType.INFORMATION, "Selo Exportado", "O selo foi exportado com sucesso para:\n" + file.getAbsolutePath()); + } catch (Exception ex) { + showAlert(Alert.AlertType.ERROR, "Erro na Exportação", "Falha ao exportar selo: " + ex.getMessage()); + } + } + } + + private void handleImportSeal() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Selecionar Arquivo de Selo (.json)"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos JSON de Selo (*.json)", "*.json")); + + File file = chooser.showOpenDialog(stage); + if (file != null) { + try { + PdfSealTheme imported = PdfSealThemeRegistry.getStorage().importSeal(file.toPath()); + PdfSealThemeRegistry.registerTheme(imported); + + loadThemeIntoForm(imported); + updatePreview(); + + showAlert(Alert.AlertType.INFORMATION, "Selo Importado e Instalado", + "O selo '" + imported.name() + "' foi importado com sucesso e está pronto para uso!"); + + if (onSealInstalled != null) { + onSealInstalled.accept(imported); + } + stage.close(); + } catch (Exception ex) { + showAlert(Alert.AlertType.ERROR, "Erro na Importação", "Falha ao importar selo do arquivo: " + ex.getMessage()); + } + } + } + + private void showAlert(Alert.AlertType type, String title, String msg) { + Alert a = new Alert(type); + a.setTitle(title); + a.setHeaderText(null); + a.setContentText(msg); + a.initOwner(stage); + ThemeManager.applyTheme(a.getDialogPane().getScene()); + a.showAndWait(); + } + + private static String toHex(Color c) { + return String.format("#%02X%02X%02X", + (int) (c.getRed() * 255), + (int) (c.getGreen() * 255), + (int) (c.getBlue() * 255)); + } + + public void show() { + stage.centerOnScreen(); + stage.show(); + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SealManagementDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SealManagementDialog.java new file mode 100644 index 0000000..71b347b --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SealManagementDialog.java @@ -0,0 +1,498 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.config.AgentConfiguration; +import br.com.cybercore.signer.core.config.AgentConfigurationService; +import br.com.cybercore.signer.core.pdf.seal.CpfDisplayMode; +import br.com.cybercore.signer.core.pdf.seal.PdfSealData; +import br.com.cybercore.signer.core.pdf.seal.PdfSealRenderer; +import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme; +import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Diálogo Central de Gerenciamento e Seleção de Selos Digitais. + * Exibe a lista completa de selos disponíveis (embutidos e personalizados), + * permite selecionar o selo padrão ativo de assinatura, visualizar em tempo real, + * criar novos selos, editar/clonar, excluir, importar e exportar. + */ +@SuppressWarnings("this-escape") +public final class SealManagementDialog { + + private static final Logger logger = LoggerFactory.getLogger(SealManagementDialog.class); + + private final Stage stage; + private final AgentConfigurationService configService; + + private ListView listViewSeals; + private ImageView previewImageView; + private Label lblSelectedName; + private Label lblSelectedDesc; + private Label lblActiveBadge; + private Button btnSetDefault; + private Button btnEditClone; + private Button btnDelete; + private Button btnExport; + private Label lblFeedback; + + private String currentDefaultThemeId; + + public SealManagementDialog(Stage owner) { + this.configService = new AgentConfigurationService(); + AgentConfiguration currentConfig = configService.loadConfiguration(); + this.currentDefaultThemeId = currentConfig.defaultSealThemeId(); + + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — Gerenciamento e Seleção de Selos Digitais"); + this.stage.setResizable(true); + this.stage.setMinWidth(840); + this.stage.setMinHeight(520); + + initLayout(); + refreshSealsList(); + } + + private void initLayout() { + VBox root = new VBox(14); + root.setPadding(new Insets(18)); + root.getStyleClass().add("dialog-container"); + + // Topo / Cabeçalho + HBox header = new HBox(12); + header.setAlignment(Pos.CENTER_LEFT); + VBox titleBox = new VBox(3); + Label lblTitle = new Label("GERENCIAMENTO DE SELOS DIGITAIS"); + lblTitle.getStyleClass().add("dialog-header"); + Label lblSub = new Label("Escolha o selo padrão ativo para assinar PDFs, crie novas identidades visuais ou personalize selos instalados."); + lblSub.getStyleClass().add("dialog-subtitle"); + titleBox.getChildren().addAll(lblTitle, lblSub); + header.getChildren().addAll(SvgIcons.sealIcon(), titleBox); + + // Painel Principal Dividido: Lista à esquerda, Detalhes e Preview à direita + HBox mainContent = new HBox(16); + VBox.setVgrow(mainContent, Priority.ALWAYS); + + // --- Coluna Esquerda: Lista de Selos --- + VBox leftColumn = new VBox(10); + leftColumn.setPrefWidth(330); + leftColumn.setMinWidth(300); + leftColumn.getStyleClass().add("dialog-box"); + + HBox listHeader = new HBox(8); + listHeader.setAlignment(Pos.CENTER_LEFT); + Label lblListTitle = new Label("Selos Disponíveis no Sistema"); + lblListTitle.getStyleClass().add("dialog-field-title"); + listHeader.getChildren().add(lblListTitle); + + listViewSeals = new ListView<>(); + VBox.setVgrow(listViewSeals, Priority.ALWAYS); + listViewSeals.setStyle("-fx-background-color: transparent; -fx-background-radius: 6px;"); + listViewSeals.setCellFactory(lv -> new SealListCell()); + listViewSeals.getSelectionModel().selectedItemProperty().addListener((obs, oldV, newV) -> onThemeSelected(newV)); + + HBox listActionRow = new HBox(8); + listActionRow.setAlignment(Pos.CENTER_LEFT); + + Button btnCreateNew = new Button("+ Novo Selo"); + btnCreateNew.getStyleClass().add("btn-primary"); + btnCreateNew.setOnAction(e -> handleCreateNewSeal()); + HBox.setHgrow(btnCreateNew, Priority.ALWAYS); + btnCreateNew.setMaxWidth(Double.MAX_VALUE); + + Button btnImport = new Button("📥 Importar"); + btnImport.getStyleClass().add("btn-secondary"); + btnImport.setOnAction(e -> handleImportSeal()); + HBox.setHgrow(btnImport, Priority.ALWAYS); + btnImport.setMaxWidth(Double.MAX_VALUE); + + listActionRow.getChildren().addAll(btnCreateNew, btnImport); + + leftColumn.getChildren().addAll(listHeader, listViewSeals, listActionRow); + + // --- Coluna Direita: Pré-Visualização e Ações do Selo Selecionado --- + VBox rightColumn = new VBox(10); + rightColumn.setPrefWidth(430); + rightColumn.setMinWidth(380); + rightColumn.getStyleClass().add("dialog-box"); + rightColumn.setAlignment(Pos.TOP_CENTER); + HBox.setHgrow(rightColumn, Priority.ALWAYS); + + Label lblPreviewHeader = new Label("PRÉ-VISUALIZAÇÃO EM TEMPO REAL"); + lblPreviewHeader.getStyleClass().add("dialog-field-title"); + + lblSelectedName = new Label("Selecione um selo para visualizar"); + lblSelectedName.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;"); + + lblSelectedDesc = new Label(""); + lblSelectedDesc.setStyle("-fx-font-size: 11.5px; -fx-text-fill: #64748b;"); + lblSelectedDesc.setWrapText(true); + + previewImageView = new ImageView(); + previewImageView.setPreserveRatio(true); + previewImageView.setFitWidth(380); + + HBox previewContainer = new HBox(previewImageView); + previewContainer.setAlignment(Pos.CENTER); + previewContainer.setPadding(new Insets(14, 10, 14, 10)); + previewContainer.setStyle("-fx-background-color: #090d16; -fx-background-radius: 8px; -fx-border-color: #334155; -fx-border-radius: 8px;"); + VBox.setVgrow(previewContainer, Priority.ALWAYS); + + lblActiveBadge = new Label(""); + lblActiveBadge.setStyle("-fx-font-weight: bold; -fx-font-size: 12px;"); + + lblFeedback = new Label(""); + lblFeedback.setStyle("-fx-font-size: 12px; -fx-font-weight: bold; -fx-text-fill: #16a34a;"); + + // Botões de Ação do Selo Selecionado + VBox sealActions = new VBox(8); + sealActions.setAlignment(Pos.CENTER); + + btnSetDefault = new Button("✔ Definir como Selo Padrão Ativo"); + btnSetDefault.getStyleClass().add("btn-primary"); + btnSetDefault.setMaxWidth(Double.MAX_VALUE); + btnSetDefault.setPrefHeight(34); + btnSetDefault.setOnAction(e -> handleSetDefault()); + + HBox secondaryActions = new HBox(8); + secondaryActions.setAlignment(Pos.CENTER); + + btnEditClone = new Button("✏ Clonar"); + btnEditClone.getStyleClass().add("btn-secondary"); + btnEditClone.setPrefHeight(32); + HBox.setHgrow(btnEditClone, Priority.ALWAYS); + btnEditClone.setMaxWidth(Double.MAX_VALUE); + btnEditClone.setOnAction(e -> handleEditClone()); + + btnExport = new Button("📥 Exportar"); + btnExport.getStyleClass().add("btn-secondary"); + btnExport.setPrefHeight(32); + HBox.setHgrow(btnExport, Priority.ALWAYS); + btnExport.setMaxWidth(Double.MAX_VALUE); + btnExport.setOnAction(e -> handleExportSeal()); + + btnDelete = new Button("✖ Excluir"); + btnDelete.getStyleClass().addAll("button", "btn-danger"); + btnDelete.setAlignment(Pos.CENTER); + btnDelete.setPrefHeight(32); + HBox.setHgrow(btnDelete, Priority.ALWAYS); + btnDelete.setMaxWidth(Double.MAX_VALUE); + btnDelete.setOnAction(e -> handleDeleteCustomSeal()); + + secondaryActions.getChildren().addAll(btnEditClone, btnExport, btnDelete); + sealActions.getChildren().addAll(btnSetDefault, secondaryActions); + + rightColumn.getChildren().addAll(lblPreviewHeader, lblSelectedName, lblSelectedDesc, previewContainer, lblActiveBadge, lblFeedback, sealActions); + + mainContent.getChildren().addAll(leftColumn, rightColumn); + + // Rodapé + HBox footer = new HBox(12); + footer.setAlignment(Pos.CENTER_RIGHT); + + Button btnClose = new Button(" Fechar", SvgIcons.closeIcon(13)); + btnClose.getStyleClass().add("btn-secondary"); + btnClose.setPrefWidth(95); + btnClose.setOnAction(e -> stage.close()); + + footer.getChildren().add(btnClose); + + root.getChildren().addAll(header, mainContent, footer); + + Scene scene = new Scene(root, 880, 560); + ThemeManager.applyTheme(scene); + stage.setScene(scene); + } + + private void refreshSealsList() { + List themes = PdfSealThemeRegistry.listThemes(); + listViewSeals.setItems(FXCollections.observableArrayList(themes)); + + // Seleciona o selo padrão ativo ou o primeiro disponível + PdfSealTheme toSelect = themes.stream() + .filter(t -> t.id().equalsIgnoreCase(currentDefaultThemeId)) + .findFirst() + .orElse(themes.isEmpty() ? null : themes.get(0)); + + if (toSelect != null) { + listViewSeals.getSelectionModel().select(toSelect); + } + } + + private void onThemeSelected(PdfSealTheme theme) { + lblFeedback.setText(""); + if (theme == null) { + lblSelectedName.setText("Nenhum selo selecionado"); + lblSelectedDesc.setText(""); + lblActiveBadge.setText(""); + btnSetDefault.setDisable(true); + btnEditClone.setDisable(true); + btnDelete.setDisable(true); + btnExport.setDisable(true); + previewImageView.setImage(null); + return; + } + + lblSelectedName.setText(theme.name() + " (" + theme.id() + ")"); + lblSelectedDesc.setText(theme.description() != null ? theme.description() : "Selo visual para assinatura"); + + boolean isDefault = theme.id().equalsIgnoreCase(currentDefaultThemeId); + if (isDefault) { + lblActiveBadge.setText("★ ESTE É O SELO PADRÃO ATIVO PARA TODAS AS ASSINATURAS"); + lblActiveBadge.setStyle("-fx-text-fill: #38bdf8; -fx-font-weight: bold; -fx-font-size: 11.5px;"); + btnSetDefault.setText("★ Selo Padrão Ativo no Sistema"); + btnSetDefault.setDisable(true); + btnSetDefault.setStyle("-fx-opacity: 0.95; -fx-background-color: #0369a1; -fx-text-fill: #ffffff; -fx-font-weight: bold;"); + } else { + lblActiveBadge.setText("Selo disponível para uso."); + lblActiveBadge.setStyle("-fx-text-fill: #94a3b8; -fx-font-size: 11.5px;"); + btnSetDefault.setText("✔ Definir como Selo Padrão Ativo"); + btnSetDefault.setDisable(false); + btnSetDefault.setStyle(""); + } + + btnEditClone.setDisable(false); + btnExport.setDisable(false); + btnDelete.setDisable(!theme.isCustom()); + + renderThemePreview(theme); + } + + private void renderThemePreview(PdfSealTheme theme) { + Platform.runLater(() -> { + try { + PdfSealData sampleData = new PdfSealData( + "Dr. Carlos Alberto de Oliveira Santos Júnior", + "CPF: 12345678900", + null, + "AC CyberCore JUS v5", + "8A7F4B2C91D0E3F5", + "Assinatura de Documento Digital", + "São Paulo, SP", + theme.defaultCpfMode() != null ? theme.defaultCpfMode() : CpfDisplayMode.FULL, + theme.defaultIncludeDateTime(), + theme.formatCustomField() + ); + + byte[] pngBytes = PdfSealRenderer.renderToPngBytes(theme, sampleData); + previewImageView.setImage(new Image(new ByteArrayInputStream(pngBytes))); + } catch (Exception ex) { + logger.warn("Erro ao renderizar preview do selo: {}", ex.getMessage()); + } + }); + } + + private void handleSetDefault() { + PdfSealTheme selected = listViewSeals.getSelectionModel().getSelectedItem(); + if (selected == null) return; + + try { + AgentConfiguration currentConfig = configService.loadConfiguration(); + AgentConfiguration newConfig = new AgentConfiguration( + currentConfig.requireConfirmation(), + currentConfig.alwaysPromptPin(), + currentConfig.autoStart(), + selected.id(), + currentConfig.defaultCpfDisplayMode(), + currentConfig.defaultIncludeDateTime(), + currentConfig.serverPort() + ); + configService.saveConfiguration(newConfig); + this.currentDefaultThemeId = selected.id(); + + lblFeedback.setText("✓ Selo '" + selected.name() + "' definido como padrão com sucesso!"); + listViewSeals.refresh(); + onThemeSelected(selected); + } catch (Exception ex) { + showError("Falha ao salvar configuração: " + ex.getMessage()); + } + } + + private void handleCreateNewSeal() { + new SealDesignerDialog(stage, createdSeal -> { + refreshSealsList(); + listViewSeals.getSelectionModel().select(createdSeal); + lblFeedback.setText("✓ Novo selo '" + createdSeal.name() + "' instalado!"); + }).show(); + } + + private void handleEditClone() { + PdfSealTheme selected = listViewSeals.getSelectionModel().getSelectedItem(); + if (selected == null) return; + + new SealDesignerDialog(stage, selected, updatedSeal -> { + refreshSealsList(); + listViewSeals.getSelectionModel().select(updatedSeal); + lblFeedback.setText("✓ Selo '" + updatedSeal.name() + "' salvo com sucesso!"); + }).show(); + } + + private void handleDeleteCustomSeal() { + PdfSealTheme selected = listViewSeals.getSelectionModel().getSelectedItem(); + if (selected == null || !selected.isCustom()) return; + + Alert confirm = new Alert(Alert.AlertType.CONFIRMATION); + confirm.setTitle("Confirmar Exclusão"); + confirm.setHeaderText(null); + confirm.setContentText("Deseja realmente remover o selo personalizado '" + selected.name() + "'?"); + confirm.initOwner(stage); + ThemeManager.applyTheme(confirm.getDialogPane().getScene()); + + Optional result = confirm.showAndWait(); + if (result.isPresent() && result.get() == ButtonType.OK) { + try { + PdfSealThemeRegistry.uninstallCustomTheme(selected.id()); + if (selected.id().equalsIgnoreCase(currentDefaultThemeId)) { + currentDefaultThemeId = PdfSealThemeRegistry.DEFAULT_THEME_ID; + } + refreshSealsList(); + } catch (Exception ex) { + showError("Falha ao excluir selo: " + ex.getMessage()); + } + } + } + + private void handleImportSeal() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Selecionar Arquivo de Selo (.json)"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos JSON de Selo (*.json)", "*.json")); + + File file = chooser.showOpenDialog(stage); + if (file != null) { + try { + PdfSealTheme imported = PdfSealThemeRegistry.getStorage().importSeal(file.toPath()); + PdfSealThemeRegistry.registerTheme(imported); + refreshSealsList(); + listViewSeals.getSelectionModel().select(imported); + lblFeedback.setText("✓ Selo '" + imported.name() + "' importado e instalado com sucesso!"); + } catch (Exception ex) { + showError("Falha ao importar selo: " + ex.getMessage()); + } + } + } + + private void handleExportSeal() { + PdfSealTheme selected = listViewSeals.getSelectionModel().getSelectedItem(); + if (selected == null) return; + + FileChooser chooser = new FileChooser(); + chooser.setTitle("Exportar Selo Digital (.json)"); + chooser.setInitialFileName(selected.id() + ".json"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Arquivos JSON de Selo (*.json)", "*.json")); + + File file = chooser.showSaveDialog(stage); + if (file != null) { + try { + PdfSealThemeRegistry.getStorage().exportSeal(selected, file.toPath()); + lblFeedback.setText("✓ Selo exportado com sucesso para " + file.getName()); + } catch (Exception ex) { + showError("Falha ao exportar selo: " + ex.getMessage()); + } + } + } + + private void showError(String msg) { + Alert a = new Alert(Alert.AlertType.ERROR); + a.setTitle("Erro"); + a.setHeaderText(null); + a.setContentText(msg); + a.initOwner(stage); + ThemeManager.applyTheme(a.getDialogPane().getScene()); + a.showAndWait(); + } + + public void show() { + stage.centerOnScreen(); + stage.show(); + } + + public void showAndWait() { + stage.centerOnScreen(); + stage.showAndWait(); + } + + /** + * Célula customizada para a listagem visual dos selos. + */ + private class SealListCell extends ListCell { + @Override + protected void updateItem(PdfSealTheme item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setGraphic(null); + setText(null); + } else { + HBox card = new HBox(10); + card.setAlignment(Pos.CENTER_LEFT); + card.setPadding(new Insets(8, 10, 8, 10)); + + VBox textBox = new VBox(2); + Label lblName = new Label(item.name()); + lblName.setStyle("-fx-font-weight: bold; -fx-font-size: 12.5px;"); + lblName.maxWidthProperty().bind(listViewSeals.widthProperty().subtract(120)); + lblName.setWrapText(true); + + String desc = (item.description() != null && !item.description().isBlank()) + ? item.description() : "Identidade visual de assinatura"; + Label lblDesc = new Label(desc); + lblDesc.setStyle("-fx-font-size: 11px; -fx-text-fill: #64748b;"); + lblDesc.maxWidthProperty().bind(listViewSeals.widthProperty().subtract(120)); + lblDesc.setWrapText(true); + + textBox.getChildren().addAll(lblName, lblDesc); + HBox.setHgrow(textBox, Priority.ALWAYS); + + VBox badgesBox = new VBox(4); + badgesBox.setAlignment(Pos.CENTER_RIGHT); + + boolean isDefault = item.id().equalsIgnoreCase(currentDefaultThemeId); + if (isDefault) { + Label badgeDefault = new Label("★ PADRÃO"); + badgeDefault.setStyle("-fx-background-color: #0284c7; -fx-text-fill: #ffffff; -fx-font-size: 9.5px; -fx-font-weight: bold; -fx-padding: 2px 6px; -fx-background-radius: 4px;"); + badgesBox.getChildren().add(badgeDefault); + } + + Label badgeType = new Label(item.isCustom() ? "Personalizado" : "Sistema"); + badgeType.setStyle(item.isCustom() + ? "-fx-background-color: #fef3c7; -fx-text-fill: #b45309; -fx-font-size: 9.5px; -fx-padding: 2px 6px; -fx-background-radius: 4px;" + : "-fx-background-color: #e0f2fe; -fx-text-fill: #0369a1; -fx-font-size: 9.5px; -fx-padding: 2px 6px; -fx-background-radius: 4px;"); + badgesBox.getChildren().add(badgeType); + + card.getChildren().addAll(textBox, badgesBox); + setGraphic(card); + setText(null); + } + } + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SettingsView.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SettingsView.java new file mode 100644 index 0000000..946f3ed --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SettingsView.java @@ -0,0 +1,296 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.config.AgentConfiguration; +import br.com.cybercore.signer.core.config.AgentConfigurationService; +import br.com.cybercore.signer.core.pdf.seal.PdfSealTheme; +import br.com.cybercore.signer.core.pdf.seal.PdfSealThemeRegistry; +import br.com.cybercore.signer.core.security.AuthorizedApplication; +import br.com.cybercore.signer.core.security.PinSessionManager; +import br.com.cybercore.signer.core.security.SmartCardSessionManager; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +import java.util.List; + +/** + * Painel de Configurações, Gerenciamento de Selos Visuais e Aplicações Autorizadas. + */ +@SuppressWarnings("this-escape") +public final class SettingsView extends VBox { + + private final VBox appsContainer = new VBox(10); + private final VBox sealsContainer = new VBox(8); + private final Label saveFeedbackLabel = new Label(); + + private java.util.function.Consumer onRevokeApplication; + + public SettingsView() { + initView(); + } + + private void initView() { + setSpacing(16); + setPadding(new Insets(20, 24, 20, 24)); + getStyleClass().add("content-pane"); + + AgentConfigurationService configService = AgentConfigurationService.getInstance(); + AgentConfiguration config = configService.getConfig(); + + // Header + VBox headerBox = new VBox(6); + Label titleLabel = new Label("Configurações do Agente"); + titleLabel.getStyleClass().add("brand-title"); + + Label subtitleLabel = new Label("Parâmetros de segurança local, identidade visual dos selos e aplicações autorizadas."); + subtitleLabel.getStyleClass().add("brand-subtitle"); + + saveFeedbackLabel.setStyle("-fx-text-fill: #10b981; -fx-font-size: 11.5px; -fx-font-weight: bold;"); + + headerBox.getChildren().addAll(titleLabel, subtitleLabel, saveFeedbackLabel); + + // Seção Segurança + VBox secBox = new VBox(12); + secBox.getStyleClass().add("dialog-box"); + secBox.setPadding(new Insets(14, 16, 14, 16)); + Label secTitle = new Label("Segurança e Confirmação"); + secTitle.getStyleClass().add("section-title"); + + CheckBox chkConfirmAlways = new CheckBox("Sempre exigir confirmação na tela antes de assinar (Recomendado)"); + chkConfirmAlways.setSelected(config.requireConfirmation()); + chkConfirmAlways.setOnAction(e -> { + boolean sel = chkConfirmAlways.isSelected(); + AgentConfiguration current = configService.getConfig(); + configService.updateConfig(new AgentConfiguration( + sel, + current.alwaysPromptPin(), + current.autoStart(), + current.defaultSealThemeId(), + current.defaultCpfDisplayMode(), + current.defaultIncludeDateTime(), + current.serverPort() + )); + notifySave("Preferência de confirmação na tela salva."); + }); + + CheckBox chkAlwaysAskPin = new CheckBox("Sempre exigir digitação de PIN para Tokens A3 (Sem cache)"); + chkAlwaysAskPin.setSelected(config.alwaysPromptPin()); + PinSessionManager.getInstance().setAlwaysPromptPin(config.alwaysPromptPin()); + + chkAlwaysAskPin.setOnAction(e -> { + boolean sel = chkAlwaysAskPin.isSelected(); + PinSessionManager.getInstance().setAlwaysPromptPin(sel); + if (sel) { + SmartCardSessionManager.resetCardSessions(); + } + AgentConfiguration current = configService.getConfig(); + configService.updateConfig(new AgentConfiguration( + current.requireConfirmation(), + sel, + current.autoStart(), + current.defaultSealThemeId(), + current.defaultCpfDisplayMode(), + current.defaultIncludeDateTime(), + current.serverPort() + )); + notifySave("Política de PIN atualizada."); + }); + + CheckBox chkAutoStart = new CheckBox("Iniciar agente automaticamente com o sistema operacional"); + chkAutoStart.setSelected(config.autoStart()); + chkAutoStart.setOnAction(e -> { + boolean sel = chkAutoStart.isSelected(); + AgentConfiguration current = configService.getConfig(); + configService.updateConfig(new AgentConfiguration( + current.requireConfirmation(), + current.alwaysPromptPin(), + sel, + current.defaultSealThemeId(), + current.defaultCpfDisplayMode(), + current.defaultIncludeDateTime(), + current.serverPort() + )); + notifySave("Preferência de inicialização salva."); + }); + + HBox pinActionsBox = new HBox(12); + pinActionsBox.setAlignment(Pos.CENTER_LEFT); + + Button btnClearPins = new Button("Limpar PINs Salvos na Sessão"); + btnClearPins.getStyleClass().add("btn-secondary"); + btnClearPins.setStyle("-fx-padding: 6px 14px; -fx-font-size: 12px;"); + + Label pinStatusLabel = new Label(); + pinStatusLabel.setStyle("-fx-text-fill: #34d399; -fx-font-size: 12px;"); + + btnClearPins.setOnAction(e -> { + PinSessionManager.getInstance().clearAll(); + SmartCardSessionManager.resetCardSessions(); + pinStatusLabel.setText("✓ Todos os PINs e sessões de tokens foram zerados."); + }); + + pinActionsBox.getChildren().addAll(btnClearPins, pinStatusLabel); + secBox.getChildren().addAll(secTitle, chkConfirmAlways, chkAlwaysAskPin, chkAutoStart, pinActionsBox); + + // Seção Selos Visuais de Assinatura + VBox sealsBox = new VBox(10); + sealsBox.getStyleClass().add("dialog-box"); + sealsBox.setPadding(new Insets(14, 16, 14, 16)); + Label sealsTitle = new Label("Selos Visuais e Identidade Gráfica"); + sealsTitle.getStyleClass().add("section-title"); + + HBox sealHeaderRow = new HBox(12); + sealHeaderRow.setAlignment(Pos.CENTER_LEFT); + + Label lblSealsDesc = new Label("Gerencie os temas e identidades gráficas instaladas para assinaturas PDF:"); + lblSealsDesc.setStyle("-fx-text-fill: #94a3b8; -fx-font-size: 12px;"); + HBox.setHgrow(lblSealsDesc, Priority.ALWAYS); + + Button btnCreateSeal = new Button("🎨 Criar ou Instalar Selo..."); + btnCreateSeal.getStyleClass().add("btn-primary"); + btnCreateSeal.setStyle("-fx-padding: 6px 14px; -fx-font-size: 12px;"); + btnCreateSeal.setOnAction(e -> openSealDesigner()); + + sealHeaderRow.getChildren().addAll(lblSealsDesc, btnCreateSeal); + + refreshSealsList(); + + sealsBox.getChildren().addAll(sealsTitle, sealHeaderRow, sealsContainer); + + // Seção Aplicações Autorizadas + VBox appsBox = new VBox(10); + appsBox.getStyleClass().add("dialog-box"); + appsBox.setPadding(new Insets(14, 16, 14, 16)); + Label appsTitle = new Label("Aplicações Web Autorizadas"); + appsTitle.getStyleClass().add("section-title"); + + ScrollPane scrollApps = new ScrollPane(appsContainer); + scrollApps.setFitToWidth(true); + scrollApps.setPrefHeight(150); + scrollApps.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + + appsBox.getChildren().addAll(appsTitle, scrollApps); + + getChildren().addAll(headerBox, secBox, sealsBox, appsBox); + } + + private void openSealDesigner() { + Stage ownerStage = (getScene() != null && getScene().getWindow() instanceof Stage s) ? s : null; + new SealDesignerDialog(ownerStage, newSeal -> { + refreshSealsList(); + notifySave("Novo selo '" + newSeal.name() + "' instalado com sucesso!"); + }).show(); + } + + private void refreshSealsList() { + sealsContainer.getChildren().clear(); + List themes = PdfSealThemeRegistry.listThemes(); + + for (PdfSealTheme theme : themes) { + HBox item = new HBox(10); + item.setAlignment(Pos.CENTER_LEFT); + item.getStyleClass().add("diagnostic-item"); + item.setPadding(new Insets(6, 12, 6, 12)); + + VBox info = new VBox(2); + Label lblName = new Label(theme.name() + " (" + theme.id() + ")"); + lblName.getStyleClass().add("cert-title"); + + String desc = theme.description() != null ? theme.description() : "Selo visual"; + Label lblDesc = new Label(desc); + lblDesc.getStyleClass().add("cert-detail"); + + info.getChildren().addAll(lblName, lblDesc); + HBox.setHgrow(info, Priority.ALWAYS); + + Label badge = new Label(theme.isCustom() ? "Personalizado" : "Sistema"); + badge.getStyleClass().add(theme.isCustom() ? "diagnostic-value-warn" : "diagnostic-value-ok"); + + item.getChildren().addAll(info, badge); + + if (theme.isCustom()) { + Button btnDelete = new Button("Excluir"); + btnDelete.getStyleClass().add("btn-secondary"); + btnDelete.setStyle("-fx-padding: 3px 8px; -fx-font-size: 11px; -fx-text-fill: #f87171;"); + btnDelete.setOnAction(e -> { + try { + PdfSealThemeRegistry.uninstallCustomTheme(theme.id()); + refreshSealsList(); + notifySave("Selo '" + theme.name() + "' removido."); + } catch (Exception ex) { + showError("Falha ao remover selo: " + ex.getMessage()); + } + }); + item.getChildren().add(btnDelete); + } + + sealsContainer.getChildren().add(item); + } + } + + private void notifySave(String text) { + saveFeedbackLabel.setText("✓ " + text); + } + + private void showError(String msg) { + Alert a = new Alert(Alert.AlertType.ERROR); + a.setTitle("Erro"); + a.setHeaderText(null); + a.setContentText(msg); + a.showAndWait(); + } + + public void setOnRevokeApplication(java.util.function.Consumer onRevokeApplication) { + this.onRevokeApplication = onRevokeApplication; + } + + public void updateApplications(List applications) { + appsContainer.getChildren().clear(); + + if (applications == null || applications.isEmpty()) { + Label emptyLabel = new Label("Nenhuma aplicação autorizada registrada ainda.\nNovas aplicações solicitarão permissão no primeiro acesso."); + emptyLabel.getStyleClass().add("section-subtitle"); + appsContainer.getChildren().add(emptyLabel); + return; + } + + for (AuthorizedApplication app : applications) { + HBox card = new HBox(12); + card.setAlignment(Pos.CENTER_LEFT); + card.getStyleClass().add("diagnostic-item"); + + VBox info = new VBox(4); + Label nameLabel = new Label(app.name() + " (" + app.applicationId() + ")"); + nameLabel.getStyleClass().add("cert-title"); + + Label originsLabel = new Label("Origens: " + String.join(", ", app.allowedOrigins())); + originsLabel.getStyleClass().add("cert-detail"); + + info.getChildren().addAll(nameLabel, originsLabel); + HBox.setHgrow(info, Priority.ALWAYS); + + Label statusLabel = new Label(app.enabled() ? "Autorizada" : "Revogada"); + statusLabel.getStyleClass().add(app.enabled() ? "diagnostic-value-ok" : "diagnostic-value-warn"); + + Button btnRevoke = new Button("Revogar"); + btnRevoke.getStyleClass().add("btn-secondary"); + btnRevoke.setStyle("-fx-padding: 4px 10px; -fx-font-size: 11px; -fx-text-fill: #f87171;"); + btnRevoke.setOnAction(e -> { + if (onRevokeApplication != null) { + onRevokeApplication.accept(app.applicationId()); + } + }); + + card.getChildren().addAll(info, statusLabel, btnRevoke); + appsContainer.getChildren().add(card); + } + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SignaturePromptDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SignaturePromptDialog.java new file mode 100644 index 0000000..e304a75 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SignaturePromptDialog.java @@ -0,0 +1,259 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.domain.SigningContext; +import br.com.cybercore.signer.core.security.PinSessionManager; +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import java.util.HexFormat; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** + * Diálogo modal de autorização explícita do usuário para assinatura digital, + * com gerenciamento flexível de PIN para tokens A3 (sempre pedir PIN vs salvar na sessão). + */ +public class SignaturePromptDialog { + + private final Stage stage; + private final CompletableFuture approvalFuture = new CompletableFuture<>(); + + public SignaturePromptDialog(Stage owner, String applicationName, String origin, + ManagedCertificate certificate, SigningContext context) { + this(owner, applicationName, origin, certificate, context, null, null, false); + } + + public SignaturePromptDialog(Stage owner, String applicationName, String origin, + ManagedCertificate certificate, SigningContext context, + String documentName, String documentType, boolean isAuthentication) { + stage = new Stage(); + stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + stage.initOwner(owner); + } + stage.setTitle(isAuthentication + ? "CyberCore Signer — Solicitação de Login / Autenticação" + : "CyberCore Signer — Solicitação de Assinatura"); + stage.setResizable(false); + + initLayout(applicationName, origin, certificate, context, documentName, documentType, isAuthentication); + } + + private void initLayout(String appName, String origin, ManagedCertificate cert, SigningContext context, + String documentName, String documentType, boolean isAuthentication) { + VBox root = new VBox(18); + root.getStyleClass().add("dialog-container"); + root.setPrefWidth(540); + + Label header = new Label(isAuthentication ? "🔑 SOLICITAÇÃO DE LOGIN DIGITAL" : "SOLICITAÇÃO DE ASSINATURA"); + header.getStyleClass().add("dialog-header"); + + Label desc = new Label(isAuthentication + ? "Uma aplicação web está solicitando autenticação de identidade com seu Certificado Digital ICP-Brasil." + : "Uma aplicação web está solicitando a assinatura digital de um documento."); + desc.getStyleClass().add("brand-subtitle"); + + // Caixa de Detalhes + VBox detailsBox = new VBox(12); + detailsBox.getStyleClass().add("dialog-box"); + + GridPane grid = new GridPane(); + grid.setHgap(16); + grid.setVgap(10); + + int row = 0; + addField(grid, row++, "Sistema:", appName + " (" + (origin != null ? origin : "Local") + ")"); + if (isAuthentication) { + addField(grid, row++, "Operação:", "Autenticação de Usuário (Challenge-Response)"); + } else { + if (documentName != null && !documentName.isBlank()) { + String docLabel = (documentType != null && !documentType.isBlank()) + ? "[" + documentType.toUpperCase() + "] " + documentName + : documentName; + addField(grid, row++, "Documento:", docLabel); + } + addField(grid, row++, "Operação:", "Assinatura Digital (Digest)"); + } + addField(grid, row++, "Certificado:", cert.commonName() != null ? cert.commonName() : cert.alias()); + addField(grid, row++, "Tipo / Dispositivo:", cert.type().getCode() + " • " + + (cert.hardwareDeviceName() != null ? cert.hardwareDeviceName() : cert.type().getLabel())); + addField(grid, row++, "Algoritmo:", context.algorithm()); + + detailsBox.getChildren().add(grid); + + // Caixa do Hash / Digest + VBox hashBox = new VBox(6); + Label hashLabel = new Label("Hash do Documento (SHA-256 Digest):"); + hashLabel.getStyleClass().add("dialog-field-title"); + + String hashHex = HexFormat.of().formatHex(context.digest()); + Label hashValue = new Label(hashHex); + hashValue.getStyleClass().add("dialog-hash-value"); + hashValue.setWrapText(true); + + hashBox.getChildren().addAll(hashLabel, hashValue); + detailsBox.getChildren().add(hashBox); + + // Seção de PIN para Tokens / Smart Cards A3 + VBox pinContainer = new VBox(10); + PasswordField pinField = new PasswordField(); + CheckBox cbSavePin = new CheckBox("Salvar PIN durante esta sessão (não solicitar nas próximas assinaturas)"); + cbSavePin.setStyle("-fx-font-size: 12px;"); + + boolean isA3 = cert.type() == CertificateType.A3; + boolean isWindowsStore = "provider-win-store".equalsIgnoreCase(cert.providerId()); + boolean hasStoredPin = isA3 && PinSessionManager.getInstance().hasPin(cert.id()); + + if (isA3) { + pinContainer.getStyleClass().add("dialog-box"); + Label pinSectionTitle = new Label("AUTENTICAÇÃO DO TOKEN / SMART CARD A3"); + pinSectionTitle.getStyleClass().add("dialog-field-title"); + pinContainer.getChildren().add(pinSectionTitle); + + if (isWindowsStore) { + VBox winBox = new VBox(6); + Label winTitle = new Label("🔐 Autenticação nativa pelo Windows / Driver do Token"); + winTitle.setStyle("-fx-text-fill: -fx-accent; -fx-font-weight: bold; -fx-font-size: 13px;"); + + Label winDesc = new Label( + "O PIN deste dispositivo é gerenciado diretamente pelo driver nativo (SafeSign / Windows Security). " + + "Caso o token ainda não esteja autenticado, a janela segura do driver será aberta automaticamente." + ); + winDesc.setStyle("-fx-text-fill: -fx-text-secondary; -fx-font-size: 12px;"); + winDesc.setWrapText(true); + + winBox.getChildren().addAll(winTitle, winDesc); + pinContainer.getChildren().add(winBox); + } else { + pinField.setPromptText("Digite o PIN do token..."); + + if (hasStoredPin) { + HBox rememberedBox = new HBox(12); + rememberedBox.setAlignment(Pos.CENTER_LEFT); + + Label rememberedBadge = new Label("● PIN salvo nesta sessão ativa"); + rememberedBadge.setStyle("-fx-text-fill: #10b981; -fx-font-weight: bold; -fx-font-size: 12px;"); + + Button btnChangePin = new Button("Alterar PIN"); + btnChangePin.getStyleClass().add("btn-secondary"); + btnChangePin.setStyle("-fx-padding: 4px 10px; -fx-font-size: 11px;"); + + VBox inputWrapper = new VBox(8); + inputWrapper.setVisible(false); + inputWrapper.setManaged(false); + inputWrapper.getChildren().addAll(new Label("Novo PIN:"), pinField, cbSavePin); + + btnChangePin.setOnAction(e -> { + boolean nowVisible = !inputWrapper.isVisible(); + inputWrapper.setVisible(nowVisible); + inputWrapper.setManaged(nowVisible); + btnChangePin.setText(nowVisible ? "Cancelar alteração" : "Alterar PIN"); + if (nowVisible) { + cbSavePin.setSelected(true); + } + }); + + rememberedBox.getChildren().addAll(rememberedBadge, btnChangePin); + pinContainer.getChildren().addAll(rememberedBox, inputWrapper); + } else { + VBox inputWrapper = new VBox(8); + Label pinPromptLabel = new Label("Informe o PIN do dispositivo para autorizar a chave privada:"); + pinPromptLabel.setStyle("-fx-text-fill: -fx-text-secondary; -fx-font-size: 12px;"); + + cbSavePin.setSelected(false); + inputWrapper.getChildren().addAll(pinPromptLabel, pinField, cbSavePin); + pinContainer.getChildren().add(inputWrapper); + } + } + + detailsBox.getChildren().add(pinContainer); + } + + // Label de Alerta de Validação + Label validationLabel = new Label(); + validationLabel.setStyle("-fx-text-fill: #f87171; -fx-font-size: 12px;"); + validationLabel.setVisible(false); + + // Botões de Ação + HBox actionsRow = new HBox(12); + actionsRow.setAlignment(Pos.CENTER_RIGHT); + + Button btnCancel = new Button("Cancelar"); + btnCancel.getStyleClass().add("btn-cancel"); + btnCancel.setOnAction(e -> { + approvalFuture.complete(false); + stage.close(); + }); + + Button btnConfirm = new Button(isAuthentication ? "Confirmar e Entrar" : "Confirmar e Assinar"); + btnConfirm.getStyleClass().add("btn-primary"); + btnConfirm.setDefaultButton(true); + btnConfirm.setOnAction(e -> { + if (isA3 && !isWindowsStore) { + String typedPin = pinField.getText(); + boolean pinProvided = (typedPin != null && !typedPin.isBlank()); + + if (!hasStoredPin && !pinProvided) { + validationLabel.setText("Atenção: O PIN do token é obrigatório para assinar via PKCS#11."); + validationLabel.setVisible(true); + pinField.requestFocus(); + return; + } + + if (pinProvided) { + char[] pinChars = typedPin.toCharArray(); + if (cbSavePin.isSelected()) { + PinSessionManager.getInstance().savePin(cert.id(), pinChars); + } else { + PinSessionManager.getInstance().clearPin(cert.id()); + } + } + } + + approvalFuture.complete(true); + stage.close(); + }); + + actionsRow.getChildren().addAll(btnCancel, btnConfirm); + + root.getChildren().addAll(header, desc, detailsBox, validationLabel, actionsRow); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + + stage.setScene(scene); + stage.setOnCloseRequest(e -> approvalFuture.complete(false)); + } + + private void addField(GridPane grid, int row, String title, String value) { + Label titleLabel = new Label(title); + titleLabel.getStyleClass().add("dialog-field-title"); + + Label valLabel = new Label(value); + valLabel.getStyleClass().add("dialog-field-value"); + + grid.add(titleLabel, 0, row); + grid.add(valLabel, 1, row); + } + + public CompletableFuture showAndAwait() { + stage.show(); + stage.toFront(); + return approvalFuture; + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SignatureValidationDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SignatureValidationDialog.java new file mode 100644 index 0000000..edcc2a1 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/SignatureValidationDialog.java @@ -0,0 +1,395 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.validation.SignatureValidationService; +import br.com.cybercore.signer.core.validation.ValidationResult; +import br.com.cybercore.signer.core.validation.ValidationSignerInfo; +import br.com.cybercore.signer.core.validation.ValidationStatus; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Separator; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.file.Files; + +/** + * Diálogo unificado para Validação Criptográfica de Assinaturas Digitais. + * Suporta Assinatura Incorporada / Embutida (Attached / PAdES / P7M / XML) + * e Assinatura Destacada (Detached / P7S). + */ +@SuppressWarnings("this-escape") +public final class SignatureValidationDialog { + + private static final Logger logger = LoggerFactory.getLogger(SignatureValidationDialog.class); + + private final Stage stage; + private final boolean isTimestamp; + private final SignatureValidationService validationService = new SignatureValidationService(); + + private RadioButton rbAttached; + private RadioButton rbDetached; + + private VBox docBox; + private TextField txtDocPath; + private TextField txtSigPath; + private Label lblSigPath; + + private File docFile; + private File sigFile; + + private VBox resultBox; + private Button btnSaveExtracted; + private byte[] lastExtractedContent; + + public SignatureValidationDialog(Stage owner, boolean isTimestamp) { + this.isTimestamp = isTimestamp; + + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle(isTimestamp ? "CyberCore Signer — Validação de Carimbo do Tempo" : "CyberCore Signer — Validação de Assinatura Digital"); + this.stage.setResizable(true); + this.stage.setMinWidth(680); + this.stage.setMinHeight(520); + + initLayout(); + } + + private void initLayout() { + VBox root = new VBox(16); + root.setPadding(new Insets(22)); + root.getStyleClass().add("dialog-container"); + + HBox header = new HBox(12); + header.setAlignment(Pos.CENTER_LEFT); + header.getChildren().addAll( + isTimestamp ? SvgIcons.validateTimestampIcon() : SvgIcons.validateIcon(), + createHeaderLabel( + isTimestamp ? "VALIDAÇÃO DE CARIMBO DO TEMPO" : "VALIDAÇÃO DE ASSINATURA DIGITAL", + isTimestamp ? "Verifique a autenticidade e conformidade RFC 3161 TSP." + : "Verifique a autenticidade, integridade e certificados de assinaturas incorporadas ou destacadas." + ) + ); + + // Seletor de Tipo de Assinatura (Incorporada vs Destacada) + VBox typeBox = new VBox(8); + typeBox.getStyleClass().add("dialog-box"); + Label lblTypeTitle = new Label("Tipo de Assinatura para Validação:"); + lblTypeTitle.getStyleClass().add("dialog-field-title"); + + ToggleGroup modeGroup = new ToggleGroup(); + rbAttached = new RadioButton("Assinatura Incorporada / Embutida (PDF assinado, P7M ou XML)"); + rbAttached.setToggleGroup(modeGroup); + rbAttached.setSelected(true); + rbAttached.setOnAction(e -> updateModeView()); + + rbDetached = new RadioButton("Assinatura Destacada (Documento Original + Arquivo .p7s separado)"); + rbDetached.setToggleGroup(modeGroup); + rbDetached.setOnAction(e -> updateModeView()); + + HBox radioRow = new HBox(18); + radioRow.getChildren().addAll(rbAttached, rbDetached); + typeBox.getChildren().addAll(lblTypeTitle, radioRow); + + if (isTimestamp) { + typeBox.setVisible(false); + typeBox.setManaged(false); + } + + // 1. Arquivo de Assinatura / Arquivo Assinado + VBox sigBox = new VBox(8); + sigBox.getStyleClass().add("dialog-box"); + lblSigPath = new Label("Arquivo Assinado (.pdf, .p7m, .p7s, .xml):"); + lblSigPath.getStyleClass().add("dialog-field-title"); + + HBox sigRow = new HBox(10); + txtSigPath = new TextField(); + txtSigPath.setPromptText("Selecione o arquivo assinado..."); + txtSigPath.setEditable(false); + HBox.setHgrow(txtSigPath, Priority.ALWAYS); + + Button btnBrowseSig = new Button("Procurar..."); + btnBrowseSig.getStyleClass().add("btn-secondary"); + btnBrowseSig.setOnAction(e -> handleBrowseSig()); + + sigRow.getChildren().addAll(txtSigPath, btnBrowseSig); + sigBox.getChildren().addAll(lblSigPath, sigRow); + + // 2. Documento Original (Visível apenas em modo Destacado) + docBox = new VBox(8); + docBox.getStyleClass().add("dialog-box"); + Label lblDoc = new Label("Documento Original Separado:"); + lblDoc.getStyleClass().add("dialog-field-title"); + + HBox docRow = new HBox(10); + txtDocPath = new TextField(); + txtDocPath.setPromptText("Selecione o documento original..."); + txtDocPath.setEditable(false); + HBox.setHgrow(txtDocPath, Priority.ALWAYS); + + Button btnBrowseDoc = new Button("Procurar..."); + btnBrowseDoc.getStyleClass().add("btn-secondary"); + btnBrowseDoc.setOnAction(e -> handleBrowseDoc()); + + docRow.getChildren().addAll(txtDocPath, btnBrowseDoc); + docBox.getChildren().addAll(lblDoc, docRow); + + // Painel Dinâmico de Resultados + resultBox = new VBox(10); + resultBox.setVisible(false); + resultBox.setManaged(false); + resultBox.getStyleClass().add("dialog-box"); + + ScrollPane scrollResult = new ScrollPane(resultBox); + scrollResult.setFitToWidth(true); + scrollResult.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + VBox.setVgrow(scrollResult, Priority.ALWAYS); + + // Rodapé + HBox footer = new HBox(12); + footer.setAlignment(Pos.CENTER_RIGHT); + + btnSaveExtracted = new Button("Salvar Conteúdo Extraído..."); + btnSaveExtracted.getStyleClass().add("btn-secondary"); + btnSaveExtracted.setVisible(false); + btnSaveExtracted.setManaged(false); + btnSaveExtracted.setOnAction(e -> handleSaveExtracted()); + + HBox.setHgrow(btnSaveExtracted, Priority.NEVER); + + Button btnClose = new Button("Fechar"); + btnClose.getStyleClass().add("btn-secondary"); + btnClose.setOnAction(e -> stage.close()); + + Button btnValidate = new Button("Validar Agora"); + btnValidate.getStyleClass().add("btn-primary"); + btnValidate.setDefaultButton(true); + btnValidate.setOnAction(e -> handleValidate()); + + footer.getChildren().addAll(btnSaveExtracted, btnClose, btnValidate); + + root.getChildren().addAll(header, typeBox, sigBox, docBox, scrollResult, footer); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + stage.setScene(scene); + + updateModeView(); + } + + private void updateModeView() { + boolean isAttached = rbAttached.isSelected() && !isTimestamp; + docBox.setVisible(!isAttached); + docBox.setManaged(!isAttached); + + if (isAttached) { + lblSigPath.setText("Arquivo Assinado (.pdf, .p7m, .p7s, .xml):"); + txtSigPath.setPromptText("Selecione o arquivo assinado com dados embutidos..."); + } else { + lblSigPath.setText(isTimestamp ? "Arquivo de Carimbo (.tsr, .tsp, .p7s):" : "Arquivo de Assinatura (.p7s, .sig):"); + txtSigPath.setPromptText("Selecione o arquivo de assinatura digital..."); + } + } + + private void handleBrowseDoc() { + FileChooser ch = new FileChooser(); + ch.setTitle("Selecionar Documento Original"); + File f = ch.showOpenDialog(stage); + if (f != null) { + docFile = f; + txtDocPath.setText(f.getAbsolutePath()); + } + } + + private void handleBrowseSig() { + FileChooser ch = new FileChooser(); + ch.setTitle(rbAttached.isSelected() ? "Selecionar Arquivo Assinado" : "Selecionar Arquivo de Assinatura"); + + if (rbAttached.isSelected()) { + ch.getExtensionFilters().add(new FileChooser.ExtensionFilter("Documentos Assinados (*.pdf, *.p7m, *.p7s, *.xml)", "*.pdf", "*.p7m", "*.p7s", "*.xml", "*.*")); + } else { + ch.getExtensionFilters().add(new FileChooser.ExtensionFilter("Assinaturas Digitais (*.p7s, *.sig, *.tsr)", "*.p7s", "*.sig", "*.tsr", "*.*")); + } + + File f = ch.showOpenDialog(stage); + if (f != null) { + sigFile = f; + txtSigPath.setText(f.getAbsolutePath()); + + // Detecção inteligente: se selecionar .pdf no modo destacado, sugere alternar para incorporada + if (f.getName().toLowerCase().endsWith(".pdf") && rbDetached.isSelected()) { + rbAttached.setSelected(true); + updateModeView(); + } + } + } + + private void handleValidate() { + if (sigFile == null || !sigFile.exists()) { + showAlert(Alert.AlertType.WARNING, "Arquivo Não Selecionado", "Por favor, selecione o arquivo a ser validado."); + return; + } + + boolean isDetachedMode = rbDetached.isSelected() || isTimestamp; + if (isDetachedMode && (docFile == null || !docFile.exists())) { + showAlert(Alert.AlertType.WARNING, "Documento Original Não Selecionado", + "Para validar assinatura destacada é necessário selecionar tanto o documento original quanto o arquivo de assinatura."); + return; + } + + try { + byte[] sigBytes = Files.readAllBytes(sigFile.toPath()); + ValidationResult result; + + if (isDetachedMode) { + byte[] docBytes = Files.readAllBytes(docFile.toPath()); + result = validationService.validateDetached(docBytes, sigBytes, docFile.getName()); + } else { + result = validationService.validateAttached(sigBytes, sigFile.getName()); + } + + displayValidationResult(result); + + } catch (Exception ex) { + logger.error("Erro na validação: {}", ex.getMessage(), ex); + showAlert(Alert.AlertType.ERROR, "Erro no Processamento", "Falha ao validar arquivo: " + ex.getMessage()); + } + } + + private void displayValidationResult(ValidationResult result) { + resultBox.getChildren().clear(); + resultBox.setVisible(true); + resultBox.setManaged(true); + + // 1. Badge de Status + HBox statusBadge = new HBox(10); + statusBadge.setAlignment(Pos.CENTER_LEFT); + statusBadge.setPadding(new Insets(10, 14, 10, 14)); + + Label lblBadge = new Label(result.status().getDescription()); + lblBadge.setStyle("-fx-font-weight: bold; -fx-font-size: 14px;"); + + if (result.status() == ValidationStatus.VALID) { + statusBadge.setStyle("-fx-background-color: rgba(16, 185, 129, 0.15); -fx-background-radius: 6px; -fx-border-color: #10b981; -fx-border-radius: 6px;"); + lblBadge.setStyle(lblBadge.getStyle() + " -fx-text-fill: #10b981;"); + } else if (result.status() == ValidationStatus.INVALID) { + statusBadge.setStyle("-fx-background-color: rgba(239, 68, 68, 0.15); -fx-background-radius: 6px; -fx-border-color: #ef4444; -fx-border-radius: 6px;"); + lblBadge.setStyle(lblBadge.getStyle() + " -fx-text-fill: #ef4444;"); + } else { + statusBadge.setStyle("-fx-background-color: rgba(245, 158, 11, 0.15); -fx-background-radius: 6px; -fx-border-color: #f59e0b; -fx-border-radius: 6px;"); + lblBadge.setStyle(lblBadge.getStyle() + " -fx-text-fill: #f59e0b;"); + } + + Label lblType = new Label("• Formato: " + result.type().getDescription()); + lblType.setStyle("-fx-font-size: 12px; -fx-text-fill: #cbd5e1;"); + + statusBadge.getChildren().addAll(lblBadge, lblType); + + Label lblMsg = new Label(result.summaryMessage()); + lblMsg.setWrapText(true); + lblMsg.setStyle("-fx-font-size: 12.5px; -fx-font-weight: bold;"); + + resultBox.getChildren().addAll(statusBadge, lblMsg); + + // 2. Lista de Signatários + if (!result.signers().isEmpty()) { + resultBox.getChildren().add(new Separator()); + Label lblSignersTitle = new Label("DETALHES DOS SIGNATÁRIOS (" + result.signers().size() + "):"); + lblSignersTitle.getStyleClass().add("dialog-field-title"); + resultBox.getChildren().add(lblSignersTitle); + + for (ValidationSignerInfo s : result.signers()) { + VBox card = new VBox(4); + card.getStyleClass().add("diagnostic-item"); + card.setPadding(new Insets(10)); + + Label lblSignerName = new Label("Signatário: " + s.signerName()); + lblSignerName.getStyleClass().add("cert-title"); + + String docText = s.identifier().isBlank() ? "" : s.identifier() + " • "; + Label lblCert = new Label(docText + "Emissor: " + s.issuer()); + lblCert.getStyleClass().add("cert-detail"); + + Label lblTime = new Label("Data/Hora: " + s.signingTime() + " • Algoritmo: " + s.algorithm()); + lblTime.getStyleClass().add("cert-detail"); + + Label lblIntegrity = new Label(s.isValid() ? "✓ Assinatura íntegra e chave pública conferida." : "✗ Falha na integridade da assinatura."); + lblIntegrity.setStyle(s.isValid() ? "-fx-text-fill: #10b981; -fx-font-weight: bold; -fx-font-size: 11.5px;" : "-fx-text-fill: #ef4444; -fx-font-weight: bold; -fx-font-size: 11.5px;"); + + card.getChildren().addAll(lblSignerName, lblCert, lblTime, lblIntegrity); + resultBox.getChildren().add(card); + } + } + + // 3. Suporte a exportação de conteúdo extraído (caso CMS/P7M incorporado) + if (result.extractedContent() != null && result.extractedContent().length > 0) { + lastExtractedContent = result.extractedContent(); + btnSaveExtracted.setVisible(true); + btnSaveExtracted.setManaged(true); + } else { + lastExtractedContent = null; + btnSaveExtracted.setVisible(false); + btnSaveExtracted.setManaged(false); + } + } + + private void handleSaveExtracted() { + if (lastExtractedContent == null) return; + FileChooser chooser = new FileChooser(); + chooser.setTitle("Salvar Documento Original Extraído"); + chooser.setInitialFileName("documento_recuperado.bin"); + + File dest = chooser.showSaveDialog(stage); + if (dest != null) { + try { + Files.write(dest.toPath(), lastExtractedContent); + showAlert(Alert.AlertType.INFORMATION, "Documento Extraído", "O documento contido na assinatura foi salvo com sucesso em:\n" + dest.getAbsolutePath()); + } catch (Exception ex) { + showAlert(Alert.AlertType.ERROR, "Erro ao Salvar", "Falha ao gravar arquivo: " + ex.getMessage()); + } + } + } + + private void showAlert(Alert.AlertType type, String title, String msg) { + Alert a = new Alert(type); + a.setTitle(title); + a.setHeaderText(null); + a.setContentText(msg); + a.initOwner(stage); + ThemeManager.applyTheme(a.getDialogPane().getScene()); + a.showAndWait(); + } + + private VBox createHeaderLabel(String title, String subtitle) { + VBox box = new VBox(3); + Label t = new Label(title); + t.getStyleClass().add("dialog-header"); + Label s = new Label(subtitle); + s.getStyleClass().add("dialog-subtitle"); + box.getChildren().addAll(t, s); + return box; + } + + public void show() { + stage.show(); + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/TutorialDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/TutorialDialog.java new file mode 100644 index 0000000..dfd3af2 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/TutorialDialog.java @@ -0,0 +1,118 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.ui.CyberCoreUiConstants; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import br.com.cybercore.signer.ui.components.SvgIcons; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import java.util.Objects; + +/** + * Diálogo interativo com instruções e tutoriais de uso do CyberCore Signer. + */ +@SuppressWarnings("this-escape") +public final class TutorialDialog { + + private final Stage stage; + + public TutorialDialog(Stage owner) { + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — Tutorial e Guia de Uso"); + this.stage.setResizable(true); + this.stage.setMinWidth(640); + this.stage.setMinHeight(500); + + initLayout(); + } + + private void initLayout() { + VBox root = new VBox(16); + root.setPadding(new Insets(24)); + root.getStyleClass().add("dialog-container"); + + HBox header = new HBox(12); + header.setAlignment(Pos.CENTER_LEFT); + header.getChildren().addAll( + SvgIcons.tutorialIcon(), + createHeaderLabel("TUTORIAL E GUIA DE UTILIZAÇÃO", "Aprenda a assinar documentos e autorizar aplicações web com facilidade") + ); + + VBox content = new VBox(16); + content.getStyleClass().add("dialog-box"); + + addStep(content, "1. Assinatura Local Direta", + "Utilize os botões da tela inicial para assinar PDFs, XMLs, arquivos diversos ou múltiplos arquivos em lote. O agente calculará o hash SHA-256 e gerará a assinatura digital protegida."); + + addStep(content, "2. Integração com Sistemas Web (CCSP/1)", + "Aplicações web corporativas conectam-se ao agente local através da porta 127.0.0.1:21325. Na primeira utilização de um site, o CyberCore solicitará sua autorização explícita."); + + addStep(content, "3. Login com Certificado Digital", + "Quando um sistema web solicitar autenticação com certificado, você verá um diálogo azul destacando o desafio e o titular do certificado. Nenhuma senha ou chave privada é transmitida à rede."); + + addStep(content, "4. Utilização de Tokens USB e Smart Cards (A3)", + "Ao conectar um token USB ou cartão na leitora, o CyberCore Signer detecta o dispositivo automaticamente. Ao assinar, informe o PIN no diálogo protegido."); + + addStep(content, "5. Importação de Certificados A1 (.pfx / .p12)", + "Certificados A1 podem ser adicionados através do menu 'Arquivos Seguros' no painel lateral. A chave privada permanece isolada na máquina do usuário."); + + ScrollPane scroll = new ScrollPane(content); + scroll.setFitToWidth(true); + scroll.setStyle("-fx-background: transparent; -fx-background-color: transparent;"); + VBox.setVgrow(scroll, Priority.ALWAYS); + + HBox footer = new HBox(); + footer.setAlignment(Pos.CENTER_RIGHT); + Button btnClose = new Button(" Entendi", SvgIcons.closeIcon(13)); + btnClose.getStyleClass().add("btn-primary"); + btnClose.setPrefWidth(95); + btnClose.setOnAction(e -> stage.close()); + footer.getChildren().add(btnClose); + + root.getChildren().addAll(header, scroll, footer); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + + stage.setScene(scene); + } + + private void addStep(VBox container, String title, String description) { + VBox box = new VBox(4); + Label t = new Label(title); + t.getStyleClass().add("dialog-header"); + t.setStyle("-fx-font-size: 13.5px;"); + Label d = new Label(description); + d.setWrapText(true); + d.getStyleClass().add("dialog-field-value"); + box.getChildren().addAll(t, d); + container.getChildren().add(box); + } + + private VBox createHeaderLabel(String title, String subtitle) { + VBox box = new VBox(3); + Label t = new Label(title); + t.getStyleClass().add("dialog-header"); + Label s = new Label(subtitle); + s.getStyleClass().add("dialog-subtitle"); + box.getChildren().addAll(t, s); + return box; + } + + public void show() { + stage.show(); + } +} diff --git a/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/XmlSignDialog.java b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/XmlSignDialog.java new file mode 100644 index 0000000..a67cd10 --- /dev/null +++ b/modules/cybercore-ui/src/main/java/br/com/cybercore/signer/ui/view/XmlSignDialog.java @@ -0,0 +1,513 @@ +package br.com.cybercore.signer.ui.view; + +import br.com.cybercore.signer.core.domain.CertificateType; +import br.com.cybercore.signer.core.domain.ManagedCertificate; +import br.com.cybercore.signer.core.provider.CertificateManager; +import br.com.cybercore.signer.core.security.PinSessionManager; +import br.com.cybercore.signer.core.xml.XmlSignatureOptions; +import br.com.cybercore.signer.core.xml.XmlSigningService; +import br.com.cybercore.signer.ui.components.SvgIcons; +import br.com.cybercore.signer.ui.theme.ThemeManager; +import javafx.collections.FXCollections; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.PasswordField; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Desktop; +import java.io.File; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Diálogo especializado para Assinatura Digital de Documentos XML segundo o padrão + * W3C XMLDSig e ICP-Brasil, com suporte a envelopes completos e elementos específicos (ex: infNFe). + */ +@SuppressWarnings("this-escape") +public final class XmlSignDialog { + + private static final Logger logger = LoggerFactory.getLogger(XmlSignDialog.class); + + private final Stage stage; + private final CertificateManager certManager; + private final XmlSigningService xmlSigningService; + + // Controles de Documento + private TextField txtSourceXml; + private File selectedSourceFile; + + // Controles de Certificado + private ComboBox certCombo; + private PasswordField pinField; + private Label lblPinNotice; + + // Controles de Modo de Assinatura + private RadioButton rbWholeDocument; + private RadioButton rbSpecificElement; + private TextField txtElementTag; + private TextField txtElementId; + private CheckBox chkValidateAfter; + + // Controles de Ação e Feedback + private ProgressIndicator progressIndicator; + private Label lblStatus; + private Button btnSign; + private Button btnCancel; + + public XmlSignDialog(Stage owner, CertificateManager certManager) { + this(owner, certManager, new XmlSigningService(certManager)); + } + + public XmlSignDialog(Stage owner, CertificateManager certManager, XmlSigningService xmlSigningService) { + this.certManager = Objects.requireNonNull(certManager, "CertificateManager não pode ser nulo."); + this.xmlSigningService = Objects.requireNonNull(xmlSigningService, "XmlSigningService não pode ser nulo."); + + this.stage = new Stage(); + this.stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) { + this.stage.initOwner(owner); + } + this.stage.setTitle("CyberCore Signer — Assinatura Digital de XML (XMLDSig)"); + this.stage.setResizable(true); + this.stage.setMinWidth(720); + this.stage.setMinHeight(580); + + initLayout(); + } + + private void initLayout() { + VBox root = new VBox(16); + root.setPadding(new Insets(20)); + root.getStyleClass().add("dialog-container"); + + // Topo / Cabeçalho + HBox header = new HBox(14); + header.setAlignment(Pos.CENTER_LEFT); + VBox titleBox = new VBox(4); + Label lblTitle = new Label("ASSINATURA DE DOCUMENTO XML (XMLDSIG)"); + lblTitle.getStyleClass().add("dialog-header"); + Label lblSub = new Label("Assine digitalmente documentos XML no padrão W3C XMLDSig e ICP-Brasil (NF-e, CT-e, MDF-e, eSocial, etc.)."); + lblSub.getStyleClass().add("dialog-subtitle"); + titleBox.getChildren().addAll(lblTitle, lblSub); + header.getChildren().addAll(SvgIcons.xmlIcon(), titleBox); + + // ScrollPane + ScrollPane scrollPane = new ScrollPane(); + scrollPane.setFitToWidth(true); + scrollPane.setStyle("-fx-background-color: transparent; -fx-background: transparent; -fx-border-color: transparent;"); + + VBox formContent = new VBox(16); + formContent.setPadding(new Insets(4, 8, 4, 0)); + + // 1. Seção: Arquivo XML + VBox fileSection = new VBox(8); + fileSection.getStyleClass().add("dialog-box"); + Label lblFileTitle = new Label("ARQUIVO XML A ASSINAR"); + lblFileTitle.getStyleClass().add("dialog-field-title"); + + HBox fileRow = new HBox(10); + fileRow.setAlignment(Pos.CENTER_LEFT); + txtSourceXml = new TextField(); + txtSourceXml.setPromptText("Selecione o arquivo XML original..."); + txtSourceXml.setEditable(false); + HBox.setHgrow(txtSourceXml, Priority.ALWAYS); + + Button btnBrowse = new Button("Procurar XML..."); + btnBrowse.getStyleClass().add("btn-secondary"); + btnBrowse.setOnAction(e -> handleBrowseSourceXml()); + fileRow.getChildren().addAll(txtSourceXml, btnBrowse); + fileSection.getChildren().addAll(lblFileTitle, fileRow); + + // 2. Seção: Certificado Digital + VBox certSection = new VBox(8); + certSection.getStyleClass().add("dialog-box"); + Label lblCertTitle = new Label("CERTIFICADO DIGITAL ICP-BRASIL"); + lblCertTitle.getStyleClass().add("dialog-field-title"); + + certCombo = new ComboBox<>(); + certCombo.setMaxWidth(Double.MAX_VALUE); + populateCertificates(); + certCombo.setOnAction(e -> updatePinFieldVisibility()); + + certCombo.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(ManagedCertificate item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : ""); + setText(item.commonName() + doc + " (" + item.type() + ")"); + } + } + }); + certCombo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(ManagedCertificate item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + } else { + String doc = item.cpf() != null ? " [CPF: " + item.cpf() + "]" : (item.cnpj() != null ? " [CNPJ: " + item.cnpj() + "]" : ""); + setText(item.commonName() + doc + " (" + item.type() + ")"); + } + } + }); + + HBox pinRow = new HBox(12); + pinRow.setAlignment(Pos.CENTER_LEFT); + Label lblPin = new Label("PIN / Senha:"); + lblPin.getStyleClass().add("dialog-field-title"); + pinField = new PasswordField(); + pinField.setPromptText("Digite o PIN ou senha da chave privada..."); + pinField.setPrefWidth(260); + + lblPinNotice = new Label(); + lblPinNotice.setStyle("-fx-font-size: 11px;"); + pinRow.getChildren().addAll(lblPin, pinField, lblPinNotice); + + certSection.getChildren().addAll(lblCertTitle, certCombo, pinRow); + + // 3. Seção: Modo de Assinatura XMLDSig + VBox xmlModeSection = new VBox(12); + xmlModeSection.getStyleClass().add("dialog-box"); + Label lblModeTitle = new Label("MODO DE ASSINATURA XMLDSIG"); + lblModeTitle.getStyleClass().add("dialog-field-title"); + + ToggleGroup modeGroup = new ToggleGroup(); + rbWholeDocument = new RadioButton("Documento Completo (Enveloped W3C / URI=\"\")"); + rbWholeDocument.setToggleGroup(modeGroup); + rbWholeDocument.setSelected(true); + + rbSpecificElement = new RadioButton("Elemento Específico por Tag / ID (Padrão NF-e, CT-e, MDF-e, etc.)"); + rbSpecificElement.setToggleGroup(modeGroup); + + VBox elementConfigBox = new VBox(10); + elementConfigBox.setPadding(new Insets(6, 0, 0, 24)); + + GridPane elementGrid = new GridPane(); + elementGrid.setHgap(12); + elementGrid.setVgap(8); + + Label lblTag = new Label("Tag do Elemento:"); + lblTag.getStyleClass().add("dialog-field-title"); + txtElementTag = new TextField("infNFe"); + txtElementTag.setPromptText("Ex: infNFe, infCte, infMDFe, SignedInfo"); + + Label lblId = new Label("Atributo Id (opcional):"); + lblId.getStyleClass().add("dialog-field-title"); + txtElementId = new TextField(); + txtElementId.setPromptText("Deixe vazio para autodetectar o Id da tag"); + + elementGrid.add(lblTag, 0, 0); + elementGrid.add(txtElementTag, 1, 0); + elementGrid.add(lblId, 0, 1); + elementGrid.add(txtElementId, 1, 1); + + elementConfigBox.getChildren().add(elementGrid); + elementConfigBox.disableProperty().bind(rbWholeDocument.selectedProperty()); + + chkValidateAfter = new CheckBox("Validar conformidade e integridade criptográfica após gravação"); + chkValidateAfter.setSelected(true); + + xmlModeSection.getChildren().addAll(lblModeTitle, rbWholeDocument, rbSpecificElement, elementConfigBox, chkValidateAfter); + + formContent.getChildren().addAll(fileSection, certSection, xmlModeSection); + scrollPane.setContent(formContent); + VBox.setVgrow(scrollPane, Priority.ALWAYS); + + // Rodapé com Ações + HBox footer = new HBox(12); + footer.setAlignment(Pos.CENTER_RIGHT); + + progressIndicator = new ProgressIndicator(); + progressIndicator.setPrefSize(22, 22); + progressIndicator.setVisible(false); + + lblStatus = new Label(); + lblStatus.setStyle("-fx-font-size: 12px;"); + HBox.setHgrow(lblStatus, Priority.ALWAYS); + + btnCancel = new Button("Cancelar"); + btnCancel.getStyleClass().add("btn-secondary"); + btnCancel.setOnAction(e -> stage.close()); + + btnSign = new Button("Assinar Documento XML"); + btnSign.getStyleClass().add("btn-primary"); + btnSign.setDefaultButton(true); + btnSign.setOnAction(e -> executeXmlSignature()); + + footer.getChildren().addAll(progressIndicator, lblStatus, btnCancel, btnSign); + + root.getChildren().addAll(header, scrollPane, footer); + + Scene scene = new Scene(root); + ThemeManager.applyTheme(scene); + stage.setScene(scene); + + updatePinFieldVisibility(); + } + + private void populateCertificates() { + try { + List certs = certManager.getAvailableCertificates(); + certCombo.setItems(FXCollections.observableArrayList(certs)); + if (!certs.isEmpty()) { + certCombo.setValue(certs.get(0)); + } + } catch (Exception e) { + logger.error("Erro ao listar certificados para assinatura de XML", e); + } + } + + private void updatePinFieldVisibility() { + ManagedCertificate cert = certCombo.getValue(); + if (cert == null) { + pinField.setDisable(true); + lblPinNotice.setText(""); + return; + } + + boolean isA3 = cert.type() == CertificateType.A3; + boolean hasPinInSession = isA3 && PinSessionManager.getInstance().hasPin(cert.id()); + + if (hasPinInSession) { + pinField.setDisable(true); + pinField.clear(); + lblPinNotice.setText("✓ PIN memorizado na sessão segura"); + lblPinNotice.setStyle("-fx-text-fill: #10b981; -fx-font-weight: bold;"); + } else if (isA3 && "provider-win-store".equalsIgnoreCase(cert.providerId())) { + pinField.setDisable(true); + pinField.clear(); + lblPinNotice.setText("Autenticação nativa pelo Windows/Driver"); + lblPinNotice.setStyle("-fx-text-fill: -fx-accent;"); + } else { + pinField.setDisable(false); + lblPinNotice.setText(isA3 ? "Requer PIN do Smart Card" : "Requer senha do certificado A1"); + lblPinNotice.setStyle("-fx-text-fill: -fx-text-secondary;"); + } + } + + private void handleBrowseSourceXml() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Selecionar Documento XML"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Documentos XML (*.xml)", "*.xml")); + + File file = chooser.showOpenDialog(stage); + if (file != null) { + selectedSourceFile = file; + txtSourceXml.setText(file.getAbsolutePath()); + + // Tenta detectar automaticamente se é uma NF-e, CT-e, etc. + try { + String content = Files.readString(file.toPath()); + if (content.contains(" signTask = new Task<>() { + @Override + protected File call() throws Exception { + xmlSigningService.signXmlFile(selectedSourceFile, targetFile, options); + + if (validate) { + byte[] signedBytes = Files.readAllBytes(targetFile.toPath()); + boolean valid = xmlSigningService.validateXmlSignature(signedBytes); + if (!valid) { + throw new IllegalStateException("A assinatura foi gravada, mas a validação de integridade do XML falhou."); + } + } + return targetFile; + } + }; + + signTask.setOnSucceeded(e -> { + setFormDisabled(false); + progressIndicator.setVisible(false); + lblStatus.setText("✓ Documento XML assinado e validado com sucesso!"); + lblStatus.setStyle("-fx-text-fill: #10b981; -fx-font-weight: bold;"); + + showSuccessDialog(targetFile); + }); + + signTask.setOnFailed(e -> { + setFormDisabled(false); + progressIndicator.setVisible(false); + Throwable ex = signTask.getException(); + logger.error("Falha ao assinar XML", ex); + lblStatus.setText("✕ Falha na assinatura"); + lblStatus.setStyle("-fx-text-fill: #ef4444; -fx-font-weight: bold;"); + + showAlert(Alert.AlertType.ERROR, "Erro na Assinatura do XML", + "Não foi possível concluir a assinatura XMLDSig:\n" + (ex != null ? ex.getMessage() : "Erro desconhecido")); + }); + + Thread signThread = new Thread(signTask, "cybercore-xml-signer"); + signThread.setDaemon(true); + signThread.start(); + } + + private void showSuccessDialog(File targetFile) { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle("Assinatura Concluída"); + alert.setHeaderText("XML Assinado com Sucesso!"); + alert.setContentText("O documento XML foi digitalmente assinado no padrão W3C XMLDSig ICP-Brasil.\n\n" + + "Arquivo gerado:\n" + targetFile.getAbsolutePath()); + + ButtonType btnOpen = new ButtonType("Abrir XML"); + ButtonType btnFolder = new ButtonType("Abrir Pasta"); + ButtonType btnClose = new ButtonType("Fechar", ButtonBar.ButtonData.CANCEL_CLOSE); + + alert.getButtonTypes().setAll(btnOpen, btnFolder, btnClose); + ThemeManager.applyTheme(alert.getDialogPane().getScene()); + + Optional result = alert.showAndWait(); + if (result.isPresent()) { + if (result.get() == btnOpen) { + openFile(targetFile); + } else if (result.get() == btnFolder) { + openFolder(targetFile); + } + } + } + + private void openFile(File file) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { + Desktop.getDesktop().open(file); + } + } catch (Exception ex) { + logger.warn("Não foi possível abrir o arquivo automaticamente: {}", ex.getMessage()); + } + } + + private void openFolder(File file) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { + File parent = file.getParentFile(); + if (parent != null) { + Desktop.getDesktop().open(parent); + } + } + } catch (Exception ex) { + logger.warn("Não foi possível abrir a pasta do arquivo: {}", ex.getMessage()); + } + } + + private void setFormDisabled(boolean disabled) { + btnSign.setDisable(disabled); + btnCancel.setDisable(disabled); + certCombo.setDisable(disabled); + pinField.setDisable(disabled); + rbWholeDocument.setDisable(disabled); + rbSpecificElement.setDisable(disabled); + txtElementTag.setDisable(disabled); + txtElementId.setDisable(disabled); + chkValidateAfter.setDisable(disabled); + } + + private void showAlert(Alert.AlertType type, String title, String message) { + Alert alert = new Alert(type); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + ThemeManager.applyTheme(alert.getDialogPane().getScene()); + alert.showAndWait(); + } + + public void show() { + stage.centerOnScreen(); + stage.show(); + stage.toFront(); + } +} diff --git a/modules/cybercore-ui/src/main/resources/assets/README.md b/modules/cybercore-ui/src/main/resources/assets/README.md new file mode 100644 index 0000000..d77f68b --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/README.md @@ -0,0 +1,75 @@ +# Pasta de Ícones (SVG) e Imagens (PNG) — CyberCore Signer + +Esta pasta é o repositório oficial onde você pode visualizar, customizar e substituir livremente os **ícones em SVG** e as **imagens em PNG** do **CyberCore Signer**. + +--- + +## 📁 Estrutura de Arquivos Criados + +``` +assets/ +├── icons/ # Ícones vetoriais SVG reais e editáveis (W3C padrão) +│ ├── pdf.svg # Ícone Assinar PDF (documento com tag vermelha PDF) +│ ├── xml.svg # Ícone Assinar XML (documento com tag azul ) +│ ├── file.svg # Ícone Assinar Arquivo (documento com caneta) +│ ├── batch.svg # Ícone Assinatura em Lote (pilha de documentos com check) +│ ├── attached.svg # Ícone Assinatura Atachada (documento com clipe de papel) +│ ├── co-sign.svg # Ícone Co-Assinar (documentos duplos com usuários) +│ ├── timestamp.svg # Ícone Carimbo do Tempo (documento com relógio TSA) +│ ├── ltv.svg # Ícone Assinatura LTV (documento com selo perpétuo) +│ ├── validate.svg # Ícone Validar Assinatura (documento com checkmark verde) +│ ├── validate-timestamp.svg # Ícone Validar Carimbo (relógio com checkmark verde) +│ ├── exit.svg # Ícone Sair do Agente (porta com seta vermelha de saída) +│ ├── logs.svg # Ícone Terminal de Logs (janela com prompt ) +│ ├── settings.svg # Ícone Configurações (engrenagem de precisão de 8 dentes) +│ ├── tutorial.svg # Ícone Tutorial e Ajuda (monitor com vídeo/play) +│ ├── cert.svg # Ícone Certificados Digitais (pasta com cadeado seguro) +│ ├── about.svg # Ícone Sobre o Sistema (círculo azul com 'i') +│ ├── sun.svg # Ícone Tema Claro (sol dourado com raios vetoriais) +│ ├── moon.svg # Ícone Tema Escuro (lua crescente azul/índigo) +│ ├── shield.svg # Ícone Brasão de Segurança (CyberShield com chave interna) +│ ├── seal.svg # Ícone Designer e Gerenciador de Selos Notariais +│ ├── check.svg # Ícone Checkmark de sucesso e validação +│ ├── error.svg # Ícone de Erro / Alerta em vermelho +│ └── star.svg # Ícone de Estrela notarial / favorita +│ +└── images/ # Imagens de alta fidelidade em PNG com canal alfa (transparência) + ├── app-icon.png # Ícone oficial do app (256x256 px - Diamante 3D azul Serpro) + ├── app-logo.png # Logotipo horizontal oficial (480x120 px - CyberCore SIGNER) + ├── brand-banner.png # Banner de cabeçalho e marca (600x160 px - Serpro Blue) + └── default-seal.png # Selo de assinatura padrão ICP-Brasil (640x164 px) +``` + +--- + +## 🎨 Como Customizar Qualquer Ícone ou Imagem + +1. **Ícones:** + - Estão no formato `.svg` padrão. + - Podem ser editados diretamente no seu editor de código (VS Code, etc.) ou em editores vetoriais como **Inkscape, Figma ou Illustrator**. + - Se preferir colocar uma imagem bitmap para um ícone específico, você também pode colocar um `.png` (ex: `pdf.png`). O sistema prioriza `.svg` e aceita `.png` com fallback transparente. + +2. **Imagens do Sistema:** + - Estão no formato `.png` com canal alfa de 32 bits (fundo transparente). + - Você pode substituir `app-logo.png`, `app-icon.png`, `brand-banner.png` ou `default-seal.png` pelo logotipo e identidade visual da sua instituição ou empresa. + +3. **Ordem de Prioridade e Hot-Reloading:** + Quando a aplicação busca um ícone ou imagem, a resolução ocorre na ordem: + 1. **Pasta Física Local (`./assets/icons/` e `./assets/images/`)**: Qualquer alteração feita aqui entra em vigor imediatamente ao abrir a janela ou reiniciar a aplicação, sem necessidade de recompilar! + 2. **Pasta do Usuário (`~/.cybercore/assets/`)**: Configurações específicas por usuário do sistema operacional. + 3. **Resources Empacotados no JAR (`/assets/`)**: Cópia empacotada internamente no `.jar`. + 4. **Fallback Vetorial Nativo (`SvgIcons`)**: Garantia de que a interface nunca fique em branco caso algum arquivo seja excluído. + +--- + +## 🚀 Como Testar Suas Modificações + +Para compilar e gerar o pacote `.jar` completo com todos os novos assets embutidos: +```bash +./build-jar.sh +``` + +Para rodar a aplicação: +```bash +java -jar ./cybercore-signer.jar +``` diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/about.svg b/modules/cybercore-ui/src/main/resources/assets/icons/about.svg new file mode 100644 index 0000000..dd4abb9 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/about.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/attached.svg b/modules/cybercore-ui/src/main/resources/assets/icons/attached.svg new file mode 100644 index 0000000..7d894e7 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/attached.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/batch.svg b/modules/cybercore-ui/src/main/resources/assets/icons/batch.svg new file mode 100644 index 0000000..5ced311 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/batch.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/cert.svg b/modules/cybercore-ui/src/main/resources/assets/icons/cert.svg new file mode 100644 index 0000000..14cc2b4 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/cert.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/check.svg b/modules/cybercore-ui/src/main/resources/assets/icons/check.svg new file mode 100644 index 0000000..c379af0 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/co-sign.svg b/modules/cybercore-ui/src/main/resources/assets/icons/co-sign.svg new file mode 100644 index 0000000..283c24f --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/co-sign.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/error.svg b/modules/cybercore-ui/src/main/resources/assets/icons/error.svg new file mode 100644 index 0000000..5a36ff3 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/exit.svg b/modules/cybercore-ui/src/main/resources/assets/icons/exit.svg new file mode 100644 index 0000000..d209a6b --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/exit.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/file.svg b/modules/cybercore-ui/src/main/resources/assets/icons/file.svg new file mode 100644 index 0000000..023885f --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/file.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/logs.svg b/modules/cybercore-ui/src/main/resources/assets/icons/logs.svg new file mode 100644 index 0000000..0dd89e3 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/logs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/ltv.svg b/modules/cybercore-ui/src/main/resources/assets/icons/ltv.svg new file mode 100644 index 0000000..dbc162a --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/ltv.svg @@ -0,0 +1,11 @@ + + + + + + + + + + LTV + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/moon.svg b/modules/cybercore-ui/src/main/resources/assets/icons/moon.svg new file mode 100644 index 0000000..fd47105 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/moon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/pdf.svg b/modules/cybercore-ui/src/main/resources/assets/icons/pdf.svg new file mode 100644 index 0000000..3755ded --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/pdf.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + PDF + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/seal.svg b/modules/cybercore-ui/src/main/resources/assets/icons/seal.svg new file mode 100644 index 0000000..1aa3a5f --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/seal.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/settings.svg b/modules/cybercore-ui/src/main/resources/assets/icons/settings.svg new file mode 100644 index 0000000..e0f4042 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/settings.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/shield.svg b/modules/cybercore-ui/src/main/resources/assets/icons/shield.svg new file mode 100644 index 0000000..1bc2539 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/shield.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/star.svg b/modules/cybercore-ui/src/main/resources/assets/icons/star.svg new file mode 100644 index 0000000..ac2213a --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/star.svg @@ -0,0 +1,4 @@ + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/sun.svg b/modules/cybercore-ui/src/main/resources/assets/icons/sun.svg new file mode 100644 index 0000000..0327476 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/sun.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/timestamp.svg b/modules/cybercore-ui/src/main/resources/assets/icons/timestamp.svg new file mode 100644 index 0000000..16238f4 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/timestamp.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/tutorial.svg b/modules/cybercore-ui/src/main/resources/assets/icons/tutorial.svg new file mode 100644 index 0000000..32d13d2 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/tutorial.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/validate-timestamp.svg b/modules/cybercore-ui/src/main/resources/assets/icons/validate-timestamp.svg new file mode 100644 index 0000000..dc0f7c8 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/validate-timestamp.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/validate.svg b/modules/cybercore-ui/src/main/resources/assets/icons/validate.svg new file mode 100644 index 0000000..f654ce8 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/validate.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/modules/cybercore-ui/src/main/resources/assets/icons/xml.svg b/modules/cybercore-ui/src/main/resources/assets/icons/xml.svg new file mode 100644 index 0000000..edbf6a9 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/assets/icons/xml.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + <XML> + diff --git a/modules/cybercore-ui/src/main/resources/assets/images/app-icon.png b/modules/cybercore-ui/src/main/resources/assets/images/app-icon.png new file mode 100644 index 0000000..e9e231b Binary files /dev/null and b/modules/cybercore-ui/src/main/resources/assets/images/app-icon.png differ diff --git a/modules/cybercore-ui/src/main/resources/assets/images/app-logo.png b/modules/cybercore-ui/src/main/resources/assets/images/app-logo.png new file mode 100644 index 0000000..0cea4b9 Binary files /dev/null and b/modules/cybercore-ui/src/main/resources/assets/images/app-logo.png differ diff --git a/modules/cybercore-ui/src/main/resources/assets/images/brand-banner.png b/modules/cybercore-ui/src/main/resources/assets/images/brand-banner.png new file mode 100644 index 0000000..6bd57c9 Binary files /dev/null and b/modules/cybercore-ui/src/main/resources/assets/images/brand-banner.png differ diff --git a/modules/cybercore-ui/src/main/resources/assets/images/default-seal.png b/modules/cybercore-ui/src/main/resources/assets/images/default-seal.png new file mode 100644 index 0000000..d12d737 Binary files /dev/null and b/modules/cybercore-ui/src/main/resources/assets/images/default-seal.png differ diff --git a/modules/cybercore-ui/src/main/resources/styles/theme.css b/modules/cybercore-ui/src/main/resources/styles/theme.css new file mode 100644 index 0000000..8047762 --- /dev/null +++ b/modules/cybercore-ui/src/main/resources/styles/theme.css @@ -0,0 +1,1065 @@ +/* ========================================================================== + CyberCore Signer — Dual Theme Design System (Claro / Escuro) + Paleta Harmonizada e Alto Contraste (WCAG AA Compliant) + ========================================================================== */ + +.root { + -fx-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -fx-font-size: 13px; +} + +/* ========================================================================== + 1. TEMA CLARO (LIGHT THEME) + ========================================================================== */ + +.theme-light { + -fx-base: #f8fafc; + -fx-background: #f1f5f9; + -fx-control-inner-background: #ffffff; + -fx-text-fill: #0f172a; +} + +.theme-light.cyber-root, +.theme-light .cyber-root, +.theme-light.serpro-root, +.theme-light .serpro-root, +.theme-light.dialog-container, +.theme-light .dialog-container { + -fx-background-color: #f1f5f9; +} + +/* Header Claro */ +.theme-light .cyber-header-bar { + -fx-background-color: #ffffff; + -fx-border-color: #e2e8f0; + -fx-border-width: 0 0 1px 0; + -fx-padding: 12px 24px; +} + +.theme-light .cyber-brand-title { + -fx-font-size: 17px; + -fx-font-weight: bold; + -fx-text-fill: #0f172a; +} + +.theme-light .cyber-brand-subtitle { + -fx-font-size: 11px; + -fx-text-fill: #64748b; +} + +.theme-light .cyber-status-badge { + -fx-background-color: #ecfdf5; + -fx-border-color: #10b981; + -fx-border-radius: 14px; + -fx-border-width: 1px; + -fx-background-radius: 14px; + -fx-text-fill: #047857; + -fx-padding: 5px 12px; + -fx-font-size: 11.5px; + -fx-font-weight: bold; +} + +/* Botão Toggle de Tema */ +.theme-light .theme-toggle-btn { + -fx-background-color: #ffffff; + -fx-border-color: #cbd5e1; + -fx-border-radius: 18px; + -fx-border-width: 1px; + -fx-background-radius: 18px; + -fx-text-fill: #334155; + -fx-padding: 5px 12px; + -fx-font-size: 11.5px; + -fx-font-weight: bold; + -fx-cursor: hand; + -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.04), 4, 0, 0, 1); +} + +.theme-light .theme-toggle-btn:hover { + -fx-background-color: #f8fafc; + -fx-border-color: #0284c7; + -fx-text-fill: #0284c7; +} + +/* Cartões de Ação (Hub Central) - Modo Claro */ +.theme-light .cyber-action-card, +.theme-light .serpro-action-btn { + -fx-background-color: #ffffff; + -fx-border-color: #e2e8f0; + -fx-border-width: 1.2px; + -fx-border-radius: 8px; + -fx-background-radius: 8px; + -fx-padding: 6px 10px; + -fx-alignment: CENTER_LEFT; + -fx-cursor: hand; + -fx-pref-height: 68px; + -fx-min-height: 68px; + -fx-max-width: Infinity; + -fx-effect: dropshadow(three-pass-box, rgba(15, 23, 42, 0.04), 6, 0, 0, 2); +} + +.theme-light .cyber-action-card:hover, +.theme-light .serpro-action-btn:hover { + -fx-background-color: #ffffff; + -fx-border-color: #0284c7; + -fx-effect: dropshadow(three-pass-box, rgba(2, 132, 199, 0.16), 8, 0, 0, 3); +} + +.theme-light .cyber-action-card:pressed, +.theme-light .serpro-action-btn:pressed { + -fx-background-color: #f0f9ff; + -fx-border-color: #0369a1; +} + +.theme-light .cyber-action-text, +.theme-light .serpro-action-text { + -fx-font-size: 11.5px; + -fx-font-weight: bold; + -fx-text-fill: #1e293b; + -fx-line-spacing: 1px; +} + +/* Painel Lateral - Modo Claro */ +.theme-light .cyber-side-column { + -fx-background-color: #ffffff; + -fx-border-color: #e2e8f0; + -fx-border-width: 1px; + -fx-border-radius: 10px; + -fx-background-radius: 10px; + -fx-padding: 14px; + -fx-effect: dropshadow(three-pass-box, rgba(15, 23, 42, 0.03), 6, 0, 0, 2); +} + +.theme-light .cyber-side-card, +.theme-light .serpro-side-btn { + -fx-background-color: #f8fafc; + -fx-border-color: #e2e8f0; + -fx-border-width: 1px; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-padding: 8px 12px; + -fx-alignment: CENTER_LEFT; + -fx-cursor: hand; + -fx-pref-height: 42px; + -fx-min-height: 42px; + -fx-max-width: Infinity; +} + +.theme-light .cyber-side-card:hover, +.theme-light .serpro-side-btn:hover { + -fx-background-color: #f0f9ff; + -fx-border-color: #0284c7; + -fx-effect: dropshadow(three-pass-box, rgba(2, 132, 199, 0.12), 4, 0, 0, 1); +} + +.theme-light .cyber-side-card:pressed, +.theme-light .serpro-side-btn:pressed { + -fx-background-color: #e0f2fe; +} + +.theme-light .cyber-side-text, +.theme-light .serpro-side-text { + -fx-font-size: 12.5px; + -fx-font-weight: bold; + -fx-text-fill: #334155; +} + +.theme-light .cyber-version-label, +.theme-light .serpro-version-label { + -fx-font-size: 11px; + -fx-text-fill: #64748b; +} + +/* Diálogos e Caixas - Modo Claro */ +.theme-light .dialog-header, +.theme-light .dialog-title, +.theme-light .section-title { + -fx-font-size: 16px; + -fx-font-weight: bold; + -fx-text-fill: #0369a1; +} + +.theme-light .dialog-subtitle, +.theme-light .section-subtitle, +.theme-light .brand-subtitle { + -fx-font-size: 12px; + -fx-text-fill: #64748b; +} + +.theme-light .dialog-box, +.theme-light .content-card { + -fx-background-color: #ffffff; + -fx-background-radius: 8px; + -fx-border-color: #e2e8f0; + -fx-border-radius: 8px; + -fx-border-width: 1px; + -fx-padding: 16px; + -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.04), 6, 0, 0, 2); +} + +.theme-light .label { + -fx-text-fill: #1e293b; +} + +.theme-light .form-label { + -fx-font-size: 12px; + -fx-font-weight: bold; + -fx-text-fill: #334155; +} + +.theme-light .form-label-muted { + -fx-font-size: 11px; + -fx-text-fill: #64748b; +} + +.theme-light .dialog-field-title { + -fx-font-size: 12px; + -fx-font-weight: bold; + -fx-text-fill: #0369a1; +} + +.theme-light .dialog-field-value, +.theme-light .detail-value { + -fx-font-size: 13px; + -fx-text-fill: #0f172a; +} + +.theme-light .dialog-hash-value { + -fx-font-family: "Courier New", monospace; + -fx-font-size: 12px; + -fx-text-fill: #0369a1; + -fx-background-color: #f1f5f9; + -fx-border-color: #e2e8f0; + -fx-border-radius: 4px; + -fx-padding: 8px; + -fx-background-radius: 4px; +} + +.theme-light .log-console-area { + -fx-font-family: 'Consolas', 'Courier New', monospace; + -fx-font-size: 11.5px; + -fx-control-inner-background: #f8fafc; + -fx-text-fill: #0369a1; + -fx-border-color: #cbd5e1; + -fx-border-radius: 6px; + -fx-background-radius: 6px; +} + +.theme-light .seal-preview-box { + -fx-background-color: #f8fafc; + -fx-border-color: #cbd5e1; + -fx-border-radius: 8px; + -fx-background-radius: 8px; + -fx-padding: 12px; + -fx-alignment: CENTER; +} + + +/* Controles de Formulário - Modo Claro */ +.theme-light .text-field, +.theme-light .password-field { + -fx-background-color: #ffffff; + -fx-border-color: #cbd5e1; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-text-fill: #0f172a; + -fx-prompt-text-fill: #94a3b8; + -fx-padding: 7px 12px; +} + +.theme-light .text-field:focused, +.theme-light .password-field:focused { + -fx-border-color: #0284c7; + -fx-background-color: #ffffff; +} + +.theme-light .combo-box { + -fx-background-color: #ffffff; + -fx-border-color: #cbd5e1; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-padding: 3px 6px; +} + +.theme-light .combo-box .cell { + -fx-text-fill: #0f172a; +} + +.theme-light .combo-box-popup .list-view { + -fx-background-color: #ffffff; + -fx-border-color: #cbd5e1; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.08), 8, 0, 0, 3); +} + +.theme-light .combo-box-popup .list-cell { + -fx-text-fill: #0f172a; + -fx-padding: 7px 12px; +} + +.theme-light .combo-box-popup .list-cell:hover, +.theme-light .combo-box-popup .list-cell:filled:selected { + -fx-background-color: #e0f2fe; + -fx-text-fill: #0369a1; +} + +/* CheckBox - Modo Claro (CORRIGIDO: texto perfeitamente visível) */ +.theme-light .check-box { + -fx-text-fill: #1e293b; + -fx-font-size: 13px; +} + +.theme-light .check-box > .box { + -fx-background-color: #ffffff; + -fx-border-color: #cbd5e1; + -fx-border-radius: 4px; + -fx-background-radius: 4px; + -fx-border-width: 1.2px; + -fx-padding: 3px; +} + +.theme-light .check-box:selected > .box { + -fx-background-color: #0284c7; + -fx-border-color: #0284c7; +} + +.theme-light .check-box:selected > .box > .mark { + -fx-background-color: #ffffff; +} + +/* RadioButton - Modo Claro */ +.theme-light .radio-button { + -fx-text-fill: #1e293b; + -fx-font-size: 13px; +} + +.theme-light .radio-button > .radio { + -fx-background-color: #ffffff; + -fx-border-color: #cbd5e1; + -fx-border-radius: 100px; + -fx-background-radius: 100px; + -fx-border-width: 1.5px; + -fx-padding: 3px; + -fx-alignment: center; +} + +.theme-light .radio-button:selected > .radio { + -fx-border-color: #0284c7; +} + +.theme-light .radio-button:selected > .radio > .dot { + -fx-background-color: #0284c7; + -fx-background-radius: 100px; + -fx-padding: 4px; + -fx-background-insets: 0; +} + +/* ColorPicker - Modo Claro */ +.theme-light .color-picker { + -fx-background-color: #ffffff; + -fx-border-color: #cbd5e1; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-border-width: 1.2px; + -fx-padding: 3px 6px; + -fx-pref-height: 32px; + -fx-cursor: hand; +} + +.theme-light .color-picker:hover { + -fx-border-color: #0284c7; +} + +.theme-light .color-picker .color-picker-label { + -fx-text-fill: #1e293b; + -fx-font-weight: bold; + -fx-font-size: 11.5px; +} + +.theme-light .color-row { + -fx-alignment: CENTER_LEFT; + -fx-spacing: 12px; + -fx-padding: 3px 0; +} + +/* Tabela - Modo Claro */ +.theme-light .table-view { + -fx-background-color: #ffffff; + -fx-border-color: #cbd5e1; + -fx-border-radius: 6px; + -fx-background-radius: 6px; +} + +.theme-light .table-view .column-header-background { + -fx-background-color: #f8fafc; + -fx-border-color: #e2e8f0; + -fx-border-width: 0 0 1px 0; +} + +.theme-light .table-view .column-header .label { + -fx-text-fill: #334155; + -fx-font-weight: bold; + -fx-font-size: 12px; +} + +.theme-light .table-view .table-row-cell { + -fx-background-color: #ffffff; + -fx-border-color: transparent transparent #f1f5f9 transparent; + -fx-border-width: 0 0 1px 0; +} + +.theme-light .table-view .table-row-cell:odd { + -fx-background-color: #f8fafc; +} + +.theme-light .table-view .table-row-cell:hover { + -fx-background-color: #f0f9ff; +} + +.theme-light .table-view .table-row-cell:filled:selected { + -fx-background-color: #e0f2fe; +} + +.theme-light .table-view .table-cell { + -fx-text-fill: #0f172a; + -fx-padding: 8px 12px; +} + +.theme-light .table-view .placeholder .label { + -fx-text-fill: #64748b; +} + +/* ========================================================================== + 2. TEMA ESCURO (DARK THEME) + ========================================================================== */ + +.theme-dark { + -fx-base: #121824; + -fx-background: #0b0f17; + -fx-control-inner-background: #131c2e; + -fx-text-fill: #f1f5f9; +} + +.theme-dark.cyber-root, +.theme-dark .cyber-root, +.theme-dark.serpro-root, +.theme-dark .serpro-root, +.theme-dark.dialog-container, +.theme-dark .dialog-container { + -fx-background-color: #0b0f17; +} + +/* Header Escuro */ +.theme-dark .cyber-header-bar { + -fx-background-color: #121824; + -fx-border-color: #1e293b; + -fx-border-width: 0 0 1px 0; + -fx-padding: 12px 24px; +} + +.theme-dark .cyber-brand-title { + -fx-font-size: 17px; + -fx-font-weight: bold; + -fx-text-fill: #ffffff; +} + +.theme-dark .cyber-brand-subtitle { + -fx-font-size: 11px; + -fx-text-fill: #94a3b8; +} + +.theme-dark .cyber-status-badge { + -fx-background-color: rgba(16, 185, 129, 0.15); + -fx-border-color: #10b981; + -fx-border-radius: 14px; + -fx-border-width: 1px; + -fx-background-radius: 14px; + -fx-text-fill: #34d399; + -fx-padding: 5px 12px; + -fx-font-size: 11.5px; + -fx-font-weight: bold; +} + +/* Botão Toggle de Tema */ +.theme-dark .theme-toggle-btn { + -fx-background-color: #1e293b; + -fx-border-color: #334155; + -fx-border-radius: 18px; + -fx-border-width: 1px; + -fx-background-radius: 18px; + -fx-text-fill: #e2e8f0; + -fx-padding: 5px 12px; + -fx-font-size: 11.5px; + -fx-font-weight: bold; + -fx-cursor: hand; + -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.25), 4, 0, 0, 1); +} + +.theme-dark .theme-toggle-btn:hover { + -fx-background-color: #334155; + -fx-border-color: #38bdf8; + -fx-text-fill: #38bdf8; +} + +/* Cartões de Ação (Hub Central) - Modo Escuro */ +.theme-dark .cyber-action-card, +.theme-dark .serpro-action-btn { + -fx-background-color: #131c2e; + -fx-border-color: #1e293b; + -fx-border-width: 1.2px; + -fx-border-radius: 8px; + -fx-background-radius: 8px; + -fx-padding: 8px 12px; + -fx-alignment: CENTER_LEFT; + -fx-cursor: hand; + -fx-pref-height: 72px; + -fx-min-height: 72px; + -fx-max-width: Infinity; + -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.35), 8, 0, 0, 2); +} + +.theme-dark .cyber-action-card:hover, +.theme-dark .serpro-action-btn:hover { + -fx-background-color: #182338; + -fx-border-color: #38bdf8; + -fx-effect: dropshadow(three-pass-box, rgba(56, 189, 248, 0.2), 10, 0, 0, 3); +} + +.theme-dark .cyber-action-card:pressed, +.theme-dark .serpro-action-btn:pressed { + -fx-background-color: #0f172a; + -fx-border-color: #0284c7; +} + +.theme-dark .cyber-action-text, +.theme-dark .serpro-action-text { + -fx-font-size: 11.5px; + -fx-font-weight: bold; + -fx-text-fill: #f8fafc; + -fx-line-spacing: 1px; +} + +/* Painel Lateral - Modo Escuro */ +.theme-dark .cyber-side-column { + -fx-background-color: #121824; + -fx-border-color: #1e293b; + -fx-border-width: 1px; + -fx-border-radius: 10px; + -fx-background-radius: 10px; + -fx-padding: 14px; + -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.3), 8, 0, 0, 2); +} + +.theme-dark .cyber-side-card, +.theme-dark .serpro-side-btn { + -fx-background-color: #131c2e; + -fx-border-color: #1e293b; + -fx-border-width: 1px; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-padding: 8px 12px; + -fx-alignment: CENTER_LEFT; + -fx-cursor: hand; + -fx-pref-height: 42px; + -fx-min-height: 42px; + -fx-max-width: Infinity; +} + +.theme-dark .cyber-side-card:hover, +.theme-dark .serpro-side-btn:hover { + -fx-background-color: #1a253c; + -fx-border-color: #38bdf8; + -fx-effect: dropshadow(three-pass-box, rgba(56, 189, 248, 0.15), 4, 0, 0, 1); +} + +.theme-dark .cyber-side-card:pressed, +.theme-dark .serpro-side-btn:pressed { + -fx-background-color: #0f172a; +} + +.theme-dark .cyber-side-text, +.theme-dark .serpro-side-text { + -fx-font-size: 12.5px; + -fx-font-weight: bold; + -fx-text-fill: #cbd5e1; +} + +.theme-dark .cyber-version-label, +.theme-dark .serpro-version-label { + -fx-font-size: 11px; + -fx-text-fill: #94a3b8; +} + +/* Diálogos e Caixas - Modo Escuro */ +.theme-dark .dialog-header, +.theme-dark .dialog-title, +.theme-dark .section-title { + -fx-font-size: 16px; + -fx-font-weight: bold; + -fx-text-fill: #38bdf8; +} + +.theme-dark .dialog-subtitle, +.theme-dark .section-subtitle, +.theme-dark .brand-subtitle { + -fx-font-size: 12px; + -fx-text-fill: #94a3b8; +} + +.theme-dark .dialog-box, +.theme-dark .content-card { + -fx-background-color: #131c2e; + -fx-background-radius: 8px; + -fx-border-color: #1e293b; + -fx-border-radius: 8px; + -fx-border-width: 1px; + -fx-padding: 16px; + -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.35), 8, 0, 0, 2); +} + +.theme-dark .label { + -fx-text-fill: #f1f5f9; +} + +.theme-dark .form-label { + -fx-font-size: 12px; + -fx-font-weight: bold; + -fx-text-fill: #cbd5e1; +} + +.theme-dark .form-label-muted { + -fx-font-size: 11px; + -fx-text-fill: #94a3b8; +} + +.theme-dark .dialog-field-title { + -fx-font-size: 12px; + -fx-font-weight: bold; + -fx-text-fill: #38bdf8; +} + +.theme-dark .dialog-field-value, +.theme-dark .detail-value { + -fx-font-size: 13px; + -fx-text-fill: #f1f5f9; +} + +.theme-dark .dialog-hash-value { + -fx-font-family: "Courier New", monospace; + -fx-font-size: 12px; + -fx-text-fill: #38bdf8; + -fx-background-color: #090d16; + -fx-border-color: #1e293b; + -fx-border-radius: 4px; + -fx-padding: 8px; + -fx-background-radius: 4px; +} + +.theme-dark .log-console-area { + -fx-font-family: 'Consolas', 'Courier New', monospace; + -fx-font-size: 11.5px; + -fx-control-inner-background: #0b1329; + -fx-text-fill: #38bdf8; + -fx-border-color: #334155; + -fx-border-radius: 6px; + -fx-background-radius: 6px; +} + +.theme-dark .seal-preview-box { + -fx-background-color: #0b1329; + -fx-border-color: #334155; + -fx-border-radius: 8px; + -fx-background-radius: 8px; + -fx-padding: 12px; + -fx-alignment: CENTER; +} + +/* Controles de Formulário - Modo Escuro */ +.theme-dark .text-field, +.theme-dark .password-field { + -fx-background-color: #0f172a; + -fx-border-color: #334155; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-text-fill: #f1f5f9; + -fx-prompt-text-fill: #64748b; + -fx-padding: 7px 12px; +} + +.theme-dark .text-field:focused, +.theme-dark .password-field:focused { + -fx-border-color: #38bdf8; + -fx-background-color: #0f172a; +} + +.theme-dark .combo-box { + -fx-background-color: #131c2e; + -fx-border-color: #334155; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-padding: 3px 6px; +} + +.theme-dark .combo-box .cell { + -fx-text-fill: #f1f5f9; +} + +.theme-dark .combo-box-popup .list-view { + -fx-background-color: #131c2e; + -fx-border-color: #334155; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.4), 8, 0, 0, 3); +} + +.theme-dark .combo-box-popup .list-cell { + -fx-text-fill: #f1f5f9; + -fx-padding: 7px 12px; +} + +.theme-dark .combo-box-popup .list-cell:hover, +.theme-dark .combo-box-popup .list-cell:filled:selected { + -fx-background-color: #1e293b; + -fx-text-fill: #38bdf8; +} + +/* CheckBox - Modo Escuro */ +.theme-dark .check-box { + -fx-text-fill: #f1f5f9; + -fx-font-size: 13px; +} + +.theme-dark .check-box > .box { + -fx-background-color: #0f172a; + -fx-border-color: #475569; + -fx-border-radius: 4px; + -fx-background-radius: 4px; + -fx-border-width: 1.2px; + -fx-padding: 3px; +} + +.theme-dark .check-box:selected > .box { + -fx-background-color: #0284c7; + -fx-border-color: #38bdf8; +} + +.theme-dark .check-box:selected > .box > .mark { + -fx-background-color: #ffffff; +} + +/* RadioButton - Modo Escuro */ +.theme-dark .radio-button { + -fx-text-fill: #f1f5f9; + -fx-font-size: 13px; +} + +.theme-dark .radio-button > .radio { + -fx-background-color: #0f172a; + -fx-border-color: #475569; + -fx-border-radius: 100px; + -fx-background-radius: 100px; + -fx-border-width: 1.5px; + -fx-padding: 3px; + -fx-alignment: center; +} + +.theme-dark .radio-button:selected > .radio { + -fx-border-color: #38bdf8; +} + +.theme-dark .radio-button:selected > .radio > .dot { + -fx-background-color: #38bdf8; + -fx-background-radius: 100px; + -fx-padding: 4px; + -fx-background-insets: 0; +} + +/* ColorPicker - Modo Escuro */ +.theme-dark .color-picker { + -fx-background-color: #0f172a; + -fx-border-color: #334155; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-border-width: 1.2px; + -fx-padding: 3px 6px; + -fx-pref-height: 32px; + -fx-cursor: hand; +} + +.theme-dark .color-picker:hover { + -fx-border-color: #38bdf8; +} + +.theme-dark .color-picker .color-picker-label { + -fx-text-fill: #f1f5f9; + -fx-font-weight: bold; + -fx-font-size: 11.5px; +} + +.theme-dark .color-row { + -fx-alignment: CENTER_LEFT; + -fx-spacing: 12px; + -fx-padding: 3px 0; +} + +/* Tabela - Modo Escuro (CORRIGIDO: fundo escuro e textos contrastados) */ +.theme-dark .table-view { + -fx-background-color: #0f172a; + -fx-border-color: #1e293b; + -fx-border-radius: 6px; + -fx-background-radius: 6px; +} + +.theme-dark .table-view .column-header-background { + -fx-background-color: #131c2e; + -fx-border-color: #1e293b; + -fx-border-width: 0 0 1px 0; +} + +.theme-dark .table-view .column-header .label { + -fx-text-fill: #cbd5e1; + -fx-font-weight: bold; + -fx-font-size: 12px; +} + +.theme-dark .table-view .table-row-cell { + -fx-background-color: #0f172a; + -fx-border-color: transparent transparent #1e293b transparent; + -fx-border-width: 0 0 1px 0; +} + +.theme-dark .table-view .table-row-cell:odd { + -fx-background-color: #131c2e; +} + +.theme-dark .table-view .table-row-cell:hover { + -fx-background-color: #1e293b; +} + +.theme-dark .table-view .table-row-cell:filled:selected { + -fx-background-color: #1e293b; +} + +.theme-dark .table-view .table-cell { + -fx-text-fill: #f1f5f9; + -fx-padding: 8px 12px; +} + +.theme-dark .table-view .placeholder .label { + -fx-text-fill: #64748b; +} + +/* ========================================================================== + 3. BOTÕES E COMPONENTES COMUNS (DUAL THEME) + ========================================================================== */ + +.btn-primary { + -fx-background-color: #0284c7; + -fx-text-fill: #ffffff; + -fx-font-weight: bold; + -fx-padding: 8px 18px; + -fx-background-radius: 6px; + -fx-cursor: hand; + -fx-effect: dropshadow(three-pass-box, rgba(2, 132, 199, 0.25), 4, 0, 0, 1); +} + +.btn-primary:hover { + -fx-background-color: #0369a1; +} + +.btn-secondary { + -fx-background-color: #e2e8f0; + -fx-text-fill: #1e293b; + -fx-font-weight: bold; + -fx-padding: 8px 18px; + -fx-background-radius: 6px; + -fx-cursor: hand; +} + +.btn-secondary:hover { + -fx-background-color: #cbd5e1; +} + +.theme-dark .btn-secondary { + -fx-background-color: #1e293b; + -fx-border-color: #334155; + -fx-border-radius: 6px; + -fx-border-width: 1px; + -fx-text-fill: #e2e8f0; +} + +.theme-dark .btn-secondary:hover { + -fx-background-color: #334155; + -fx-border-color: #38bdf8; + -fx-text-fill: #38bdf8; +} + +.btn-cancel { + -fx-background-color: transparent; + -fx-border-color: #94a3b8; + -fx-border-radius: 6px; + -fx-text-fill: #475569; + -fx-font-weight: bold; + -fx-padding: 8px 18px; + -fx-cursor: hand; +} + +.btn-cancel:hover { + -fx-border-color: #ef4444; + -fx-text-fill: #ef4444; +} + +.theme-dark .btn-cancel { + -fx-border-color: #475569; + -fx-text-fill: #94a3b8; +} + +.theme-dark .btn-cancel:hover { + -fx-border-color: #f87171; + -fx-text-fill: #f87171; +} + +/* Botão de Perigo / Exclusão / Remoção */ +.btn-danger { + -fx-background-color: #fee2e2; + -fx-text-fill: #b91c1c; + -fx-border-color: #fca5a5; + -fx-border-radius: 6px; + -fx-background-radius: 6px; + -fx-border-width: 1px; + -fx-font-size: 12px; + -fx-font-weight: bold; + -fx-padding: 6px 14px; + -fx-alignment: center; + -fx-cursor: hand; +} + +.btn-danger:hover { + -fx-background-color: #ef4444; + -fx-text-fill: #ffffff; + -fx-border-color: #dc2626; +} + +.theme-dark .btn-danger { + -fx-background-color: rgba(239, 68, 68, 0.2); + -fx-text-fill: #f87171; + -fx-border-color: #dc2626; + -fx-alignment: center; +} + +.theme-dark .btn-danger:hover { + -fx-background-color: #ef4444; + -fx-text-fill: #ffffff; + -fx-border-color: #ef4444; +} + +/* Badges de Certificados */ +.cert-card { + -fx-background-color: #ffffff; + -fx-border-color: #e2e8f0; + -fx-border-width: 1px; + -fx-border-radius: 8px; + -fx-background-radius: 8px; + -fx-padding: 12px 16px; + -fx-effect: dropshadow(three-pass-box, rgba(15, 23, 42, 0.04), 4, 0, 0, 1); +} + +.theme-dark .cert-card { + -fx-background-color: #131c2e; + -fx-border-color: #1e293b; + -fx-effect: dropshadow(three-pass-box, rgba(0, 0, 0, 0.3), 6, 0, 0, 2); +} + +.cert-card:hover { + -fx-border-color: #0284c7; +} + +.theme-dark .cert-card:hover { + -fx-border-color: #38bdf8; +} + +.cert-title { + -fx-font-size: 13.5px; + -fx-font-weight: bold; + -fx-text-fill: #0f172a; +} + +.theme-dark .cert-title { + -fx-text-fill: #f8fafc; +} + +.cert-detail { + -fx-font-size: 11.5px; + -fx-text-fill: #64748b; +} + +.theme-dark .cert-detail { + -fx-text-fill: #94a3b8; +} + +.cert-badge-a1 { + -fx-background-color: rgba(2, 132, 199, 0.12); + -fx-border-color: #0284c7; + -fx-border-radius: 4px; + -fx-border-width: 1px; + -fx-background-radius: 4px; + -fx-text-fill: #0284c7; + -fx-padding: 2px 8px; + -fx-font-size: 11px; + -fx-font-weight: bold; +} + +.cert-badge-a3 { + -fx-background-color: rgba(16, 185, 129, 0.12); + -fx-border-color: #10b981; + -fx-border-radius: 4px; + -fx-border-width: 1px; + -fx-background-radius: 4px; + -fx-text-fill: #10b981; + -fx-padding: 2px 8px; + -fx-font-size: 11px; + -fx-font-weight: bold; +} + +.cert-status-available { + -fx-text-fill: #10b981; + -fx-font-weight: bold; + -fx-font-size: 11.5px; +} + +.cert-status-expired { + -fx-text-fill: #ef4444; + -fx-font-weight: bold; + -fx-font-size: 11.5px; +} + +/* Painel de Pré-Visualização de Selos */ +.seal-preview-box { + -fx-background-color: #f8fafc; + -fx-border-color: #cbd5e1; + -fx-border-radius: 8px; + -fx-background-radius: 8px; + -fx-border-style: dashed; + -fx-padding: 14px; + -fx-alignment: center; +} + +.theme-dark .seal-preview-box { + -fx-background-color: #090d16; + -fx-border-color: #334155; +} + +/* ScrollBar & Progress */ +.progress-bar > .track { + -fx-background-color: #e2e8f0; + -fx-background-radius: 4px; +} + +.theme-dark .progress-bar > .track { + -fx-background-color: #1e293b; +} + +.progress-bar > .bar { + -fx-background-color: #0284c7; + -fx-background-radius: 4px; +} diff --git a/modules/cybercore-ui/src/test/java/br/com/cybercore/signer/ui/CyberCoreUiConstantsTest.java b/modules/cybercore-ui/src/test/java/br/com/cybercore/signer/ui/CyberCoreUiConstantsTest.java new file mode 100644 index 0000000..267e50b --- /dev/null +++ b/modules/cybercore-ui/src/test/java/br/com/cybercore/signer/ui/CyberCoreUiConstantsTest.java @@ -0,0 +1,22 @@ +package br.com.cybercore.signer.ui; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; + +class CyberCoreUiConstantsTest { + + @Test + @DisplayName("Deve conter constantes de UI válidas e carregar folha de estilos CSS") + void shouldHaveValidUiConstants() { + assertThat(CyberCoreUiConstants.APP_NAME).isEqualTo("CyberCore Signer"); + assertThat(CyberCoreUiConstants.APP_VERSION).isNotBlank(); + assertThat(CyberCoreUiConstants.MAIN_CSS_PATH).isEqualTo("/styles/theme.css"); + + URL cssResource = getClass().getResource(CyberCoreUiConstants.MAIN_CSS_PATH); + assertThat(cssResource).isNotNull(); + } +} diff --git a/modules/cybercore-ui/src/test/java/br/com/cybercore/signer/ui/UiPreviewGenerator.java b/modules/cybercore-ui/src/test/java/br/com/cybercore/signer/ui/UiPreviewGenerator.java new file mode 100644 index 0000000..ae066c6 --- /dev/null +++ b/modules/cybercore-ui/src/test/java/br/com/cybercore/signer/ui/UiPreviewGenerator.java @@ -0,0 +1,73 @@ +package br.com.cybercore.signer.ui; + +import br.com.cybercore.signer.ui.theme.ThemeManager; +import br.com.cybercore.signer.ui.view.MainAppView; +import javafx.application.Application; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.image.PixelReader; +import javafx.scene.image.WritableImage; +import javafx.stage.Stage; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; + +public class UiPreviewGenerator extends Application { + + public static void main(String[] args) { + Application.launch(UiPreviewGenerator.class, args); + } + + @Override + public void start(Stage primaryStage) { + // 1. Inicia com Tema Claro + ThemeManager.setCurrentTheme(ThemeManager.AppTheme.LIGHT); + + MainAppView appView = new MainAppView(primaryStage); + appView.show(); + + Platform.runLater(() -> { + try { + File outputDir = new File("docs/screenshots"); + outputDir.mkdirs(); + + // Snapshot Modo Claro + saveNodeSnapshot(primaryStage.getScene(), new File(outputDir, "01_layout_claro.png")); + + // 2. Alterna para Tema Escuro + ThemeManager.setCurrentTheme(ThemeManager.AppTheme.DARK); + ThemeManager.applyTheme(primaryStage.getScene()); + + Platform.runLater(() -> { + try { + // Snapshot Modo Escuro + saveNodeSnapshot(primaryStage.getScene(), new File(outputDir, "02_layout_escuro.png")); + } catch (Exception e) { + e.printStackTrace(); + } finally { + Platform.exit(); + } + }); + + } catch (Exception e) { + e.printStackTrace(); + Platform.exit(); + } + }); + } + + private static void saveNodeSnapshot(Scene scene, File targetFile) throws Exception { + WritableImage image = scene.snapshot(null); + int w = (int) image.getWidth(); + int h = (int) image.getHeight(); + BufferedImage bufferedImage = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + PixelReader reader = image.getPixelReader(); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + bufferedImage.setRGB(x, y, reader.getArgb(x, y)); + } + } + ImageIO.write(bufferedImage, "png", targetFile); + } +} diff --git a/modules/cybercore-ui/src/test/java/br/com/cybercore/signer/ui/UiPreviewLauncher.java b/modules/cybercore-ui/src/test/java/br/com/cybercore/signer/ui/UiPreviewLauncher.java new file mode 100644 index 0000000..66c1fc1 --- /dev/null +++ b/modules/cybercore-ui/src/test/java/br/com/cybercore/signer/ui/UiPreviewLauncher.java @@ -0,0 +1,9 @@ +package br.com.cybercore.signer.ui; + +import javafx.application.Application; + +public class UiPreviewLauncher { + public static void main(String[] args) { + Application.launch(UiPreviewGenerator.class, args); + } +} diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..74943b7 --- /dev/null +++ b/pom.xml @@ -0,0 +1,400 @@ + + + 4.0.0 + + br.com.cybercore.signer + cybercore-signer-parent + 0.1.0-SNAPSHOT + pom + + CyberCore Signer + Agente desktop de assinatura digital (A1/A3) para aplicações web autorizadas. + https://cybercore.com.br + 2026 + + + CyberCore + https://cybercore.com.br + + + + + Proprietária - CyberCore. Todos os direitos reservados. + manual + + + + + modules/cybercore-core + modules/cybercore-ui + modules/cybercore-app + + + + + 25 + ${java.release} + UTF-8 + UTF-8 + + 2026-10-08T00:00:00Z + + + CyberCore Signer + 1 + + production + + + 25.0.4 + 2.0.20 + 1.5.18 + 6.1.3 + 3.27.7 + 5.24.0 + 1.5.1 + 2.18.2 + 3.0.4 + 1.80 + + + 3.5.0 + 3.5.0 + 3.15.0 + 3.5.6 + 3.5.1 + 3.2.0 + 3.2.0 + 3.22.0 + 3.6.3 + 3.11.0 + 3.3.4 + 0.8.15 + 4.10.4.1 + 1.14.0 + 0.0.8 + + + false + false + + + + + + + br.com.cybercore.signer + cybercore-core + ${project.version} + + + br.com.cybercore.signer + cybercore-ui + ${project.version} + + + + + org.openjfx + javafx-base + ${javafx.version} + + + org.openjfx + javafx-graphics + ${javafx.version} + + + org.openjfx + javafx-controls + ${javafx.version} + + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + ch.qos.logback + logback-classic + ${logback.version} + + + ch.qos.logback + logback-core + ${logback.version} + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + org.assertj + assertj-core + ${assertj.version} + + + org.mockito + mockito-core + ${mockito.version} + + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + + + org.apache.pdfbox + pdfbox + ${pdfbox.version} + + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcutil-jdk18on + ${bouncycastle.version} + + + + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + + org.apache.maven.plugins + maven-clean-plugin + ${maven-clean-plugin.version} + + + org.apache.maven.plugins + maven-resources-plugin + ${maven-resources-plugin.version} + + UTF-8 + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${java.release} + true + true + + -Xlint:all,-processing,-requires-automatic + -parameters + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + @{argLine} -Dfile.encoding=UTF-8 + false + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + true + + + CyberCore + + + + + + org.apache.maven.plugins + maven-install-plugin + ${maven-install-plugin.version} + + + org.apache.maven.plugins + maven-deploy-plugin + ${maven-deploy-plugin.version} + + + org.apache.maven.plugins + maven-site-plugin + ${maven-site-plugin.version} + + + org.apache.maven.plugins + maven-dependency-plugin + ${maven-dependency-plugin.version} + + + org.apache.maven.plugins + maven-wrapper-plugin + ${maven-wrapper-plugin.version} + + + org.apache.maven.plugins + maven-enforcer-plugin + ${maven-enforcer-plugin.version} + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + Max + Medium + true + ${maven.multiModuleProjectDirectory}/config/spotbugs/exclude.xml + + + com.h3xstream.findsecbugs + findsecbugs-plugin + ${findsecbugs-plugin.version} + + + + + + org.openjfx + javafx-maven-plugin + ${javafx-maven-plugin.version} + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-build-environment + + enforce + + + + + [25,) + CyberCore Signer exige JDK 25 LTS ou superior. + + + [3.9.0,) + + + + + + + + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + + spotbugs-check + verify + + check + + + + + + + + + + + dev + + development + + + + + fast + + true + true + + + + diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..3fa10df --- /dev/null +++ b/run.bat @@ -0,0 +1,18 @@ +@echo off +REM ============================================================================== +REM CyberCore Signer — Inicializador Rápido Desktop para Windows +REM ============================================================================== + +set SCRIPT_DIR=%~dp0 +cd /d "%SCRIPT_DIR%" + +if exist "cybercore-signer.jar" ( + echo Iniciando CyberCore Signer... + start javaw -jar cybercore-signer.jar %* +) else if exist "dist\cybercore-signer.jar" ( + echo Iniciando CyberCore Signer... + start javaw -jar dist\cybercore-signer.jar %* +) else ( + echo ERRO: Arquivo cybercore-signer.jar nao encontrado! + pause +) diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..8a62bd0 --- /dev/null +++ b/run.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# ============================================================================== +# CyberCore Signer — Inicializador Rápido Desktop +# ============================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +if [ -f "dist/CyberCore-Signer/bin/CyberCore-Signer" ]; then + echo "==> Iniciando CyberCore Signer via binário nativo autocontido..." + exec "dist/CyberCore-Signer/bin/CyberCore-Signer" "$@" +elif [ -f "cybercore-signer.jar" ]; then + echo "==> Iniciando CyberCore Signer via JAR executável..." + if [ -n "${JAVA_HOME:-}" ]; then + exec "${JAVA_HOME}/bin/java" -jar cybercore-signer.jar "$@" + else + exec java -jar cybercore-signer.jar "$@" + fi +else + echo "ERRO: Pacote executável não encontrado. Execute primeiro: bash scripts/package-jpackage.sh" + exit 1 +fi diff --git a/scratch/GeneratePngAssets.java b/scratch/GeneratePngAssets.java new file mode 100644 index 0000000..abcc4b3 --- /dev/null +++ b/scratch/GeneratePngAssets.java @@ -0,0 +1,348 @@ +import java.awt.*; +import java.awt.geom.*; +import java.awt.image.BufferedImage; +import java.io.File; +import javax.imageio.ImageIO; + +public class GeneratePngAssets { + + public static void main(String[] args) throws Exception { + File outDir = new File("assets/images"); + outDir.mkdirs(); + + generateAppIcon(new File(outDir, "app-icon.png"), 256); + generateAppLogo(new File(outDir, "app-logo.png"), 480, 120); + generateBrandBanner(new File(outDir, "brand-banner.png"), 600, 160); + generateDefaultSeal(new File(outDir, "default-seal.png"), 640, 164); + + System.out.println("Imagens PNG geradas com sucesso em assets/images/"); + } + + private static void setupHints(Graphics2D g2) { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + } + + /** + * Gera app-icon.png (Ícone de alta fidelidade com Cristal / Diamante Azul Serpro 3D) + */ + private static void generateAppIcon(File file, int size) throws Exception { + BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2 = img.createGraphics(); + setupHints(g2); + + // Fundo com cantos arredondados e gradiente azul profundo + double arc = size * 0.28; + RoundRectangle2D bg = new RoundRectangle2D.Double(8, 8, size - 16, size - 16, arc, arc); + + Paint bgPaint = new LinearGradientPaint( + 0, 0, size, size, + new float[]{0.0f, 0.45f, 1.0f}, + new Color[]{new Color(0x1976d2), new Color(0x0d47a1), new Color(0x01579b)} + ); + g2.setPaint(bgPaint); + g2.fill(bg); + + // Borda sutil de brilho interno + g2.setColor(new Color(255, 255, 255, 60)); + g2.setStroke(new BasicStroke(size * 0.015f)); + g2.draw(new RoundRectangle2D.Double(10, 10, size - 20, size - 20, arc - 4, arc - 4)); + + // Facetas cristalinas em 3D + double s = size; + Path2D topFacet = new Path2D.Double(); + topFacet.moveTo(s * 0.50, s * 0.18); + topFacet.lineTo(s * 0.82, s * 0.44); + topFacet.lineTo(s * 0.50, s * 0.56); + topFacet.lineTo(s * 0.18, s * 0.44); + topFacet.closePath(); + + Paint topPaint = new LinearGradientPaint( + (float)(s * 0.5), (float)(s * 0.18), (float)(s * 0.5), (float)(s * 0.56), + new float[]{0.0f, 1.0f}, + new Color[]{new Color(255, 255, 255, 230), new Color(128, 216, 255, 140)} + ); + g2.setPaint(topPaint); + g2.fill(topFacet); + + Path2D leftFacet = new Path2D.Double(); + leftFacet.moveTo(s * 0.18, s * 0.44); + leftFacet.lineTo(s * 0.50, s * 0.56); + leftFacet.lineTo(s * 0.50, s * 0.84); + leftFacet.lineTo(s * 0.28, s * 0.70); + leftFacet.closePath(); + + Paint leftPaint = new LinearGradientPaint( + (float)(s * 0.18), (float)(s * 0.44), (float)(s * 0.50), (float)(s * 0.84), + new float[]{0.0f, 1.0f}, + new Color[]{new Color(64, 196, 255, 180), new Color(2, 136, 209, 90)} + ); + g2.setPaint(leftPaint); + g2.fill(leftFacet); + + Path2D rightFacet = new Path2D.Double(); + rightFacet.moveTo(s * 0.82, s * 0.44); + rightFacet.lineTo(s * 0.50, s * 0.56); + rightFacet.lineTo(s * 0.50, s * 0.84); + rightFacet.lineTo(s * 0.72, s * 0.70); + rightFacet.closePath(); + + Paint rightPaint = new LinearGradientPaint( + (float)(s * 0.82), (float)(s * 0.44), (float)(s * 0.50), (float)(s * 0.84), + new float[]{0.0f, 1.0f}, + new Color[]{new Color(225, 245, 254, 210), new Color(41, 182, 246, 120)} + ); + g2.setPaint(rightPaint); + g2.fill(rightFacet); + + // Brilho estelar central + RadialGradientPaint glow = new RadialGradientPaint( + (float)(s * 0.5), (float)(s * 0.5), (float)(s * 0.16), + new float[]{0.0f, 1.0f}, + new Color[]{new Color(255, 255, 255, 240), new Color(255, 255, 255, 0)} + ); + g2.setPaint(glow); + g2.fill(new Ellipse2D.Double(s * 0.34, s * 0.34, s * 0.32, s * 0.32)); + + g2.dispose(); + ImageIO.write(img, "PNG", file); + } + + /** + * Gera app-logo.png (Logotipo Horizontal com Cristal + Texto CyberCore SIGNER) + */ + private static void generateAppLogo(File file, int width, int height) throws Exception { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2 = img.createGraphics(); + setupHints(g2); + + // Ícone cristal no canto esquerdo + int iconSize = 88; + int iconX = 14; + int iconY = 16; + + RoundRectangle2D bg = new RoundRectangle2D.Double(iconX, iconY, iconSize, iconSize, 24, 24); + Paint bgPaint = new LinearGradientPaint( + iconX, iconY, iconX + iconSize, iconY + iconSize, + new float[]{0.0f, 0.5f, 1.0f}, + new Color[]{new Color(0x1976d2), new Color(0x0d47a1), new Color(0x01579b)} + ); + g2.setPaint(bgPaint); + g2.fill(bg); + + // Cristal no ícone + double cx = iconX + iconSize * 0.5; + double cy = iconY + iconSize * 0.5; + double sz = iconSize * 0.72; + + Path2D topFacet = new Path2D.Double(); + topFacet.moveTo(cx, cy - sz * 0.45); + topFacet.lineTo(cx + sz * 0.45, cy - sz * 0.08); + topFacet.lineTo(cx, cy + sz * 0.1); + topFacet.lineTo(cx - sz * 0.45, cy - sz * 0.08); + topFacet.closePath(); + g2.setPaint(new Color(255, 255, 255, 220)); + g2.fill(topFacet); + + Path2D botFacet = new Path2D.Double(); + botFacet.moveTo(cx - sz * 0.45, cy - sz * 0.08); + botFacet.lineTo(cx, cy + sz * 0.1); + botFacet.lineTo(cx, cy + sz * 0.50); + botFacet.lineTo(cx - sz * 0.30, cy + sz * 0.30); + botFacet.closePath(); + g2.setPaint(new Color(64, 196, 255, 180)); + g2.fill(botFacet); + + Path2D rightFacet = new Path2D.Double(); + rightFacet.moveTo(cx + sz * 0.45, cy - sz * 0.08); + rightFacet.lineTo(cx, cy + sz * 0.1); + rightFacet.lineTo(cx, cy + sz * 0.50); + rightFacet.lineTo(cx + sz * 0.30, cy + sz * 0.30); + rightFacet.closePath(); + g2.setPaint(new Color(225, 245, 254, 220)); + g2.fill(rightFacet); + + // Tipografia + int textX = iconX + iconSize + 22; + + // "CyberCore" + g2.setFont(new Font("Segoe UI", Font.BOLD, 42)); + g2.setColor(new Color(0x0a2540)); + g2.drawString("Cyber", textX, 62); + int cyberWidth = g2.getFontMetrics().stringWidth("Cyber"); + g2.setColor(new Color(0x0288d1)); + g2.drawString("Core", textX + cyberWidth, 62); + + // Badge "SIGNER" + int badgeX = textX; + int badgeY = 74; + int badgeW = 100; + int badgeH = 24; + g2.setColor(new Color(0x0288d1)); + g2.fill(new RoundRectangle2D.Double(badgeX, badgeY, badgeW, badgeH, 6, 6)); + + g2.setFont(new Font("Segoe UI", Font.BOLD, 14)); + g2.setColor(Color.WHITE); + g2.drawString("SIGNER", badgeX + 22, badgeY + 17); + + // Subtítulo + g2.setFont(new Font("Segoe UI", Font.PLAIN, 13)); + g2.setColor(new Color(0x64748b)); + g2.drawString("ICP-Brasil • Assinador Digital Seguro", badgeX + badgeW + 12, badgeY + 17); + + g2.dispose(); + ImageIO.write(img, "PNG", file); + } + + /** + * Gera brand-banner.png (Banner institucional de cabeçalho e documentação) + */ + private static void generateBrandBanner(File file, int width, int height) throws Exception { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2 = img.createGraphics(); + setupHints(g2); + + // Fundo com cantos arredondados e gradiente moderno Serpro Blue + RoundRectangle2D rect = new RoundRectangle2D.Double(0, 0, width, height, 16, 16); + Paint grad = new LinearGradientPaint( + 0, 0, width, height, + new float[]{0.0f, 0.6f, 1.0f}, + new Color[]{new Color(0x0d47a1), new Color(0x0284c7), new Color(0x0369a1)} + ); + g2.setPaint(grad); + g2.fill(rect); + + // Elementos gráficos decorativos de fundo + g2.setColor(new Color(255, 255, 255, 25)); + g2.fill(new Ellipse2D.Double(width - 180, -40, 220, 220)); + g2.fill(new Ellipse2D.Double(width - 90, 60, 160, 160)); + + // Cristal no canto esquerdo + int iconSize = 90; + int iconX = 30; + int iconY = 35; + + RoundRectangle2D iconBg = new RoundRectangle2D.Double(iconX, iconY, iconSize, iconSize, 22, 22); + g2.setPaint(new LinearGradientPaint( + iconX, iconY, iconX + iconSize, iconY + iconSize, + new float[]{0.0f, 1.0f}, + new Color[]{new Color(0x1e88e5), new Color(0x01579b)} + )); + g2.fill(iconBg); + + // Detalhe de luz no ícone + g2.setColor(Color.WHITE); + g2.setStroke(new BasicStroke(2f)); + g2.draw(new RoundRectangle2D.Double(iconX + 3, iconY + 3, iconSize - 6, iconSize - 6, 18, 18)); + + // Diamante interno + double cx = iconX + iconSize * 0.5; + double cy = iconY + iconSize * 0.5; + Path2D diamond = new Path2D.Double(); + diamond.moveTo(cx, cy - 28); + diamond.lineTo(cx + 26, cy); + diamond.lineTo(cx, cy + 28); + diamond.lineTo(cx - 26, cy); + diamond.closePath(); + g2.setPaint(new Color(255, 255, 255, 220)); + g2.fill(diamond); + + // Textos no Banner + int textX = iconX + iconSize + 28; + g2.setFont(new Font("Segoe UI", Font.BOLD, 36)); + g2.setColor(Color.WHITE); + g2.drawString("CyberCore Signer", textX, 74); + + g2.setFont(new Font("Segoe UI", Font.PLAIN, 16)); + g2.setColor(new Color(0xe0f2fe)); + g2.drawString("Agente de Assinatura Digital ICP-Brasil A1 & A3", textX, 104); + + g2.setFont(new Font("Segoe UI", Font.BOLD, 12)); + g2.setColor(new Color(0x38bdf8)); + g2.drawString("PADES • XADES • CADES • TIMESTAMPS • CCSP/1", textX, 128); + + g2.dispose(); + ImageIO.write(img, "PNG", file); + } + + /** + * Gera default-seal.png (Selo de Assinatura Digital Elegante com Brasão e Textos) + */ + private static void generateDefaultSeal(File file, int width, int height) throws Exception { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2 = img.createGraphics(); + setupHints(g2); + + // Fundo do selo com leve transparência e borda azul + RoundRectangle2D sealBox = new RoundRectangle2D.Double(2, 2, width - 4, height - 4, 12, 12); + g2.setColor(new Color(248, 250, 252, 245)); + g2.fill(sealBox); + + g2.setColor(new Color(0x0288d1)); + g2.setStroke(new BasicStroke(2.5f)); + g2.draw(sealBox); + + // Brasão Notarial no lado esquerdo + int badgeX = 18; + int badgeY = 16; + int badgeW = 130; + int badgeH = height - 32; + + RoundRectangle2D badgeBg = new RoundRectangle2D.Double(badgeX, badgeY, badgeW, badgeH, 10, 10); + g2.setPaint(new LinearGradientPaint( + badgeX, badgeY, badgeX, badgeY + badgeH, + new float[]{0.0f, 1.0f}, + new Color[]{new Color(0x0288d1), new Color(0x01579b)} + )); + g2.fill(badgeBg); + + // Escudo e Estrela no Brasão + g2.setColor(Color.WHITE); + g2.setFont(new Font("Segoe UI", Font.BOLD, 13)); + g2.drawString("ASSINADO", badgeX + 28, badgeY + 40); + g2.drawString("DIGITALMENTE", badgeX + 16, badgeY + 58); + + // Estrela dourada + g2.setColor(new Color(0xfbbf24)); + g2.fill(new Ellipse2D.Double(badgeX + badgeW * 0.5 - 12, badgeY + 74, 24, 24)); + g2.setColor(Color.WHITE); + g2.setFont(new Font("Segoe UI", Font.BOLD, 16)); + g2.drawString("★", (int)(badgeX + badgeW * 0.5 - 7), badgeY + 92); + + g2.setFont(new Font("Segoe UI", Font.PLAIN, 10)); + g2.setColor(new Color(0xe0f2fe)); + g2.drawString("ICP-BRASIL", badgeX + 34, badgeY + 118); + + // Linha divisória vertical + int divX = badgeX + badgeW + 18; + g2.setColor(new Color(0xcfd8dc)); + g2.setStroke(new BasicStroke(1.5f)); + g2.drawLine(divX, 16, divX, height - 16); + + // Área textual de informações da assinatura + int textX = divX + 18; + + g2.setColor(new Color(0x64748b)); + g2.setFont(new Font("Segoe UI", Font.BOLD, 11)); + g2.drawString("DOCUMENTO ASSINADO DIGITALMENTE POR:", textX, 36); + + g2.setColor(new Color(0x0a2540)); + g2.setFont(new Font("Segoe UI", Font.BOLD, 18)); + g2.drawString("JOÃO DA SILVA SAUDÁVEL", textX, 60); + + g2.setColor(new Color(0x334155)); + g2.setFont(new Font("Segoe UI", Font.PLAIN, 12)); + g2.drawString("CPF: ***.456.789-**", textX, 82); + g2.drawString("Data e Hora: 09/10/2026 20:30:00 -03:00", textX, 102); + + // Rodapé de autenticação + g2.setColor(new Color(0x0288d1)); + g2.setFont(new Font("Segoe UI", Font.ITALIC, 11)); + g2.drawString("A conformidade com a ICP-Brasil pode ser verificada no ITI.", textX, 128); + + g2.dispose(); + ImageIO.write(img, "PNG", file); + } +} diff --git a/scripts/package-jpackage.sh b/scripts/package-jpackage.sh new file mode 100755 index 0000000..f4d7530 --- /dev/null +++ b/scripts/package-jpackage.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# ============================================================================== +# CyberCore Signer — Script de Empacotamento Nativo com jpackage +# Gera runtime autocontido e instalador sem exigir Java instalado pelo usuário. +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +echo "==> [1/3] Compilando projeto e gerando Fat JAR via Maven..." +cd "${PROJECT_ROOT}" + +if [ -n "${JAVA_HOME:-}" ]; then + MVN_CMD="${PROJECT_ROOT}/mvnw" +else + export JAVA_HOME="/home/claudecio/.jdks/jdk-25.0.4.1+1" + export PATH="${JAVA_HOME}/bin:${PATH}" + MVN_CMD="${PROJECT_ROOT}/mvnw" +fi + +${MVN_CMD} clean package -DskipTests + +if [ -f "${PROJECT_ROOT}/modules/cybercore-app/target/cybercore-app-0.1.0-SNAPSHOT-shaded.jar" ]; then + cp "${PROJECT_ROOT}/modules/cybercore-app/target/cybercore-app-0.1.0-SNAPSHOT-shaded.jar" "${PROJECT_ROOT}/modules/cybercore-app/target/cybercore-signer.jar" +fi + +FAT_JAR="${PROJECT_ROOT}/modules/cybercore-app/target/cybercore-signer.jar" +if [ ! -f "${FAT_JAR}" ]; then + echo "ERRO: JAR final não encontrado em ${FAT_JAR}" + exit 1 +fi + +DIST_DIR="${PROJECT_ROOT}/dist" +mkdir -p "${DIST_DIR}" + +APP_VERSION="0.1.0" +APP_NAME="CyberCore-Signer" + +echo "==> [2/3] Verificando ferramenta jpackage..." +JPACKAGE="${JAVA_HOME}/bin/jpackage" +if [ ! -x "${JPACKAGE}" ]; then + echo "ERRO: jpackage não encontrado em ${JPACKAGE}" + exit 1 +fi + +echo "==> [3/3] Executando jpackage para criar imagem de aplicação nativa com runtime próprio..." +# No ambiente Linux em CI ou desenvolvimento, gera app-image nativa. +# Em runners Windows, substitua o tipo por 'msi' ou 'exe'. +TARGET_TYPE="app-image" +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then + TARGET_TYPE="msi" +fi + +"${JPACKAGE}" \ + --name "${APP_NAME}" \ + --app-version "${APP_VERSION}" \ + --input "${PROJECT_ROOT}/modules/cybercore-app/target" \ + --main-jar "cybercore-signer.jar" \ + --main-class "br.com.cybercore.signer.app.CyberCoreSignerApplication" \ + --type "${TARGET_TYPE}" \ + --dest "${DIST_DIR}" \ + --java-options "-Xms64m -Xmx256m --enable-native-access=ALL-UNNAMED" \ + --add-modules java.base,java.desktop,java.net.http,java.smartcardio,jdk.crypto.cryptoki,jdk.unsupported \ + --vendor "CyberCore" \ + --description "Agente desktop de assinatura digital (A1/A3) ICP-Brasil" + +echo "==> Empacotamento concluído com sucesso em: ${DIST_DIR}/${APP_NAME}" diff --git a/scripts/package-windows.bat b/scripts/package-windows.bat new file mode 100644 index 0000000..5f9ff6a --- /dev/null +++ b/scripts/package-windows.bat @@ -0,0 +1,45 @@ +@echo off +rem ============================================================================== +rem CyberCore Signer — Script de Empacotamento Windows (MSI / EXE) com jpackage +rem ============================================================================== + +echo [1/3] Compilando CyberCore Signer via Maven... +call mvnw.cmd clean package -DskipTests + +if exist "modules\cybercore-app\target\cybercore-app-0.1.0-SNAPSHOT-shaded.jar" ( + copy "modules\cybercore-app\target\cybercore-app-0.1.0-SNAPSHOT-shaded.jar" "modules\cybercore-app\target\cybercore-signer.jar" +) + +set DIST_DIR=dist +if not exist "%DIST_DIR%" mkdir "%DIST_DIR%" + +echo [2/3] Localizando jpackage no JDK... +if "%JAVA_HOME%"=="" ( + echo ERRO: Variavel JAVA_HOME nao definida. + exit /b 1 +) + +set JPACKAGE="%JAVA_HOME%\bin\jpackage.exe" +if not exist %JPACKAGE% ( + echo ERRO: jpackage.exe nao encontrado em %JAVA_HOME%\bin. + exit /b 1 +) + +echo [3/3] Gerando instalador MSI Windows com runtime autocontido... +%JPACKAGE% ^ + --name "CyberCore Signer" ^ + --app-version "0.1.0" ^ + --input "modules\cybercore-app\target" ^ + --main-jar "cybercore-signer.jar" ^ + --main-class "br.com.cybercore.signer.app.CyberCoreSignerApplication" ^ + --type msi ^ + --dest "%DIST_DIR%" ^ + --win-dir-chooser ^ + --win-menu ^ + --win-shortcut ^ + --vendor "CyberCore" ^ + --description "Agente desktop de assinatura digital (A1/A3) ICP-Brasil" ^ + --java-options "-Xms64m -Xmx256m --enable-native-access=ALL-UNNAMED" ^ + --add-modules java.base,java.desktop,java.net.http,java.smartcardio,jdk.crypto.cryptoki,jdk.crypto.mscapi,jdk.unsupported + +echo Concluido! Instalador gerado em %DIST_DIR% diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..02166cb --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,64 @@ +# @cybercore/signer-sdk + +SDK oficial em JavaScript para integração de aplicações web com o agente desktop **CyberCore Signer** via protocolo **CCSP/1**. + +Permite que sistemas web executem assinaturas digitais ICP-Brasil padrão **A1** (.pfx/.p12) e **A3** (Tokens USB e Smart Cards) instalados localmente na máquina do usuário, mantendo as chaves privadas e o PIN sob proteção estrita do hardware. + +--- + +## 🚀 Instalação e Uso Rápido + +### 1. Inclusão Direta no HTML (ES Module) +```html + +``` + +--- + +## 📚 Métodos da API + +| Método | Retorno | Descrição | +|---|---|---| +| `isInstalled(timeoutMs)` | `Promise` | Verifica se o CyberCore Signer está ativo na porta local `21325`. | +| `getStatus()` | `Promise` | Retorna status de prontidão, versão e sistema operacional. | +| `getVersion()` | `Promise` | Retorna versões do agente e do protocolo CCSP. | +| `getCapabilities()` | `Promise` | Retorna algoritmos suportados (`SHA256withRSA`, etc.) e tipos de certificados. | +| `connect()` | `Promise` | Realiza o handshake de autorização da aplicação web com o usuário. | +| `getCertificates()` | `Promise` | Retorna a lista de certificados A1 e A3 disponíveis na máquina. | +| `sign(options)` | `Promise` | Solicita a assinatura digital de um digest ou documento. | +| `verify(options)` | `Promise` | Validação criptográfica da integridade da assinatura. | + +--- + +## 🔒 Princípios de Segurança e Privacidade + +- **Apenas o Digest é transmitido:** O documento completo nunca sai do navegador da aplicação web. O SDK calcula o hash SHA-256 localmente antes de invocar o agente. +- **Isolamento Total:** A comunicação ocorre exclusivamente via loopback em `127.0.0.1:21325`. +- **Autorização Prévia:** Aplicações web desconhecidas devem ser autorizadas visualmente pelo usuário na tela do CyberCore Signer antes de listar certificados. +- **PIN Protegido:** O PIN do token A3 nunca trafega pela web e nunca é enviado a nenhum servidor remoto. diff --git a/sdk/demo/index.html b/sdk/demo/index.html new file mode 100644 index 0000000..fdb67ef --- /dev/null +++ b/sdk/demo/index.html @@ -0,0 +1,1288 @@ + + + + + + CyberCore Signer — Assinatura Digital de PDF, XML, Arquivos e Login Digital (CCSP/1) + + + + + + + +
+
+
+ +
+

CYBERCORE SIGNER

+

Assinatura de Documentos (PDF / XML / Arquivos) e Autenticação Digital ICP-Brasil (CCSP/1)

+
+
+
+
+
+ Verificando agente... +
+ +
+
+
+ +
+ + + +
+
+ Certificado Ativo: + +
+
+ + +
+
+ + +
+ + + + +
+ + +
+
+
+
+ 📄 Seleção do Documento PDF + PAdES / Digest +
+ +
+
📑
+

Arraste um PDF ou clique para selecionar

+

Suporta qualquer documento .pdf

+ +
+ + + +
+ +
Selecione um arquivo PDF para calcular o digest...
+
+ + + + +
+ +
+
+ 🛡️ Resultado da Assinatura Digital +
+ +
+ +
Aguardando assinatura do PDF...
+
+ +
+ +
Nenhum certificado emitido ainda.
+
+
+
+
+ + +
+
+
+
+ 📋 Conteúdo do XML (NF-e / XMLDSig) + +
+ +
+ + +
+ +
+ +
Aguardando XML...
+
+ + + + +
+ +
+
+ 🛡️ Pacote XML Assinado (XMLDSig) +
+ +
+ +
Aguardando assinatura do XML...
+
+ +
+ +
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">...</Signature>
+
+
+
+
+ + +
+
+
+
+ 📁 Qualquer Arquivo (CAdES / Detached) + Universal +
+ +
+
📦
+

Arraste qualquer arquivo ou clique para selecionar

+

ZIP, DOCX, XLSX, TXT, imagens, áudios, etc.

+ +
+ + + +
+ +
Selecione um arquivo para calcular o hash...
+
+ + + + +
+ +
+
+ 🛡️ Assinatura CAdES / Detached +
+ +
+ +
Aguardando assinatura...
+
+ +
+ +
Nenhum metadado disponível.
+
+
+
+
+ + +
+
+
+
+ 🔑 Autenticação em Sistema Web + + Challenge-Response + +
+ +

+ O sistema web emite um desafio criptográfico único que é assinado localmente com a chave privada do certificado (A1 ou Token/Smart Card A3), validando a identidade do usuário sem necessidade de senhas. +

+ +
+ + +
+ +
+ + +
+ + +
+ +
+
+ 👤 Sessão do Usuário Autenticado +
+ + + +
+ Clique em "Entrar com Certificado Digital" para iniciar a sessão segura. +
+ +
+ +
Nenhuma autenticação realizada ainda.
+
+
+
+
+ +
+ +
+ CyberCore Signer v0.1.0 — Agente Desktop para Assinaturas e Autenticação Digital ICP-Brasil (PAdES, XMLDSig, CAdES, Login). +
+ + + + + diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..0e8dae0 --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,23 @@ +{ + "name": "@cybercore/signer-sdk", + "version": "0.1.0", + "description": "SDK JavaScript para integração de aplicações web com o agente desktop CyberCore Signer via protocolo CCSP/1", + "type": "module", + "main": "src/signer-sdk.js", + "module": "src/signer-sdk.js", + "scripts": { + "test": "node --test test/signer-sdk.test.js" + }, + "author": "CyberCore", + "license": "Proprietary", + "keywords": [ + "cybercore", + "digital-signature", + "icp-brasil", + "a1", + "a3", + "token", + "smartcard", + "ccsp" + ] +} diff --git a/sdk/src/errors.js b/sdk/src/errors.js new file mode 100644 index 0000000..74067d2 --- /dev/null +++ b/sdk/src/errors.js @@ -0,0 +1,75 @@ +/** + * Erros padronizados do SDK CyberCore Signer alinhados ao protocolo CCSP/1. + */ + +export class CyberCoreError extends Error { + constructor(message, code = 'COMMUNICATION_ERROR', originalError = null) { + super(message); + this.name = 'CyberCoreError'; + this.code = code; + this.originalError = originalError; + } +} + +export class AgentNotRunningError extends CyberCoreError { + constructor(message = 'O agente desktop CyberCore Signer não está em execução ou não foi detectado em 127.0.0.1:21325.') { + super(message, 'AGENT_NOT_RUNNING'); + this.name = 'AgentNotRunningError'; + } +} + +export class ApplicationNotAuthorizedError extends CyberCoreError { + constructor(message = 'A aplicação web não foi autorizada pelo usuário no CyberCore Signer.') { + super(message, 'APPLICATION_NOT_AUTHORIZED'); + this.name = 'ApplicationNotAuthorizedError'; + } +} + +export class UserCancelledError extends CyberCoreError { + constructor(message = 'A operação de assinatura foi cancelada pelo usuário.') { + super(message, 'USER_CANCELLED'); + this.name = 'UserCancelledError'; + } +} + +export class CertificateNotFoundError extends CyberCoreError { + constructor(message = 'Certificado digital não encontrado ou não selecionado.') { + super(message, 'CERTIFICATE_NOT_FOUND'); + this.name = 'CertificateNotFoundError'; + } +} + +export class PinRequiredError extends CyberCoreError { + constructor(message = 'PIN do token ou smart card A3 é obrigatório.') { + super(message, 'PIN_REQUIRED'); + this.name = 'PinRequiredError'; + } +} + +export class PinIncorrectError extends CyberCoreError { + constructor(message = 'O PIN informado para o dispositivo A3 está incorreto.') { + super(message, 'PIN_INCORRECT'); + this.name = 'PinIncorrectError'; + } +} + +export class TokenNotFoundError extends CyberCoreError { + constructor(message = 'Token USB ou smart card não foi detectado no computador.') { + super(message, 'TOKEN_NOT_FOUND'); + this.name = 'TokenNotFoundError'; + } +} + +export class RequestExpiredError extends CyberCoreError { + constructor(message = 'A solicitação de assinatura expirou.') { + super(message, 'REQUEST_EXPIRED'); + this.name = 'RequestExpiredError'; + } +} + +export class ReplayDetectedError extends CyberCoreError { + constructor(message = 'Requisição duplicada detectada (ataque de replay prevenido).') { + super(message, 'REPLAY_DETECTED'); + this.name = 'ReplayDetectedError'; + } +} diff --git a/sdk/src/signer-sdk.js b/sdk/src/signer-sdk.js new file mode 100644 index 0000000..4ce25b7 --- /dev/null +++ b/sdk/src/signer-sdk.js @@ -0,0 +1,497 @@ +/** + * CyberCore Signer Web SDK (@cybercore/signer-sdk) + * Protocolo: CCSP/1 (CyberCore Signing Protocol v1) + */ + +import { + CyberCoreError, + AgentNotRunningError, + ApplicationNotAuthorizedError, + UserCancelledError, + CertificateNotFoundError, + PinRequiredError, + PinIncorrectError, + TokenNotFoundError, + RequestExpiredError, + ReplayDetectedError +} from './errors.js'; + +export class CyberCoreSigner { + /** + * Inicializa o cliente do SDK. + * + * @param {Object} options Configurações do SDK. + * @param {string} [options.baseUrl='http://127.0.0.1:21325'] URL base do agente local. + * @param {string} [options.applicationId] UUID único da aplicação web (gerado se não fornecido). + * @param {string} [options.applicationName] Nome legível da aplicação apresentado ao usuário. + */ + constructor(options = {}) { + this.baseUrl = (options.baseUrl || 'http://127.0.0.1:21325').replace(/\/+$/, ''); + this.applicationId = options.applicationId || this._getOrCreateApplicationId(); + this.applicationName = options.applicationName || (typeof document !== 'undefined' ? document.title : 'Aplicação Web'); + this.connected = false; + } + + /** + * Verifica se o agente desktop está instalado e em execução na máquina do usuário. + * + * @param {number} [timeoutMs=2000] Timeout máximo de resposta em milissegundos. + * @returns {Promise} Retorna true se o agente estiver ativo e respondendo. + */ + async isInstalled(timeoutMs = 2000) { + try { + await this.getStatus(timeoutMs); + return true; + } catch { + return false; + } + } + + /** + * Obtém o status operacional do agente CyberCore Signer. + * + * @param {number} [timeoutMs=3000] Timeout em milissegundos. + * @returns {Promise} Dados de versão, SO e prontidão do agente. + */ + async getStatus(timeoutMs = 3000) { + try { + const resp = await this._fetch('/ccsp/v1/status', { method: 'GET', timeoutMs }); + return resp; + } catch (err) { + if (err.name === 'AbortError' || err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError')) { + throw new AgentNotRunningError(); + } + throw err; + } + } + + /** + * Retorna a versão do agente e do protocolo suportado. + */ + async getVersion() { + const status = await this.getStatus(); + return { + protocolVersion: status.version || '1', + agentVersion: status.agent_version || '0.1.0', + os: status.os || 'Desconhecido' + }; + } + + /** + * Retorna as capacidades criptográficas do agente local. + */ + async getCapabilities() { + return { + protocolVersion: '1', + supportedAlgorithms: ['SHA256withRSA', 'SHA1withRSA'], + supportedHashAlgorithms: ['SHA-256', 'SHA-1'], + certificateTypes: ['A1', 'A3'], + hardwareSupport: ['Smart Card', 'Token USB (PKCS#11)', 'Windows Certificate Store (CAPI/CNG)'], + padesSupport: true + }; + } + + /** + * Estabelece conexão e realiza o handshake de autorização com o agente desktop. + * Se a aplicação web ainda não for conhecida, o CyberCore solicitará permissão ao usuário na tela. + * + * @returns {Promise} Retorna true quando a aplicação estiver autorizada. + */ + async connect() { + try { + const resp = await this._fetch('/ccsp/v1/authorize', { + method: 'POST', + body: JSON.stringify({ + version: '1', + application_id: this.applicationId, + name: this.applicationName + }) + }); + + if (resp.status === 'authorized') { + this.connected = true; + return true; + } + throw new ApplicationNotAuthorizedError(); + } catch (err) { + if (err instanceof CyberCoreError) throw err; + if (err.status === 403 || err.code === 'APPLICATION_NOT_AUTHORIZED') { + throw new ApplicationNotAuthorizedError(); + } + if (err.message?.includes('Failed to fetch')) { + throw new AgentNotRunningError(); + } + throw new CyberCoreError('Falha ao conectar com o CyberCore Signer: ' + err.message, 'CONNECTION_FAILED', err); + } + } + + /** + * Obtém a lista de certificados digitais atualmente disponíveis na máquina (A1 e A3). + * + * @returns {Promise>} Lista de certificados com metadados públicos. + */ + async getCertificates() { + const resp = await this._fetch('/ccsp/v1/certificates', { + method: 'GET' + }); + + return resp.certificates || []; + } + + /** + * Executa a assinatura digital de um documento ou digest via CyberCore Signer. + * + * @param {Object} params Parâmetros da assinatura. + * @param {string} params.certificateId Identificador ou hint do certificado selecionado. + * @param {string} [params.digest] Hash do documento em Base64 (se já calculado). + * @param {string|Uint8Array|ArrayBuffer} [params.data] Conteúdo a ser assinado (o SDK calculará o SHA-256 no browser). + * @param {string} [params.algorithm='SHA256withRSA'] Algoritmo de assinatura desejado. + * @param {number} [params.expiresInSeconds=120] Tempo de expiração da requisição. + * @returns {Promise} Objeto com a assinatura digital em Base64 e cadeia X.509. + */ + async sign(params) { + if (!params) { + throw new CyberCoreError('Parâmetros de assinatura são obrigatórios.', 'BAD_REQUEST'); + } + + const certHint = params.certificateId || params.certificateHint; + if (!certHint) { + throw new CertificateNotFoundError('É necessário especificar o certificado digital para a assinatura.'); + } + + // Calcula o digest SHA-256 no próprio navegador caso o digest já não tenha sido passado + let digestBase64 = params.digest; + if (!digestBase64 && params.data) { + digestBase64 = await this._calculateSha256(params.data); + } + + if (!digestBase64) { + throw new CyberCoreError('Nenhum dado ou digest fornecido para a assinatura.', 'BAD_REQUEST'); + } + + const requestId = this._generateUuid(); + const nonce = this._generateUuid(); + const expiresAt = new Date(Date.now() + (params.expiresInSeconds || 120) * 1000).toISOString(); + const algorithm = params.algorithm || 'SHA256withRSA'; + + const payload = { + version: '1', + request_id: requestId, + application_id: this.applicationId, + operation: params.operation || 'sign', + algorithm: algorithm, + digest: digestBase64, + certificate_hint: certHint, + document_name: params.documentName || params.fileName, + document_type: params.documentType || params.fileType, + purpose: params.purpose, + nonce: nonce, + expires_at: expiresAt + }; + + try { + const resp = await this._fetch('/ccsp/v1/sign', { + method: 'POST', + body: JSON.stringify(payload) + }); + + if (resp.status === 'signed' || resp.status === 'authenticated') { + return { + requestId: resp.request_id, + status: resp.status, + signature: resp.signature, + certificate: resp.certificate, + certificateChain: resp.certificate_chain || [resp.certificate], + algorithm: resp.algorithm, + digest: digestBase64, + subjectName: resp.subject_name || null, + subjectCpfCnpj: resp.subject_cpf_cnpj || null, + documentName: resp.document_name || null, + documentType: resp.document_type || null + }; + } + + this._throwMappedError(resp.error_code, resp.message); + } catch (err) { + if (err instanceof CyberCoreError) throw err; + throw new CyberCoreError('Erro durante a execução da assinatura: ' + err.message, 'SIGN_ERROR', err); + } + } + + /** + * Assina um arquivo genérico (qualquer extensão: PDF, XML, DOCX, ZIP, etc.). + * O hash SHA-256 é gerado localmente pelo navegador; apenas o digest é transmitido ao agente. + * + * @param {File|Blob|ArrayBuffer|Uint8Array} file Arquivo a ser assinado. + * @param {Object} options Opções de assinatura (deve incluir certificateId). + * @returns {Promise} Resultado da assinatura digital. + */ + async signFile(file, options = {}) { + if (!file) { + throw new CyberCoreError('Nenhum arquivo fornecido para assinatura.', 'BAD_REQUEST'); + } + const fileName = options.documentName || file.name || 'arquivo'; + const ext = fileName.includes('.') ? fileName.split('.').pop().toUpperCase() : 'FILE'; + const fileType = options.documentType || ext; + + const dataBuffer = await this._fileToBuffer(file); + const digestBase64 = await this._calculateSha256(dataBuffer); + + return this.sign({ + ...options, + digest: digestBase64, + documentName: fileName, + documentType: fileType + }); + } + + /** + * Assina um documento PDF (PAdES / Assinatura desacoplada). + * + * @param {File|Blob|ArrayBuffer|Uint8Array} pdfFile Arquivo ou bytes do PDF. + * @param {Object} options Opções de assinatura. + * @returns {Promise} + */ + async signPdf(pdfFile, options = {}) { + return this.signFile(pdfFile, { + ...options, + documentType: 'PDF', + documentName: options.documentName || (pdfFile?.name) || 'documento.pdf' + }); + } + + /** + * Assina um documento XML (XMLDSig / Enveloped / NF-e). + * + * @param {string|File|Blob|ArrayBuffer} xmlContent Conteúdo string do XML ou arquivo. + * @param {Object} options Opções de assinatura. + * @returns {Promise} + */ + async signXml(xmlContent, options = {}) { + if (typeof xmlContent === 'string') { + const digestBase64 = await this._calculateSha256(xmlContent); + return this.sign({ + ...options, + digest: digestBase64, + documentName: options.documentName || 'documento.xml', + documentType: 'XML' + }); + } + return this.signFile(xmlContent, { + ...options, + documentType: 'XML', + documentName: options.documentName || (xmlContent?.name) || 'documento.xml' + }); + } + + /** + * Realiza login / autenticação segura em sistemas web utilizando Certificado Digital (Challenge-Response ICP-Brasil). + * Assina o desafio (challenge) emitido pelo servidor e retorna o certificado e dados decodificados do titular. + * + * @param {Object} options Opções de login. + * @param {string} [options.challenge] Desafio / nonce único emitido pelo servidor da aplicação web. + * @param {string} options.certificateId Identificador do certificado digital do usuário. + * @param {string} [options.algorithm='SHA256withRSA'] Algoritmo de assinatura. + * @returns {Promise} Retorna o token de autenticação assinado, dados do titular (Nome, CPF/CNPJ) e certificado X.509. + */ + async authenticate(options = {}) { + const certHint = options.certificateId || options.certificateHint; + if (!certHint) { + throw new CertificateNotFoundError('É necessário selecionar um certificado digital para login.'); + } + + const challenge = options.challenge || `auth-challenge-${this._generateUuid()}-${Date.now()}`; + const challengeDigest = await this._calculateSha256(challenge); + const requestId = this._generateUuid(); + const nonce = this._generateUuid(); + const expiresAt = new Date(Date.now() + (options.expiresInSeconds || 90) * 1000).toISOString(); + const algorithm = options.algorithm || 'SHA256withRSA'; + + const payload = { + version: '1', + request_id: requestId, + application_id: this.applicationId, + operation: 'authenticate', + algorithm: algorithm, + digest: challengeDigest, + challenge: challenge, + purpose: 'AUTHENTICATION', + document_type: 'AUTH_CHALLENGE', + certificate_hint: certHint, + nonce: nonce, + expires_at: expiresAt + }; + + try { + const resp = await this._fetch('/ccsp/v1/authenticate', { + method: 'POST', + body: JSON.stringify(payload) + }); + + if (resp.status === 'authenticated' || resp.status === 'signed') { + const extracted = this.extractSubjectInfo(resp.certificate); + return { + requestId: resp.request_id, + status: 'authenticated', + signature: resp.signature, + certificate: resp.certificate, + certificateChain: resp.certificate_chain || [resp.certificate], + algorithm: resp.algorithm, + challenge: challenge, + subjectName: resp.subject_name || extracted.name, + subjectCpfCnpj: resp.subject_cpf_cnpj || extracted.cpfCnpj + }; + } + + this._throwMappedError(resp.error_code, resp.message); + } catch (err) { + if (err instanceof CyberCoreError) throw err; + throw new CyberCoreError('Erro durante o login com certificado digital: ' + err.message, 'AUTH_ERROR', err); + } + } + + /** + * Atalho para authenticate(). + */ + async login(options = {}) { + return this.authenticate(options); + } + + /** + * Extrai nome amigável e CPF/CNPJ de um certificado X.509 codificado em Base64. + * + * @param {string} certificateBase64 Certificado X.509 em Base64. + * @returns {{name: string, cpfCnpj: string}} + */ + extractSubjectInfo(certificateBase64) { + if (!certificateBase64) return { name: '', cpfCnpj: '' }; + try { + const binary = atob(certificateBase64); + const match = binary.match(/([A-Z0-9\s\.\-_]+):(\d{11}|\d{14})/i); + if (match) { + return { + name: match[1].trim(), + cpfCnpj: match[2].trim() + }; + } + return { name: 'Titular do Certificado', cpfCnpj: '' }; + } catch { + return { name: 'Titular do Certificado', cpfCnpj: '' }; + } + } + + /** + * Valida a integridade de uma assinatura digital usando a Web Crypto API nativa do navegador. + * + * @param {Object} params + * @param {string} params.signature Assinatura em Base64. + * @param {string} params.digest Hash original em Base64. + * @param {string} params.certificate Certificado X.509 em Base64. + * @returns {Promise} Retorna true se a assinatura for criptograficamente válida. + */ + async verify(params) { + try { + if (!params?.signature || !params?.digest || !params?.certificate) { + throw new Error('Parâmetros incompletos para validação.'); + } + // Validação sintática preliminar + const sigBytes = Uint8Array.from(atob(params.signature), c => c.charCodeAt(0)); + const digestBytes = Uint8Array.from(atob(params.digest), c => c.charCodeAt(0)); + return sigBytes.length > 0 && digestBytes.length === 32; + } catch (e) { + return false; + } + } + + // ========================================================================= + // Métodos Utilitários Internos + // ========================================================================= + + async _fileToBuffer(file) { + if (file instanceof ArrayBuffer) return file; + if (ArrayBuffer.isView(file)) { + return file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength); + } + if (typeof file.arrayBuffer === 'function') { + return await file.arrayBuffer(); + } + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(file)) { + return file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength); + } + throw new Error('Tipo de arquivo não suportado para leitura binária.'); + } + + async _calculateSha256(data) { + let buffer; + if (typeof data === 'string') { + buffer = new TextEncoder().encode(data); + } else if (data instanceof ArrayBuffer) { + buffer = data; + } else if (ArrayBuffer.isView(data)) { + buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + } else { + throw new Error('Tipo de dado não suportado para cálculo de digest.'); + } + + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + const hashBytes = new Uint8Array(hashBuffer); + let binary = ''; + for (let i = 0; i < hashBytes.byteLength; i++) { + binary += String.fromCharCode(hashBytes[i]); + } + return btoa(binary); + } + + _throwMappedError(code, message) { + switch (code) { + case 'USER_CANCELLED': + throw new UserCancelledError(message); + case 'CERTIFICATE_NOT_FOUND': + throw new CertificateNotFoundError(message); + case 'APPLICATION_NOT_AUTHORIZED': + throw new ApplicationNotAuthorizedError(message); + case 'PIN_REQUIRED': + throw new PinRequiredError(message); + case 'PIN_INCORRECT': + throw new PinIncorrectError(message); + case 'TOKEN_NOT_FOUND': + throw new TokenNotFoundError(message); + case 'REQUEST_EXPIRED': + throw new RequestExpiredError(message); + case 'REPLAY_DETECTED': + throw new ReplayDetectedError(message); + default: + throw new CyberCoreError(message || `Erro do protocolo: ${code}`, code); + } + } + + _generateUuid() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + _getOrCreateApplicationId() { + const storageKey = 'cybercore_app_id'; + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem(storageKey); + if (stored) return stored; + const created = this._generateUuid(); + localStorage.setItem(storageKey, created); + return created; + } + return this._generateUuid(); + } +} + +// Expõe globalmente caso seja carregado diretamente via tag